Skip to content

Commit 1645f60

Browse files
authored
Merge pull request #1010 from devoxx/feature/welcome-blog-feed
feat(welcome): blog feed on welcome screen + friendlier analytics copy
2 parents b96ce81 + 38b12e2 commit 1645f60

9 files changed

Lines changed: 504 additions & 32 deletions

File tree

build.gradle.kts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,92 @@ tasks.named("buildPlugin") {
121121
dependsOn("updateProperties")
122122
}
123123

124+
val generatedBlogResourcesDir = layout.buildDirectory.dir("generated-resources/blog")
125+
126+
sourceSets.named("main") {
127+
resources.srcDir(generatedBlogResourcesDir)
128+
}
129+
130+
tasks.register("generateBlogIndex") {
131+
group = "build"
132+
description = "Generates blog-posts.json from docusaurus/blog/*.md frontmatter"
133+
134+
val blogDir = file("docusaurus/blog")
135+
val outputFile = generatedBlogResourcesDir.get().file("devoxxgenie/blog-posts.json").asFile
136+
137+
inputs.dir(blogDir)
138+
outputs.file(outputFile)
139+
140+
doLast {
141+
if (!blogDir.exists()) {
142+
logger.warn("Blog directory not found: $blogDir — writing empty index")
143+
outputFile.parentFile.mkdirs()
144+
outputFile.writeText("[]")
145+
return@doLast
146+
}
147+
148+
// Very small YAML frontmatter parser — sufficient for our blog posts
149+
fun parseFrontmatter(file: File): Map<String, String>? {
150+
val lines = file.readLines()
151+
if (lines.isEmpty() || lines[0].trim() != "---") return null
152+
val end = lines.drop(1).indexOfFirst { it.trim() == "---" }
153+
if (end < 0) return null
154+
val map = mutableMapOf<String, String>()
155+
for (line in lines.subList(1, end + 1)) {
156+
val idx = line.indexOf(':')
157+
if (idx <= 0) continue
158+
val key = line.substring(0, idx).trim()
159+
var value = line.substring(idx + 1).trim()
160+
// Strip surrounding quotes
161+
if ((value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) ||
162+
(value.startsWith("'") && value.endsWith("'") && value.length >= 2)
163+
) {
164+
value = value.substring(1, value.length - 1)
165+
}
166+
map[key] = value
167+
}
168+
return map
169+
}
170+
171+
fun jsonEscape(s: String): String = buildString {
172+
for (c in s) when (c) {
173+
'\\' -> append("\\\\")
174+
'"' -> append("\\\"")
175+
'\n' -> append("\\n")
176+
'\r' -> append("\\r")
177+
'\t' -> append("\\t")
178+
else -> if (c.code < 0x20) append("\\u%04x".format(c.code)) else append(c)
179+
}
180+
}
181+
182+
data class Entry(val slug: String, val title: String, val date: String, val description: String)
183+
184+
val entries = blogDir.listFiles { f -> f.isFile && f.name.endsWith(".md") }
185+
?.mapNotNull { f ->
186+
val fm = parseFrontmatter(f) ?: return@mapNotNull null
187+
val slug = fm["slug"] ?: return@mapNotNull null
188+
val title = fm["title"] ?: return@mapNotNull null
189+
// Date: prefer explicit `date:` field, fall back to filename prefix yyyy-MM-dd
190+
val date = fm["date"] ?: f.name.take(10)
191+
val description = fm["description"] ?: ""
192+
Entry(slug, title, date, description)
193+
}
194+
?.sortedByDescending { it.date }
195+
?: emptyList()
196+
197+
outputFile.parentFile.mkdirs()
198+
val json = entries.joinToString(prefix = "[\n", postfix = "\n]", separator = ",\n") { e ->
199+
""" {"slug":"${jsonEscape(e.slug)}","title":"${jsonEscape(e.title)}","date":"${jsonEscape(e.date)}","description":"${jsonEscape(e.description)}"}"""
200+
}
201+
outputFile.writeText(json)
202+
logger.lifecycle("Generated ${entries.size} blog entries → $outputFile")
203+
}
204+
}
205+
206+
tasks.named("processResources") {
207+
dependsOn("generateBlogIndex")
208+
}
209+
124210
dependencies {
125211
intellijPlatform {
126212
// Allow overriding IDE version via property: ./gradlew runIde -PideVersion=2025.1.7

src/main/java/com/devoxx/genie/service/analytics/AnalyticsConsentNotifier.java

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,23 @@ public final class AnalyticsConsentNotifier {
2222

2323
private static final String NOTIFICATION_GROUP_ID = "com.devoxx.genie.notifications";
2424

25-
private static final String TITLE = "DevoxxGenie usage analytics";
25+
private static final String TITLE = "Help shape DevoxxGenie";
26+
27+
private static final String ANALYTICS_SOURCE_URL =
28+
"https://github.com/devoxx/DevoxxGenieIDEAPlugin/blob/master/" +
29+
"src/main/java/com/devoxx/genie/service/analytics/AnalyticsEventBuilder.java";
2630

2731
private static final String CONTENT =
28-
"<html>To guide which features and LLM providers we invest engineering effort in, " +
29-
"DevoxxGenie collects <b>anonymous</b> usage data when you run a prompt or change models:" +
30-
"<ul>" +
31-
"<li>Anonymous install ID, per-launch session ID, plugin version, IDE version</li>" +
32-
"<li>LLM provider name and model name</li>" +
33-
"<li>Which optional features are enabled (RAG, Agent, MCP, Web Search) and coarse counts</li>" +
34-
"<li>Which features are actually used during a prompt (feature identifiers only)</li>" +
35-
"</ul>" +
36-
"<b>We never send</b> prompt text, response text, file content, file paths, project names, " +
37-
"API keys, MCP server names/URLs/commands, user-defined prompt names, or anything " +
38-
"that could identify you. " +
39-
"You can change this any time in <i>Settings → DevoxxGenie → Analytics</i>." +
32+
"<html>" +
33+
"We're a small open-source team, and <b>anonymous</b> usage data is the only way " +
34+
"we know which features are actually worth our time. " +
35+
"<b>No prompts, no code, no file paths, no API keys</b> — ever. " +
36+
"Just things like which LLM provider you picked and whether RAG is enabled." +
37+
"<br><br>" +
38+
"See exactly what we send: " +
39+
"<a href=\"" + ANALYTICS_SOURCE_URL + "\">AnalyticsEventBuilder.java</a>" +
40+
"<br><br>" +
41+
"You can turn this off any time in <i>Settings → DevoxxGenie → Analytics</i>." +
4042
"</html>";
4143

4244
private AnalyticsConsentNotifier() {
@@ -59,7 +61,7 @@ public static void maybeShow(@NotNull Project project) {
5961
.getNotificationGroup(NOTIFICATION_GROUP_ID)
6062
.createNotification(TITLE, CONTENT, NotificationType.INFORMATION);
6163

62-
notification.addAction(new AnAction("OK, Keep Enabled") {
64+
notification.addAction(new AnAction("Sure, help out") {
6365
@Override
6466
public void actionPerformed(@NotNull AnActionEvent e) {
6567
DevoxxGenieStateService.getInstance().setAnalyticsNoticeAcknowledged(true);
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package com.devoxx.genie.service.blog;
2+
3+
import com.devoxx.genie.util.HttpClientProvider;
4+
import com.google.gson.Gson;
5+
import com.intellij.ide.util.PropertiesComponent;
6+
import com.intellij.openapi.application.ApplicationManager;
7+
import lombok.extern.slf4j.Slf4j;
8+
import okhttp3.Request;
9+
import okhttp3.Response;
10+
import okhttp3.ResponseBody;
11+
import org.jetbrains.annotations.NotNull;
12+
import org.jetbrains.annotations.Nullable;
13+
14+
import javax.xml.parsers.DocumentBuilder;
15+
import javax.xml.parsers.DocumentBuilderFactory;
16+
import java.io.ByteArrayInputStream;
17+
import java.io.InputStream;
18+
import java.io.InputStreamReader;
19+
import java.nio.charset.StandardCharsets;
20+
import java.time.ZonedDateTime;
21+
import java.time.format.DateTimeFormatter;
22+
import java.util.ArrayList;
23+
import java.util.Collections;
24+
import java.util.List;
25+
import java.util.concurrent.CompletableFuture;
26+
import java.util.function.Consumer;
27+
28+
import org.w3c.dom.Document;
29+
import org.w3c.dom.Element;
30+
import org.w3c.dom.NodeList;
31+
32+
/**
33+
* Loads blog posts to surface on the welcome screen.
34+
*
35+
* <p>Two sources:
36+
* <ol>
37+
* <li>A bundled {@code blog-posts.json} generated at build time from {@code docusaurus/blog/*.md}.
38+
* Always available, works offline, but stale between releases.</li>
39+
* <li>The live RSS feed at {@code https://genie.devoxx.com/blog/rss.xml}, fetched asynchronously
40+
* and cached in {@link PropertiesComponent} for {@value #CACHE_TTL_MILLIS} ms.</li>
41+
* </ol>
42+
*
43+
* <p>Callers should display the bundled list immediately, then call
44+
* {@link #refreshRemoteAsync(Consumer)} to update the UI when fresh posts arrive.
45+
*/
46+
@Slf4j
47+
public final class BlogFeedService {
48+
49+
private static final String BUNDLED_RESOURCE = "/devoxxgenie/blog-posts.json";
50+
private static final String FEED_URL = "https://genie.devoxx.com/blog/rss.xml";
51+
52+
private static final String CACHE_KEY = "devoxxgenie.blog.cache.json";
53+
private static final String CACHE_TIMESTAMP_KEY = "devoxxgenie.blog.cache.ts";
54+
private static final long CACHE_TTL_MILLIS = 6L * 60 * 60 * 1000; // 6 hours
55+
56+
private static final BlogFeedService INSTANCE = new BlogFeedService();
57+
58+
private final Gson gson = new Gson();
59+
60+
private BlogFeedService() {}
61+
62+
public static BlogFeedService getInstance() {
63+
return INSTANCE;
64+
}
65+
66+
/** Synchronously load the bundled blog post list from the plugin classpath. */
67+
public @NotNull List<BlogPost> getBundled() {
68+
try (InputStream in = BlogFeedService.class.getResourceAsStream(BUNDLED_RESOURCE)) {
69+
if (in == null) {
70+
log.warn("Bundled blog index not found at {}", BUNDLED_RESOURCE);
71+
return Collections.emptyList();
72+
}
73+
BlogPost[] posts = gson.fromJson(new InputStreamReader(in, StandardCharsets.UTF_8), BlogPost[].class);
74+
return posts == null ? Collections.emptyList() : List.of(posts);
75+
} catch (Exception e) {
76+
log.warn("Failed to load bundled blog index", e);
77+
return Collections.emptyList();
78+
}
79+
}
80+
81+
/**
82+
* Returns cached remote posts if a fresh cache exists; otherwise {@code null}.
83+
* Cheap and safe to call on the EDT.
84+
*/
85+
public @Nullable List<BlogPost> getCachedRemote() {
86+
PropertiesComponent props = PropertiesComponent.getInstance();
87+
String json = props.getValue(CACHE_KEY);
88+
long ts = props.getLong(CACHE_TIMESTAMP_KEY, 0L);
89+
if (json == null || json.isEmpty()) return null;
90+
if (System.currentTimeMillis() - ts > CACHE_TTL_MILLIS) return null;
91+
try {
92+
BlogPost[] posts = gson.fromJson(json, BlogPost[].class);
93+
return posts == null ? null : List.of(posts);
94+
} catch (Exception e) {
95+
log.debug("Failed to parse cached blog posts", e);
96+
return null;
97+
}
98+
}
99+
100+
/**
101+
* Best-effort: returns cached remote posts if fresh, otherwise the bundled list.
102+
* Never blocks on network — safe for the initial welcome render.
103+
*/
104+
public @NotNull List<BlogPost> getInitialPosts() {
105+
List<BlogPost> cached = getCachedRemote();
106+
if (cached != null && !cached.isEmpty()) {
107+
return cached;
108+
}
109+
return getBundled();
110+
}
111+
112+
/**
113+
* Fetch the live RSS feed off the EDT and invoke {@code onUpdated} with fresh posts when
114+
* (and only if) they differ from what is currently cached. Failures are silent.
115+
*/
116+
public void refreshRemoteAsync(@NotNull Consumer<List<BlogPost>> onUpdated) {
117+
// Snapshot whether the UI is currently backed by a fresh remote cache. If it is,
118+
// and the fetched feed matches that cache, we can skip the UI update. Otherwise
119+
// (no cache, expired cache, or content changed) we must push the fetched posts
120+
// to the UI so the welcome screen leaves bundled-stale data behind.
121+
final boolean initialWasFreshRemote = getCachedRemote() != null;
122+
123+
CompletableFuture.supplyAsync(this::fetchRemote, ApplicationManager.getApplication()::executeOnPooledThread)
124+
.thenAccept(posts -> {
125+
if (posts == null || posts.isEmpty()) return;
126+
127+
PropertiesComponent props = PropertiesComponent.getInstance();
128+
String previous = props.getValue(CACHE_KEY);
129+
String fresh = gson.toJson(posts);
130+
boolean contentChanged = !fresh.equals(previous);
131+
132+
if (contentChanged) {
133+
props.setValue(CACHE_KEY, fresh);
134+
}
135+
props.setValue(CACHE_TIMESTAMP_KEY, String.valueOf(System.currentTimeMillis()));
136+
137+
// Push to UI when content actually changed, OR when the UI was
138+
// previously showing bundled/stale data (so it can switch to remote).
139+
if (contentChanged || !initialWasFreshRemote) {
140+
ApplicationManager.getApplication().invokeLater(() -> onUpdated.accept(posts));
141+
}
142+
})
143+
.exceptionally(ex -> {
144+
log.debug("Remote blog refresh failed: {}", ex.getMessage());
145+
return null;
146+
});
147+
}
148+
149+
private @Nullable List<BlogPost> fetchRemote() {
150+
Request request = new Request.Builder().url(FEED_URL).get().build();
151+
try (Response response = HttpClientProvider.getClient().newCall(request).execute()) {
152+
if (!response.isSuccessful()) {
153+
log.debug("Blog RSS fetch returned {}", response.code());
154+
return null;
155+
}
156+
ResponseBody body = response.body();
157+
if (body == null) return null;
158+
return parseRss(body.bytes());
159+
} catch (Exception e) {
160+
log.debug("Blog RSS fetch failed", e);
161+
return null;
162+
}
163+
}
164+
165+
private @Nullable List<BlogPost> parseRss(byte[] xml) {
166+
try {
167+
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
168+
// Defensive XML parser configuration
169+
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
170+
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
171+
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
172+
dbf.setXIncludeAware(false);
173+
dbf.setExpandEntityReferences(false);
174+
175+
DocumentBuilder db = dbf.newDocumentBuilder();
176+
Document doc = db.parse(new ByteArrayInputStream(xml));
177+
178+
NodeList items = doc.getElementsByTagName("item");
179+
List<BlogPost> posts = new ArrayList<>(items.getLength());
180+
for (int i = 0; i < items.getLength(); i++) {
181+
Element item = (Element) items.item(i);
182+
String title = textOf(item, "title");
183+
String link = textOf(item, "link");
184+
String description = stripHtml(textOf(item, "description"));
185+
String pubDate = textOf(item, "pubDate");
186+
if (title == null || link == null) continue;
187+
188+
String slug = slugFromLink(link);
189+
String date = normalizeDate(pubDate);
190+
posts.add(new BlogPost(slug, title, date, description == null ? "" : description));
191+
}
192+
return posts;
193+
} catch (Exception e) {
194+
log.debug("Failed to parse RSS feed", e);
195+
return null;
196+
}
197+
}
198+
199+
private static @Nullable String textOf(@NotNull Element parent, @NotNull String tag) {
200+
NodeList nl = parent.getElementsByTagName(tag);
201+
if (nl.getLength() == 0) return null;
202+
String text = nl.item(0).getTextContent();
203+
return text == null ? null : text.trim();
204+
}
205+
206+
private static @NotNull String slugFromLink(@NotNull String link) {
207+
// Expect https://genie.devoxx.com/blog/<slug>(/)
208+
int idx = link.indexOf("/blog/");
209+
if (idx < 0) return link;
210+
String tail = link.substring(idx + "/blog/".length());
211+
if (tail.endsWith("/")) tail = tail.substring(0, tail.length() - 1);
212+
return tail;
213+
}
214+
215+
private static @NotNull String normalizeDate(@Nullable String pubDate) {
216+
if (pubDate == null || pubDate.isEmpty()) return "";
217+
try {
218+
ZonedDateTime zdt = ZonedDateTime.parse(pubDate, DateTimeFormatter.RFC_1123_DATE_TIME);
219+
return zdt.toLocalDate().toString();
220+
} catch (Exception e) {
221+
return pubDate;
222+
}
223+
}
224+
225+
private static @NotNull String stripHtml(@Nullable String s) {
226+
if (s == null) return "";
227+
return s.replaceAll("<[^>]+>", "").replaceAll("\\s+", " ").trim();
228+
}
229+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.devoxx.genie.service.blog;
2+
3+
/**
4+
* A blog post entry shown on the welcome screen.
5+
*
6+
* @param slug the post slug, used to build the URL (https://genie.devoxx.com/blog/{slug})
7+
* @param title the post title
8+
* @param date the publication date as ISO string (yyyy-MM-dd)
9+
* @param description short description / excerpt
10+
*/
11+
public record BlogPost(String slug, String title, String date, String description) {
12+
13+
public String url() {
14+
return "https://genie.devoxx.com/blog/" + slug;
15+
}
16+
}

0 commit comments

Comments
 (0)