diff --git a/src/skill_tree/tree.py b/src/skill_tree/tree.py index 678227d9f7ea85bd19654be8dc19e5594ac9da4f..877cbc10824f5c8a47e23fffbb7e4dc17be06828 100644 --- a/src/skill_tree/tree.py +++ b/src/skill_tree/tree.py @@ -9,13 +9,18 @@ import re from parsec import BasicState, ParsecError from .exercises.markdown import parse -from .exercises.init_exercises import emit_head, emit_answer, emit_options, simple_list_md_dump +from .exercises.init_exercises import ( + emit_head, + emit_answer, + emit_options, + simple_list_md_dump, +) id_set = set() logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) handler = logging.StreamHandler(sys.stdout) -formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) @@ -29,14 +34,12 @@ def search_author(author_dict, username): def user_name(md_file, author_dict): - ret = subprocess.Popen([ - "git", "log", md_file - ], stdout=subprocess.PIPE) + ret = subprocess.Popen(["git", "log", md_file], stdout=subprocess.PIPE) lines = list(map(lambda l: l.decode(), ret.stdout.readlines())) author_lines = [] for line in lines: - if line.startswith('Author'): - author_lines.append(line.split(' ')[1]) + if line.startswith("Author"): + author_lines.append(line.split(" ")[1]) if len(author_lines) == 0: return None author_nick_name = author_lines[-1] @@ -44,12 +47,11 @@ def user_name(md_file, author_dict): def load_json(p): - with open(p, 'r', encoding="utf-8") as f: + with open(p, "r", encoding="utf-8") as f: try: return json.loads(f.read()) except UnicodeDecodeError: - logger.info( - "json 文件 [{p}] 编码错误,请确保其内容保存为 utf-8 或 base64 后的 ascii 格式。") + logger.info("json 文件 [{p}] 编码错误,请确保其内容保存为 utf-8 或 base64 后的 ascii 格式。") def dump_json(p, j, exist_ok=False, override=False): @@ -61,18 +63,20 @@ def dump_json(p, j, exist_ok=False, override=False): logger.error(f"{p} already exist") sys.exit(0) - with open(p, 'w+', encoding="utf8") as f: + with open(p, "w+", encoding="utf8") as f: f.write(json.dumps(j, indent=2, ensure_ascii=False)) def ensure_config(path): config_path = os.path.join(path, "config.json") if not os.path.exists(config_path): - node = {"keywords": [], - "keywords_must": [], - "keywords_forbid": [], - "group": 0, - "subtree": ""} + node = { + "keywords": [], + "keywords_must": [], + "keywords_forbid": [], + "group": 0, + "subtree": "", + } dump_json(config_path, node, exist_ok=True, override=False) return node else: @@ -80,7 +84,7 @@ def ensure_config(path): def parse_no_name(d): - p = r'(\d+)\.(.*)' + p = r"(\d+)\.(.*)" m = re.search(p, d) try: @@ -95,7 +99,7 @@ def parse_no_name(d): def check_export(base, cfg): flag = False exports = [] - for export in cfg.get('export', []): + for export in cfg.get("export", []): ecfg_path = os.path.join(base, export) if os.path.exists(ecfg_path): exports.append(export) @@ -106,15 +110,53 @@ def check_export(base, cfg): return flag +def read_project_markdown(file): + start_desc = False + start_project = False + desc = [] + project = [] + with open(file, "r") as f: + for line in f.readlines(): + line = line.strip("\n") + if start_desc and line.strip() != "": + desc.append(line) + if start_project and line.strip() != "": + project.append(line) + if line == "# 项目说明": + start_desc = True + if line == "# 项目地址": + start_desc = False + start_project = True + + print(desc) + print(project) + return "\n".join(desc), project[0].strip().replace("<", "").replace(">", "") + + +def walk_project_2_config(data_path): + for base, dirs, files in os.walk(data_path): + for file in files: + parts = file.split(".") + if parts[-1] == "md": + desc, project = read_project_markdown(os.path.join(base, file)) + config_path = os.path.join(base, file.replace("md", "json")) + config = load_json(config_path) + config["project"] = project + config["desc"] = desc + dump_json(config_path, config, exist_ok=True, override=True) + + class TreeWalker: def __init__( - self, root, - tree_name, - title=None, - log=None, - authors=None, - enable_notebook=None, - ignore_keywords=False + self, + root, + tree_name, + title=None, + log=None, + authors=None, + enable_notebook=None, + ignore_keywords=False, + default_exercise_type="code_options", ): self.ignore_keywords = ignore_keywords self.authors = authors if authors else {} @@ -124,6 +166,7 @@ class TreeWalker: self.title = tree_name if title is None else title self.tree = {} self.logger = logger if log is None else log + self.default_exercise_type = default_exercise_type def walk(self): root = self.load_root() @@ -134,7 +177,7 @@ class TreeWalker: "keywords_must": root.get("keywords_must", []), "keywords_forbid": root.get("keywords_forbid", []), "group": root.get("group", 0), - "subtree": root.get("subtree", "") + "subtree": root.get("subtree", ""), } self.tree[root["tree_name"]] = root_node self.load_levels(root_node) @@ -147,13 +190,13 @@ class TreeWalker: for index, chapter in enumerate(level_node["children"]): chapter_title = list(chapter.keys())[0] chapter_node = list(chapter.values())[0] - chapter_path = os.path.join( - level_path, f"{index + 1}.{chapter_title}") + chapter_path = os.path.join(level_path, f"{index + 1}.{chapter_title}") self.load_sections(chapter_path, chapter_node) for index, section_node in enumerate(chapter_node["children"]): section_title = list(section_node.keys())[0] full_path = os.path.join( - chapter_path, f"{index + 1}.{section_title}") + chapter_path, f"{index + 1}.{section_title}" + ) if os.path.isdir(full_path): self.check_section_keywords(full_path) self.ensure_exercises(full_path) @@ -198,7 +241,7 @@ class TreeWalker: "keywords_must": config.get("keywords_must", []), "keywords_forbid": config.get("keywords_forbid", []), "group": config.get("group", 0), - "subtree": config.get("subtree", "") + "subtree": config.get("subtree", ""), } } @@ -253,7 +296,7 @@ class TreeWalker: "keywords_must": [], "keywords_forbid": [], "group": 0, - "subtree": "" + "subtree": "", } dump_json(config_path, config, exist_ok=True, override=True) else: @@ -267,9 +310,7 @@ class TreeWalker: def ensure_level_config(self, path): config_path = os.path.join(path, "config.json") if not os.path.exists(config_path): - config = { - "node_id": self.gen_node_id() - } + config = {"node_id": self.gen_node_id()} dump_json(config_path, config, exist_ok=True, override=True) else: config = load_json(config_path) @@ -287,7 +328,7 @@ class TreeWalker: "keywords_must": [], "keywords_forbid": [], "group": 0, - "subtree": "" + "subtree": "", } dump_json(config_path, config, exist_ok=True, override=True) else: @@ -308,7 +349,7 @@ class TreeWalker: "keywords_must": [], "keywords_forbid": [], "group": 0, - "subtree": "" + "subtree": "", } dump_json(config_path, config, exist_ok=True, override=True) else: @@ -320,9 +361,11 @@ class TreeWalker: def ensure_node_id(self, config): flag = False - if "node_id" not in config or \ - not config["node_id"].startswith(f"{self.name}-") or \ - config["node_id"] in id_set: + if ( + "node_id" not in config + or not config["node_id"].startswith(f"{self.name}-") + or config["node_id"] in id_set + ): new_id = self.gen_node_id() id_set.add(new_id) config["node_id"] = new_id @@ -360,7 +403,7 @@ class TreeWalker: "keywords_must": config.get("keywords_must", []), "keywords_forbid": config.get("keywords_forbid", []), "group": config.get("group", 0), - "subtree": config.get("subtree", "") + "subtree": config.get("subtree", ""), } } return num, result @@ -376,7 +419,7 @@ class TreeWalker: "keywords_must": config.get("keywords_must", []), "keywords_forbid": config.get("keywords_forbid", []), "group": config.get("group", 0), - "subtree": config.get("subtree", "") + "subtree": config.get("subtree", ""), } } # if "children" in config: @@ -409,7 +452,7 @@ class TreeWalker: logger.error(f"习题 [{md_file}] 编码错误,请确保其保存为 utf-8 编码") sys.exit(1) - if data.strip() == '': + if data.strip() == "": md = [] emit_head(md) emit_answer(md, None) @@ -426,16 +469,19 @@ class TreeWalker: state = BasicState(data) try: - doc = parse(state) + if meta["type"] == "code_options": + doc = parse(state) + else: + walk_project_2_config(self.root) except ParsecError as err: index = state.index - context = state.data[index - 15:index + 15] + context = state.data[index - 15 : index + 15] logger.error( - f"习题 [{md_file}] 解析失败,在位置 {index} [{context}] 附近有格式: [{err}]") + f"习题 [{md_file}] 解析失败,在位置 {index} [{context}] 附近有格式: [{err}]" + ) if flag: - dump_json(os.path.join(section_path, "config.json"), - config, True, True) + dump_json(os.path.join(section_path, "config.json"), config, True, True) for e in config.get("export", []): full_name = os.path.join(section_path, e) @@ -463,18 +509,18 @@ class TreeWalker: meta["source"] = source if "author" not in meta: meta["author"] = user_name(md_file, self.authors) - elif meta['author'] is None: + elif meta["author"] is None: meta["author"] = user_name(md_file, self.authors) if "type" not in meta: - meta["type"] = "code_options" + meta["type"] = self.default_exercise_type if meta is None: meta = { - "type": "code_options", + "type": self.default_exercise_type, "author": user_name(md_file, self.authors), "source": source, "notebook_enable": self.default_notebook(), - "exercise_id": uuid.uuid4().hex + "exercise_id": uuid.uuid4().hex, } dump_json(meta_path, meta, True, True) @@ -499,11 +545,12 @@ class TreeWalker: os.makedirs(data_root, exist_ok=True) node_dirs = [ - os.path.join(data_root, f'1.{self.title}初阶'), - os.path.join(data_root, f'2.{self.title}中阶'), - os.path.join(data_root, f'3.{self.title}高阶'), - os.path.join(data_root, f'1.{self.title}初阶', - f"1.{self.title}入门", f"1.HelloWorld"), + os.path.join(data_root, f"1.{self.title}初阶"), + os.path.join(data_root, f"2.{self.title}中阶"), + os.path.join(data_root, f"3.{self.title}高阶"), + os.path.join( + data_root, f"1.{self.title}初阶", f"1.{self.title}入门", f"1.HelloWorld" + ), ] for node_dir in node_dirs: @@ -513,187 +560,195 @@ class TreeWalker: emit_head(md) emit_answer(md, None) emit_options(md, None) - simple_list_md_dump(os.path.join( - node_dirs[len(node_dirs) - 1], 'helloworld.md'), md) + simple_list_md_dump( + os.path.join(node_dirs[len(node_dirs) - 1], "helloworld.md"), md + ) self.walk() self.init_readme() - with open('.gitignore', 'w', encoding='utf-8') as f: - f.write('\n'.join([ - ".vscode", - ".idea", - ".DS_Store", - "__pycache__", - "*.pyc", - "*.zip", - "*.out", - "bin/", - "debug/", - "release/", - ])) - - with open('requirements.txt', 'w', encoding='utf-8') as f: - f.write('\n'.join([ - "pre_commit", - "skill-tree-parser", - ])) + with open(".gitignore", "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + ".vscode", + ".idea", + ".DS_Store", + "__pycache__", + "*.pyc", + "*.zip", + "*.out", + "bin/", + "debug/", + "release/", + ] + ) + ) + + with open("requirements.txt", "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + "pre_commit", + "skill-tree-parser", + ] + ) + ) def init_readme(self): - md = [ - f'# skill_tree_{self.name}', - f'', - f'`{self.title}技能树`是[技能森林](https://gitcode.net/csdn/skill_tree)的一部分。', - f'', - f'## 编辑环境初始化', - f'', - f'```', - f'pip install -r requirements.txt', - f'```', - f'', - f'## 目录结构说明', - f'技能树编辑仓库的 data 目录是主要的编辑目录,目录的结构是固定的', - f'', - f'* 技能树`骨架文件`:', - f' * 位置:`data/tree.json`', - f' * 说明:该文件是执行 `python main.py` 生成的,请勿人工编辑', - f'* 技能树`根节点`配置文件:', - f' * 位置:`data/config.json`', - f' * 说明:可编辑配置关键词等字段,其中 `node_id` 字段是生成的,请勿编辑', - f'* 技能树`难度节点`:', - f' * 位置:`data/xxx`,例如: `data/1.{self.title}初阶`', - f' * 说明:', - f' * 每个技能树有 3 个等级,目录前的序号是必要的,用来保持文件夹目录的顺序', - f' * 每个目录下有一个 `config.json` 可配置关键词信息,其中 `node_id` 字段是生成的,请勿编辑', - f'* 技能树`章节点`:', - f' * 位置:`data/xxx/xxx`,例如:`data/1.{self.title}初阶/1.{self.title}简介`', - f' * 说明:', - f' * 每个技能树的每个难度等级有 n 个章节,目录前的序号是必要的,用来保持文件夹目录的顺序', - f' * 每个目录下有一个 `config.json` 可配置关键词信息,其中 `node_id` 字段是生成的,请勿编辑', - f'* 技能树`知识节点`:', - f' * 位置:`data/xxx/xxx`,例如:`data/1.{self.title}初阶/1.{self.title}简介`', - f' * 说明:', - f' * 每个技能树的每章有 n 个知识节点,目录前的序号是必要的,用来保持文件夹目录的顺序', - f' * 每个目录下有一个 `config.json`', - f' * 其中 `node_id` 字段是生成的,请勿编辑', - f' * 其中 `keywords` 可配置关键字字段', - f' * 其中 `children` 可配置该`知识节点`下的子树结构信息,参考后面描述', - f' * 其中 `export` 可配置该`知识节点`下的导出习题信息,参考后面描述', - f'', - f'## `知识节点` 子树信息结构', - f'', - f'例如 `data/1.{self.title}初阶/1.{self.title}简介/1.HelloWorld/config.json` 里配置对该知识节点子树信息结构,这个配置是可选的:', - f'```json', - f'{{', - f' // ...', - f'', + f"# skill_tree_{self.name}", + f"", + f"`{self.title}技能树`是[技能森林](https://gitcode.net/csdn/skill_tree)的一部分。", + f"", + f"## 编辑环境初始化", + f"", + f"```", + f"pip install -r requirements.txt", + f"```", + f"", + f"## 目录结构说明", + f"技能树编辑仓库的 data 目录是主要的编辑目录,目录的结构是固定的", + f"", + f"* 技能树`骨架文件`:", + f" * 位置:`data/tree.json`", + f" * 说明:该文件是执行 `python main.py` 生成的,请勿人工编辑", + f"* 技能树`根节点`配置文件:", + f" * 位置:`data/config.json`", + f" * 说明:可编辑配置关键词等字段,其中 `node_id` 字段是生成的,请勿编辑", + f"* 技能树`难度节点`:", + f" * 位置:`data/xxx`,例如: `data/1.{self.title}初阶`", + f" * 说明:", + f" * 每个技能树有 3 个等级,目录前的序号是必要的,用来保持文件夹目录的顺序", + f" * 每个目录下有一个 `config.json` 可配置关键词信息,其中 `node_id` 字段是生成的,请勿编辑", + f"* 技能树`章节点`:", + f" * 位置:`data/xxx/xxx`,例如:`data/1.{self.title}初阶/1.{self.title}简介`", + f" * 说明:", + f" * 每个技能树的每个难度等级有 n 个章节,目录前的序号是必要的,用来保持文件夹目录的顺序", + f" * 每个目录下有一个 `config.json` 可配置关键词信息,其中 `node_id` 字段是生成的,请勿编辑", + f"* 技能树`知识节点`:", + f" * 位置:`data/xxx/xxx`,例如:`data/1.{self.title}初阶/1.{self.title}简介`", + f" * 说明:", + f" * 每个技能树的每章有 n 个知识节点,目录前的序号是必要的,用来保持文件夹目录的顺序", + f" * 每个目录下有一个 `config.json`", + f" * 其中 `node_id` 字段是生成的,请勿编辑", + f" * 其中 `keywords` 可配置关键字字段", + f" * 其中 `children` 可配置该`知识节点`下的子树结构信息,参考后面描述", + f" * 其中 `export` 可配置该`知识节点`下的导出习题信息,参考后面描述", + f"", + f"## `知识节点` 子树信息结构", + f"", + f"例如 `data/1.{self.title}初阶/1.{self.title}简介/1.HelloWorld/config.json` 里配置对该知识节点子树信息结构,这个配置是可选的:", + f"```json", + f"{{", + f" // ...", + f"", f' "children": [', - f' {{', + f" {{", f' "XX开发入门": {{', f' "keywords": [', f' "XX开发",', - f' ],', + f" ],", f' "children": [],', f' "keywords_must": [', f' "XX"', - f' ],', + f" ],", f' "keywords_forbid": []', - f' }}', - f' }}', - f' ],', - f'}}', - f'```', - f'', - f'## `知识节点` 的导出习题编辑', - f'', - f'例如 `data/1.{self.title}初阶/1.{self.title}简介/1.HelloWorld/config.json` 里配置对该知识节点导出的习题', - f'', - f'```json', - f'{{', - f' // ...', + f" }}", + f" }}", + f" ],", + f"}}", + f"```", + f"", + f"## `知识节点` 的导出习题编辑", + f"", + f"例如 `data/1.{self.title}初阶/1.{self.title}简介/1.HelloWorld/config.json` 里配置对该知识节点导出的习题", + f"", + f"```json", + f"{{", + f" // ...", f' "export": [', f' "helloworld.json"', - f' ]', - f'}}', - f'```', - f'', - f'helloworld.json 的格式如下:', - f'```bash', - f'{{', + f" ]", + f"}}", + f"```", + f"", + f"helloworld.json 的格式如下:", + f"```bash", + f"{{", f' "type": "code_options",', f' "author": "xxx",', f' "source": "helloworld.md",', f' "notebook_enable": false,', f' "exercise_id": "xxx"', - f'}}', - f'```', - f'', - f'其中 ', + f"}}", + f"```", + f"", + f"其中 ", f'* "type": "code_options" 表示是一个选择题', f'* "author" 可以放作者的 CSDN id,', f'* "source" 指向了习题 MarkDown文件', f'* "notebook_enable" 目前都是false', f'* "exercise_id" 是工具生成的,不填', - f'', - f'', - f'习题格式模版如下:', - f'', - f'````mardown', - f'# {{标题}}', - f'', - f'{{习题描述}}', - f'', - f'以下关于上述游戏代码说法[正确/错误]的是?', - f'', - f'## 答案', - f'', - f'{{目标选项}}', - f'', - f'## 选项', - f'', - f'### A', - f'', - f'{{混淆选项1}}', - f'', - f'### B', - f'', - f'{{混淆选项2}}', - f'', - f'### C', - f'', - f'{{混淆选项3}}', - f'', - f'````', - f'', - f'## 技能树合成', - f'', - f'在根目录下执行 `python main.py` 会合成技能树文件,合成的技能树文件: `data/tree.json`', - f'* 合成过程中,会自动检查每个目录下 `config.json` 里的 `node_id` 是否存在,不存在则生成', - f'* 合成过程中,会自动检查每个知识点目录下 `config.json` 里的 `export` 里导出的习题配置,检查是否存在`exercise_id` 字段,如果不存在则生成', - f'* 在 节 目录下根据需要,可以添加一些子目录用来测试代码。', - f'* 开始游戏入门技能树构建之旅,GoodLuck! ', - f'', - f'## FAQ', - f'', - f'**难度目录是固定的么?**', - f'', - f'1. data/xxx 目录下的子目录是固定的初/中/高三个难度等级目录', - f'', - f'**如何增加章目录?**', - f'', - f'1. 在VSCode里打开项目仓库', - f'2. 在对应的难度等级目录新建章目录,例如在 data/1.xxx初阶/ 下新建章文件夹,data/1.xxx初阶/1.yyy', - f'3. 在项目根目录下执行 python main.py 脚本,会自动生成章的配置文件 data/1.xxx初阶/1.yyy/config.json', - f'', - f'**如何增加节目录?**:', + f"", + f"", + f"习题格式模版如下:", + f"", + f"````mardown", + f"# {{标题}}", + f"", + f"{{习题描述}}", + f"", + f"以下关于上述游戏代码说法[正确/错误]的是?", + f"", + f"## 答案", + f"", + f"{{目标选项}}", + f"", + f"## 选项", + f"", + f"### A", + f"", + f"{{混淆选项1}}", + f"", + f"### B", + f"", + f"{{混淆选项2}}", + f"", + f"### C", + f"", + f"{{混淆选项3}}", + f"", + f"````", + f"", + f"## 技能树合成", + f"", + f"在根目录下执行 `python main.py` 会合成技能树文件,合成的技能树文件: `data/tree.json`", + f"* 合成过程中,会自动检查每个目录下 `config.json` 里的 `node_id` 是否存在,不存在则生成", + f"* 合成过程中,会自动检查每个知识点目录下 `config.json` 里的 `export` 里导出的习题配置,检查是否存在`exercise_id` 字段,如果不存在则生成", + f"* 在 节 目录下根据需要,可以添加一些子目录用来测试代码。", + f"* 开始游戏入门技能树构建之旅,GoodLuck! ", + f"", + f"## FAQ", + f"", + f"**难度目录是固定的么?**", + f"", + f"1. data/xxx 目录下的子目录是固定的初/中/高三个难度等级目录", + f"", + f"**如何增加章目录?**", + f"", + f"1. 在VSCode里打开项目仓库", + f"2. 在对应的难度等级目录新建章目录,例如在 data/1.xxx初阶/ 下新建章文件夹,data/1.xxx初阶/1.yyy", + f"3. 在项目根目录下执行 python main.py 脚本,会自动生成章的配置文件 data/1.xxx初阶/1.yyy/config.json", + f"", + f"**如何增加节目录?**:", f'1. 直接在VSCode里创建文件夹,例如 "data/1.xxx初阶/1.yyy/2.zzz"', - f'2. 项目根目录下执行 python main.py 会自动为新增节创建配置文件 data/1.xxx初阶/1.yyy/2.zzz/config.json', - f'', - f'**如何在节下新增一个习题**:', + f"2. 项目根目录下执行 python main.py 会自动为新增节创建配置文件 data/1.xxx初阶/1.yyy/2.zzz/config.json", + f"", + f"**如何在节下新增一个习题**:", f'3. 在"data/1.xxx初阶/1.yyy/2.zzz" 目录下添加一个 markdown 文件编辑,例如 yyy.md,按照习题markdown格式编辑习题。', - f'4. md编辑完后,可以再次执行 python main.py 会自动生成同名的 yyy.json,并将 yyy.json 添加到config.json 的export数组里。', - f'5. yyy.json里的author信息放作者 CSDN ID。', + f"4. md编辑完后,可以再次执行 python main.py 会自动生成同名的 yyy.json,并将 yyy.json 添加到config.json 的export数组里。", + f"5. yyy.json里的author信息放作者 CSDN ID。", ] - simple_list_md_dump('README.md', md) + simple_list_md_dump("README.md", md)