main.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: BSD-2-Clause
  4
  5package main
  6
  7import (
  8	"bytes"
  9	"encoding/xml"
 10	"fmt"
 11	"net/url"
 12	"os"
 13	"strings"
 14	"text/template"
 15
 16	flag "github.com/spf13/pflag"
 17)
 18
 19var (
 20	flagInput     *string = flag.StringP("input", "i", "", "Path to input OPML")
 21	flagOutput    *string = flag.StringP("output", "o", "", "Path to output MD")
 22	flagXMLOutput *string = flag.StringP("xmloutput", "x", "", "Path to output XML")
 23	flagTemplate  *string = flag.StringP("template", "t", "", "Path to template MD file")
 24	flagIgnore    *string = flag.StringP("ignore", "g", "", "Comma-separated list of sections to ignore")
 25	flagHelp      *bool   = flag.BoolP("help", "h", false, "Show help and exit")
 26)
 27
 28type OPML struct {
 29	XMLName xml.Name `xml:"opml"`
 30	Version string   `xml:"version,attr"`
 31	Head    Head     `xml:"head"`
 32	Body    Body     `xml:"body"`
 33}
 34
 35type Head struct {
 36	Title           string `xml:"title"`
 37	DateCreated     string `xml:"dateCreated,omitempty"`
 38	DateModified    string `xml:"dateModified,omitempty"`
 39	OwnerName       string `xml:"ownerName,omitempty"`
 40	OwnerEmail      string `xml:"ownerEmail,omitempty"`
 41	OwnerID         string `xml:"ownerId,omitempty"`
 42	Docs            string `xml:"docs,omitempty"`
 43	ExpansionState  string `xml:"expansionState,omitempty"`
 44	VertScrollState string `xml:"vertScrollState,omitempty"`
 45	WindowTop       string `xml:"windowTop,omitempty"`
 46	WindowBottom    string `xml:"windowBottom,omitempty"`
 47	WindowLeft      string `xml:"windowLeft,omitempty"`
 48	WindowRight     string `xml:"windowRight,omitempty"`
 49}
 50
 51type Body struct {
 52	Outlines []Outline `xml:"outline"`
 53}
 54
 55type Outline struct {
 56	Outlines     []Outline `xml:"outline"`
 57	Text         string    `xml:"text,attr"`
 58	Type         string    `xml:"type,attr,omitempty"`
 59	IsComment    string    `xml:"isComment,attr,omitempty"`
 60	IsBreakpoint string    `xml:"isBreakpoint,attr,omitempty"`
 61	Created      string    `xml:"created,attr,omitempty"`
 62	Category     string    `xml:"category,attr,omitempty"`
 63	XMLURL       string    `xml:"xmlUrl,attr,omitempty"`
 64	HTMLURL      string    `xml:"htmlUrl,attr,omitempty"`
 65	URL          string    `xml:"url,attr,omitempty"`
 66	Language     string    `xml:"language,attr,omitempty"`
 67	Title        string    `xml:"title,attr,omitempty"`
 68	Version      string    `xml:"version,attr,omitempty"`
 69	Description  string    `xml:"description,attr,omitempty"`
 70}
 71
 72func main() {
 73	flags()
 74	opml, err := parseOPML(*flagInput)
 75	if err != nil {
 76		fmt.Println(err)
 77		os.Exit(1)
 78	}
 79
 80	feedsList := opmlToList(opml.Body.Outlines)
 81
 82	listString, err := feedsToFile(feedsList)
 83	if err != nil {
 84		fmt.Println(err)
 85		os.Exit(1)
 86	}
 87
 88	err = os.WriteFile(*flagOutput, []byte(listString), 0o644)
 89	if err != nil {
 90		fmt.Println(err)
 91		os.Exit(1)
 92	}
 93	fmt.Printf("Wrote %s\n", *flagOutput)
 94
 95	if *flagXMLOutput != "" {
 96		newOPML := newOPML(opml)
 97		marshalledOPML, err := xml.MarshalIndent(newOPML, "", "\t")
 98		if err != nil {
 99			fmt.Println(err)
100			os.Exit(1)
101		}
102		xmlBytes := []byte(xml.Header + string(marshalledOPML))
103
104		err = os.WriteFile(*flagXMLOutput, xmlBytes, 0o644)
105		if err != nil {
106			fmt.Println(err)
107			os.Exit(1)
108		}
109		fmt.Printf("Wrote %s\n", *flagXMLOutput)
110	}
111}
112
113func parseOPML(filename string) (*OPML, error) {
114	// Read filename into []byte
115	file, err := os.ReadFile(filename)
116	if err != nil {
117		fmt.Println(err)
118		os.Exit(1)
119	}
120
121	var feeds OPML
122	err = xml.Unmarshal(file, &feeds)
123	if err != nil {
124		return nil, err
125	}
126
127	return &feeds, nil
128}
129
130func opmlToList(outlines []Outline) string {
131	var feedsList string
132	ignoreMap := make(map[string]bool)
133	if *flagIgnore != "" {
134		ignore := strings.Split(*flagIgnore, ",")
135		for _, i := range ignore {
136			ignoreMap[i] = true
137		}
138	}
139	for _, outline := range outlines {
140		if outline.Outlines != nil {
141			if ignoreMap[outline.Text] {
142				continue
143			}
144			feedsList += fmt.Sprintf("\n### %s\n\n", outline.Text)
145			feedsList += opmlToList(outline.Outlines)
146		} else {
147			if outline.HTMLURL == "" {
148				parsedXMLURL, err := url.Parse(outline.XMLURL)
149				if err != nil {
150					fmt.Println(err)
151					continue
152				}
153				outline.HTMLURL = fmt.Sprintf("%s://%s", parsedXMLURL.Scheme, parsedXMLURL.Host)
154			}
155			feedsList += fmt.Sprintf("- [%s](%s) [(Feed)](%s)\n", outline.Text, outline.HTMLURL, outline.XMLURL)
156		}
157	}
158	return feedsList
159}
160
161func feedsToFile(feedsList string) (string, error) {
162	// Read template into []byte
163	templateFile, err := os.ReadFile(*flagTemplate)
164	if err != nil {
165		return "", err
166	}
167
168	tmpl, err := template.New("feeds").Parse(string(templateFile))
169	if err != nil {
170		return "", err
171	}
172
173	var buf bytes.Buffer
174	err = tmpl.Execute(&buf, feedsList)
175	if err != nil {
176		return "", err
177	}
178
179	return buf.String(), nil
180}
181
182func newOPML(opml *OPML) *OPML {
183	ignoreMap := make(map[string]bool)
184	if *flagIgnore != "" {
185		ignore := strings.Split(*flagIgnore, ",")
186		for _, i := range ignore {
187			ignoreMap[i] = true
188		}
189	}
190
191	newOPML := *opml
192	newOPML.Body.Outlines = ignoreOutlines(newOPML.Body.Outlines, ignoreMap)
193	return &newOPML
194}
195
196func ignoreOutlines(outline []Outline, ignoreMap map[string]bool) []Outline {
197	var newOutlines []Outline
198	for _, o := range outline {
199		if o.Outlines != nil {
200			if ignoreMap[o.Text] {
201				continue
202			}
203			o.Outlines = ignoreOutlines(o.Outlines, ignoreMap)
204			newOutlines = append(newOutlines, o)
205		} else {
206			if o.HTMLURL == "" {
207				parsedXMLURL, err := url.Parse(o.XMLURL)
208				if err != nil {
209					fmt.Println(err)
210					continue
211				}
212				o.HTMLURL = fmt.Sprintf("%s://%s", parsedXMLURL.Scheme, parsedXMLURL.Host)
213			}
214			newOutlines = append(newOutlines, o)
215		}
216	}
217	return newOutlines
218}
219
220func flags() {
221	flag.Parse()
222	if *flagHelp {
223		fmt.Println("Usage: opml2md -i input.opml -o output.md -t template.md")
224		flag.PrintDefaults()
225		fmt.Println("\nTemplate should contain {{ . }} to be replaced with the")
226		fmt.Println("sections, titles, and URLs from the OPML file.")
227		os.Exit(0)
228	}
229	if *flagInput == "" {
230		fmt.Println("Input file is required")
231		return
232	}
233	if _, err := os.Stat(*flagInput); os.IsNotExist(err) {
234		fmt.Println("Input file does not exist")
235		os.Exit(1)
236	}
237	if *flagTemplate == "" {
238		fmt.Println("Template file is required")
239		os.Exit(1)
240	}
241	if _, err := os.Stat(*flagTemplate); os.IsNotExist(err) {
242		fmt.Println("Template file does not exist")
243		os.Exit(1)
244	}
245	if *flagOutput == "" {
246		fmt.Println("Output file is required")
247		os.Exit(1)
248	}
249}