Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,92 @@ tasks.named("buildPlugin") {
dependsOn("updateProperties")
}

val generatedBlogResourcesDir = layout.buildDirectory.dir("generated-resources/blog")

sourceSets.named("main") {
resources.srcDir(generatedBlogResourcesDir)
}

tasks.register("generateBlogIndex") {
group = "build"
description = "Generates blog-posts.json from docusaurus/blog/*.md frontmatter"

val blogDir = file("docusaurus/blog")
val outputFile = generatedBlogResourcesDir.get().file("devoxxgenie/blog-posts.json").asFile

inputs.dir(blogDir)
outputs.file(outputFile)

doLast {
if (!blogDir.exists()) {
logger.warn("Blog directory not found: $blogDir — writing empty index")
outputFile.parentFile.mkdirs()
outputFile.writeText("[]")
return@doLast
}

// Very small YAML frontmatter parser — sufficient for our blog posts
fun parseFrontmatter(file: File): Map<String, String>? {
val lines = file.readLines()
if (lines.isEmpty() || lines[0].trim() != "---") return null
val end = lines.drop(1).indexOfFirst { it.trim() == "---" }
if (end < 0) return null
val map = mutableMapOf<String, String>()
for (line in lines.subList(1, end + 1)) {
val idx = line.indexOf(':')
if (idx <= 0) continue
val key = line.substring(0, idx).trim()
var value = line.substring(idx + 1).trim()
// Strip surrounding quotes
if ((value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) ||
(value.startsWith("'") && value.endsWith("'") && value.length >= 2)
) {
value = value.substring(1, value.length - 1)
}
map[key] = value
}
return map
}

fun jsonEscape(s: String): String = buildString {
for (c in s) when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (c.code < 0x20) append("\\u%04x".format(c.code)) else append(c)
}
}

data class Entry(val slug: String, val title: String, val date: String, val description: String)

val entries = blogDir.listFiles { f -> f.isFile && f.name.endsWith(".md") }
?.mapNotNull { f ->
val fm = parseFrontmatter(f) ?: return@mapNotNull null
val slug = fm["slug"] ?: return@mapNotNull null
val title = fm["title"] ?: return@mapNotNull null
// Date: prefer explicit `date:` field, fall back to filename prefix yyyy-MM-dd
val date = fm["date"] ?: f.name.take(10)
val description = fm["description"] ?: ""
Entry(slug, title, date, description)
}
?.sortedByDescending { it.date }
?: emptyList()

outputFile.parentFile.mkdirs()
val json = entries.joinToString(prefix = "[\n", postfix = "\n]", separator = ",\n") { e ->
""" {"slug":"${jsonEscape(e.slug)}","title":"${jsonEscape(e.title)}","date":"${jsonEscape(e.date)}","description":"${jsonEscape(e.description)}"}"""
}
outputFile.writeText(json)
logger.lifecycle("Generated ${entries.size} blog entries → $outputFile")
}
}

tasks.named("processResources") {
dependsOn("generateBlogIndex")
}

dependencies {
intellijPlatform {
// Allow overriding IDE version via property: ./gradlew runIde -PideVersion=2025.1.7
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,23 @@ public final class AnalyticsConsentNotifier {

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

private static final String TITLE = "DevoxxGenie usage analytics";
private static final String TITLE = "Help shape DevoxxGenie";

private static final String ANALYTICS_SOURCE_URL =
"https://github.com/devoxx/DevoxxGenieIDEAPlugin/blob/master/" +
"src/main/java/com/devoxx/genie/service/analytics/AnalyticsEventBuilder.java";

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

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

notification.addAction(new AnAction("OK, Keep Enabled") {
notification.addAction(new AnAction("Sure, help out") {
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
DevoxxGenieStateService.getInstance().setAnalyticsNoticeAcknowledged(true);
Expand Down
229 changes: 229 additions & 0 deletions src/main/java/com/devoxx/genie/service/blog/BlogFeedService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package com.devoxx.genie.service.blog;

import com.devoxx.genie.util.HttpClientProvider;
import com.google.gson.Gson;
import com.intellij.ide.util.PropertiesComponent;
import com.intellij.openapi.application.ApplicationManager;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

/**
* Loads blog posts to surface on the welcome screen.
*
* <p>Two sources:
* <ol>
* <li>A bundled {@code blog-posts.json} generated at build time from {@code docusaurus/blog/*.md}.
* Always available, works offline, but stale between releases.</li>
* <li>The live RSS feed at {@code https://genie.devoxx.com/blog/rss.xml}, fetched asynchronously
* and cached in {@link PropertiesComponent} for {@value #CACHE_TTL_MILLIS} ms.</li>
* </ol>
*
* <p>Callers should display the bundled list immediately, then call
* {@link #refreshRemoteAsync(Consumer)} to update the UI when fresh posts arrive.
*/
@Slf4j
public final class BlogFeedService {

private static final String BUNDLED_RESOURCE = "/devoxxgenie/blog-posts.json";
private static final String FEED_URL = "https://genie.devoxx.com/blog/rss.xml";

private static final String CACHE_KEY = "devoxxgenie.blog.cache.json";
private static final String CACHE_TIMESTAMP_KEY = "devoxxgenie.blog.cache.ts";
private static final long CACHE_TTL_MILLIS = 6L * 60 * 60 * 1000; // 6 hours

private static final BlogFeedService INSTANCE = new BlogFeedService();

private final Gson gson = new Gson();

private BlogFeedService() {}

public static BlogFeedService getInstance() {
return INSTANCE;
}

/** Synchronously load the bundled blog post list from the plugin classpath. */
public @NotNull List<BlogPost> getBundled() {
try (InputStream in = BlogFeedService.class.getResourceAsStream(BUNDLED_RESOURCE)) {
if (in == null) {
log.warn("Bundled blog index not found at {}", BUNDLED_RESOURCE);
return Collections.emptyList();
}
BlogPost[] posts = gson.fromJson(new InputStreamReader(in, StandardCharsets.UTF_8), BlogPost[].class);
return posts == null ? Collections.emptyList() : List.of(posts);
} catch (Exception e) {
log.warn("Failed to load bundled blog index", e);
return Collections.emptyList();
}
}

/**
* Returns cached remote posts if a fresh cache exists; otherwise {@code null}.
* Cheap and safe to call on the EDT.
*/
public @Nullable List<BlogPost> getCachedRemote() {
PropertiesComponent props = PropertiesComponent.getInstance();
String json = props.getValue(CACHE_KEY);
long ts = props.getLong(CACHE_TIMESTAMP_KEY, 0L);
if (json == null || json.isEmpty()) return null;
if (System.currentTimeMillis() - ts > CACHE_TTL_MILLIS) return null;
try {
BlogPost[] posts = gson.fromJson(json, BlogPost[].class);
return posts == null ? null : List.of(posts);
} catch (Exception e) {
log.debug("Failed to parse cached blog posts", e);
return null;
}
}

/**
* Best-effort: returns cached remote posts if fresh, otherwise the bundled list.
* Never blocks on network — safe for the initial welcome render.
*/
public @NotNull List<BlogPost> getInitialPosts() {
List<BlogPost> cached = getCachedRemote();
if (cached != null && !cached.isEmpty()) {
return cached;
}
return getBundled();
}

/**
* Fetch the live RSS feed off the EDT and invoke {@code onUpdated} with fresh posts when
* (and only if) they differ from what is currently cached. Failures are silent.
*/
public void refreshRemoteAsync(@NotNull Consumer<List<BlogPost>> onUpdated) {
// Snapshot whether the UI is currently backed by a fresh remote cache. If it is,
// and the fetched feed matches that cache, we can skip the UI update. Otherwise
// (no cache, expired cache, or content changed) we must push the fetched posts
// to the UI so the welcome screen leaves bundled-stale data behind.
final boolean initialWasFreshRemote = getCachedRemote() != null;

CompletableFuture.supplyAsync(this::fetchRemote, ApplicationManager.getApplication()::executeOnPooledThread)
.thenAccept(posts -> {
if (posts == null || posts.isEmpty()) return;

PropertiesComponent props = PropertiesComponent.getInstance();
String previous = props.getValue(CACHE_KEY);
String fresh = gson.toJson(posts);
boolean contentChanged = !fresh.equals(previous);

if (contentChanged) {
props.setValue(CACHE_KEY, fresh);
}
props.setValue(CACHE_TIMESTAMP_KEY, String.valueOf(System.currentTimeMillis()));

// Push to UI when content actually changed, OR when the UI was
// previously showing bundled/stale data (so it can switch to remote).
if (contentChanged || !initialWasFreshRemote) {
ApplicationManager.getApplication().invokeLater(() -> onUpdated.accept(posts));
}
})
.exceptionally(ex -> {
log.debug("Remote blog refresh failed: {}", ex.getMessage());
return null;
});
}

private @Nullable List<BlogPost> fetchRemote() {
Request request = new Request.Builder().url(FEED_URL).get().build();
try (Response response = HttpClientProvider.getClient().newCall(request).execute()) {
if (!response.isSuccessful()) {
log.debug("Blog RSS fetch returned {}", response.code());
return null;
}
ResponseBody body = response.body();
if (body == null) return null;
return parseRss(body.bytes());
} catch (Exception e) {
log.debug("Blog RSS fetch failed", e);
return null;
}
}

private @Nullable List<BlogPost> parseRss(byte[] xml) {
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// Defensive XML parser configuration
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);

DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(xml));

NodeList items = doc.getElementsByTagName("item");
List<BlogPost> posts = new ArrayList<>(items.getLength());
for (int i = 0; i < items.getLength(); i++) {
Element item = (Element) items.item(i);
String title = textOf(item, "title");
String link = textOf(item, "link");
String description = stripHtml(textOf(item, "description"));
String pubDate = textOf(item, "pubDate");
if (title == null || link == null) continue;

String slug = slugFromLink(link);
String date = normalizeDate(pubDate);
posts.add(new BlogPost(slug, title, date, description == null ? "" : description));
}
return posts;
} catch (Exception e) {
log.debug("Failed to parse RSS feed", e);
return null;
}
}

private static @Nullable String textOf(@NotNull Element parent, @NotNull String tag) {
NodeList nl = parent.getElementsByTagName(tag);
if (nl.getLength() == 0) return null;
String text = nl.item(0).getTextContent();
return text == null ? null : text.trim();
}

private static @NotNull String slugFromLink(@NotNull String link) {
// Expect https://genie.devoxx.com/blog/<slug>(/)
int idx = link.indexOf("/blog/");
if (idx < 0) return link;
String tail = link.substring(idx + "/blog/".length());
if (tail.endsWith("/")) tail = tail.substring(0, tail.length() - 1);
return tail;
}

private static @NotNull String normalizeDate(@Nullable String pubDate) {
if (pubDate == null || pubDate.isEmpty()) return "";
try {
ZonedDateTime zdt = ZonedDateTime.parse(pubDate, DateTimeFormatter.RFC_1123_DATE_TIME);
return zdt.toLocalDate().toString();
} catch (Exception e) {
return pubDate;
}
}

private static @NotNull String stripHtml(@Nullable String s) {
if (s == null) return "";
return s.replaceAll("<[^>]+>", "").replaceAll("\\s+", " ").trim();
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/devoxx/genie/service/blog/BlogPost.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.devoxx.genie.service.blog;

/**
* A blog post entry shown on the welcome screen.
*
* @param slug the post slug, used to build the URL (https://genie.devoxx.com/blog/{slug})
* @param title the post title
* @param date the publication date as ISO string (yyyy-MM-dd)
* @param description short description / excerpt
*/
public record BlogPost(String slug, String title, String date, String description) {

public String url() {
return "https://genie.devoxx.com/blog/" + slug;
}
}
Loading
Loading