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		xmlBytes := []byte(xml.Header + string(marshalledOPML))
 99
100		err = os.WriteFile(*flagXMLOutput, xmlBytes, 0o644)
101		if err != nil {
102			fmt.Println(err)
103			os.Exit(1)
104		}
105		fmt.Printf("Wrote %s\n", *flagXMLOutput)
106	}
107}
108
109func parseOPML(filename string) (*OPML, error) {
110	// Read filename into []byte
111	file, err := os.ReadFile(filename)
112	if err != nil {
113		fmt.Println(err)
114		os.Exit(1)
115	}
116
117	var feeds OPML
118	err = xml.Unmarshal(file, &feeds)
119	if err != nil {
120		return nil, err
121	}
122
123	return &feeds, nil
124}
125
126func opmlToList(outlines []Outline) string {
127	var feedsList string
128	ignoreMap := make(map[string]bool)
129	if *flagIgnore != "" {
130		ignore := strings.Split(*flagIgnore, ",")
131		for _, i := range ignore {
132			ignoreMap[i] = true
133		}
134	}
135	for _, outline := range outlines {
136		if outline.Outlines != nil {
137			if ignoreMap[outline.Text] {
138				continue
139			}
140			feedsList += fmt.Sprintf("\n### %s\n\n", outline.Text)
141			feedsList += opmlToList(outline.Outlines)
142		} else {
143			if outline.HTMLURL == "" {
144				parsedXMLURL, err := url.Parse(outline.XMLURL)
145				if err != nil {
146					fmt.Println(err)
147					continue
148				}
149				outline.HTMLURL = fmt.Sprintf("%s://%s", parsedXMLURL.Scheme, parsedXMLURL.Host)
150			}
151			feedsList += fmt.Sprintf("- [%s](%s) [(Feed)](%s)\n", outline.Text, outline.HTMLURL, outline.XMLURL)
152		}
153	}
154	return feedsList
155}
156
157func feedsToFile(feedsList string) (string, error) {
158	// Read template into []byte
159	templateFile, err := os.ReadFile(*flagTemplate)
160	if err != nil {
161		return "", err
162	}
163
164	tmpl, err := template.New("feeds").Parse(string(templateFile))
165	if err != nil {
166		return "", err
167	}
168
169	var buf bytes.Buffer
170	err = tmpl.Execute(&buf, feedsList)
171	if err != nil {
172		return "", err
173	}
174
175	return buf.String(), nil
176}
177
178func newOPML(opml *OPML) *OPML {
179	ignoreMap := make(map[string]bool)
180	if *flagIgnore != "" {
181		ignore := strings.Split(*flagIgnore, ",")
182		for _, i := range ignore {
183			ignoreMap[i] = true
184		}
185	}
186
187	newOPML := *opml
188	newOPML.Body.Outlines = ignoreOutlines(newOPML.Body.Outlines, ignoreMap)
189	return &newOPML
190}
191
192func ignoreOutlines(outline []Outline, ignoreMap map[string]bool) []Outline {
193	var newOutlines []Outline
194	for _, o := range outline {
195		if o.Outlines != nil {
196			if ignoreMap[o.Text] {
197				continue
198			}
199			o.Outlines = ignoreOutlines(o.Outlines, ignoreMap)
200			newOutlines = append(newOutlines, o)
201		} else {
202			if o.HTMLURL == "" {
203				parsedXMLURL, err := url.Parse(o.XMLURL)
204				if err != nil {
205					fmt.Println(err)
206					continue
207				}
208				o.HTMLURL = fmt.Sprintf("%s://%s", parsedXMLURL.Scheme, parsedXMLURL.Host)
209			}
210			newOutlines = append(newOutlines, o)
211		}
212	}
213	return newOutlines
214}
215
216func flags() {
217	flag.Parse()
218	if *flagHelp {
219		fmt.Println("Usage: opml2md -i input.opml -o output.md -t template.md")
220		flag.PrintDefaults()
221		fmt.Println("\nTemplate should contain {{ . }} to be replaced with the")
222		fmt.Println("sections, titles, and URLs from the OPML file.")
223		os.Exit(0)
224	}
225	if *flagInput == "" {
226		fmt.Println("Input file is required")
227		return
228	}
229	if _, err := os.Stat(*flagInput); os.IsNotExist(err) {
230		fmt.Println("Input file does not exist")
231		os.Exit(1)
232	}
233	if *flagTemplate == "" {
234		fmt.Println("Template file is required")
235		os.Exit(1)
236	}
237	if _, err := os.Stat(*flagTemplate); os.IsNotExist(err) {
238		fmt.Println("Template file does not exist")
239		os.Exit(1)
240	}
241	if *flagOutput == "" {
242		fmt.Println("Output file is required")
243		os.Exit(1)
244	}
245}