package xiaozhi.modules.parent.catalog; import java.io.InputStream; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; /** * 成语课文清单。增删只改 classpath:catalog/idiom-lessons.json。 * 故事按 7 章收录一次,6 个 level 共用;lessonId = 级别.序号(如 l1.001)。 * 分类 systemId 只给家长端筛选,不进文件路径。 */ public final class IdiomLessonCatalog { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final TypeReference> MAP = new TypeReference>() { }; private static final Pattern LESSON = Pattern.compile("^(l[1-6])\\.(\\d{3})$", Pattern.CASE_INSENSITIVE); private static final Map DATA = load(); private static final Map> BY_ID = indexStories(); private IdiomLessonCatalog() { } public static Map asApiMap() { Map out = new LinkedHashMap<>(); out.put("version", DATA.getOrDefault("version", 2)); out.put("defaults", DATA.get("defaults")); out.put("pathPatterns", DATA.get("pathPatterns")); out.put("levels", DATA.get("levels")); out.put("systems", DATA.get("systems")); List> stories = new ArrayList<>(); for (Map story : stories()) { if (enabled(story)) { stories.add(publicStory(story)); } } out.put("stories", stories); return out; } public static Map findStory(String lessonId) { if (lessonId == null || lessonId.isBlank()) { return null; } String key = lessonId.trim().toLowerCase(Locale.ROOT); Map hit = BY_ID.get(key); if (hit != null) { return hit; } String[] parts = parseLesson(key); if (parts != null) { return BY_ID.get(parts[1]); } return null; } public static String canonicalLessonId(String lessonId) { Map story = findStory(lessonId); if (story == null) { return lessonId == null ? "" : lessonId.trim(); } Map bound = bindStory(story, lessonId); return text(bound.get("levelId")) + "." + text(story.get("id")); } public static String storyName(String lessonId) { Map story = findStory(lessonId); return story == null ? "" : text(story.get("name")); } public static String storyNameEn(String lessonId) { Map story = findStory(lessonId); return story == null ? "" : text(story.get("nameEn")); } public static int sentenceCount(String lessonId) { Map story = findStory(lessonId); if (story == null) { return defaultInt("sentenceCount", 30); } return Math.max(1, Math.min(200, intVal(story.get("sentenceCount"), defaultInt("sentenceCount", 30)))); } @SuppressWarnings("unchecked") public static List> defaultEnPasses() { Map defaults = (Map) DATA.get("defaults"); Object raw = defaults == null ? null : defaults.get("enPasses"); if (!(raw instanceof List list) || list.isEmpty()) { return List.of( pass(0.8, 1), pass(0.9, 2), pass(1.0, 5)); } List> out = new ArrayList<>(); for (Object item : list) { Map row = normalizePass(item); if (row != null) { out.add(row); } } return out.isEmpty() ? List.of(pass(0.8, 1), pass(0.9, 2), pass(1.0, 5)) : out; } public static List relativePaths(String scene, String lessonId, String language) { Map story = findStory(lessonId); if (story == null) { return List.of(); } Map bound = bindStory(story, lessonId); if ("extensive".equals(scene)) { return extensivePaths(bound, lessonId); } if ("shadowing".equals(scene)) { return shadowingPaths(bound, language, sentenceCount(lessonId), lessonId); } return List.of(); } public static List> lessons(String scene) { if (!"extensive".equals(scene) && !"shadowing".equals(scene)) { return List.of(); } String kind = "extensive".equals(scene) ? "泛听" : "跟读"; List> out = new ArrayList<>(); for (Map story : stories()) { if (!enabled(story)) { continue; } Map row = publicStory(story); row.put("lessonId", "l1." + text(story.get("id"))); row.put("levelId", "l1"); row.put("scene", scene); row.put("hint", kind + " · " + text(story.get("name"))); out.add(row); } return out; } private static Map publicStory(Map story) { Map row = new LinkedHashMap<>(); row.put("id", text(story.get("id"))); row.put("slug", text(story.get("slug"))); row.put("systemId", text(story.get("systemId"))); row.put("name", text(story.get("name"))); row.put("nameEn", text(story.get("nameEn"))); row.put("sentenceCount", intVal(story.get("sentenceCount"), defaultInt("sentenceCount", 30))); return row; } @SuppressWarnings("unchecked") private static List extensivePaths(Map story, String lessonId) { Map patterns = (Map) DATA.get("pathPatterns"); String unified = patterns == null ? "" : text(patterns.get("repeater")); if (!unified.isBlank()) { return List.of(fill(unified, story, "cn", "01"), fill(unified, story, "en", "01")); } String cnPat = patterns == null ? "" : text(patterns.get("repeaterCn")); String enPat = patterns == null ? "" : text(patterns.get("repeaterEn")); return List.of(fill(cnPat, story, "cn", "01"), fill(enPat, story, "en", "01")); } @SuppressWarnings("unchecked") private static List shadowingPaths(Map story, String language, int count, String lessonId) { String lang = "cn".equalsIgnoreCase(language) ? "cn" : "en"; int width = count > 99 ? 3 : 2; List files = new ArrayList<>(count); Map patterns = (Map) DATA.get("pathPatterns"); String pattern = patterns == null ? "" : text(patterns.get("shadowing")); for (int i = 1; i <= count; i++) { files.add(fill(pattern, story, lang, String.format("%0" + width + "d", i))); } return files; } private static Map bindStory(Map story, String lessonId) { Map bound = new LinkedHashMap<>(story); String[] parts = parseLesson(lessonId); bound.put("levelId", parts != null ? parts[0] : "l1"); return bound; } private static String[] parseLesson(String lessonId) { if (lessonId == null || lessonId.isBlank()) { return null; } Matcher compact = LESSON.matcher(lessonId.trim()); if (compact.matches()) { return new String[] { compact.group(1).toLowerCase(Locale.ROOT), compact.group(2) }; } return null; } private static String fill(String pattern, Map story, String lang, String nn) { String value = pattern == null ? "" : pattern; return value .replace("{level}", text(story.get("levelId"))) .replace("{story}", text(story.get("id"))) .replace("{lang}", lang) .replace("{nn}", nn); } @SuppressWarnings("unchecked") private static List> stories() { Object raw = DATA.get("stories"); if (!(raw instanceof List list)) { return List.of(); } List> out = new ArrayList<>(); for (Object item : list) { if (item instanceof Map map) { out.add((Map) map); } } return out; } private static Map> indexStories() { Map> index = new LinkedHashMap<>(); for (Map story : stories()) { putIndex(index, text(story.get("id")), story); } return index; } private static void putIndex(Map> index, String key, Map story) { if (key == null || key.isBlank()) { return; } index.putIfAbsent(key.trim().toLowerCase(Locale.ROOT), story); } private static boolean enabled(Map story) { Object raw = story.get("enabled"); if (raw instanceof Boolean bool) { return bool; } return true; } public static Map normalizePass(Object raw) { if (!(raw instanceof Map map)) { return null; } double speed = doubleVal(map.get("speed"), 1.0); if (speed < 0.5) { speed = 0.5; } if (speed > 2.0) { speed = 2.0; } int repeat = intVal(map.get("repeat"), 1); if (repeat < 1) { repeat = 1; } if (repeat > 10) { repeat = 10; } return pass(speed, repeat); } private static Map pass(double speed, int repeat) { Map row = new LinkedHashMap<>(); row.put("speed", speed); row.put("repeat", repeat); return row; } @SuppressWarnings("unchecked") private static int defaultInt(String key, int fallback) { Map defaults = (Map) DATA.get("defaults"); return defaults == null ? fallback : intVal(defaults.get(key), fallback); } private static int intVal(Object value, int fallback) { if (value == null) { return fallback; } if (value instanceof Number number) { return number.intValue(); } try { return Integer.parseInt(String.valueOf(value).trim()); } catch (NumberFormatException ex) { return fallback; } } private static double doubleVal(Object value, double fallback) { if (value == null) { return fallback; } if (value instanceof Number number) { return number.doubleValue(); } try { return Double.parseDouble(String.valueOf(value).trim()); } catch (NumberFormatException ex) { return fallback; } } private static String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); } private static Map load() { try (InputStream in = IdiomLessonCatalog.class.getResourceAsStream("/catalog/idiom-lessons.json")) { if (in == null) { throw new IllegalStateException("missing classpath:catalog/idiom-lessons.json"); } Map data = MAPPER.readValue(in, MAP); return data == null ? Map.of() : data; } catch (Exception ex) { throw new IllegalStateException("failed to load idiom-lessons.json", ex); } } }