diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c393a..99e0182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ -# 0.3.0 - 2022-03-01 -* support progress bar for skill +# 0.3.0 - 2022-03-02 +* support different styles of progress bar for skill # 0.2.3 - 2022-02-27 * add a default skill map name and mark python 3.8 to be the min version # 0.2.2 - 2022-02-27 diff --git a/README.md b/README.md index 37fab70..e6a94c4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ This project borrows inspiration and ideas from two sources: 1. https://hacks.mozilla.org/2018/10/webassemblys-post-mvp-future/ 2. https://github.com/nikomatsakis/skill-tree +# Features +* skill tree/map generation +* specify pre-requisite skills +* multiple themes +* multiple skill progress bar styles # Installation ``` pip install skillmap @@ -43,6 +48,7 @@ icon = "rocket" * Each node can have a string label and an fontawsome icon. * Skills with different statuses will be shown with different colors. +* Each skill may have a progress bar to indicate its learning progress. * Unnamed skill will be shown as a locked skill. * Pre-requisite skills will be connected with an directed edge. * You can embed the generated mermaid diagram into github markdown directly, but the fontawesome icons in the diagrams are not shown by github so far. @@ -56,4 +62,6 @@ icon = "rocket" * install several tools to make hot reloading to work * [`entr`](https://github.com/eradman/entr), run arbitrary commands when files change * [Visual Studio Code](https://code.visualstudio.com) + [Markdown Preview Enhanced Visual Studio Code Extension](https://marketplace.visualstudio.com/items?itemName=shd101wyy.markdown-preview-enhanced) - * Basically, use `entr` to watch toml file changes, and generate a `md` makrdown file using `skillmap` every time when toml file changes. And use `vscode` + `Markdown Preview Enhanced` extension to open this generated markdown file. Check out `build_sample` and `dev_sample` in [justfile](justfile) to see how to make hot reloading work \ No newline at end of file + * Basically, use `entr` to watch toml file changes, and generate a `md` makrdown file using `skillmap` every time when toml file changes. And use `vscode` + `Markdown Preview Enhanced` extension to open this generated markdown file. Check out `build_sample` and `dev_sample` in [justfile](justfile) to see how to make hot reloading work +# Known issues +* Sometimes, the group's text will be clipped when rendered in mermaid. And you have to edit the generated file slightly and then change it back to ask mermaid to refersh the diagram to avoid clipping. It is probably a bug for mermaid as far as I can tell. \ No newline at end of file diff --git a/docs/images/ocean_theme_example.png b/docs/images/ocean_theme_example.png index 3aabce1..9d27013 100644 Binary files a/docs/images/ocean_theme_example.png and b/docs/images/ocean_theme_example.png differ diff --git a/docs/images/orientation_example.png b/docs/images/orientation_example.png index b3b8806..911f1bb 100644 Binary files a/docs/images/orientation_example.png and b/docs/images/orientation_example.png differ diff --git a/docs/skillmap_descriptor.md b/docs/skillmap_descriptor.md index ec16a2e..faeb765 100644 --- a/docs/skillmap_descriptor.md +++ b/docs/skillmap_descriptor.md @@ -21,12 +21,14 @@ * BT - bottom to top * RL - right to left * LR - left to right + * `progress_bar_style`: [optional] an integer value as different styles of progress bar. When a skill is specified with a progress (e.g. "1/3"), this property can be used to tell skillmap to render the skill progress bar in different styles. There are currently 28 different styles you can choose from. Simply specify a value between `0` ~ `27` to give it a try. For example, here are two styles `๐Ÿ’š๐Ÿค๐Ÿค`/`โ– โ–กโ–ก` you can choose from. ## group/skill toml tables * The `group`/`skill` toml table can have some fields: * `name`: [optional] the name of the skillmap/group/skill. It will be used as a label in the diagram. . * `icon`: [optional] a fontawsome icon name. It will be used as an icon in the diagram. You can find the fontawsome icon list [here](https://fontawesome.com/v4.7.0/icons/). * `requires`: [optional] a list of strings. It indicates a list of skill groups or skills to be learned before this learning this skill group/skill. where each string is a toml table name of a group/skill. It will be rendered as an edge(s) from one node to another. * `progress`: [optional] only applies to a `skill` toml table. It is a fraction number string like `1/3` that indicates the learning progression of the skill. A progress bar like `โ– โ–กโ–ก` will be shown in the skill node to visualize the progress. Skill nodes will be rendered with different colors according to different progresses (zero progress, ongoing, finished). + * `status`: [optional] a string value indicating the status of the skill. Three valid values are supported [new|beingLearned|learned]. When specifying a different status, different colors will be used when rendering the node. Usually, you simply use `progress` to indicate the status. If you do NOT want a progress bar but just want some simple status, you can specify this property. * locked skill: if a skill table doesn't have name or icon, it will be rendered as a locked skill (a grey box + lock icon + `???` as name). ## Example @@ -34,12 +36,14 @@ [groups.learn_python] name = "learn python" icon = "rocket" + [groups.learn_python.skills.string_literal_usage] + name = "string literal" + icon = "book" + progress = "1/3" [groups.learn_python.skills.print] name = "print statement" icon = "printer" - [groups.learn_python.skills.string] - name = "string literal" - icon = "book" + requires = ["groups.learn_python.skills.string_literal_usage"] [groups.program_with_python] name = "program with python" @@ -49,6 +53,8 @@ requires = ["groups.learn_python"] In this exmaple, there are: * two groups: `groups.learn_python` and `groups.program_with_python` * `groups.learn_python` has two skills: + * `groups.learn_python.skills.string_literal_usage` + * this skill's learning progress is `1/3` * `groups.learn_python.skills.print` - * `groups.learn_python.skills.string` + * this skill requires `groups.learn_python.skills.string_literal_usage` to be learned first * `groups.program_with_python` requires `groups.learn_python` to be learned first. When drawn in the diagram, it will be rendered as an edge from `groups.learn_python` to `groups.program_with_python`. \ No newline at end of file diff --git a/justfile b/justfile index 478c87d..9de69fc 100644 --- a/justfile +++ b/justfile @@ -1,18 +1,45 @@ +#!/usr/bin/env just --justfile +set dotenv-load := true + +sample_toml := "tests/url_shortener.toml" +sample_md := "dist/url_shortener.md" +sample_png := "dist/url_shortener.png" +built_wheel := "./dist/skillmap-*-py3-none-any.whl" + +# build and install package into system python for every python file change dev: - find skillmap -iname "*.py" -o -iname "*.cmake" -iname "pyproject.toml" | entr -s "poetry build && pip install ./dist/skillmap-*-any.whl --force-reinstall --no-dependencies" + find skillmap -iname "*.py" -iname "pyproject.toml" | entr -s "poetry build && pip install {{ built_wheel }} --force-reinstall" +# install package into system python setup: # install the library into system python rm -fr ./dist - poetry build && pip install ./dist/skillmap-*-py3-none-any.whl --force-reinstall + poetry build && pip install {{ built_wheel }} --force-reinstall +# publish package to pypi publish: poetry build poetry publish -build_sample src="tests/url_shortener.toml" dest="dist/url_shortener.md": - echo '```mermaid' > {{ dest }} && skillmap {{ src }} >> {{ dest }} && echo '```' >> {{ dest }} +# generate markdown from source toml file +generate src dest: + echo '```mermaid' > {{ dest }} && poetry run skillmap {{ src }} >> {{ dest }} && echo '```' >> {{ dest }} + +# generate png from source toml file +png src dest: + # mermaid cli (https://github.com/mermaid-js/mermaid-cli) needs to be installed + poetry run skillmap {{ src }} | mmdc -o {{ dest }} + +# generate markdown for sample skillmap +generate_sample: + just generate {{ sample_toml }} {{ sample_md }} + +# generate png for sample skillmap +png_sample: + just png {{ sample_toml }} {{ sample_png }} + +# develop sample skillmap by hot reloading the file and generated results +dev_sample: + find "tests" -iname "*.toml" | entr -s "just generate_sample" -dev_sample src="tests": - find {{ src }} -iname "*.toml" | entr -s "just build_sample" diff --git a/skillmap/main.py b/skillmap/main.py index a4028e4..b70b141 100644 --- a/skillmap/main.py +++ b/skillmap/main.py @@ -23,7 +23,7 @@ def _skillmap_parser(): "descriptor_toml", default=False, type=str, - help="The path to a toml file describing the skillmap", + help="The path to a toml file describing the skillmap. You can find more deetails https://github.com/niyue/skillmap/blob/main/docs/skillmap_descriptor.md", ) parser.add_argument( "--version", diff --git a/skillmap/nodes/group_node.py b/skillmap/nodes/group_node.py index 22c3d1d..551c860 100644 --- a/skillmap/nodes/group_node.py +++ b/skillmap/nodes/group_node.py @@ -1,4 +1,9 @@ -from skillmap.nodes.common import get_icon, get_node_content, get_required_node_edges, SECTION_SEPARATOR +from skillmap.nodes.common import ( + get_icon, + get_node_content, + get_required_node_edges, + SECTION_SEPARATOR, +) from skillmap.nodes.skill_node import create_skill_node @@ -20,10 +25,12 @@ def create_groups_edges(map_id, groups): return groups_edges -def get_group_skills_list(qualified_group_id, group_skills): +def get_group_skills_list(qualified_group_id, group_skills, progress_bar_style=0): group_skills = [ create_skill_node( - _qualified_skill_id(qualified_group_id, skill_id), skill_value + _qualified_skill_id(qualified_group_id, skill_id), + skill_value, + progress_bar_style, ) for skill_id, skill_value in group_skills.items() ] @@ -38,13 +45,13 @@ def get_group_status(group_skills): return "new" -def create_group_subgraph(group_id, group_value): +def create_group_subgraph(group_id, group_value, progress_bar_style=0): qualified_group_id = _qualify(group_id) group_name = group_value.get("name", "") group_icon = get_icon(group_value) group_icon_label = get_node_content([group_icon, group_name], False) group_skills_list = get_group_skills_list( - qualified_group_id, group_value.get("skills", {}) + qualified_group_id, group_value.get("skills", {}), progress_bar_style ) group_status = get_group_status(group_value.get("skills", {})) @@ -52,7 +59,7 @@ def create_group_subgraph(group_id, group_value): qualified_group_id, group_value.get("requires", []) ) - group_id_and_name = f"subgraph {qualified_group_id}[{group_icon_label}]" + group_id_and_name = f"subgraph {qualified_group_id}[\"{group_icon_label}\"]" group_style = f"class {qualified_group_id} {group_status}SkillGroup;" group_subgraph_end = "end" sections = [ @@ -68,9 +75,9 @@ def create_group_subgraph(group_id, group_value): return group_graph -def create_group_subgraphs(groups): +def create_group_subgraphs(groups, progress_bar_style=0): group_graphs = [ - create_group_subgraph(group_id, group_value) + create_group_subgraph(group_id, group_value, progress_bar_style) for group_id, group_value in groups.items() ] return "\n\n".join(group_graphs) diff --git a/skillmap/nodes/progress_bar.py b/skillmap/nodes/progress_bar.py new file mode 100644 index 0000000..581a16d --- /dev/null +++ b/skillmap/nodes/progress_bar.py @@ -0,0 +1,30 @@ +PROGRESS_BAR_STYLES = [ + 'โ–กโ– ', + 'โ–โ–ˆ', + 'โฃ€โฃฟ', + 'โ–‘โ–ˆ', + 'โ–’โ–ˆ', + 'โ–กโ–ฉ', + 'โ–กโ–ฆ', + 'โ–ฑโ–ฐ', + 'โ–ญโ—ผ', + 'โ–ฏโ–ฎ', + 'โ—ฏโฌค', + 'โšโš‘', + 'โฌœโฌ›', + 'โฌœ๐ŸŸฉ', + 'โฌœ๐ŸŸฆ', + 'โฌœ๐ŸŸง', + '๐Ÿค๐Ÿ’š', + '๐Ÿค๐Ÿ’™', + '๐Ÿค๐Ÿงก', + 'โšชโšซ', + 'โšช๐ŸŸข', + 'โšช๐Ÿ”ต', + 'โšช๐ŸŸ ', + '๐ŸŒ‘๐ŸŒ•', + 'โ•โ—', + '๐Ÿฅš๐Ÿฃ', + '๐Ÿ’ฃ๐Ÿ’ฅ', + 'โŒโœ…', +] \ No newline at end of file diff --git a/skillmap/nodes/skill_node.py b/skillmap/nodes/skill_node.py index 81509a9..c6e4a4d 100644 --- a/skillmap/nodes/skill_node.py +++ b/skillmap/nodes/skill_node.py @@ -1,44 +1,56 @@ from skillmap.nodes.common import get_icon, get_node_content, get_required_node_edges from fractions import Fraction from enum import Enum +from skillmap.nodes.progress_bar import PROGRESS_BAR_STYLES -class Status(Enum): + +class SkillStatus(Enum): NEW = "new" BEING_LEANRED = "beingLearned" LEARNED = "learned" UNKNOWN = "unknown" - + + def _is_locked_skill_value(skill_value): icon = skill_value.get("icon", None) status = skill_value.get("status", None) return icon == "lock" and status == "unknown" -def get_progress(skill_value): + +def get_progress(skill_value, progress_bar_style=0): if _is_locked_skill_value(skill_value): - return ("", Status.UNKNOWN) + return ("", SkillStatus.UNKNOWN) progress_string = skill_value.get("progress", None) if progress_string: Fraction(progress_string) current, total = map(int, progress_string.split("/")) - status = Status.NEW + status = SkillStatus.NEW if current > 0: - status = Status.BEING_LEANRED if current < total else Status.LEARNED + status = SkillStatus.BEING_LEANRED if current < total else SkillStatus.LEARNED - return (f"{'โ– ' * current}{'โ–ก' * (total - current)}", status) + chosen_progress_bar_style = PROGRESS_BAR_STYLES[ + progress_bar_style % len(PROGRESS_BAR_STYLES) + ] + empty_cell, finished_cell = chosen_progress_bar_style + return (f"{finished_cell * current}{empty_cell * (total - current)}", status) else: - return ("", Status.NEW) + status_value = skill_value.get("status", "new") + + for s in [SkillStatus.NEW, SkillStatus.BEING_LEANRED, SkillStatus.LEARNED]: + if status_value == s.value: + return ("", s) -def create_skill_node(skill_id, skill_value): +def create_skill_node(skill_id, skill_value, progress_bar_style=0): if not skill_value: locked_skill_value = {"name": "???", "icon": "lock", "status": "unknown"} skill_value = locked_skill_value skill_name = skill_value.get("name", "") skill_icon = get_icon(skill_value) - skill_progress, skill_status = get_progress(skill_value) + skill_progress, skill_status = get_progress(skill_value, progress_bar_style) skill_icon_label = get_node_content([skill_icon, skill_name, skill_progress]) - skill_id_and_name = f"{skill_id}(\"{skill_icon_label}\")" + skill_id_and_name = f'{skill_id}("{skill_icon_label}")' skill_style = f"class {skill_id} {skill_status.value}Skill;" skill_requires = get_required_node_edges(skill_id, skill_value.get("requires", [])) sections = [ @@ -47,4 +59,4 @@ def create_skill_node(skill_id, skill_value): skill_requires, ] skill_graph = "\n".join(sections) - return skill_graph \ No newline at end of file + return skill_graph diff --git a/skillmap/nodes/skillmap_node.py b/skillmap/nodes/skillmap_node.py index b7f543d..3003f70 100644 --- a/skillmap/nodes/skillmap_node.py +++ b/skillmap/nodes/skillmap_node.py @@ -15,20 +15,32 @@ def get_orientation(skill_map_dict): return orientation +def get_progress_bar_style(skill_map_dict): + progress_bar_style = 0 + try: + progress_bar_style = int(skill_map_dict.get("progress_bar_style", 0)) + except: + pass + return progress_bar_style + + # generate a mermaid graph from a skill map toml dict def create_skillmap_graph(skill_map): skill_map_dict = skill_map.get("skillmap", {}) map_name = skill_map_dict.get("name", "unamed_skill_map") map_id = alphanumerize(map_name) theme = skill_map_dict.get("theme", "ocean") + progress_bar_style = get_progress_bar_style(skill_map_dict) orientation = get_orientation(skill_map_dict) map_icon = get_icon(skill_map_dict) map_icon_label = get_node_content([map_icon, map_name]) map_to_group_edges = create_groups_edges(map_id, skill_map.get("groups", {})) - map_group_subgraphs = create_group_subgraphs(skill_map.get("groups", {})) + map_group_subgraphs = create_group_subgraphs( + skill_map.get("groups", {}), progress_bar_style + ) - skill_map_node = f"{map_id}({map_icon_label})" + skill_map_node = f"{map_id}(\"{map_icon_label}\")" skill_map_node_style = f"class {map_id} normalSkillGroup;" skill_map_header = f"flowchart {orientation}" sections = [ diff --git a/tests/nodes/skill_node_test.py b/tests/nodes/skill_node_test.py index bd76b08..754fbc1 100644 --- a/tests/nodes/skill_node_test.py +++ b/tests/nodes/skill_node_test.py @@ -1,19 +1,36 @@ -from skillmap.nodes.skill_node import create_skill_node, get_progress, Status +from skillmap.nodes.skill_node import create_skill_node, get_progress, SkillStatus +from skillmap.nodes.progress_bar import PROGRESS_BAR_STYLES def test_get_0_progress(): progress, status = get_progress({}) assert progress == "" - assert status == Status.NEW + assert status == SkillStatus.NEW def test_get_fraction_progress(): progress, status = get_progress({"progress": "1/3"}) assert progress == "โ– โ–กโ–ก" - assert status == Status.BEING_LEANRED + assert status == SkillStatus.BEING_LEANRED + +def test_get_finished_progress(): + progress, status = get_progress({"progress": "3/3"}) + assert progress == "โ– โ– โ– " + assert status == SkillStatus.LEARNED + +def test_get_different_style_progress_bar(): + progress, status = get_progress({"progress": "1/3"}, 1) + assert progress == "โ–ˆโ–โ–" + assert status == SkillStatus.BEING_LEANRED + +def test_get_very_big_style_progress_bar(): + progress, status = get_progress({"progress": "1/3"}, len(PROGRESS_BAR_STYLES) + 1) + assert progress == "โ–ˆโ–โ–" + assert status == SkillStatus.BEING_LEANRED + def test_get_0_fraction_progress(): progress, status = get_progress({"progress": "0/4"}) assert progress == "โ–กโ–กโ–กโ–ก" - assert status == Status.NEW + assert status == SkillStatus.NEW def test_create_skill_node(): skill_graph = create_skill_node("s1", {"name": "url validator", "icon": "globe"}) diff --git a/tests/nodes/skillmap_node_test.py b/tests/nodes/skillmap_node_test.py index 184850c..266a880 100644 --- a/tests/nodes/skillmap_node_test.py +++ b/tests/nodes/skillmap_node_test.py @@ -88,7 +88,7 @@ def test_visit_group_without_skill(): ) sections = [ SECTION_SEPARATOR, - "subgraph groups.g1[fa:fa-anchor web ui]", + 'subgraph groups.g1["fa:fa-anchor web ui"]', "", # skill list is skipped "end", "class groups.g1 newSkillGroup;", @@ -111,7 +111,7 @@ def test_visit_group(): ) sections = [ SECTION_SEPARATOR, - "subgraph groups.g1[fa:fa-anchor web ui]", + 'subgraph groups.g1["fa:fa-anchor web ui"]', 'groups.g1.skills.s1("fa:fa-globe
url validator")', "class groups.g1.skills.s1 newSkill;", "", @@ -136,7 +136,7 @@ def test_visit_group_with_requires(): ) sections = [ SECTION_SEPARATOR, - "subgraph groups.g1[fa:fa-anchor web ui]", + 'subgraph groups.g1["fa:fa-anchor web ui"]', "", # skill list is skipped "end", "class groups.g1 newSkillGroup;", diff --git a/tests/url_shortener.toml b/tests/url_shortener.toml index 581b64d..f6e75cf 100644 --- a/tests/url_shortener.toml +++ b/tests/url_shortener.toml @@ -3,6 +3,7 @@ name = "url shortener" icon = "hashtag" # theme = "pale" # orientation = "LR" +# progress_bar_style = 16 [groups.webui] name = "web ui"