~chrisppy/beagles

910f80403d08732fc3db6abf82e937a6b270610c — Chris Palmer 29 days ago 5f8be01 main
Implement OPML import/export
M CHANGELOG.md => CHANGELOG.md +2 -0
@@ 8,6 8,8 @@

### Added
  - Added support for feeds using the gemini protocol (~chrisppy)
  - Added import command to import an OPML file (~chrisppy)
  - Added export command to export an OPML file (~chrisppy)

### Changed
  - Update to cview 1.5.0 (~chrisppy)

M db/db.go => db/db.go +60 -0
@@ 20,7 20,9 @@ package db
import (
	"fmt"
	"path/filepath"
	"time"

	"git.sr.ht/~chrisppy/beagles/opml"
	bolt "go.etcd.io/bbolt"
)



@@ 89,6 91,64 @@ func ReadDB(path string, gmniPath string) (*Storage, error) {
	return s, nil
}

// Import will use an OPML file to insert feeds that you are not already
// subscribed.
func (s *Storage) Import(path string) (map[string]*Item, map[error]bool) {
	errors := make(map[error]bool)
	o, err := opml.FromFile(path)
	if err != nil {
		errors[err] = false
		return nil, errors
	}

	nitems := make(map[string]*Item)
	for _, out := range o.Body.Outlines {
		if _, ok := s.Feeds[out.XMLURL]; ok {
			continue
		}

		items, err := s.CreateFeed(out.XMLURL)
		if err != nil {
			errors[err] = false
			continue
		}

		for k, v := range items {
			nitems[k] = v
		}
	}

	return nitems, errors
}

// Export will export all feeds to an OPML file.
func (s *Storage) Export(path string) error {
	o := &opml.OPML{
		Version: "2.0",
		Head: opml.Head{
			Title:       "beagles subscriptions",
			DateCreated: time.Now().Format(time.RFC822),
		},
		Body: opml.Body{
			Outlines: make([]*opml.Outline, len(s.Feeds)),
		},
	}

	i := 0
	for k, v := range s.Feeds {
		o.Body.Outlines[i] = &opml.Outline{
			Text:    v.Title,
			Title:   v.Title,
			Type:    "rss",
			HTMLURL: v.Link,
			XMLURL:  k,
		}
		i++
	}

	return o.Write(path)
}

// CreateFeed will collect the rss feed and process through the elements
// and add the relevant data elements to the database
func (s *Storage) CreateFeed(url string) (map[string]*Item, error) {

M doc/beagles.1.scd => doc/beagles.1.scd +15 -8
@@ 112,12 112,8 @@ FAVORITES
*add* [url]
	add a feed

*remove, rm* [url]
	remove the feed, note you can omit the url if you are in the++
subscription page on a current feed

*update, up*
	update all feeds
*export* [path]
	export an opml file

*help, h*
	display help page


@@ 125,12 121,23 @@ subscription page on a current feed
*hide*
	hide read posts in SUBSCRIPTIONS

*unhide*
	unhide read posts in SUBSCRIPTIONS
*import* [path]
	import an opml file

*quit, q*
	exit the application

*remove, rm* [url]
	remove the feed, note you can omit the url if you are in the++
subscription page on a current feed

*unhide*
	unhide read posts in SUBSCRIPTIONS

*update, up*
	update all feeds


# CONFIGURATION

See *beagles-config*(5)

M go.mod => go.mod +2 -3
@@ 13,11 13,10 @@ require (
	github.com/mmcdole/gofeed v1.1.0
	github.com/olekukonko/tablewriter v0.0.4 // indirect
	github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
	github.com/stretchr/testify v1.4.0 // indirect
	github.com/stretchr/testify v1.6.1
	gitlab.com/tslocum/cbind v0.1.3
	gitlab.com/tslocum/cview v1.5.1-0.20201009235145-c33ce9563344
	go.etcd.io/bbolt v1.3.5
	golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect
	golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d
	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
	gopkg.in/yaml.v2 v2.3.0 // indirect
)

M go.sum => go.sum +6 -6
@@ 62,8 62,8 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cma
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
gitlab.com/tslocum/cbind v0.1.2 h1:ptDjO7WeOl1HglprsK18L8I9JeRkmtuBoBBaYw/6/Ow=
gitlab.com/tslocum/cbind v0.1.2/go.mod h1:HfB7qAhHSZbn1rFK8M9SvSN5NG6ScAg/3h3iE6xdeeI=


@@ 81,8 81,8 @@ golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d h1:dOiJ2n2cMwGLce/74I/QHMbnpk5GfY7InR8rczoMqRM=
golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=


@@ 107,5 107,5 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

A opml/opml.go => opml/opml.go +126 -0
@@ 0,0 1,126 @@
// This file is part of beagles.
//
// Copyright © 2020 Chris Palmer <chris@red-oxide.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package opml

import (
	"bytes"
	"encoding/xml"
	"fmt"
	"io"
	"io/ioutil"
	"path/filepath"
	"strings"

	"golang.org/x/net/html/charset"
)

// OPML elements
type OPML struct {
	XMLName xml.Name `xml:"opml"`
	Version string   `xml:"version,attr"`
	Head    Head     `xml:"head"`
	Body    Body     `xml:"body"`
}

// Head Elements
type Head struct {
	XMLName         xml.Name `xml:"head"`
	Title           string   `xml:"title,omitempty"`
	DateCreated     string   `xml:"dateCreated,omitempty"`
	DateModified    string   `xml:"dateModified,omitempty"`
	OwnerName       string   `xml:"ownerName,omitempty"`
	OwnerEmail      string   `xml:"ownerEmail,omitempty"`
	OwnerID         string   `xml:"ownerId,omitempty"`
	Docs            string   `xml:"docs,omitempty"`
	ExpansionState  string   `xml:"expansionState,omitempty"`
	VertScrollState int      `xml:"vertScrollState,omitempty"`
	WindowTop       int      `xml:"windowTop,omitempty"`
	WindowLeft      int      `xml:"windowLeft,omitempty"`
	WindowBottom    int      `xml:"windowBottom,omitempty"`
	WindowRight     int      `xml:"windowRight,omitempty"`
}

// Body Elements
type Body struct {
	XMLName  xml.Name   `xml:"body"`
	Outlines []*Outline `xml:"outline"`
}

// Outline elements
type Outline struct {
	XMLName     xml.Name   `xml:"outline"`
	Text        string     `xml:"text,attr"`
	Description string     `xml:"description,attr,omitempty"`
	HTMLURL     string     `xml:"htmlUrl,attr,omitempty"`
	Language    string     `xml:"language,attr,omitempty"`
	Title       string     `xml:"title,attr,omitempty"`
	Type        string     `xml:"type,attr,omitempty"`
	Version     string     `xml:"version,attr,omitempty"`
	XMLURL      string     `xml:"xmlUrl,attr,omitempty"`
	Created     string     `xml:"created,attr,omitempty"`
	Outlines    []*Outline `xml:"outline"`
}

// FromFile will read the xml file
func FromFile(path string) (*OPML, error) {
	b, err := ioutil.ReadFile(filepath.Clean(path))
	if err != nil {
		return nil, err
	}
	r := bytes.NewReader(b)
	return Read(r)
}

// Read the xml from a reader
func Read(r io.Reader) (*OPML, error) {
	var o OPML
	decoder := xml.NewDecoder(r)
	decoder.CharsetReader = charset.NewReaderLabel
	if err := decoder.Decode(&o); err != nil {
		return nil, err
	}

	return &o, nil
}

// XML will convert the structure to XML
func (o *OPML) XML() ([]byte, error) {
	b := &bytes.Buffer{}
	enc := xml.NewEncoder(b)
	enc.Indent("", "\t")
	if err := enc.Encode(o); err != nil {
		return nil, err
	}

	s := fmt.Sprintf(`<?xml version="1.0" encoding="ISO-8859-1"?>
%s
`, b.String())

	s = strings.ReplaceAll(s, "></outline>", "/>")
	return []byte(s), nil
}

// Write the structure to a file as XML
func (o *OPML) Write(path string) error {
	b, err := o.XML()
	if err != nil {
		return err
	}

	return ioutil.WriteFile(filepath.Clean(path), b, 0600)
}

A opml/opml_test.go => opml/opml_test.go +391 -0
@@ 0,0 1,391 @@
package opml

import (
	"bytes"
	"io/ioutil"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestPathDNE(t *testing.T) {
	data, err := FromFile("invalidPath.opml")
	assert.Error(t, err)
	assert.Nil(t, data)
}

func TestErrorData(t *testing.T) {
	data, err := Read(bytes.NewReader([]byte("Invalid Data")))
	assert.Error(t, err)
	assert.Nil(t, data)
}

func TestReadSubscriptionList(t *testing.T) {
	data, err := FromFile("test-data/subscriptionList.opml")
	assert.NoError(t, err)
	assert.NotNil(t, data)
	assert.Equal(t, "2.0", data.Version)

	// Test Head
	assert.Equal(t, "mySubscriptions.opml", data.Head.Title)
	assert.Equal(t, "Sat, 18 Jun 2005 12:11:52 GMT", data.Head.DateCreated)
	assert.Equal(t, "Tue, 02 Aug 2005 21:42:48 GMT", data.Head.DateModified)
	assert.Equal(t, "Dave Winer", data.Head.OwnerName)
	assert.Equal(t, "dave@scripting.com", data.Head.OwnerEmail)
	assert.Equal(t, "", data.Head.OwnerID)
	assert.Equal(t, "", data.Head.Docs)
	assert.Equal(t, "", data.Head.ExpansionState)
	assert.Equal(t, 1, data.Head.VertScrollState)
	assert.Equal(t, 61, data.Head.WindowTop)
	assert.Equal(t, 304, data.Head.WindowLeft)
	assert.Equal(t, 562, data.Head.WindowBottom)
	assert.Equal(t, 842, data.Head.WindowRight)

	// Test Body
	assert.Equal(t, 13, len(data.Body.Outlines))
	for _, o := range data.Body.Outlines {
		assert.Equal(t, "", o.Created)
		assert.NotEqual(t, "", o.Text)
		assert.NotEqual(t, "", o.Version)
		assert.NotEqual(t, "", o.Type)
		assert.NotNil(t, o.Language)
		assert.NotNil(t, o.Title)
		assert.NotNil(t, o.Description)
		assert.NotNil(t, o.HTMLURL)
		assert.NotNil(t, o.XMLURL)
	}
}

func TestReadStates(t *testing.T) {
	data, err := FromFile("test-data/states.opml")
	assert.NoError(t, err)
	assert.NotNil(t, data)
	assert.Equal(t, "2.0", data.Version)

	// Test Head
	assert.Equal(t, "states.opml", data.Head.Title)
	assert.Equal(t, "Tue, 15 Mar 2005 16:35:45 GMT", data.Head.DateCreated)
	assert.Equal(t, "Thu, 14 Jul 2005 23:41:05 GMT", data.Head.DateModified)
	assert.Equal(t, "Dave Winer", data.Head.OwnerName)
	assert.Equal(t, "dave@scripting.com", data.Head.OwnerEmail)
	assert.Equal(t, "", data.Head.OwnerID)
	assert.Equal(t, "", data.Head.Docs)
	assert.Equal(t, "1, 6, 13, 16, 18, 20", data.Head.ExpansionState)
	assert.Equal(t, 1, data.Head.VertScrollState)
	assert.Equal(t, 106, data.Head.WindowTop)
	assert.Equal(t, 106, data.Head.WindowLeft)
	assert.Equal(t, 558, data.Head.WindowBottom)
	assert.Equal(t, 479, data.Head.WindowRight)

	// Test Body
	assert.Equal(t, 1, len(data.Body.Outlines))
	p := data.Body.Outlines[0]
	assert.Equal(t, "United States", p.Text)
	assert.Equal(t, "", p.Version)
	assert.Equal(t, "", p.Type)
	assert.Equal(t, "", p.Created)
	assert.Equal(t, "", p.Language)
	assert.Equal(t, "", p.Title)
	assert.Equal(t, "", p.Description)
	assert.Equal(t, "", p.HTMLURL)
	assert.Equal(t, "", p.XMLURL)
	assert.Equal(t, 8, len(p.Outlines))

	for i, o := range p.Outlines {
		assert.Equal(t, "", o.Version)
		assert.Equal(t, "", o.Type)
		assert.Equal(t, "", o.Created)
		assert.Equal(t, "", o.Language)
		assert.Equal(t, "", o.Title)
		assert.Equal(t, "", o.Description)
		assert.Equal(t, "", o.HTMLURL)
		assert.Equal(t, "", o.XMLURL)
		switch i {
		case 0:
			assert.Equal(t, "Far West", o.Text)
			assert.Equal(t, 6, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				switch j {
				case 0:
					assert.Equal(t, "Alaska", n.Text)
					assert.Equal(t, 0, len(n.Outlines))
				case 1:
					assert.Equal(t, "California", n.Text)
					assert.Equal(t, 0, len(n.Outlines))
				case 2:
					assert.Equal(t, "Hawaii", n.Text)
					assert.Equal(t, 0, len(n.Outlines))
				case 3:
					assert.Equal(t, "Nevada", n.Text)
					assert.Equal(t, 4, len(n.Outlines))
					for k, m := range n.Outlines {
						assert.NotEqual(t, "", m.Created)
						assert.Equal(t, "", m.Version)
						assert.Equal(t, "", m.Type)
						assert.Equal(t, "", m.Language)
						assert.Equal(t, "", m.Title)
						assert.Equal(t, "", m.Description)
						assert.Equal(t, "", m.HTMLURL)
						assert.Equal(t, "", m.XMLURL)
						switch k {
						case 0:
							assert.Equal(t, "Reno", m.Text)
						case 1:
							assert.Equal(t, "Las Vegas", m.Text)
						case 2:
							assert.Equal(t, "Ely", m.Text)
						case 3:
							assert.Equal(t, "Gerlach", m.Text)
						}
					}
				case 4:
					assert.Equal(t, "Oregon", n.Text)
					assert.Equal(t, 0, len(n.Outlines))
				case 5:
					assert.Equal(t, "Washington", n.Text)
					assert.Equal(t, 0, len(n.Outlines))
				}
			}
		case 1:
			assert.Equal(t, "Great Plains", o.Text)
			assert.Equal(t, 5, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				assert.Equal(t, 0, len(n.Outlines))
				switch j {
				case 0:
					assert.Equal(t, "Kansas", n.Text)
				case 1:
					assert.Equal(t, "Nebraska", n.Text)
				case 2:
					assert.Equal(t, "North Dakota", n.Text)
				case 3:
					assert.Equal(t, "Oklahoma", n.Text)
				case 4:
					assert.Equal(t, "South Dakota", n.Text)
				}
			}
		case 2:
			assert.Equal(t, "Mid-Atlantic", o.Text)
			assert.Equal(t, 5, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				assert.Equal(t, 0, len(n.Outlines))
				switch j {
				case 0:
					assert.Equal(t, "Delaware", n.Text)
				case 1:
					assert.Equal(t, "Maryland", n.Text)
				case 2:
					assert.Equal(t, "New Jersey", n.Text)
				case 3:
					assert.Equal(t, "New York", n.Text)
				case 4:
					assert.Equal(t, "Pennsylvania", n.Text)
				}
			}
		case 3:
			assert.Equal(t, "Midwest", o.Text)
			assert.Equal(t, 10, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				assert.Equal(t, 0, len(n.Outlines))
				switch j {
				case 0:
					assert.Equal(t, "Illinois", n.Text)
				case 1:
					assert.Equal(t, "Indiana", n.Text)
				case 2:
					assert.Equal(t, "Iowa", n.Text)
				case 3:
					assert.Equal(t, "Kentucky", n.Text)
				case 4:
					assert.Equal(t, "Michigan", n.Text)
				case 5:
					assert.Equal(t, "Minnesota", n.Text)
				case 6:
					assert.Equal(t, "Missouri", n.Text)
				case 7:
					assert.Equal(t, "Ohio", n.Text)
				case 8:
					assert.Equal(t, "West Virginia", n.Text)
				case 9:
					assert.Equal(t, "Wisconsin", n.Text)
				}
			}
		case 4:
			assert.Equal(t, "Mountains", o.Text)
			assert.Equal(t, 5, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				assert.Equal(t, 0, len(n.Outlines))
				switch j {
				case 0:
					assert.Equal(t, "Colorado", n.Text)
				case 1:
					assert.Equal(t, "Idaho", n.Text)
				case 2:
					assert.Equal(t, "Montana", n.Text)
				case 3:
					assert.Equal(t, "Utah", n.Text)
				case 4:
					assert.Equal(t, "Wyoming", n.Text)
				}
			}
		case 5:
			assert.Equal(t, "New England", o.Text)
			assert.Equal(t, 6, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				assert.Equal(t, 0, len(n.Outlines))
				switch j {
				case 0:
					assert.Equal(t, "Connecticut", n.Text)
				case 1:
					assert.Equal(t, "Maine", n.Text)
				case 2:
					assert.Equal(t, "Massachusetts", n.Text)
				case 3:
					assert.Equal(t, "New Hampshire", n.Text)
				case 4:
					assert.Equal(t, "Rhode Island", n.Text)
				case 5:
					assert.Equal(t, "Vermont", n.Text)
				}
			}
		case 6:
			assert.Equal(t, "South", o.Text)
			assert.Equal(t, 10, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				assert.Equal(t, 0, len(n.Outlines))
				switch j {
				case 0:
					assert.Equal(t, "Alabama", n.Text)
				case 1:
					assert.Equal(t, "Arkansas", n.Text)
				case 2:
					assert.Equal(t, "Florida", n.Text)
				case 3:
					assert.Equal(t, "Georgia", n.Text)
				case 4:
					assert.Equal(t, "Louisiana", n.Text)
				case 5:
					assert.Equal(t, "Mississippi", n.Text)
				case 6:
					assert.Equal(t, "North Carolina", n.Text)
				case 7:
					assert.Equal(t, "South Carolina", n.Text)
				case 8:
					assert.Equal(t, "Tennessee", n.Text)
				case 9:
					assert.Equal(t, "Virginia", n.Text)
				}
			}
		case 7:
			assert.Equal(t, "Southwest", o.Text)
			assert.Equal(t, 3, len(o.Outlines))
			for j, n := range o.Outlines {
				assert.Equal(t, "", n.Version)
				assert.Equal(t, "", n.Type)
				assert.Equal(t, "", n.Created)
				assert.Equal(t, "", n.Language)
				assert.Equal(t, "", n.Title)
				assert.Equal(t, "", n.Description)
				assert.Equal(t, "", n.HTMLURL)
				assert.Equal(t, "", n.XMLURL)
				assert.Equal(t, 0, len(n.Outlines))
				switch j {
				case 0:
					assert.Equal(t, "Arizona", n.Text)
				case 1:
					assert.Equal(t, "New Mexico", n.Text)
				case 2:
					assert.Equal(t, "Texas", n.Text)
				}
			}
		}
	}
}

func TestWriteSubscriptionList(t *testing.T) {
	b, err := ioutil.ReadFile("test-data/subscriptionList.opml")
	assert.NoError(t, err)
	assert.NotNil(t, b)

	data, err := Read(bytes.NewReader(b))
	assert.NoError(t, err)
	assert.NotNil(t, data)

	out, err := data.XML()
	assert.NoError(t, err)
	assert.NotNil(t, out)

	assert.Equal(t, string(b), string(out))
}

func TestWriteState(t *testing.T) {
	b, err := ioutil.ReadFile("test-data/states.opml")
	assert.NoError(t, err)
	assert.NotNil(t, b)

	data, err := Read(bytes.NewReader(b))
	assert.NoError(t, err)
	assert.NotNil(t, data)

	out, err := data.XML()
	assert.NoError(t, err)
	assert.NotNil(t, out)

	assert.Equal(t, string(b), string(out))
}

A opml/test-data/states.opml => opml/test-data/states.opml +91 -0
@@ 0,0 1,91 @@
<?xml version="1.0" encoding="ISO-8859-1"?>
<opml version="2.0">
	<head>
		<title>states.opml</title>
		<dateCreated>Tue, 15 Mar 2005 16:35:45 GMT</dateCreated>
		<dateModified>Thu, 14 Jul 2005 23:41:05 GMT</dateModified>
		<ownerName>Dave Winer</ownerName>
		<ownerEmail>dave@scripting.com</ownerEmail>
		<expansionState>1, 6, 13, 16, 18, 20</expansionState>
		<vertScrollState>1</vertScrollState>
		<windowTop>106</windowTop>
		<windowLeft>106</windowLeft>
		<windowBottom>558</windowBottom>
		<windowRight>479</windowRight>
	</head>
	<body>
		<outline text="United States">
			<outline text="Far West">
				<outline text="Alaska"/>
				<outline text="California"/>
				<outline text="Hawaii"/>
				<outline text="Nevada">
					<outline text="Reno" created="Tue, 12 Jul 2005 23:56:35 GMT"/>
					<outline text="Las Vegas" created="Tue, 12 Jul 2005 23:56:37 GMT"/>
					<outline text="Ely" created="Tue, 12 Jul 2005 23:56:39 GMT"/>
					<outline text="Gerlach" created="Tue, 12 Jul 2005 23:56:47 GMT"/>
				</outline>
				<outline text="Oregon"/>
				<outline text="Washington"/>
			</outline>
			<outline text="Great Plains">
				<outline text="Kansas"/>
				<outline text="Nebraska"/>
				<outline text="North Dakota"/>
				<outline text="Oklahoma"/>
				<outline text="South Dakota"/>
			</outline>
			<outline text="Mid-Atlantic">
				<outline text="Delaware"/>
				<outline text="Maryland"/>
				<outline text="New Jersey"/>
				<outline text="New York"/>
				<outline text="Pennsylvania"/>
			</outline>
			<outline text="Midwest">
				<outline text="Illinois"/>
				<outline text="Indiana"/>
				<outline text="Iowa"/>
				<outline text="Kentucky"/>
				<outline text="Michigan"/>
				<outline text="Minnesota"/>
				<outline text="Missouri"/>
				<outline text="Ohio"/>
				<outline text="West Virginia"/>
				<outline text="Wisconsin"/>
			</outline>
			<outline text="Mountains">
				<outline text="Colorado"/>
				<outline text="Idaho"/>
				<outline text="Montana"/>
				<outline text="Utah"/>
				<outline text="Wyoming"/>
			</outline>
			<outline text="New England">
				<outline text="Connecticut"/>
				<outline text="Maine"/>
				<outline text="Massachusetts"/>
				<outline text="New Hampshire"/>
				<outline text="Rhode Island"/>
				<outline text="Vermont"/>
			</outline>
			<outline text="South">
				<outline text="Alabama"/>
				<outline text="Arkansas"/>
				<outline text="Florida"/>
				<outline text="Georgia"/>
				<outline text="Louisiana"/>
				<outline text="Mississippi"/>
				<outline text="North Carolina"/>
				<outline text="South Carolina"/>
				<outline text="Tennessee"/>
				<outline text="Virginia"/>
			</outline>
			<outline text="Southwest">
				<outline text="Arizona"/>
				<outline text="New Mexico"/>
				<outline text="Texas"/>
			</outline>
		</outline>
	</body>
</opml>

A opml/test-data/subscriptionList.opml => opml/test-data/subscriptionList.opml +30 -0
@@ 0,0 1,30 @@
<?xml version="1.0" encoding="ISO-8859-1"?>
<opml version="2.0">
	<head>
		<title>mySubscriptions.opml</title>
		<dateCreated>Sat, 18 Jun 2005 12:11:52 GMT</dateCreated>
		<dateModified>Tue, 02 Aug 2005 21:42:48 GMT</dateModified>
		<ownerName>Dave Winer</ownerName>
		<ownerEmail>dave@scripting.com</ownerEmail>
		<vertScrollState>1</vertScrollState>
		<windowTop>61</windowTop>
		<windowLeft>304</windowLeft>
		<windowBottom>562</windowBottom>
		<windowRight>842</windowRight>
	</head>
	<body>
		<outline text="CNET News.com" description="Tech news and business reports by CNET News.com. Focused on information technology, core topics include computers, hardware, software, networking, and Internet media." htmlUrl="http://news.com.com/" language="unknown" title="CNET News.com" type="rss" version="RSS2" xmlUrl="http://news.com.com/2547-1_3-0-5.xml"/>
		<outline text="washingtonpost.com - Politics" description="Politics" htmlUrl="http://www.washingtonpost.com/wp-dyn/politics?nav=rss_politics" language="unknown" title="washingtonpost.com - Politics" type="rss" version="RSS2" xmlUrl="http://www.washingtonpost.com/wp-srv/politics/rssheadlines.xml"/>
		<outline text="Scobleizer: Microsoft Geek Blogger" description="Robert Scoble&#39;s look at geek and Microsoft life." htmlUrl="http://radio.weblogs.com/0001011/" language="unknown" title="Scobleizer: Microsoft Geek Blogger" type="rss" version="RSS2" xmlUrl="http://radio.weblogs.com/0001011/rss.xml"/>
		<outline text="Yahoo! News: Technology" description="Technology" htmlUrl="http://news.yahoo.com/news?tmpl=index&amp;cid=738" language="unknown" title="Yahoo! News: Technology" type="rss" version="RSS2" xmlUrl="http://rss.news.yahoo.com/rss/tech"/>
		<outline text="Workbench" description="Programming and publishing news and comment" htmlUrl="http://www.cadenhead.org/workbench/" language="unknown" title="Workbench" type="rss" version="RSS2" xmlUrl="http://www.cadenhead.org/workbench/rss.xml"/>
		<outline text="Christian Science Monitor | Top Stories" description="Read the front page stories of csmonitor.com." htmlUrl="http://csmonitor.com" language="unknown" title="Christian Science Monitor | Top Stories" type="rss" version="RSS" xmlUrl="http://www.csmonitor.com/rss/top.rss"/>
		<outline text="Dictionary.com Word of the Day" description="A new word is presented every day with its definition and example sentences from actual published works." htmlUrl="http://dictionary.reference.com/wordoftheday/" language="unknown" title="Dictionary.com Word of the Day" type="rss" version="RSS" xmlUrl="http://www.dictionary.com/wordoftheday/wotd.rss"/>
		<outline text="The Motley Fool" description="To Educate, Amuse, and Enrich" htmlUrl="http://www.fool.com" language="unknown" title="The Motley Fool" type="rss" version="RSS" xmlUrl="http://www.fool.com/xml/foolnews_rss091.xml"/>
		<outline text="InfoWorld: Top News" description="The latest on Top News from InfoWorld" htmlUrl="http://www.infoworld.com/news/index.html" language="unknown" title="InfoWorld: Top News" type="rss" version="RSS2" xmlUrl="http://www.infoworld.com/rss/news.xml"/>
		<outline text="NYT &gt; Business" description="Find breaking news &amp; business news on Wall Street, media &amp; advertising, international business, banking, interest rates, the stock market, currencies &amp; funds." htmlUrl="http://www.nytimes.com/pages/business/index.html?partner=rssnyt" language="unknown" title="NYT &gt; Business" type="rss" version="RSS2" xmlUrl="http://www.nytimes.com/services/xml/rss/nyt/Business.xml"/>
		<outline text="NYT &gt; Technology" htmlUrl="http://www.nytimes.com/pages/technology/index.html?partner=rssnyt" language="unknown" title="NYT &gt; Technology" type="rss" version="RSS2" xmlUrl="http://www.nytimes.com/services/xml/rss/nyt/Technology.xml"/>
		<outline text="Scripting News" description="It&#39;s even worse than it appears." htmlUrl="http://www.scripting.com/" language="unknown" title="Scripting News" type="rss" version="RSS2" xmlUrl="http://www.scripting.com/rss.xml"/>
		<outline text="Wired News" description="Technology, and the way we do business, is changing the world we know. Wired News is a technology - and business-oriented news service feeding an intelligent, discerning audience. What role does technology play in the day-to-day living of your life? Wired News tells you. How has evolving technology changed the face of the international business world? Wired News puts you in the picture." htmlUrl="http://www.wired.com/" language="unknown" title="Wired News" type="rss" version="RSS" xmlUrl="http://www.wired.com/news_drop/netcenter/netcenter.rdf"/>
	</body>
</opml>

M ui/helpContent.go => ui/helpContent.go +15 -9
@@ 95,24 95,30 @@ COMMANDS:
	add [url]
		add a feed

	remove,rm [url]
		remove the feed, note you can omit the url if you are in the
		subscription page on a current feed

	update,up
		update all feeds

	export [path]
		export an opml file
	
	help,h
		display help page

	hide
		hide read posts in SUBSCRIPTIONS
	
	import [path]
		import an opml file
	
	quit,q
		exit the application

	remove,rm [url]
		remove the feed, note you can omit the url if you are in the
		subscription page on a current feed

	unhide
		unhide read posts in SUBSCRIPTIONS

	quit,q
		exit the application
	update,up
		update all feeds

SEE ALSO:
	Additional information can be found in the manual pages:

M ui/ui.go => ui/ui.go +63 -8
@@ 18,6 18,7 @@
package ui

import (
	"path/filepath"
	"strings"
	"time"



@@ 377,23 378,29 @@ func (i *UI) Init() {
		args := strings.Split(input, " ")

		switch args[0] {
		case ":quit", ":q":
			i.app.exit()
		case ":add":
			i.commandLine.setText("adding feed...")
			i.addFeed(args)
		case ":export":
			i.commandLine.setText("exporting feed...")
			i.exportOPML(args)
		case ":help", ":h":
			i.commandLine.setText("")
			i.setStatus(helpStatus)
		case ":hide":
			i.commandLine.setText("")
			i.hide()
		case ":unhide":
			i.commandLine.setText("")
			i.unhide()
		case ":add":
			i.commandLine.setText("adding feed...")
			i.addFeed(args)
		case ":import":
			i.commandLine.setText("importing feed...")
			i.importOPML(args)
		case ":quit", ":q":
			i.app.exit()
		case ":remove", ":rm":
			i.commandLine.setText("removing feed...")
			i.deleteFeed(args)
		case ":unhide":
			i.commandLine.setText("")
			i.unhide()
		case ":update", ":up":
			i.commandLine.setText("updating feeds...")
			go i.updateFeed(args)


@@ 448,6 455,54 @@ func (i *UI) updateFeed(args []string) {
	})
}

func (i *UI) exportOPML(args []string) {
	i.app.draw(func() {
		if len(args) != 2 {
			i.commandLine.setError("export must contain one path")
			i.setStatus(i.status)
			return
		}

		path := filepath.Join(args[1], "beagles.opml")

		if err := i.DB.Export(path); err != nil {
			i.commandLine.setError(err.Error())
			i.setStatus(i.status)
			return
		}

		i.commandLine.setText("")
	})
}

func (i *UI) importOPML(args []string) {
	i.app.draw(func() {
		if len(args) != 2 {
			i.commandLine.setError("import must contain one path")
			i.setStatus(i.status)
			return
		}

		nitems, errors := i.DB.Import(args[1])

		for err := range errors {
			i.Logger.Error(err.Error())
		}

		for _, v := range nitems {
			i.list.insert(v)
		}
		i.subTree.refresh(i.DB.Feeds, i.DB.Items, i.hideRead)

		i.commandLine.setText("")
		if len(errors) > 0 {
			i.commandLine.setError("see log for errors")
			i.setStatus(i.status)
		}
	})

}

func (i *UI) addFeed(args []string) {
	i.app.draw(func() {
		if len(args) != 2 {