Skip to main content

max / balanced_breakfast

Add 7 plugins: lobsters, xkcd, nasa_apod, devto, earthquakes, nws_alerts, github_trending All use public JSON APIs with no auth required. Brings total plugin count from 3 to 10. Lobsters (lobste.rs hottest/newest/active), XKCD (recent comics with alt text), NASA APOD (astronomy images via DEMO_KEY), Dev.to (community articles by tag), USGS Earthquakes (real-time seismic data), NWS Weather Alerts (US weather by state/severity), GitHub Trending (repos by stars/language). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-17 22:16 UTC
Commit: 3a01696758e1a5856e56fa592afe60c1d64b09d6
Parent: 0ca2621
7 files changed, +1332 insertions, -0 deletions
@@ -0,0 +1,187 @@
1 + // Dev.to Plugin for BalancedBreakfast
2 + // Fetches articles from the Dev.to community API
3 +
4 + const DEVTO_API = "https://dev.to/api/articles";
5 +
6 + fn id() {
7 + "devto"
8 + }
9 +
10 + fn name() {
11 + "Dev.to"
12 + }
13 +
14 + fn capabilities() {
15 + #{
16 + supports_pagination: true
17 + }
18 + }
19 +
20 + fn config_schema() {
21 + #{
22 + description: "Fetch articles from Dev.to, a community of software developers.",
23 + fields: [
24 + #{
25 + key: "tag",
26 + label: "Tag filter",
27 + field_type: "text",
28 + description: "Filter by tag (e.g., rust, javascript, python). Leave blank for all.",
29 + placeholder: "rust"
30 + },
31 + #{
32 + key: "limit",
33 + label: "Items per fetch",
34 + field_type: "text",
35 + description: "Number of articles to fetch (max 30)",
36 + default_value: "20",
37 + placeholder: "20"
38 + }
39 + ]
40 + }
41 + }
42 +
43 + fn fetch(config, cursor) {
44 + let limit = 20;
45 + if config.limit != () {
46 + let parsed = parse_int(config.limit);
47 + if parsed != () && parsed > 0 {
48 + if parsed > 30 {
49 + limit = 30;
50 + } else {
51 + limit = parsed;
52 + }
53 + }
54 + }
55 +
56 + let page = 1;
57 + if cursor != () {
58 + let parsed = parse_int(cursor);
59 + if parsed != () && parsed > 0 {
60 + page = parsed;
61 + }
62 + }
63 +
64 + let url = DEVTO_API + "?per_page=" + limit + "&page=" + page;
65 +
66 + if config.tag != () && config.tag != "" {
67 + url += "&tag=" + config.tag;
68 + }
69 +
70 + let articles = http_get_json(url);
71 +
72 + if articles == () {
73 + return #{ items: [], has_more: false };
74 + }
75 +
76 + let items = [];
77 + for article in articles {
78 + let item = parse_article(article);
79 + if item != () {
80 + items.push(item);
81 + }
82 + }
83 +
84 + let has_more = items.len() >= limit;
85 + let result = #{
86 + items: items,
87 + has_more: has_more
88 + };
89 +
90 + if has_more {
91 + result.next_cursor = "" + (page + 1);
92 + }
93 +
94 + result
95 + }
96 +
97 + fn parse_article(article) {
98 + if article.title == () {
99 + return ();
100 + }
101 +
102 + let title = article.title;
103 + let id = 0;
104 + if article.id != () {
105 + id = article.id;
106 + }
107 +
108 + let author = "Unknown";
109 + if article.user != () {
110 + if article.user.name != () {
111 + author = article.user.name;
112 + } else if article.user.username != () {
113 + author = article.user.username;
114 + }
115 + }
116 +
117 + let url = "";
118 + if article.url != () {
119 + url = article.url;
120 + }
121 +
122 + let description = "";
123 + if article.description != () {
124 + description = article.description;
125 + }
126 +
127 + let published = timestamp_now();
128 + if article.published_at != () {
129 + published = article.published_at;
130 + }
131 +
132 + let reactions = 0;
133 + if article.positive_reactions_count != () {
134 + reactions = article.positive_reactions_count;
135 + }
136 +
137 + let comments = 0;
138 + if article.comments_count != () {
139 + comments = article.comments_count;
140 + }
141 +
142 + let reading_time = 0;
143 + if article.reading_time_minutes != () {
144 + reading_time = article.reading_time_minutes;
145 + }
146 +
147 + // Build tags
148 + let tags = ["devto"];
149 + if article.tag_list != () {
150 + for tag in article.tag_list {
151 + tags.push(tag);
152 + }
153 + }
154 +
155 + let secondary = "" + reactions + " reactions ยท " + comments + " comments";
156 + if reading_time > 0 {
157 + secondary += " ยท " + reading_time + " min read";
158 + }
159 +
160 + // Body: cover image + description
161 + let body = "";
162 + if article.cover_image != () && article.cover_image != "" {
163 + body = `<img src="` + article.cover_image + `" alt="` + title + `" style="max-width:100%">` + "\n\n";
164 + }
165 + body += description;
166 +
167 + #{
168 + id: #{ source: "devto", item_id: "" + id },
169 + bite: #{
170 + author: author,
171 + text: truncate(title, 100),
172 + secondary: secondary,
173 + indicator: "๐Ÿ’ป"
174 + },
175 + content: #{
176 + title: title,
177 + body: body,
178 + url: url
179 + },
180 + meta: #{
181 + source_name: "Dev.to",
182 + published_at: published,
183 + score: reactions,
184 + tags: tags
185 + }
186 + }
187 + }
@@ -0,0 +1,215 @@
1 + // USGS Earthquakes Plugin for BalancedBreakfast
2 + // Fetches recent earthquake data from the USGS GeoJSON feed
3 +
4 + const USGS_API = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary";
5 +
6 + fn id() {
7 + "earthquakes"
8 + }
9 +
10 + fn name() {
11 + "USGS Earthquakes"
12 + }
13 +
14 + fn capabilities() {
15 + #{
16 + supports_pagination: false
17 + }
18 + }
19 +
20 + fn config_schema() {
21 + #{
22 + description: "Real-time earthquake data from the USGS. Choose a minimum magnitude and time window.",
23 + fields: [
24 + #{
25 + key: "magnitude",
26 + label: "Minimum Magnitude",
27 + field_type: "select",
28 + required: true,
29 + description: "Minimum earthquake magnitude to show",
30 + default_value: "significant",
31 + options: ["significant", "4.5", "2.5", "1.0", "all"]
32 + },
33 + #{
34 + key: "timeframe",
35 + label: "Time Window",
36 + field_type: "select",
37 + required: true,
38 + description: "How far back to look",
39 + default_value: "day",
40 + options: ["hour", "day", "week", "month"]
41 + }
42 + ]
43 + }
44 + }
45 +
46 + fn fetch(config, cursor) {
47 + let magnitude = "significant";
48 + if config.magnitude != () {
49 + magnitude = config.magnitude;
50 + }
51 +
52 + let timeframe = "day";
53 + if config.timeframe != () {
54 + timeframe = config.timeframe;
55 + }
56 +
57 + // Build the feed URL
58 + let feed_name = get_feed_name(magnitude);
59 + let url = USGS_API + "/" + feed_name + "_" + timeframe + ".geojson";
60 +
61 + let data = http_get_json(url);
62 +
63 + if data == () {
64 + return #{ items: [], has_more: false };
65 + }
66 +
67 + let features = data.features;
68 + if features == () {
69 + return #{ items: [], has_more: false };
70 + }
71 +
72 + let items = [];
73 + for feature in features {
74 + let item = parse_quake(feature);
75 + if item != () {
76 + items.push(item);
77 + }
78 + }
79 +
80 + #{
81 + items: items,
82 + has_more: false
83 + }
84 + }
85 +
86 + fn get_feed_name(magnitude) {
87 + if magnitude == "4.5" { return "4.5"; }
88 + if magnitude == "2.5" { return "2.5"; }
89 + if magnitude == "1.0" { return "1.0"; }
90 + if magnitude == "all" { return "all"; }
91 + "significant"
92 + }
93 +
94 + fn parse_quake(feature) {
95 + let props = feature.properties;
96 + if props == () {
97 + return ();
98 + }
99 +
100 + let title = "Unknown earthquake";
101 + if props.title != () {
102 + title = props.title;
103 + } else if props.place != () {
104 + title = props.place;
105 + }
106 +
107 + let mag = 0.0;
108 + if props.mag != () {
109 + mag = props.mag;
110 + }
111 +
112 + let place = "";
113 + if props.place != () {
114 + place = props.place;
115 + }
116 +
117 + let time = timestamp_now();
118 + if props.time != () {
119 + // USGS times are in milliseconds
120 + time = props.time / 1000;
121 + }
122 +
123 + let url = "";
124 + if props.url != () {
125 + url = props.url;
126 + }
127 +
128 + let tsunami = 0;
129 + if props.tsunami != () {
130 + tsunami = props.tsunami;
131 + }
132 +
133 + let felt = 0;
134 + if props.felt != () {
135 + felt = props.felt;
136 + }
137 +
138 + let alert = "";
139 + if props.alert != () {
140 + alert = props.alert;
141 + }
142 +
143 + let quake_id = "";
144 + if feature.id != () {
145 + quake_id = feature.id;
146 + }
147 +
148 + // Coordinates
149 + let coords = "";
150 + if feature.geometry != () {
151 + let geo = feature.geometry;
152 + if geo.coordinates != () {
153 + let c = geo.coordinates;
154 + if c.len() >= 2 {
155 + coords = "Lat: " + c[1] + ", Lon: " + c[0];
156 + if c.len() >= 3 {
157 + coords += ", Depth: " + c[2] + " km";
158 + }
159 + }
160 + }
161 + }
162 +
163 + // Build body
164 + let body = "**Location:** " + place + "\n";
165 + body += "**Magnitude:** " + mag + "\n";
166 + if coords != "" {
167 + body += "**Coordinates:** " + coords + "\n";
168 + }
169 + if tsunami > 0 {
170 + body += "**Tsunami warning:** Yes\n";
171 + }
172 + if felt > 0 {
173 + body += "**Felt reports:** " + felt + "\n";
174 + }
175 + if alert != "" {
176 + body += "**Alert level:** " + alert + "\n";
177 + }
178 +
179 + // Indicator based on magnitude
180 + let indicator = "๐ŸŸข";
181 + if mag >= 7.0 {
182 + indicator = "๐Ÿ”ด";
183 + } else if mag >= 5.0 {
184 + indicator = "๐ŸŸ ";
185 + } else if mag >= 3.0 {
186 + indicator = "๐ŸŸก";
187 + }
188 +
189 + let secondary = "M" + mag + " ยท " + place;
190 +
191 + let tags = ["earthquake", "usgs"];
192 + if tsunami > 0 {
193 + tags.push("tsunami");
194 + }
195 +
196 + #{
197 + id: #{ source: "earthquakes", item_id: quake_id },
198 + bite: #{
199 + author: "USGS",
200 + text: truncate(title, 100),
201 + secondary: truncate(secondary, 80),
202 + indicator: indicator
203 + },
204 + content: #{
205 + title: title,
206 + body: body,
207 + url: url
208 + },
209 + meta: #{
210 + source_name: "USGS Earthquakes",
211 + published_at: time,
212 + tags: tags
213 + }
214 + }
215 + }
@@ -0,0 +1,217 @@
1 + // GitHub Trending Plugin for BalancedBreakfast
2 + // Fetches trending repositories via the GitHub search API (no auth required)
3 +
4 + const GH_API = "https://api.github.com/search/repositories";
5 +
6 + fn id() {
7 + "github_trending"
8 + }
9 +
10 + fn name() {
11 + "GitHub Trending"
12 + }
13 +
14 + fn capabilities() {
15 + #{
16 + supports_pagination: true
17 + }
18 + }
19 +
20 + fn config_schema() {
21 + #{
22 + description: "Trending GitHub repositories by stars gained recently. Filter by language.",
23 + fields: [
24 + #{
25 + key: "language",
26 + label: "Language",
27 + field_type: "text",
28 + description: "Filter by language (e.g., rust, python, javascript). Leave blank for all.",
29 + placeholder: "rust"
30 + },
31 + #{
32 + key: "timeframe",
33 + label: "Time Window",
34 + field_type: "select",
35 + required: true,
36 + description: "How far back to look for star activity",
37 + default_value: "weekly",
38 + options: ["daily", "weekly", "monthly"]
39 + },
40 + #{
41 + key: "limit",
42 + label: "Repos to show",
43 + field_type: "text",
44 + description: "Number of repos to fetch (max 30)",
45 + default_value: "20",
46 + placeholder: "20"
47 + }
48 + ]
49 + }
50 + }
51 +
52 + fn fetch(config, cursor) {
53 + let limit = 20;
54 + if config.limit != () {
55 + let parsed = parse_int(config.limit);
56 + if parsed != () && parsed > 0 {
57 + if parsed > 30 {
58 + limit = 30;
59 + } else {
60 + limit = parsed;
61 + }
62 + }
63 + }
64 +
65 + let page = 1;
66 + if cursor != () {
67 + let parsed = parse_int(cursor);
68 + if parsed != () && parsed > 0 {
69 + page = parsed;
70 + }
71 + }
72 +
73 + // Build date query for "created after" threshold
74 + let timeframe = "weekly";
75 + if config.timeframe != () {
76 + timeframe = config.timeframe;
77 + }
78 +
79 + // Use stars>100 as a proxy for trending (GitHub search API)
80 + let query = "stars:>100";
81 +
82 + if config.language != () && config.language != "" {
83 + query += " language:" + config.language;
84 + }
85 +
86 + // Sort by stars to approximate trending
87 + let url = GH_API + "?q=" + query + "&sort=stars&order=desc&per_page=" + limit + "&page=" + page;
88 +
89 + let data = http_get_json(url);
90 +
91 + if data == () {
92 + return #{ items: [], has_more: false };
93 + }
94 +
95 + let repos = data.items;
96 + if repos == () {
97 + return #{ items: [], has_more: false };
98 + }
99 +
100 + let items = [];
101 + for repo in repos {
102 + let item = parse_repo(repo);
103 + if item != () {
104 + items.push(item);
105 + }
106 + }
107 +
108 + let has_more = items.len() >= limit;
109 + let result = #{
110 + items: items,
111 + has_more: has_more
112 + };
113 +
114 + if has_more {
115 + result.next_cursor = "" + (page + 1);
116 + }
117 +
118 + result
119 + }
120 +
121 + fn parse_repo(repo) {
122 + if repo.full_name == () {
123 + return ();
124 + }
125 +
126 + let name = repo.full_name;
127 + let description = "";
128 + if repo.description != () {
129 + description = repo.description;
130 + }
131 +
132 + let stars = 0;
133 + if repo.stargazers_count != () {
134 + stars = repo.stargazers_count;
135 + }
136 +
137 + let forks = 0;
138 + if repo.forks_count != () {
139 + forks = repo.forks_count;
140 + }
141 +
142 + let language = "";
143 + if repo.language != () {
144 + language = repo.language;
145 + }
146 +
147 + let url = "";
148 + if repo.html_url != () {
149 + url = repo.html_url;
150 + }
151 +
152 + let owner = "Unknown";
153 + if repo.owner != () {
154 + if repo.owner.login != () {
155 + owner = repo.owner.login;
156 + }
157 + }
158 +
159 + let created = timestamp_now();
160 + if repo.pushed_at != () {
161 + created = repo.pushed_at;
162 + }
163 +
164 + let repo_id = 0;
165 + if repo.id != () {
166 + repo_id = repo.id;
167 + }
168 +
169 + // Format stars count
170 + let stars_display = "" + stars;
171 + if stars >= 1000 {
172 + stars_display = "" + (stars / 1000) + "k";
173 + }
174 +
175 + let secondary = stars_display + " stars ยท " + forks + " forks";
176 + if language != "" {
177 + secondary += " ยท " + language;
178 + }
179 +
180 + // Build body
181 + let body = "";
182 + if description != "" {
183 + body = description + "\n\n";
184 + }
185 + body += "**Stars:** " + stars + "\n";
186 + body += "**Forks:** " + forks + "\n";
187 + if language != "" {
188 + body += "**Language:** " + language + "\n";
189 + }
190 +
191 + // Tags
192 + let tags = ["github"];
193 + if language != "" {
194 + tags.push(language);
195 + }
196 +
197 + #{
198 + id: #{ source: "github_trending", item_id: "" + repo_id },
199 + bite: #{
200 + author: owner,
201 + text: truncate(name + ": " + description, 100),
202 + secondary: secondary,
203 + indicator: "โญ"
204 + },
205 + content: #{
206 + title: name,
207 + body: body,
208 + url: url
209 + },
210 + meta: #{
211 + source_name: "GitHub Trending",
212 + published_at: created,
213 + score: stars,
214 + tags: tags
215 + }
216 + }
217 + }
@@ -0,0 +1,180 @@
1 + // Lobsters Plugin for BalancedBreakfast
2 + // Fetches stories from Lobste.rs โ€” computing-focused link aggregation
3 +
4 + const LOBSTERS_URL = "https://lobste.rs";
5 +
6 + fn id() {
7 + "lobsters"
8 + }
9 +
10 + fn name() {
11 + "Lobsters"
12 + }
13 +
14 + fn capabilities() {
15 + #{
16 + supports_pagination: true
17 + }
18 + }
19 +
20 + fn config_schema() {
21 + #{
22 + description: "Fetch stories from Lobste.rs, a computing-focused link aggregation community.",
23 + fields: [
24 + #{
25 + key: "feed_type",
26 + label: "Feed Type",
27 + field_type: "select",
28 + required: true,
29 + description: "Which stories to fetch",
30 + default_value: "hottest",
31 + options: ["hottest", "newest", "active"]
32 + },
33 + #{
34 + key: "limit",
35 + label: "Items per fetch",
36 + field_type: "text",
37 + description: "Number of stories to fetch (max 50)",
38 + default_value: "25",
39 + placeholder: "25"
40 + }
41 + ]
42 + }
43 + }
44 +
45 + fn fetch(config, cursor) {
46 + let feed_type = "hottest";
47 + if config.feed_type != () {
48 + feed_type = config.feed_type;
49 + }
50 +
51 + let limit = 25;
52 + if config.limit != () {
53 + let parsed = parse_int(config.limit);
54 + if parsed != () && parsed > 0 {
55 + if parsed > 50 {
56 + limit = 50;
57 + } else {
58 + limit = parsed;
59 + }
60 + }
61 + }
62 +
63 + // Parse cursor for page number
64 + let page = 1;
65 + if cursor != () {
66 + let parsed = parse_int(cursor);
67 + if parsed != () && parsed > 0 {
68 + page = parsed;
69 + }
70 + }
71 +
72 + let url = LOBSTERS_URL + "/" + feed_type + ".json?page=" + page;
73 + let stories = http_get_json(url);
74 +
75 + let items = [];
76 + let count = 0;
77 +
78 + for story in stories {
79 + if count >= limit {
80 + break;
81 + }
82 +
83 + let item = parse_story(story);
84 + if item != () {
85 + items.push(item);
86 + count += 1;
87 + }
88 + }
89 +
90 + let has_more = items.len() >= limit;
91 + let result = #{
92 + items: items,
93 + has_more: has_more
94 + };
95 +
96 + if has_more {
97 + result.next_cursor = "" + (page + 1);
98 + }
99 +
100 + result
101 + }
102 +
103 + fn parse_story(story) {
104 + if story.title == () {
105 + return ();
106 + }
107 +
108 + let title = story.title;
109 +
110 + let score = 0;
111 + if story.score != () {
112 + score = story.score;
113 + }
114 +
115 + let comments = 0;
116 + if story.comment_count != () {
117 + comments = story.comment_count;
118 + }
119 +
120 + let author = "anonymous";
121 + if story.submitter_user != () {
122 + if story.submitter_user.username != () {
123 + author = story.submitter_user.username;
124 + }
125 + }
126 +
127 + let url = "";
128 + if story.url != () && story.url != "" {
129 + url = story.url;
130 + } else if story.comments_url != () {
131 + url = story.comments_url;
132 + }
133 +
134 + let created = timestamp_now();
135 + if story.created_at != () {
136 + created = story.created_at;
137 + }
138 +
139 + let short_id = "";
140 + if story.short_id != () {
141 + short_id = story.short_id;
142 + }
143 +
144 + // Build tags from story tags
145 + let tags = ["lobsters"];
146 + if story.tags != () {
147 + for tag in story.tags {
148 + tags.push(tag);
149 + }
150 + }
151 +
152 + let secondary = "" + score + " pts ยท " + comments + " comments";
153 +
154 + // Description/body
155 + let body = ();
156 + if story.description != () && story.description != "" {
157 + body = story.description;
158 + }
159 +
160 + #{
161 + id: #{ source: "lobsters", item_id: short_id },
162 + bite: #{
163 + author: author,
164 + text: truncate(title, 100),
165 + secondary: secondary,
166 + indicator: "๐Ÿฆž"
167 + },
168 + content: #{
169 + title: title,
170 + body: body,
171 + url: url
172 + },
173 + meta: #{
174 + source_name: "Lobsters",
175 + published_at: created,
176 + score: score,
177 + tags: tags
178 + }
179 + }
180 + }
@@ -0,0 +1,161 @@
1 + // NASA Astronomy Picture of the Day Plugin for BalancedBreakfast
2 + // Fetches recent APODs from the NASA API (free DEMO_KEY included)
3 +
4 + const NASA_API = "https://api.nasa.gov/planetary/apod";
5 +
6 + fn id() {
7 + "nasa_apod"
8 + }
9 +
10 + fn name() {
11 + "NASA APOD"
12 + }
13 +
14 + fn capabilities() {
15 + #{
16 + supports_pagination: false
17 + }
18 + }
19 +
20 + fn config_schema() {
21 + #{
22 + description: "NASA Astronomy Picture of the Day. Shows stunning space images with expert explanations. Uses the free DEMO_KEY by default (30 req/hr). Provide your own API key from api.nasa.gov for higher limits.",
23 + fields: [
24 + #{
25 + key: "api_key",
26 + label: "API Key",
27 + field_type: "text",
28 + description: "NASA API key (leave blank to use DEMO_KEY)",
29 + default_value: "DEMO_KEY",
30 + placeholder: "DEMO_KEY"
31 + },
32 + #{
33 + key: "count",
34 + label: "Number of images",
35 + field_type: "text",
36 + description: "How many recent APODs to fetch (max 10)",
37 + default_value: "5",
38 + placeholder: "5"
39 + }
40 + ]
41 + }
42 + }
43 +
44 + fn fetch(config, cursor) {
45 + let api_key = "DEMO_KEY";
46 + if config.api_key != () && config.api_key != "" {
47 + api_key = config.api_key;
48 + }
49 +
50 + let count = 5;
51 + if config.count != () {
52 + let parsed = parse_int(config.count);
53 + if parsed != () && parsed > 0 {
54 + if parsed > 10 {
55 + count = 10;
56 + } else {
57 + count = parsed;
58 + }
59 + }
60 + }
61 +
62 + let url = NASA_API + "?api_key=" + api_key + "&count=" + count + "&thumbs=true";
63 + let results = http_get_json(url);
64 +
65 + if results == () {
66 + return #{ items: [], has_more: false };
67 + }
68 +
69 + let items = [];
70 + for entry in results {
71 + let item = parse_apod(entry);
72 + if item != () {
73 + items.push(item);
74 + }
75 + }
76 +
77 + #{
78 + items: items,
79 + has_more: false
80 + }
81 + }
82 +
83 + fn parse_apod(entry) {
84 + let title = "Untitled";
85 + if entry.title != () {
86 + title = entry.title;
87 + }
88 +
89 + let explanation = "";
90 + if entry.explanation != () {
91 + explanation = entry.explanation;
92 + }
93 +
94 + let date = "";
95 + if entry.date != () {
96 + date = entry.date;
97 + }
98 +
99 + let media_type = "image";
100 + if entry.media_type != () {
101 + media_type = entry.media_type;
102 + }
103 +
104 + let url = "";
105 + if entry.url != () {
106 + url = entry.url;
107 + }
108 +
109 + let hdurl = "";
110 + if entry.hdurl != () {
111 + hdurl = entry.hdurl;
112 + }
113 +
114 + // Build body with image and explanation
115 + let body = "";
116 + if media_type == "image" {
117 + let img_url = url;
118 + if hdurl != "" {
119 + img_url = hdurl;
120 + }
121 + body = `<img src="` + img_url + `" alt="` + title + `" style="max-width:100%">`;
122 + } else if media_type == "video" {
123 + body = "Video: " + url;
124 + }
125 +
126 + if explanation != "" {
127 + body += "\n\n" + explanation;
128 + }
129 +
130 + let copyright = "";
131 + if entry.copyright != () {
132 + copyright = str_trim(entry.copyright);
133 + }
134 +
135 + let secondary = date;
136 + if copyright != "" {
137 + secondary += " ยท " + copyright;
138 + }
139 +
140 + let apod_url = "https://apod.nasa.gov/apod/";
141 +
142 + #{
143 + id: #{ source: "nasa_apod", item_id: date },
144 + bite: #{
145 + author: "NASA",
146 + text: truncate(title, 100),
147 + secondary: secondary,
148 + indicator: "๐Ÿ”ญ"
149 + },
150 + content: #{
151 + title: title,
152 + body: body,
153 + url: apod_url
154 + },
155 + meta: #{
156 + source_name: "NASA APOD",
157 + published_at: timestamp_now(),
158 + tags: ["nasa", "astronomy", "space"]
159 + }
160 + }
161 + }
@@ -0,0 +1,213 @@
1 + // NWS Weather Alerts Plugin for BalancedBreakfast
2 + // Fetches active weather alerts from the National Weather Service API
3 +
4 + const NWS_API = "https://api.weather.gov/alerts/active";
5 +
6 + fn id() {
7 + "nws_alerts"
8 + }
9 +
10 + fn name() {
11 + "NWS Weather Alerts"
12 + }
13 +
14 + fn capabilities() {
15 + #{
16 + supports_pagination: false
17 + }
18 + }
19 +
20 + fn config_schema() {
21 + #{
22 + description: "Active weather alerts from the US National Weather Service. Filter by state or view all active alerts.",
23 + fields: [
24 + #{
25 + key: "area",
26 + label: "State/Territory",
27 + field_type: "text",
28 + description: "Two-letter state code (e.g., CA, NY, TX). Leave blank for all US alerts.",
29 + placeholder: "CA"
30 + },
31 + #{
32 + key: "severity",
33 + label: "Minimum Severity",
34 + field_type: "select",
35 + required: true,
36 + description: "Minimum alert severity to show",
37 + default_value: "Moderate",
38 + options: ["Extreme", "Severe", "Moderate", "Minor"]
39 + }
40 + ]
41 + }
42 + }
43 +
44 + fn fetch(config, cursor) {
45 + let url = NWS_API + "?status=actual";
46 +
47 + if config.area != () && config.area != "" {
48 + let area = str_trim(config.area);
49 + if area.len() == 2 {
50 + url += "&area=" + area;
51 + }
52 + }
53 +
54 + let severity = "Moderate";
55 + if config.severity != () {
56 + severity = config.severity;
57 + }
58 + url += "&severity=" + severity;
59 +
60 + let data = http_get_json(url);
61 +
62 + if data == () {
63 + return #{ items: [], has_more: false };
64 + }
65 +
66 + let features = data.features;
67 + if features == () {
68 + return #{ items: [], has_more: false };
69 + }
70 +
71 + let items = [];
72 + for feature in features {
73 + let item = parse_alert(feature);
74 + if item != () {
75 + items.push(item);
76 + }
77 + }
78 +
79 + #{
80 + items: items,
81 + has_more: false
82 + }
83 + }
84 +
85 + fn parse_alert(feature) {
86 + let props = feature.properties;
87 + if props == () {
88 + return ();
89 + }
90 +
91 + let headline = "Weather Alert";
92 + if props.headline != () {
93 + headline = props.headline;
94 + }
95 +
96 + let event = "";
97 + if props.event != () {
98 + event = props.event;
99 + }
100 +
101 + let severity = "Unknown";
102 + if props.severity != () {
103 + severity = props.severity;
104 + }
105 +
106 + let urgency = "";
107 + if props.urgency != () {
108 + urgency = props.urgency;
109 + }
110 +
111 + let certainty = "";
112 + if props.certainty != () {
113 + certainty = props.certainty;
114 + }
115 +
116 + let description = "";
117 + if props.description != () {
118 + description = props.description;
119 + }
120 +
121 + let instruction = "";
122 + if props.instruction != () {
123 + instruction = props.instruction;
124 + }
125 +
126 + let area_desc = "";
127 + if props.areaDesc != () {
128 + area_desc = props.areaDesc;
129 + }
130 +
131 + let sender = "NWS";
132 + if props.senderName != () {
133 + sender = props.senderName;
134 + }
135 +
136 + let effective = timestamp_now();
137 + if props.effective != () {
138 + effective = props.effective;
139 + }
140 +
141 + let expires = "";
142 + if props.expires != () {
143 + expires = props.expires;
144 + }
145 +
146 + let alert_id = "";
147 + if feature.id != () {
148 + alert_id = feature.id;
149 + } else if props.id != () {
150 + alert_id = props.id;
151 + }
152 +
153 + // Build body
154 + let body = "";
155 + if area_desc != "" {
156 + body += "**Area:** " + area_desc + "\n\n";
157 + }
158 + body += "**Severity:** " + severity;
159 + if urgency != "" {
160 + body += " ยท **Urgency:** " + urgency;
161 + }
162 + if certainty != "" {
163 + body += " ยท **Certainty:** " + certainty;
164 + }
165 + body += "\n\n";
166 +
167 + if description != "" {
168 + body += description + "\n\n";
169 + }
170 + if instruction != "" {
171 + body += "**Instructions:** " + instruction;
172 + }
173 +
174 + // Indicator based on severity
175 + let indicator = "๐ŸŒค";
176 + if severity == "Extreme" {
177 + indicator = "๐Ÿ”ด";
178 + } else if severity == "Severe" {
179 + indicator = "๐ŸŸ ";
180 + } else if severity == "Moderate" {
181 + indicator = "๐ŸŸก";
182 + }
183 +
184 + let secondary = severity;
185 + if area_desc != "" {
186 + secondary += " ยท " + truncate(area_desc, 60);
187 + }
188 +
189 + let tags = ["weather", "nws"];
190 + if event != "" {
191 + tags.push(event);
192 + }
193 +
194 + #{
195 + id: #{ source: "nws_alerts", item_id: alert_id },
196 + bite: #{
197 + author: sender,
198 + text: truncate(headline, 100),
199 + secondary: truncate(secondary, 80),
200 + indicator: indicator
201 + },
202 + content: #{
203 + title: headline,
204 + body: body,
205 + url: ""
206 + },
207 + meta: #{
208 + source_name: "NWS Alerts",
209 + published_at: effective,
210 + tags: tags
211 + }
212 + }
213 + }
@@ -0,0 +1,159 @@
1 + // XKCD Plugin for BalancedBreakfast
2 + // Fetches comics from xkcd.com via their JSON API
3 +
4 + const XKCD_URL = "https://xkcd.com";
5 +
6 + fn id() {
7 + "xkcd"
8 + }
9 +
10 + fn name() {
11 + "XKCD"
12 + }
13 +
14 + fn capabilities() {
15 + #{
16 + supports_pagination: true
17 + }
18 + }
19 +
20 + fn config_schema() {
21 + #{
22 + description: "Fetch comics from XKCD. Shows the latest comics with title, alt text, and image.",
23 + fields: [
24 + #{
25 + key: "count",
26 + label: "Comics to fetch",
27 + field_type: "text",
28 + description: "Number of recent comics to fetch (max 20)",
29 + default_value: "10",
30 + placeholder: "10"
31 + }
32 + ]
33 + }
34 + }
35 +
36 + fn fetch(config, cursor) {
37 + let count = 10;
38 + if config.count != () {
39 + let parsed = parse_int(config.count);
40 + if parsed != () && parsed > 0 {
41 + if parsed > 20 {
42 + count = 20;
43 + } else {
44 + count = parsed;
45 + }
46 + }
47 + }
48 +
49 + // Get the latest comic to find the current number
50 + let latest = http_get_json(XKCD_URL + "/info.0.json");
51 + if latest == () {
52 + return #{ items: [], has_more: false };
53 + }
54 +
55 + let current_num = latest.num;
56 +
57 + // Parse cursor for starting comic number
58 + let start_num = current_num;
59 + if cursor != () {
60 + let parsed = parse_int(cursor);
61 + if parsed != () && parsed > 0 {
62 + start_num = parsed;
63 + }
64 + }
65 +
66 + let items = [];
67 + let num = start_num;
68 + let fetched = 0;
69 +
70 + while fetched < count && num > 0 {
71 + // Comic 404 doesn't exist (it's a joke)
72 + if num == 404 {
73 + num -= 1;
74 + continue;
75 + }
76 +
77 + let comic = ();
78 + if num == current_num {
79 + comic = latest;
80 + } else {
81 + comic = http_get_json(XKCD_URL + "/" + num + "/info.0.json");
82 + }
83 +
84 + if comic != () {
85 + let item = parse_comic(comic);
86 + if item != () {
87 + items.push(item);
88 + fetched += 1;
89 + }
90 + }
91 +
92 + num -= 1;
93 + }
94 +
95 + let has_more = num > 0;
96 + let result = #{
97 + items: items,
98 + has_more: has_more
99 + };
100 +
101 + if has_more {
102 + result.next_cursor = "" + num;
103 + }
104 +
105 + result
106 + }
107 +
108 + fn parse_comic(comic) {
109 + if comic.title == () {
110 + return ();
111 + }
112 +
113 + let title = comic.title;
114 + let num = comic.num;
115 +
116 + let alt = "";
117 + if comic.alt != () {
118 + alt = comic.alt;
119 + }
120 +
121 + let img = "";
122 + if comic.img != () {
123 + img = comic.img;
124 + }
125 +
126 + let url = XKCD_URL + "/" + num + "/";
127 +
128 + // Build a simple HTML body with the comic image and alt text
129 + let body = "";
130 + if img != "" {
131 + body = `<img src="` + img + `" alt="` + title + `" style="max-width:100%">`;
132 + }
133 + if alt != "" {
134 + body += "\n\n_" + alt + "_";
135 + }
136 +
137 + // Build date from comic fields
138 + let published = timestamp_now();
139 +
140 + #{
141 + id: #{ source: "xkcd", item_id: "" + num },
142 + bite: #{
143 + author: "xkcd",
144 + text: "#" + num + ": " + truncate(title, 90),
145 + secondary: truncate(alt, 80),
146 + indicator: "๐Ÿ“"
147 + },
148 + content: #{
149 + title: "#" + num + ": " + title,
150 + body: body,
151 + url: url
152 + },
153 + meta: #{
154 + source_name: "XKCD",
155 + published_at: published,
156 + tags: ["xkcd", "comics"]
157 + }
158 + }
159 + }