diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java index ee6936df..bb3ef752 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java @@ -6,7 +6,6 @@ import java.io.InputStreamReader; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; @@ -20,8 +19,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; import net.minecraft.client.Minecraft; import net.minecraft.client.resources.AbstractResourcePack; @@ -35,13 +32,13 @@ import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.guide.Guide; -import com.hfstudio.guidenh.guide.compiler.Frontmatter; -import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.internal.DirectoryResourcePack; import com.hfstudio.guidenh.guide.internal.GuideDevelopmentResourcePacks; import com.hfstudio.guidenh.guide.internal.MutableGuide; +import com.hfstudio.guidenh.guide.internal.datadriven.GuideResourcePackScanner.GuideLanguage; +import com.hfstudio.guidenh.guide.internal.datadriven.GuideResourcePackScanner.PackEntry; +import com.hfstudio.guidenh.guide.internal.datadriven.GuideResourcePackScanner.PackScan; import com.hfstudio.guidenh.guide.internal.resource.GuideResourceAccess; -import com.hfstudio.guidenh.guide.internal.util.LangUtil; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.mixins.early.fml.AccessorFMLClientHandler; import com.hfstudio.guidenh.mixins.early.minecraft.AccessorAbstractResourcePack; @@ -53,7 +50,7 @@ public class DataDrivenGuideLoader { // Matches the numeric format placeholders normalized by StringTranslate.parseLangFile. - private static final Pattern NUMERIC_LANG_VARIABLE = Pattern.compile("%(\\d+\\$)?[\\d\\.]*[df]"); + private static final Pattern NUMERIC_LANG_VARIABLE = Pattern.compile("%(\\d+\\$)?[\\d.]*[df]"); public static final String AUTO_GUIDE_FOLDER = "guidenh"; public static final String LANGUAGE_FOLDER_PREFIX = "_"; @@ -70,7 +67,14 @@ boolean shouldReplace(PackCandidate previous) { public record ScanResult(Map guides, Map> pagePaths, Map> discoveredLanguages) {} - private record NamespaceRoot(String namespace, File directory, boolean allowDirectoryAsGuideRoot) {} + private record ZipCacheKey(String folder, ResourcePackViewKey view) {} + + private record CachedZipScan(long size, long lastModified, PackScan scan) { + + boolean matches(File root) { + return !root.isDirectory() && size == root.length() && lastModified == root.lastModified(); + } + } private static final Map, Field> LOOSE_ROOT_FIELDS = new IdentityHashMap<>(); private static volatile List lastActiveResourcePacks = List.of(); @@ -87,19 +91,7 @@ private record NamespaceRoot(String namespace, File directory, boolean allowDire */ private static final Map> PACK_LANG_FILE_PATHS = new HashMap<>(); - private static volatile @Nullable ScanCache lastScanCache = null; - - private record ScanCache(String folder, List contentManifest, ScanResult result, - Map> pagePackIndexSnapshot, - Map> assetPackIndexSnapshot, - Map> langFilePathsSnapshot) { - - boolean matches(String f, List manifest) { - return folder.equals(f) && contentManifest.equals(manifest); - } - } - - private static final ConcurrentHashMap> nativeNamespaceRootsCache = new ConcurrentHashMap<>(); + private static volatile Map lastZipScanCache = Map.of(); private DataDrivenGuideLoader() {} @@ -108,40 +100,42 @@ public static ScanResult scanAndBuildAll(String folder) { } public static ScanResult scanAndBuildAll(String folder, Iterable activeResourcePacks) { - // Cache hit requires the same roots and the same resource-pack content inventory. var resolvedPacks = toList(activeResourcePacks); - var packRoots = resolvePackRoots(resolvedPacks); - ScanCache cache = lastScanCache; - var contentManifest = ResourcePackContentManifest.capture( - packRoots, - folder, - cache != null && cache.folder() - .equals(folder) ? cache.contentManifest() : List.of()); - if (cache != null && cache.matches(folder, contentManifest)) { - indexReady = true; - pagePackIndex.putAll(cache.pagePackIndexSnapshot()); - assetPackIndex.putAll(cache.assetPackIndexSnapshot()); - PACK_LANG_FILE_PATHS.putAll(cache.langFilePathsSnapshot()); - return cache.result(); - } pagePackIndex.clear(); assetPackIndex.clear(); PACK_LANG_FILE_PATHS.clear(); - nativeNamespaceRootsCache.clear(); + GuideResourcePackScanner.clearCaches(); indexReady = false; pagePackOrder.set(0); var pagePaths = new LinkedHashMap>(); var discoveredLanguages = new LinkedHashMap>(); + var previousZipCache = lastZipScanCache; + var nextZipCache = new HashMap(); + for (var pack : resolvedPacks) { var root = getLooseResourcePackRoot(pack); if (root == null || !root.exists()) continue; - if (!root.isDirectory()) { - scanZipBuildIndex(root, folder, pagePaths, pack, discoveredLanguages); + + final PackScan scan; + if (root.isDirectory()) { + scan = GuideResourcePackScanner.scanDirectoryPack(root, folder); } else { - scanDirectoryBuildIndex(pack, root, folder, pagePaths, discoveredLanguages); + var cacheKey = new ZipCacheKey(folder, resourcePackViewKey(pack)); + var cached = previousZipCache.get(cacheKey); + + if (cached != null && cached.matches(root)) { + scan = cached.scan(); + } else { + scan = GuideResourcePackScanner.scanZipPack(root, folder, pack); + if (scan == null) continue; + } + + nextZipCache.put(cacheKey, new CachedZipScan(root.length(), root.lastModified(), scan)); } + + applyPackScan(pack, folder, scan, pagePaths, discoveredLanguages); } var guides = new LinkedHashMap(); @@ -155,18 +149,7 @@ public static ScanResult scanAndBuildAll(String folder, Iterable(pagePackIndex), - new HashMap<>(assetPackIndex), - new HashMap<>(PACK_LANG_FILE_PATHS)); - } - + lastZipScanCache = nextZipCache; indexReady = true; return new ScanResult(guides, pagePaths, freezeDiscoveredLanguages(discoveredLanguages)); @@ -200,234 +183,53 @@ public static void clearCaches() { pagePackIndex.clear(); assetPackIndex.clear(); PACK_LANG_FILE_PATHS.clear(); - nativeNamespaceRootsCache.clear(); + GuideResourcePackScanner.clearCaches(); indexReady = false; pagePackOrder.set(0); - // Keep the previous result for a subsequent scan, but validate it with the content manifest. - } - - private static void scanZipBuildIndex(File resourcePackFile, String folder, - LinkedHashMap> pagePaths, IResourcePack resourcePack, - Map> discoveredLanguages) { - var prefix = "assets/"; - try (var zip = new ZipFile(resourcePackFile)) { - var entries = zip.entries(); - while (entries.hasMoreElements()) { - var entry = entries.nextElement(); - if (entry.isDirectory()) continue; - var path = entry.getName(); - if (!path.startsWith(prefix)) continue; - - var afterAssets = path.substring(prefix.length()); - var firstSlash = afterAssets.indexOf('/'); - if (firstSlash <= 0) continue; - if (path.endsWith(".lang")) { - var resourceLocation = new ResourceLocation( - afterAssets.substring(0, firstSlash), - afterAssets.substring(firstSlash + 1)); - if (!resourceExists(resourcePack, resourceLocation)) continue; - PACK_LANG_FILE_PATHS.computeIfAbsent(resourcePackViewKey(resourcePack), k -> new ArrayList<>()) - .add(path); - continue; - } - var namespace = afterAssets.substring(0, firstSlash); - var afterNamespace = afterAssets.substring(firstSlash + 1); - if (!afterNamespace.startsWith(folder + "/")) continue; - var resourceLocation = new ResourceLocation(namespace, afterNamespace); - if (!resourceExists(resourcePack, resourceLocation)) continue; - var afterFolder = afterNamespace.substring(folder.length() + 1); - var slashIndex = afterFolder.indexOf('/'); - if (slashIndex <= 0) continue; - var language = afterFolder.substring(0, slashIndex); - if (!isLanguageFolder(language)) continue; - var relativePath = afterFolder.substring(slashIndex + 1); - if (relativePath.isEmpty()) continue; - - if (path.endsWith(".md")) { - pagePaths.computeIfAbsent(namespace, k -> new LinkedHashSet<>()) - .add(relativePath); - discoveredLanguages - .computeIfAbsent(new ResourceLocation(namespace, folder), k -> new LinkedHashSet<>()) - .add(toLanguageCode(language)); - - int loadPriority = parseLoadPriorityFromZipEntry( - zip, - entry, - new ResourceLocation(namespace, folder + "/" + language + "/" + relativePath)); - synchronized (pagePackIndex) { - pagePackIndex - .computeIfAbsent( - new ResourceLocation(namespace, folder + "/" + language + "/" + relativePath), - k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, loadPriority, pagePackOrder.getAndIncrement())); - pagePackIndex - .computeIfAbsent( - new ResourceLocation(namespace, folder + "/" + relativePath), - k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, loadPriority, pagePackOrder.getAndIncrement())); - } - } else { - synchronized (assetPackIndex) { - assetPackIndex - .computeIfAbsent( - new ResourceLocation(namespace, folder + "/" + language + "/" + relativePath), - k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, 0, pagePackOrder.getAndIncrement())); - assetPackIndex - .computeIfAbsent( - new ResourceLocation(namespace, folder + "/" + relativePath), - k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, 0, pagePackOrder.getAndIncrement())); - } - } - } - } catch (IOException e) { - GuideDebugLog.warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to scan guide pages from resource pack {}", - resourcePackFile.getAbsolutePath(), - e); - } + // Keep immutable ZIP scans; archive size + timestamp cheaply validates them next time. } - private static int parseLoadPriorityFromZipEntry(ZipFile zip, ZipEntry entry, ResourceLocation loc) { - try (var stream = zip.getInputStream(entry)) { - String content = new String(GuideResourceAccess.readFully(stream), StandardCharsets.UTF_8); - if (content.startsWith("")) content = content.substring(1); - String yamlText = PageCompiler.extractFrontmatterText(PageCompiler.normalizeLineEndings(content)); - if (yamlText != null) { - var nav = Frontmatter.parse(loc, yamlText) - .navigationEntry(); - return nav != null ? nav.loadPriority() : 0; - } - } catch (Exception ignored) {} - return 0; - } - - private static void scanDirectoryBuildIndex(IResourcePack resourcePack, File resourcePackRoot, String folder, + private static void applyPackScan(IResourcePack resourcePack, String folder, PackScan scan, LinkedHashMap> pagePaths, Map> discoveredLanguages) { - for (NamespaceRoot namespaceRoot : discoverNamespaceRoots(resourcePackRoot)) { - scanDirectoryBuildIndexForNamespace(resourcePack, namespaceRoot, folder, pagePaths, discoveredLanguages); - } - collectDirectoryLangPaths(resourcePack, resourcePackRoot); - } - - private static void collectDirectoryLangPaths(IResourcePack resourcePack, File resourcePackRoot) { - Path assets = resourcePackRoot.toPath() - .resolve("assets"); - if (Files.isDirectory(assets)) { - try (var paths = Files.walk(assets)) { - paths.filter(Files::isRegularFile) - .map(assets::relativize) - .map(Path::toString) - .map(path -> "assets/" + path.replace(File.separatorChar, '/')) - .filter(path -> path.endsWith(".lang")) - .forEach( - path -> PACK_LANG_FILE_PATHS - .computeIfAbsent(resourcePackViewKey(resourcePack), key -> new ArrayList<>()) - .add(path)); - } catch (IOException e) { - GuideDebugLog.warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to index language files in {}", - resourcePackRoot, - e); - } - } - File[] looseRoots = resourcePackRoot.listFiles(File::isDirectory); - if (looseRoots == null) { - return; - } - for (File looseRoot : looseRoots) { - if ("assets".equals(looseRoot.getName())) { - continue; - } - try (var paths = Files.walk(looseRoot.toPath())) { - paths.filter(Files::isRegularFile) - .filter( - path -> path.toString() - .endsWith(".lang")) - .map(resourcePackRoot.toPath()::relativize) - .map(Path::toString) - .map(path -> path.replace(File.separatorChar, '/')) - .forEach( - path -> PACK_LANG_FILE_PATHS - .computeIfAbsent(resourcePackViewKey(resourcePack), key -> new ArrayList<>()) - .add(path)); - } catch (IOException e) { - GuideDebugLog - .warn("[GuideNH] [DataDrivenGuideLoader] Failed to index language files in {}", looseRoot, e); - } + + if (!scan.langPaths() + .isEmpty()) { + PACK_LANG_FILE_PATHS.put(resourcePackViewKey(resourcePack), scan.langPaths()); } - } - private static void scanDirectoryBuildIndexForNamespace(IResourcePack resourcePack, NamespaceRoot namespaceRoot, - String folder, LinkedHashMap> pagePaths, - Map> discoveredLanguages) { - File guideRoot = new File(namespaceRoot.directory(), folder); - if (!guideRoot.isDirectory()) return; - File[] languageDirs = guideRoot.listFiles(File::isDirectory); - if (languageDirs == null) return; - - for (File languageDir : languageDirs) { - String language = languageDir.getName(); - if (!isLanguageFolder(language)) continue; + for (GuideLanguage language : scan.languages()) { discoveredLanguages - .computeIfAbsent(new ResourceLocation(namespaceRoot.namespace(), folder), k -> new LinkedHashSet<>()) - .add(toLanguageCode(language)); - - var collectors = new LinkedHashSet(); - collectMarkdownPaths(languageDir, "", collectors); - - for (String pagePath : collectors) { - pagePaths.computeIfAbsent(namespaceRoot.namespace(), k -> new LinkedHashSet<>()) - .add(pagePath); - var loc = new ResourceLocation(namespaceRoot.namespace(), folder + "/" + language + "/" + pagePath); - int loadPriority = parseLoadPriorityFromFile( - languageDir.toPath() - .resolve(pagePath), - loc); - synchronized (pagePackIndex) { - pagePackIndex.computeIfAbsent(loc, k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, loadPriority, pagePackOrder.getAndIncrement())); - pagePackIndex - .computeIfAbsent( - new ResourceLocation(namespaceRoot.namespace(), folder + "/" + pagePath), - k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, loadPriority, pagePackOrder.getAndIncrement())); - } - } + .computeIfAbsent(new ResourceLocation(language.namespace(), folder), k -> new LinkedHashSet<>()) + .add(language.language()); + } - var assetCollector = new LinkedHashSet(); - collectAssetPaths(languageDir, "", assetCollector); - for (String assetPath : assetCollector) { - synchronized (assetPackIndex) { - assetPackIndex - .computeIfAbsent( - new ResourceLocation(namespaceRoot.namespace(), folder + "/" + language + "/" + assetPath), - k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, 0, pagePackOrder.getAndIncrement())); - assetPackIndex - .computeIfAbsent( - new ResourceLocation(namespaceRoot.namespace(), folder + "/" + assetPath), - k -> new ArrayList<>()) - .add(new PackCandidate(resourcePack, 0, pagePackOrder.getAndIncrement())); - } + for (PackEntry entry : scan.entries()) { + if (entry.relativePath() + .endsWith(".md")) { + pagePaths.computeIfAbsent(entry.namespace(), k -> new LinkedHashSet<>()) + .add(entry.relativePath()); + addPackCandidates(pagePackIndex, resourcePack, folder, entry); + } else { + addPackCandidates(assetPackIndex, resourcePack, folder, entry); } } } - private static int parseLoadPriorityFromFile(Path filePath, ResourceLocation loc) { - try { - String content = Files.readString(filePath); - if (content.startsWith("")) content = content.substring(1); - String yamlText = PageCompiler.extractFrontmatterText(PageCompiler.normalizeLineEndings(content)); - if (yamlText != null) { - var nav = Frontmatter.parse(loc, yamlText) - .navigationEntry(); - return nav != null ? nav.loadPriority() : 0; - } - } catch (Exception ignored) {} - return 0; + private static void addPackCandidates(Map> index, IResourcePack resourcePack, + String folder, PackEntry entry) { + + index + .computeIfAbsent( + new ResourceLocation(entry.namespace(), folder + "/" + entry.language() + "/" + entry.relativePath()), + k -> new ArrayList<>()) + .add(new PackCandidate(resourcePack, entry.loadPriority(), pagePackOrder.getAndIncrement())); + + index + .computeIfAbsent( + new ResourceLocation(entry.namespace(), folder + "/" + entry.relativePath()), + k -> new ArrayList<>()) + .add(new PackCandidate(resourcePack, entry.loadPriority(), pagePackOrder.getAndIncrement())); } public static List getLangFilePaths(IResourcePack resourcePack) { @@ -563,81 +365,38 @@ public static Set discoverPagePaths(ResourceLocation guideId, String fol var root = getLooseResourcePackRoot(pack); if (root == null || !root.exists()) continue; if (root.isDirectory()) { - for (File guideRoot : guideRootCandidates(root, guideId.getResourceDomain(), folder)) { + for (File guideRoot : GuideResourcePackScanner + .guideRootCandidates(root, guideId.getResourceDomain(), folder)) { var langDirs = guideRoot.listFiles(File::isDirectory); if (langDirs == null) continue; for (var langDir : langDirs) { - if (isLanguageFolder(langDir.getName())) { - collectMarkdownPaths(langDir, "", result); + if (GuideResourcePackScanner.isLanguageFolder(langDir.getName())) { + GuideResourcePackScanner.collectMarkdownPaths(langDir, "", result); } } } } else { - scanZipPagePaths(root, "assets/" + guideId.getResourceDomain() + "/" + folder + "/", result); + GuideResourcePackScanner + .scanZipPagePaths(root, "assets/" + guideId.getResourceDomain() + "/" + folder + "/", result); } } return result; } public static void scanZipPagePaths(File resourcePackFile, String prefix, Set pagePaths) { - try (var zip = new ZipFile(resourcePackFile)) { - var entries = zip.entries(); - while (entries.hasMoreElements()) { - var entry = entries.nextElement(); - if (entry.isDirectory()) continue; - var path = entry.getName(); - if (!path.startsWith(prefix) || !path.endsWith(".md")) continue; - var relative = path.substring(prefix.length()); - var slashIndex = relative.indexOf('/'); - if (slashIndex <= 0) continue; - if (!isLanguageFolder(relative.substring(0, slashIndex))) continue; - var pagePath = relative.substring(slashIndex + 1); - if (!pagePath.isEmpty()) pagePaths.add(pagePath); - } - } catch (IOException e) { - GuideDebugLog.warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to scan zip for pages: {}", - resourcePackFile.getAbsolutePath(), - e); - } + GuideResourcePackScanner.scanZipPagePaths(resourcePackFile, prefix, pagePaths); } public static void collectMarkdownPaths(File directory, String relativePath, Set pagePaths) { - var children = directory.listFiles(); - if (children == null) return; - for (var child : children) { - String childPath = relativePath.isEmpty() ? child.getName() : relativePath + "/" + child.getName(); - if (child.isDirectory()) { - collectMarkdownPaths(child, childPath, pagePaths); - } else if (child.isFile() && child.getName() - .endsWith(".md")) { - pagePaths.add(childPath); - } - } - } - - private static void collectAssetPaths(File directory, String relativePath, Set assetPaths) { - var children = directory.listFiles(); - if (children == null) return; - for (var child : children) { - String childPath = relativePath.isEmpty() ? child.getName() : relativePath + "/" + child.getName(); - if (child.isDirectory()) { - collectAssetPaths(child, childPath, assetPaths); - } else if (child.isFile() && !child.getName() - .endsWith(".md") - && !child.getName() - .endsWith(".lang")) { - assetPaths.add(childPath); - } - } + GuideResourcePackScanner.collectMarkdownPaths(directory, relativePath, pagePaths); } public static boolean isLanguageFolder(String name) { - return name.startsWith(LANGUAGE_FOLDER_PREFIX) && LangUtil.isLanguageCode(name.substring(1)); + return GuideResourcePackScanner.isLanguageFolder(name); } public static String toLanguageCode(String folderName) { - return LangUtil.normalizeLanguage(folderName.substring(LANGUAGE_FOLDER_PREFIX.length())); + return GuideResourcePackScanner.toLanguageCode(folderName); } public static List getActiveResourcePacks() { @@ -820,125 +579,12 @@ public static IResourcePack findResourcePack(ResourceLocation resourceLocation, return null; } - private static List guideRootCandidates(File resourcePackRoot, String namespace, String folder) { - var candidates = new LinkedHashMap(3); - for (NamespaceRoot nr : discoverNamespaceRoots(resourcePackRoot)) { - if (nr.namespace() - .equals(namespace)) { - addGuideRootCandidates(candidates, nr, folder); - } - } - addGuideRootCandidate(candidates, resourcePackRoot, "assets/" + namespace + "/" + folder + "/"); - addGuideRootCandidate(candidates, resourcePackRoot, namespace + "/" + folder + "/"); - if (folder.equals(namespace)) { - addGuideRootCandidate(candidates, resourcePackRoot, folder + "/"); - } - return List.copyOf(candidates.values()); - } - - private static List guideRootCandidates(NamespaceRoot namespaceRoot, String folder) { - var candidates = new LinkedHashMap(2); - addGuideRootCandidates(candidates, namespaceRoot, folder); - return List.copyOf(candidates.values()); - } - - private static void addGuideRootCandidates(LinkedHashMap candidates, NamespaceRoot namespaceRoot, - String folder) { - addGuideRootCandidate(candidates, namespaceRoot.directory(), folder + "/"); - if (folder.equals(namespaceRoot.namespace()) && namespaceRoot.allowDirectoryAsGuideRoot()) { - addGuideRootCandidate(candidates, namespaceRoot.directory(), ""); - } - } - - private static void addGuideRootCandidate(LinkedHashMap candidates, File resourcePackRoot, - String relativePath) { - var candidate = new File(resourcePackRoot, relativePath.replace('/', File.separatorChar)); - if (candidate.isDirectory()) { - candidates.putIfAbsent( - candidate.toPath() - .toAbsolutePath() - .normalize(), - candidate); - } - } - - private static List discoverNamespaceRoots(File resourcePackRoot) { - var byPath = new LinkedHashMap(); - - var assetsDir = new File(resourcePackRoot, "assets"); - var assetDirs = assetsDir.listFiles(File::isDirectory); - if (assetDirs != null) { - for (var dir : assetDirs) { - addNamespaceRoot(byPath, new NamespaceRoot(namespaceFromDirectoryName(dir.getName()), dir, false)); - } - } - - Path cacheKey = resourcePackRoot.toPath() - .toAbsolutePath() - .normalize(); - List cached = nativeNamespaceRootsCache.get(cacheKey); - if (cached != null) { - for (var nr : cached) { - addNamespaceRoot(byPath, nr); - } - return List.copyOf(byPath.values()); - } - - var nativeRoots = new ArrayList(); - var nativeDirs = resourcePackRoot.listFiles(File::isDirectory); - if (nativeDirs != null) { - for (var dir : nativeDirs) { - if ("assets".equals(dir.getName())) continue; - var nr = new NamespaceRoot(namespaceFromDirectoryName(dir.getName()), dir, true); - addNamespaceRoot(byPath, nr); - nativeRoots.add(nr); - } - } - nativeNamespaceRootsCache.put(cacheKey, List.copyOf(nativeRoots)); - return List.copyOf(byPath.values()); - } - - private static void addNamespaceRoot(LinkedHashMap roots, NamespaceRoot nr) { - roots.putIfAbsent( - nr.directory() - .toPath() - .toAbsolutePath() - .normalize(), - nr); - } - - private static String namespaceFromDirectoryName(String directoryName) { - if (isValidNamespace(directoryName)) return directoryName; - int openBracket = directoryName.lastIndexOf('['); - if (openBracket < 0 || !directoryName.endsWith("]")) return directoryName; - return directoryName.substring(openBracket + 1, directoryName.length() - 1); - } - - private static boolean isValidNamespace(String namespace) { - if (namespace == null || namespace.isEmpty()) return false; - for (int i = 0; i < namespace.length(); i++) { - char ch = namespace.charAt(i); - if (ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '_' || ch == '-' || ch == '.') continue; - return false; - } - return true; - } - private static List toList(Iterable resourcePacks) { var result = new ArrayList(); for (IResourcePack pack : resourcePacks) result.add(pack); return result; } - private static List resolvePackRoots(List packs) { - var roots = new ArrayList(packs.size()); - for (var pack : packs) { - File root = getLooseResourcePackRoot(pack); - if (root != null) roots.add(root); - } - return roots; - } - private static Map> freezeDiscoveredLanguages( LinkedHashMap> discoveredLanguages) { if (discoveredLanguages.isEmpty()) return Map.of(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/GuideResourcePackScanner.java b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/GuideResourcePackScanner.java new file mode 100644 index 00000000..c1a3ef3e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/GuideResourcePackScanner.java @@ -0,0 +1,383 @@ +package com.hfstudio.guidenh.guide.internal.datadriven; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.ZipFile; + +import net.minecraft.client.resources.IResourcePack; +import net.minecraft.util.ResourceLocation; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.compiler.Frontmatter; +import com.hfstudio.guidenh.guide.internal.util.LangUtil; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +final class GuideResourcePackScanner { + + private static final String LANGUAGE_FOLDER_PREFIX = "_"; + + record GuideLanguage(String namespace, String language) {} + + record PackEntry(String namespace, String language, String relativePath, int loadPriority) {} + + record PackScan(List entries, List langPaths, List languages) {} + + private record NamespaceRoot(String namespace, File directory, boolean allowDirectoryAsGuideRoot) {} + + private static final ConcurrentHashMap> nativeNamespaceRootsCache = new ConcurrentHashMap<>(); + + private GuideResourcePackScanner() {} + + static void clearCaches() { + nativeNamespaceRootsCache.clear(); + } + + static @Nullable PackScan scanZipPack(File resourcePackFile, String folder, IResourcePack resourcePack) { + var entries = new ArrayList(); + var langPaths = new ArrayList(); + var languages = new LinkedHashSet(); + var prefix = "assets/"; + + try (var zip = new ZipFile(resourcePackFile)) { + var zipEntries = zip.entries(); + while (zipEntries.hasMoreElements()) { + var entry = zipEntries.nextElement(); + if (entry.isDirectory()) continue; + + var path = entry.getName(); + if (!path.startsWith(prefix)) continue; + + var afterAssets = path.substring(prefix.length()); + var firstSlash = afterAssets.indexOf('/'); + if (firstSlash <= 0) continue; + + var namespace = afterAssets.substring(0, firstSlash); + var afterNamespace = afterAssets.substring(firstSlash + 1); + + if (path.endsWith(".lang")) { + var resourceLocation = new ResourceLocation(namespace, afterNamespace); + if (resourceExists(resourcePack, resourceLocation)) { + langPaths.add(path); + } + continue; + } + if (!afterNamespace.startsWith(folder + "/")) continue; + + var resourceLocation = new ResourceLocation(namespace, afterNamespace); + if (!resourceExists(resourcePack, resourceLocation)) continue; + + var afterFolder = afterNamespace.substring(folder.length() + 1); + var slashIndex = afterFolder.indexOf('/'); + if (slashIndex <= 0) continue; + + var language = afterFolder.substring(0, slashIndex); + if (!isLanguageFolder(language)) continue; + + var relativePath = afterFolder.substring(slashIndex + 1); + if (relativePath.isEmpty()) continue; + + if (path.endsWith(".md")) { + int loadPriority; + try { + loadPriority = parseLoadPriority(zip.getInputStream(entry), resourceLocation); + } catch (IOException e) { + loadPriority = 0; + } + + entries.add(new PackEntry(namespace, language, relativePath, loadPriority)); + languages.add(new GuideLanguage(namespace, toLanguageCode(language))); + } else { + entries.add(new PackEntry(namespace, language, relativePath, 0)); + } + } + } catch (IOException e) { + GuideDebugLog.warn( + "[GuideNH] [GuideResourcePackScanner] Failed to scan guide pages from resource pack {}", + resourcePackFile.getAbsolutePath(), + e); + return null; + } + + return new PackScan(entries, langPaths, List.copyOf(languages)); + } + + static PackScan scanDirectoryPack(File resourcePackRoot, String folder) { + var entries = new ArrayList(); + var langPaths = new ArrayList(); + var languages = new LinkedHashSet(); + + for (NamespaceRoot namespaceRoot : discoverNamespaceRoots(resourcePackRoot)) { + scanGuideDirectory(namespaceRoot, folder, entries, languages); + collectNamespaceLangPaths(resourcePackRoot, namespaceRoot.directory(), langPaths); + } + + return new PackScan(entries, langPaths, List.copyOf(languages)); + } + + private static void scanGuideDirectory(NamespaceRoot namespaceRoot, String folder, List entries, + Set languages) { + + File guideRoot = new File(namespaceRoot.directory(), folder); + if (!guideRoot.isDirectory()) return; + + File[] languageDirs = guideRoot.listFiles(File::isDirectory); + if (languageDirs == null) return; + + for (File languageDir : languageDirs) { + String language = languageDir.getName(); + if (!isLanguageFolder(language)) continue; + + Path languageRoot = languageDir.toPath(); + try (var paths = Files.walk(languageRoot)) { + paths.forEach(path -> { + if (!Files.isRegularFile(path)) return; + + String relativePath = languageRoot.relativize(path) + .toString(); + if (relativePath.isEmpty() || relativePath.endsWith(".lang")) return; + if (File.separatorChar != '/') relativePath = relativePath.replace(File.separatorChar, '/'); + + int loadPriority = 0; + if (relativePath.endsWith(".md")) { + var location = new ResourceLocation( + namespaceRoot.namespace(), + folder + "/" + language + "/" + relativePath); + loadPriority = parseLoadPriority(path, location); + + languages.add(new GuideLanguage(namespaceRoot.namespace(), toLanguageCode(language))); + } + entries.add(new PackEntry(namespaceRoot.namespace(), language, relativePath, loadPriority)); + }); + } catch (IOException e) { + GuideDebugLog + .warn("[GuideNH] [GuideResourcePackScanner] Failed to scan guide files in {}", languageDir, e); + } + } + } + + private static void collectNamespaceLangPaths(File resourcePackRoot, File namespaceRoot, List langPaths) { + Path langRoot = namespaceRoot.toPath() + .resolve("lang"); + if (!Files.isDirectory(langRoot)) return; + + try (var paths = Files.walk(langRoot)) { + paths.filter(Files::isRegularFile) + .filter( + path -> path.getFileName() + .toString() + .endsWith(".lang")) + .map(resourcePackRoot.toPath()::relativize) + .map(Path::toString) + .map(path -> path.replace(File.separatorChar, '/')) + .forEach(langPaths::add); + } catch (IOException e) { + GuideDebugLog + .warn("[GuideNH] [GuideResourcePackScanner] Failed to index language files in {}", langRoot, e); + } + } + + private static int parseLoadPriority(Path path, ResourceLocation location) { + try { + return parseLoadPriority(Files.newInputStream(path), location); + } catch (IOException ignored) { + return 0; + } + } + + /** + * Reads only the leading YAML frontmatter needed to resolve navigation load priority. + */ + private static int parseLoadPriority(InputStream input, ResourceLocation location) { + try (input; var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + String firstLine = reader.readLine(); + if (firstLine != null && firstLine.startsWith("\uFEFF")) { + firstLine = firstLine.substring(1); + } + if (!"---".equals(firstLine)) { + return 0; + } + + var frontmatter = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + if ("---".equals(line)) { + var navigation = Frontmatter.parse(location, frontmatter.toString()) + .navigationEntry(); + return navigation != null ? navigation.loadPriority() : 0; + } + + if (!frontmatter.isEmpty()) { + frontmatter.append('\n'); + } + frontmatter.append(line); + } + } catch (Exception ignored) {} + + return 0; + } + + static List guideRootCandidates(File resourcePackRoot, String namespace, String folder) { + var candidates = new LinkedHashMap(3); + for (NamespaceRoot namespaceRoot : discoverNamespaceRoots(resourcePackRoot)) { + if (namespaceRoot.namespace() + .equals(namespace)) { + addGuideRootCandidates(candidates, namespaceRoot, folder); + } + } + addGuideRootCandidate(candidates, resourcePackRoot, "assets/" + namespace + "/" + folder + "/"); + addGuideRootCandidate(candidates, resourcePackRoot, namespace + "/" + folder + "/"); + if (folder.equals(namespace)) { + addGuideRootCandidate(candidates, resourcePackRoot, folder + "/"); + } + return List.copyOf(candidates.values()); + } + + private static void addGuideRootCandidates(LinkedHashMap candidates, NamespaceRoot namespaceRoot, + String folder) { + addGuideRootCandidate(candidates, namespaceRoot.directory(), folder + "/"); + if (folder.equals(namespaceRoot.namespace()) && namespaceRoot.allowDirectoryAsGuideRoot()) { + addGuideRootCandidate(candidates, namespaceRoot.directory(), ""); + } + } + + private static void addGuideRootCandidate(LinkedHashMap candidates, File resourcePackRoot, + String relativePath) { + var candidate = new File(resourcePackRoot, relativePath.replace('/', File.separatorChar)); + if (candidate.isDirectory()) { + candidates.putIfAbsent( + candidate.toPath() + .toAbsolutePath() + .normalize(), + candidate); + } + } + + private static List discoverNamespaceRoots(File resourcePackRoot) { + var byPath = new LinkedHashMap(); + + var assetsDir = new File(resourcePackRoot, "assets"); + var assetDirs = assetsDir.listFiles(File::isDirectory); + if (assetDirs != null) { + for (var dir : assetDirs) { + addNamespaceRoot(byPath, new NamespaceRoot(namespaceFromDirectoryName(dir.getName()), dir, false)); + } + } + + Path cacheKey = resourcePackRoot.toPath() + .toAbsolutePath() + .normalize(); + List cached = nativeNamespaceRootsCache.get(cacheKey); + if (cached != null) { + for (var namespaceRoot : cached) { + addNamespaceRoot(byPath, namespaceRoot); + } + return List.copyOf(byPath.values()); + } + + var nativeRoots = new ArrayList(); + var nativeDirs = resourcePackRoot.listFiles(File::isDirectory); + if (nativeDirs != null) { + for (var dir : nativeDirs) { + if ("assets".equals(dir.getName())) continue; + var namespaceRoot = new NamespaceRoot(namespaceFromDirectoryName(dir.getName()), dir, true); + addNamespaceRoot(byPath, namespaceRoot); + nativeRoots.add(namespaceRoot); + } + } + nativeNamespaceRootsCache.put(cacheKey, List.copyOf(nativeRoots)); + return List.copyOf(byPath.values()); + } + + private static void addNamespaceRoot(LinkedHashMap roots, NamespaceRoot namespaceRoot) { + roots.putIfAbsent( + namespaceRoot.directory() + .toPath() + .toAbsolutePath() + .normalize(), + namespaceRoot); + } + + private static String namespaceFromDirectoryName(String directoryName) { + if (isValidNamespace(directoryName)) return directoryName; + int openBracket = directoryName.lastIndexOf('['); + if (openBracket < 0 || !directoryName.endsWith("]")) return directoryName; + return directoryName.substring(openBracket + 1, directoryName.length() - 1); + } + + private static boolean isValidNamespace(String namespace) { + if (namespace == null || namespace.isEmpty()) return false; + for (int i = 0; i < namespace.length(); i++) { + char ch = namespace.charAt(i); + if (ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '_' || ch == '-' || ch == '.') continue; + return false; + } + return true; + } + + static void scanZipPagePaths(File resourcePackFile, String prefix, Set pagePaths) { + try (var zip = new ZipFile(resourcePackFile)) { + var entries = zip.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.isDirectory()) continue; + var path = entry.getName(); + if (!path.startsWith(prefix) || !path.endsWith(".md")) continue; + var relative = path.substring(prefix.length()); + var slashIndex = relative.indexOf('/'); + if (slashIndex <= 0) continue; + if (!isLanguageFolder(relative.substring(0, slashIndex))) continue; + var pagePath = relative.substring(slashIndex + 1); + if (!pagePath.isEmpty()) pagePaths.add(pagePath); + } + } catch (IOException e) { + GuideDebugLog.warn( + "[GuideNH] [GuideResourcePackScanner] Failed to scan zip for pages: {}", + resourcePackFile.getAbsolutePath(), + e); + } + } + + static void collectMarkdownPaths(File directory, String relativePath, Set pagePaths) { + var children = directory.listFiles(); + if (children == null) return; + for (var child : children) { + String childPath = relativePath.isEmpty() ? child.getName() : relativePath + "/" + child.getName(); + if (child.isDirectory()) { + collectMarkdownPaths(child, childPath, pagePaths); + } else if (child.isFile() && child.getName() + .endsWith(".md")) { + pagePaths.add(childPath); + } + } + } + + static boolean isLanguageFolder(String name) { + return name.startsWith(LANGUAGE_FOLDER_PREFIX) && LangUtil.isLanguageCode(name.substring(1)); + } + + static String toLanguageCode(String folderName) { + return LangUtil.normalizeLanguage(folderName.substring(LANGUAGE_FOLDER_PREFIX.length())); + } + + private static boolean resourceExists(IResourcePack resourcePack, ResourceLocation location) { + try { + return resourcePack.resourceExists(location); + } catch (RuntimeException ignored) { + // Third-party resource packs occasionally implement a missing resource as a runtime failure. + return false; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/ResourcePackContentManifest.java b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/ResourcePackContentManifest.java deleted file mode 100644 index 5c7f5207..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/ResourcePackContentManifest.java +++ /dev/null @@ -1,237 +0,0 @@ -package com.hfstudio.guidenh.guide.internal.datadriven; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.ArrayList; -import java.util.List; -import java.util.zip.CRC32; - -/** - * Lightweight content inventory used to decide whether a resource-pack scan can be reused. - * The inventory is only a cache key. Directory packs include a streamed checksum for GuideNH - * files and language files, so a same-size edit (or an edit with a preserved timestamp) still - * invalidates the scan cache. ZIP packs need only immutable archive metadata: their internal - * entries are never retained or enumerated for this purpose. - */ -public class ResourcePackContentManifest { - - /** - * Compact cache key for a resource pack. Directory packs retain only an order-independent - * fingerprint. ZIP packs retain only their normalized path and archive metadata. - */ - public record Pack(String path, boolean directory, long size, long lastModified, int entryCount, long fingerprint) { - - public boolean matchesZip(File root) { - return !directory && !root.isDirectory() - && path.equals(normalizePath(root)) - && size == root.length() - && lastModified == root.lastModified(); - } - } - - public static List capture(List roots, String guideFolder) { - return capture(roots, guideFolder, List.of()); - } - - /** - * Reuses the immutable ZIP metadata from the last scan. Directory packs remain content-aware - * and are rescanned on every reload because changing a child does not reliably update the - * directory timestamp across filesystems. - */ - public static List capture(List roots, String guideFolder, List previous) { - var result = new ArrayList(roots.size()); - for (int index = 0; index < roots.size(); index++) { - File root = roots.get(index); - Pack cached = index < previous.size() ? previous.get(index) : null; - if (!root.isDirectory() && cached != null && cached.matchesZip(root)) { - result.add(cached); - } else { - result.add(root.isDirectory() ? captureDirectory(root, guideFolder) : captureZip(root)); - } - } - return List.copyOf(result); - } - - public static Pack captureDirectory(File root, String guideFolder) { - Path absoluteRoot = root.toPath() - .toAbsolutePath() - .normalize(); - var hashes = new LongHashBuffer(); - - Path assets = absoluteRoot.resolve("assets"); - collectAssets(assets, absoluteRoot, guideFolder, hashes); - try (var children = Files.list(absoluteRoot)) { - children.filter(Files::isDirectory) - .filter( - path -> !"assets".equals( - path.getFileName() - .toString())) - .forEach(path -> collectNativeNamespace(path, absoluteRoot, guideFolder, hashes)); - } catch (IOException e) { - hashes.add(entryHash("", -1L, -1L, -1L)); - } - - return new Pack( - normalizePath(root), - true, - root.length(), - root.lastModified(), - hashes.size(), - hashes.fingerprint()); - } - - private static void collectAssets(Path assets, Path root, String guideFolder, LongHashBuffer hashes) { - if (!Files.isDirectory(assets)) return; - try (var namespaces = Files.list(assets)) { - namespaces.filter(Files::isDirectory) - .forEach(namespace -> { - collectFiles(namespace.resolve(guideFolder), root, hashes); - collectLangFiles(namespace.resolve("lang"), root, hashes); - }); - } catch (IOException e) { - hashes.add(entryHash("assets/", -1L, -1L, -1L)); - } - } - - private static void collectNativeNamespace(Path namespace, Path root, String guideFolder, LongHashBuffer hashes) { - collectFiles(namespace.resolve(guideFolder), root, hashes); - if (guideFolder.equals( - namespace.getFileName() - .toString())) { - collectFiles(namespace, root, hashes); - } - collectLangFiles(namespace.resolve("lang"), root, hashes); - } - - private static void collectLangFiles(Path directory, Path root, LongHashBuffer hashes) { - if (!Files.isDirectory(directory)) return; - byte[] contentBuffer = new byte[16 * 1024]; - try (var files = Files.walk(directory)) { - files.filter(Files::isRegularFile) - .filter( - path -> path.getFileName() - .toString() - .endsWith(".lang")) - .forEach(path -> addFile(path, root, hashes, contentBuffer)); - } catch (IOException e) { - hashes.add( - entryHash( - root.relativize(directory) - .toString() - .replace(File.separatorChar, '/') + "/", - -1L, - -1L, - -1L)); - } - } - - public static void collectFiles(Path directory, Path root, LongHashBuffer hashes) { - if (!Files.isDirectory(directory)) return; - byte[] contentBuffer = new byte[16 * 1024]; - try (var files = Files.walk(directory)) { - files.filter(Files::isRegularFile) - .forEach(path -> addFile(path, root, hashes, contentBuffer)); - } catch (IOException e) { - hashes.add( - entryHash( - root.relativize(directory) - .toString() - .replace(File.separatorChar, '/') + "/", - -1L, - -1L, - -1L)); - } - } - - public static Pack captureZip(File root) { - return new Pack(normalizePath(root), false, root.length(), root.lastModified(), 0, 0L); - } - - public static long entryHash(String path, long size, long modified, long crc) { - long hash = 0xcbf29ce484222325L; - for (int i = 0; i < path.length(); i++) { - hash ^= path.charAt(i); - hash *= 0x100000001b3L; - } - hash ^= size; - hash *= 0x100000001b3L; - hash ^= modified; - hash *= 0x100000001b3L; - hash ^= crc; - hash *= 0x100000001b3L; - return mix64(hash); - } - - public static long mix64(long value) { - value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; - value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; - return value ^ (value >>> 31); - } - - private static long contentChecksum(Path path, byte[] buffer) { - CRC32 crc = new CRC32(); - try (var input = Files.newInputStream(path)) { - int read; - while ((read = input.read(buffer)) >= 0) { - if (read > 0) { - crc.update(buffer, 0, read); - } - } - return crc.getValue(); - } catch (IOException e) { - return -1L; - } - } - - private static void addFile(Path path, Path root, LongHashBuffer hashes, byte[] contentBuffer) { - String relativePath = root.relativize(path) - .toString() - .replace(File.separatorChar, '/'); - try { - var attributes = Files.readAttributes(path, BasicFileAttributes.class); - hashes.add( - entryHash( - relativePath, - attributes.size(), - attributes.lastModifiedTime() - .toMillis(), - contentChecksum(path, contentBuffer))); - } catch (IOException e) { - hashes.add(entryHash(relativePath, -1L, -1L, -1L)); - } - } - - private static String normalizePath(File root) { - return root.toPath() - .toAbsolutePath() - .normalize() - .toString(); - } - - /** Small primitive buffer used only during a scan; no per-entry objects survive the scan. */ - public static final class LongHashBuffer { - - public int size; - public long sum; - public long xor; - - public void add(long value) { - size++; - sum += value; - xor ^= value; - } - - public int size() { - return size; - } - - public long fingerprint() { - // The aggregate is independent of filesystem/ZIP enumeration order. Combining both - // sum and xor keeps accidental collisions unlikely without retaining all entries. - return mix64(sum ^ Long.rotateLeft(xor, 17) ^ ((long) size * 0x9e3779b97f4a7c15L)); - } - } -}