# Directory Structure
```
├── .DS_Store
├── .gitignore
├── docs
│ ├── javaConf-mcp-server-architecture.drawio
│ ├── javaconf-mcp-server-conversation.png
│ └── javaConf-mcp-server.png
├── pom.xml
├── README.md
└── src
└── main
├── java
│ └── nano
│ └── dev
│ └── javaconf
│ ├── config
│ │ └── McpToolConfiguration.java
│ ├── JavaConfMcpServerApplication.java
│ ├── model
│ │ └── ConferenceInfo.java
│ └── service
│ ├── ConferenceToolService.java
│ ├── GitHubService.java
│ ├── MarkdownParsingHelper.java
│ ├── MarkdownParsingService.java
│ └── util
│ └── MarkdownParsingHelper.java
└── resources
├── application.properties
└── logback-spring.xml
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
# MAVEN #
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# Avoid ignoring Maven wrapper jar file (.jar files are ignored above)
!/.mvn/wrapper/maven-wrapper.jar
# IDEA #
.idea/
*.iml
*.ipr
*.iws
out/
# Eclipse #
.settings/
.classpath
.project
# NetBeans #
nbproject/private/
build/
nbbuild/
dist/
nbdist/
.nb-gradle/
# VS Code #
.vscode/
# Mac #
.DS_Store
# Other #
*.swp
*~
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# 🔌 Java Conferences MCP Server 🔌
A Model Call Protocol (MCP) server that provides AI assistants with access to data about Java conferences from around the world. This server exposes conference information from a public GitHub repository including names, dates, locations, and Call for Papers (CFP) details through a standardized MCP interface.
## Data Source
The server fetches data by parsing the `README.md` file from the [🔗 javaconferences/javaconferences.github.io](https://github.com/javaconferences/javaconferences.github.io) repository.
The specific URL is configured in `application.properties` and defaults to:
`https://raw.githubusercontent.com/javaconferences/javaconferences.github.io/main/README.md`
## Provided Tool
* **Name:** `getJavaConferences`
* **Description:** Get information about Java conferences for a specific year (if specified and found in the source) or the current year by default. Parses data for all years found under H3 headings in the source markdown file.
* **Input Parameter:**
* `year` (String, Optional): The 4-digit year to retrieve conferences for. If omitted or invalid, defaults to the current year.
* **Output:** A list of JSON objects, each representing a conference with the following fields:
* `conferenceName` (String)
* `date` (String)
* `location` (String)
* `isHybrid` (Boolean)
* `cfpLink` (String) - URL for the Call for Papers, if available
* `cfpDate` (String) - Closing date for CFP, if available
* `link` (String) - Main conference link
* `country` (String)
## MCP Server Architecture

## Configuration
## Connecting an MCP Client (e.g., Claude Desktop)
To connect an MCP client like Claude Desktop to this server:
1. Configure your MCP client to connect to the server. For Claude Desktop, you might update your `claude_desktop_config.json` file like this:
```json
{
"mcpServers": {
"javaConf-mcp-server": {
"command": "java",
// "command": "PATH_TO_USER/.sdkman/candidates/java/current/bin/java", /* in my case i'm using the java version installed by sdkman */
"args": [
"-jar",
"PATH_TO_PROJECT/javaConf-mcp-server/target/javaconf-mcp-server-0.0.1-SNAPSHOT.jar"
]
}
}
}
```
2. Start the MCP client and ensure that it is connected to the server.
3. Use the tool by asking questions like:
- "What are the upcoming Java conferences?"
- "What are hybrid conferences?"
- "Give the CPF link of Jfokus conference to submit a talk"
### Example of a conversation with the MCP server

## Tech Stack
- **[`🍃️ Spring Boot`](https://spring.io/projects/spring-boot)**
- **[`🤖️ Spring AI`](https://spring.io/projects/spring-ai)**
- **[`🔌 Spring AI MCP`](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html)**
- **[`📦️ Maven`](https://maven.apache.org/)**
- **[`🧾 CommonMark`](https://commonmark.org/)**
## Support
- ⭐️️ Star this repository if you find it useful.
- 🐛️ If you find a bug, raise an issue or fix it and send a pull request.
- 📢️ If you have any feature requests, raise an issue or send a pull request.
- 🤲 If you have a moment, don't forget to make a duaa for me and my parents.
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/JavaConfMcpServerApplication.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaconf;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JavaConfMcpServerApplication {
public static void main(String[] args) {
SpringApplication.run(JavaConfMcpServerApplication.class, args);
}
}
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/model/ConferenceInfo.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaconf.model;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
@Data
@Builder
@Jacksonized
public class ConferenceInfo {
private int year;
private String conferenceName;
private String location;
private Boolean isHybrid;
private String cfpStatus;
private String cfpLink;
private String link;
private String country;
}
```
--------------------------------------------------------------------------------
/src/main/resources/logback-spring.xml:
--------------------------------------------------------------------------------
```
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<appender name="CONSOLE_STDERR" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
<target>System.err</target>
</appender>
<!-- Configure the Root logger -->
<root level="INFO">
<appender-ref ref="CONSOLE_STDERR" />
</root>
</configuration>
```
--------------------------------------------------------------------------------
/src/main/resources/application.properties:
--------------------------------------------------------------------------------
```
spring.application.name=javaconf-mcp-server
# MCP Server Identification
spring.ai.mcp.server.name=javaConf-mcp-server
spring.ai.mcp.server.version=0.0.1-SNAPSHOT
# Disable the Spring Boot banner when running
spring.main.banner-mode=off
# Tell Spring Boot not to start a web server
spring.main.web-application-type=none
# Logging configuration
logging.level.nano.dev.javaconf=DEBUG
logging.level.org.springframework.ai=INFO
logging.level.root=INFO
logging.level.org.springframework=INFO
# GitHub URL for the markdown file
github.markdown.url=https://raw.githubusercontent.com/javaconferences/javaconferences.github.io/main/README.md
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/config/McpToolConfiguration.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaconf.config;
import lombok.extern.slf4j.Slf4j;
import nano.dev.javaconf.service.ConferenceToolService;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbacks;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
@Slf4j
public class McpToolConfiguration {
/**
* Defines the ConferenceToolService as a ToolCallback bean for the MCP server.
* Spring AI MCP Server automatically discovers beans of type ToolCallback.
*/
@Bean
public List<ToolCallback> javaConferenceTool(ConferenceToolService conferenceToolService) {
log.debug("Registering ConferenceToolService as ToolCallback bean via McpToolConfiguration.");
return List.of(ToolCallbacks.from(conferenceToolService));
}
}
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/GitHubService.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaconf.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
@Service
@Slf4j
public class GitHubService {
private final RestTemplate restTemplate;
private final String markdownUrl;
public GitHubService(RestTemplateBuilder builder,
@Value("${github.markdown.url}") String markdownUrl
) {
this.restTemplate = builder.build();
this.markdownUrl = markdownUrl;
log.info("GitHubService initialized to fetch from URL: {}", this.markdownUrl);
}
public String fetchMarkdownContent() {
log.info("Fetching Java Conference Markdown from: {} using RestTemplate", markdownUrl);
try {
log.debug("Attempting to fetch Markdown content from {}", markdownUrl);
ResponseEntity<String> response = restTemplate.getForEntity(markdownUrl, String.class);
if (response.getStatusCode() == HttpStatus.OK) {
String content = response.getBody();
log.info("Successfully fetched Markdown content ({} bytes)", content != null ? content.length() : 0);
return content;
} else {
log.error("Failed to fetch Markdown content. Status code: {}", response.getStatusCode());
return null;
}
} catch (RestClientException e) {
log.error("Error fetching Markdown content from {}: {}", markdownUrl, e.getMessage());
return null;
}
}
}
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/ConferenceToolService.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaconf.service;
import lombok.extern.slf4j.Slf4j;
import nano.dev.javaconf.model.ConferenceInfo;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.Year;
import java.util.Collections;
import java.util.List;
@Service
@Slf4j
public class ConferenceToolService {
private final GitHubService gitHubService;
private final MarkdownParsingService markdownParsingService;
public ConferenceToolService(GitHubService gitHubService, MarkdownParsingService markdownParsingService) {
this.gitHubService = gitHubService;
this.markdownParsingService = markdownParsingService;
}
public record ToolRequest(String year) { }
@Tool(name = "getJavaConferences",
description = "Get information about Java conferences for a specific year (if specified and found in the source) or the current year by default. Parses data for all years found under H3 headings.")
public List<ConferenceInfo> getJavaConferences(ToolRequest request) {
final int finalTargetYear = determineTargetYear(request);
try {
String markdownContent = gitHubService.fetchMarkdownContent();
if (!StringUtils.hasText(markdownContent)) {
log.warn("Markdown content was null or empty after fetch, returning empty list.");
return Collections.emptyList();
}
List<ConferenceInfo> allConferences = markdownParsingService.parse(markdownContent);
log.debug("Parser returned {} conferences in total (before filtering).", allConferences.size());
List<ConferenceInfo> filteredConferences = allConferences.stream()
.filter(conf -> conf.getYear() == finalTargetYear)
.toList();
log.info("Returning {} conferences for year {}", filteredConferences.size(), finalTargetYear);
return filteredConferences;
} catch (Exception e) {
log.error("Error processing conference info request for year {}", finalTargetYear, e);
return Collections.emptyList();
}
}
private int determineTargetYear(ToolRequest request) {
int currentYear = Year.now().getValue();
int targetYear;
String requestedYear = request.year();
if (StringUtils.hasText(requestedYear)) {
try {
targetYear = Integer.parseInt(requestedYear);
log.info("Tool called: getJavaConferences attempting requested year: {}", targetYear);
} catch (NumberFormatException e) {
log.warn("Invalid year format '{}' requested, falling back to current year {}.", requestedYear, currentYear);
targetYear = currentYear;
log.info("Tool called: getJavaConferences falling back to current year: {}", targetYear);
}
} else {
targetYear = currentYear;
log.info("Tool called: getJavaConferences using current year: {} (no year specified)", targetYear);
}
return targetYear;
}
}
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/util/MarkdownParsingHelper.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaconf.service.util;
import org.commonmark.node.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* Utility class containing static helper methods for parsing specific elements
* from commonmark-java Nodes, primarily for the Java Conference MCP server.
*/
public final class MarkdownParsingHelper {
private MarkdownParsingHelper() {}
public static <T extends Node> Stream<T> streamChildren(Node parent, Class<T> type) {
List<T> children = new ArrayList<>();
Node child = parent.getFirstChild();
while (child != null) {
if (type.isInstance(child)) {
children.add(type.cast(child));
}
child = child.getNext();
}
return children.stream();
}
/**
* Extracts the destination URL of the first Link node found within the children of parentNode.
* Uses a dedicated visitor for efficiency.
* @param parentNode The node potentially containing a Link node.
* @return The URL string if a link is found, otherwise null.
*/
public static String extractFirstLinkUrlFromNode(Node parentNode) {
if (parentNode == null) return null;
UrlExtractorVisitor visitor = new UrlExtractorVisitor();
parentNode.accept(visitor);
return visitor.getFirstUrl();
}
/**
* Helper visitor (inner class) to find the first Link node and get its destination URL.
*/
private static class UrlExtractorVisitor extends AbstractVisitor {
private String firstUrl = null;
public String getFirstUrl() {
return this.firstUrl;
}
@Override
public void visit(Link link) {
if (this.firstUrl == null) {
this.firstUrl = link.getDestination();
}
}
}
/**
* Extracts all visible text content from a node and its children.
* Handles common inline formatting nodes.
* @param node The node to extract text from.
* @return The extracted text as a single string.
*/
public static String extractText(Node node) {
if (node == null) return "";
StringBuilder sb = new StringBuilder();
TextExtractorVisitor textVisitor = new TextExtractorVisitor(sb);
node.accept(textVisitor);
return sb.toString();
}
/**
* Helper visitor (inner class) to extract all text content from a node and its children.
*/
private static class TextExtractorVisitor extends AbstractVisitor {
private final StringBuilder buffer;
TextExtractorVisitor(StringBuilder buffer) {
this.buffer = buffer;
}
@Override public void visit(Text text) { buffer.append(text.getLiteral()); }
@Override public void visit(Code code) { buffer.append(code.getLiteral()); }
@Override public void visit(Emphasis emphasis) { visitChildren(emphasis); }
@Override public void visit(StrongEmphasis strongEmphasis) { visitChildren(strongEmphasis); }
@Override public void visit(Link link) { visitChildren(link); }
}
/**
* Parses the hybrid status text ("yes" or "hybrid", case-insensitive).
* @param text The text from the table cell.
* @return true if the text indicates hybrid, false otherwise.
*/
public static Boolean parseHybrid(String text) {
if (text == null) return false;
String lowerCaseText = text.trim().toLowerCase();
return lowerCaseText.equals("yes") || lowerCaseText.equals("hybrid");
}
/**
* Extracts the country name from a location string (assumes "City, Country" format).
* @param location The location string.
* @return The country name if found, otherwise null.
*/
public static String extractCountryFromLocation(String location) {
if (location != null && location.contains(",")) {
String[] parts = location.split(",");
if (parts.length > 1) {
return parts[parts.length - 1].trim();
}
}
return null;
}
}
```
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
```
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.1</version>
<relativePath/>
</parent>
<groupId>nano.dev</groupId>
<artifactId>javaconf-mcp-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>javaconf-mcp-server</name>
<description>MCP Server for Java Conference Information</description>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
<commonmark.version>0.22.0</commonmark.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- CommonMark for Markdown rendering -->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>${commonmark.version}</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>${commonmark.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
<repository>
<id>central-portal-snapshots</id>
<name>Central Portal Snapshots</name>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</project>
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/MarkdownParsingHelper.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaConf.service;
import org.commonmark.node.Link;
import org.commonmark.node.Node;
import org.commonmark.node.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDate;
import java.time.Month;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MarkdownParsingHelper {
private static final Logger log = LoggerFactory.getLogger(MarkdownParsingHelper.class);
private static final Pattern DATE_PATTERN = Pattern.compile("(\w+)\s+(\d{1,2})(-\d{1,2})?"); // e.g., "January 10-12" or "Feb 5"
/**
* Extracts the URL from a Link node or its text child.
*/
public static String extractLinkUrl(Node cellNode) {
if (cellNode instanceof Link) {
return ((Link) cellNode).getDestination();
} else if (cellNode.getFirstChild() instanceof Link) {
// Sometimes the link is wrapped inside another node (e.g., StrongEmphasis)
return ((Link) cellNode.getFirstChild()).getDestination();
} else if (cellNode.getFirstChild() instanceof Text) {
// Check if the text itself is a URL (less common but possible)
String text = ((Text) cellNode.getFirstChild()).getLiteral().trim();
if (text.startsWith("http://") || text.startsWith("https://")) {
return text;
}
}
// Look deeper for Link nodes if nested
Node node = cellNode.getFirstChild();
while (node != null) {
if (node instanceof Link) {
return ((Link) node).getDestination();
}
// Recursively check children if necessary, though CommonMark usually flattens
if (node.getFirstChild() != null) {
String nestedUrl = extractLinkUrl(node); // Simplified recursion for example
if (nestedUrl != null) {
return nestedUrl;
}
}
node = node.getNext();
}
log.trace("Could not extract URL from node: {}", cellNode);
return null;
}
/**
* Extracts and parses the conference date string.
*/
public static String extractConferenceDate(Node cellNode, int year) {
String dateString = cellNode.getFirstChild() instanceof Text ? ((Text) cellNode.getFirstChild()).getLiteral().trim() : "N/A";
return parseDate(dateString, year);
}
/**
* Parses the date string into a standard format (e.g., "YYYY-MM-DD").
* Handles formats like "Month DD-DD" or "Month DD".
*/
private static String parseDate(String dateString, int year) {
Matcher matcher = DATE_PATTERN.matcher(dateString);
if (matcher.find()) {
try {
String monthStr = matcher.group(1);
String dayStr = matcher.group(2);
// Simple month abbreviation handling
if (monthStr.length() > 3 && !monthStr.equalsIgnoreCase("sept")) {
monthStr = monthStr.substring(0, 3);
} else if (monthStr.equalsIgnoreCase("sept")) {
monthStr = "Sep"; // Handle September abbreviation specifically
}
// Use DateTimeFormatter for robust month parsing
DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMM", Locale.ENGLISH);
Month month = Month.from(monthFormatter.parse(monthStr));
int day = Integer.parseInt(dayStr);
LocalDate startDate = LocalDate.of(year, month, day);
return startDate.format(DateTimeFormatter.ISO_DATE); // Format as YYYY-MM-DD
} catch (DateTimeParseException | NumberFormatException e) {
log.warn("Could not parse date string: '{}' for year {}. Error: {}", dateString, year, e.getMessage());
return dateString; // Return original string if parsing fails
}
}
// Handle TBD or other non-standard formats
if (dateString.equalsIgnoreCase("TBD")) {
return "TBD";
}
log.warn("Date string '{}' did not match expected pattern for year {}", dateString, year);
return dateString; // Return original string if no match
}
/**
* Parses the hybrid status string. Returns true for "yes" or "hybrid", false otherwise.
*/
public static boolean parseHybrid(Node cellNode) {
if (cellNode != null && cellNode.getFirstChild() instanceof Text) {
String hybridStatus = ((Text) cellNode.getFirstChild()).getLiteral().trim().toLowerCase();
// Consider "yes" or "hybrid" as true, explicitly treat "no" as false, otherwise false.
return hybridStatus.equals("yes") || hybridStatus.equals("hybrid");
}
return false; // Default to false if node is null or not Text
}
}
```
--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/MarkdownParsingService.java:
--------------------------------------------------------------------------------
```java
package nano.dev.javaconf.service;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import nano.dev.javaconf.model.ConferenceInfo;
import nano.dev.javaconf.service.util.MarkdownParsingHelper;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TableBody;
import org.commonmark.ext.gfm.tables.TableRow;
import org.commonmark.ext.gfm.tables.TableCell;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.node.*;
import org.commonmark.parser.Parser;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
@Service
public class MarkdownParsingService {
private final Parser parser;
private static final int CONF_NAME_INDEX = 0;
private static final int LOCATION_INDEX = 1;
private static final int HYBRID_INDEX = 2;
private static final int CFP_LINK_INDEX = 4;
private static final int EXPECTED_COLUMNS = 5;
public MarkdownParsingService() {
this.parser = Parser.builder()
.extensions(Collections.singletonList(TablesExtension.create()))
.build();
}
public List<ConferenceInfo> parse(String markdownContent) {
if (!StringUtils.hasText(markdownContent)) {
log.warn("Markdown content is empty or null. Skipping parsing.");
return Collections.emptyList();
}
List<ConferenceInfo> allConferences = new ArrayList<>();
try {
Node document = parser.parse(markdownContent);
Node node = document.getFirstChild();
while (node != null) {
if (node instanceof Heading heading && heading.getLevel() == 3) {
String headingText = MarkdownParsingHelper.extractText(heading).trim();
log.debug("Found H3 heading: '{}'", headingText);
// Check if the heading text looks like a 4-digit year
if (headingText.matches("^\\d{4}$")) {
processYearSection(heading, headingText).ifPresent(allConferences::addAll);
} else {
log.debug("Skipping H3 heading '{}' as it doesn't look like a year.", headingText);
}
}
node = node.getNext();
}
log.info("Parsed {} conferences in total from Markdown.", allConferences.size());
return allConferences;
} catch (Exception e) {
log.error("Failed to parse markdown content due to an unexpected error", e);
return Collections.emptyList();
}
}
// Helper to process a section identified by a year heading
private Optional<List<ConferenceInfo>> processYearSection(Heading yearHeading, String year) {
log.info("Processing potential section for year: {}", year);
Node nextNode = yearHeading.getNext();
if (nextNode instanceof TableBlock tableBlock) {
log.debug("Found TableBlock immediately after H3 heading for year {}", year);
Node tableChild = tableBlock.getFirstChild();
while (tableChild != null && !(tableChild instanceof TableBody)) {
tableChild = tableChild.getNext();
}
if (tableChild instanceof TableBody tableBody) {
return Optional.of(processTableRows(tableBody, year));
} else {
log.warn("Could not find TableBody within TableBlock for year {}", year);
return Optional.empty();
}
} else {
log.warn("No TableBlock found immediately after H3 heading for year {}", year);
return Optional.empty();
}
}
// Helper to process all rows within a TableBody using Streams
private List<ConferenceInfo> processTableRows(TableBody tableBody, String currentYear) {
return MarkdownParsingHelper.streamChildren(tableBody, TableRow.class)
.map(row -> mapRowToConferenceInfo(row, currentYear))
.flatMap(Optional::stream)
.collect(Collectors.toList());
}
// Helper to map a single TableRow to ConferenceInfo, handling errors
private Optional<ConferenceInfo> mapRowToConferenceInfo(TableRow row, String currentYear) {
List<String> cellTexts = new ArrayList<>();
List<Node> cellNodes = new ArrayList<>();
Node cellNode = row.getFirstChild();
while (cellNode instanceof TableCell) {
cellTexts.add(MarkdownParsingHelper.extractText(cellNode).trim());
cellNodes.add(cellNode);
cellNode = cellNode.getNext();
}
log.trace("Processing row for year {} with {} cells: {}", currentYear, cellTexts.size(), cellTexts);
if (cellTexts.size() < EXPECTED_COLUMNS) {
log.warn("Skipping row in year {}: Insufficient cells (expected >= {}, found {}). Cell texts: {}",
currentYear, EXPECTED_COLUMNS, cellTexts.size(), cellTexts);
return Optional.empty();
}
try {
String confName = cellTexts.get(CONF_NAME_INDEX);
String primaryLink = MarkdownParsingHelper.extractFirstLinkUrlFromNode(cellNodes.get(CONF_NAME_INDEX));
String cfpStatusText = "-".equals(cellTexts.get(CFP_LINK_INDEX)) ? null : cellTexts.get(CFP_LINK_INDEX);
String extractedCfpLink = MarkdownParsingHelper.extractFirstLinkUrlFromNode(cellNodes.get(CFP_LINK_INDEX));
ConferenceInfo conference = ConferenceInfo.builder()
.year(Integer.parseInt(currentYear))
.conferenceName(confName)
.location(cellTexts.get(LOCATION_INDEX))
.isHybrid(MarkdownParsingHelper.parseHybrid(cellTexts.get(HYBRID_INDEX)))
.cfpStatus(cfpStatusText)
.cfpLink(extractedCfpLink)
.link(primaryLink)
.country(MarkdownParsingHelper.extractCountryFromLocation(cellTexts.get(LOCATION_INDEX)))
.build();
log.debug("Mapped conference: {} from year {}", confName, currentYear);
return Optional.of(conference);
} catch (NumberFormatException nfe) {
log.warn("Skipping row in year {}: Could not parse currentYear '{}' as integer.", currentYear, currentYear);
return Optional.empty();
} catch (IndexOutOfBoundsException iobe) {
log.warn("Skipping row in year {}: Error accessing cell index. Columns expected >= {}, found {}. Error: {}. Row cell texts: {}",
currentYear, EXPECTED_COLUMNS, cellTexts.size(), iobe.getMessage(), cellTexts);
return Optional.empty();
} catch (Exception e) {
log.warn("Skipping row in year {} due to generic parsing error: {}. Row cell texts: {}", currentYear, e.getMessage(), cellTexts, e);
return Optional.empty();
}
}
}
```