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}