From 81566cf00a0b3a601b43a1f98deedfc30b102bfe Mon Sep 17 00:00:00 2001 From: Su5eD Date: Tue, 9 Jun 2026 09:27:04 +0200 Subject: [PATCH 01/17] Update Gradle and Loom --- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 7d66ec9..a58ca69 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'dev.architectury.loom' version '1.7-SNAPSHOT' apply false + id 'dev.architectury.loom' version '1.13-SNAPSHOT' apply false id 'architectury-plugin' version '3.4-SNAPSHOT' id 'com.github.johnrengelman.shadow' version '8.1.1' apply false id 'org.moddedmc.wiki.toolkit' version '0.2.7' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a441313..5dd3c01 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 148e7eeffb5cd71ca84504b4d62a931e20611423 Mon Sep 17 00:00:00 2001 From: Su5eD Date: Tue, 9 Jun 2026 09:27:23 +0200 Subject: [PATCH 02/17] Use enum for Docs mode --- .../main/java/rearth/oracle/OracleClient.java | 5 ++-- .../java/rearth/oracle/format/DocsMode.java | 6 ++++ .../java/rearth/oracle/ui/OracleScreen.java | 30 +++++++++++-------- 3 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 common/src/main/java/rearth/oracle/format/DocsMode.java diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java index e43a8c6..ec34f86 100644 --- a/common/src/main/java/rearth/oracle/OracleClient.java +++ b/common/src/main/java/rearth/oracle/OracleClient.java @@ -18,6 +18,7 @@ import org.apache.logging.log4j.core.config.Configurator; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; +import rearth.oracle.format.DocsMode; import rearth.oracle.progress.AdvancementProgressValidator; import rearth.oracle.ui.OracleScreen; import rearth.oracle.ui.SearchScreen; @@ -40,7 +41,7 @@ public final class OracleClient { public static final Set LOADED_WIKIS = new HashSet<>(); // just keeps a set of loaded wiki ids public static final HashMap ITEM_LINKS = new HashMap<>(); // items that have a corresponding wiki page (docs or content) public static final HashMap> UNLOCK_CRITERIONS = new HashMap<>(); // path/key here is: "books/modid/folder/entry.mdx". Value is unlock type and content - public static final HashMap> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) + public static final HashMap> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) public static final HashMap CONTENT_ID_MAP = new HashMap<>();// item / block id -> resource path (e.g., "oritech:enderic_laser" -> "oracle_index:books/oritech/.content/machines/laser.mdx") public static ItemStack tooltipStack; @@ -171,7 +172,7 @@ private static void findAllResourceEntries(ResourceManager manager) { // check docs or content var isContent = path.contains("/.content/"); - var mode = isContent ? "content" : "docs"; + var mode = isContent ? DocsMode.CONTENT : DocsMode.DOCS; AVAILABLE_MODES.computeIfAbsent(modId, k -> new HashSet<>()).add(mode); // parse frontmatter diff --git a/common/src/main/java/rearth/oracle/format/DocsMode.java b/common/src/main/java/rearth/oracle/format/DocsMode.java new file mode 100644 index 0000000..d85b7c6 --- /dev/null +++ b/common/src/main/java/rearth/oracle/format/DocsMode.java @@ -0,0 +1,6 @@ +package rearth.oracle.format; + +public enum DocsMode { + DOCS, + CONTENT +} diff --git a/common/src/main/java/rearth/oracle/ui/OracleScreen.java b/common/src/main/java/rearth/oracle/ui/OracleScreen.java index a8a00c8..c024575 100644 --- a/common/src/main/java/rearth/oracle/ui/OracleScreen.java +++ b/common/src/main/java/rearth/oracle/ui/OracleScreen.java @@ -15,6 +15,7 @@ import org.lwjgl.glfw.GLFW; import rearth.oracle.Oracle; import rearth.oracle.OracleClient; +import rearth.oracle.format.DocsMode; import rearth.oracle.progress.OracleProgressAPI; import rearth.oracle.ui.widgets.*; import rearth.oracle.util.MarkdownParser; @@ -33,7 +34,7 @@ public class OracleScreen extends WikiBaseScreen { public static final HashMap PAGE_FALLBACK_NAMES = new HashMap<>(); - public static String activeWikiMode = "docs"; + public static DocsMode activeWikiMode = DocsMode.DOCS; public static Identifier activeEntry; public static String activeWiki; @@ -123,6 +124,11 @@ protected void buildRoots() { addRoot(contentScroll); addRoot(actionHub); + if (activeEntry != null) { + var isContent = activeEntry.getPath().contains("/.content/"); + var mode = isContent ? DocsMode.CONTENT : DocsMode.DOCS; + activeWikiMode = mode; + } buildNavigationTree(); if (activeEntry != null) { try { @@ -429,7 +435,7 @@ private void buildNavigationTree() { if (canSwitchWikiMode(activeWiki)) { navigationBar.child(buildModeSelector()); } - var path = activeWikiMode.equals("docs") ? "" : "/.content"; + var path = activeWikiMode.equals(DocsMode.DOCS) ? "" : "/.content"; buildNavigationEntries(activeWiki, path, navigationBar); } @@ -437,14 +443,14 @@ private FlowWidget buildModeSelector() { var row = FlowWidget.horizontal().gap(-1); row.size(SIDEBAR_WIDTH - 10, 0); row.horizontalAlignment(FlowWidget.HorizontalAlignment.CENTER); - row.child(makeModeButton("docs")); - row.child(makeModeButton("content")); + row.child(makeModeButton(DocsMode.DOCS)); + row.child(makeModeButton(DocsMode.CONTENT)); return row; } - private ClickableWidget makeModeButton(String mode) { + private ClickableWidget makeModeButton(DocsMode mode) { boolean selected = activeWikiMode.equals(mode); - var text = Text.translatable("oracle_index.button." + mode).formatted(selected ? Formatting.WHITE : Formatting.DARK_GRAY); + var text = Text.translatable("oracle_index.button." + mode.name().toLowerCase(Locale.ROOT)).formatted(selected ? Formatting.WHITE : Formatting.DARK_GRAY); var label = new LabelWidget(text); var widget = new ClickableWidget(label, b -> { if (!selected) { @@ -466,16 +472,16 @@ private ClickableWidget makeModeButton(String mode) { return widget; } - private String getWikiMode(String wikiId) { - var modes = OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of("docs")); + private DocsMode getWikiMode(String wikiId) { + var modes = OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of(DocsMode.DOCS)); if (modes.contains(activeWikiMode)) return activeWikiMode; - if (modes.contains("docs")) return "docs"; - if (modes.contains("content")) return "content"; - return modes.stream().findFirst().orElse("docs"); + if (modes.contains(DocsMode.DOCS)) return DocsMode.DOCS; + if (modes.contains(DocsMode.CONTENT)) return DocsMode.CONTENT; + return modes.stream().findFirst().orElse(DocsMode.DOCS); } private boolean canSwitchWikiMode(String wikiId) { - return OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of("docs")).size() > 1; + return OracleClient.AVAILABLE_MODES.getOrDefault(wikiId, Set.of(DocsMode.DOCS)).size() > 1; } /** From 70978360b8512c1dea32adea25fca0678ee4e7a9 Mon Sep 17 00:00:00 2001 From: Su5eD Date: Tue, 9 Jun 2026 10:11:49 +0200 Subject: [PATCH 03/17] Fix entry translations broken --- .../main/java/rearth/oracle/OracleClient.java | 19 ++++++++++++++----- .../rearth/oracle/mixin/DrawContextMixin.java | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java index ec34f86..e25a13c 100644 --- a/common/src/main/java/rearth/oracle/OracleClient.java +++ b/common/src/main/java/rearth/oracle/OracleClient.java @@ -1,5 +1,6 @@ package rearth.oracle; +import com.google.common.base.Suppliers; import dev.architectury.event.events.client.ClientTickEvent; import dev.architectury.registry.ReloadListenerRegistry; import dev.architectury.registry.client.keymappings.KeyMappingRegistry; @@ -30,6 +31,7 @@ import java.util.HashSet; import java.util.Optional; import java.util.Set; +import java.util.function.Supplier; public final class OracleClient { @@ -185,10 +187,16 @@ private static void findAllResourceEntries(ResourceManager manager) { var id = frontmatter.get("id").trim(); CONTENT_ID_MAP.put(id, resourceId); var itemId = Identifier.of(id); + + Supplier lazyTitle = null; var title = frontmatter.getOrDefault("title", "missing"); - if (title.equals("missing") && Registries.ITEM.containsId(itemId)) - title = I18n.translate(Registries.ITEM.get(itemId).getTranslationKey()); - ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, title, modId)); + if (title.equals("missing") && Registries.ITEM.containsId(itemId)) { + // Translations may not be available at this time yet + lazyTitle = Suppliers.memoize(() -> I18n.translate(Registries.ITEM.get(itemId).getTranslationKey())); + } else { + lazyTitle = () -> title; + } + ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId)); } // frontmatter custom item links indexing @@ -196,7 +204,8 @@ private static void findAllResourceEntries(ResourceManager manager) { var baseString = frontmatter.get("related_items").replace("[", "").replace("]", "").replace("\"", ""); for (var itemString : baseString.split(", ")) { var itemId = Identifier.of(itemString.trim()); - ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, frontmatter.getOrDefault("title", "missing"), modId)); + var title = frontmatter.getOrDefault("title", "missing"); + ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, () -> title, modId)); } } @@ -242,7 +251,7 @@ public static Optional getTranslatedPath(Identifier identifier, Stri return Optional.empty(); } - public record ItemArticleRef(Identifier linkTarget, String entryName, String wikiId) { + public record ItemArticleRef(Identifier linkTarget, Supplier entryName, String wikiId) { } } diff --git a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java index de79cdf..2de09fd 100644 --- a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java +++ b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java @@ -43,7 +43,7 @@ private void injectTooltipComponents(TextRenderer textRenderer, List Date: Tue, 9 Jun 2026 10:12:10 +0200 Subject: [PATCH 04/17] Expand nav tree on opened page entry --- .../java/rearth/oracle/ui/OracleScreen.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/rearth/oracle/ui/OracleScreen.java b/common/src/main/java/rearth/oracle/ui/OracleScreen.java index c024575..46e2ff1 100644 --- a/common/src/main/java/rearth/oracle/ui/OracleScreen.java +++ b/common/src/main/java/rearth/oracle/ui/OracleScreen.java @@ -436,7 +436,7 @@ private void buildNavigationTree() { navigationBar.child(buildModeSelector()); } var path = activeWikiMode.equals(DocsMode.DOCS) ? "" : "/.content"; - buildNavigationEntries(activeWiki, path, navigationBar); + buildNavigationEntries(activeWiki, path, navigationBar, new Stack<>()); } private FlowWidget buildModeSelector() { @@ -487,7 +487,7 @@ private boolean canSwitchWikiMode(String wikiId) { /** * @return true if any entry under this path is unlocked. */ - private boolean buildNavigationEntries(String wikiId, String path, FlowWidget container) { + private boolean buildNavigationEntries(String wikiId, String path, FlowWidget container, Stack hierarchy) { var rm = MinecraftClient.getInstance().getResourceManager(); var metaPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/_meta.json"); var translated = OracleClient.getTranslatedPath(metaPath, wikiId); @@ -507,9 +507,15 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co var levelContainers = new ArrayList(); for (var entry : entries) { + final var labelPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/" + entry.id()); + if (entry.directory) { - var directory = new CollapsibleWidget(Text.translatable(entry.name()).formatted(Formatting.WHITE), false); - boolean childrenUnlocked = buildNavigationEntries(wikiId, path + "/" + entry.id(), directory.body()); + boolean expanded = activeEntry != null && activeEntry.getPath().startsWith(labelPath.getPath()); + + var directory = new CollapsibleWidget(Text.translatable(entry.name()).formatted(Formatting.WHITE), expanded); + hierarchy.push(directory); + boolean childrenUnlocked = buildNavigationEntries(wikiId, path + "/" + entry.id(), directory.body(), hierarchy); + hierarchy.pop(); if (childrenUnlocked) anyUnlocked = true; final var captured = directory; @@ -526,8 +532,6 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co levelContainers.add(directory); } } else { - final var labelPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/" + entry.id()); - var shownName = entry.name; if (shownName.isBlank()) { var contentRc = rm.getResource(labelPath); @@ -566,6 +570,10 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co var firstPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + path + "/" + first.get().id()); loadContent(firstPath, wikiId); activeEntry = firstPath; + + for (CollapsibleWidget parent : hierarchy) { + parent.setExpanded(true); + } } } } catch (IOException e) { From cea18808e0f84f631ccf5b9d0bd225253d2e553c Mon Sep 17 00:00:00 2001 From: Su5eD Date: Tue, 9 Jun 2026 10:37:08 +0200 Subject: [PATCH 05/17] Fix page title padding --- common/src/main/java/rearth/oracle/util/MarkdownParser.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index 6508bf5..e46989e 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -381,7 +381,8 @@ public void layout(int parentWidthHint, int parentHeightHint) { titleH = labelH + TITLE_PAD_Y * 2; titleX = x + leadingWidth(); titleY = y + (height - titleH) / 2; - titleLabel.setPosition(titleX + TITLE_PAD_X, titleY + TITLE_PAD_Y); + int offset = icon != null ? TITLE_PAD_X / 2 : 0; + titleLabel.setPosition(titleX + TITLE_PAD_X + offset, titleY + TITLE_PAD_Y); titleLabel.setLayoutSize(labelW, labelH); titleLabel.layout(labelW, labelH); if (icon != null) { From 135dedb6d93d94544d380bd3ae5f46b8a6ce77da Mon Sep 17 00:00:00 2001 From: Su5eD Date: Tue, 9 Jun 2026 12:20:44 +0200 Subject: [PATCH 06/17] Add support for V1 wiki format --- .../main/java/rearth/oracle/OracleClient.java | 119 ++++------- .../java/rearth/oracle/SemanticSearch.java | 5 +- .../java/rearth/oracle/docs/DocsFormat.java | 15 ++ .../java/rearth/oracle/docs/DocsIndexer.java | 190 ++++++++++++++++++ .../oracle/{format => docs}/DocsMode.java | 2 +- .../rearth/oracle/docs/LegacyDocsFormat.java | 36 ++++ .../java/rearth/oracle/docs/V1DocsFormat.java | 36 ++++ .../java/rearth/oracle/ui/OracleScreen.java | 13 +- .../rearth/oracle/util/MarkdownParser.java | 8 +- 9 files changed, 332 insertions(+), 92 deletions(-) create mode 100644 common/src/main/java/rearth/oracle/docs/DocsFormat.java create mode 100644 common/src/main/java/rearth/oracle/docs/DocsIndexer.java rename common/src/main/java/rearth/oracle/{format => docs}/DocsMode.java (61%) create mode 100644 common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java create mode 100644 common/src/main/java/rearth/oracle/docs/V1DocsFormat.java diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java index e25a13c..bc56cde 100644 --- a/common/src/main/java/rearth/oracle/OracleClient.java +++ b/common/src/main/java/rearth/oracle/OracleClient.java @@ -1,15 +1,12 @@ package rearth.oracle; -import com.google.common.base.Suppliers; import dev.architectury.event.events.client.ClientTickEvent; import dev.architectury.registry.ReloadListenerRegistry; import dev.architectury.registry.client.keymappings.KeyMappingRegistry; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.option.KeyBinding; -import net.minecraft.client.resource.language.I18n; import net.minecraft.item.ItemStack; -import net.minecraft.registry.Registries; import net.minecraft.resource.ResourceManager; import net.minecraft.resource.ResourceType; import net.minecraft.resource.SynchronousResourceReloader; @@ -19,18 +16,15 @@ import org.apache.logging.log4j.core.config.Configurator; import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; -import rearth.oracle.format.DocsMode; +import rearth.oracle.docs.DocsFormat; +import rearth.oracle.docs.DocsIndexer; +import rearth.oracle.docs.DocsMode; import rearth.oracle.progress.AdvancementProgressValidator; import rearth.oracle.ui.OracleScreen; import rearth.oracle.ui.SearchScreen; -import rearth.oracle.util.MarkdownParser; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; +import java.util.*; +import java.util.function.BiPredicate; import java.util.function.Supplier; public final class OracleClient { @@ -40,7 +34,7 @@ public final class OracleClient { public static final KeyBinding ORACLE_WIKI = new KeyBinding("key.oracle_index.open", GLFW.GLFW_KEY_H, "key.categories.oracle"); public static final KeyBinding ORACLE_SEARCH = new KeyBinding("key.oracle_index.search", -1, "key.categories.oracle"); - public static final Set LOADED_WIKIS = new HashSet<>(); // just keeps a set of loaded wiki ids + public static final Map LOADED_WIKIS = new HashMap<>(); // map of loaded wiki ids to formats (specifies directory layout) public static final HashMap ITEM_LINKS = new HashMap<>(); // items that have a corresponding wiki page (docs or content) public static final HashMap> UNLOCK_CRITERIONS = new HashMap<>(); // path/key here is: "books/modid/folder/entry.mdx". Value is unlock type and content public static final HashMap> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) @@ -150,82 +144,47 @@ public static boolean hasContentEntry(Identifier assetId) { return CONTENT_ID_MAP.containsKey(assetId.toString()); } + public static DocsMode getDocsModeForPage(Identifier pageId) { + String path = pageId.getPath(); + String modId = Objects.requireNonNull(DocsIndexer.extractModid(path), "modid must be extracted"); + DocsFormat format = getWikiFormat(modId); + return format.isContentPath(path) ? DocsMode.CONTENT : DocsMode.DOCS; + } + + public static DocsFormat getWikiFormat(String wikiId) { + return Objects.requireNonNull(LOADED_WIKIS.get(wikiId), "unknown wiki id"); + } + private static void findAllResourceEntries(ResourceManager manager) { - var resources = manager.findResources(ROOT_DIR, path -> path.getPath().endsWith(".mdx")); - + DocsIndexer indexer = new DocsIndexer(); + indexer.findAllResourceEntries(manager); + LOADED_WIKIS.clear(); + LOADED_WIKIS.putAll(indexer.getLoadedWikis()); + ITEM_LINKS.clear(); + ITEM_LINKS.putAll(indexer.getItemLinks()); + UNLOCK_CRITERIONS.clear(); + UNLOCK_CRITERIONS.putAll(indexer.getUnlockCriterions()); + CONTENT_ID_MAP.clear(); + CONTENT_ID_MAP.putAll(indexer.getContentIds()); + AVAILABLE_MODES.clear(); - - for (var entry : resources.entrySet()) { - var resourceId = entry.getKey(); - var path = resourceId.getPath(); // e.g., "books/oritech/.content/machines/laser.mdx" - - // extract mode + mod id - var segments = path.split("/"); - if (segments.length < 2) continue; - - var modId = segments[1]; // e.g., "oritech" - LOADED_WIKIS.add(modId); - - if (path.contains(".translated")) continue; // skip / don't support translations for now in indexing - - // check docs or content - var isContent = path.contains("/.content/"); - var mode = isContent ? DocsMode.CONTENT : DocsMode.DOCS; - AVAILABLE_MODES.computeIfAbsent(modId, k -> new HashSet<>()).add(mode); - - // parse frontmatter - try (var inputStream = entry.getValue().getInputStream()) { - var fileContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - var frontmatter = MarkdownParser.parseFrontmatter(fileContent); - - // map game id for content links - if (isContent && frontmatter.containsKey("id")) { - var id = frontmatter.get("id").trim(); - CONTENT_ID_MAP.put(id, resourceId); - var itemId = Identifier.of(id); - - Supplier lazyTitle = null; - var title = frontmatter.getOrDefault("title", "missing"); - if (title.equals("missing") && Registries.ITEM.containsId(itemId)) { - // Translations may not be available at this time yet - lazyTitle = Suppliers.memoize(() -> I18n.translate(Registries.ITEM.get(itemId).getTranslationKey())); - } else { - lazyTitle = () -> title; - } - ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId)); - } - - // frontmatter custom item links indexing - if (frontmatter.containsKey("related_items")) { - var baseString = frontmatter.get("related_items").replace("[", "").replace("]", "").replace("\"", ""); - for (var itemString : baseString.split(", ")) { - var itemId = Identifier.of(itemString.trim()); - var title = frontmatter.getOrDefault("title", "missing"); - ITEM_LINKS.put(itemId, new ItemArticleRef(resourceId, () -> title, modId)); - } - } - - if (frontmatter.containsKey("unlock")) { - var unlockText = frontmatter.get("unlock"); - var parts = unlockText.split(":", 2); - if (parts.length == 2) { - UNLOCK_CRITERIONS.put(path, new Pair<>(parts[0], parts[1])); - } - } - - } catch (IOException e) { - Oracle.LOGGER.error("Unable to load book entry: {}", resourceId, e); - } - } + AVAILABLE_MODES.putAll(indexer.getAvailableModes()); } public static SemanticSearch getOrCreateSearch() { - if (searchInstance == null) searchInstance = new SemanticSearch(); + if (searchInstance == null) { + BiPredicate filter = (modId, path) -> { + DocsFormat format = getWikiFormat(modId); + return !format.isTranslatedPath(path); + }; + + searchInstance = new SemanticSearch(filter); + } return searchInstance; } @@ -240,7 +199,9 @@ public static Optional getTranslatedPath(Identifier identifier, Stri var resourceManager = MinecraftClient.getInstance().getResourceManager(); if (!languageCode.startsWith("en_")) { - var translatedPath = Identifier.of(identifier.getNamespace(), identifier.getPath().replace(ROOT_DIR + "/" + wikiId, ROOT_DIR + "/" + wikiId + "/.translated/" + languageCode)); + var format = getWikiFormat(wikiId); + var translatedDir = format.getTranslatedDir(languageCode); + var translatedPath = Identifier.of(identifier.getNamespace(), identifier.getPath().replace(ROOT_DIR + "/" + wikiId, ROOT_DIR + "/" + wikiId + translatedDir)); if (resourceManager.getResource(translatedPath).isPresent()) { return Optional.of(translatedPath); diff --git a/common/src/main/java/rearth/oracle/SemanticSearch.java b/common/src/main/java/rearth/oracle/SemanticSearch.java index 4286349..79bedcd 100644 --- a/common/src/main/java/rearth/oracle/SemanticSearch.java +++ b/common/src/main/java/rearth/oracle/SemanticSearch.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiPredicate; import static rearth.oracle.OracleClient.ROOT_DIR; @@ -33,7 +34,7 @@ public class SemanticSearch { public static AtomicBoolean EMBEDDING_ERRORED = new AtomicBoolean(false); public static AtomicBoolean FINISHED = new AtomicBoolean(false); - public SemanticSearch() { + public SemanticSearch(BiPredicate filter) { // do this in background to avoid freezing main new Thread(() -> { @@ -73,7 +74,7 @@ public SemanticSearch() { var entryFileName = segments[segments.length - 1]; // e.g. "wrench.mdx" var entryDirectory = entryPath.replace(entryFileName, ""); // e.g. "tools" or "processing/reactor" - if (entryDirectory.contains(".translated")) continue; // skip / don't support translations for now + if (!filter.test(modId, entryDirectory)) continue; // skip / don't support translations for now try { var fileContent = new String(resources.get(resourceId).getInputStream().readAllBytes(), StandardCharsets.UTF_8); diff --git a/common/src/main/java/rearth/oracle/docs/DocsFormat.java b/common/src/main/java/rearth/oracle/docs/DocsFormat.java new file mode 100644 index 0000000..2e5bdf5 --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/DocsFormat.java @@ -0,0 +1,15 @@ +package rearth.oracle.docs; + +public interface DocsFormat { + boolean isContentPath(String path); + + boolean isTranslatedPath(String path); + + String getDocsRoot(DocsMode mode); + + String getTranslatedDir(String locale); + + String getAssetsRoot(); + + String getDocsPagePath(String slug); +} diff --git a/common/src/main/java/rearth/oracle/docs/DocsIndexer.java b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java new file mode 100644 index 0000000..49a47f0 --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java @@ -0,0 +1,190 @@ +package rearth.oracle.docs; + +import com.google.common.base.Suppliers; +import com.google.gson.Gson; +import com.mojang.logging.LogUtils; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.registry.Registries; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.util.Identifier; +import net.minecraft.util.Pair; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import rearth.oracle.Oracle; +import rearth.oracle.OracleClient.ItemArticleRef; +import rearth.oracle.util.MarkdownParser; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Supplier; + +import static rearth.oracle.OracleClient.ROOT_DIR; + +public class DocsIndexer { + public static final String WIKI_META_FILE = "sinytra-wiki.json"; + public static final String SCHEMA_V1 = "1"; + + private static final Logger LOGGER = LogUtils.getLogger(); + private static final Gson GSON = new Gson(); + + private final Map loadedWikis; + private final Map itemLinks; + private final Map> unlockCriterions; + private final Map> availableModes; + private final Map contentIds; + + public DocsIndexer() { + this.loadedWikis = new HashMap<>(); + this.itemLinks = new HashMap<>(); + this.unlockCriterions = new HashMap<>(); + this.availableModes = new HashMap<>(); + this.contentIds = new HashMap<>(); + } + + public Map getLoadedWikis() { + return loadedWikis; + } + + public Map getItemLinks() { + return itemLinks; + } + + public Map> getUnlockCriterions() { + return unlockCriterions; + } + + public Map> getAvailableModes() { + return availableModes; + } + + public Map getContentIds() { + return contentIds; + } + + public void findAllResourceEntries(ResourceManager manager) { + var resources = manager.findResources(ROOT_DIR, path -> path.getPath().endsWith(".mdx")); + + Map> wikis = new HashMap<>(); + for (var entry : resources.entrySet()) { + var resourceId = entry.getKey(); + var path = resourceId.getPath(); // e.g., "books/oritech/.content/machines/laser.mdx" + + var modId = extractModid(path); // e.g., "oritech" + if (modId == null) continue; + + var modResources = wikis.computeIfAbsent(modId, m -> new ArrayList<>()); + modResources.add(new IdentifiedResource(resourceId, entry.getValue())); + } + + for (var wiki : wikis.entrySet()) { + var modId = wiki.getKey(); + var entries = wiki.getValue(); + var format = detectDocsFormat(manager, modId); + + this.loadedWikis.put(modId, format); + + for (IdentifiedResource entry : entries) { + processEntry(modId, entry.id(), entry.resource(), format); + } + } + } + + private void processEntry(String modId, Identifier resourceId, Resource resource, DocsFormat format) { + var path = resourceId.getPath(); // e.g., "books/oritech/.content/machines/laser.mdx" + + if (format.isTranslatedPath(path)) { + return; // skip / don't support translations for now in indexing + } + + // check docs or content + var isContent = format.isContentPath(path); + var mode = isContent ? DocsMode.CONTENT : DocsMode.DOCS; + this.availableModes.computeIfAbsent(modId, k -> new HashSet<>()).add(mode); + + // parse frontmatter + try (var inputStream = resource.getInputStream()) { + var fileContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + var frontmatter = MarkdownParser.parseFrontmatter(fileContent); + + // map game id for content links + if (isContent && frontmatter.containsKey("id")) { + var id = frontmatter.get("id").trim(); + this.contentIds.put(id, resourceId); + var itemId = Identifier.of(id); + + Supplier lazyTitle = null; + var title = frontmatter.getOrDefault("title", "missing"); + if (title.equals("missing") && Registries.ITEM.containsId(itemId)) { + // Translations may not be available at this time yet + lazyTitle = Suppliers.memoize(() -> I18n.translate(Registries.ITEM.get(itemId).getTranslationKey())); + } else { + lazyTitle = () -> title; + } + this.itemLinks.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId)); + } + + // frontmatter custom item links indexing + if (frontmatter.containsKey("related_items")) { + var baseString = frontmatter.get("related_items").replace("[", "").replace("]", "").replace("\"", ""); + for (var itemString : baseString.split(", ")) { + var itemId = Identifier.of(itemString.trim()); + var title = frontmatter.getOrDefault("title", "missing"); + this.itemLinks.put(itemId, new ItemArticleRef(resourceId, () -> title, modId)); + } + } + + if (frontmatter.containsKey("unlock")) { + var unlockText = frontmatter.get("unlock"); + var parts = unlockText.split(":", 2); + if (parts.length == 2) { + this.unlockCriterions.put(path, new Pair<>(parts[0], parts[1])); + } + } + + } catch (IOException e) { + Oracle.LOGGER.error("Unable to load book entry: {}", resourceId, e); + } + } + + private static DocsFormat detectDocsFormat(ResourceManager manager, String wikiId) { + var metaPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + "/" + WIKI_META_FILE); + WikiMetaStub meta = manager.getResource(metaPath) + .map(DocsIndexer::parseWikiMeta) + .orElse(null); + + if (meta != null && SCHEMA_V1.equals(meta.schema)) { + return new V1DocsFormat(); + } + + return new LegacyDocsFormat(); + } + + @Nullable + private static WikiMetaStub parseWikiMeta(Resource resource) { + try (Reader reader = new InputStreamReader(resource.getInputStream())) { + return GSON.fromJson(reader, WikiMetaStub.class); + } catch (Exception e) { + LOGGER.error("Error parsing wiki metadata", e); + return null; + } + } + + @Nullable + public static String extractModid(String path) { + var segments = path.split("/"); + if (segments.length < 2) { + return null; + } + return segments[1]; + } + + record IdentifiedResource(Identifier id, Resource resource) { + } + + record WikiMetaStub(@Nullable String schema) { + } +} diff --git a/common/src/main/java/rearth/oracle/format/DocsMode.java b/common/src/main/java/rearth/oracle/docs/DocsMode.java similarity index 61% rename from common/src/main/java/rearth/oracle/format/DocsMode.java rename to common/src/main/java/rearth/oracle/docs/DocsMode.java index d85b7c6..6d97723 100644 --- a/common/src/main/java/rearth/oracle/format/DocsMode.java +++ b/common/src/main/java/rearth/oracle/docs/DocsMode.java @@ -1,4 +1,4 @@ -package rearth.oracle.format; +package rearth.oracle.docs; public enum DocsMode { DOCS, diff --git a/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java b/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java new file mode 100644 index 0000000..48d6be8 --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java @@ -0,0 +1,36 @@ +package rearth.oracle.docs; + +public class LegacyDocsFormat implements DocsFormat { + @Override + public boolean isContentPath(String path) { + return path.contains("/.content/"); + } + + @Override + public boolean isTranslatedPath(String path) { + return path.contains("/.translated"); + } + + @Override + public String getDocsRoot(DocsMode mode) { + return switch (mode) { + case DOCS -> ""; + case CONTENT -> "/.content"; + }; + } + + @Override + public String getTranslatedDir(String locale) { + return "/.translated/" + locale; + } + + @Override + public String getAssetsRoot() { + return "/.assets"; + } + + @Override + public String getDocsPagePath(String slug) { + return slug; + } +} diff --git a/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java b/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java new file mode 100644 index 0000000..eaffd5c --- /dev/null +++ b/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java @@ -0,0 +1,36 @@ +package rearth.oracle.docs; + +public class V1DocsFormat implements DocsFormat { + @Override + public boolean isContentPath(String path) { + return path.contains("/content/"); + } + + @Override + public boolean isTranslatedPath(String path) { + return path.contains("/translated"); + } + + @Override + public String getDocsRoot(DocsMode mode) { + return switch (mode) { + case DOCS -> "/docs"; + case CONTENT -> "/content"; + }; + } + + @Override + public String getTranslatedDir(String locale) { + return "/translated/" + locale; + } + + @Override + public String getAssetsRoot() { + return "/assets"; + } + + @Override + public String getDocsPagePath(String slug) { + return "docs/" + slug; + } +} diff --git a/common/src/main/java/rearth/oracle/ui/OracleScreen.java b/common/src/main/java/rearth/oracle/ui/OracleScreen.java index 46e2ff1..0e2866f 100644 --- a/common/src/main/java/rearth/oracle/ui/OracleScreen.java +++ b/common/src/main/java/rearth/oracle/ui/OracleScreen.java @@ -15,7 +15,7 @@ import org.lwjgl.glfw.GLFW; import rearth.oracle.Oracle; import rearth.oracle.OracleClient; -import rearth.oracle.format.DocsMode; +import rearth.oracle.docs.DocsMode; import rearth.oracle.progress.OracleProgressAPI; import rearth.oracle.ui.widgets.*; import rearth.oracle.util.MarkdownParser; @@ -125,9 +125,7 @@ protected void buildRoots() { addRoot(actionHub); if (activeEntry != null) { - var isContent = activeEntry.getPath().contains("/.content/"); - var mode = isContent ? DocsMode.CONTENT : DocsMode.DOCS; - activeWikiMode = mode; + activeWikiMode = OracleClient.getDocsModeForPage(activeEntry); } buildNavigationTree(); if (activeEntry != null) { @@ -140,7 +138,7 @@ protected void buildRoots() { } private UIComponent buildWikiTitleHeader() { - var wikiIds = OracleClient.LOADED_WIKIS.stream().sorted().toList(); + var wikiIds = OracleClient.LOADED_WIKIS.keySet().stream().sorted().toList(); if (wikiIds.isEmpty()) return FlowWidget.horizontal(); if (activeWiki == null || !wikiIds.contains(activeWiki)) activeWiki = wikiIds.get(0); @@ -190,7 +188,7 @@ private FlowWidget buildModDropdown() { private void rebuildModDropdown(FlowWidget dropdown) { dropdown.clearChildren(); - var wikiIds = OracleClient.LOADED_WIKIS.stream().sorted().toList(); + var wikiIds = OracleClient.LOADED_WIKIS.keySet().stream().sorted().toList(); for (var wikiId : wikiIds) { var label = new LabelWidget(Text.translatable(Oracle.MOD_ID + ".title." + wikiId).formatted(wikiId.equals(activeWiki) ? Formatting.WHITE : Formatting.DARK_GRAY)); label.setPadding(Insets.of(4, 3)); @@ -435,7 +433,8 @@ private void buildNavigationTree() { if (canSwitchWikiMode(activeWiki)) { navigationBar.child(buildModeSelector()); } - var path = activeWikiMode.equals(DocsMode.DOCS) ? "" : "/.content"; + var format = OracleClient.getWikiFormat(activeWiki); + var path = format.getDocsRoot(activeWikiMode); buildNavigationEntries(activeWiki, path, navigationBar, new Stack<>()); } diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index e46989e..2999a46 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -292,7 +292,8 @@ public static Identifier getLinkTarget(String link, String activeWikiId, Identif var id = link.startsWith("@") ? link.substring(1) : link; targetFile = OracleClient.CONTENT_ID_MAP.get(id); } else if (link.startsWith("$")) { - var p = "books/" + activeWikiId + "/" + link.substring(1); + var format = OracleClient.getWikiFormat(activeWikiId); + var p = "books/" + activeWikiId + "/" + format.getDocsPagePath(link.substring(1)); if (!p.endsWith(".mdx")) p += ".mdx"; targetFile = Identifier.of(Oracle.MOD_ID, p); } else { @@ -530,14 +531,15 @@ public static UIComponent buildImage(String location, String widthSource, String } // case 2: texture path + String assetsRoot = OracleClient.getWikiFormat(wikiId).getAssetsRoot(); Identifier searchPath; if (isModAsset) { var parts = location.split(":", 2); var imageModId = parts.length > 0 ? parts[0] : wikiId; var imagePath = parts.length > 1 ? parts[1] : location; - searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + "/.assets/" + imageModId + "/" + imagePath + ".png"); + searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + imageModId + "/" + imagePath + ".png"); } else { - searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + "/.assets/" + wikiId + "/" + location + ".png"); + searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + wikiId + "/" + location + ".png"); } var rm = MinecraftClient.getInstance().getResourceManager(); From 0e1ea12a84e3f6ed3575b316b88c5dd11b2ed4fd Mon Sep 17 00:00:00 2001 From: Su5eD Date: Tue, 9 Jun 2026 17:54:41 +0200 Subject: [PATCH 07/17] Make ModAsset an alias of Asset --- .../rearth/oracle/util/MarkdownParser.java | 19 ++++++++----------- .../rearth/oracle/util/MdxBlockFactory.java | 4 ++-- .../rearth/oracle/util/MdxComponentBlock.java | 14 ++++---------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index 2999a46..8ffb140 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -193,7 +193,7 @@ public void visit(CustomBlock customBlock) { if (customBlock instanceof MdxComponentBlock.CraftingRecipeBlock recipe) { components.add(buildRecipe(recipe.slots, recipe.result, recipe.count)); } else if (customBlock instanceof MdxComponentBlock.AssetBlock image) { - components.add(buildImage(image.location, image.width, this.wikiId, image.isModAsset(), contentWidthPx)); + components.add(buildImage(image.location, image.width, this.wikiId, contentWidthPx)); } else if (customBlock instanceof MdxComponentBlock.CalloutBlock callout) { var oldComponents = this.components; var inner = new ArrayList(); @@ -211,7 +211,7 @@ public void visit(CustomBlock customBlock) { @Override public void visit(Image image) { flushBuffer(); - components.add(buildImage(image.getDestination(), "60%", wikiId, true, contentWidthPx)); + components.add(buildImage(image.getDestination(), "60%", wikiId, contentWidthPx)); } @Override @@ -510,7 +510,7 @@ public static UIComponent buildRecipe(List inputs, String resultId, int return panel; } - public static UIComponent buildImage(String location, String widthSource, String wikiId, boolean isModAsset, int contentWidthPx) { + public static UIComponent buildImage(String location, String widthSource, String wikiId, int contentWidthPx) { float widthRatio = convertImageWidth(widthSource); if (widthRatio <= 0) widthRatio = 0.5f; if (location.startsWith("@")) location = location.substring(1); @@ -533,14 +533,11 @@ public static UIComponent buildImage(String location, String widthSource, String // case 2: texture path String assetsRoot = OracleClient.getWikiFormat(wikiId).getAssetsRoot(); Identifier searchPath; - if (isModAsset) { - var parts = location.split(":", 2); - var imageModId = parts.length > 0 ? parts[0] : wikiId; - var imagePath = parts.length > 1 ? parts[1] : location; - searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + imageModId + "/" + imagePath + ".png"); - } else { - searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + wikiId + "/" + location + ".png"); - } + var parts = location.split(":", 2); + var imageModId = parts.length > 0 ? parts[0] : wikiId; + var imagePath = parts.length > 1 ? parts[1] : location; + var extension = imagePath.contains(".") ? "" : ".png"; + searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + imageModId + "/" + imagePath + extension); var rm = MinecraftClient.getInstance().getResourceManager(); var resource = rm.getResource(searchPath); diff --git a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java index 0c71765..bb4e00d 100644 --- a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java +++ b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java @@ -15,9 +15,9 @@ public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockPar if (line.startsWith(" Date: Wed, 10 Jun 2026 18:47:28 +0200 Subject: [PATCH 08/17] Add support for ref page links --- .../main/java/rearth/oracle/OracleClient.java | 4 + .../java/rearth/oracle/SemanticSearch.java | 15 +- .../java/rearth/oracle/docs/DocsFormat.java | 2 + .../java/rearth/oracle/docs/DocsIndexer.java | 84 +++++-- .../rearth/oracle/docs/LegacyDocsFormat.java | 5 + .../java/rearth/oracle/docs/V1DocsFormat.java | 5 + .../rearth/oracle/util/MarkdownParser.java | 211 ++++++++++-------- .../rearth/oracle/test/MarkdownTests.java | 6 +- 8 files changed, 218 insertions(+), 114 deletions(-) diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java index bc56cde..e63808f 100644 --- a/common/src/main/java/rearth/oracle/OracleClient.java +++ b/common/src/main/java/rearth/oracle/OracleClient.java @@ -39,6 +39,7 @@ public final class OracleClient { public static final HashMap> UNLOCK_CRITERIONS = new HashMap<>(); // path/key here is: "books/modid/folder/entry.mdx". Value is unlock type and content public static final HashMap> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) public static final HashMap CONTENT_ID_MAP = new HashMap<>();// item / block id -> resource path (e.g., "oritech:enderic_laser" -> "oracle_index:books/oritech/.content/machines/laser.mdx") + public static final HashMap CONTENT_REF_MAP = new HashMap<>();// page ref -> resource path (e.g., "colored_cables" -> "oracle_index:books/oritech/.content/cabling/colored_cables.mdx") public static ItemStack tooltipStack; public static float openEntryProgress = 0; @@ -170,6 +171,9 @@ private static void findAllResourceEntries(ResourceManager manager) { CONTENT_ID_MAP.clear(); CONTENT_ID_MAP.putAll(indexer.getContentIds()); + + CONTENT_REF_MAP.clear(); + CONTENT_REF_MAP.putAll(indexer.getContentRefs()); AVAILABLE_MODES.clear(); AVAILABLE_MODES.putAll(indexer.getAvailableModes()); diff --git a/common/src/main/java/rearth/oracle/SemanticSearch.java b/common/src/main/java/rearth/oracle/SemanticSearch.java index 79bedcd..8d93315 100644 --- a/common/src/main/java/rearth/oracle/SemanticSearch.java +++ b/common/src/main/java/rearth/oracle/SemanticSearch.java @@ -12,6 +12,7 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.util.Identifier; import rearth.oracle.util.MarkdownParser; +import rearth.oracle.util.MarkdownParser.Frontmatter; import java.io.IOException; import java.io.InvalidObjectException; @@ -78,9 +79,13 @@ public SemanticSearch(BiPredicate filter) { try { var fileContent = new String(resources.get(resourceId).getInputStream().readAllBytes(), StandardCharsets.UTF_8); - var fileComponents = MarkdownParser.parseFrontmatter(fileContent); - + var frontmatter = MarkdownParser.parseFrontmatter(fileContent); + // generate embeddings + var fileComponents = new HashMap(); + frontmatter.map().forEach((k, v) -> { + if (v.size() == 1) fileComponents.put(k, v.getFirst()); + }); this.queueEmbeddingsJob(modId, entryDirectory, entryFileName, fileComponents, fileContent); @@ -128,12 +133,12 @@ public ArrayList search(String query) { var id = match.embedded().metadata().getString("wiki") + ":" + match.embedded().metadata().getString("category") + match.embedded().metadata().getString("fileName"); var title = match.embedded().metadata().getString("title"); if (title == null) { - var frontmatter = new HashMap(); + var frontmatter = new HashMap>(); for (var data : match.embedded().metadata().toMap().entrySet()) { if (data.getValue() instanceof String value) - frontmatter.put(data.getKey(), value); + frontmatter.put(data.getKey(), List.of(value)); } - title = MarkdownParser.getTitle(frontmatter, Identifier.of(id)); + title = MarkdownParser.getTitle(new Frontmatter(frontmatter), Identifier.of(id)); } // check if id already exists, add it to alt texts diff --git a/common/src/main/java/rearth/oracle/docs/DocsFormat.java b/common/src/main/java/rearth/oracle/docs/DocsFormat.java index 2e5bdf5..3497d9f 100644 --- a/common/src/main/java/rearth/oracle/docs/DocsFormat.java +++ b/common/src/main/java/rearth/oracle/docs/DocsFormat.java @@ -12,4 +12,6 @@ public interface DocsFormat { String getAssetsRoot(); String getDocsPagePath(String slug); + + String stripContentPrefix(String path); } diff --git a/common/src/main/java/rearth/oracle/docs/DocsIndexer.java b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java index 49a47f0..373b670 100644 --- a/common/src/main/java/rearth/oracle/docs/DocsIndexer.java +++ b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java @@ -14,6 +14,7 @@ import rearth.oracle.Oracle; import rearth.oracle.OracleClient.ItemArticleRef; import rearth.oracle.util.MarkdownParser; +import rearth.oracle.util.MarkdownParser.Frontmatter; import java.io.IOException; import java.io.InputStreamReader; @@ -30,12 +31,13 @@ public class DocsIndexer { private static final Logger LOGGER = LogUtils.getLogger(); private static final Gson GSON = new Gson(); - + private final Map loadedWikis; private final Map itemLinks; private final Map> unlockCriterions; private final Map> availableModes; private final Map contentIds; + private final Map contentRefs; public DocsIndexer() { this.loadedWikis = new HashMap<>(); @@ -43,6 +45,7 @@ public DocsIndexer() { this.unlockCriterions = new HashMap<>(); this.availableModes = new HashMap<>(); this.contentIds = new HashMap<>(); + this.contentRefs = new HashMap<>(); } public Map getLoadedWikis() { @@ -65,6 +68,10 @@ public Map getContentIds() { return contentIds; } + public Map getContentRefs() { + return contentRefs; + } + public void findAllResourceEntries(ResourceManager manager) { var resources = manager.findResources(ROOT_DIR, path -> path.getPath().endsWith(".mdx")); @@ -111,25 +118,35 @@ private void processEntry(String modId, Identifier resourceId, Resource resource var frontmatter = MarkdownParser.parseFrontmatter(fileContent); // map game id for content links - if (isContent && frontmatter.containsKey("id")) { - var id = frontmatter.get("id").trim(); - this.contentIds.put(id, resourceId); - var itemId = Identifier.of(id); - - Supplier lazyTitle = null; - var title = frontmatter.getOrDefault("title", "missing"); - if (title.equals("missing") && Registries.ITEM.containsId(itemId)) { - // Translations may not be available at this time yet - lazyTitle = Suppliers.memoize(() -> I18n.translate(Registries.ITEM.get(itemId).getTranslationKey())); - } else { - lazyTitle = () -> title; + if (isContent) { + List ids = frontmatter.getAll("id"); + String ref = computePageRef(resourceId, format, frontmatter, ids); + this.contentRefs.put(ref, resourceId); + + if (frontmatter.containsKey("id")) { + for (String id : ids) { + this.contentIds.put(id, resourceId); + + var itemId = Identifier.of(id); + + Supplier lazyTitle; + var title = frontmatter.getOrDefault("title", "missing"); + if (title.equals("missing") && Registries.ITEM.containsId(itemId)) { + // Use supplier as translations may not be available at this time yet + lazyTitle = Suppliers.memoize(() -> I18n.translate(Registries.ITEM.get(itemId).getTranslationKey())); + } else { + lazyTitle = () -> title; + } + + // TODO Pick best page for item + this.itemLinks.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId)); + } } - this.itemLinks.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId)); } // frontmatter custom item links indexing if (frontmatter.containsKey("related_items")) { - var baseString = frontmatter.get("related_items").replace("[", "").replace("]", "").replace("\"", ""); + var baseString = frontmatter.getOne("related_items").replace("[", "").replace("]", "").replace("\"", ""); for (var itemString : baseString.split(", ")) { var itemId = Identifier.of(itemString.trim()); var title = frontmatter.getOrDefault("title", "missing"); @@ -138,7 +155,7 @@ private void processEntry(String modId, Identifier resourceId, Resource resource } if (frontmatter.containsKey("unlock")) { - var unlockText = frontmatter.get("unlock"); + var unlockText = frontmatter.getOne("unlock"); var parts = unlockText.split(":", 2); if (parts.length == 2) { this.unlockCriterions.put(path, new Pair<>(parts[0], parts[1])); @@ -163,6 +180,41 @@ private static DocsFormat detectDocsFormat(ResourceManager manager, String wikiI return new LegacyDocsFormat(); } + /** + * Compute page ref using the same process as the wiki web + */ + @Nullable + private String computePageRef(Identifier resourceId, DocsFormat format, Frontmatter frontmatter, List ids) { + // 1. Try using user-specified ref + var userRef = frontmatter.getOne("ref"); + if (userRef != null && !this.contentRefs.containsKey(userRef)) { + return userRef; + } + + // 2. Try deriving the ref from a single specified item ID + if (ids.size() == 1) { + var id = Identifier.tryParse(ids.getFirst()); + if (id != null) { + var primaryRef = id.getPath().replace("/", "_"); + if (!this.contentRefs.containsKey(primaryRef)) { + return primaryRef; + } + } + } + + // 3. Try deriving from the page file name + var relativePath = format.stripContentPrefix(resourceId.getPath()); + var stripped = List.of(relativePath.split("\\.")).getLast(); + var fileName = List.of(stripped.split("/")).getLast(); + var normalized = fileName.replace("/", "_"); + if (!this.contentRefs.containsKey(normalized)) { + return normalized; + } + + // 4. Use the full path + return stripped.replace("/", "_"); + } + @Nullable private static WikiMetaStub parseWikiMeta(Resource resource) { try (Reader reader = new InputStreamReader(resource.getInputStream())) { diff --git a/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java b/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java index 48d6be8..01565a1 100644 --- a/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java +++ b/common/src/main/java/rearth/oracle/docs/LegacyDocsFormat.java @@ -33,4 +33,9 @@ public String getAssetsRoot() { public String getDocsPagePath(String slug) { return slug; } + + @Override + public String stripContentPrefix(String path) { + return path.split("/\\.content/")[1]; + } } diff --git a/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java b/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java index eaffd5c..33b474f 100644 --- a/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java +++ b/common/src/main/java/rearth/oracle/docs/V1DocsFormat.java @@ -33,4 +33,9 @@ public String getAssetsRoot() { public String getDocsPagePath(String slug) { return "docs/" + slug; } + + @Override + public String stripContentPrefix(String path) { + return path.split("/content/")[1]; + } } diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index 8ffb140..3348f2b 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -36,21 +36,21 @@ * Replaces the previous owo-lib-based parser. */ public class MarkdownParser { - + private static final String[] removedLines = {"
", "
", "
", "
", "", ""}; - + private static final List EXTENSIONS = List.of(YamlFrontMatterExtension.create()); private static final Set> ENABLED_BLOCKS = Set.of( - Heading.class, HtmlBlock.class, ThematicBreak.class, - FencedCodeBlock.class, BlockQuote.class, ListBlock.class + Heading.class, HtmlBlock.class, ThematicBreak.class, + FencedCodeBlock.class, BlockQuote.class, ListBlock.class ); - + private static final Parser PARSER = Parser.builder() - .enabledBlockTypes(ENABLED_BLOCKS) - .extensions(EXTENSIONS) - .customBlockParserFactory(new MdxBlockFactory()) - .build(); - + .enabledBlockTypes(ENABLED_BLOCKS) + .extensions(EXTENSIONS) + .customBlockParserFactory(new MdxBlockFactory()) + .build(); + /** * Parse markdown and produce a list of top-level widgets. * @@ -60,55 +60,55 @@ public class MarkdownParser { public static List parseMarkdownToWidgets(String markdown, String wikiId, Identifier currentPath, Predicate linkHandler, int contentWidthPx) { for (var toRemove : removedLines) markdown = markdown.replace(toRemove, ""); - + var document = PARSER.parse(markdown); var yamlVisitor = new YamlFrontMatterVisitor(); document.accept(yamlVisitor); - + var frontMatter = parseFrontmatter(markdown); - + var visitor = new WikiMarkdownVisitor(linkHandler, wikiId, currentPath, contentWidthPx); document.accept(visitor); - + var widgets = new ArrayList(); widgets.add(buildTitlePanel(linkHandler, frontMatter, currentPath, contentWidthPx)); widgets.addAll(visitor.results()); - + if (frontMatter.containsKey("id")) { - var gameId = frontMatter.get("id"); + var gameId = frontMatter.getOne("id"); var id = Identifier.of(gameId); if (Registries.ITEM.containsId(id) || Registries.BLOCK.containsId(id)) widgets.add(buildPropertiesPanel(ContentProperties.getProperties(gameId), contentWidthPx)); } - + return widgets; } - + // ---------------------------------------------------------------- visitor - + private static class WikiMarkdownVisitor extends AbstractVisitor { - + private final Predicate linkHandler; private final String wikiId; private final Identifier contentPath; private final int contentWidthPx; - + private List components = new ArrayList<>(); private MutableText buffer = Text.empty(); private Style currentStyle = Style.EMPTY; private int currentIndentation = 0; - + WikiMarkdownVisitor(Predicate linkHandler, String wikiId, Identifier contentPath, int contentWidthPx) { this.linkHandler = linkHandler; this.wikiId = wikiId; this.contentPath = contentPath; this.contentWidthPx = contentWidthPx; } - + List results() { return components; } - + private void flushBuffer() { if (buffer == null || buffer.getString().isEmpty()) return; var label = new LabelWidget(buffer).linkHandler(linkHandler).lineSpacing(1).fillWidth(); @@ -117,13 +117,13 @@ private void flushBuffer() { buffer = Text.empty(); currentIndentation = 0; } - + @Override public void visit(Paragraph paragraph) { visitChildren(paragraph); flushBuffer(); } - + @Override public void visit(Heading heading) { buffer = Text.empty(); @@ -131,14 +131,14 @@ public void visit(Heading heading) { currentStyle = currentStyle.withColor(Formatting.GRAY); visitChildren(heading); currentStyle = oldStyle; - + var label = new LabelWidget(buffer).linkHandler(linkHandler).fillWidth(); label.scale(Math.max(1.0f, 2.0f - heading.getLevel() * 0.2f)); label.setPadding(Insets.of(10, 5, 0, 0)); components.add(label); buffer = Text.empty(); } - + @Override public void visit(FencedCodeBlock codeBlock) { flushBuffer(); @@ -149,17 +149,17 @@ public void visit(FencedCodeBlock codeBlock) { panel.child(new LabelWidget(text)); components.add(panel); } - + @Override public void visit(BulletList l) { visitChildren(l); } - + @Override public void visit(OrderedList l) { visitChildren(l); } - + @Override public void visit(ListItem listItem) { var parent = listItem.getParent(); @@ -170,7 +170,7 @@ public void visit(ListItem listItem) { ancestor = ancestor.getParent(); } this.currentIndentation = depth - 1; - + if (parent instanceof BulletList) { buffer.append(Text.literal("• ").formatted(Formatting.DARK_GRAY)); } else if (parent instanceof OrderedList orderedList) { @@ -187,7 +187,7 @@ public void visit(ListItem listItem) { flushBuffer(); this.currentIndentation = 0; } - + @Override public void visit(CustomBlock customBlock) { if (customBlock instanceof MdxComponentBlock.CraftingRecipeBlock recipe) { @@ -201,24 +201,24 @@ public void visit(CustomBlock customBlock) { visitChildren(callout); flushBuffer(); this.components = oldComponents; - + var widget = new CalloutWidget(callout.variant); for (var c : inner) widget.addBodyChild(c); components.add(widget); } } - + @Override public void visit(Image image) { flushBuffer(); components.add(buildImage(image.getDestination(), "60%", wikiId, contentWidthPx)); } - + @Override public void visit(org.commonmark.node.Text text) { if (buffer != null) buffer.append(Text.literal(text.getLiteral()).setStyle(currentStyle)); } - + @Override public void visit(StrongEmphasis e) { var old = currentStyle; @@ -226,7 +226,7 @@ public void visit(StrongEmphasis e) { visitChildren(e); currentStyle = old; } - + @Override public void visit(Emphasis e) { var old = currentStyle; @@ -234,13 +234,13 @@ public void visit(Emphasis e) { visitChildren(e); currentStyle = old; } - + @Override public void visit(Link link) { var old = currentStyle; var clickEvent = new ClickEvent(ClickEvent.Action.OPEN_URL, link.getDestination()); currentStyle = currentStyle.withColor(Formatting.BLUE).withUnderline(true).withClickEvent(clickEvent); - + if (link.getFirstChild() == null && (link.getTitle() == null || link.getTitle().isBlank())) { var linkTitle = getLinkText(link.getDestination(), wikiId, contentPath); buffer.append(Text.literal(linkTitle).setStyle(currentStyle)); @@ -248,27 +248,27 @@ public void visit(Link link) { visitChildren(link); currentStyle = old; } - + @Override public void visit(Code inlineCode) { if (buffer != null) { buffer.append(Text.literal(inlineCode.getLiteral()).formatted(Formatting.RED)); } } - + @Override public void visit(SoftLineBreak n) { if (buffer != null) buffer.append(Text.literal(" ")); } - + @Override public void visit(HardLineBreak n) { if (buffer != null) buffer.append(Text.literal("\n")); } } - + // ---------------------------------------------------------------- helpers - + public static String getLinkText(String link, String activeWikiId, Identifier sourceEntryPath) { var linkTarget = getLinkTarget(link, activeWikiId, sourceEntryPath); if (linkTarget == null) return ""; @@ -284,7 +284,7 @@ public static String getLinkText(String link, String activeWikiId, Identifier so return ""; } } - + @Nullable public static Identifier getLinkTarget(String link, String activeWikiId, Identifier sourceEntryPath) { Identifier targetFile; @@ -296,6 +296,9 @@ public static Identifier getLinkTarget(String link, String activeWikiId, Identif var p = "books/" + activeWikiId + "/" + format.getDocsPagePath(link.substring(1)); if (!p.endsWith(".mdx")) p += ".mdx"; targetFile = Identifier.of(Oracle.MOD_ID, p); + } else if (link.startsWith("+")) { + var id = link.substring(1); + targetFile = OracleClient.CONTENT_REF_MAP.get(id); } else { var p = OracleScreen.parsePathLink(link, sourceEntryPath); if (!p.endsWith(".mdx")) p += ".mdx"; @@ -303,20 +306,23 @@ public static Identifier getLinkTarget(String link, String activeWikiId, Identif } return targetFile; } - - public static String getTitle(Map frontMatter, Identifier pagePath) { - if (frontMatter.containsKey("title")) return frontMatter.get("title"); - if (frontMatter.containsKey("id")) { - var item = frontMatter.get("id"); + + public static String getTitle(Frontmatter frontMatter, Identifier pagePath) { + String title = frontMatter.getOne("title"); + if (title != null) return title; + + String item = frontMatter.getOne("id"); + if (item != null) { if (Identifier.validate(item).isSuccess() && Registries.ITEM.containsId(Identifier.of(item))) { return I18n.translate(Registries.ITEM.get(Identifier.of(item)).getTranslationKey()); } return item; } + return OracleScreen.PAGE_FALLBACK_NAMES.getOrDefault(pagePath, "No title found"); } - - private static UIComponent buildTitlePanel(Predicate linkHandler, Map frontMatter, Identifier pageId, int contentWidthPx) { + + private static UIComponent buildTitlePanel(Predicate linkHandler, Frontmatter frontMatter, Identifier pageId, int contentWidthPx) { ItemStack iconStack = ItemStack.EMPTY; var iconId = frontMatter.getOrDefault("icon", ""); if (iconId.isBlank()) iconId = frontMatter.getOrDefault("id", ""); @@ -325,25 +331,25 @@ private static UIComponent buildTitlePanel(Predicate linkHandler, Map linkHandler, int contentWidthPx) { this.titleLabel = new LabelWidget(title).scale(2f).linkHandler(linkHandler); this.icon = iconStack.isEmpty() ? null : new ItemWidget(iconStack); @@ -353,7 +359,7 @@ private static class PageTitleWidget extends UIComponent { } this.contentWidthPx = contentWidthPx; } - + @Override public int getPreferredWidth(int widthHint) { int maxWidth = widthHint > 0 ? widthHint : contentWidthPx; @@ -362,7 +368,7 @@ public int getPreferredWidth(int widthHint) { int titlePanelWidth = titleLabel.getPreferredWidth(labelMaxWidth) + TITLE_PAD_X * 2; return leadingWidth() + titlePanelWidth; } - + @Override public int getPreferredHeight(int widthHint) { int maxWidth = widthHint > 0 ? widthHint : contentWidthPx; @@ -371,7 +377,7 @@ public int getPreferredHeight(int widthHint) { int titlePanelHeight = titleLabel.getPreferredHeight(labelMaxWidth) + TITLE_PAD_Y * 2; return Math.max(icon == null ? 0 : ICON_PANEL_SIZE, titlePanelHeight); } - + @Override public void layout(int parentWidthHint, int parentHeightHint) { int labelMaxWidth = Math.max(80, width - leadingWidth() - TITLE_PAD_X * 2); @@ -394,7 +400,7 @@ public void layout(int parentWidthHint, int parentHeightHint) { icon.layout(ICON_ITEM_SIZE, ICON_ITEM_SIZE); } } - + @Override protected void renderContent(DrawContext context, int mouseX, int mouseY, float delta) { WikiSurface.BEDROCK_PANEL.render(context, titleX, titleY, titleW, titleH); @@ -404,22 +410,22 @@ protected void renderContent(DrawContext context, int mouseX, int mouseY, float icon.render(context, mouseX, mouseY, delta); } } - + @Override public List tooltip(int mouseX, int mouseY) { if (icon != null && icon.isInBounds(mouseX, mouseY)) return icon.tooltip(mouseX, mouseY); return super.tooltip(mouseX, mouseY); } - + private int leadingWidth() { return icon == null ? 0 : ICON_PANEL_SIZE - TITLE_OVERLAP; } - + private int labelMaxWidth(int maxWidth) { return Math.max(80, maxWidth - leadingWidth() - TITLE_PAD_X * 2); } } - + private static UIComponent buildPropertiesPanel(Map properties, int contentWidthPx) { var tr = MinecraftClient.getInstance().textRenderer; int titleWidth = tr.getWidth("Details"); @@ -436,32 +442,32 @@ private static UIComponent buildPropertiesPanel(Map properties, in outer.size(innerWidth, 0); outer.horizontalAlignment(FlowWidget.HorizontalAlignment.CENTER); outer.child(new LabelWidget(Text.literal("Details").formatted(Formatting.BOLD, Formatting.GRAY))); - + for (var entry : properties.entrySet()) { outer.child(new PropertyRowWidget(Text.literal(entry.getKey()).formatted(Formatting.GOLD), entry.getValue())); } return outer; } - + private static class PropertyRowWidget extends UIComponent { private final Text key; private final Text value; - + PropertyRowWidget(Text key, Text value) { this.key = key; this.value = value; } - + @Override public int getPreferredWidth(int widthHint) { return widthHint > 0 ? widthHint : MinecraftClient.getInstance().textRenderer.getWidth(key) + 28 + MinecraftClient.getInstance().textRenderer.getWidth(value); } - + @Override public int getPreferredHeight(int widthHint) { return MinecraftClient.getInstance().textRenderer.fontHeight; } - + @Override protected void renderContent(DrawContext context, int mouseX, int mouseY, float delta) { var tr = MinecraftClient.getInstance().textRenderer; @@ -469,12 +475,12 @@ protected void renderContent(DrawContext context, int mouseX, int mouseY, float context.drawText(tr, value, x + width - tr.getWidth(value), y, 0xFFFFFFFF, false); } } - + public static UIComponent buildRecipe(List inputs, String resultId, int resultCount) { if (inputs.size() != 9) { return new LabelWidget(Text.literal("Invalid crafting recipe data: expected 9 inputs").formatted(Formatting.RED)); } - + // Layered: a 3x3 grid of slots with items overlaid on top. var grid = new GridWidget(3, 3, ItemSlotWidget.SLOT_SIZE, ItemSlotWidget.SLOT_SIZE).gap(0, 0); grid.setPadding(Insets.of(3)); @@ -488,18 +494,18 @@ public static UIComponent buildRecipe(List inputs, String resultId, int var item = new ItemWidget(stack); grid.set(i / 3, i % 3, new ItemSlotWidget(item)); } - + // → arrow var arrow = new TextureWidget(Identifier.of(Oracle.MOD_ID, "textures/arrow_empty.png"), 29, 16); - + // result slot var resultIdObj = Identifier.of(resultId); var resultStack = Registries.ITEM.containsId(resultIdObj) - ? new ItemStack(Registries.ITEM.get(resultIdObj), resultCount) - : ItemStack.EMPTY; + ? new ItemStack(Registries.ITEM.get(resultIdObj), resultCount) + : ItemStack.EMPTY; var result = new ItemWidget(resultStack); var resultSlot = new ItemSlotWidget(result); - + var panel = FlowWidget.horizontal().gap(8); panel.setSurface(WikiSurface.BEDROCK_PANEL); panel.setPadding(Insets.of(8)); @@ -509,15 +515,15 @@ public static UIComponent buildRecipe(List inputs, String resultId, int panel.child(resultSlot); return panel; } - + public static UIComponent buildImage(String location, String widthSource, String wikiId, int contentWidthPx) { float widthRatio = convertImageWidth(widthSource); if (widthRatio <= 0) widthRatio = 0.5f; if (location.startsWith("@")) location = location.substring(1); - + // available pixel budget after scrollbar gutter + a tiny breathing margin int budget = Math.max(16, contentWidthPx - 12); - + // case 1: ingame item → render as ItemWidget var itemIdCandidate = Identifier.of(location); if (Registries.ITEM.containsId(itemIdCandidate)) { @@ -529,7 +535,7 @@ public static UIComponent buildImage(String location, String widthSource, String itemWidget.setHideItemDecorations(true); return itemWidget; } - + // case 2: texture path String assetsRoot = OracleClient.getWikiFormat(wikiId).getAssetsRoot(); Identifier searchPath; @@ -538,7 +544,7 @@ public static UIComponent buildImage(String location, String widthSource, String var imagePath = parts.length > 1 ? parts[1] : location; var extension = imagePath.contains(".") ? "" : ".png"; searchPath = Identifier.of(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + imageModId + "/" + imagePath + extension); - + var rm = MinecraftClient.getInstance().getResourceManager(); var resource = rm.getResource(searchPath); if (resource.isEmpty()) { @@ -557,25 +563,25 @@ public static UIComponent buildImage(String location, String widthSource, String return new LabelWidget(Text.literal("Error reading image: " + location).formatted(Formatting.RED)); } } - - public static Map parseFrontmatter(String markdown) { + + public static Frontmatter parseFrontmatter(String markdown) { var document = PARSER.parse(markdown); var yamlVisitor = new YamlFrontMatterVisitor(); document.accept(yamlVisitor); var frontmatter = yamlVisitor.getData(); try { - var simple = new HashMap(); + var inner = new HashMap>(); for (var pair : frontmatter.entrySet()) { if (pair.getValue().isEmpty()) continue; - simple.put(pair.getKey(), pair.getValue().getFirst().trim()); + inner.put(pair.getKey(), pair.getValue().stream().map(String::trim).toList()); } - return simple; + return new Frontmatter(inner); } catch (RuntimeException ex) { Oracle.LOGGER.warn("Error parsing markdown frontmatter: {} in {}", ex, markdown); - return new HashMap<>(); + return new Frontmatter(Map.of()); } } - + public static float convertImageWidth(String input) { if (input == null || input.isEmpty()) return 0.0f; var trimmed = input.trim(); @@ -602,4 +608,29 @@ public static float convertImageWidth(String input) { } return 0.0f; } + + public record Frontmatter(Map> map) { + @Nullable + public List getAll(String key) { + return this.map.get(key); + } + + @Nullable + public String getOne(String key) { + List values = this.map.get(key); + if (values == null) { + return null; + } + return values.size() == 1 ? values.getFirst() : null; + } + + public String getOrDefault(String key, String _default) { + String value = getOne(key); + return value != null ? value : _default; + } + + public boolean containsKey(String key) { + return this.map.containsKey(key); + } + } } diff --git a/fabric/src/main/java/rearth/oracle/test/MarkdownTests.java b/fabric/src/main/java/rearth/oracle/test/MarkdownTests.java index c19f4ef..c7e6b62 100644 --- a/fabric/src/main/java/rearth/oracle/test/MarkdownTests.java +++ b/fabric/src/main/java/rearth/oracle/test/MarkdownTests.java @@ -7,12 +7,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import rearth.oracle.util.MarkdownParser; +import rearth.oracle.util.MarkdownParser.Frontmatter; import rearth.oracle.util.MdxBlockFactory; import rearth.oracle.util.MdxComponentBlock; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -38,8 +38,8 @@ class MarkdownTests { @DisplayName("Frontmatter: Extraction logic") void testFrontMatter() { String md = "---\ntitle: Nickel\n---\nBody"; - Map data = MarkdownParser.parseFrontmatter(md); - assertEquals("Nickel", data.get("title")); + Frontmatter data = MarkdownParser.parseFrontmatter(md); + assertEquals("Nickel", data.getOne("title")); } @Test From 11d311be738ea72f2171182d9610add54ae2b2ed Mon Sep 17 00:00:00 2001 From: Su5eD Date: Wed, 10 Jun 2026 18:57:17 +0200 Subject: [PATCH 09/17] Fix vanilla link text inferrence --- .../rearth/oracle/util/MarkdownParser.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index 3348f2b..f12e59e 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -4,6 +4,7 @@ import net.minecraft.client.gui.DrawContext; import net.minecraft.client.resource.language.I18n; import net.minecraft.client.texture.NativeImage; +import net.minecraft.item.Item; import net.minecraft.item.ItemStack; import net.minecraft.registry.Registries; import net.minecraft.text.ClickEvent; @@ -243,7 +244,7 @@ public void visit(Link link) { if (link.getFirstChild() == null && (link.getTitle() == null || link.getTitle().isBlank())) { var linkTitle = getLinkText(link.getDestination(), wikiId, contentPath); - buffer.append(Text.literal(linkTitle).setStyle(currentStyle)); + buffer.append(linkTitle.setStyle(currentStyle)); } visitChildren(link); currentStyle = old; @@ -269,7 +270,21 @@ public void visit(HardLineBreak n) { // ---------------------------------------------------------------- helpers - public static String getLinkText(String link, String activeWikiId, Identifier sourceEntryPath) { + public static MutableText getLinkText(String link, String activeWikiId, Identifier sourceEntryPath) { + if (link.startsWith("@")) { + Identifier id = Identifier.tryParse(link.substring(1)); + if (id != null && id.getNamespace().equals(Identifier.DEFAULT_NAMESPACE)) { + Item item = Registries.ITEM.get(id); + if (item != null) { + return Text.translatable(item.getTranslationKey()); + } + } + } + + return Text.literal(getLinkTextLiteral(link, activeWikiId, sourceEntryPath)); + } + + public static String getLinkTextLiteral(String link, String activeWikiId, Identifier sourceEntryPath) { var linkTarget = getLinkTarget(link, activeWikiId, sourceEntryPath); if (linkTarget == null) return ""; var rm = MinecraftClient.getInstance().getResourceManager(); @@ -614,7 +629,7 @@ public record Frontmatter(Map> map) { public List getAll(String key) { return this.map.get(key); } - + @Nullable public String getOne(String key) { List values = this.map.get(key); From 0283a5a13781aa770caf48ab312d4c4abb20c1a9 Mon Sep 17 00:00:00 2001 From: Su5eD Date: Thu, 11 Jun 2026 14:11:33 +0200 Subject: [PATCH 10/17] Add support for multi-item pages --- .../main/java/rearth/oracle/OracleClient.java | 18 ++- .../java/rearth/oracle/docs/DocsIndexer.java | 30 ++++- .../rearth/oracle/mixin/DrawContextMixin.java | 2 + .../java/rearth/oracle/ui/SearchScreen.java | 2 +- .../rearth/oracle/ui/widgets/ItemWidget.java | 20 +-- .../rearth/oracle/ui/widgets/TooltipMode.java | 7 ++ .../rearth/oracle/util/MarkdownParser.java | 118 +++++++++++++++--- 7 files changed, 162 insertions(+), 35 deletions(-) create mode 100644 common/src/main/java/rearth/oracle/ui/widgets/TooltipMode.java diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java index e63808f..d279a27 100644 --- a/common/src/main/java/rearth/oracle/OracleClient.java +++ b/common/src/main/java/rearth/oracle/OracleClient.java @@ -35,11 +35,11 @@ public final class OracleClient { public static final KeyBinding ORACLE_SEARCH = new KeyBinding("key.oracle_index.search", -1, "key.categories.oracle"); public static final Map LOADED_WIKIS = new HashMap<>(); // map of loaded wiki ids to formats (specifies directory layout) - public static final HashMap ITEM_LINKS = new HashMap<>(); // items that have a corresponding wiki page (docs or content) - public static final HashMap> UNLOCK_CRITERIONS = new HashMap<>(); // path/key here is: "books/modid/folder/entry.mdx". Value is unlock type and content - public static final HashMap> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) - public static final HashMap CONTENT_ID_MAP = new HashMap<>();// item / block id -> resource path (e.g., "oritech:enderic_laser" -> "oracle_index:books/oritech/.content/machines/laser.mdx") - public static final HashMap CONTENT_REF_MAP = new HashMap<>();// page ref -> resource path (e.g., "colored_cables" -> "oracle_index:books/oritech/.content/cabling/colored_cables.mdx") + public static final Map ITEM_LINKS = new HashMap<>(); // items that have a corresponding wiki page (docs or content) + public static final Map> UNLOCK_CRITERIONS = new HashMap<>(); // path/key here is: "books/modid/folder/entry.mdx". Value is unlock type and content + public static final Map> AVAILABLE_MODES = new HashMap<>(); // wikiID -> Set of available modes (e.g., "oritech" -> ["docs", "content"]) + public static final Map CONTENT_ID_MAP = new HashMap<>();// item / block id -> resource path (e.g., "oritech:enderic_laser" -> "oracle_index:books/oritech/.content/machines/laser.mdx") + public static final Map> CONTENT_REF_MAP = new HashMap<>();// page ref -> resource path (e.g., "colored_cables" -> "oracle_index:books/oritech/.content/cabling/colored_cables.mdx") public static ItemStack tooltipStack; public static float openEntryProgress = 0; @@ -155,6 +155,12 @@ public static DocsMode getDocsModeForPage(Identifier pageId) { public static DocsFormat getWikiFormat(String wikiId) { return Objects.requireNonNull(LOADED_WIKIS.get(wikiId), "unknown wiki id"); } + + @Nullable + public static Identifier getPage(String wikiId, String ref) { + Map refs = CONTENT_REF_MAP.get(wikiId); + return refs != null ? refs.get(ref) : null; + } private static void findAllResourceEntries(ResourceManager manager) { DocsIndexer indexer = new DocsIndexer(); @@ -216,7 +222,7 @@ public static Optional getTranslatedPath(Identifier identifier, Stri return Optional.empty(); } - public record ItemArticleRef(Identifier linkTarget, Supplier entryName, String wikiId) { + public record ItemArticleRef(Identifier linkTarget, Supplier entryName, String wikiId, int pageIDs) { } } diff --git a/common/src/main/java/rearth/oracle/docs/DocsIndexer.java b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java index 373b670..ccb1479 100644 --- a/common/src/main/java/rearth/oracle/docs/DocsIndexer.java +++ b/common/src/main/java/rearth/oracle/docs/DocsIndexer.java @@ -1,6 +1,8 @@ package rearth.oracle.docs; import com.google.common.base.Suppliers; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; import com.google.gson.Gson; import com.mojang.logging.LogUtils; import net.minecraft.client.resource.language.I18n; @@ -21,6 +23,7 @@ import java.io.Reader; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.Map.Entry; import java.util.function.Supplier; import static rearth.oracle.OracleClient.ROOT_DIR; @@ -33,14 +36,16 @@ public class DocsIndexer { private static final Gson GSON = new Gson(); private final Map loadedWikis; + private final Multimap itemLinkCandidates; private final Map itemLinks; private final Map> unlockCriterions; private final Map> availableModes; private final Map contentIds; - private final Map contentRefs; + private final Map> contentRefs; public DocsIndexer() { this.loadedWikis = new HashMap<>(); + this.itemLinkCandidates = HashMultimap.create(); this.itemLinks = new HashMap<>(); this.unlockCriterions = new HashMap<>(); this.availableModes = new HashMap<>(); @@ -68,7 +73,7 @@ public Map getContentIds() { return contentIds; } - public Map getContentRefs() { + public Map> getContentRefs() { return contentRefs; } @@ -98,6 +103,12 @@ public void findAllResourceEntries(ResourceManager manager) { processEntry(modId, entry.id(), entry.resource(), format); } } + + for (Entry> entry : this.itemLinkCandidates.asMap().entrySet()) { + Identifier itemId = entry.getKey(); + ItemArticleRef bestMatch = findBestMatch(entry.getValue()); + this.itemLinks.put(itemId, bestMatch); + } } private void processEntry(String modId, Identifier resourceId, Resource resource, DocsFormat format) { @@ -121,7 +132,7 @@ private void processEntry(String modId, Identifier resourceId, Resource resource if (isContent) { List ids = frontmatter.getAll("id"); String ref = computePageRef(resourceId, format, frontmatter, ids); - this.contentRefs.put(ref, resourceId); + this.contentRefs.computeIfAbsent(modId, k -> new HashMap<>()).put(ref, resourceId); if (frontmatter.containsKey("id")) { for (String id : ids) { @@ -139,7 +150,7 @@ private void processEntry(String modId, Identifier resourceId, Resource resource } // TODO Pick best page for item - this.itemLinks.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId)); + this.itemLinkCandidates.put(itemId, new ItemArticleRef(resourceId, lazyTitle, modId, ids.size())); } } } @@ -150,7 +161,7 @@ private void processEntry(String modId, Identifier resourceId, Resource resource for (var itemString : baseString.split(", ")) { var itemId = Identifier.of(itemString.trim()); var title = frontmatter.getOrDefault("title", "missing"); - this.itemLinks.put(itemId, new ItemArticleRef(resourceId, () -> title, modId)); + this.itemLinkCandidates.put(itemId, new ItemArticleRef(resourceId, () -> title, modId, 0)); } } @@ -204,7 +215,7 @@ private String computePageRef(Identifier resourceId, DocsFormat format, Frontmat // 3. Try deriving from the page file name var relativePath = format.stripContentPrefix(resourceId.getPath()); - var stripped = List.of(relativePath.split("\\.")).getLast(); + var stripped = List.of(relativePath.split("\\.")).getFirst(); var fileName = List.of(stripped.split("/")).getLast(); var normalized = fileName.replace("/", "_"); if (!this.contentRefs.containsKey(normalized)) { @@ -234,6 +245,13 @@ public static String extractModid(String path) { return segments[1]; } + private static ItemArticleRef findBestMatch(Collection articles) { + if (articles.size() == 1) { + return articles.iterator().next(); + } + return articles.stream().min(Comparator.comparingInt(ItemArticleRef::pageIDs)).orElseThrow(); + } + record IdentifiedResource(Identifier id, Resource resource) { } diff --git a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java index 2de09fd..3cc86fc 100644 --- a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java +++ b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java @@ -16,6 +16,7 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import rearth.oracle.OracleClient; +import rearth.oracle.ui.OracleScreen; import java.util.ArrayList; import java.util.List; @@ -27,6 +28,7 @@ public class DrawContextMixin { @Inject(method = "drawTooltip(Lnet/minecraft/client/font/TextRenderer;Ljava/util/List;IILnet/minecraft/client/gui/tooltip/TooltipPositioner;)V", at = @At("HEAD")) private void injectTooltipComponents(TextRenderer textRenderer, List components, int x, int y, TooltipPositioner positioner, CallbackInfo ci, @Local(argsOnly = true) LocalRef> componentsRef) { if (OracleClient.tooltipStack == null) return; + if (MinecraftClient.getInstance().currentScreen instanceof OracleScreen) return; var stackItem = OracleClient.tooltipStack.getItem(); var stackId = Registries.ITEM.getId(stackItem); diff --git a/common/src/main/java/rearth/oracle/ui/SearchScreen.java b/common/src/main/java/rearth/oracle/ui/SearchScreen.java index 7720142..4dbb343 100644 --- a/common/src/main/java/rearth/oracle/ui/SearchScreen.java +++ b/common/src/main/java/rearth/oracle/ui/SearchScreen.java @@ -191,7 +191,7 @@ private void onSearchTyped(String query) { var iconStack = new ItemStack(Registries.ITEM.get(Identifier.of(result.iconName()))); var icon = new ItemWidget(iconStack); icon.size(12, 12); - icon.setHideItemTooltip(true); + icon.setTooltipMode(TooltipMode.HIDDEN); icon.setHideItemDecorations(true); titleRow.child(icon); } diff --git a/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java index bd46112..df40745 100644 --- a/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java +++ b/common/src/main/java/rearth/oracle/ui/widgets/ItemWidget.java @@ -15,7 +15,7 @@ public class ItemWidget extends UIComponent { private final ItemStack stack; - private boolean hideItemTooltip; + private TooltipMode tooltipMode; private boolean hideItemDecorations; public ItemWidget(ItemStack stack) { @@ -38,20 +38,24 @@ protected void renderContent(DrawContext context, int mouseX, int mouseY, float if (!hideItemDecorations) context.drawItemInSlot(mc.textRenderer, stack, 0, 0); matrices.pop(); } - - public void setHideItemTooltip(boolean hideItemTooltip) { - this.hideItemTooltip = hideItemTooltip; + + public void setTooltipMode(TooltipMode tooltipMode) { + this.tooltipMode = tooltipMode; } - + public void setHideItemDecorations(boolean hideItemDecorations) { this.hideItemDecorations = hideItemDecorations; } @Override public List tooltip(int mouseX, int mouseY) { - if (stack == null || stack.isEmpty() || hideItemTooltip) return super.tooltip(mouseX, mouseY); + if (stack == null || stack.isEmpty() || tooltipMode == TooltipMode.HIDDEN) return super.tooltip(mouseX, mouseY); var mc = MinecraftClient.getInstance(); - return stack.getTooltip(Item.TooltipContext.create(mc.world), mc.player, - mc.options.advancedItemTooltips ? TooltipType.ADVANCED : TooltipType.BASIC); + List tooltip = stack.getTooltip( + Item.TooltipContext.create(mc.world), + mc.player, + mc.options.advancedItemTooltips ? TooltipType.ADVANCED : TooltipType.BASIC + ); + return tooltipMode == TooltipMode.NAME_ONLY && !tooltip.isEmpty() ? tooltip.subList(0, 1) : tooltip; } } diff --git a/common/src/main/java/rearth/oracle/ui/widgets/TooltipMode.java b/common/src/main/java/rearth/oracle/ui/widgets/TooltipMode.java new file mode 100644 index 0000000..996adb4 --- /dev/null +++ b/common/src/main/java/rearth/oracle/ui/widgets/TooltipMode.java @@ -0,0 +1,7 @@ +package rearth.oracle.ui.widgets; + +public enum TooltipMode { + HIDDEN, + NAME_ONLY, + FULL +} diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index f12e59e..6685087 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -75,8 +75,8 @@ public static List parseMarkdownToWidgets(String markdown, String w widgets.add(buildTitlePanel(linkHandler, frontMatter, currentPath, contentWidthPx)); widgets.addAll(visitor.results()); - if (frontMatter.containsKey("id")) { - var gameId = frontMatter.getOne("id"); + var gameId = frontMatter.getOne("id"); + if (gameId != null) { var id = Identifier.of(gameId); if (Registries.ITEM.containsId(id) || Registries.BLOCK.containsId(id)) widgets.add(buildPropertiesPanel(ContentProperties.getProperties(gameId), contentWidthPx)); @@ -313,7 +313,7 @@ public static Identifier getLinkTarget(String link, String activeWikiId, Identif targetFile = Identifier.of(Oracle.MOD_ID, p); } else if (link.startsWith("+")) { var id = link.substring(1); - targetFile = OracleClient.CONTENT_REF_MAP.get(id); + targetFile = OracleClient.getPage(activeWikiId, id); } else { var p = OracleScreen.parsePathLink(link, sourceEntryPath); if (!p.endsWith(".mdx")) p += ".mdx"; @@ -338,24 +338,53 @@ public static String getTitle(Frontmatter frontMatter, Identifier pagePath) { } private static UIComponent buildTitlePanel(Predicate linkHandler, Frontmatter frontMatter, Identifier pageId, int contentWidthPx) { - ItemStack iconStack = ItemStack.EMPTY; var iconId = frontMatter.getOrDefault("icon", ""); if (iconId.isBlank()) iconId = frontMatter.getOrDefault("id", ""); + ItemStack iconStack = getIconStack(iconId); + + List itemStacks = new ArrayList<>(); + List ids = frontMatter.getAll("id"); + if (ids != null && ids.size() > 1) { + for (String id : ids) { + ItemStack stack = getIconStack(id); + if (!stack.isEmpty()) { + itemStacks.add(stack); + } + } + } + + return new PageTitleWidget( + Text.literal(getTitle(frontMatter, pageId)).formatted(Formatting.DARK_GRAY), + iconStack, + itemStacks, + linkHandler, + contentWidthPx + ); + } + + private static ItemStack getIconStack(String iconId) { if (Identifier.validate(iconId).isSuccess() && Registries.ITEM.containsId(Identifier.of(iconId))) { - iconStack = new ItemStack(Registries.ITEM.get(Identifier.of(iconId))); + return new ItemStack(Registries.ITEM.get(Identifier.of(iconId))); } - return new PageTitleWidget(Text.literal(getTitle(frontMatter, pageId)).formatted(Formatting.DARK_GRAY), iconStack, linkHandler, contentWidthPx); + return ItemStack.EMPTY; } private static class PageTitleWidget extends UIComponent { private static final int ICON_PANEL_SIZE = 58; private static final int ICON_ITEM_SIZE = 50; + + private static final int ITEM_PANEL_SIZE = 32; + private static final int ITEM_ICON_SIZE = 24; + private static final int ITEM_PADDING = (ITEM_PANEL_SIZE - ITEM_ICON_SIZE) / 2; + private static final int ITEMS_MARGIN = 2; + private static final int TITLE_OVERLAP = 12; private static final int TITLE_PAD_X = 14; private static final int TITLE_PAD_Y = 9; private final LabelWidget titleLabel; private final ItemWidget icon; + private final List items; private final int contentWidthPx; private int titleX; @@ -365,13 +394,21 @@ private static class PageTitleWidget extends UIComponent { private int iconX; private int iconY; - PageTitleWidget(Text title, ItemStack iconStack, Predicate linkHandler, int contentWidthPx) { + PageTitleWidget(Text title, ItemStack iconStack, List itemStacks, Predicate linkHandler, int contentWidthPx) { this.titleLabel = new LabelWidget(title).scale(2f).linkHandler(linkHandler); this.icon = iconStack.isEmpty() ? null : new ItemWidget(iconStack); if (icon != null) { - icon.setHideItemTooltip(true); + icon.setTooltipMode(TooltipMode.HIDDEN); icon.setHideItemDecorations(true); } + this.items = itemStacks.stream() + .map(ItemWidget::new) + .peek(w -> { + w.setTooltipMode(TooltipMode.NAME_ONLY); + w.setHideItemDecorations(true); + w.size(ITEM_ICON_SIZE, ITEM_ICON_SIZE); + }) + .toList(); this.contentWidthPx = contentWidthPx; } @@ -381,7 +418,8 @@ public int getPreferredWidth(int widthHint) { int labelMaxWidth = labelMaxWidth(maxWidth); titleLabel.wrapWidth(labelMaxWidth); int titlePanelWidth = titleLabel.getPreferredWidth(labelMaxWidth) + TITLE_PAD_X * 2; - return leadingWidth() + titlePanelWidth; + int itemsRowWidth = Math.min(maxWidth, items.size() * ITEM_PANEL_SIZE); + return Math.max(leadingWidth() + titlePanelWidth, itemsRowWidth); } @Override @@ -390,11 +428,28 @@ public int getPreferredHeight(int widthHint) { int labelMaxWidth = labelMaxWidth(maxWidth); titleLabel.wrapWidth(labelMaxWidth); int titlePanelHeight = titleLabel.getPreferredHeight(labelMaxWidth) + TITLE_PAD_Y * 2; - return Math.max(icon == null ? 0 : ICON_PANEL_SIZE, titlePanelHeight); + int itemRowsHeight = getOuterRowsHeight(maxWidth); + return Math.max(icon == null ? 0 : ICON_PANEL_SIZE, titlePanelHeight) + itemRowsHeight; + } + + private int getMaxCols(int maxWidth) { + return maxWidth / ITEM_PANEL_SIZE; + } + + private int getInnerRowsHeight(int maxWidth) { + int itemCols = getMaxCols(maxWidth); + return (int) Math.ceil(items.size() / (double) itemCols) * ITEM_PANEL_SIZE; + } + + private int getOuterRowsHeight(int maxWidth) { + int height = getInnerRowsHeight(maxWidth); + return height > 0 ? height + ITEMS_MARGIN : 0; } @Override public void layout(int parentWidthHint, int parentHeightHint) { + int centerOffset = getOuterRowsHeight(width) / 2; + int labelMaxWidth = Math.max(80, width - leadingWidth() - TITLE_PAD_X * 2); titleLabel.wrapWidth(labelMaxWidth); int labelW = titleLabel.getPreferredWidth(labelMaxWidth); @@ -402,33 +457,68 @@ public void layout(int parentWidthHint, int parentHeightHint) { titleW = labelW + TITLE_PAD_X * 2; titleH = labelH + TITLE_PAD_Y * 2; titleX = x + leadingWidth(); - titleY = y + (height - titleH) / 2; + titleY = y + (height - titleH) / 2 - centerOffset; int offset = icon != null ? TITLE_PAD_X / 2 : 0; titleLabel.setPosition(titleX + TITLE_PAD_X + offset, titleY + TITLE_PAD_Y); titleLabel.setLayoutSize(labelW, labelH); titleLabel.layout(labelW, labelH); + if (icon != null) { iconX = x; - iconY = y + (height - ICON_PANEL_SIZE) / 2; + iconY = y + (height - ICON_PANEL_SIZE) / 2 - centerOffset; icon.setPosition(iconX + (ICON_PANEL_SIZE - ICON_ITEM_SIZE) / 2, iconY + (ICON_PANEL_SIZE - ICON_ITEM_SIZE) / 2); icon.setLayoutSize(ICON_ITEM_SIZE, ICON_ITEM_SIZE); icon.layout(ICON_ITEM_SIZE, ICON_ITEM_SIZE); } + + if (!items.isEmpty()) { + int cols = getMaxCols(width); + int rowsHeight = getInnerRowsHeight(width); + int baseX = x; + int baseY = y + height - rowsHeight; + + for (int i = 0; i < items.size(); i++) { + ItemWidget item = items.get(i); + int row = i / cols; + int col = i % cols; + int iconX = baseX + ITEM_PADDING + col * ITEM_PANEL_SIZE; + int iconY = baseY + ITEM_PADDING + row * ITEM_PANEL_SIZE; + + item.setPosition(iconX, iconY); + item.setLayoutSize(ITEM_ICON_SIZE, ITEM_ICON_SIZE); + item.layout(ITEM_ICON_SIZE, ITEM_ICON_SIZE); + } + } } @Override protected void renderContent(DrawContext context, int mouseX, int mouseY, float delta) { WikiSurface.BEDROCK_PANEL.render(context, titleX, titleY, titleW, titleH); titleLabel.render(context, mouseX, mouseY, delta); + if (icon != null) { WikiSurface.BEDROCK_PANEL.render(context, iconX, iconY, ICON_PANEL_SIZE, ICON_PANEL_SIZE); icon.render(context, mouseX, mouseY, delta); } + + if (!items.isEmpty()) { + for (ItemWidget item : items) { + WikiSurface.BEDROCK_PANEL.render(context, item.getX() - ITEM_PADDING, item.getY() - ITEM_PADDING, ITEM_PANEL_SIZE, ITEM_PANEL_SIZE); + item.render(context, mouseX, mouseY, delta); + } + } } @Override public List tooltip(int mouseX, int mouseY) { - if (icon != null && icon.isInBounds(mouseX, mouseY)) return icon.tooltip(mouseX, mouseY); + if (icon != null && icon.isInBounds(mouseX, mouseY)) { + return icon.tooltip(mouseX, mouseY); + } + for (ItemWidget item : items) { + if (item.isInBounds(mouseX, mouseY)) { + return item.tooltip(mouseX, mouseY); + } + } return super.tooltip(mouseX, mouseY); } @@ -450,7 +540,7 @@ private static UIComponent buildPropertiesPanel(Map properties, in keyWidth = Math.max(keyWidth, tr.getWidth(entry.getKey())); valueWidth = Math.max(valueWidth, tr.getWidth(entry.getValue())); } - int innerWidth = Math.min(contentWidthPx, Math.max(160, Math.max(titleWidth, keyWidth + valueWidth + 28) + 20)); + int innerWidth = Math.clamp(Math.max(titleWidth, keyWidth + valueWidth + 28) + 20, 160, contentWidthPx); var outer = FlowWidget.vertical().gap(2); outer.setSurface(WikiSurface.BEDROCK_PANEL_DARK); outer.setPadding(Insets.of(10)); From 5b926fe49e10ec8d8ccbca2de56ee189fde2f3d2 Mon Sep 17 00:00:00 2001 From: Su5eD Date: Thu, 11 Jun 2026 15:11:50 +0200 Subject: [PATCH 11/17] Add colored callout labels --- .../oracle/ui/widgets/CalloutWidget.java | 13 +++++++----- .../rearth/oracle/ui/widgets/WikiSurface.java | 3 +++ .../rearth/oracle/util/CalloutVariant.java | 20 ++++++++++++++++++ .../rearth/oracle/util/MdxComponentBlock.java | 13 ++++++++++-- .../assets/oracle_index/lang/en_us.json | 6 +++++- .../bedrock_panel_danger.json | 14 ++++++++++++ .../bedrock_panel_note.json | 14 ++++++++++++ .../bedrock_panel_warning.json | 14 ++++++++++++ .../textures/gui/bedrock_panel_danger.png | Bin 0 -> 226 bytes .../textures/gui/bedrock_panel_note.png | Bin 0 -> 230 bytes .../textures/gui/bedrock_panel_warning.png | Bin 0 -> 227 bytes 11 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 common/src/main/java/rearth/oracle/util/CalloutVariant.java create mode 100644 common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_danger.json create mode 100644 common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_note.json create mode 100644 common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_warning.json create mode 100644 common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_danger.png create mode 100644 common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_note.png create mode 100644 common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_warning.png diff --git a/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java index 4f62098..31c3cb2 100644 --- a/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java +++ b/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java @@ -4,7 +4,9 @@ import net.minecraft.client.gui.DrawContext; import net.minecraft.text.Text; import net.minecraft.util.Formatting; -import org.apache.commons.lang3.StringUtils; +import rearth.oracle.util.CalloutVariant; + +import java.util.Locale; /** * Stylised callout block used by {@code } markdown tags. @@ -15,10 +17,10 @@ public class CalloutWidget extends FlowWidget { private static final int BODY_TEXT_COLOR = 0xFF555555; - private final String variant; + private final CalloutVariant variant; private final FlowWidget body; - public CalloutWidget(String variant) { + public CalloutWidget(CalloutVariant variant) { super(Direction.VERTICAL); this.variant = variant; this.body = FlowWidget.vertical(); @@ -73,13 +75,14 @@ protected void renderContent(DrawContext context, int mouseX, int mouseY, float super.renderContent(context, mouseX, mouseY, delta); // overlapping title chip rendered on top var tr = MinecraftClient.getInstance().textRenderer; - var title = Text.literal(StringUtils.capitalize(variant)).formatted(Formatting.WHITE); + var key = "orale_index.callout." + this.variant.name().toLowerCase(Locale.ROOT); + var title = Text.translatable(key).formatted(Formatting.WHITE); int textW = tr.getWidth(title); int chipW = textW + 12; int chipH = tr.fontHeight + 9; int chipX = body.getX(); int chipY = body.getY(); - WikiSurface.BEDROCK_PANEL_PRESSED.render(context, chipX - 6, chipY - 6, chipW, chipH); + this.variant.getSurface().render(context, chipX - 6, chipY - 6, chipW, chipH); context.drawText(tr, title, chipX, chipY, 0xFFFFFFFF, false); } } diff --git a/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java b/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java index 74c4c5f..dcc8f7b 100644 --- a/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java +++ b/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java @@ -14,6 +14,9 @@ public enum WikiSurface { BEDROCK_PANEL(ninePatch("bedrock_panel")), BEDROCK_PANEL_HOVER(ninePatch("bedrock_panel_hover")), BEDROCK_PANEL_PRESSED(ninePatch("bedrock_panel_pressed")), + BEDROCK_PANEL_NOTE(ninePatch("bedrock_panel_note")), + BEDROCK_PANEL_WARNING(ninePatch("bedrock_panel_warning")), + BEDROCK_PANEL_DANGER(ninePatch("bedrock_panel_danger")), BEDROCK_PANEL_DARK(ninePatch("bedrock_panel_dark")), BEDROCK_PANEL_DISABLED(ninePatch("bedrock_panel_disabled")); diff --git a/common/src/main/java/rearth/oracle/util/CalloutVariant.java b/common/src/main/java/rearth/oracle/util/CalloutVariant.java new file mode 100644 index 0000000..d5bb57b --- /dev/null +++ b/common/src/main/java/rearth/oracle/util/CalloutVariant.java @@ -0,0 +1,20 @@ +package rearth.oracle.util; + +import rearth.oracle.ui.widgets.WikiSurface; + +public enum CalloutVariant { + DEFAULT(WikiSurface.BEDROCK_PANEL_NOTE), + INFO(WikiSurface.BEDROCK_PANEL_PRESSED), + WARNING(WikiSurface.BEDROCK_PANEL_WARNING), + DANGER(WikiSurface.BEDROCK_PANEL_DANGER); + + private final WikiSurface surface; + + CalloutVariant(WikiSurface surface) { + this.surface = surface; + } + + public WikiSurface getSurface() { + return surface; + } +} diff --git a/common/src/main/java/rearth/oracle/util/MdxComponentBlock.java b/common/src/main/java/rearth/oracle/util/MdxComponentBlock.java index 1284ac8..d3e69a7 100644 --- a/common/src/main/java/rearth/oracle/util/MdxComponentBlock.java +++ b/common/src/main/java/rearth/oracle/util/MdxComponentBlock.java @@ -1,13 +1,17 @@ package rearth.oracle.util; +import com.mojang.logging.LogUtils; import org.commonmark.node.CustomBlock; import org.jsoup.Jsoup; +import org.slf4j.Logger; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.regex.Pattern; public abstract class MdxComponentBlock extends CustomBlock { + private static final Logger LOGGER = LogUtils.getLogger(); protected String rawContent; @@ -71,14 +75,19 @@ public String toString() { } public static class CalloutBlock extends MdxComponentBlock { - public String variant = "info"; + public CalloutVariant variant = CalloutVariant.DEFAULT; @Override void parseContent() { var el = Jsoup.parseBodyFragment(rawContent).selectFirst("Callout"); if (el != null) { if (el.hasAttr("variant")) { - this.variant = el.attr("variant"); + String name = el.attr("variant"); + try { + this.variant = CalloutVariant.valueOf(name.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + LOGGER.error("Unknown callout variant: '{}'", name, e); + } } } } diff --git a/common/src/main/resources/assets/oracle_index/lang/en_us.json b/common/src/main/resources/assets/oracle_index/lang/en_us.json index 3d57bb8..b5f55ca 100644 --- a/common/src/main/resources/assets/oracle_index/lang/en_us.json +++ b/common/src/main/resources/assets/oracle_index/lang/en_us.json @@ -10,5 +10,9 @@ "tooltip.oracle_index.back": "Go back to last page.", "tooltip.oracle_index.close_screen": "Close Wiki.", "oracle_index.button.docs": "Docs", - "oracle_index.button.content": "Content" + "oracle_index.button.content": "Content", + "orale_index.callout.default": "Note", + "orale_index.callout.info": "Info", + "orale_index.callout.warning": "Warning", + "orale_index.callout.danger": "Danger" } \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_danger.json b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_danger.json new file mode 100644 index 0000000..b784fc5 --- /dev/null +++ b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_danger.json @@ -0,0 +1,14 @@ +{ + "texture": "oracle_index:textures/gui/bedrock_panel_danger.png", + "texture_width": 16, + "texture_height": 16, + "repeat": false, + "corner_patch_size": { + "width": 4, + "height": 4 + }, + "center_patch_size": { + "width": 8, + "height": 8 + } +} \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_note.json b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_note.json new file mode 100644 index 0000000..29441a1 --- /dev/null +++ b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_note.json @@ -0,0 +1,14 @@ +{ + "texture": "oracle_index:textures/gui/bedrock_panel_note.png", + "texture_width": 16, + "texture_height": 16, + "repeat": false, + "corner_patch_size": { + "width": 4, + "height": 4 + }, + "center_patch_size": { + "width": 8, + "height": 8 + } +} \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_warning.json b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_warning.json new file mode 100644 index 0000000..dd82fa6 --- /dev/null +++ b/common/src/main/resources/assets/oracle_index/nine_patch_textures/bedrock_panel_warning.json @@ -0,0 +1,14 @@ +{ + "texture": "oracle_index:textures/gui/bedrock_panel_warning.png", + "texture_width": 16, + "texture_height": 16, + "repeat": false, + "corner_patch_size": { + "width": 4, + "height": 4 + }, + "center_patch_size": { + "width": 8, + "height": 8 + } +} \ No newline at end of file diff --git a/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_danger.png b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_danger.png new file mode 100644 index 0000000000000000000000000000000000000000..63de755eedf8609fda0dd183c7099380e6438b50 GIT binary patch literal 226 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jPK-BC>eK@{Ea{HEjtmSN z`?>!lvI6-E$sR$z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD3Rdl z;uvCaIyqs1fWfJgC;u-$!XU=R#@2Q?u1_U9Mf^dkLG(7xBnDrf?N6< zL7?-JfrM$o6^4qSH1P+m%XK6s#BSkf{coYiwVmeK@{Ea{HEjtmSN z`?>!lvI6-E$sR$z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD3Rjn z;uvCaIyqs1fWfJgC;u-$!XU;51S?;CRmq<6JSo*8?k%%nLt|s(!y~8lRU`|VX3ur( z19Cer8AzBWTw$mPng&vJ@NA#bN8MoY*7K%+jozI(lK94Z+FSc`dCP3fnHbLe7QDJh S^eK@{Ea{HEjtmSN z`?>!lvI6-E$sR$z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD3R#t z;uvCaIyqs1fWfJgC;u-$!XU=R#?}_PQA#EI$pg`Z53|ddk{FnonGfG)H}^1V@OGHd z2m+m#3?xhwt}s*tJ$oS9uv|yt!YlixL;J%rnyrn0Y8y{Z{e5hAz2^B|hI1+cAq{%k RO+d>SJYD@<);T3K0RYG4M@Ikv literal 0 HcmV?d00001 From 9e849fc55232840db1dafec0f5ad17bf36cbf18d Mon Sep 17 00:00:00 2001 From: Su5eD Date: Thu, 11 Jun 2026 15:21:45 +0200 Subject: [PATCH 12/17] Use universal tooltip text --- .../java/rearth/oracle/mixin/DrawContextMixin.java | 12 ++++++------ .../java/rearth/oracle/ui/widgets/CalloutWidget.java | 2 +- .../resources/assets/oracle_index/lang/en_us.json | 9 +++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java index 3cc86fc..4527cc6 100644 --- a/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java +++ b/common/src/main/java/rearth/oracle/mixin/DrawContextMixin.java @@ -45,9 +45,7 @@ private void injectTooltipComponents(TextRenderer textRenderer, List 0.95f) { @@ -65,8 +63,10 @@ private void injectTooltipComponents(TextRenderer textRenderer, List Date: Thu, 11 Jun 2026 15:40:56 +0200 Subject: [PATCH 13/17] Change inline code color --- common/src/main/java/rearth/oracle/util/MarkdownParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index 6685087..b79cacd 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -253,7 +253,7 @@ public void visit(Link link) { @Override public void visit(Code inlineCode) { if (buffer != null) { - buffer.append(Text.literal(inlineCode.getLiteral()).formatted(Formatting.RED)); + buffer.append(Text.literal(inlineCode.getLiteral()).formatted(Formatting.DARK_AQUA)); } } From f8b42fb60b384e49801338c2883512dab25201a6 Mon Sep 17 00:00:00 2001 From: Su5eD Date: Thu, 11 Jun 2026 15:41:42 +0200 Subject: [PATCH 14/17] Remove PrefabObtaining Just use JEI --- .../src/main/java/rearth/oracle/util/MdxBlockFactory.java | 2 -- .../main/java/rearth/oracle/util/MdxComponentBlock.java | 7 ------- 2 files changed, 9 deletions(-) diff --git a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java index bb4e00d..62a6e17 100644 --- a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java +++ b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java @@ -18,8 +18,6 @@ public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockPar return startLeaf(new MdxComponentBlock.AssetBlock("ModAsset"), "ModAsset", line, state); } else if (line.startsWith(" Date: Thu, 11 Jun 2026 16:13:44 +0200 Subject: [PATCH 15/17] Update tree when navigating links --- common/src/main/java/rearth/oracle/ui/OracleScreen.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/rearth/oracle/ui/OracleScreen.java b/common/src/main/java/rearth/oracle/ui/OracleScreen.java index 0e2866f..3527d0e 100644 --- a/common/src/main/java/rearth/oracle/ui/OracleScreen.java +++ b/common/src/main/java/rearth/oracle/ui/OracleScreen.java @@ -28,7 +28,7 @@ import java.util.*; import java.util.function.Consumer; -import static rearth.oracle.OracleClient.ROOT_DIR; +import static rearth.oracle.OracleClient.*; public class OracleScreen extends WikiBaseScreen { @@ -362,6 +362,7 @@ private void loadContent(Identifier filePath, String wikiId) throws IOException var lastEntry = activeEntry; contentContainer.clearChildren(); activeEntry = filePath; + activeWikiMode = getDocsModeForPage(filePath); var translatedPath = OracleClient.getTranslatedPath(filePath, wikiId); if (translatedPath.isPresent()) filePath = translatedPath.get(); @@ -394,6 +395,7 @@ private boolean onLinkClicked(String wikiId, String link, Identifier sourceEntry var ingameTarget = MarkdownParser.getLinkTarget(link, wikiId, sourceEntryPath); if (ingameTarget != null) { loadContent(ingameTarget, wikiId); + buildNavigationTree(); return true; } if (link.startsWith("@") || link.contains(":")) { From 53eea5e67fd309d999e4f15c1943369b8d28ebe8 Mon Sep 17 00:00:00 2001 From: Su5eD Date: Thu, 11 Jun 2026 20:25:27 +0200 Subject: [PATCH 16/17] Get fallback title from markdown heading --- .../main/java/rearth/oracle/OracleClient.java | 3 + .../java/rearth/oracle/SemanticSearch.java | 17 ++-- .../java/rearth/oracle/ui/OracleScreen.java | 8 +- .../rearth/oracle/util/MarkdownParser.java | 80 ++++++++++++------- .../java/rearth/oracle/util/TitleLookup.java | 76 ++++++++++++++++++ 5 files changed, 139 insertions(+), 45 deletions(-) create mode 100644 common/src/main/java/rearth/oracle/util/TitleLookup.java diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java index d279a27..cd16626 100644 --- a/common/src/main/java/rearth/oracle/OracleClient.java +++ b/common/src/main/java/rearth/oracle/OracleClient.java @@ -22,6 +22,7 @@ import rearth.oracle.progress.AdvancementProgressValidator; import rearth.oracle.ui.OracleScreen; import rearth.oracle.ui.SearchScreen; +import rearth.oracle.util.TitleLookup; import java.util.*; import java.util.function.BiPredicate; @@ -163,6 +164,8 @@ public static Identifier getPage(String wikiId, String ref) { } private static void findAllResourceEntries(ResourceManager manager) { + TitleLookup.clearCache(); + DocsIndexer indexer = new DocsIndexer(); indexer.findAllResourceEntries(manager); diff --git a/common/src/main/java/rearth/oracle/SemanticSearch.java b/common/src/main/java/rearth/oracle/SemanticSearch.java index 8d93315..8298020 100644 --- a/common/src/main/java/rearth/oracle/SemanticSearch.java +++ b/common/src/main/java/rearth/oracle/SemanticSearch.java @@ -12,7 +12,7 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.util.Identifier; import rearth.oracle.util.MarkdownParser; -import rearth.oracle.util.MarkdownParser.Frontmatter; +import rearth.oracle.util.TitleLookup; import java.io.IOException; import java.io.InvalidObjectException; @@ -80,17 +80,18 @@ public SemanticSearch(BiPredicate filter) { try { var fileContent = new String(resources.get(resourceId).getInputStream().readAllBytes(), StandardCharsets.UTF_8); var frontmatter = MarkdownParser.parseFrontmatter(fileContent); + var title = MarkdownParser.parseHeadingTitle(fileContent); // generate embeddings var fileComponents = new HashMap(); frontmatter.map().forEach((k, v) -> { if (v.size() == 1) fileComponents.put(k, v.getFirst()); }); - this.queueEmbeddingsJob(modId, entryDirectory, entryFileName, fileComponents, fileContent); + this.queueEmbeddingsJob(modId, entryDirectory, entryFileName, fileComponents, fileContent, title); } catch (IOException e) { - Oracle.LOGGER.error("Unable to load book with id: " + resourceId); + Oracle.LOGGER.error("Unable to load book with id: {}", resourceId); throw new RuntimeException(e); } } @@ -133,12 +134,7 @@ public ArrayList search(String query) { var id = match.embedded().metadata().getString("wiki") + ":" + match.embedded().metadata().getString("category") + match.embedded().metadata().getString("fileName"); var title = match.embedded().metadata().getString("title"); if (title == null) { - var frontmatter = new HashMap>(); - for (var data : match.embedded().metadata().toMap().entrySet()) { - if (data.getValue() instanceof String value) - frontmatter.put(data.getKey(), List.of(value)); - } - title = MarkdownParser.getTitle(new Frontmatter(frontmatter), Identifier.of(id)); + title = TitleLookup.getTitle(Identifier.of(id)); } // check if id already exists, add it to alt texts @@ -160,12 +156,13 @@ public ArrayList search(String query) { } - public void queueEmbeddingsJob(String wikiId, String filePath, String fileName, Map frontmatter, String content) { + public void queueEmbeddingsJob(String wikiId, String filePath, String fileName, Map frontmatter, String content, String title) { var document = Document.from(content, Metadata.from(frontmatter)); document.metadata().put("fileName", fileName); document.metadata().put("category", filePath); document.metadata().put("wiki", wikiId); + if (title != null) document.metadata().put("title", title); ingestor.ingest(document); } diff --git a/common/src/main/java/rearth/oracle/ui/OracleScreen.java b/common/src/main/java/rearth/oracle/ui/OracleScreen.java index 3527d0e..6ec723d 100644 --- a/common/src/main/java/rearth/oracle/ui/OracleScreen.java +++ b/common/src/main/java/rearth/oracle/ui/OracleScreen.java @@ -19,6 +19,7 @@ import rearth.oracle.progress.OracleProgressAPI; import rearth.oracle.ui.widgets.*; import rearth.oracle.util.MarkdownParser; +import rearth.oracle.util.TitleLookup; import java.io.IOException; import java.net.URI; @@ -28,7 +29,8 @@ import java.util.*; import java.util.function.Consumer; -import static rearth.oracle.OracleClient.*; +import static rearth.oracle.OracleClient.ROOT_DIR; +import static rearth.oracle.OracleClient.getDocsModeForPage; public class OracleScreen extends WikiBaseScreen { @@ -540,9 +542,7 @@ private boolean buildNavigationEntries(String wikiId, String path, FlowWidget co Oracle.LOGGER.warn("Unable to get name for entry: {}", labelPath); shownName = ""; } else { - var fileContent = new String(contentRc.get().getInputStream().readAllBytes(), StandardCharsets.UTF_8); - var fm = MarkdownParser.parseFrontmatter(fileContent); - shownName = MarkdownParser.getTitle(fm, labelPath); + shownName = TitleLookup.getTitle(labelPath); } } PAGE_FALLBACK_NAMES.put(labelPath, shownName); diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java index b79cacd..6b8adf3 100644 --- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java +++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java @@ -2,7 +2,6 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.resource.language.I18n; import net.minecraft.client.texture.NativeImage; import net.minecraft.item.Item; import net.minecraft.item.ItemStack; @@ -26,7 +25,6 @@ import rearth.oracle.ui.widgets.*; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.Predicate; @@ -85,9 +83,45 @@ public static List parseMarkdownToWidgets(String markdown, String w return widgets; } + public static String parseHeadingTitle(String markdown) { + var document = PARSER.parse(markdown); + var visitor = new WikiTitleVisitor(); + document.accept(visitor); + + return visitor.getTitle(); + } + // ---------------------------------------------------------------- visitor - private static class WikiMarkdownVisitor extends AbstractVisitor { + private static class WikiTitleVisitor extends AbstractVisitor { + protected String title; + protected MutableText buffer = Text.empty(); + + public String getTitle() { + return title; + } + + @Override + public void visit(Heading heading) { + buffer = Text.empty(); + visitChildren(heading); + + if (heading.getLevel() == 1 && title == null) { + title = buffer.getString(); + } + + buffer = Text.empty(); + } + + @Override + public void visit(org.commonmark.node.Text text) { + if (buffer != null) { + buffer.append(Text.literal(text.getLiteral())); + } + } + } + + private static class WikiMarkdownVisitor extends WikiTitleVisitor { private final Predicate linkHandler; private final String wikiId; @@ -133,10 +167,15 @@ public void visit(Heading heading) { visitChildren(heading); currentStyle = oldStyle; - var label = new LabelWidget(buffer).linkHandler(linkHandler).fillWidth(); - label.scale(Math.max(1.0f, 2.0f - heading.getLevel() * 0.2f)); - label.setPadding(Insets.of(10, 5, 0, 0)); - components.add(label); + if (heading.getLevel() == 1 && title == null) { + title = buffer.getString(); + } else { + var label = new LabelWidget(buffer).linkHandler(linkHandler).fillWidth(); + label.scale(Math.max(1.0f, 2.0f - heading.getLevel() * 0.2f)); + label.setPadding(Insets.of(10, 5, 0, 0)); + components.add(label); + } + buffer = Text.empty(); } @@ -290,14 +329,8 @@ public static String getLinkTextLiteral(String link, String activeWikiId, Identi var rm = MinecraftClient.getInstance().getResourceManager(); var rc = rm.getResource(linkTarget); if (rc.isEmpty()) return ""; - try { - var fileContent = new String(rc.get().getInputStream().readAllBytes(), StandardCharsets.UTF_8); - var fm = parseFrontmatter(fileContent); - return getTitle(fm, linkTarget); - } catch (IOException e) { - Oracle.LOGGER.warn("Unable to load file content to get link title: {}, {}", linkTarget, e); - return ""; - } + String title = TitleLookup.getTitle(linkTarget); + return title != null ? title : ""; } @Nullable @@ -322,21 +355,6 @@ public static Identifier getLinkTarget(String link, String activeWikiId, Identif return targetFile; } - public static String getTitle(Frontmatter frontMatter, Identifier pagePath) { - String title = frontMatter.getOne("title"); - if (title != null) return title; - - String item = frontMatter.getOne("id"); - if (item != null) { - if (Identifier.validate(item).isSuccess() && Registries.ITEM.containsId(Identifier.of(item))) { - return I18n.translate(Registries.ITEM.get(Identifier.of(item)).getTranslationKey()); - } - return item; - } - - return OracleScreen.PAGE_FALLBACK_NAMES.getOrDefault(pagePath, "No title found"); - } - private static UIComponent buildTitlePanel(Predicate linkHandler, Frontmatter frontMatter, Identifier pageId, int contentWidthPx) { var iconId = frontMatter.getOrDefault("icon", ""); if (iconId.isBlank()) iconId = frontMatter.getOrDefault("id", ""); @@ -354,7 +372,7 @@ private static UIComponent buildTitlePanel(Predicate linkHandler, Frontm } return new PageTitleWidget( - Text.literal(getTitle(frontMatter, pageId)).formatted(Formatting.DARK_GRAY), + Text.literal(TitleLookup.getTitle(pageId)).formatted(Formatting.DARK_GRAY), iconStack, itemStacks, linkHandler, diff --git a/common/src/main/java/rearth/oracle/util/TitleLookup.java b/common/src/main/java/rearth/oracle/util/TitleLookup.java new file mode 100644 index 0000000..bc35104 --- /dev/null +++ b/common/src/main/java/rearth/oracle/util/TitleLookup.java @@ -0,0 +1,76 @@ +package rearth.oracle.util; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.resource.language.I18n; +import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import rearth.oracle.ui.OracleScreen; +import rearth.oracle.util.MarkdownParser.Frontmatter; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public class TitleLookup { + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final Map cachedTitles = new HashMap<>(); + + public static String getTitle(Identifier pagePath) { + return cachedTitles.computeIfAbsent(pagePath, TitleLookup::computeTitle); + } + + private static String computeTitle(Identifier pagePath) { + String cached = cachedTitles.get(pagePath); + if (cached != null) { + return cached; + } + + String markdown = parseContents(pagePath); + if (markdown == null) { + return OracleScreen.PAGE_FALLBACK_NAMES.getOrDefault(pagePath, "No title found"); + } + + Frontmatter frontMatter = MarkdownParser.parseFrontmatter(markdown); + + String title = frontMatter.getOne("title"); + if (title != null) return title; + + String headingTitle = MarkdownParser.parseHeadingTitle(markdown); + if (headingTitle != null) { + return headingTitle; + } + + String item = frontMatter.getOne("id"); + if (item != null) { + if (Identifier.validate(item).isSuccess() && Registries.ITEM.containsId(Identifier.of(item))) { + return I18n.translate(Registries.ITEM.get(Identifier.of(item)).getTranslationKey()); + } + return item; + } + + return OracleScreen.PAGE_FALLBACK_NAMES.getOrDefault(pagePath, "No title found"); + } + + public static void clearCache() { + cachedTitles.clear(); + } + + @Nullable + private static String parseContents(Identifier pagePath) { + try { + var rm = MinecraftClient.getInstance().getResourceManager(); + var rc = rm.getResource(pagePath); + if (rc.isEmpty()) { + return null; + } + return new String(rc.get().getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + LOGGER.error("Error parsing markdown title for page {}", pagePath, e); + return null; + } + } +} From c525eee9cac8d716d6594fda0844af949393cb57 Mon Sep 17 00:00:00 2001 From: Su5eD Date: Thu, 11 Jun 2026 20:48:36 +0200 Subject: [PATCH 17/17] Add test mod --- build.gradle | 24 +++++ common/build.gradle | 9 +- .../java/rearth/oracle_test/OracleTest.java | 20 +++++ .../oracle_index_test/folder_layout.png | Bin 0 -> 21332 bytes .../assets/oracle_index_test/sounds/snare.ogg | Bin 0 -> 5195 bytes .../oracle-index-test/content/_meta.json | 3 + .../oracle-index-test/content/misc/_meta.json | 4 + .../oracle-index-test/content/misc/apples.mdx | 11 +++ .../content/misc/green_apple.mdx | 12 +++ .../books/oracle-index-test/docs/_meta.json | 3 + .../oracle-index-test/docs/introduction.mdx | 84 ++++++++++++++++++ .../books/oracle-index-test/sinytra-wiki.json | 9 ++ .../assets/oracle_index_test/lang/en_us.json | 7 ++ .../models/item/blue_apple.json | 6 ++ .../models/item/green_apple.json | 6 ++ .../models/item/yellow_apple.json | 6 ++ .../textures/item/blue_apple.png | Bin 0 -> 717 bytes .../textures/item/green_apple.png | Bin 0 -> 672 bytes .../textures/item/yellow_apple.png | Bin 0 -> 739 bytes .../rearth/oracle/fabric/OracleFabric.java | 1 - fabric/src/main/resources/fabric.mod.json | 2 +- .../oracle_test/fabric/OracleTestFabric.java | 15 ++++ fabric/src/testmod/resources/fabric.mod.json | 20 +++++ neoforge/build.gradle | 19 +++- .../resources/META-INF/neoforge.mods.toml | 2 +- .../oracle_test/neo/OracleTestNeoForge.java | 19 ++++ .../resources/META-INF/neoforge.mods.toml | 33 +++++++ 27 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 common/src/testmod/java/rearth/oracle_test/OracleTest.java create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/folder_layout.png create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/sounds/snare.ogg create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/_meta.json create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/_meta.json create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/apples.mdx create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/content/misc/green_apple.mdx create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/_meta.json create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx create mode 100644 common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/sinytra-wiki.json create mode 100644 common/src/testmod/resources/assets/oracle_index_test/lang/en_us.json create mode 100644 common/src/testmod/resources/assets/oracle_index_test/models/item/blue_apple.json create mode 100644 common/src/testmod/resources/assets/oracle_index_test/models/item/green_apple.json create mode 100644 common/src/testmod/resources/assets/oracle_index_test/models/item/yellow_apple.json create mode 100644 common/src/testmod/resources/assets/oracle_index_test/textures/item/blue_apple.png create mode 100644 common/src/testmod/resources/assets/oracle_index_test/textures/item/green_apple.png create mode 100644 common/src/testmod/resources/assets/oracle_index_test/textures/item/yellow_apple.png create mode 100644 fabric/src/testmod/java/rearth/oracle_test/fabric/OracleTestFabric.java create mode 100644 fabric/src/testmod/resources/fabric.mod.json create mode 100644 neoforge/src/testmod/java/rearth/oracle_test/neo/OracleTestNeoForge.java create mode 100644 neoforge/src/testmod/resources/META-INF/neoforge.mods.toml diff --git a/build.gradle b/build.gradle index a58ca69..71e029c 100644 --- a/build.gradle +++ b/build.gradle @@ -91,4 +91,28 @@ subprojects { // retrieving dependencies. } } + + if (project.name != 'common') { + sourceSets { + testmod { + compileClasspath += sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.runtimeClasspath + + java.srcDir project(':common').file('src/testmod/java') + resources.srcDir project(':common').file('src/testmod/resources') + } + } + + loom { + runs { + testmodClient { + client() + ideConfigGenerated true + name = "Testmod Client" + source sourceSets.main + source sourceSets.testmod + } + } + } + } } diff --git a/common/build.gradle b/common/build.gradle index 6af4540..5e6ba8d 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -35,4 +35,11 @@ tasks.named('processResources') { doLast { println "processResources executed: wiki folder copied into assets/oracle_index" } -} \ No newline at end of file +} + +sourceSets { + testmod { + compileClasspath += sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.runtimeClasspath + } +} diff --git a/common/src/testmod/java/rearth/oracle_test/OracleTest.java b/common/src/testmod/java/rearth/oracle_test/OracleTest.java new file mode 100644 index 0000000..7e38bd8 --- /dev/null +++ b/common/src/testmod/java/rearth/oracle_test/OracleTest.java @@ -0,0 +1,20 @@ +package rearth.oracle_test; + +import com.google.common.base.Suppliers; +import net.minecraft.item.Item; +import net.minecraft.item.Item.Settings; +import net.minecraft.util.Identifier; + +import java.util.function.Supplier; + +public class OracleTest { + public static final String MODID = "oracle_index_test"; + + public static final Supplier GREEN_APPLE = Suppliers.memoize(() -> new Item(new Settings())); + public static final Supplier BLUE_APPLE = Suppliers.memoize(() -> new Item(new Settings())); + public static final Supplier YELLOW_APPLE = Suppliers.memoize(() -> new Item(new Settings())); + + public static Identifier id(String path) { + return Identifier.of(MODID, path); + } +} diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/folder_layout.png b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/folder_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..0a74261625e7045b4708f996f7d556b074beb720 GIT binary patch literal 21332 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 literal 0 HcmV?d00001 diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/sounds/snare.ogg b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/assets/oracle_index_test/sounds/snare.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b1db8ae016b6abe5b5ba40f0086a470f8f374e49 GIT binary patch literal 5195 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 + +### `Audio` + +