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", "", "Input file")
 21	flagOutput   *string = flag.StringP("output", "o", "", "Output file")
 22	flagTemplate *string = flag.StringP("template", "t", "", "Template file")
 23	flagIgnore   *string = flag.StringP("ignore", "g", "", "Comma-separated list of sections to ignore")
 24	flagHelp     *bool   = flag.BoolP("help", "h", false, "Show help and exit")
 25)
 26
 27type OPML struct {
 28	XMLName xml.Name `xml:"opml"`
 29	Version string   `xml:"version,attr"`
 30	Head    Head     `xml:"head"`
 31	Body    Body     `xml:"body"`
 32}
 33
 34type Head struct {
 35	Title           string `xml:"title"`
 36	DateCreated     string `xml:"dateCreated,omitempty"`
 37	DateModified    string `xml:"dateModified,omitempty"`
 38	OwnerName       string `xml:"ownerName,omitempty"`
 39	OwnerEmail      string `xml:"ownerEmail,omitempty"`
 40	OwnerID         string `xml:"ownerId,omitempty"`
 41	Docs            string `xml:"docs,omitempty"`
 42	ExpansionState  string `xml:"expansionState,omitempty"`
 43	VertScrollState string `xml:"vertScrollState,omitempty"`
 44	WindowTop       string `xml:"windowTop,omitempty"`
 45	WindowBottom    string `xml:"windowBottom,omitempty"`
 46	WindowLeft      string `xml:"windowLeft,omitempty"`
 47	WindowRight     string `xml:"windowRight,omitempty"`
 48}
 49
 50type Body struct {
 51	Outlines []Outline `xml:"outline"`
 52}
 53
 54type Outline struct {
 55	Outlines     []Outline `xml:"outline"`
 56	Text         string    `xml:"text,attr"`
 57	Type         string    `xml:"type,attr,omitempty"`
 58	IsComment    string    `xml:"isComment,attr,omitempty"`
 59	IsBreakpoint string    `xml:"isBreakpoint,attr,omitempty"`
 60	Created      string    `xml:"created,attr,omitempty"`
 61	Category     string    `xml:"category,attr,omitempty"`
 62	XMLURL       string    `xml:"xmlUrl,attr,omitempty"`
 63	HTMLURL      string    `xml:"htmlUrl,attr,omitempty"`
 64	URL          string    `xml:"url,attr,omitempty"`
 65	Language     string    `xml:"language,attr,omitempty"`
 66	Title        string    `xml:"title,attr,omitempty"`
 67	Version      string    `xml:"version,attr,omitempty"`
 68	Description  string    `xml:"description,attr,omitempty"`
 69}
 70
 71func main() {
 72	flags()
 73	opml, err := parseOPML(*flagInput)
 74	if err != nil {
 75		fmt.Println(err)
 76		os.Exit(1)
 77	}
 78
 79	feedsList := opmlToList(opml.Body.Outlines)
 80
 81	str, err := feedsToFile(feedsList)
 82	if err != nil {
 83		fmt.Println(err)
 84		os.Exit(1)
 85	}
 86
 87	err = os.WriteFile(*flagOutput, []byte(str), 0o644)
 88	if err != nil {
 89		fmt.Println(err)
 90		os.Exit(1)
 91	}
 92
 93	fmt.Printf("Wrote %s\n", *flagOutput)
 94}
 95
 96func parseOPML(filename string) (*OPML, error) {
 97	// Read filename into []byte
 98	file, err := os.ReadFile(filename)
 99	if err != nil {
100		fmt.Println(err)
101		os.Exit(1)
102	}
103
104	var feeds OPML
105	err = xml.Unmarshal(file, &feeds)
106	if err != nil {
107		return nil, err
108	}
109
110	return &feeds, nil
111}
112
113func opmlToList(outlines []Outline) string {
114	var feedsList string
115	ignoreMap := make(map[string]bool)
116	if *flagIgnore != "" {
117		ignore := strings.Split(*flagIgnore, ",")
118		for _, i := range ignore {
119			ignoreMap[i] = true
120		}
121	}
122	for _, outline := range outlines {
123		if outline.Outlines != nil {
124			if ignoreMap[outline.Text] {
125				continue
126			}
127			feedsList += fmt.Sprintf("\n### %s\n\n", outline.Text)
128			feedsList += opmlToList(outline.Outlines)
129		} else {
130			if outline.HTMLURL == "" {
131				parsedXMLURL, err := url.Parse(outline.XMLURL)
132				if err != nil {
133					fmt.Println(err)
134					continue
135				}
136				outline.HTMLURL = fmt.Sprintf("%s://%s", parsedXMLURL.Scheme, parsedXMLURL.Host)
137			}
138			feedsList += fmt.Sprintf("- [%s](%s) [(Feed)](%s)\n", outline.Text, outline.HTMLURL, outline.XMLURL)
139		}
140	}
141	return feedsList
142}
143
144func feedsToFile(feedsList string) (string, error) {
145	// Read template into []byte
146	templateFile, err := os.ReadFile(*flagTemplate)
147	if err != nil {
148		return "", err
149	}
150
151	tmpl, err := template.New("feeds").Parse(string(templateFile))
152	if err != nil {
153		return "", err
154	}
155
156	var buf bytes.Buffer
157	err = tmpl.Execute(&buf, feedsList)
158	if err != nil {
159		return "", err
160	}
161
162	return buf.String(), nil
163}
164
165func flags() {
166	flag.Parse()
167	if *flagHelp {
168		fmt.Println("Usage: opml2md -i input.opml -o output.md -t template.md")
169		flag.PrintDefaults()
170		fmt.Println("\nTemplate should contain {{ . }} to be replaced with the")
171		fmt.Println("sections, titles, and URLs from the OPML file.")
172		os.Exit(0)
173	}
174	if *flagInput == "" {
175		fmt.Println("Input file is required")
176		return
177	}
178	if _, err := os.Stat(*flagInput); os.IsNotExist(err) {
179		fmt.Println("Input file does not exist")
180		os.Exit(1)
181	}
182	if *flagTemplate == "" {
183		fmt.Println("Template file is required")
184		os.Exit(1)
185	}
186	if _, err := os.Stat(*flagTemplate); os.IsNotExist(err) {
187		fmt.Println("Template file does not exist")
188		os.Exit(1)
189	}
190	if *flagOutput == "" {
191		fmt.Println("Output file is required")
192		os.Exit(1)
193	}
194}