Merge pull request 'exporter' (#46) from exporter into trunk

Reviewed-on: #46
This commit is contained in:
Daniel 2024-11-21 22:31:30 -05:00
commit 043974b074
18 changed files with 495 additions and 183 deletions

View file

@ -44,3 +44,12 @@ func PtrOrNull[T comparable](val T) *T {
return &val
}
func ZeroOr[T any](v *T) T {
var zero T
if v == nil {
return zero
}
return *v
}

View file

@ -1,113 +0,0 @@
package calls
import (
"context"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/pb"
tgs "dynatron.me/x/stillbox/pkg/talkgroups"
)
type TalkgroupFilter struct {
Talkgroups []tgs.ID `json:"talkgroups,omitempty"`
TalkgroupsNot []tgs.ID `json:"talkgroupsNot,omitempty"`
TalkgroupTagsAll []string `json:"talkgroupTagsAll,omitempty"`
TalkgroupTagsAny []string `json:"talkgroupTagsAny,omitempty"`
TalkgroupTagsNot []string `json:"talkgroupTagsNot,omitempty"`
talkgroups map[tgs.ID]bool
}
func TalkgroupFilterFromPB(ctx context.Context, p *pb.Filter) (*TalkgroupFilter, error) {
tgf := &TalkgroupFilter{
TalkgroupTagsAll: p.TalkgroupTagsAll,
TalkgroupTagsAny: p.TalkgroupTagsAny,
TalkgroupTagsNot: p.TalkgroupTagsNot,
}
if l := len(p.Talkgroups); l > 0 {
tgf.Talkgroups = make([]tgs.ID, l)
for i, t := range p.Talkgroups {
tgf.Talkgroups[i] = tgs.ID{
System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup),
}
}
}
if l := len(p.TalkgroupsNot); l > 0 {
tgf.TalkgroupsNot = make([]tgs.ID, l)
for i, t := range p.TalkgroupsNot {
tgf.TalkgroupsNot[i] = tgs.ID{
System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup),
}
}
}
return tgf, tgf.compile(ctx)
}
func (f *TalkgroupFilter) hasTags() bool {
return len(f.TalkgroupTagsAny) > 0 || len(f.TalkgroupTagsAll) > 0 || len(f.TalkgroupTagsNot) > 0
}
func (f *TalkgroupFilter) GetFinalTalkgroups() map[tgs.ID]bool {
return f.talkgroups
}
func (f *TalkgroupFilter) compile(ctx context.Context) error {
f.talkgroups = make(map[tgs.ID]bool)
for _, tg := range f.Talkgroups {
f.talkgroups[tg] = true
}
if f.hasTags() { // don't bother with DB if no tags
db := database.FromCtx(ctx)
tagTGs, err := db.GetTalkgroupIDsByTags(ctx, f.TalkgroupTagsAny, f.TalkgroupTagsAll, f.TalkgroupTagsNot)
if err != nil {
return err
}
for _, tg := range tagTGs {
f.talkgroups[tgs.ID{System: uint32(tg.SystemID), Talkgroup: uint32(tg.TGID)}] = true
}
}
for _, tg := range f.TalkgroupsNot {
f.talkgroups[tg] = false
}
return nil
}
func (f *TalkgroupFilter) Test(ctx context.Context, call *Call) bool {
if f == nil { // no filter means all calls
return true
}
if f.talkgroups == nil {
err := f.compile(ctx)
if err != nil {
panic(err)
}
}
tg := call.TalkgroupTuple()
tgRes, have := f.talkgroups[tg]
if have {
return tgRes
}
for _, patch := range call.Patches {
tg.Talkgroup = uint32(patch)
tgRes, have := f.talkgroups[tg]
if have {
return tgRes
}
}
return false
}

View file

@ -8,9 +8,9 @@ import (
"sync"
"dynatron.me/x/stillbox/internal/version"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/pb"
tgfilter "dynatron.me/x/stillbox/pkg/talkgroups/filter"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/proto"
@ -33,7 +33,7 @@ type client struct {
Connection
liveState pb.LiveState
filter *calls.TalkgroupFilter
filter *tgfilter.TalkgroupFilter
nexus *Nexus
}

View file

@ -3,9 +3,9 @@ package nexus
import (
"context"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/pb"
"dynatron.me/x/stillbox/pkg/talkgroups"
tgfilter "dynatron.me/x/stillbox/pkg/talkgroups/filter"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"github.com/rs/zerolog/log"
@ -111,7 +111,7 @@ func (c *client) Live(ctx context.Context, cmd *pb.Live) error {
}
if cmd.Filter != nil {
filter, err := calls.TalkgroupFilterFromPB(ctx, cmd.Filter)
filter, err := tgfilter.TalkgroupFilterFromPB(ctx, cmd.Filter)
if err != nil {
log.Error().Err(err).Msg("filter create failed")
return err

View file

@ -75,7 +75,6 @@ func errTextNotFound(err error) render.Renderer {
}
}
func internalError(err error) render.Renderer {
return &errResponse{
Err: err,
@ -89,7 +88,7 @@ type errResponder func(error) render.Renderer
var statusMapping = map[error]errResponder{
tgstore.ErrNoSuchSystem: errTextNotFound,
tgstore.ErrNotFound: errTextNotFound,
pgx.ErrNoRows: recordNotFound,
pgx.ErrNoRows: recordNotFound,
}
func autoError(err error) render.Renderer {

View file

@ -7,7 +7,7 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/importer"
"dynatron.me/x/stillbox/pkg/talkgroups/xport"
"github.com/go-chi/chi/v5"
)
@ -114,7 +114,7 @@ func (tga *talkgroupAPI) put(w http.ResponseWriter, r *http.Request) {
}
func (tga *talkgroupAPI) tgImport(w http.ResponseWriter, r *http.Request) {
var impJob importer.ImportJob
var impJob xport.ImportJob
err := forms.Unmarshal(r, &impJob, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil {
wErr(w, r, badRequest(err))

View file

@ -15,7 +15,7 @@ import (
)
type DatabaseSink struct {
db database.Store
db database.Store
tgs tgstore.Store
}

View file

@ -0,0 +1,168 @@
package filter
import (
"context"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/pb"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
)
type TalkgroupFilter struct {
Talkgroups []tgsp.ID `json:"talkgroups,omitempty"`
TalkgroupsNot []tgsp.ID `json:"talkgroupsNot,omitempty"`
TalkgroupTagsAll []string `json:"talkgroupTagsAll,omitempty"`
TalkgroupTagsAny []string `json:"talkgroupTagsAny,omitempty"`
TalkgroupTagsNot []string `json:"talkgroupTagsNot,omitempty"`
talkgroups map[tgsp.ID]bool `json:"-"`
}
func (f *TalkgroupFilter) TGs(ctx context.Context) (tgsp.IDs, error) {
err := f.ensureCompiled(ctx)
if err != nil {
return nil, err
}
r := make(tgsp.IDs, 0, len(f.talkgroups))
for tg := range f.talkgroups {
r = append(r, tg)
}
return r, nil
}
func (f *TalkgroupFilter) Tuples(ctx context.Context) (database.TGTuples, error) {
err := f.ensureCompiled(ctx)
if err != nil {
return database.TGTuples{}, err
}
sys := make([]uint32, len(f.talkgroups))
tgs := make([]uint32, len(f.talkgroups))
i := 0
for tg := range f.talkgroups {
sys[i] = tg.System
tgs[i] = tg.Talkgroup
}
return database.TGTuples{sys, tgs}, nil
}
func (f *TalkgroupFilter) ensureCompiled(ctx context.Context) error {
if f.talkgroups == nil {
return f.compile(ctx)
}
return nil
}
func (tgf *TalkgroupFilter) IsEmpty() bool {
if tgf == nil {
return true
}
if len(tgf.Talkgroups) > 0 ||
len(tgf.TalkgroupsNot) > 0 ||
len(tgf.TalkgroupTagsAll) > 0 ||
len(tgf.TalkgroupTagsAny) > 0 ||
len(tgf.TalkgroupsNot) > 0 {
return false
}
return true
}
func TalkgroupFilterFromPB(ctx context.Context, p *pb.Filter) (*TalkgroupFilter, error) {
tgf := &TalkgroupFilter{
TalkgroupTagsAll: p.TalkgroupTagsAll,
TalkgroupTagsAny: p.TalkgroupTagsAny,
TalkgroupTagsNot: p.TalkgroupTagsNot,
}
if l := len(p.Talkgroups); l > 0 {
tgf.Talkgroups = make([]tgsp.ID, l)
for i, t := range p.Talkgroups {
tgf.Talkgroups[i] = tgsp.ID{
System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup),
}
}
}
if l := len(p.TalkgroupsNot); l > 0 {
tgf.TalkgroupsNot = make([]tgsp.ID, l)
for i, t := range p.TalkgroupsNot {
tgf.TalkgroupsNot[i] = tgsp.ID{
System: uint32(t.System),
Talkgroup: uint32(t.Talkgroup),
}
}
}
return tgf, tgf.compile(ctx)
}
func (f *TalkgroupFilter) hasTags() bool {
return len(f.TalkgroupTagsAny) > 0 || len(f.TalkgroupTagsAll) > 0 || len(f.TalkgroupTagsNot) > 0
}
func (f *TalkgroupFilter) GetFinalTalkgroups() map[tgsp.ID]bool {
return f.talkgroups
}
func (f *TalkgroupFilter) compile(ctx context.Context) error {
f.talkgroups = make(map[tgsp.ID]bool)
for _, tg := range f.Talkgroups {
f.talkgroups[tg] = true
}
if f.hasTags() { // don't bother with DB if no tags
db := database.FromCtx(ctx)
tagTGs, err := db.GetTalkgroupIDsByTags(ctx, f.TalkgroupTagsAny, f.TalkgroupTagsAll, f.TalkgroupTagsNot)
if err != nil {
return err
}
for _, tg := range tagTGs {
f.talkgroups[tgsp.ID{System: uint32(tg.SystemID), Talkgroup: uint32(tg.TGID)}] = true
}
}
for _, tg := range f.TalkgroupsNot {
f.talkgroups[tg] = false
}
return nil
}
func (f *TalkgroupFilter) Test(ctx context.Context, call *calls.Call) bool {
if f == nil { // no filter means all calls
return true
}
err := f.ensureCompiled(ctx)
if err != nil {
panic(err)
}
tg := call.TalkgroupTuple()
tgRes, have := f.talkgroups[tg]
if have {
return tgRes
}
for _, patch := range call.Patches {
tg.Talkgroup = uint32(patch)
tgRes, have := f.talkgroups[tg]
if have {
return tgRes
}
}
return false
}

View file

@ -9,9 +9,9 @@ import (
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/pkg/auth"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/calls"
tgsp "dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/jackc/pgx/v5"
@ -135,6 +135,15 @@ func (t *cache) Hint(ctx context.Context, tgs []tgsp.ID) error {
return nil
}
func (t *cache) get(id tgsp.ID) (*tgsp.Talkgroup, bool) {
t.RLock()
defer t.RUnlock()
tg, has := t.tgs[id]
return tg, has
}
func (t *cache) add(rec *tgsp.Talkgroup) {
t.Lock()
defer t.Unlock()
@ -176,16 +185,14 @@ func (t *cache) TGs(ctx context.Context, tgs tgsp.IDs) ([]*tgsp.Talkgroup, error
var err error
if tgs != nil {
toGet := make(tgsp.IDs, 0, len(tgs))
t.RLock()
for _, id := range tgs {
rec, has := t.tgs[id]
rec, has := t.get(id)
if has {
r = append(r, rec)
} else {
toGet = append(toGet, id)
}
}
t.RUnlock()
tgRecords, err := database.FromCtx(ctx).GetTalkgroupsWithLearnedBySysTGID(ctx, toGet.Tuples())
if err != nil {
@ -240,9 +247,7 @@ func (t *cache) SystemTGs(ctx context.Context, systemID int32) ([]*tgsp.Talkgrou
}
func (t *cache) TG(ctx context.Context, tg tgsp.ID) (*tgsp.Talkgroup, error) {
t.RLock()
rec, has := t.tgs[tg]
t.RUnlock()
rec, has := t.get(tg)
if has {
return rec, nil
@ -326,7 +331,7 @@ func (t *cache) LearnTG(ctx context.Context, c *calls.Call) (*tgsp.Talkgroup, er
tg := &tgsp.Talkgroup{
Talkgroup: tgm,
System: database.System{
ID: c.System,
ID: c.System,
Name: sys,
},
Learned: tgm.Learned,
@ -361,7 +366,6 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
input[i].SystemID = int32(system)
input[i].Learned = common.PtrTo(false)
}
var oerr error
@ -375,8 +379,8 @@ func (t *cache) UpsertTGs(ctx context.Context, system int, input []database.Upse
return
}
versionParams = append(versionParams, database.StoreTGVersionParams{
SystemID: int32(system),
TGID: r.TGID,
SystemID: int32(system),
TGID: r.TGID,
Submitter: auth.UIDFrom(ctx),
})
tgs = append(tgs, &tgsp.Talkgroup{

View file

@ -0,0 +1,54 @@
package xport
import (
"context"
"io"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/filter"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/xport/sdrtrunk"
)
type Exporter interface {
ExportTalkgroups(ctx context.Context, w io.Writer, tgs []*talkgroups.Talkgroup, tmpl []byte) error
}
type ExportJob struct {
Type Format `json:"type"`
SystemID int `json:"systemID"`
Template []byte `json:"template"`
filter.TalkgroupFilter
Exporter
}
func (ej *ExportJob) Export(ctx context.Context, w io.Writer) error {
var tgs []*talkgroups.Talkgroup
var err error
tgst := tgstore.FromCtx(ctx)
if ej.TalkgroupFilter.IsEmpty() {
tgs, err = tgst.SystemTGs(ctx, int32(ej.SystemID))
if err != nil {
return err
}
} else {
ids, err := ej.TalkgroupFilter.TGs(ctx)
if err != nil {
return err
}
tgs, err = tgst.TGs(ctx, ids)
if err != nil {
return err
}
}
switch ej.Type {
case FormatSDRTrunk:
ej.Exporter = sdrtrunk.New()
default:
return ErrBadType
}
return ej.ExportTalkgroups(ctx, w, tgs, ej.Template)
}

View file

@ -0,0 +1,16 @@
package xport
import (
"errors"
)
type Format string
const (
FormatRadioReference Format = "radioreference"
FormatSDRTrunk Format = "sdrtrunk"
)
var (
ErrBadType = errors.New("unknown format type")
)

View file

@ -0,0 +1,35 @@
package xport
import (
"bytes"
"context"
"io"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/xport/radioref"
)
type Importer interface {
ImportTalkgroups(ctx context.Context, sys int, r io.Reader) ([]talkgroups.Talkgroup, error)
}
type ImportJob struct {
Type Format `json:"type"`
SystemID int `json:"systemID"`
Body string `json:"body"`
Importer `json:"-"`
}
func (ij *ImportJob) Import(ctx context.Context) ([]talkgroups.Talkgroup, error) {
r := bytes.NewReader([]byte(ij.Body))
switch ij.Type {
case FormatRadioReference:
ij.Importer = radioref.New()
default:
return nil, ErrBadType
}
return ij.ImportTalkgroups(ctx, ij.SystemID, r)
}

View file

@ -0,0 +1,51 @@
package xport_test
import (
"context"
"testing"
"dynatron.me/x/stillbox/pkg/talkgroups/xport"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestImport(t *testing.T) {
tests := []struct {
name string
impType string
input []byte
sysID int
sysName string
jsExpect []byte
expectErr error
}{
{
name: "unknown importer",
impType: "nonexistent",
expectErr: xport.ErrBadType,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
ij := &xport.ImportJob{
Type: xport.Format(tc.impType),
SystemID: tc.sysID,
Body: string(tc.input),
}
_, err := ij.Import(ctx)
if tc.expectErr != nil {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectErr.Error())
} else {
require.NoError(t, err)
}
})
}
}

View file

@ -1,10 +1,8 @@
package importer
package radioref
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"regexp"
"strconv"
@ -16,42 +14,10 @@ import (
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
)
type ImportSource string
type Driver struct{}
const (
ImportSrcRadioReference ImportSource = "radioreference"
)
var (
ErrBadImportType = errors.New("unknown import type")
)
type importer interface {
importTalkgroups(ctx context.Context, sys int, r io.Reader) ([]talkgroups.Talkgroup, error)
}
type ImportJob struct {
Type ImportSource `json:"type"`
SystemID int `json:"systemID"`
Body string `json:"body"`
importer `json:"-"`
}
func (ij *ImportJob) Import(ctx context.Context) ([]talkgroups.Talkgroup, error) {
r := bytes.NewReader([]byte(ij.Body))
switch ij.Type {
case ImportSrcRadioReference:
ij.importer = new(radioReferenceImporter)
default:
return nil, ErrBadImportType
}
return ij.importTalkgroups(ctx, ij.SystemID, r)
}
type radioReferenceImporter struct {
func New() *Driver {
return new(Driver)
}
type rrState int
@ -64,7 +30,7 @@ const (
var rrRE = regexp.MustCompile(`DEC\s+HEX\s+Mode\s+Alpha Tag\s+Description\s+Tag`)
func (rr *radioReferenceImporter) importTalkgroups(ctx context.Context, sys int, r io.Reader) ([]talkgroups.Talkgroup, error) {
func (rr *Driver) ImportTalkgroups(ctx context.Context, sys int, r io.Reader) ([]talkgroups.Talkgroup, error) {
sc := bufio.NewScanner(r)
tgs := make([]talkgroups.Talkgroup, 0, 8)
sysn, has := tgstore.FromCtx(ctx).SystemName(ctx, sys)

View file

@ -1,4 +1,4 @@
package importer_test
package radioref_test
import (
"context"
@ -15,8 +15,8 @@ import (
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/database/mocks"
"dynatron.me/x/stillbox/pkg/talkgroups"
"dynatron.me/x/stillbox/pkg/talkgroups/importer"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/talkgroups/xport"
)
func getFixture(fixture string) []byte {
@ -28,7 +28,7 @@ func getFixture(fixture string) []byte {
return fixt
}
func TestImport(t *testing.T) {
func TestRadioRef(t *testing.T) {
// this is for deterministic UUIDs
uuid.SetRand(rand.New(rand.NewSource(1)))
@ -42,18 +42,13 @@ func TestImport(t *testing.T) {
expectErr error
}{
{
name: "radioreference",
name: "radioreference import",
impType: "radioreference",
input: getFixture("riscon.txt"),
jsExpect: getFixture("riscon.json"),
sysID: 197,
sysName: "RISCON",
},
{
name: "unknown importer",
impType: "nonexistent",
expectErr: importer.ErrBadImportType,
},
}
for _, tc := range tests {
@ -64,8 +59,8 @@ func TestImport(t *testing.T) {
}
ctx := database.CtxWithDB(context.Background(), dbMock)
ctx = tgstore.CtxWithStore(ctx, tgstore.NewCache())
ij := &importer.ImportJob{
Type: importer.ImportSource(tc.impType),
ij := &xport.ImportJob{
Type: xport.Format(tc.impType),
SystemID: tc.sysID,
Body: string(tc.input),
}

View file

@ -0,0 +1,128 @@
package sdrtrunk
import (
"context"
"encoding/xml"
"io"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/pkg/talkgroups"
)
type Playlist struct {
XMLName xml.Name `xml:"playlist"`
Version int `xml:"version,attr"`
Aliases []Alias `xml:"alias"`
Channels []Channel `xml:"channel,omitempty"`
Streams []Stream `xml:"stream,omitempty"`
}
type Alias struct {
XMLName xml.Name `xml:"alias"`
Name string `xml:"name,attr,omitempty"`
Color int `xml:"color,attr,omitempty"`
Group string `xml:"group,attr,omitempty"`
IconName string `xml:"iconName,attr,omitempty"`
List string `xml:"list,attr,omitempty"`
IDs []ID `xml:"id"`
}
func tgToAlias(tg *talkgroups.Talkgroup) Alias {
return Alias{
XMLName: xml.Name{Local: "alias"},
Name: common.ZeroOr(tg.Name),
Group: common.ZeroOr(tg.TGGroup),
List: "Stillbox",
IDs: []ID{
ID{
XMLName: xml.Name{Local: "id"},
Type: "talkgroup",
Value: common.PtrTo(int(tg.TGID)),
},
},
}
}
type ID struct {
XMLName xml.Name `xml:"id"`
Type string `xml:"type,attr"`
Priority *int `xml:"priority,attr,omitempty"`
Channel *string `xml:"channel,attr,omitempty"`
Protocol *string `xml:"protocol,attr,omitempty"`
Value *int `xml:"value,attr,omitempty"`
Min *int `xml:"min,attr,omitempty"`
Max *int `xml:"max,attr,omitempty"`
}
type Channel struct {
XMLName xml.Name `xml:"channel"`
Name string `xml:"name,attr"`
System string `xml:"system,attr"`
Enabled bool `xml:"enabled,attr"`
Site string `xml:"site,attr"`
Order int `xml:"order,attr"`
AliasListName string `xml:"alias_list_name"`
EventLogConfig EventLogConfig `xml:"event_log_configuration"`
SourceConfig SourceConfig `xml:"source_configuration"`
AuxDecodeConfig AuxDecodeConfig `xml:"aux_decode_configuration"`
DecodeConfig DecodeConfig `xml:"decode_configuration"`
RecordConfig RecordConfig `xml:"record_configuration"`
}
type EventLogConfig struct {
EventLogConfig []byte `xml:",innerxml"`
}
type SourceConfig struct {
SourceConfig []byte `xml:",innerxml"`
}
type AuxDecodeConfig struct {
AuxDecodeConfig []byte `xml:",innerxml"`
}
type DecodeConfig struct {
DecodeConfig []byte `xml:",innerxml"`
}
type RecordConfig struct {
RecordConfig []byte `xml:",innerxml"`
}
type Stream struct {
Attributes []xml.Attr `xml:",any,attr"`
Stream []byte `xml:",innerxml"`
}
func New() *Driver {
return new(Driver)
}
type Driver struct{}
func (st *Driver) ExportTalkgroups(ctx context.Context, w io.Writer, tgs []*talkgroups.Talkgroup, tmpl []byte) error {
var pl Playlist
if tmpl != nil {
err := xml.Unmarshal(tmpl, &pl)
if err != nil {
return err
}
pl.Aliases = nil
}
for _, tg := range tgs {
pl.Aliases = append(pl.Aliases, tgToAlias(tg))
}
enc := xml.NewEncoder(w)
enc.Indent("", " ")
err := enc.Encode(&pl)
if err != nil {
return err
}
return enc.Close()
}