diff --git a/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/description.md b/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/description.md new file mode 100644 index 0000000000000000000000000000000000000000..2609933cb9879e2457c0021f2c4387fb686e183f --- /dev/null +++ b/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/description.md @@ -0,0 +1,27 @@ +Find the page named "Computer Science Student Dashboard" and extend the **Code Snippets** section with Go content. + +**Task Requirements:** +1. Add a bold paragraph that contains exactly the text `Go` to mark the start of the Go snippets. +2. Directly under that heading, add three code blocks configured with `language` set to **go**: + a. **Basic Go program** – Caption must be `Basic Go program` and the code content must be exactly: + ```go + package main + + import "fmt" + + func main() { + fmt.Println("Hello, World!") + } + ``` + b. **For loop in Go** – Caption must be `For loop in Go` and the code content must be exactly: + ```go + for i := 0; i < 5; i++ { + fmt.Println(i) + } + ``` + c. **Function definition in Go** – Caption must be `Function definition in Go` and the code content must be exactly: + ```go + func add(a, b int) int { + return a + b + } + ``` diff --git a/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/meta.json b/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..f0c972591e20f26fffe62e3de3f907b3c7454503 --- /dev/null +++ b/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "simple__code_snippets_go", + "task_name": "Simple Code Snippets Go", + "category_id": "computer_science_student_dashboard", + "category_name": "Computer Science Student Dashboard", + "description": "Add a new Go column to the Code Snippets section between Python and JavaScript columns.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "content organization", + "visual formatting", + "template population" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Computer-Science-Student-Dashboard-23e81626b6d78083b787d3c832b02ef4", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/computer-science-student-dashboard" + } +} diff --git a/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/verify.py b/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..2601c90fc06c0761d751613a76e49004f98a0a74 --- /dev/null +++ b/tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/verify.py @@ -0,0 +1,125 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +# Expected code blocks (language=go) +EXPECTED_CODE_BLOCKS = [ + { + "caption": "Basic Go program", + "code": ( + 'package main\n\nimport "fmt"\n\nfunc main() {\n fmt.Println("Hello, World!")\n}' + ), + }, + { + "caption": "For loop in Go", + "code": ("for i := 0; i < 5; i++ {\n fmt.Println(i)\n}"), + }, + { + "caption": "Function definition in Go", + "code": ("func add(a, b int) int {\n return a + b\n}"), + }, +] + +HEADER_TEXT = "Go" + + +def _normalize(text: str) -> str: + """Remove trailing spaces on each line and strip leading/trailing blank lines.""" + return "\n".join(line.rstrip() for line in text.strip().splitlines()) + + +def _find_page(notion: Client, main_id: str | None) -> str | None: + """Return a page_id to verify against or None if not found.""" + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + if not page_id: + page_id = notion_utils.find_page(notion, "Computer Science Student Dashboard") + return page_id + + +def _has_bold_header_text(block, text: str) -> bool: + """Generic bold header/paragraph check for a given text.""" + block_type = block.get("type") + if block_type not in {"paragraph", "heading_1", "heading_2", "heading_3"}: + return False + rich_text_list = block.get(block_type, {}).get("rich_text", []) + if not rich_text_list: + return False + plain = "".join(rt.get("plain_text", "") for rt in rich_text_list).strip() + if plain != text: + return False + return any(rt.get("annotations", {}).get("bold", False) for rt in rich_text_list) + + +def _collect_code_blocks(blocks): + """Return list of (code_content, caption) tuples for code blocks with language 'go'.""" + collected = [] + for block in blocks: + if block.get("type") != "code": + continue + code_data = block.get("code", {}) + if code_data.get("language") != "go": + continue + code_plain = "".join( + rt.get("plain_text", "") for rt in code_data.get("rich_text", []) + ) + caption_plain = "".join( + rt.get("plain_text", "") for rt in code_data.get("caption", []) + ) + collected.append((code_plain, caption_plain)) + return collected + + +def verify(notion: Client, main_id: str | None = None) -> bool: + page_id = _find_page(notion, main_id) + if not page_id: + print("Error: Target page not found.", file=sys.stderr) + return False + + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + # Verify header + header_ok = any(_has_bold_header_text(b, HEADER_TEXT) for b in all_blocks) + if not header_ok: + print("Failure: Bold header 'Go' not found.", file=sys.stderr) + return False + + # Verify code blocks + code_blocks_found = _collect_code_blocks(all_blocks) + + remaining = EXPECTED_CODE_BLOCKS.copy() + for code, caption in code_blocks_found: + norm_code = _normalize(code) + for expected in remaining: + if ( + _normalize(expected["code"]) == norm_code + and expected["caption"] == caption + ): + remaining.remove(expected) + break + if remaining: + missing = ", ".join(exp["caption"] for exp in remaining) + print( + f"Failure: Missing or incorrect Go code blocks: {missing}", file=sys.stderr + ) + return False + + print( + "Success: Verified Go header and required Go code blocks." + ) + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + sys.exit(0 if verify(notion, main_id) else 1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/description.md b/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/description.md new file mode 100644 index 0000000000000000000000000000000000000000..42db4b82047546b94be8635010e92479aee2a1cf --- /dev/null +++ b/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/description.md @@ -0,0 +1,4 @@ +Create a new study-session entry on the **Computer Science Student Dashboard** page. + +1. Locate the ☑️ Habit tracker section of the page. +2. **Insert a new date mention** for `2025-01-29` immediately **after the existing `2022-09-02` items but before the divider block** that follows them. Match the formatting of the existing dates (bold text with a Notion date mention). diff --git a/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/meta.json b/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..ec338b4cf6e133b717d6e13ceac32ba47a4a90e4 --- /dev/null +++ b/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "simple__study_session_tracker", + "task_name": "Simple Study Session Tracker", + "category_id": "computer_science_student_dashboard", + "category_name": "Computer Science Student Dashboard", + "description": "Create a new study-session entry in the Habit tracker section with four unchecked to-do items.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "content organization", + "visual formatting", + "status tracking" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Computer-Science-Student-Dashboard-23e81626b6d78083b787d3c832b02ef4", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/computer-science-student-dashboard" + } +} diff --git a/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/verify.py b/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..727cedbc81cb9e90db313a410e2ad2c07a71491c --- /dev/null +++ b/tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/verify.py @@ -0,0 +1,132 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str | None = None) -> bool: + """Verify that the new study-session entry for 2025-01-29 was added correctly. + + The script checks that: + 1. A bold date-mention with start=2025-01-29 exists. + 2. The mention sits after the 2022-09-02 section but before the divider that originally + followed that section. + """ + + # --------------------------------------------------------------------- + # Locate the main page ------------------------------------------------- + # --------------------------------------------------------------------- + page_id: str | None = None + + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Computer Science Student Dashboard") + + if not page_id: + print( + "Error: Page 'Computer Science Student Dashboard' not found.", + file=sys.stderr, + ) + return False + + # --------------------------------------------------------------------- + # Fetch all blocks under the page (flattened order) -------------------- + # --------------------------------------------------------------------- + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + # --------------------------------------------------------------------- + # Locate reference blocks --------------------------------------------- + # --------------------------------------------------------------------- + TARGET_DATE = "2025-01-29" + PREVIOUS_DATE = "2022-09-02" + + index_previous_date: int | None = None + index_new_date: int | None = None + index_divider_after_previous: int | None = None + + for idx, block in enumerate(all_blocks): + # Divider detection (we care only about the first divider that appears after + # the 2022-09-02 block) + if block.get("type") == "divider": + if index_previous_date is not None and index_divider_after_previous is None: + index_divider_after_previous = idx + + # We only need to inspect paragraph blocks that contain a date mention + if block.get("type") != "paragraph": + continue + + rich_text_list = block["paragraph"].get("rich_text", []) + for rt in rich_text_list: + if ( + rt.get("type") != "mention" + or rt.get("mention", {}).get("type") != "date" + ): + continue + + date_start = rt["mention"]["date"].get("start") + + if date_start == PREVIOUS_DATE and index_previous_date is None: + index_previous_date = idx + + if date_start == TARGET_DATE and index_new_date is None: + index_new_date = idx + # (1) Verify bold annotation + if not rt.get("annotations", {}).get("bold", False): + print( + "Error: The 2025-01-29 date mention is not bold.", + file=sys.stderr, + ) + return False + + # Ensure all reference indices were found + if index_previous_date is None: + print("Error: Could not locate the 2022-09-02 date section.", file=sys.stderr) + return False + if index_divider_after_previous is None: + print( + "Error: Could not locate the divider that follows the 2022-09-02 section.", + file=sys.stderr, + ) + return False + if index_new_date is None: + print( + "Error: Could not locate the new 2025-01-29 date mention.", file=sys.stderr + ) + return False + + # (2) Verify ordering + if not (index_previous_date < index_new_date < index_divider_after_previous): + print( + "Error: The 2025-01-29 section is positioned incorrectly.", file=sys.stderr + ) + return False + + # --------------------------------------------------------------------- + # Success -------------------------------------------------------------- + # --------------------------------------------------------------------- + print("Success: Date mention for 2025-01-29 added in the correct position.") + return True + + +# ------------------------------------------------------------------------- +# Command-line entry-point ------------------------------------------------- +# ------------------------------------------------------------------------- + + +def main() -> None: + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/description.md b/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/description.md new file mode 100644 index 0000000000000000000000000000000000000000..431a2af94297f323f6f245d3c5acc573ab1bdd17 --- /dev/null +++ b/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/description.md @@ -0,0 +1,11 @@ +Please migrate expiring assets out of the **IT Inventory** database using the simplified checklist below. Your changes will be verified automatically, so match the details exactly. + +--- +Task Steps +1. Inside the **IT Trouble Shooting Hub** page, locate the database named **IT Inventory**. +2. Collect every page in **IT Inventory** whose **Status** is **Expired** or **To be returned**. +3. Create a **new full-page database** under the same hub titled **IT Asset Retirement Queue** with exactly these properties (names and types must match): + • Serial – title + • Status – select + • Expiration date – date +4. For every item gathered in step 2, create a page in **IT Asset Retirement Queue**, copy over the Serial, Status, and Expiration date values, then archive the original inventory page once the copy is made. diff --git a/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/meta.json b/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..b88aaea5936133dcad6a0c7fe68d98273f2c5a59 --- /dev/null +++ b/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/meta.json @@ -0,0 +1,26 @@ +{ + "task_id": "simple__asset_retirement_migration", + "task_name": "Simple Asset Retirement Migration", + "category_id": "it_trouble_shooting_hub", + "category_name": "IT Trouble Shooting Hub", + "description": "Restructure the IT Inventory database by migrating expired assets to a new IT Asset Retirement Queue database.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "database manipulation", + "automated migration", + "conditional filtering", + "data aggregation", + "report generation" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub" + } +} diff --git a/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/verify.py b/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..2f41977f0e333b7bacf8ea40a222782e6e45f6bd --- /dev/null +++ b/tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/verify.py @@ -0,0 +1,143 @@ +import sys +from typing import Dict +from notion_client import Client +from tasks.utils import notion_utils + + +def _get_database(root_page_id: str, notion: Client, name: str) -> str | None: + """Helper that finds a child database by title inside a page.""" + return notion_utils.find_database_in_block(notion, root_page_id, name) + + +def _check_property(props: Dict, name: str, expected_type: str) -> bool: + if name not in props: + print(f"Error: Property '{name}' missing in database.", file=sys.stderr) + return False + if props[name]["type"] != expected_type: + print( + f"Error: Property '{name}' expected type '{expected_type}', found '{props[name]['type']}'.", + file=sys.stderr, + ) + return False + return True + + +def verify(notion: Client, main_id: str | None = None) -> bool: + """Verifies that the IT Asset Retirement Queue was created and populated correctly.""" + + # ------------------------------------------------------------------------- + # Resolve the root IT Trouble Shooting Hub page + # ------------------------------------------------------------------------- + root_page_id = None + if main_id: + found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if found_id and obj_type == "page": + root_page_id = found_id + + if not root_page_id: + root_page_id = notion_utils.find_page(notion, "IT Trouble Shooting Hub") + if not root_page_id: + print( + "Error: Could not locate the 'IT Trouble Shooting Hub' page.", + file=sys.stderr, + ) + return False + + # ------------------------------------------------------------------------- + # Locate the original and new databases + # ------------------------------------------------------------------------- + inventory_db_id = _get_database(root_page_id, notion, "IT Inventory") + if not inventory_db_id: + print("Error: 'IT Inventory' database not found.", file=sys.stderr) + return False + + retirement_db_id = _get_database(root_page_id, notion, "IT Asset Retirement Queue") + if not retirement_db_id: + print("Error: 'IT Asset Retirement Queue' database not found.", file=sys.stderr) + return False + + # ------------------------------------------------------------------------- + # Validate schema of the retirement queue database + # ------------------------------------------------------------------------- + retirement_db = notion.databases.retrieve(database_id=retirement_db_id) + r_props = retirement_db["properties"] + + required_schema = { + "Serial": "title", + "Status": "select", + "Expiration date": "date", + } + + for pname, ptype in required_schema.items(): + if not _check_property(r_props, pname, ptype): + return False + + # ------------------------------------------------------------------------- + # Validate that inventory items are moved & archived + # ------------------------------------------------------------------------- + expired_filter = { + "property": "Status", + "select": {"equals": "Expired"}, + } + to_return_filter = { + "property": "Status", + "select": {"equals": "To be returned"}, + } + compound_filter = {"or": [expired_filter, to_return_filter]} + + # Query for any *active* items that still match these statuses + remaining_items = notion.databases.query( + database_id=inventory_db_id, + filter=compound_filter, + archived=False, + ).get("results", []) + + if remaining_items: + print( + f"Error: {len(remaining_items)} 'Expired' / 'To be returned' items still present in IT Inventory.", + file=sys.stderr, + ) + return False + + # There should be at least one entry in the retirement queue + retirement_pages = notion.databases.query(database_id=retirement_db_id).get( + "results", [] + ) + expected_serials = {"65XYQ/GB", "36x10PIQ"} + if len(retirement_pages) != len(expected_serials): + print( + f"Error: Expected {len(expected_serials)} retirement pages, found {len(retirement_pages)}.", + file=sys.stderr, + ) + return False + + serials_seen = set() + for page in retirement_pages: + props = page["properties"] + # Collect Serial title + title_rich = props.get("Serial", {}).get("title", []) + serial_val = "".join([t.get("plain_text", "") for t in title_rich]).strip() + serials_seen.add(serial_val) + + if serials_seen != expected_serials: + print( + f"Error: Serial values mismatch. Expected {sorted(expected_serials)}, found {sorted(serials_seen)}.", + file=sys.stderr, + ) + return False + + print("Success: All verification criteria satisfied.") + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/description.md b/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/description.md new file mode 100644 index 0000000000000000000000000000000000000000..02510bb918550b12c5c8a20e63988322715ebab4 --- /dev/null +++ b/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/description.md @@ -0,0 +1 @@ +Go to Japan Travel Planner, and go to the Travel Itineray database, and remove the itinerary in OSAKA after 6 PM (excluding 6 PM) in Day 1 and Day 2. \ No newline at end of file diff --git a/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/meta.json b/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..b4e0fddf8d2d922aebad5da674fa21a70baffb16 --- /dev/null +++ b/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/meta.json @@ -0,0 +1,23 @@ +{ + "task_id": "simple__remove_osaka_itinerary", + "task_name": "Simple Remove Osaka Itinerary", + "category_id": "japan_travel_planner", + "category_name": "Japan Travel Planner", + "description": "Remove the itinerary items in Osaka after 6 PM from Day 1 and Day 2 travel schedules.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "conditional filtering", + "automated migration" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Japan-Travel-Planner-23181626b6d781c4b6bedb12786b5abe", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/japantravelplanner101" + } +} diff --git a/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/verify.py b/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..8316e347a0ab02d22ce0bf913ab09dcf60873085 --- /dev/null +++ b/tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/verify.py @@ -0,0 +1,288 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +def get_page_title(page_result): + """Extract title from a page result""" + properties = page_result.get('properties', {}) + name_property = properties.get('Name', {}) + if name_property.get('type') == 'title': + title_array = name_property.get('title', []) + if title_array and len(title_array) > 0: + return title_array[0].get('plain_text', '') + return '' + +def get_page_time(page_result): + """Extract time from Notes field""" + properties = page_result.get('properties', {}) + notes_property = properties.get('Notes', {}) + if notes_property.get('type') == 'rich_text': + rich_text_array = notes_property.get('rich_text', []) + if rich_text_array and len(rich_text_array) > 0: + notes_text = rich_text_array[0].get('plain_text', '') + return notes_text.strip() + return '' + +def get_page_group(page_result): + """Extract group/location from page""" + properties = page_result.get('properties', {}) + group_property = properties.get('Group', {}) + if group_property.get('type') == 'select': + select = group_property.get('select') + if select: + return select.get('name', '') + return '' + +def get_page_day(page_result): + """Extract day from page""" + properties = page_result.get('properties', {}) + day_property = properties.get('Day', {}) + if day_property.get('type') == 'select': + select = day_property.get('select') + if select: + return select.get('name', '') + return '' + +def parse_time_to_minutes(time_str): + """Convert time string to minutes for comparison + Returns None if time cannot be parsed""" + if not time_str: + return None + + # Clean the time string + time_str = time_str.strip().upper() + + # Remove any text after the time (e.g., "7:30 PM\n" -> "7:30 PM") + time_str = time_str.split('\n')[0].strip() + + # Extract time components + try: + if 'PM' in time_str: + time_part = time_str.replace('PM', '').strip() + if ':' in time_part: + hours, minutes = time_part.split(':') + hours = int(hours) + minutes = int(minutes) + else: + hours = int(time_part) + minutes = 0 + # Convert PM hours (add 12 for PM times except 12 PM) + if hours != 12: + hours += 12 + return hours * 60 + minutes + elif 'AM' in time_str: + time_part = time_str.replace('AM', '').strip() + if ':' in time_part: + hours, minutes = time_part.split(':') + hours = int(hours) + minutes = int(minutes) + else: + hours = int(time_part) + minutes = 0 + # Handle 12 AM (midnight) + if hours == 12: + hours = 0 + return hours * 60 + minutes + except: + return None + + return None + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that all OSAKA events after 6PM have been removed from Day 1 and Day 2 in the Japan Travel Planner. + + Expected items that should be deleted (all in OSAKA, after 6PM, on Day 1 or Day 2): + 1. Rikuro's Namba Main Branch - 7 PM (Day 1) + 2. Shin Sekai "New World" - 8 PM (Day 2) + 3. Katsudon Chiyomatsu - 7:30 PM (Day 2) + 4. Ebisubashi Bridge - 9 PM (Day 1) + + Note: Kuromon Ichiba Market at 6 PM should NOT be deleted (it's at 6PM, not after) + Items after 6PM on other days (Day 3-8) should NOT be deleted + """ + + # Step 1: Find the main Japan Travel Planner page + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not found_id or object_type != 'page': + print("Error: Japan Travel Planner page not found.", file=sys.stderr) + return False + else: + # Try to find the page by searching + found_id = notion_utils.find_page(notion, "Japan Travel Planner") + if not found_id: + print("Error: Japan Travel Planner page not found.", file=sys.stderr) + return False + + print(f"Found Japan Travel Planner page: {found_id}") + + # Step 2: Find the Travel Itinerary database + all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id) + travel_itinerary_db_id = None + + for block in all_blocks: + if block and block.get("type") == "child_database": + title = block.get("child_database", {}).get("title", "") + if "Travel Itinerary" in title: + travel_itinerary_db_id = block.get("id") + print(f"Found Travel Itinerary database: {travel_itinerary_db_id}") + break + + if not travel_itinerary_db_id: + print("Error: Travel Itinerary database not found", file=sys.stderr) + return False + + # Step 3: Query the database for OSAKA items on Day 1 and Day 2 + try: + query_result = notion.databases.query( + database_id=travel_itinerary_db_id, + filter={ + "and": [ + {"property": "Group", "select": {"equals": "Osaka"}}, + {"or": [ + {"property": "Day", "select": {"equals": "Day 1"}}, + {"property": "Day", "select": {"equals": "Day 2"}} + ]} + ] + } + ) + except Exception as e: + print(f"Error querying Travel Itinerary database: {e}", file=sys.stderr) + return False + + # Step 4: Check for items that should have been deleted + six_pm_minutes = 18 * 60 # 6 PM in minutes (18:00) + + # Expected deleted items (4 specific items after 6 PM on Day 1 and Day 2) + expected_deleted = { + "Rikuro's Namba Main Branch": {"time": "7 PM", "day": "Day 1", "found": False}, + "Shin Sekai \"New World\"": {"time": "8 PM", "day": "Day 2", "found": False}, + "Katsudon Chiyomatsu": {"time": "7:30 PM", "day": "Day 2", "found": False}, + "Ebisubashi Bridge": {"time": "9 PM", "day": "Day 1", "found": False} + } + + # Items that should remain (at or before 6 PM) + expected_remaining = { + "Kuromon Ichiba Market": {"time": "6 PM", "found": False} + } + + osaka_items_after_6pm = [] + osaka_items_at_or_before_6pm = [] + + # Debug: Show total query results + print(f"Debug: Found {len(query_result.get('results', []))} total OSAKA items on Day 1 and Day 2") + + # Process all OSAKA items on Day 1 and Day 2 + for page in query_result.get('results', []): + page_title = get_page_title(page).strip() + page_time = get_page_time(page) + page_group = get_page_group(page) + page_day = get_page_day(page) + + if page_group != "Osaka": + continue + + # Parse time to check if after 6 PM + time_minutes = parse_time_to_minutes(page_time) + + if time_minutes is not None and time_minutes > six_pm_minutes: + osaka_items_after_6pm.append({ + "title": page_title, + "time": page_time, + "day": page_day, + "id": page.get('id') + }) + + # Check if this is one of the expected deleted items + for expected_title, expected_info in expected_deleted.items(): + # Clean up the titles for comparison + clean_page_title = page_title.strip().lower() + clean_expected_title = expected_title.strip().lower() + + # Check for "Rikuro's" or "Rikuro's" (different apostrophe types) + if "rikuro" in clean_page_title and "rikuro" in clean_expected_title: + title_match = True + elif clean_page_title == clean_expected_title: + title_match = True + elif clean_expected_title in clean_page_title or clean_page_title in clean_expected_title: + title_match = True + else: + title_match = False + + if title_match and page_day == expected_info["day"]: + print(f"Debug: Found '{page_title}' on {page_day} at {page_time} - matches expected '{expected_title}'") + expected_deleted[expected_title]["found"] = True + + elif time_minutes is not None and time_minutes <= six_pm_minutes: + osaka_items_at_or_before_6pm.append({ + "title": page_title, + "time": page_time, + "day": page_day, + "id": page.get('id') + }) + + # Check if this is one of the expected remaining items + for expected_title in expected_remaining: + if expected_title.lower() in page_title.lower() or page_title.lower() in expected_title.lower(): + expected_remaining[expected_title]["found"] = True + + # Step 5: Verify results + print(f"\nVerification Summary:") + print(f"=" * 50) + + all_passed = True + + # Check that the 4 expected items after 6 PM have been deleted + print("\n4 Items that should be deleted (after 6 PM on Day 1 and Day 2):") + for item_name, item_info in expected_deleted.items(): + if item_info["found"]: + # If found = True, it means the item still exists (was not deleted) + print(f"✗ {item_name} ({item_info['day']}, {item_info['time']}) - Still exists, should be deleted", file=sys.stderr) + all_passed = False + else: + # If found = False, it means the item was deleted correctly + print(f"✓ {item_name} ({item_info['day']}, {item_info['time']}) - Correctly deleted") + + + # Check that items at or before 6 PM remain + print("\nItems that should remain (at or before 6 PM on Day 1 and Day 2):") + for item_name, item_info in expected_remaining.items(): + if item_info["found"]: + print(f"✓ {item_name} ({item_info['time']}) - Correctly retained") + else: + print(f"✗ {item_name} ({item_info['time']}) - Missing, should not be deleted", file=sys.stderr) + all_passed = False + + # Report any items after 6 PM that still exist + if osaka_items_after_6pm: + print(f"\n✗ Found {len(osaka_items_after_6pm)} OSAKA item(s) after 6 PM on Day 1/Day 2:", file=sys.stderr) + for item in osaka_items_after_6pm: + print(f" - {item['title']} at {item['time']} ({item['day']})", file=sys.stderr) + else: + print(f"\n✓ No OSAKA items found after 6 PM on Day 1/Day 2 (all correctly deleted)") + + # Report count summary + print(f"\nCount Summary:") + print(f"- OSAKA items after 6 PM on Day 1/Day 2 found: {len(osaka_items_after_6pm)} (should be 0)") + print(f"- OSAKA items at/before 6 PM on Day 1/Day 2 found: {len(osaka_items_at_or_before_6pm)}") + print(f"- Expected deletions verified: {sum(1 for item in expected_deleted.values() if not item['found'])}/4") + + return all_passed + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + + if verify(notion, main_id): + print("\nVerification passed: All 4 required OSAKA events after 6 PM on Day 1 and Day 2 have been removed") + sys.exit(0) + else: + print("\nVerification failed: Some OSAKA events after 6 PM on Day 1/Day 2 still exist") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/easy/online_resume/simple__skills_development_tracker/description.md b/tasks/notion/easy/online_resume/simple__skills_development_tracker/description.md new file mode 100644 index 0000000000000000000000000000000000000000..4a78d912c156770a7960e82869c4c4c504121573 --- /dev/null +++ b/tasks/notion/easy/online_resume/simple__skills_development_tracker/description.md @@ -0,0 +1,19 @@ +Create a comprehensive skills audit system by performing the following tasks: + +**Task Requirements:** +1. Create a new database named "Skills Development Tracker" as a child database in the main resume page with the following properties: + - Name (title property) + - Current Skill (relation to Skills database) + - Current Proficiency (rollup from related skill's "Skill Level" property) + - Target Proficiency (number property with format "percent") + - Gap (formula: Target Proficiency - Current Proficiency) + - Learning Resources (rich text property) + - Progress Notes (rich text property) + +2. Populate the Skills Development Tracker database with entries for all skills that have a proficiency level below 70% (0.7): + - For each qualifying skill, create an entry with: + - Name: "[Skill Name] Development Plan" + - Link to the corresponding skill in Skills database + - Target Proficiency: Set to Current + 25% (capped at 95%) + - Learning Resources: "Online courses and practice projects" + - Progress Notes: "Initial assessment completed" diff --git a/tasks/notion/easy/online_resume/simple__skills_development_tracker/meta.json b/tasks/notion/easy/online_resume/simple__skills_development_tracker/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..66b6bde446a2f77037fb9db8169ee3151c629d51 --- /dev/null +++ b/tasks/notion/easy/online_resume/simple__skills_development_tracker/meta.json @@ -0,0 +1,27 @@ +{ + "task_id": "simple__skills_development_tracker", + "task_name": "Simple Skills Development Tracker", + "category_id": "online_resume", + "category_name": "Online Resume", + "description": "Create a comprehensive skills audit system with development tracking for skills below 70% proficiency.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "database manipulation", + "cross-reference linking", + "conditional filtering", + "data aggregation", + "template population", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Online-Resume-23181626b6d781159faaeb5eadaf612e", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/online-resume" + } +} diff --git a/tasks/notion/easy/online_resume/simple__skills_development_tracker/verify.py b/tasks/notion/easy/online_resume/simple__skills_development_tracker/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..9ab912057d4d91b9c256920c4107b930e51d69c9 --- /dev/null +++ b/tasks/notion/easy/online_resume/simple__skills_development_tracker/verify.py @@ -0,0 +1,206 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the Skills Development Tracker database was created correctly. + """ + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "New Online Resume") + if not page_id: + print("Error: Page 'New Online Resume' not found.", file=sys.stderr) + return False + + # Step 1: Verify Skills Development Tracker database exists + tracker_db_id = notion_utils.find_database_in_block( + notion, page_id, "Skills Development Tracker" + ) + if not tracker_db_id: + print( + "Error: Database 'Skills Development Tracker' not found.", file=sys.stderr + ) + return False + + # Step 2: Verify database schema + try: + db_info = notion.databases.retrieve(database_id=tracker_db_id) + properties = db_info.get("properties", {}) + + # Check required properties + required_props = { + "Name": "title", + "Current Skill": "relation", + "Current Proficiency": "rollup", + "Target Proficiency": "number", + "Gap": "formula", + "Learning Resources": "rich_text", + "Progress Notes": "rich_text", + } + + for prop_name, expected_type in required_props.items(): + if prop_name not in properties: + print( + f"Error: Property '{prop_name}' not found in database.", + file=sys.stderr, + ) + return False + if properties[prop_name]["type"] != expected_type: + print( + f"Error: Property '{prop_name}' has incorrect type. Expected '{expected_type}', got '{properties[prop_name]['type']}'.", + file=sys.stderr, + ) + return False + + # Verify Target Proficiency is percent format + if ( + properties["Target Proficiency"].get("number", {}).get("format") + != "percent" + ): + print( + "Error: Target Proficiency should have 'percent' format.", + file=sys.stderr, + ) + return False + + except Exception as e: + print(f"Error retrieving database info: {e}", file=sys.stderr) + return False + + # Step 3: Get Skills database to check entries + skills_db_id = notion_utils.find_database_in_block(notion, page_id, "Skills") + if not skills_db_id: + print("Error: Skills database not found.", file=sys.stderr) + return False + + # Get all skills with proficiency < 70% + skills_below_70 = [] + try: + skills_results = notion.databases.query(database_id=skills_db_id).get( + "results", [] + ) + for skill in skills_results: + skill_level = ( + skill.get("properties", {}).get("Skill Level", {}).get("number", 1.0) + ) + if skill_level < 0.7: + skill_name = ( + skill.get("properties", {}).get("Skill", {}).get("title", []) + ) + if skill_name: + skill_name_text = skill_name[0].get("text", {}).get("content", "") + skills_below_70.append( + { + "name": skill_name_text, + "id": skill["id"], + "level": skill_level, + } + ) + except Exception as e: + print(f"Error querying Skills database: {e}", file=sys.stderr) + return False + + if not skills_below_70: + print("Warning: No skills found with proficiency below 70%.", file=sys.stderr) + # This might be OK if all skills are above 70% + + # Step 4: Verify entries in Skills Development Tracker + try: + tracker_results = notion.databases.query(database_id=tracker_db_id).get( + "results", [] + ) + + # Check that we have entries for skills below 70% + if len(skills_below_70) > 0 and len(tracker_results) == 0: + print( + "Error: No entries found in Skills Development Tracker database.", + file=sys.stderr, + ) + return False + + # Verify each entry + for entry in tracker_results: + props = entry.get("properties", {}) + + # Check name format + name_prop = props.get("Name", {}).get("title", []) + if not name_prop: + print("Error: Entry missing Name property.", file=sys.stderr) + return False + name_text = name_prop[0].get("text", {}).get("content", "") + if not name_text.endswith(" Development Plan"): + print( + f"Error: Entry name '{name_text}' doesn't follow expected format.", + file=sys.stderr, + ) + return False + + # Check relation to Skills database + skill_relation = props.get("Current Skill", {}).get("relation", []) + if not skill_relation: + print( + f"Error: Entry '{name_text}' missing Current Skill relation.", + file=sys.stderr, + ) + return False + + # Check Target Proficiency (should be set) + target_prof = props.get("Target Proficiency", {}).get("number") + if target_prof is None: + print( + f"Error: Entry '{name_text}' missing Target Proficiency.", + file=sys.stderr, + ) + return False + + # Check Learning Resources + learning_resources = props.get("Learning Resources", {}).get( + "rich_text", [] + ) + if not learning_resources: + print( + f"Error: Entry '{name_text}' missing Learning Resources.", + file=sys.stderr, + ) + return False + + # Check Progress Notes + progress_notes = props.get("Progress Notes", {}).get("rich_text", []) + if not progress_notes: + print( + f"Error: Entry '{name_text}' missing Progress Notes.", + file=sys.stderr, + ) + return False + + except Exception as e: + print(f"Error querying Skills Development Tracker: {e}", file=sys.stderr) + return False + + print("Success: Skills Development Tracker database verified successfully.") + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/description.md b/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/description.md new file mode 100644 index 0000000000000000000000000000000000000000..afaac23ebb22acfd69a9e26f6e6380d3f37bb0b9 --- /dev/null +++ b/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/description.md @@ -0,0 +1,30 @@ +# Task: Expert Level Learning Path (Simplified) + +## Objective +Extend the Python Roadmap with a new Expert Level chapter, create a bridge lesson, and add two expert lessons that build on existing material. + +## Requirements + +### 1. Add the Expert Level chapter +- **Database**: Chapters +- **Name**: `Expert Level` +- **Icon**: 🟣 (purple circle emoji) +- Make sure it is linked into the roadmap alongside the existing chapters. + +### 2. Create the bridge lesson +Create a lesson that connects advanced material to the new chapter: +- **Title**: `Advanced Foundations Review` +- **Status**: Done +- **Chapter**: Link it to `Expert Level` +- **Parent item**: Link to the lesson whose title contains "Control" (e.g., "Control Flow") +- **Sub-items**: Include links to the lessons containing "Decorators" and "Calling API" + +### 3. Add two expert lessons +Add the following entries to the Steps database: + +| Lesson Title | Status | Chapter | Parent item | Date | +|--------------|--------|---------|-------------|------| +| `Metaprogramming and AST Manipulation` | To Do | Expert Level | Advanced Foundations Review | 2025-09-15 | +| `Async Concurrency Patterns` | To Do | Expert Level | Calling API | 2025-09-20 | + +The lessons must inherit the correct chapter link, parent relationship, and due date as shown above. diff --git a/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/meta.json b/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..18a0aa37f818e115ae808da62e57f6fb73ce5153 --- /dev/null +++ b/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/meta.json @@ -0,0 +1,26 @@ +{ + "task_id": "expert_level_lessons", + "task_name": "Expert Level Lessons", + "category_id": "python_roadmap", + "category_name": "Python Roadmap", + "description": "Create an Expert Level chapter with sophisticated prerequisite chains and four expert-level lessons.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "database manipulation", + "cross-reference linking", + "conditional filtering", + "status tracking", + "template population" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Python-Roadmap-25281626b6d78012bf2bce1fa8711f4d", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/python-roadmap" + } +} diff --git a/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/verify.py b/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..5ee120b4bb0d5c9497dc8418e0a53a6a1bb68961 --- /dev/null +++ b/tasks/notion/easy/python_roadmap/simple__expert_level_lessons/verify.py @@ -0,0 +1,234 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +TARGET_PAGE_TITLE = "Python Roadmap" +CHAPTER_NAME = "Expert Level" +CHAPTER_ICON = "🟣" +BRIDGE_TITLE = "Advanced Foundations Review" +REQUIRED_SUBITEM_TITLES = ["Decorators", "Calling API"] + +LESSON_REQUIREMENTS = [ + { + "title": "Metaprogramming and AST Manipulation", + "status": "To Do", + "date": "2025-09-15", + "parent_title": BRIDGE_TITLE, + }, + { + "title": "Async Concurrency Patterns", + "status": "To Do", + "date": "2025-09-20", + "parent_title": "Calling API", + }, +] + + +def _get_database_ids(notion: Client, page_id: str) -> tuple[str | None, str | None]: + """Return the block IDs for the Chapters and Steps databases on the page.""" + chapters_db_id = None + steps_db_id = None + + blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + for block in blocks: + if block.get("type") != "child_database": + continue + title = block.get("child_database", {}).get("title", "") + if "Chapters" in title and not chapters_db_id: + chapters_db_id = block["id"] + elif "Steps" in title and not steps_db_id: + steps_db_id = block["id"] + + return chapters_db_id, steps_db_id + + +def _query_step_by_title(notion: Client, database_id: str, title: str, *, exact: bool = True): + """Return the first step entry matching the given title pattern.""" + title_filter = {"equals": title} if exact else {"contains": title} + response = notion.databases.query( + database_id=database_id, + filter={"property": "Lessons", "title": title_filter}, + page_size=5, + ) + results = response.get("results", []) + return results[0] if results else None + + +def verify(notion: Client, main_id: str | None = None) -> bool: + """Verify the simplified Expert Level learning path setup.""" + # Resolve the roadmap page. + if main_id: + page_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not page_id or object_type != "page": + print("Error: Python Roadmap page not found.", file=sys.stderr) + return False + else: + page_id = notion_utils.find_page(notion, TARGET_PAGE_TITLE) + if not page_id: + print("Error: Python Roadmap page not found.", file=sys.stderr) + return False + + # Locate the Chapters and Steps databases. + chapters_db_id, steps_db_id = _get_database_ids(notion, page_id) + if not chapters_db_id: + print("Error: Chapters database not found on the page.", file=sys.stderr) + return False + if not steps_db_id: + print("Error: Steps database not found on the page.", file=sys.stderr) + return False + + # Ensure the Expert Level chapter exists with the purple icon. + try: + chapter_resp = notion.databases.query( + database_id=chapters_db_id, + filter={"property": "Name", "title": {"equals": CHAPTER_NAME}}, + page_size=1, + ) + except Exception as exc: + print(f"Error querying Chapters database: {exc}", file=sys.stderr) + return False + + results = chapter_resp.get("results", []) + if not results: + print("Error: Expert Level chapter not found.", file=sys.stderr) + return False + + expert_chapter = results[0] + expert_chapter_id = expert_chapter["id"] + icon = expert_chapter.get("icon") or {} + if icon.get("type") != "emoji" or icon.get("emoji") != CHAPTER_ICON: + print("Error: Expert Level chapter must use the purple circle emoji icon.", file=sys.stderr) + return False + + print("✓ Expert Level chapter exists with the correct icon.") + + # Locate prerequisite lessons (Control Flow, Decorators, Calling API). + control_lesson = _query_step_by_title(notion, steps_db_id, "Control", exact=False) + if not control_lesson: + print("Error: Could not find a lesson containing 'Control' in its title.", file=sys.stderr) + return False + control_lesson_id = control_lesson["id"] + + prerequisite_ids = {} + for title in REQUIRED_SUBITEM_TITLES: + lesson = _query_step_by_title(notion, steps_db_id, title, exact=False) + if not lesson: + print(f"Error: Required lesson containing '{title}' not found.", file=sys.stderr) + return False + prerequisite_ids[title] = lesson["id"] + + # Verify the bridge lesson. + bridge_lesson = _query_step_by_title(notion, steps_db_id, BRIDGE_TITLE, exact=True) + if not bridge_lesson: + print("Error: Advanced Foundations Review lesson not found.", file=sys.stderr) + return False + + status = (bridge_lesson["properties"].get("Status", {}).get("status") or {}).get("name") + if status != "Done": + print("Error: Advanced Foundations Review must have status 'Done'.", file=sys.stderr) + return False + + # Ensure chapter relation includes Expert Level. + chapter_rel = bridge_lesson["properties"].get("Chapters", {}).get("relation", []) + if not any(rel["id"] == expert_chapter_id for rel in chapter_rel): + print("Error: Advanced Foundations Review must link to the Expert Level chapter.", file=sys.stderr) + return False + + # Parent item should be the control lesson. + parent_rel = bridge_lesson["properties"].get("Parent item", {}).get("relation", []) + if not parent_rel or parent_rel[0]["id"] != control_lesson_id: + print("Error: Advanced Foundations Review should use the control lesson as its Parent item.", file=sys.stderr) + return False + + # Sub-items must include the required lessons. + sub_rel = bridge_lesson["properties"].get("Sub-item", {}).get("relation", []) + sub_ids = {rel["id"] for rel in sub_rel} + missing = [title for title, rel_id in prerequisite_ids.items() if rel_id not in sub_ids] + if missing: + print( + f"Error: Advanced Foundations Review must include these lessons as sub-items: {', '.join(missing)}.", + file=sys.stderr, + ) + return False + + print("✓ Bridge lesson configured with the correct status, chapter, parent, and sub-items.") + + # Verify the two expert lessons. + overall_success = True + for spec in LESSON_REQUIREMENTS: + lesson = _query_step_by_title(notion, steps_db_id, spec["title"], exact=True) + if not lesson: + print(f"Error: Lesson '{spec['title']}' not found.", file=sys.stderr) + overall_success = False + continue + + lesson_ok = True + + # Status check. + status_name = (lesson["properties"].get("Status", {}).get("status") or {}).get("name") + if status_name != spec["status"]: + print( + f"Error: Lesson '{spec['title']}' should have status '{spec['status']}', found '{status_name}'.", + file=sys.stderr, + ) + lesson_ok = False + + # Chapter relation check. + lesson_chapters = lesson["properties"].get("Chapters", {}).get("relation", []) + if not any(rel["id"] == expert_chapter_id for rel in lesson_chapters): + print(f"Error: Lesson '{spec['title']}' must link to the Expert Level chapter.", file=sys.stderr) + lesson_ok = False + + # Parent relation check. + parent_title = spec["parent_title"] + if parent_title == BRIDGE_TITLE: + expected_parent_id = bridge_lesson["id"] + else: + expected_parent_id = prerequisite_ids.get(parent_title) + + parent_relation = lesson["properties"].get("Parent item", {}).get("relation", []) + if not expected_parent_id: + print( + f"Error: Could not resolve expected parent '{parent_title}' for lesson '{spec['title']}'.", + file=sys.stderr, + ) + lesson_ok = False + else: + if not parent_relation or parent_relation[0]["id"] != expected_parent_id: + print( + f"Error: Lesson '{spec['title']}' should have '{parent_title}' as its Parent item.", + file=sys.stderr, + ) + lesson_ok = False + # Date check. + date_prop = lesson["properties"].get("Date", {}).get("date") or {} + if date_prop.get("start") != spec["date"]: + print( + f"Error: Lesson '{spec['title']}' should use date {spec['date']}, found {date_prop.get('start')}.", + file=sys.stderr, + ) + lesson_ok = False + + if lesson_ok: + print(f"✓ Lesson '{spec['title']}' has the expected properties.") + else: + overall_success = False + + if not overall_success: + return False + + print("Success: Expert Level chapter, bridge lesson, and expert lessons configured correctly.") + return True + + +def main() -> None: + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/easy/self_assessment/simple__faq_column_layout/description.md b/tasks/notion/easy/self_assessment/simple__faq_column_layout/description.md new file mode 100644 index 0000000000000000000000000000000000000000..3a7daa60125e53d4a184b5a570aaa9380c374548 --- /dev/null +++ b/tasks/notion/easy/self_assessment/simple__faq_column_layout/description.md @@ -0,0 +1,6 @@ +Navigate to the "Self Assessment" page and reorganize the FAQ toggle content to make it easier to scan. + +**Task Requirements:** +1. Add a column list with two columns inside the FAQ toggle. +2. Move the first two existing Q&A pairs from the FAQ into the left column. +3. Move the third existing Q&A pair into the right column, keeping the original heading/paragraph formatting. diff --git a/tasks/notion/easy/self_assessment/simple__faq_column_layout/meta.json b/tasks/notion/easy/self_assessment/simple__faq_column_layout/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..9f356b30092f39d246441f4eba9ff391e8e46f3e --- /dev/null +++ b/tasks/notion/easy/self_assessment/simple__faq_column_layout/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "simple__faq_column_layout", + "task_name": "Simple FAQ Column Layout", + "category_id": "self_assessment", + "category_name": "Self Assessment", + "description": "Reorganize the FAQ section content into a two-column layout with balanced Q&A pairs.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "content organization", + "visual formatting", + "template population" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d", + "stateOriginalUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d" + } +} diff --git a/tasks/notion/easy/self_assessment/simple__faq_column_layout/verify.py b/tasks/notion/easy/self_assessment/simple__faq_column_layout/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..dccddbf0696e148176d4508d0b125d0e1fef1ff2 --- /dev/null +++ b/tasks/notion/easy/self_assessment/simple__faq_column_layout/verify.py @@ -0,0 +1,161 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the FAQ toggle has been properly reorganized with a column list. + """ + # Start from main_id if provided + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + # Try to find the Self Assessment page + page_id = notion_utils.find_page(notion, "Self Assessment") + + if not page_id: + print("Error: Self Assessment page not found.", file=sys.stderr) + return False + + # Get all blocks recursively from the page + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + # Find the FAQ toggle block + faq_toggle_block = None + faq_toggle_id = None + for block in all_blocks: + if block.get("type") == "toggle": + block_text = notion_utils.get_block_plain_text(block) + if "FAQ" in block_text: + faq_toggle_block = block + faq_toggle_id = block.get("id") + print(f"Found FAQ toggle block: {block_text}") + break + + if not faq_toggle_block: + print("Error: FAQ toggle block not found.", file=sys.stderr) + return False + + # Find column_list inside the FAQ toggle + column_list_block = None + for block in all_blocks: + if ( + block.get("type") == "column_list" + and block.get("parent", {}).get("block_id") == faq_toggle_id + ): + column_list_block = block + break + + if not column_list_block: + print("Error: No column_list found inside FAQ toggle.", file=sys.stderr) + return False + + # Check that there are no Q&A pairs directly under FAQ toggle (outside column_list) + direct_faq_children = [] + for block in all_blocks: + if block.get("parent", {}).get("block_id") == faq_toggle_id and block.get( + "id" + ) != column_list_block.get("id"): + direct_faq_children.append(block) + + # Check if any of these are heading_3 or paragraph blocks (Q&A content) + for block in direct_faq_children: + if block.get("type") in ["heading_3", "paragraph"]: + print( + f"Error: Found Q&A content outside column_list: {notion_utils.get_block_plain_text(block)[:50]}...", + file=sys.stderr, + ) + return False + + # Find the two columns + columns = [] + column_list_id = column_list_block.get("id") + for block in all_blocks: + if ( + block.get("type") == "column" + and block.get("parent", {}).get("block_id") == column_list_id + ): + columns.append(block) + + if len(columns) != 2: + print(f"Error: Expected 2 columns, found {len(columns)}.", file=sys.stderr) + return False + + # Count Q&A pairs in each column + qa_counts = [] + total_pairs = 0 + + for i, column in enumerate(columns[:2]): + column_id = column.get("id") + + column_blocks = [ + block + for block in all_blocks + if block.get("parent", {}).get("block_id") == column_id + ] + + qa_pairs = 0 + j = 0 + while j < len(column_blocks): + if ( + column_blocks[j].get("type") == "heading_3" + and j + 1 < len(column_blocks) + and column_blocks[j + 1].get("type") == "paragraph" + ): + qa_pairs += 1 + j += 2 + else: + j += 1 + + qa_counts.append(qa_pairs) + total_pairs += qa_pairs + print(f"Column {i + 1}: Found {qa_pairs} Q&A pairs") + + if qa_counts[0] < 2: + print( + f"Error: Left column should contain at least 2 Q&A pairs, found {qa_counts[0]}.", + file=sys.stderr, + ) + return False + + if qa_counts[1] < 1: + print( + f"Error: Right column should contain at least 1 Q&A pair, found {qa_counts[1]}.", + file=sys.stderr, + ) + return False + + if total_pairs < 3: + print( + f"Error: Expected at least 3 total Q&A pairs across both columns, found {total_pairs}.", + file=sys.stderr, + ) + return False + + print( + "Success: FAQ toggle organized with two columns holding the existing Q&A pairs (two on the left, one on the right)." + ) + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/easy/standard_operating_procedure/simple__section_organization/description.md b/tasks/notion/easy/standard_operating_procedure/simple__section_organization/description.md new file mode 100644 index 0000000000000000000000000000000000000000..ebf2f9c29702fdf75932f4a7ab0a1ab721af12f2 --- /dev/null +++ b/tasks/notion/easy/standard_operating_procedure/simple__section_organization/description.md @@ -0,0 +1,10 @@ +# Task: Reorganize Standard Operating Procedure Page Sections + +## Objective +Modify the structure of the Standard Operating Procedure page in Notion by updating the order of two sections. + +## Requirements +- Navigate to the Standard Operating Procedure page +- Swap the positions of the "Terminologies" and "Roles & responsibilities" sections +- Preserve all content within each section exactly as is +- Maintain the original formatting and structure of each section diff --git a/tasks/notion/easy/standard_operating_procedure/simple__section_organization/meta.json b/tasks/notion/easy/standard_operating_procedure/simple__section_organization/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..ba72cfaa88237ab7e53c36f98f8aa4c5640af11a --- /dev/null +++ b/tasks/notion/easy/standard_operating_procedure/simple__section_organization/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "simple__section_organization", + "task_name": "Simple Section Organization", + "category_id": "standard_operating_procedure", + "category_name": "Standard Operating Procedure", + "description": "Reorganize the Standard Operating Procedure page by swapping sections and creating a column layout.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "content organization", + "cross-reference linking", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Standard-Operating-Procedure-24381626b6d780a8b678f9e62ae5b152", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/standard-operating-procedure" + } +} diff --git a/tasks/notion/easy/standard_operating_procedure/simple__section_organization/verify.py b/tasks/notion/easy/standard_operating_procedure/simple__section_organization/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..e2503df2fc0117c51a54dfaa270ea38813f0bd5e --- /dev/null +++ b/tasks/notion/easy/standard_operating_procedure/simple__section_organization/verify.py @@ -0,0 +1,76 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +TARGET_PAGE_TITLE = "Standard Operating Procedure" +ROLES_HEADING = "Roles & responsibilities" +TERMINOLOGIES_HEADING = "Terminologies" + + +def _find_heading_indices(blocks: list[dict]) -> tuple[int | None, int | None]: + """Return the indices of the target headings within the flattened block list.""" + roles_index = None + terminologies_index = None + + for index, block in enumerate(blocks): + if block.get("type") != "heading_2": + continue + rich_text = block.get("heading_2", {}).get("rich_text", []) + if not rich_text: + continue + heading_text = rich_text[0].get("text", {}).get("content", "") + if heading_text == ROLES_HEADING and roles_index is None: + roles_index = index + elif heading_text == TERMINOLOGIES_HEADING and terminologies_index is None: + terminologies_index = index + + return roles_index, terminologies_index + + +def verify(notion: Client, main_id: str | None = None) -> bool: + """Ensure the Roles & responsibilities section appears before Terminologies.""" + # Resolve page id. + if main_id: + page_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not page_id or object_type != "page": + print("Error: Standard Operating Procedure page not found.", file=sys.stderr) + return False + else: + page_id = notion_utils.find_page(notion, TARGET_PAGE_TITLE) + if not page_id: + print("Error: Standard Operating Procedure page not found.", file=sys.stderr) + return False + + # Fetch all blocks (flattened order from top to bottom). + blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + roles_index, terminologies_index = _find_heading_indices(blocks) + + if roles_index is None: + print("Error: 'Roles & responsibilities' section not found.", file=sys.stderr) + return False + if terminologies_index is None: + print("Error: 'Terminologies' section not found.", file=sys.stderr) + return False + + if roles_index >= terminologies_index: + print( + "Error: Sections are not swapped. 'Roles & responsibilities' should appear before 'Terminologies'.", + file=sys.stderr, + ) + return False + + print("Success: Section order updated so 'Roles & responsibilities' precedes 'Terminologies'.") + return True + + +def main() -> None: + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/easy/team_projects/simple__swap_tasks/description.md b/tasks/notion/easy/team_projects/simple__swap_tasks/description.md new file mode 100644 index 0000000000000000000000000000000000000000..403698c7d45f629e942a29efd7c5eea740d6af6b --- /dev/null +++ b/tasks/notion/easy/team_projects/simple__swap_tasks/description.md @@ -0,0 +1 @@ +Go to the Team Projects page, find the person responsible for the most tasks (10 in total) and the person responsible for the fewest tasks (3 in total), then swap their assigned tasks. \ No newline at end of file diff --git a/tasks/notion/easy/team_projects/simple__swap_tasks/meta.json b/tasks/notion/easy/team_projects/simple__swap_tasks/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..a0501526e481fcc6087e049c7c79b1170ac2f315 --- /dev/null +++ b/tasks/notion/easy/team_projects/simple__swap_tasks/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "simple__swap_tasks", + "task_name": "Simple Swap Tasks", + "category_id": "team_projects", + "category_name": "Team Projects", + "description": "Find the person responsible for the most and fewest tasks, then swap their assigned tasks.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "data aggregation", + "automated migration", + "conditional filtering" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Team-Projects-24e81626b6d7809c982fdb7a25825898", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/gantt-chart" + } +} diff --git a/tasks/notion/easy/team_projects/simple__swap_tasks/verify.py b/tasks/notion/easy/team_projects/simple__swap_tasks/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..2c9a8d6c6b4b9327759635915517325b2c97174b --- /dev/null +++ b/tasks/notion/easy/team_projects/simple__swap_tasks/verify.py @@ -0,0 +1,215 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the task assignees have been swapped correctly. + Checks: + 1. "Develop a plan for promotion" and "Evaluate different third-party services" have swapped assignees + 2. The person with most tasks and person with least tasks have swapped all their tasks + """ + # Step 1: Find the Team Projects page + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not found_id or object_type != 'page': + print("Error: Team Projects page not found.", file=sys.stderr) + return False + else: + # Try to find the page by searching + found_id = notion_utils.find_page(notion, "Team Projects") + if not found_id: + print("Error: Team Projects page not found.", file=sys.stderr) + return False + + # Get all blocks from the page to find database references + all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id) + + # Find Tasks database ID from the page + tasks_db_id = None + + for block in all_blocks: + if block and block.get("type") == "child_database": + db_title = block.get("child_database", {}).get("title", "") + if "Tasks" in db_title: + tasks_db_id = block["id"] + break + + if not tasks_db_id: + print("Error: Tasks database not found.", file=sys.stderr) + return False + + print("\n📋 Starting verification...") + + # Step 2: Query all tasks to analyze assignees + + try: + all_tasks_response = notion.databases.query( + database_id=tasks_db_id, + page_size=100 + ) + + if not all_tasks_response.get("results"): + print("Error: No tasks found in Tasks database.", file=sys.stderr) + return False + + tasks = all_tasks_response["results"] + + except Exception as e: + print(f"Error querying Tasks database: {e}", file=sys.stderr) + return False + + # Step 3: Check specific tasks have swapped assignees + + develop_plan_task = None + evaluate_services_task = None + + for task in tasks: + task_name = task["properties"]["Name"]["title"][0]["text"]["content"] + if task_name == "Develop a plan for promotion": + develop_plan_task = task + elif task_name == "Evaluate different third-party services": + evaluate_services_task = task + + if not develop_plan_task or not evaluate_services_task: + print("Error: Could not find both required tasks.", file=sys.stderr) + return False + + # Get assignees for these tasks + develop_plan_assignees = develop_plan_task["properties"]["Assigned"]["people"] + evaluate_services_assignees = evaluate_services_task["properties"]["Assigned"]["people"] + + if not develop_plan_assignees or not evaluate_services_assignees: + print("Error: Tasks don't have assignees.", file=sys.stderr) + return False + + develop_plan_assignee_id = develop_plan_assignees[0]["id"] + evaluate_services_assignee_id = evaluate_services_assignees[0]["id"] + + # These should be different (swapped) + if develop_plan_assignee_id == evaluate_services_assignee_id: + print("Error: Tasks should have different assignees after swap.", file=sys.stderr) + return False + + # Step 4: Count tasks per person + + task_counts = {} + unassigned_count = 0 + + for task in tasks: + assignees = task["properties"]["Assigned"]["people"] + if assignees: + assignee_id = assignees[0]["id"] + if assignee_id not in task_counts: + task_counts[assignee_id] = [] + task_counts[assignee_id].append(task["properties"]["Name"]["title"][0]["text"]["content"]) + else: + unassigned_count += 1 + + # Sort by task count + sorted_assignees = sorted(task_counts.items(), key=lambda x: len(x[1])) + + if len(sorted_assignees) < 2: + print("Error: Need at least 2 people with tasks to verify swap.", file=sys.stderr) + return False + + # Get person with least and most tasks + person_with_least = sorted_assignees[0] + person_with_most = sorted_assignees[-1] + + least_id, least_tasks = person_with_least + most_id, most_tasks = person_with_most + + # Step 5: Verify the swap pattern + + # Original distribution (before swap): + # - 5ac96c02-49a4-4320-8de6-b663ba83126b had 3 tasks (least) + # - ac7a3bd0-c111-4464-8f45-8a857a1abc8a had 10 tasks (most) + + # After complete swap, we expect: + # - 5ac96c02-49a4-4320-8de6-b663ba83126b should have 10 tasks + # - ac7a3bd0-c111-4464-8f45-8a857a1abc8a should have 3 tasks + + original_least_id = "5ac96c02-49a4-4320-8de6-b663ba83126b" + original_most_id = "ac7a3bd0-c111-4464-8f45-8a857a1abc8a" + + # Check if the swap has been completed + swap_completed = False + for assignee_id, assignee_tasks in task_counts.items(): + if assignee_id == original_least_id and len(assignee_tasks) == 10: + # Person who had 3 now has 10 + for other_id, other_tasks in task_counts.items(): + if other_id == original_most_id and len(other_tasks) == 3: + # Person who had 10 now has 3 + swap_completed = True + break + + # Step 6: Summary + print(f"\n📊 Task Distribution:") + print(f" • Total tasks: {len(tasks)}") + print(f" • Assigned tasks: {len(tasks) - unassigned_count}") + print(f" • Unassigned tasks: {unassigned_count}") + print(f" • People with tasks: {len(task_counts)}") + print(f"\n Task counts by person:") + for assignee_id, assignee_tasks in sorted_assignees: + print(f" - {assignee_id[:8]}...: {len(assignee_tasks)} tasks") + + # Step 7: Final verification + print("\n🔍 Verification Results:") + + # Check that the swap has created a significant difference + if len(most_tasks) - len(least_tasks) < 5: + print(f"Warning: Difference between most and least is only {len(most_tasks) - len(least_tasks)} tasks", file=sys.stderr) + + # Verify specific expected outcomes + verification_passed = True + + # Check 1: Specific tasks have been swapped + specific_tasks_swapped = develop_plan_assignee_id != evaluate_services_assignee_id + if specific_tasks_swapped: + print(" ✓ Specific tasks have been swapped") + else: + print(" ✗ Specific tasks were not swapped", file=sys.stderr) + verification_passed = False + + # Check 2: Task distribution shows a complete swap + if swap_completed: + print(" ✓ Complete task swap verified (3↔10 tasks)") + else: + # Show actual distribution for debugging + person1_tasks = len(task_counts.get(original_least_id, [])) + person2_tasks = len(task_counts.get(original_most_id, [])) + print(f" ✗ Swap incomplete! Expected 5ac96c02→10 tasks, ac7a3bd0→3 tasks", file=sys.stderr) + print(f" Actual: 5ac96c02→{person1_tasks} tasks, ac7a3bd0→{person2_tasks} tasks", file=sys.stderr) + verification_passed = False + + # Check 3: Total task count is preserved + total_assigned_tasks = sum(len(tasks) for _, tasks in task_counts.items()) + expected_total = len(tasks) - unassigned_count + + if total_assigned_tasks == expected_total: + print(f" ✓ Total task count preserved ({total_assigned_tasks} assigned)") + else: + print(f" ✗ Task count mismatch: {total_assigned_tasks} vs {expected_total} expected", file=sys.stderr) + verification_passed = False + + if verification_passed: + print("\n✅ All verification checks passed!") + return True + else: + print("\n❌ Verification failed", file=sys.stderr) + return False + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/easy/toronto_guide/simple__change_color/description.md b/tasks/notion/easy/toronto_guide/simple__change_color/description.md new file mode 100644 index 0000000000000000000000000000000000000000..e69a8ade8fcfd462c471b32a56779028391cf4bf --- /dev/null +++ b/tasks/notion/easy/toronto_guide/simple__change_color/description.md @@ -0,0 +1,7 @@ +Open the **Toronto Guide** page and refresh the colors of the tags in the **Food** database. + +## Requirements +1. Find and open the Toronto Guide page in Notion. +2. Locate the *Food* database on that page. +3. Update every tag in the Food database that is currently pink so that it uses a different color of your choice (any non-pink color is fine). +4. Do not modify callouts or tags in the other databases. diff --git a/tasks/notion/easy/toronto_guide/simple__change_color/meta.json b/tasks/notion/easy/toronto_guide/simple__change_color/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..f5d3132275c096d7e9c72ae0cff30dc3fcc74e1e --- /dev/null +++ b/tasks/notion/easy/toronto_guide/simple__change_color/meta.json @@ -0,0 +1,23 @@ +{ + "task_id": "simple__change_color", + "task_name": "Simple Change Color", + "category_id": "toronto_guide", + "category_name": "Toronto Guide", + "description": "Navigate to the Toronto Guide page and change all pink-colored elements to different colors.", + "author": "Xiangyan Liu", + "created_at": "2025-11-15", + "difficulty": "L1", + "tags": [ + "visual formatting", + "conditional filtering" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Toronto-Guide-25281626b6d7802caa7cc394647e901c", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/conquering-toronto-a-destination-guide" + } +} diff --git a/tasks/notion/easy/toronto_guide/simple__change_color/verify.py b/tasks/notion/easy/toronto_guide/simple__change_color/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..44a3f69295e5d2643179fcc221f1c28da4eada2a --- /dev/null +++ b/tasks/notion/easy/toronto_guide/simple__change_color/verify.py @@ -0,0 +1,100 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +TARGET_PAGE_TITLE = "Toronto Guide" +FOOD_DATABASE_KEYWORD = "Food" +TARGET_TAG_NAMES = [ + "Middle Eastern", + "Jamaican", + "Indian", +] + + +def _get_food_database_id(notion: Client, page_id: str) -> str | None: + """Return the block ID of the Food database shown on the target page.""" + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + for block in all_blocks: + if not block or block.get("type") != "child_database": + continue + title = block.get("child_database", {}).get("title", "") + if FOOD_DATABASE_KEYWORD.lower() in title.lower(): + return block.get("id") + return None + + +def verify(notion: Client, main_id: str | None = None) -> bool: + """Check that all target tags in the Food database are no longer pink.""" + # Resolve the Toronto Guide page ID. + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not found_id or object_type != "page": + print("Error: Toronto Guide page not found.", file=sys.stderr) + return False + page_id = found_id + else: + page_id = notion_utils.find_page(notion, TARGET_PAGE_TITLE) + if not page_id: + print("Error: Toronto Guide page not found.", file=sys.stderr) + return False + + # Locate the Food database block. + food_db_id = _get_food_database_id(notion, page_id) + if not food_db_id: + print("Error: Food database not found on the Toronto Guide page.", file=sys.stderr) + return False + + # Fetch database definition and inspect tag options. + try: + db_info = notion.databases.retrieve(database_id=food_db_id) + except Exception as exc: + print(f"Error: Unable to retrieve Food database ({exc}).", file=sys.stderr) + return False + + tags_property = db_info.get("properties", {}).get("Tags", {}) + if tags_property.get("type") != "multi_select": + print("Error: Food database does not have a multi-select Tags property.", file=sys.stderr) + return False + + options = tags_property.get("multi_select", {}).get("options", []) + remaining_targets = set(TARGET_TAG_NAMES) + failures = False + + for option in options: + tag_name = option.get("name", "").strip() + if tag_name not in remaining_targets: + continue + + remaining_targets.discard(tag_name) + color = option.get("color") + if color == "pink": + print(f"Error: Tag '{tag_name}' in Food database is still pink.", file=sys.stderr) + failures = True + else: + print(f"✓ Tag '{tag_name}' color updated to '{color}'.") + + if remaining_targets: + print( + f"Error: Food tags not found (expected to exist): {sorted(remaining_targets)}.", + file=sys.stderr, + ) + return False + + if failures: + return False + + print("Success: All Food database tags are now non-pink.") + return True + + +def main() -> None: + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/company_in_a_box/employee_onboarding/description.md b/tasks/notion/standard/company_in_a_box/employee_onboarding/description.md new file mode 100644 index 0000000000000000000000000000000000000000..02a2230924917d698c3b08b19f553818a12c74f7 --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/employee_onboarding/description.md @@ -0,0 +1,15 @@ +Build an integrated **Employee Onboarding** system for the existing **Company In A Box** page. + +**Task Requirements:** +1. Create a new **database** titled **Employee Onboarding Checklist** with the following properties *exactly*: + • **Employee Name** – title + • **Start Date** – date + • **Department** – select (options: Product, Marketing, Sales, HR, Engineering) + + Populate this database with **3** sample new-hire pages covering three different departments. Every property in each entry must be filled. + +2. Under the top-level page **Company In A Box**, create a new child page titled **Onboarding Hub** containing, in order: + 1) The **Employee Onboarding Checklist** database embedded at the top. + 2) A section headed **Benefits Overview** that includes linked mentions (@-mentions or link-to-page blocks) to **≥ 3** distinct benefit-policy pages from the **Company Wiki** (for example *Benefits policy*, *Vacation Policy*, *Corporate travel*). + 3) A section headed **30-Day Timeline** that presents a numbered list with **7** steps covering the first 30 days. **Each step must reference (via @-mention) an existing page or database**. + 4) A section headed **Feedback Form** that provides **≥ 3** to-do items for new hires to check off. \ No newline at end of file diff --git a/tasks/notion/standard/company_in_a_box/employee_onboarding/meta.json b/tasks/notion/standard/company_in_a_box/employee_onboarding/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..347981dfbeede991f3aa9fbf4ff07e8c06d20eeb --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/employee_onboarding/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "employee_onboarding", + "task_name": "Employee Onboarding", + "category_id": "company_in_a_box", + "category_name": "Company In A Box", + "description": "Build an integrated Employee Onboarding system for the existing Company In A Box page with a checklist database, onboarding hub, and feedback form.", + "author": "Zijian Wu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "database manipulation", + "template population", + "cross-reference linking", + "status tracking" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Company-In-A-Box-23d81626b6d7800098f3d0e64a706cd8", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/company-in-a-box" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/company_in_a_box/employee_onboarding/verify.py b/tasks/notion/standard/company_in_a_box/employee_onboarding/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..c9c35aa5809560963c579361e2cb5bce8d785d5f --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/employee_onboarding/verify.py @@ -0,0 +1,177 @@ +import sys +from typing import Dict, Set +from notion_client import Client +from tasks.utils import notion_utils + + +def _check_db_schema(db_props: Dict[str, Dict], required: Dict[str, str]) -> bool: + """Return True if every required property exists with the correct type.""" + for prop_name, expected_type in required.items(): + if prop_name not in db_props: + print( + f"Error: Property '{prop_name}' missing from database.", file=sys.stderr + ) + return False + actual_type = db_props[prop_name]["type"] + if actual_type != expected_type: + print( + f"Error: Property '{prop_name}' has type '{actual_type}', expected '{expected_type}'.", + file=sys.stderr, + ) + return False + return True + + +def verify(notion: Client, main_id: str | None = None) -> bool: # noqa: C901 + """Programmatically verify the onboarding system described in description.md.""" + + DB_TITLE = "Employee Onboarding Checklist" + HUB_PAGE_TITLE = "Onboarding Hub" + DEPARTMENT_OPTIONS: Set[str] = { + "Product", + "Marketing", + "Sales", + "HR", + "Engineering", + } + REQUIRED_DB_PROPERTIES = { + "Employee Name": "title", + "Start Date": "date", + "Department": "select", + } + + # 1. Locate onboarding database + db_id = notion_utils.find_database(notion, DB_TITLE) + if not db_id: + print(f"Error: Database '{DB_TITLE}' not found.", file=sys.stderr) + return False + + try: + db_obj = notion.databases.retrieve(database_id=db_id) + except Exception as exc: + print(f"Error retrieving database: {exc}", file=sys.stderr) + return False + + db_props = db_obj.get("properties", {}) + if not _check_db_schema(db_props, REQUIRED_DB_PROPERTIES): + return False + + # Extra: validate select options + dept_options = {opt["name"] for opt in db_props["Department"]["select"]["options"]} + if not DEPARTMENT_OPTIONS.issubset(dept_options): + print( + f"Error: Department select options must include {sorted(DEPARTMENT_OPTIONS)}. Current: {sorted(dept_options)}", + file=sys.stderr, + ) + return False + + # Check there are at least 3 entries in the database + try: + db_pages = notion.databases.query(database_id=db_id).get("results", []) + except Exception as exc: + print(f"Error querying database: {exc}", file=sys.stderr) + return False + if len(db_pages) < 3: + print( + "Error: Less than 3 onboarding entries found in the database.", + file=sys.stderr, + ) + return False + + # 2. Locate Onboarding Hub page + hub_page_id = notion_utils.find_page(notion, HUB_PAGE_TITLE) + if not hub_page_id: + print(f"Error: Page '{HUB_PAGE_TITLE}' not found.", file=sys.stderr) + return False + + # 3. Ensure the onboarding database is embedded in the hub page + embedded_db_id = notion_utils.find_database_in_block(notion, hub_page_id, DB_TITLE) + if embedded_db_id != db_id: + print( + "Error: The Employee Onboarding Checklist database is not embedded in the Onboarding Hub page.", + file=sys.stderr, + ) + return False + + # 4. Analyse blocks within the hub page for linked mentions, timeline, and feedback form + all_blocks = notion_utils.get_all_blocks_recursively(notion, hub_page_id) + + seen_link_targets: Set[str] = set() + numbered_list_count = 0 + todo_count = 0 + + for blk in all_blocks: + blk_type = blk.get("type") + + # Direct link-to-page blocks + if blk_type == "link_to_page": + info = blk.get("link_to_page", {}) + target_id = info.get("page_id") or info.get("database_id") + if target_id: + seen_link_targets.add(target_id) + continue + + # Rich-text mentions inside content blocks + if blk_type in { + "paragraph", + "numbered_list_item", + "bulleted_list_item", + "to_do", + }: + content = blk.get(blk_type, {}) + for rt in content.get("rich_text", []): + if rt.get("type") == "mention": + mention = rt.get("mention", {}) + if mention.get("type") in {"page", "database"}: + target_id = mention.get("page", {}).get("id") or mention.get( + "database", {} + ).get("id") + if target_id: + seen_link_targets.add(target_id) + + # Count numbered list items + if blk_type == "numbered_list_item": + numbered_list_count += 1 + + # Count to-do items in Feedback Form + if blk_type == "to_do": + todo_count += 1 + + if len(seen_link_targets) < 3: + print( + "Error: Fewer than 3 linked mentions to benefit policy pages found in the Benefits Overview section.", + file=sys.stderr, + ) + return False + + if numbered_list_count < 7: + print( + "Error: Numbered list contains fewer than 7 steps in the 30-Day Timeline section.", + file=sys.stderr, + ) + return False + + if todo_count < 3: + print( + "Error: Feedback Form section contains fewer than 3 to-do items.", + file=sys.stderr, + ) + return False + + print( + "Success: Verified Employee Onboarding Checklist database, Onboarding Hub page, and all required sections." + ) + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/company_in_a_box/goals_restructure/description.md b/tasks/notion/standard/company_in_a_box/goals_restructure/description.md new file mode 100644 index 0000000000000000000000000000000000000000..c15722304e24768d723a1d27952fc122beb3f634 --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/goals_restructure/description.md @@ -0,0 +1,17 @@ +Please restructure the **Current Goals** section on my **Company In A Box** page as follows: + +1. **Add a new goal heading** — create a new `heading_3` block titled: + + `🔄 Digital Transformation Initiative` + +2. **Convert all four goal headings to toggles** — the three existing goals + * ⚙️ Expand Operations to LATAM + * 🛠️ Push for Enterprise + * 🩶 Boost Employee Engagement + * 🔄 Digital Transformation Initiative + +3. **Move descriptions inside the toggles** — every paragraph or list that originally sat directly under a goal heading should become a **child block** of that heading after it is made toggleable. + +4. **Preserve content & order** — apart from the changes above, do **not** modify the text, formatting, or order of existing goal descriptions. + +The end result should be a clean **Current Goals** section containing four toggleable goal headings, each with its corresponding details tucked inside. \ No newline at end of file diff --git a/tasks/notion/standard/company_in_a_box/goals_restructure/meta.json b/tasks/notion/standard/company_in_a_box/goals_restructure/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..0938bb6f40c869598287e55df82249348d458950 --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/goals_restructure/meta.json @@ -0,0 +1,23 @@ +{ + "task_id": "goals_restructure", + "task_name": "Goals Restructure", + "category_id": "company_in_a_box", + "category_name": "Company In A Box", + "description": "Restructure the Current Goals section on the Company In A Box page by adding a new goal heading and converting all goal headings to toggles with content inside.", + "author": "Zijian Wu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "content organization", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Company-In-A-Box-23d81626b6d7800098f3d0e64a706cd8", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/company-in-a-box" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/company_in_a_box/goals_restructure/verify.py b/tasks/notion/standard/company_in_a_box/goals_restructure/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..46d6123628d31a31dfe39a1560918e284ad93fc3 --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/goals_restructure/verify.py @@ -0,0 +1,212 @@ +import sys +from typing import List +from notion_client import Client +from tasks.utils import notion_utils + +# Expected new goal heading text (including emoji) +NEW_GOAL_HEADING = "🔄 Digital Transformation Initiative" + +# Section title to look for +GOALS_SECTION_TITLE = "Current Goals" + + +def _plain(block) -> str: + """Return concatenated plain text of a block.""" + return notion_utils.get_block_plain_text(block) + + +# Some Notion rich-text strings may include non-breaking spaces (\xa0) after emoji. +# Normalize them to plain spaces so text matching is robust. +def _normalize_string(s: str) -> str: + return s.replace("\xa0", " ") + + +def _is_heading(block) -> bool: + return block.get("type") in ["heading_1", "heading_2", "heading_3"] + + +def _is_toggle(block) -> bool: + """Determine whether a block is a toggle (standard toggle block or toggle-able heading).""" + btype = block.get("type") + # In our scenario, goal blocks are headings (usually heading_3) marked as toggleable. + if btype in ["heading_1", "heading_2", "heading_3"]: + heading_data = block.get(btype, {}) + return heading_data.get("is_toggleable", False) + # Some Notion pages may contain classic toggle blocks (type == "toggle"). They are + # not expected in this task, but keeping this check allows broader compatibility. + return btype == "toggle" + + +def _get_children(notion: Client, block_id: str) -> List[dict]: + """Retrieve **direct** children of a block (no pagination handling needed for small test pages).""" + try: + return notion.blocks.children.list(block_id=block_id).get("results", []) + except Exception: + return [] + + +def verify(notion: Client, main_id: str = None) -> bool: + """Verifies that the Company in a Box page has been updated per the task requirements.""" + # 1. Locate the main page + page_id = None + if main_id: + found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if found_id and obj_type == "page": + page_id = found_id + + if not page_id: + # Try a few case variations just in case + for title in [ + "Company In A Box", + ]: + page_id = notion_utils.find_page(notion, title) + if page_id: + break + + if not page_id: + print("Error: Could not find the 'Company in a Box' page.", file=sys.stderr) + return False + + # 2. Recursively locate the "Current Goals" heading and collect its sibling blocks that + # constitute the section. + + def _fetch_children(bid: str) -> List[dict]: + try: + return notion.blocks.children.list(block_id=bid).get("results", []) + except Exception: + return [] + + goals_section_blocks: List[dict] = [] + + # Breadth-first traversal to find the heading + queue = [page_id] + found_parent = None + found_index = None + + while queue and found_parent is None: + parent_id = queue.pop(0) + children = _fetch_children(parent_id) + for idx, child in enumerate(children): + if ( + _is_heading(child) + and GOALS_SECTION_TITLE.lower() + in _normalize_string(_plain(child)).lower() + ): + found_parent = parent_id + found_index = idx + break + # enqueue grandchildren for further search + for ch in children: + if ch.get("has_children"): + queue.append(ch["id"]) + + if found_parent is None: + print( + "Error: Could not find the 'Current Goals' heading anywhere in the page.", + file=sys.stderr, + ) + return False + + # Retrieve siblings once more to get the final list and slice after heading. + siblings = _fetch_children(found_parent) + if found_index is None or found_index >= len(siblings): + print( + "Error: Internal logic issue when locating Current Goals section.", + file=sys.stderr, + ) + return False + + goals_section_blocks = siblings[found_index + 1 :] + + if not goals_section_blocks: + print("Error: 'Current Goals' section appears to be empty.", file=sys.stderr) + return False + + # 3. Identify toggle blocks that represent goals + toggle_blocks = [b for b in goals_section_blocks if _is_toggle(b)] + + if len(toggle_blocks) != 4: + print( + f"Error: Expected 4 toggle blocks for goals, found {len(toggle_blocks)}.", + file=sys.stderr, + ) + return False + + # 4. Ensure the new goal heading exists among the toggles + found_new_goal = False + for tb in toggle_blocks: + if ( + _normalize_string(NEW_GOAL_HEADING).lower() + in _normalize_string(_plain(tb)).lower() + ): + found_new_goal = True + break + if not found_new_goal: + print( + f"Error: Did not find a toggle block with heading '{NEW_GOAL_HEADING}'.", + file=sys.stderr, + ) + return False + + # 5. Validate that each toggle has at least one child paragraph/description + for tb in toggle_blocks: + if ( + _normalize_string(NEW_GOAL_HEADING).lower() + in _normalize_string(_plain(tb)).lower() + ): + # Skip checking the new goal itself, as it does not have a description yet. + continue + if not tb.get("has_children", False): + print( + f"Error: Toggle '{_normalize_string(_plain(tb))}' has no child blocks (description not moved).", + file=sys.stderr, + ) + return False + children = _get_children(notion, tb["id"]) + # Ensure there is at least one content child (paragraph, list item, etc.) + content_types = { + "paragraph", + "bulleted_list_item", + "numbered_list_item", + "to_do", + "callout", + "quote", + } + if not any(c.get("type") in content_types for c in children): + print( + f"Error: Toggle '{_normalize_string(_plain(tb))}' seems to lack any description/content inside it.", + file=sys.stderr, + ) + return False + + # 6. Confirm that there are **no** residual heading_3 blocks (non-toggle) for the goals + non_toggle_headings = [ + b + for b in goals_section_blocks + if b.get("type") == "heading_3" and not _is_toggle(b) + ] + if non_toggle_headings: + titles = [_normalize_string(_plain(b)) for b in non_toggle_headings] + print( + f"Error: Found heading_3 blocks that were not converted to toggles: {titles}.", + file=sys.stderr, + ) + return False + + print( + "Success: Verified goal restructuring with new toggle blocks and descriptions." + ) + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/description.md b/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/description.md new file mode 100644 index 0000000000000000000000000000000000000000..00aa6c763bad8575e05ae530b1b0aba302e815e9 --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/description.md @@ -0,0 +1,19 @@ +Create a quarterly business review dashboard in Notion based on the existing **Company In A Box** workspace. + +**Task Requirements:** +1. Inside the **Company Wiki** page you will find a sub-page named **Company Goals**. Extract every departmental objective listed under the four departments — **Product**, **Marketing**, **Sales**, and **HR**. +2. Under the top-level page **Company In A Box**, create a new child page titled **Q4 2024 Business Review Dashboard**. +3. Inside that new page build the following structure (all parts must exist): + 1. A single **callout** block near the top that summarises progress toward the three *Current Goals* shown on the main page: + • *LATAM expansion* • *Enterprise push* • *Employee engagement* + (All three phrases must appear in the callout text.) + 2. Four separate **section headings** (any heading level) – one for each department (**Product**, **Marketing**, **Sales**, **Human Resources**) – placed below the callout. Under each heading list that department’s objectives in a progress-tracking format (e.g. to-dos, check-box list). Each objective from the **Company Goals** page must appear at least once. + 3. Add a **database** named **Action Items** with the following properties *exactly*: + • **Task Name** – title + • **Department** – select (options: Product, Marketing, Sales, HR) + • **Priority** – select (options: High, Medium, Low) + • **Status** – status + Populate this database with **≥ 5** action-item pages derived from the departmental objectives, making sure every field in each entry is filled: + • **Task Name** & **Department** must correctly correspond to the underlying objective/department. + • **Priority** and **Status** can be any allowed value, but they must **not** be left empty. +4. Keep the overall visual style consistent with the existing wiki (use headings, dividers, etc.). \ No newline at end of file diff --git a/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/meta.json b/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..6da24603dc6af208f285fd1b86f5bc8be0ba1d48 --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/meta.json @@ -0,0 +1,26 @@ +{ + "task_id": "quarterly_review_dashboard", + "task_name": "Quarterly Review Dashboard", + "category_id": "company_in_a_box", + "category_name": "Company In A Box", + "description": "Create a quarterly business review dashboard in Notion based on the existing Company In A Box workspace with department objectives and action items database.", + "author": "Zijian Wu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "database manipulation", + "data aggregation", + "report generation", + "status tracking", + "template population" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Company-In-A-Box-23d81626b6d7800098f3d0e64a706cd8", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/company-in-a-box" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/verify.py b/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..5beaf4ba2656c2aab10b6730c010e3b871b721ad --- /dev/null +++ b/tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/verify.py @@ -0,0 +1,202 @@ +import sys +from typing import List +from notion_client import Client +from tasks.utils import notion_utils + + +def _contains_keywords(text: str, keywords: List[str]) -> bool: + lowered = text.lower() + return all(kw.lower() in lowered for kw in keywords) + + +def verify(notion: Client, main_id: str = None) -> bool: + """Programmatically verify that the dashboard page and its contents meet the + requirements described in description.md. + """ + DASHBOARD_TITLE = "Q4 2024 Business Review Dashboard" + PARENT_PAGE_TITLE = "Company In A Box" + CALL_OUT_KEYWORDS = ["latam", "enterprise", "employee engagement"] + DEPARTMENTS = ["Product", "Marketing", "Sales", "Human Resources"] + REQUIRED_DB_PROPERTIES = { + "Task Name": "title", + "Department": "select", + "Priority": "select", + "Status": "status", + } + PRIORITY_OPTIONS = {"High", "Medium", "Low"} + + # 1. Locate the dashboard page + page_id = None + if main_id: + found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if found_id and obj_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, DASHBOARD_TITLE) + + if not page_id: + print(f"Error: Page '{DASHBOARD_TITLE}' not found.", file=sys.stderr) + return False + + # Optional: ensure it is a child of Company In A Box + try: + page_obj = notion.pages.retrieve(page_id=page_id) + parent_id = page_obj.get("parent", {}).get("page_id") + if parent_id: + parent_page = notion.pages.retrieve(page_id=parent_id) + parent_title_rt = ( + parent_page.get("properties", {}).get("title", {}).get("title", []) + ) + parent_title = ( + parent_title_rt[0].get("plain_text") if parent_title_rt else None + ) + if parent_title != PARENT_PAGE_TITLE: + print( + f"Error: Dashboard page is not a direct child of '{PARENT_PAGE_TITLE}'.", + file=sys.stderr, + ) + return False + except Exception: + pass # parent check is best-effort only + + # 2. Verify callout with keywords + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + callout_ok = False + for block in all_blocks: + if block.get("type") == "callout": + callout_text = notion_utils.get_block_plain_text(block) + if _contains_keywords(callout_text, CALL_OUT_KEYWORDS): + callout_ok = True + break + if not callout_ok: + print( + "Error: No callout found that includes all three Current Goal keywords (LATAM, Enterprise, Employee engagement).", + file=sys.stderr, + ) + return False + + # 3. Verify department section headings + found_depts = set() + for block in all_blocks: + if block.get("type") in {"heading_1", "heading_2", "heading_3"}: + heading_text = notion_utils.get_block_plain_text(block) + for dept in DEPARTMENTS: + if dept.lower() in heading_text.lower(): + found_depts.add(dept) + if set(DEPARTMENTS) != found_depts: + missing = set(DEPARTMENTS) - found_depts + print( + f"Error: Missing department headings: {', '.join(missing)}.", + file=sys.stderr, + ) + return False + + # 4. Verify Action Items database exists and has correct schema + db_id = notion_utils.find_database_in_block(notion, page_id, "Action Items") + if not db_id: + print( + "Error: Database 'Action Items' not found on the dashboard.", + file=sys.stderr, + ) + return False + + try: + db = notion.databases.retrieve(database_id=db_id) + except Exception as exc: + print(f"Error: Unable to retrieve database: {exc}", file=sys.stderr) + return False + + db_props = db.get("properties", {}) + for prop_name, expected_type in REQUIRED_DB_PROPERTIES.items(): + if prop_name not in db_props: + print( + f"Error: Property '{prop_name}' missing from database.", file=sys.stderr + ) + return False + actual_type = db_props[prop_name]["type"] + if isinstance(expected_type, list): + if actual_type not in expected_type: + print( + f"Error: Property '{prop_name}' has type '{actual_type}', expected one of {expected_type}.", + file=sys.stderr, + ) + return False + else: + if actual_type != expected_type: + print( + f"Error: Property '{prop_name}' has type '{actual_type}', expected '{expected_type}'.", + file=sys.stderr, + ) + return False + # Extra check for Priority options + if prop_name == "Priority": + options = {opt["name"] for opt in db_props[prop_name]["select"]["options"]} + if not PRIORITY_OPTIONS.issubset(options): + print( + f"Error: Priority property options must include High/Medium/Low. Current options: {options}", + file=sys.stderr, + ) + return False + + # 5. Verify at least 5 action items exist + try: + pages = notion.databases.query(database_id=db_id).get("results", []) + except Exception as exc: + print(f"Error querying database pages: {exc}", file=sys.stderr) + return False + + if len(pages) < 5: + print("Error: Database contains fewer than 5 action items.", file=sys.stderr) + return False + + # Optional: Verify Department values valid + for page in pages: + props = page.get("properties", {}) + + # Task Name must be non-empty + title_rt = props.get("Task Name", {}).get("title", []) + task_name = title_rt[0].get("plain_text") if title_rt else "" + if not task_name.strip(): + print( + f"Error: Action item '{page.get('id')}' is missing a Task Name.", + file=sys.stderr, + ) + return False + + # Department must be valid + dept_select = props.get("Department", {}).get("select", {}).get("name") + if not dept_select or dept_select not in DEPARTMENTS: + print( + f"Error: Action item '{page.get('id')}' has invalid or missing Department value.", + file=sys.stderr, + ) + return False + + # Priority and Status must be set (any value) + priority_val = props.get("Priority", {}).get("select", {}).get("name") + status_val = props.get("Status", {}).get("status", {}).get("name") + if not priority_val or not status_val: + print( + f"Error: Action item '{page.get('id')}' must have both Priority and Status set.", + file=sys.stderr, + ) + return False + + print( + "Success: Verified Business Review Dashboard, departmental sections, callout, and Action Items database with ≥5 entries." + ) + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/description.md b/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/description.md new file mode 100644 index 0000000000000000000000000000000000000000..b460882693573618cd693d1745bc332c9da34014 --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/description.md @@ -0,0 +1,25 @@ +Please restructure the **IT Inventory** database as described below. Your automation will be checked by an automated script, so follow every detail exactly. + +--- +Task Steps +1. Inside the **IT Trouble Shooting Hub** page, locate the database named **IT Inventory**. +2. Query this database and collect every page whose **Status** property is **Expired** or **To be returned**. +3. Create a **new full-page database** directly under the same IT Trouble Shooting Hub page called **IT Asset Retirement Queue**. +4. Configure this new database so that it contains **exactly** the following properties (spellings and types must match): + • Serial – title + • Tags – multi_select + • Status – select + • Vendor – select + • Expiration date – date + • Retirement Reason – select with option set { **Expired License**, **Hardware Obsolete**, **Security Risk**, **User Offboarding** } +5. For every inventory item gathered in step2: + a. Create a corresponding page in **IT Asset Retirement Queue** and copy over the values of the Serial, Tags, Status, Vendor and Expiration date properties. + b. Set **Retirement Reason** to one of the four options above (choose the most appropriate). + c. Archive the original inventory page **after** the new page has been created. +6. After all items are migrated: + a. Update the **description** of the **IT Asset Retirement Queue** database so it is **exactly** `AUTO-GENERATED MIGRATION COMPLETED` (no additional text). + b. Create a new page under **IT Trouble Shooting Hub** titled **Retirement Migration Log**. Inside this page, add a **callout block** whose text follows the exact pattern: + + `Successfully migrated assets to the retirement queue on 2025-03-24.` + + • `` is the total number of items moved. \ No newline at end of file diff --git a/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/meta.json b/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..86af9b97992d5aa2317998ff3040f0a6290125b0 --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/meta.json @@ -0,0 +1,26 @@ +{ + "task_id": "asset_retirement_migration", + "task_name": "Asset Retirement Migration", + "category_id": "it_trouble_shooting_hub", + "category_name": "IT Trouble Shooting Hub", + "description": "Restructure the IT Inventory database by migrating expired assets to a new IT Asset Retirement Queue database.", + "author": "Zijian Wu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "database manipulation", + "automated migration", + "conditional filtering", + "data aggregation", + "report generation" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/verify.py b/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..e6a194b2cc1f2739cb6c7826174ca9da5de8d7e6 --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/verify.py @@ -0,0 +1,226 @@ +import sys +from typing import Dict, Set +from notion_client import Client +from tasks.utils import notion_utils + + +def _get_database(root_page_id: str, notion: Client, name: str) -> str | None: + """Helper that finds a child database by title inside a page.""" + return notion_utils.find_database_in_block(notion, root_page_id, name) + + +def _check_property(props: Dict, name: str, expected_type: str) -> bool: + if name not in props: + print(f"Error: Property '{name}' missing in database.", file=sys.stderr) + return False + if props[name]["type"] != expected_type: + print( + f"Error: Property '{name}' expected type '{expected_type}', found '{props[name]['type']}'.", + file=sys.stderr, + ) + return False + return True + + +def verify(notion: Client, main_id: str | None = None) -> bool: + """Verifies that the IT Asset Retirement Queue was created and populated correctly.""" + + # ------------------------------------------------------------------------- + # Resolve the root IT Trouble Shooting Hub page + # ------------------------------------------------------------------------- + root_page_id = None + if main_id: + found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if found_id and obj_type == "page": + root_page_id = found_id + + if not root_page_id: + root_page_id = notion_utils.find_page(notion, "IT Trouble Shooting Hub") + if not root_page_id: + print( + "Error: Could not locate the 'IT Trouble Shooting Hub' page.", + file=sys.stderr, + ) + return False + + # ------------------------------------------------------------------------- + # Locate the original and new databases + # ------------------------------------------------------------------------- + inventory_db_id = _get_database(root_page_id, notion, "IT Inventory") + if not inventory_db_id: + print("Error: 'IT Inventory' database not found.", file=sys.stderr) + return False + + retirement_db_id = _get_database(root_page_id, notion, "IT Asset Retirement Queue") + if not retirement_db_id: + print("Error: 'IT Asset Retirement Queue' database not found.", file=sys.stderr) + return False + + # ------------------------------------------------------------------------- + # Validate schema of the retirement queue database + # ------------------------------------------------------------------------- + retirement_db = notion.databases.retrieve(database_id=retirement_db_id) + r_props = retirement_db["properties"] + + required_schema = { + "Serial": "title", + "Tags": "multi_select", + "Status": "select", + "Vendor": "select", + "Expiration date": "date", + "Retirement Reason": "select", + } + + for pname, ptype in required_schema.items(): + if not _check_property(r_props, pname, ptype): + return False + + # Check Retirement Reason options + expected_reason_options: Set[str] = { + "Expired License", + "Hardware Obsolete", + "Security Risk", + "User Offboarding", + } + actual_options = { + opt["name"] for opt in r_props["Retirement Reason"]["select"]["options"] + } + if actual_options != expected_reason_options: + print( + "Error: 'Retirement Reason' select options mismatch.\n" + f"Expected: {sorted(expected_reason_options)}\n" + f"Found: {sorted(actual_options)}", + file=sys.stderr, + ) + return False + + # --------------------------------------------------------------- + # Validate database description starts with required phrase + # --------------------------------------------------------------- + desc_rich = retirement_db.get("description", []) + desc_text = "".join([t.get("plain_text", "") for t in desc_rich]) + required_desc = "AUTO-GENERATED MIGRATION COMPLETED" + if desc_text.strip() != required_desc: + print( + f"Error: Retirement database description must be exactly '{required_desc}'.", + file=sys.stderr, + ) + return False + + # ------------------------------------------------------------------------- + # Validate that inventory items are moved & archived + # ------------------------------------------------------------------------- + expired_filter = { + "property": "Status", + "select": {"equals": "Expired"}, + } + to_return_filter = { + "property": "Status", + "select": {"equals": "To be returned"}, + } + compound_filter = {"or": [expired_filter, to_return_filter]} + + # Query for any *active* items that still match these statuses + remaining_items = notion.databases.query( + database_id=inventory_db_id, + filter=compound_filter, + archived=False, + ).get("results", []) + + if remaining_items: + print( + f"Error: {len(remaining_items)} 'Expired' / 'To be returned' items still present in IT Inventory.", + file=sys.stderr, + ) + return False + + # There should be at least one entry in the retirement queue + retirement_pages = notion.databases.query(database_id=retirement_db_id).get( + "results", [] + ) + expected_serials = {"65XYQ/GB", "36x10PIQ"} + if len(retirement_pages) != len(expected_serials): + print( + f"Error: Expected {len(expected_serials)} retirement pages, found {len(retirement_pages)}.", + file=sys.stderr, + ) + return False + + # Each retirement page must have a Retirement Reason + serials_seen = set() + for page in retirement_pages: + props = page["properties"] + reason = props.get("Retirement Reason", {}).get("select", {}) + if not reason or reason.get("name") not in expected_reason_options: + print( + f"Error: Page {page['id']} missing valid 'Retirement Reason'.", + file=sys.stderr, + ) + return False + + # Collect Serial title + title_rich = props.get("Serial", {}).get("title", []) + serial_val = "".join([t.get("plain_text", "") for t in title_rich]).strip() + serials_seen.add(serial_val) + + if serials_seen != expected_serials: + print( + f"Error: Serial values mismatch. Expected {sorted(expected_serials)}, found {sorted(serials_seen)}.", + file=sys.stderr, + ) + return False + + # ----------------------------------------------------------------- + # Verify the migration log page and callout block contents + # ----------------------------------------------------------------- + log_page_title = "Retirement Migration Log" + log_page_id = notion_utils.find_page(notion, log_page_title) + if not log_page_id: + print(f"Error: Page '{log_page_title}' not found.", file=sys.stderr) + return False + + # Search for a callout block with required pattern + import re + + callout_pattern = re.compile( + r"Successfully migrated (\d+) assets to the retirement queue on 2025-03-24\." + ) + blocks = notion_utils.get_all_blocks_recursively(notion, log_page_id) + match_found = False + for blk in blocks: + if blk.get("type") == "callout": + text = notion_utils.get_block_plain_text(blk) + m = callout_pattern.search(text) + if m: + migrated_num = int(m.group(1)) + if migrated_num == len(expected_serials): + match_found = True + else: + print( + f"Error: Callout reports {migrated_num} assets, but {len(retirement_pages)} retirement pages found.", + file=sys.stderr, + ) + return False + break + if not match_found: + print( + "Error: Required callout block not found in migration log page.", + file=sys.stderr, + ) + return False + + print("Success: All verification criteria satisfied.") + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/description.md b/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/description.md new file mode 100644 index 0000000000000000000000000000000000000000..c1a4448ca4d838324fd518468c240ea1a0fb2fba --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/description.md @@ -0,0 +1,21 @@ +Please help me create a comprehensive security audit ticket based on the data already stored in the **IT Trouble Shooting Hub** page. + +Your automation should: + +1. In the **IT Inventory** database, find every item whose **Expiration date** is **before 2023-07-15**. +2. In the **IT FAQs** database, look up any FAQ entries that have the **"Security"** tag. +3. **Create a new page** inside the **IT Requests** database with **exact title**: + + `Quarterly Security Audit - Expired Assets Review` +4. Set its **Priority** property to **High**. +5. Set its **Due** property to **2023-06-22**. +6. In the page body, add a bullet-list block that enumerates **each expired inventory item**. **Each bullet item must follow this exact text format (including the dashes):** + + ` - - ` + + • `` is the item’s Serial value. + • `` is the first tag assigned to the inventory item (e.g., "Laptop"). + • `` is a brief action you suggest based on the security FAQ entry (any text is acceptable). + + Example (do **not** copy): + `ABC123 - Laptop - Renew warranty and enable disk encryption` \ No newline at end of file diff --git a/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/meta.json b/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..51d37bb8601ba861da7754c7af583f91806969cd --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "security_audit_ticket", + "task_name": "Security Audit Ticket", + "category_id": "it_trouble_shooting_hub", + "category_name": "IT Trouble Shooting Hub", + "description": "Create a comprehensive security audit ticket based on expired inventory items and security FAQ entries.", + "author": "Zijian Wu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "conditional filtering", + "database manipulation", + "data aggregation", + "report generation" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/verify.py b/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..62fa2f2bde8c6cdec244fba73ba0197f9d54ec29 --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/verify.py @@ -0,0 +1,176 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils +import re + + +def _get_title_text(page_properties: dict) -> str: + """Extract the plain text of the first title property from a page.""" + for prop in page_properties.values(): + if prop.get("type") == "title": + title_rich = prop.get("title", []) + if title_rich: + return title_rich[0].get("plain_text") + return "" + + +def verify(notion: Client, main_id: str | None = None) -> bool: + """Verify that the automation created the expected security audit ticket.""" + + # ---------------------------------------------------------------------------------- + # Locate the root page (IT Trouble Shooting Hub) either via main_id or by title. + # ---------------------------------------------------------------------------------- + root_page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + root_page_id = found_id + + if not root_page_id: + root_page_id = notion_utils.find_page(notion, "IT Trouble Shooting Hub") + if not root_page_id: + print( + "Error: Could not locate the 'IT Trouble Shooting Hub' page.", + file=sys.stderr, + ) + return False + + # ---------------------------------------------------------------------------------- + # Find the IT Requests database under the root page. + # ---------------------------------------------------------------------------------- + requests_db_id = notion_utils.find_database_in_block( + notion, root_page_id, "IT Requests" + ) + if not requests_db_id: + print( + "Error: 'IT Requests' database not found in the workspace.", file=sys.stderr + ) + return False + + # ---------------------------------------------------------------------------------- + # Search for the expected ticket inside the IT Requests database. + # ---------------------------------------------------------------------------------- + expected_title = "Quarterly Security Audit - Expired Assets Review" + results = notion.databases.query(database_id=requests_db_id).get("results", []) + + target_page = None + for page in results: + title_text = _get_title_text(page.get("properties", {})) + if title_text == expected_title: + target_page = page + break + + if not target_page: + print( + f"Failure: Ticket with title '{expected_title}' was not found in 'IT Requests' database.", + file=sys.stderr, + ) + return False + + props = target_page.get("properties", {}) + + # ---------------------------------------------------------------------------------- + # Validate Priority property. + # ---------------------------------------------------------------------------------- + priority_value = props.get("Priority", {}).get("select", {}).get("name") + if priority_value != "High": + print( + f"Failure: Expected Priority 'High', found '{priority_value}'.", + file=sys.stderr, + ) + return False + + # ---------------------------------------------------------------------------------- + # Validate Due date property. + # ---------------------------------------------------------------------------------- + due_date_start = props.get("Due", {}).get("date", {}).get("start") + expected_due_iso = "2023-06-22" + if not due_date_start or not due_date_start.startswith(expected_due_iso): + print( + f"Failure: Expected Due date '{expected_due_iso}', found '{due_date_start}'.", + file=sys.stderr, + ) + return False + + # ---------------------------------------------------------------------------------- + # Validate the bulleted list contains the correct expired items in required format. + # ---------------------------------------------------------------------------------- + page_id = target_page["id"] + blocks = notion.blocks.children.list(block_id=page_id).get("results", []) + bullet_texts = [ + notion_utils.get_block_plain_text(b) + for b in blocks + if b.get("type") == "bulleted_list_item" + ] + + expected_items = { + "192371-8910/54": "Computer Accessory", + "32x11PIP": "Computer Accessory", + "76x87PCY": "Laptop", + "36x10PIQ": "Computer Accessory", + "65XYQ/GB": "License", + } + + if len(bullet_texts) != len(expected_items): + print( + f"Failure: Expected {len(expected_items)} bullet items, found {len(bullet_texts)}.", + file=sys.stderr, + ) + return False + + bullet_pattern = re.compile(r"^\s*(.*?)\s+-\s+(.*?)\s+-\s+(.+?)\s*$") + matched = set() + for text in bullet_texts: + m = bullet_pattern.match(text) + if not m: + print( + f"Failure: Bullet item '{text}' does not follow ' - - ' format.", + file=sys.stderr, + ) + return False + serial, tag, advice = m.group(1).strip(), m.group(2).strip(), m.group(3).strip() + if serial not in expected_items: + print( + f"Failure: Unexpected Serial '{serial}' found in bullet list.", + file=sys.stderr, + ) + return False + if expected_items[serial] != tag: + print( + f"Failure: Serial '{serial}' expected tag '{expected_items[serial]}', found '{tag}'.", + file=sys.stderr, + ) + return False + if not advice: + print( + f"Failure: Bullet item for Serial '{serial}' is missing a recommendation/advice.", + file=sys.stderr, + ) + return False + matched.add(serial) + + if len(matched) != len(expected_items): + missing = set(expected_items.keys()) - matched + print( + f"Failure: Missing bullet items for serials: {', '.join(missing)}.", + file=sys.stderr, + ) + return False + + print("Success: All verification criteria satisfied.") + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/description.md b/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/description.md new file mode 100644 index 0000000000000000000000000000000000000000..42821c7e6be3482fcf20f543f278d3213dedf196 --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/description.md @@ -0,0 +1,17 @@ +**Task Overview** + +My IT knowledge base contains pages whose verification status has expired: + +**Task Requirements** +1. Locate the database named **"IT Homepage"** inside the main page **"It Trouble Shooting Hub"**. +2. Within that database, find every page (except for **"It Inventory"**) where the **Verification** property state contains `expired`. +3. For **each** expired page: + • Insert a **callout block** at the very top (as the first child block) whose rich-text content is: + `VERIFICATION EXPIRED - This page needs review and re-verification` + • Set the callout’s icon to ⚠️. + • Set the callout’s colour to `red_background`. +4. Create a new entry in the **"IT Requests"** database with: + • Title (property **Task name**) **exactly** `Batch Verification Update Required`. + • **Priority** set to `High`. + • **Status** set to `In progress`. + • In the page body add a **bulleted list** where each bullet is a **mention** of the page processed in step 3 (i.e., use the Notion mention object linking to that page). \ No newline at end of file diff --git a/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/meta.json b/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..a4602df2709acbe5915a2ef4f120346d6f9453a8 --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/meta.json @@ -0,0 +1,26 @@ +{ + "task_id": "verification_expired_update", + "task_name": "Verification Expired Update", + "category_id": "it_trouble_shooting_hub", + "category_name": "IT Trouble Shooting Hub", + "description": "Update pages with expired verification status by adding warning callouts and creating a batch update request.", + "author": "Zijian Wu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "conditional filtering", + "visual formatting", + "database manipulation", + "cross-reference linking", + "status tracking" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/verify.py b/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..8ac5d898f29d967c250138943703974db1c7acad --- /dev/null +++ b/tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/verify.py @@ -0,0 +1,195 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +CALL_OUT_TEXT = "VERIFICATION EXPIRED - This page needs review and re-verification" +CALL_OUT_ICON = "⚠️" +CALL_OUT_COLOR = "red_background" +IT_HOMEPAGE_DB_TITLE = "IT Homepage" +IT_REQUESTS_DB_TITLE = "IT Requests" +REQUEST_TITLE = "Batch Verification Update Required" +PRIORITY_HIGH = "High" +STATUS_IN_PROGRESS = "In progress" + + +def _get_main_page_id(notion: Client, main_id: str | None) -> str | None: + """Resolve the main page id starting from CLI arg or by title search.""" + if main_id: + found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if found_id and obj_type == "page": + return found_id + # Fallback to title search (case-insensitive) + return notion_utils.find_page(notion, "It Trouble Shooting Hub") + + +def _fetch_database_id( + notion: Client, parent_page_id: str, db_title: str +) -> str | None: + """Locate a child database by title inside a given page.""" + return notion_utils.find_database_in_block(notion, parent_page_id, db_title) + + +def _expired_pages(notion: Client, db_id: str) -> list[dict]: + """Return list of page objects with Verification.state == 'expired'.""" + # Query all pages (API max 100 per call). If many pages expected, iterate. + results = notion.databases.query(database_id=db_id).get("results", []) + expired = [] + for page in results: + verification_prop = page.get("properties", {}).get("Verification", {}) + state = verification_prop.get("verification", {}).get("state") + # Skip the IT Inventory database entry + title_prop = page.get("properties", {}).get("Page", {}).get("title", []) + title_text = title_prop[0].get("plain_text") if title_prop else "" + if title_text.strip().lower() == "it inventory": + continue + + if state and "expired" in state.lower(): + expired.append(page) + return expired + + +def _check_callout_present(notion: Client, page_id: str) -> bool: + """Verify the specified callout is the first child block of the page.""" + children = notion.blocks.children.list(block_id=page_id, page_size=1).get( + "results", [] + ) + if not children: + return False + first_block = children[0] + if first_block.get("type") != "callout": + return False + data = first_block.get("callout", {}) + # Check color + if data.get("color") != CALL_OUT_COLOR: + return False + + # Check icon + icon = data.get("icon", {}) + if icon.get("type") != "emoji" or icon.get("emoji") != CALL_OUT_ICON: + return False + + # Check text content (callout rich text plain text) + plain_text = notion_utils.get_block_plain_text(first_block) + return CALL_OUT_TEXT in plain_text + + +def _find_request_page(notion: Client, db_id: str) -> dict | None: + """Find the IT Request page with the expected title.""" + # Use a simple search inside database + res = notion.databases.query( + database_id=db_id, + filter={"property": "Task name", "title": {"equals": REQUEST_TITLE}}, + ).get("results", []) + return res[0] if res else None + + +def _check_request_properties(page: dict) -> bool: + props = page.get("properties", {}) + priority = props.get("Priority", {}).get("select", {}).get("name") + status = ( + props.get("Status", {}).get("status", {}).get("name") + if props.get("Status", {}).get("status") + else props.get("Status", {}).get("select", {}).get("name") + ) + return priority == PRIORITY_HIGH and status == STATUS_IN_PROGRESS + + +def _request_page_contains_mentions( + notion: Client, request_page_id: str, expected_page_ids: list[str] +) -> bool: + children = notion.blocks.children.list(block_id=request_page_id, page_size=100).get( + "results", [] + ) + bullet_blocks = [b for b in children if b.get("type") == "bulleted_list_item"] + mentioned_ids: set[str] = set() + for block in bullet_blocks: + rich_text = block.get("bulleted_list_item", {}).get("rich_text", []) + for rt in rich_text: + if rt.get("type") == "mention": + mention = rt.get("mention", {}) + if mention.get("type") == "page": + mentioned_ids.add(mention.get("page", {}).get("id")) + if len(mentioned_ids) < len(expected_page_ids): + return False + return all(pid in mentioned_ids for pid in expected_page_ids) + + +def verify(notion: Client, main_id: str | None = None) -> bool: + main_page_id = _get_main_page_id(notion, main_id) + if not main_page_id: + print( + "Error: Could not locate the main page 'It Trouble Shooting Hub'.", + file=sys.stderr, + ) + return False + + # Locate required databases + it_home_db_id = _fetch_database_id(notion, main_page_id, IT_HOMEPAGE_DB_TITLE) + it_req_db_id = _fetch_database_id(notion, main_page_id, IT_REQUESTS_DB_TITLE) + if not all([it_home_db_id, it_req_db_id]): + print( + "Error: Required databases not found under the main page.", file=sys.stderr + ) + return False + + # Identify expired pages + expired_pages = _expired_pages(notion, it_home_db_id) + if not expired_pages: + print( + "Failure: No expired pages found; expected at least one for this task.", + file=sys.stderr, + ) + return False + + # Verify callout on each expired page + for pg in expired_pages: + pid = pg["id"] + if not _check_callout_present(notion, pid): + print( + f"Failure: Callout missing or incorrect on page {pid}.", file=sys.stderr + ) + return False + + # Verify IT Request entry + request_page = _find_request_page(notion, it_req_db_id) + if not request_page: + print( + "Failure: IT Request 'Batch Verification Update Required' not found.", + file=sys.stderr, + ) + return False + if not _check_request_properties(request_page): + print("Failure: Priority or Status incorrect on IT Request.", file=sys.stderr) + return False + + # Verify bullet list in IT Request body + expired_titles = [] + for p in expired_pages: + title_prop = p.get("properties", {}).get("Page", {}).get("title", []) + title_text = title_prop[0].get("plain_text") if title_prop else None + if title_text: + expired_titles.append(title_text) + expected_page_ids = [p["id"] for p in expired_pages] + if not _request_page_contains_mentions( + notion, request_page["id"], expected_page_ids + ): + print( + "Failure: IT Request body does not contain mentions for all affected pages.", + file=sys.stderr, + ) + return False + + print("Success: All verification checks passed.") + return True + + +def main(): + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/description.md b/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/description.md new file mode 100644 index 0000000000000000000000000000000000000000..af7baa11d4f13ddab2c7b28d0adeb872e7f570f8 --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/description.md @@ -0,0 +1,14 @@ +Create a comprehensive daily itinerary overview page to help organize my Japan travel plans. I need you to create a new page called 'Daily Itinerary Overview' as a child of the main Japan Travel Planner page. + +**Task Requirements:** +1. Create a new page titled 'Daily Itinerary Overview' as a child page of the main Japan Travel Planner page +2. Query the Travel Itinerary database to retrieve all activities +3. Structure the page with the following specific format: + - Add a heading_1 block with text "📅 Daily Itinerary Overview" + - Add a heading_2 block with text "📊 Trip Summary" + - Under Trip Summary, add a paragraph listing the total number of visited activities + - Create heading_2 blocks for "🌅 Day 1", "🌆 Day 2", and "🌃 Day 3" + - Under each day heading, list the activities scheduled for that day in to do list + - Each activity (use To-do list) should show: Activity Name - City (if available), for example, "Osaka Castle - Osaka". Check it if it's visited. +4. The summary paragraph must contain the exact text "Total activities visited (from Day 1 to Day 3): [NUMBER]" where [NUMBER] is the actual count. +5. Ensure all headings use the exact emoji and text format specified above \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/meta.json b/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..68740f0a7e8385700cf321ececb3e733b715be8f --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "daily_itinerary_overview", + "task_name": "Daily Itinerary Overview", + "category_id": "japan_travel_planner", + "category_name": "Japan Travel Planner", + "description": "Create a comprehensive daily itinerary overview page to organize Japan travel plans with structured day-by-day activities.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "database manipulation", + "data aggregation", + "report generation", + "visual formatting", + "status tracking" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Japan-Travel-Planner-23181626b6d781c4b6bedb12786b5abe" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/verify.py b/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..bc27d7f8202dcb650bbac05a7940e677a035f4e1 --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/verify.py @@ -0,0 +1,373 @@ +import sys +import re +from notion_client import Client +from tasks.utils import notion_utils + + +def verify_todo_database_correspondence(all_blocks, activities_by_day, _): + """ + Verify that to-do items in the overview page correspond exactly to database activities. + """ + # Extract to-do items organized by day from the overview page + todos_by_day = {"Day 1": [], "Day 2": [], "Day 3": []} + current_day = None + checked_todos_count = 0 + + for block in all_blocks: + block_type = block.get("type") + block_text = notion_utils.get_block_plain_text(block) + + # Track which day section we're in + if block_type == "heading_2": + if "🌅 Day 1" in block_text: + current_day = "Day 1" + elif "🌆 Day 2" in block_text: + current_day = "Day 2" + elif "🌃 Day 3" in block_text: + current_day = "Day 3" + else: + current_day = None # Reset for non-day headings + + # Collect to-do items under day headings + elif block_type == "to_do" and current_day: + to_do_data = block.get("to_do", {}) + is_checked = to_do_data.get("checked", False) + + if is_checked: + checked_todos_count += 1 + + todos_by_day[current_day].append( + {"text": block_text, "checked": is_checked} + ) + + # Verify each day's activities match + for day in ["Day 1", "Day 2", "Day 3"]: + db_activities = activities_by_day[day] + page_todos = todos_by_day[day] + + # Check if counts match + if len(db_activities) != len(page_todos): + print( + f"Error: {day} activity count mismatch. Database has {len(db_activities)} activities, page has {len(page_todos)} to-dos.", + file=sys.stderr, + ) + return False + + # Verify each database activity has corresponding to-do + for db_activity in db_activities: + expected_format = f"{db_activity['name']}" + if db_activity["city"]: + expected_format += f" - {db_activity['city']}" + + # Find matching to-do item + matching_todo = None + for todo in page_todos: + if ( + expected_format in todo["text"] + or db_activity["name"] in todo["text"] + ): + matching_todo = todo + break + + if not matching_todo: + print( + f"Error: {day} - Database activity '{expected_format}' not found in to-do list.", + file=sys.stderr, + ) + return False + + # Verify checked status matches visited status + if db_activity["visited"] != matching_todo["checked"]: + status_desc = "checked" if db_activity["visited"] else "unchecked" + actual_desc = "checked" if matching_todo["checked"] else "unchecked" + print( + f"Error: {day} - Activity '{db_activity['name']}' should be {status_desc} but is {actual_desc}.", + file=sys.stderr, + ) + return False + + # Verify summary count matches checked to-dos + for block in all_blocks: + if block.get("type") == "paragraph": + block_text = notion_utils.get_block_plain_text(block) + if "Total activities visited (from Day 1 to Day 3): 8" in block_text: + print( + f"Success: Daily Itinerary Overview page created with correct structure. All {checked_todos_count} visited activities match database." + ) + return True + + print( + f"Error: Summary shows incorrect visited activity count. Expected: {checked_todos_count} (based on checked to-do items)", + file=sys.stderr, + ) + return False + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the Daily Itinerary Overview page has been created correctly. + """ + # Find the main Japan Travel Planner page + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Japan Travel Planner") + if not page_id: + print("Error: Main 'Japan Travel Planner' page not found.", file=sys.stderr) + return False + + # Find the Daily Itinerary Overview child page + overview_page_id = None + try: + # Get all child pages of the main page + response = notion.search( + query="Daily Itinerary Overview", + filter={"property": "object", "value": "page"}, + ) + + for result in response.get("results", []): + # Check if this page is a child of the main page + parent = result.get("parent", {}) + if parent.get("type") == "page_id" and parent.get("page_id") == page_id: + overview_page_id = result["id"] + break + + if not overview_page_id: + # Alternative method: check page title directly + for result in response.get("results", []): + title_list = ( + result.get("properties", {}).get("title", {}).get("title", []) + ) + for title_obj in title_list: + if "Daily Itinerary Overview" in title_obj.get("plain_text", ""): + overview_page_id = result["id"] + break + if overview_page_id: + break + + except Exception as e: + print( + f"Error searching for Daily Itinerary Overview page: {e}", file=sys.stderr + ) + return False + + if not overview_page_id: + print( + "Error: 'Daily Itinerary Overview' page not found as child of main page.", + file=sys.stderr, + ) + return False + + # Get all blocks from the overview page + all_blocks = notion_utils.get_all_blocks_recursively(notion, overview_page_id) + + # Required content to verify - must appear in this exact order + required_headings_sequence = [ + ("📅 Daily Itinerary Overview", "heading_1"), + ("📊 Trip Summary", "heading_2"), + ("🌅 Day 1", "heading_2"), + ("🌆 Day 2", "heading_2"), + ("🌃 Day 3", "heading_2"), + ] + + found_headings_in_order = [] + found_summary = False + summary_has_correct_format = False + found_todo_items = False + + # Check each block and track heading sequence + for block in all_blocks: + block_text = notion_utils.get_block_plain_text(block) + block_type = block.get("type") + + # Check for required headings in sequence + for heading_text, expected_type in required_headings_sequence: + if heading_text in block_text and block_type == expected_type: + found_headings_in_order.append((heading_text, expected_type)) + + # Check for trip summary paragraph + if ( + block_type == "paragraph" + and "Total activities visited (from Day 1 to Day 3):" in block_text + ): + found_summary = True + # Check if the format is correct (contains a number) + if re.search( + r"Total activities visited \(from Day 1 to Day 3\):\s*\d+", block_text + ): + summary_has_correct_format = True + + # Check for to-do list items (activities under day headings) + if block_type == "to_do": + found_todo_items = True + # Check if to-do items follow the format "Activity Name - City" + if " - " in block_text: + # Format appears to be correct (contains dash separator) + pass + + # Verify all required headings are found in correct sequence + if len(found_headings_in_order) != len(required_headings_sequence): + missing_headings = [] + for heading_text, heading_type in required_headings_sequence: + if (heading_text, heading_type) not in found_headings_in_order: + missing_headings.append(f"{heading_text} ({heading_type})") + print(f"Error: Missing required headings: {missing_headings}", file=sys.stderr) + return False + + # Verify headings appear in correct order + for i, (found_heading, found_type) in enumerate(found_headings_in_order): + expected_heading, expected_type = required_headings_sequence[i] + if found_heading != expected_heading or found_type != expected_type: + print( + f"Error: Headings not in correct order. Expected '{expected_heading}' ({expected_type}) at position {i + 1}, but found '{found_heading}' ({found_type})", + file=sys.stderr, + ) + return False + + # Verify trip summary exists and has correct format + if not found_summary: + print( + "Error: Trip summary paragraph with 'Total activities visite' not found.", + file=sys.stderr, + ) + return False + + if not summary_has_correct_format: + print( + "Error: Trip summary does not have correct format 'Total activities visited: [NUMBER]'.", + file=sys.stderr, + ) + return False + + # Verify to-do list items exist (activities should be in to-do format) + if not found_todo_items: + print( + "Error: No to-do list items found. Activities should be listed as to-do items under day headings.", + file=sys.stderr, + ) + return False + + # Additional verification: Check if Travel Itinerary database exists and has data + try: + itinerary_db_id = notion_utils.find_database_in_block( + notion, page_id, "Travel Itinerary" + ) + if not itinerary_db_id: + itinerary_db_id = notion_utils.find_database(notion, "Travel Itinerary") + + if itinerary_db_id: + # Query the database to get all activities + db_response = notion.databases.query(database_id=itinerary_db_id) + db_activities = db_response.get("results", []) + + # Organize database activities by day + activities_by_day = {"Day 1": [], "Day 2": [], "Day 3": []} + visited_count = 0 + + for result in db_activities: + properties = result.get("properties", {}) + + # Extract activity info + activity_info = {"name": "", "city": "", "visited": False, "day": None} + + for prop_name, prop_value in properties.items(): + prop_type = prop_value.get("type") + + # Get activity name (usually from title property) + if prop_type == "title" and prop_value.get("title"): + activity_info["name"] = prop_value["title"][0]["plain_text"] + + # Get city info + elif "city" in prop_name.lower() and prop_type in [ + "rich_text", + "select", + ]: + if prop_type == "rich_text" and prop_value.get("rich_text"): + activity_info["city"] = prop_value["rich_text"][0][ + "plain_text" + ] + elif prop_type == "select" and prop_value.get("select"): + activity_info["city"] = prop_value["select"]["name"] + + # Get visited status + elif prop_type == "checkbox": + if prop_value.get("checkbox"): + activity_info["visited"] = True + visited_count += 1 + + # Get day info + elif "day" in prop_name.lower() and prop_type in [ + "select", + "rich_text", + ]: + if prop_type == "select" and prop_value.get("select"): + day_value = prop_value["select"]["name"] + if day_value in activities_by_day: + activity_info["day"] = day_value + elif prop_type == "rich_text" and prop_value.get("rich_text"): + day_value = prop_value["rich_text"][0]["plain_text"] + if day_value in activities_by_day: + activity_info["day"] = day_value + + # Add to appropriate day if day is specified + if activity_info["day"] and activity_info["name"]: + activities_by_day[activity_info["day"]].append(activity_info) + + # Now verify to-do items match database activities + return verify_todo_database_correspondence( + all_blocks, activities_by_day, visited_count + ) + else: + print( + "Warning: Travel Itinerary database not found, using to-do items for count verification." + ) + # Count checked to-do items in the overview page even without database + checked_todos_count = 0 + for block in all_blocks: + if block.get("type") == "to_do": + to_do_data = block.get("to_do", {}) + if to_do_data.get("checked", False): + checked_todos_count += 1 + + # Verify the summary shows the correct visited count based on checked to-dos + for block in all_blocks: + if block.get("type") == "paragraph": + block_text = notion_utils.get_block_plain_text(block) + if f"Total activities visited: {checked_todos_count}" in block_text: + print( + f"Success: Daily Itinerary Overview page created with correct structure and {checked_todos_count} visited activities." + ) + return True + + print( + f"Error: Summary shows incorrect visited activity count. Expected: {checked_todos_count} (based on checked to-do items)", + file=sys.stderr, + ) + return False + + except Exception as e: + print(f"Warning: Could not verify activity count: {e}") + print("Success: Daily Itinerary Overview page created with correct structure.") + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/japan_travel_planner/packing_progress_summary/description.md b/tasks/notion/standard/japan_travel_planner/packing_progress_summary/description.md new file mode 100644 index 0000000000000000000000000000000000000000..cd77f30b68e4eeeb2d36119dca6850cd3d3c41e4 --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/packing_progress_summary/description.md @@ -0,0 +1,12 @@ +I'm preparing for my Japan trip and need to organize my packing list. Please help me: + +**Step 1: Update Items in the Packing List Database** +In the Clothes category, all items have already been packed except for the hat After this, check the `SIM Card` entry and the `Wallet` entry. + +**Step 2: Create Packing Progress Summary** +After adding the items, create a new section in the main Japan Travel Planner page immediately after the "Packing List 💼" heading. This section should contain: + +1. A paragraph block with the bold text "**Packing Progress Summary**" +2. Followed by bullet list items showing statistics for each category in the format: + - "Category: X/Y packed" (where X is packed items, Y is total items), for example: "Shoes: 2/10 packed" + - ... \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/packing_progress_summary/meta.json b/tasks/notion/standard/japan_travel_planner/packing_progress_summary/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..5e4b6d49c88fe64148bedec0ff9843cc680d38a9 --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/packing_progress_summary/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "packing_progress_summary", + "task_name": "Packing Progress Summary", + "category_id": "japan_travel_planner", + "category_name": "Japan Travel Planner", + "description": "Update packing list items and create a progress summary section showing statistics for each category.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "database manipulation", + "data aggregation", + "report generation", + "status tracking" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Japan-Travel-Planner-23181626b6d781c4b6bedb12786b5abe", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/japantravelplanner101" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/packing_progress_summary/verify.py b/tasks/notion/standard/japan_travel_planner/packing_progress_summary/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..d45690f21207ce9a14ad080244a266b00041dd7b --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/packing_progress_summary/verify.py @@ -0,0 +1,279 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that: + 1. All Clothes items except hat are marked as packed + 2. SIM Card and Wallet entries are checked + 3. Packing Progress Summary section is created with statistics + """ + # Find the main Japan Travel Planner page + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Japan Travel Planner") + if not page_id: + print("Error: Page 'Japan Travel Planner' not found.", file=sys.stderr) + return False + + # Find the Packing List database + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + packing_list_db_id = None + packing_list_heading_id = None + + for i, block in enumerate(all_blocks): + # Find the Packing List heading + if block.get("type") == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + if "Packing List" in heading_text and "💼" in heading_text: + packing_list_heading_id = block["id"] + # Look for the database after this heading + for j in range(i + 1, len(all_blocks)): + if all_blocks[j].get("type") == "child_database": + packing_list_db_id = all_blocks[j]["id"] + break + break + + if not packing_list_db_id: + print("Error: Packing List database not found.", file=sys.stderr) + return False + + # Query the database for all items + try: + db_items = notion.databases.query(database_id=packing_list_db_id) + + # Track items for verification + clothes_items = [] + sim_card_found = False + sim_card_packed = False + wallet_found = False + wallet_packed = False + + # Process all items + for page in db_items.get("results", []): + props = page.get("properties", {}) + + # Get item name + name_prop = props.get("Name", {}) + if name_prop.get("type") == "title": + name = "".join( + [t.get("plain_text", "") for t in name_prop.get("title", [])] + ) + else: + continue + + # Get type (multi_select) + type_prop = props.get("Type", {}) + types = [] + if type_prop.get("type") == "multi_select": + types = [ + opt.get("name", "") for opt in type_prop.get("multi_select", []) + ] + + # Get packed status + packed_prop = props.get("Packed", {}) + packed = False + if packed_prop.get("type") == "checkbox": + packed = packed_prop.get("checkbox", False) + + # Check specific items + if name == "SIM Card": + sim_card_found = True + sim_card_packed = packed + elif name == "Wallet": + wallet_found = True + wallet_packed = packed + + # Track Clothes items + if "Clothes" in types: + clothes_items.append( + {"name": name, "packed": packed, "is_hat": "hat" in name.lower()} + ) + + # Verify Clothes items (all packed except hat) + for item in clothes_items: + if item["is_hat"]: + if item["packed"]: + print( + "Error: Hat should not be packed but is marked as packed.", + file=sys.stderr, + ) + return False + else: + if not item["packed"]: + print( + f"Error: Clothes item '{item['name']}' should be packed but is not.", + file=sys.stderr, + ) + return False + + print("Success: All Clothes items are correctly marked (packed except hat).") + + # Verify SIM Card and Wallet + if not sim_card_found: + print("Error: SIM Card entry not found.", file=sys.stderr) + return False + if not sim_card_packed: + print("Error: SIM Card entry is not checked (packed).", file=sys.stderr) + return False + + if not wallet_found: + print("Error: Wallet entry not found.", file=sys.stderr) + return False + if not wallet_packed: + print("Error: Wallet entry is not checked (packed).", file=sys.stderr) + return False + + print("Success: SIM Card and Wallet entries are checked.") + + except Exception as e: + print(f"Error querying Packing List database: {e}", file=sys.stderr) + return False + + # Expected ground truth statistics + expected_stats = { + "Clothes": {"packed": 12, "total": 13}, + "Electronics": {"packed": 1, "total": 10}, + "Essentials": {"packed": 1, "total": 12}, + "Miscellaneous": {"packed": 0, "total": 10}, + "Shoes": {"packed": 0, "total": 2}, + "Toiletries": {"packed": 0, "total": 19}, + } + + # Verify Packing Progress Summary section + # Re-fetch blocks to get updated content + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + # Find the Packing List heading again and check blocks after it + packing_heading_index = None + for i, block in enumerate(all_blocks): + if block.get("id") == packing_list_heading_id: + packing_heading_index = i + break + + summary_found = False + statistics_verified = True + found_statistics = {} + + if packing_heading_index is not None: + # Look for summary in the next few blocks + for i in range( + packing_heading_index + 1, min(packing_heading_index + 15, len(all_blocks)) + ): + block = all_blocks[i] + block_text = notion_utils.get_block_plain_text(block) + + # Check for "Packing Progress Summary" paragraph + if "Packing Progress Summary" in block_text: + summary_found = True + # Check if it's bold + if block.get("type") == "paragraph": + rich_text_list = block.get("paragraph", {}).get("rich_text", []) + for text_obj in rich_text_list: + if "Packing Progress Summary" in text_obj.get("text", {}).get( + "content", "" + ): + if not text_obj.get("annotations", {}).get("bold", False): + print( + "Error: 'Packing Progress Summary' text is not bold.", + file=sys.stderr, + ) + return False + + # Check for statistics bullet points in format "Category: X/Y packed" + if ( + block.get("type") == "bulleted_list_item" + and ":" in block_text + and "/" in block_text + and "packed" in block_text + ): + # Parse the statistic line + # Expected format: "Category: X/Y packed" + try: + parts = block_text.split(":") + if len(parts) >= 2: + category = parts[0].strip() + stats_part = parts[1].strip() + + # Extract X/Y from "X/Y packed" + if "/" in stats_part and "packed" in stats_part: + nums = stats_part.split("packed")[0].strip() + if "/" in nums: + x_str, y_str = nums.split("/") + x = int(x_str.strip()) + y = int(y_str.strip()) + found_statistics[category] = {"packed": x, "total": y} + except: + pass # Continue if parsing fails + + if not summary_found: + print( + "Error: 'Packing Progress Summary' section not found after Packing List heading.", + file=sys.stderr, + ) + return False + + if not found_statistics: + print( + "Error: No valid packing statistics bullet points found in format 'Category: X/Y packed'.", + file=sys.stderr, + ) + return False + + # Verify the statistics match the expected values + for category, stats in expected_stats.items(): + if category not in found_statistics: + print( + f"Error: Category '{category}' missing from Packing Progress Summary.", + file=sys.stderr, + ) + statistics_verified = False + else: + found = found_statistics[category] + if found["packed"] != stats["packed"] or found["total"] != stats["total"]: + print( + f"Error: Statistics mismatch for '{category}': expected {stats['packed']}/{stats['total']} packed, found {found['packed']}/{found['total']} packed.", + file=sys.stderr, + ) + statistics_verified = False + + # Check for extra categories in summary that don't exist in expected + for category in found_statistics: + if category not in expected_stats: + print( + f"Error: Unexpected category '{category}' in summary.", file=sys.stderr + ) + statistics_verified = False + + if not statistics_verified: + return False + + print("Success: Packing Progress Summary section created with correct statistics.") + # print(f"Verified statistics: {', '.join(f'{k}: {v['packed']}/{v['total']} packed' for k, v in expected_stats.items())}") + + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/description.md b/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/description.md new file mode 100644 index 0000000000000000000000000000000000000000..f6fcfc10b6443f134f42aa374e72e41983a64808 --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/description.md @@ -0,0 +1 @@ +Go to Japan Travel Planner and remove the itinerary in OSAKA after 6 PM (excluding 6 PM) in Day 1 and Day 2. \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/meta.json b/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..642f94e2bf081d104b049d16a7e2129e777099db --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/meta.json @@ -0,0 +1,23 @@ +{ + "task_id": "remove_osaka_itinerary", + "task_name": "Remove Osaka Itinerary", + "category_id": "japan_travel_planner", + "category_name": "Japan Travel Planner", + "description": "Remove the itinerary items in Osaka after 6 PM from Day 1 and Day 2 travel schedules.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "conditional filtering", + "automated migration" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Japan-Travel-Planner-23181626b6d781c4b6bedb12786b5abe", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/japantravelplanner101" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/verify.py b/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..8316e347a0ab02d22ce0bf913ab09dcf60873085 --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/remove_osaka_itinerary/verify.py @@ -0,0 +1,288 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +def get_page_title(page_result): + """Extract title from a page result""" + properties = page_result.get('properties', {}) + name_property = properties.get('Name', {}) + if name_property.get('type') == 'title': + title_array = name_property.get('title', []) + if title_array and len(title_array) > 0: + return title_array[0].get('plain_text', '') + return '' + +def get_page_time(page_result): + """Extract time from Notes field""" + properties = page_result.get('properties', {}) + notes_property = properties.get('Notes', {}) + if notes_property.get('type') == 'rich_text': + rich_text_array = notes_property.get('rich_text', []) + if rich_text_array and len(rich_text_array) > 0: + notes_text = rich_text_array[0].get('plain_text', '') + return notes_text.strip() + return '' + +def get_page_group(page_result): + """Extract group/location from page""" + properties = page_result.get('properties', {}) + group_property = properties.get('Group', {}) + if group_property.get('type') == 'select': + select = group_property.get('select') + if select: + return select.get('name', '') + return '' + +def get_page_day(page_result): + """Extract day from page""" + properties = page_result.get('properties', {}) + day_property = properties.get('Day', {}) + if day_property.get('type') == 'select': + select = day_property.get('select') + if select: + return select.get('name', '') + return '' + +def parse_time_to_minutes(time_str): + """Convert time string to minutes for comparison + Returns None if time cannot be parsed""" + if not time_str: + return None + + # Clean the time string + time_str = time_str.strip().upper() + + # Remove any text after the time (e.g., "7:30 PM\n" -> "7:30 PM") + time_str = time_str.split('\n')[0].strip() + + # Extract time components + try: + if 'PM' in time_str: + time_part = time_str.replace('PM', '').strip() + if ':' in time_part: + hours, minutes = time_part.split(':') + hours = int(hours) + minutes = int(minutes) + else: + hours = int(time_part) + minutes = 0 + # Convert PM hours (add 12 for PM times except 12 PM) + if hours != 12: + hours += 12 + return hours * 60 + minutes + elif 'AM' in time_str: + time_part = time_str.replace('AM', '').strip() + if ':' in time_part: + hours, minutes = time_part.split(':') + hours = int(hours) + minutes = int(minutes) + else: + hours = int(time_part) + minutes = 0 + # Handle 12 AM (midnight) + if hours == 12: + hours = 0 + return hours * 60 + minutes + except: + return None + + return None + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that all OSAKA events after 6PM have been removed from Day 1 and Day 2 in the Japan Travel Planner. + + Expected items that should be deleted (all in OSAKA, after 6PM, on Day 1 or Day 2): + 1. Rikuro's Namba Main Branch - 7 PM (Day 1) + 2. Shin Sekai "New World" - 8 PM (Day 2) + 3. Katsudon Chiyomatsu - 7:30 PM (Day 2) + 4. Ebisubashi Bridge - 9 PM (Day 1) + + Note: Kuromon Ichiba Market at 6 PM should NOT be deleted (it's at 6PM, not after) + Items after 6PM on other days (Day 3-8) should NOT be deleted + """ + + # Step 1: Find the main Japan Travel Planner page + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not found_id or object_type != 'page': + print("Error: Japan Travel Planner page not found.", file=sys.stderr) + return False + else: + # Try to find the page by searching + found_id = notion_utils.find_page(notion, "Japan Travel Planner") + if not found_id: + print("Error: Japan Travel Planner page not found.", file=sys.stderr) + return False + + print(f"Found Japan Travel Planner page: {found_id}") + + # Step 2: Find the Travel Itinerary database + all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id) + travel_itinerary_db_id = None + + for block in all_blocks: + if block and block.get("type") == "child_database": + title = block.get("child_database", {}).get("title", "") + if "Travel Itinerary" in title: + travel_itinerary_db_id = block.get("id") + print(f"Found Travel Itinerary database: {travel_itinerary_db_id}") + break + + if not travel_itinerary_db_id: + print("Error: Travel Itinerary database not found", file=sys.stderr) + return False + + # Step 3: Query the database for OSAKA items on Day 1 and Day 2 + try: + query_result = notion.databases.query( + database_id=travel_itinerary_db_id, + filter={ + "and": [ + {"property": "Group", "select": {"equals": "Osaka"}}, + {"or": [ + {"property": "Day", "select": {"equals": "Day 1"}}, + {"property": "Day", "select": {"equals": "Day 2"}} + ]} + ] + } + ) + except Exception as e: + print(f"Error querying Travel Itinerary database: {e}", file=sys.stderr) + return False + + # Step 4: Check for items that should have been deleted + six_pm_minutes = 18 * 60 # 6 PM in minutes (18:00) + + # Expected deleted items (4 specific items after 6 PM on Day 1 and Day 2) + expected_deleted = { + "Rikuro's Namba Main Branch": {"time": "7 PM", "day": "Day 1", "found": False}, + "Shin Sekai \"New World\"": {"time": "8 PM", "day": "Day 2", "found": False}, + "Katsudon Chiyomatsu": {"time": "7:30 PM", "day": "Day 2", "found": False}, + "Ebisubashi Bridge": {"time": "9 PM", "day": "Day 1", "found": False} + } + + # Items that should remain (at or before 6 PM) + expected_remaining = { + "Kuromon Ichiba Market": {"time": "6 PM", "found": False} + } + + osaka_items_after_6pm = [] + osaka_items_at_or_before_6pm = [] + + # Debug: Show total query results + print(f"Debug: Found {len(query_result.get('results', []))} total OSAKA items on Day 1 and Day 2") + + # Process all OSAKA items on Day 1 and Day 2 + for page in query_result.get('results', []): + page_title = get_page_title(page).strip() + page_time = get_page_time(page) + page_group = get_page_group(page) + page_day = get_page_day(page) + + if page_group != "Osaka": + continue + + # Parse time to check if after 6 PM + time_minutes = parse_time_to_minutes(page_time) + + if time_minutes is not None and time_minutes > six_pm_minutes: + osaka_items_after_6pm.append({ + "title": page_title, + "time": page_time, + "day": page_day, + "id": page.get('id') + }) + + # Check if this is one of the expected deleted items + for expected_title, expected_info in expected_deleted.items(): + # Clean up the titles for comparison + clean_page_title = page_title.strip().lower() + clean_expected_title = expected_title.strip().lower() + + # Check for "Rikuro's" or "Rikuro's" (different apostrophe types) + if "rikuro" in clean_page_title and "rikuro" in clean_expected_title: + title_match = True + elif clean_page_title == clean_expected_title: + title_match = True + elif clean_expected_title in clean_page_title or clean_page_title in clean_expected_title: + title_match = True + else: + title_match = False + + if title_match and page_day == expected_info["day"]: + print(f"Debug: Found '{page_title}' on {page_day} at {page_time} - matches expected '{expected_title}'") + expected_deleted[expected_title]["found"] = True + + elif time_minutes is not None and time_minutes <= six_pm_minutes: + osaka_items_at_or_before_6pm.append({ + "title": page_title, + "time": page_time, + "day": page_day, + "id": page.get('id') + }) + + # Check if this is one of the expected remaining items + for expected_title in expected_remaining: + if expected_title.lower() in page_title.lower() or page_title.lower() in expected_title.lower(): + expected_remaining[expected_title]["found"] = True + + # Step 5: Verify results + print(f"\nVerification Summary:") + print(f"=" * 50) + + all_passed = True + + # Check that the 4 expected items after 6 PM have been deleted + print("\n4 Items that should be deleted (after 6 PM on Day 1 and Day 2):") + for item_name, item_info in expected_deleted.items(): + if item_info["found"]: + # If found = True, it means the item still exists (was not deleted) + print(f"✗ {item_name} ({item_info['day']}, {item_info['time']}) - Still exists, should be deleted", file=sys.stderr) + all_passed = False + else: + # If found = False, it means the item was deleted correctly + print(f"✓ {item_name} ({item_info['day']}, {item_info['time']}) - Correctly deleted") + + + # Check that items at or before 6 PM remain + print("\nItems that should remain (at or before 6 PM on Day 1 and Day 2):") + for item_name, item_info in expected_remaining.items(): + if item_info["found"]: + print(f"✓ {item_name} ({item_info['time']}) - Correctly retained") + else: + print(f"✗ {item_name} ({item_info['time']}) - Missing, should not be deleted", file=sys.stderr) + all_passed = False + + # Report any items after 6 PM that still exist + if osaka_items_after_6pm: + print(f"\n✗ Found {len(osaka_items_after_6pm)} OSAKA item(s) after 6 PM on Day 1/Day 2:", file=sys.stderr) + for item in osaka_items_after_6pm: + print(f" - {item['title']} at {item['time']} ({item['day']})", file=sys.stderr) + else: + print(f"\n✓ No OSAKA items found after 6 PM on Day 1/Day 2 (all correctly deleted)") + + # Report count summary + print(f"\nCount Summary:") + print(f"- OSAKA items after 6 PM on Day 1/Day 2 found: {len(osaka_items_after_6pm)} (should be 0)") + print(f"- OSAKA items at/before 6 PM on Day 1/Day 2 found: {len(osaka_items_at_or_before_6pm)}") + print(f"- Expected deletions verified: {sum(1 for item in expected_deleted.values() if not item['found'])}/4") + + return all_passed + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + + if verify(notion, main_id): + print("\nVerification passed: All 4 required OSAKA events after 6 PM on Day 1 and Day 2 have been removed") + sys.exit(0) + else: + print("\nVerification failed: Some OSAKA events after 6 PM on Day 1/Day 2 still exist") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/description.md b/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/description.md new file mode 100644 index 0000000000000000000000000000000000000000..4c095262902329737f2f6bbf3b7de10215cca6eb --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/description.md @@ -0,0 +1 @@ +Please find the restaurants that appear in Day 1 of the Travel Itinerary database, then create corresponding entries in the Expenses database, one restaurant per entry. Set the date uniformly to Jan 1, 2025, and the cost uniformly to $120. Display the restaurant name in the Expense field. Set Category to Dining. For Comment, use the Description from the corresponding restaurant page. Leave other properties empty. \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/meta.json b/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..6b6aa070c091251acf21ccb3e56b298cdca40ede --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "restaurant_expenses_sync", + "task_name": "Restaurant Expenses Sync", + "category_id": "japan_travel_planner", + "category_name": "Japan Travel Planner", + "description": "Find restaurants from Day 1 Travel Itinerary and create corresponding entries in the Expenses database.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "conditional filtering", + "database manipulation", + "cross-reference linking", + "template population" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Japan-Travel-Planner-23181626b6d781c4b6bedb12786b5abe", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/japantravelplanner101" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/verify.py b/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..a4bd93d0268d668e8f8aa25dec2036b50c2731f8 --- /dev/null +++ b/tasks/notion/standard/japan_travel_planner/restaurant_expenses_sync/verify.py @@ -0,0 +1,197 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that restaurants from Day 1 of Travel Itinerary have corresponding expense entries. + """ + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Japan Travel Planner") + if not page_id: + print("Error: Page 'Japan Travel Planner' not found.", file=sys.stderr) + return False + + # Find Travel Itinerary database + itinerary_db_id = notion_utils.find_database_in_block( + notion, page_id, "Travel Itinerary" + ) + if not itinerary_db_id: + print("Error: Database 'Travel Itinerary' not found.", file=sys.stderr) + return False + + # Find Expenses database + expenses_db_id = notion_utils.find_database_in_block(notion, page_id, "Expenses") + if not expenses_db_id: + print("Error: Database 'Expenses' not found.", file=sys.stderr) + return False + + # Find Japan Places to Visit database + places_db_id = notion_utils.find_database_in_block( + notion, page_id, "Travel Itinerary" + ) + if not places_db_id: + print("Error: Database 'Japan Places to Visit' not found.", file=sys.stderr) + return False + + # Query Day 1 restaurants from Travel Itinerary + try: + itinerary_results = notion.databases.query( + database_id=itinerary_db_id, + filter={ + "and": [ + {"property": "Day", "select": {"equals": "Day 1"}}, + {"property": "Type", "multi_select": {"contains": "Food"}}, + ] + }, + ).get("results", []) + except Exception as e: + print(f"Error querying Travel Itinerary database: {e}", file=sys.stderr) + return False + + if not itinerary_results: + print( + "Error: No restaurants found for Day 1 in Travel Itinerary.", + file=sys.stderr, + ) + return False + + # Extract restaurant names + restaurant_names = [] + for entry in itinerary_results: + props = entry.get("properties", {}) + name_prop = props.get("Name", {}) + name_text = "".join(t.get("plain_text", "") for t in name_prop.get("title", [])) + if name_text: + restaurant_names.append(name_text.strip()) + + if not restaurant_names: + print("Error: No restaurant names found in Day 1 entries.", file=sys.stderr) + return False + + # Get descriptions from Japan Places to Visit database + try: + places_results = notion.databases.query(database_id=places_db_id).get( + "results", [] + ) + except Exception as e: + print(f"Error querying Japan Places to Visit database: {e}", file=sys.stderr) + return False + + # Create a map of restaurant names to descriptions + restaurant_descriptions = {} + for place in places_results: + props = place.get("properties", {}) + name_prop = props.get("Name", {}) + name_text = "".join(t.get("plain_text", "") for t in name_prop.get("title", [])) + + desc_prop = props.get("Description", {}) + desc_text = "".join( + t.get("plain_text", "") for t in desc_prop.get("rich_text", []) + ) + + if name_text and desc_text: + restaurant_descriptions[name_text.strip()] = desc_text.strip() + + # Query Expenses database + try: + expenses_results = notion.databases.query(database_id=expenses_db_id).get( + "results", [] + ) + except Exception as e: + print(f"Error querying Expenses database: {e}", file=sys.stderr) + return False + + # Verify each restaurant has a corresponding expense entry + verified_restaurants = [] + for restaurant_name in restaurant_names: + found_matching_expense = False + expected_description = restaurant_descriptions.get(restaurant_name, "") + + for expense in expenses_results: + props = expense.get("properties", {}) + + # Check Expense field (title) + expense_prop = props.get("Expense", {}) + expense_text = "".join( + t.get("plain_text", "") for t in expense_prop.get("title", []) + ) + if expense_text.strip() != restaurant_name: + continue + + # Check Date + date_prop = props.get("Date", {}) + date_start = date_prop.get("date", {}).get("start") + if date_start != "2025-01-01": + continue + + # Check Transaction Amount + amount_prop = props.get("Transaction Amount", {}) + amount = amount_prop.get("number") + if amount != 120: + continue + + # Check Category contains Dining + category_prop = props.get("Category", {}) + categories = [c.get("name") for c in category_prop.get("multi_select", [])] + if "Dining" not in categories: + continue + + # Check Comment matches description (if description exists) + if expected_description: + comment_prop = props.get("Comment", {}) + comment_text = "".join( + t.get("plain_text", "") for t in comment_prop.get("rich_text", []) + ) + if comment_text.strip().replace( + "\u202f", " " + ) != expected_description.replace("\u202f", " "): + continue + + found_matching_expense = True + verified_restaurants.append(restaurant_name) + break + + if not found_matching_expense: + print( + f"Error: No matching expense entry found for restaurant '{restaurant_name}'.", + file=sys.stderr, + ) + return False + + if len(verified_restaurants) == len(restaurant_names): + print( + f"Success: Found matching expense entries for all {len(restaurant_names)} Day 1 restaurants." + ) + return True + else: + print( + f"Error: Only {len(verified_restaurants)} out of {len(restaurant_names)} restaurants have matching expense entries.", + file=sys.stderr, + ) + return False + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/online_resume/layout_adjustment/description.md b/tasks/notion/standard/online_resume/layout_adjustment/description.md new file mode 100644 index 0000000000000000000000000000000000000000..3d1560ae8b0c16c83cb5be5fb71172ad2f0fdf0d --- /dev/null +++ b/tasks/notion/standard/online_resume/layout_adjustment/description.md @@ -0,0 +1,13 @@ +Please go to my Online Resume page and adjust the Skills display with the following requirements: + +## Skills Section Adjustment +1. Delete the Skills database from the right side of the page +2. Add a new Skills section on the left side, under the Languages section +3. Format skills as "[icon] skill description (type)", for example "✨✨ Photoshop (Design Tool)" + - Use ✨✨ icon for skills with level >= 50% + - Use ✨ icon for skills with level < 50% + +## Work History and Education Layout Adjustment +1. Adjust the layout so that logo/image columns take up 50% width in each section + - Note: Column width ratio might not be returned by API when columns are equal (50/50) +2. Replace all images/icons with black placeholder images using URL containing "https://singlecolorimage.com/get/000000/1024x128" \ No newline at end of file diff --git a/tasks/notion/standard/online_resume/layout_adjustment/meta.json b/tasks/notion/standard/online_resume/layout_adjustment/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..6b2075552c5e472786e7e23cad643a9ed3d0cd72 --- /dev/null +++ b/tasks/notion/standard/online_resume/layout_adjustment/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "layout_adjustment", + "task_name": "Layout Adjustment", + "category_id": "online_resume", + "category_name": "Online Resume", + "description": "This task involves modifying the layout and content of an online resume page by restructuring the Skills section with icon indicators and adjusting the Work History and Education sections to use equal column widths with placeholder images.", + "author": "Xiangyan Liu", + "created_at": "2025-08-14", + "difficulty": "L3", + "tags": [ + "content organization", + "visual formatting", + "conditional filtering", + "template population" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Online-Resume-23181626b6d781159faaeb5eadaf612e", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/online-resume" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/online_resume/layout_adjustment/verify.py b/tasks/notion/standard/online_resume/layout_adjustment/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..3d705b544180578075b27c3f130598ce9d0b7c89 --- /dev/null +++ b/tasks/notion/standard/online_resume/layout_adjustment/verify.py @@ -0,0 +1,331 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the Skills display has been adjusted correctly: + 1. Skills database on the right side should be deleted + 2. Skills section should be added on the left side under Languages + 3. Skills should be formatted with correct icons based on skill level + 4. Work History and Education sections should use black placeholder images + """ + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Online Resume") + if not page_id: + print("Error: Page 'Online Resume' not found.", file=sys.stderr) + return False + + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + # Step 1: Verify Skills database is NOT in the right column anymore + # Find the main column list + for block in all_blocks: + if block.get("type") == "column_list": + column_list_id = block["id"] + columns = notion_utils.get_all_blocks_recursively(notion, column_list_id) + + # Check if this is the main two-column layout + if len(columns) == 2: + # Find the right column (usually the one with larger width ratio) + for column in columns: + if column.get("type") == "column": + width_ratio = column.get("column", {}).get("width_ratio", 0) + # Right column typically has width_ratio > 0.5 + if width_ratio > 0.5: + right_column_id = column["id"] + right_column_blocks = notion_utils.get_all_blocks_recursively( + notion, right_column_id + ) + + # Check if Skills database exists in right column + for right_block in right_column_blocks: + if ( + right_block.get("type") == "child_database" + and right_block.get("child_database", {}).get("title") == "Skills" + ): + print( + "Error: Skills database still exists in the right column.", + file=sys.stderr, + ) + return False + + # Step 2: Find the left column and verify Skills section exists there + skills_section_found = False + skills_with_double_sparkles = [] + skills_with_single_sparkle = [] + + # First, find the main column_list (top-level) + main_column_list_id = None + for block in all_blocks: + if block.get("type") == "column_list" and block.get("parent", {}).get("type") == "page_id": + main_column_list_id = block["id"] + break + + if not main_column_list_id: + print("Error: Main column list not found.", file=sys.stderr) + return False + + # Get the columns directly + columns = notion_utils.get_all_blocks_recursively(notion, main_column_list_id) + + # Find the left column (the one with width_ratio around 0.25) + left_column_id = None + for column in columns: + if column.get("type") == "column": + width_ratio = column.get("column", {}).get("width_ratio", 0) + # Left column has width_ratio around 0.25 + if 0.2 <= width_ratio <= 0.3: + left_column_id = column["id"] + break + + if not left_column_id: + print("Error: Left column not found.", file=sys.stderr) + return False + + # Get all blocks in the left column + left_column_blocks = notion_utils.get_all_blocks_recursively(notion, left_column_id) + + # Find Languages heading + languages_index = -1 + for i, left_block in enumerate(left_column_blocks): + if ( + left_block.get("type") == "heading_2" + and "Languages" in notion_utils.get_block_plain_text(left_block) + ): + languages_index = i + break + + if languages_index == -1: + print("Error: Languages heading not found in left column.", file=sys.stderr) + return False + + # Look for Skills heading after Languages + for i in range(languages_index + 1, len(left_column_blocks)): + left_block = left_column_blocks[i] + + if ( + left_block.get("type") == "heading_2" + and "Skills" in notion_utils.get_block_plain_text(left_block) + ): + skills_section_found = True + + # Check divider after Skills heading + if i + 1 < len(left_column_blocks): + next_block = left_column_blocks[i + 1] + if next_block.get("type") != "divider": + print( + "Error: Divider not found after Skills heading.", + file=sys.stderr, + ) + return False + + # Collect skills after divider + for j in range(i + 2, len(left_column_blocks)): + skill_block = left_column_blocks[j] + if skill_block.get("type") == "paragraph": + skill_text = notion_utils.get_block_plain_text(skill_block) + if skill_text and skill_text.strip(): # Check for non-empty text + # Check if text is bold + rich_text = skill_block.get("paragraph", {}).get("rich_text", []) + if rich_text and not rich_text[0].get("annotations", {}).get("bold"): + print( + f"Error: Skill '{skill_text}' is not bold.", + file=sys.stderr, + ) + return False + + # Check icon format + if skill_text.startswith("✨✨"): + skills_with_double_sparkles.append(skill_text) + elif skill_text.startswith("✨"): + skills_with_single_sparkle.append(skill_text) + else: + print( + f"Error: Skill '{skill_text}' doesn't start with sparkle icon.", + file=sys.stderr, + ) + return False + + # Check format includes type in parentheses + if "(" not in skill_text or ")" not in skill_text: + print( + f"Error: Skill '{skill_text}' doesn't include type in parentheses.", + file=sys.stderr, + ) + return False + elif skill_block.get("type") in ["heading_1", "heading_2", "heading_3"]: + # Stop when we reach another section + break + break + + if not skills_section_found: + print( + "Error: Skills section not found in the left column under Languages.", + file=sys.stderr, + ) + return False + + # Step 3: Verify we have the expected skills + expected_double_sparkle_skills = [ + "Photoshop", + "Figma", + "Notion", + "Framer" + ] + + expected_single_sparkle_skills = [ + "Webflow", + "Rive", + "CSS + Basic JS" + ] + + # Check if all expected skills are present + for skill_name in expected_double_sparkle_skills: + found = any(skill_name in skill for skill in skills_with_double_sparkles) + if not found: + print( + f"Error: Expected skill '{skill_name}' with ✨✨ not found.", + file=sys.stderr, + ) + return False + + for skill_name in expected_single_sparkle_skills: + found = any(skill_name in skill for skill in skills_with_single_sparkle) + if not found: + print( + f"Error: Expected skill '{skill_name}' with ✨ not found.", + file=sys.stderr, + ) + return False + + # Step 4: Verify Work History and Education sections have black placeholder images + work_history_images_found = 0 + education_images_found = 0 + black_placeholder_url = "https://singlecolorimage.com/get/000000/" + + # Find Work History and Education sections in the right column + right_column_id = None + for column in columns: + if column.get("type") == "column": + width_ratio = column.get("column", {}).get("width_ratio", 0.5) + # Right column has width_ratio around 0.75 or no width_ratio (which means equal split) + if width_ratio > 0.6 or width_ratio == 0.5: + right_column_id = column["id"] + break + + if right_column_id: + right_column_blocks = notion_utils.get_all_blocks_recursively(notion, right_column_id) + + # Find Work History section + work_history_index = -1 + education_index = -1 + + for i, block in enumerate(right_column_blocks): + if block.get("type") == "heading_1": + heading_text = notion_utils.get_block_plain_text(block) + if "Work History" in heading_text: + work_history_index = i + elif "Education" in heading_text: + education_index = i + + # Check Work History column lists for images + if work_history_index != -1: + for i in range(work_history_index + 1, min(education_index if education_index > work_history_index else len(right_column_blocks), len(right_column_blocks))): + block = right_column_blocks[i] + if block.get("type") == "column_list": + column_list_blocks = notion_utils.get_all_blocks_recursively(notion, block["id"]) + for column in column_list_blocks: + if column.get("type") == "column": + # Check width_ratio - must be 50% (0.5) or absent (which defaults to 50%) + col_width = column.get("column", {}).get("width_ratio") + # First column should be image column (either no ratio=50%, or exactly 0.5) + if col_width is None or col_width == 0.5: + column_contents = notion_utils.get_all_blocks_recursively(notion, column["id"]) + for content_block in column_contents: + if content_block.get("type") == "embed": + embed_url = content_block.get("embed", {}).get("url", "") + if black_placeholder_url in embed_url: + work_history_images_found += 1 + elif content_block.get("type") == "image": + # Also check for image blocks with external URL + image_url = content_block.get("image", {}).get("external", {}).get("url", "") + if black_placeholder_url in image_url: + work_history_images_found += 1 + break # Only check first column + + # Check Education column list for images + if education_index != -1: + for i in range(education_index + 1, len(right_column_blocks)): + block = right_column_blocks[i] + if block.get("type") == "heading_1": + break # Stop at next section + if block.get("type") == "column_list": + column_list_blocks = notion_utils.get_all_blocks_recursively(notion, block["id"]) + for column in column_list_blocks: + if column.get("type") == "column": + # Check width_ratio - must be 50% (0.5) or absent (which defaults to 50%) + col_width = column.get("column", {}).get("width_ratio") + # First column should be image column (either no ratio=50%, or exactly 0.5) + if col_width is None or col_width == 0.5: + column_contents = notion_utils.get_all_blocks_recursively(notion, column["id"]) + for content_block in column_contents: + if content_block.get("type") == "embed": + embed_url = content_block.get("embed", {}).get("url", "") + if black_placeholder_url in embed_url: + education_images_found += 1 + elif content_block.get("type") == "image": + image_url = content_block.get("image", {}).get("external", {}).get("url", "") + if black_placeholder_url in image_url: + education_images_found += 1 + break # Only check first column + break # Only check first column_list in Education + + # Verify images were found + if work_history_images_found < 2: + print( + f"Warning: Expected at least 2 Work History images with black placeholder, found {work_history_images_found}.", + file=sys.stderr, + ) + return False + + if education_images_found < 1: + print( + f"Warning: Expected at least 1 Education image with black placeholder, found {education_images_found}.", + file=sys.stderr, + ) + return False + + print("Success: Skills display adjusted correctly.") + print(f"- Found {len(skills_with_double_sparkles)} skills with ✨✨ (skill level >= 50%)") + print(f"- Found {len(skills_with_single_sparkle)} skills with ✨ (skill level < 50%)") + print("- Skills database removed from right column") + print("- Skills section added to left column under Languages") + print(f"- Found {work_history_images_found} Work History images with black placeholder") + print(f"- Found {education_images_found} Education images with black placeholder") + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/standard/online_resume/projects_section_update/description.md b/tasks/notion/standard/online_resume/projects_section_update/description.md new file mode 100644 index 0000000000000000000000000000000000000000..cb297ed5733a1e1ebe129d5502b73e3f7cfffcf5 --- /dev/null +++ b/tasks/notion/standard/online_resume/projects_section_update/description.md @@ -0,0 +1,18 @@ +Find the page named "Online Resume" and reorganize the projects section to showcase only the most recent and relevant work. + +**Task Requirements:** +1. Delete the project named "Knitties eComm Website" from the Projects database since it's from 2022 and no longer relevant + +2. Create a new project entry called "Zapier Dashboard Redesign" with: + - Description: "Led the complete redesign of Zapier's main dashboard, focusing on improved usability and modern design patterns. Implemented new navigation system and responsive layouts." + - Date: Start "2024-01-01", End "2024-06-30" + - Tags: Add the existing "UI Design" tag, and create a new tag "Enterprise" with purple color, then add both tags to this project + - Phone: Same as the phone number under the Contact section + - Url: Same as the personal website under the Contact section + +3. After the Projects database block, add the following blocks in sequence: + - A divider block + - A heading_2 block with text "Current Focus" + - A paragraph block with content that dynamically references: + - The highest skill level from your Skills database (find the skill with the highest Skill Level percentage) + - Incorporate this into the text: "The Zapier Dashboard Redesign represents my most impactful recent work, leveraging my expertise in [highest skill name] ([skill level]%) to deliver enterprise-grade solutions that prioritize both aesthetics and functionality." \ No newline at end of file diff --git a/tasks/notion/standard/online_resume/projects_section_update/meta.json b/tasks/notion/standard/online_resume/projects_section_update/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..e18341cad74943c1306fbe3b17ede5d271a254e8 --- /dev/null +++ b/tasks/notion/standard/online_resume/projects_section_update/meta.json @@ -0,0 +1,26 @@ +{ + "task_id": "projects_section_update", + "task_name": "Projects Section Update", + "category_id": "online_resume", + "category_name": "Online Resume", + "description": "Reorganize the projects section by removing outdated projects and adding new relevant work with proper formatting.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "database manipulation", + "template population", + "data aggregation", + "visual formatting", + "cross-reference linking" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Online-Resume-23181626b6d781159faaeb5eadaf612e", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/online-resume" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/online_resume/projects_section_update/verify.py b/tasks/notion/standard/online_resume/projects_section_update/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..8434f3a7d975f7c975eda48a4bdcd675a8bb4c0a --- /dev/null +++ b/tasks/notion/standard/online_resume/projects_section_update/verify.py @@ -0,0 +1,261 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the projects section has been reorganized correctly with cross-section references. + """ + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Online Resume") + if not page_id: + print("Error: Page 'Online Resume' not found.", file=sys.stderr) + return False + + # Find the Projects database + projects_db_id = notion_utils.find_database_in_block(notion, page_id, "Projects") + if not projects_db_id: + print("Error: Database 'Projects' not found.", file=sys.stderr) + return False + + # Find the Skills database to get the highest skill level + skills_db_id = notion_utils.find_database_in_block(notion, page_id, "Skills") + if not skills_db_id: + print("Error: Database 'Skills' not found.", file=sys.stderr) + return False + + # Query Skills database to find the highest skill level + skills_results = notion.databases.query(database_id=skills_db_id).get("results", []) + highest_skill_name = "" + highest_skill_level = 0 + + for skill_page in skills_results: + properties = skill_page.get("properties", {}) + skill_name_prop = properties.get("Skill", {}).get("title", []) + skill_level_prop = properties.get("Skill Level", {}).get("number") + + if skill_name_prop and skill_level_prop is not None: + skill_name = skill_name_prop[0].get("text", {}).get("content", "") + if skill_level_prop > highest_skill_level: + highest_skill_level = skill_level_prop + highest_skill_name = skill_name + + if not highest_skill_name: + print("Error: Could not find any skills with skill levels.", file=sys.stderr) + return False + + # Query Projects database + projects_results = notion.databases.query(database_id=projects_db_id).get( + "results", [] + ) + + # Check that "Knitties eComm Website" is deleted + for page in projects_results: + properties = page.get("properties", {}) + name_prop = properties.get("Name", {}).get("title", []) + if ( + name_prop + and name_prop[0].get("text", {}).get("content") == "Knitties eComm Website" + ): + print( + "Failure: 'Knitties eComm Website' project was not deleted.", + file=sys.stderr, + ) + return False + + # Check that "Zapier Dashboard Redesign" exists with correct properties + zapier_project_found = False + for page in projects_results: + properties = page.get("properties", {}) + name_prop = properties.get("Name", {}).get("title", []) + if ( + name_prop + and name_prop[0].get("text", {}).get("content") + == "Zapier Dashboard Redesign" + ): + zapier_project_found = True + + # Check description contains reference to UI Design Internship + desc_prop = properties.get("Description", {}).get("rich_text", []) + if not desc_prop: + print("Failure: Zapier project has no description.", file=sys.stderr) + return False + + description_text = desc_prop[0].get("text", {}).get("content", "") + base_desc = "Led the complete redesign of Zapier's main dashboard, focusing on improved usability and modern design patterns. Implemented new navigation system and responsive layouts." + if base_desc not in description_text: + print( + "Failure: Zapier project description is missing base content.", + file=sys.stderr, + ) + return False + + # Check date + date_prop = properties.get("Date", {}).get("date", {}) + if ( + not date_prop + or date_prop.get("start") != "2024-01-01" + or date_prop.get("end") != "2024-06-30" + ): + print( + "Failure: Zapier project date range is incorrect.", file=sys.stderr + ) + return False + + # Check tags + tags_prop = properties.get("Tags", {}).get("multi_select", []) + tag_names = {tag.get("name") for tag in tags_prop} + if "UI Design" not in tag_names or "Enterprise" not in tag_names: + print( + "Failure: Zapier project is missing required tags.", file=sys.stderr + ) + return False + + # Check phone + phone_prop = properties.get("Phone", {}).get("phone_number", []) + if not phone_prop or phone_prop != "+44 7871263013": + print( + "Failure: Zapier project phone number is incorrect.", + file=sys.stderr, + ) + return + + # Check url + url_prop = properties.get("Url", {}).get("url", []) + if not url_prop or url_prop != "www.zinenwine.com": + print("Failure: Zapier project url is incorrect.", file=sys.stderr) + return + + # Check Enterprise tag color + enterprise_tag_purple = False + for tag in tags_prop: + if tag.get("name") == "Enterprise" and tag.get("color") == "purple": + enterprise_tag_purple = True + break + if not enterprise_tag_purple: + print( + "Failure: Enterprise tag does not have purple color.", + file=sys.stderr, + ) + return False + + break + + if not zapier_project_found: + print( + "Failure: 'Zapier Dashboard Redesign' project not found.", file=sys.stderr + ) + return False + + # Find the Projects database block and verify blocks after it + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + # Find the Projects database block + projects_db_index = -1 + for i, block in enumerate(all_blocks): + if ( + block.get("type") == "child_database" + and block.get("child_database", {}).get("title") == "Projects" + ): + projects_db_index = i + break + + if projects_db_index == -1: + print("Error: Could not find Projects database block.", file=sys.stderr) + return False + + # Check blocks after Projects database + if projects_db_index + 3 > len(all_blocks): + print("Failure: Not enough blocks after Projects database.", file=sys.stderr) + return False + + # Check divider block + divider_block = all_blocks[projects_db_index + 1] + if divider_block.get("type") != "divider": + print( + "Failure: Expected divider block after Projects database.", file=sys.stderr + ) + return False + + # Check heading block + heading_block = all_blocks[projects_db_index + 2] + if heading_block.get("type") != "heading_2": + print("Failure: Expected heading_2 block after divider.", file=sys.stderr) + return False + + heading_text = heading_block.get("heading_2", {}).get("rich_text", []) + if ( + not heading_text + or heading_text[0].get("text", {}).get("content") != "Current Focus" + ): + print("Failure: Heading text is incorrect.", file=sys.stderr) + return False + + # Check paragraph block with dynamic skill reference + paragraph_block = all_blocks[projects_db_index + 3] + if paragraph_block.get("type") != "paragraph": + print("Failure: Expected paragraph block after heading.", file=sys.stderr) + return False + + paragraph_text = paragraph_block.get("paragraph", {}).get("rich_text", []) + if not paragraph_text: + print("Failure: Paragraph block is empty.", file=sys.stderr) + return False + + paragraph_content = paragraph_text[0].get("text", {}).get("content", "") + + # Check that paragraph contains the base text + base_text = "The Zapier Dashboard Redesign represents my most impactful recent work, leveraging my expertise in" + if base_text not in paragraph_content: + print("Failure: Paragraph does not contain base text.", file=sys.stderr) + return False + + # Check that paragraph references the highest skill + skill_level_percent = int(highest_skill_level * 100) + expected_skill_ref = f"{highest_skill_name} ({skill_level_percent}%)" + if expected_skill_ref not in paragraph_content: + print( + f"Failure: Paragraph does not reference highest skill '{expected_skill_ref}'.", + file=sys.stderr, + ) + return False + + # Check that paragraph contains the ending text + ending_text = ( + "enterprise-grade solutions that prioritize both aesthetics and functionality" + ) + if ending_text not in paragraph_content: + print( + "Failure: Paragraph does not contain proper ending text.", file=sys.stderr + ) + return False + + print( + f"Success: Projects section has been reorganized correctly with cross-section references (highest skill: {highest_skill_name} at {skill_level_percent}%)." + ) + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/online_resume/work_history_addition/description.md b/tasks/notion/standard/online_resume/work_history_addition/description.md new file mode 100644 index 0000000000000000000000000000000000000000..eabef045d5dea6fa05113d4d1d872ec73e253e6d --- /dev/null +++ b/tasks/notion/standard/online_resume/work_history_addition/description.md @@ -0,0 +1,9 @@ +Hi! I realized I forgot to include one work experience on my resume page titled "Online Resume." Could you please help me add it to the "Work History" section? + +The position is "Research Assistant," and it took place from January to August 2023. The description should be: "Assisted in conducting user experience research projects at my bachelor’s program, supporting data collection, analyzing user feedback, and preparing research reports. Developed strong skills in research methodologies and improved collaboration with interdisciplinary teams." + +For the image or logo, please use the one from the "Education" section (my bachelor school) to keep everything consistent. + +Also, please make sure that the formatting — including font style, size, and layout — matches the existing entries in the Work History section so it looks seamless. + +Thank you! \ No newline at end of file diff --git a/tasks/notion/standard/online_resume/work_history_addition/meta.json b/tasks/notion/standard/online_resume/work_history_addition/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..973cbd808b6672203515ced14324a171a72c4d7f --- /dev/null +++ b/tasks/notion/standard/online_resume/work_history_addition/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "work_history_addition", + "task_name": "Work History Addition", + "category_id": "online_resume", + "category_name": "Online Resume", + "description": "Add a Research Assistant position to the Work History section with consistent formatting and university logo.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "database manipulation", + "template population", + "cross-reference linking", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Online-Resume-23181626b6d781159faaeb5eadaf612e", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/online-resume" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/online_resume/work_history_addition/verify.py b/tasks/notion/standard/online_resume/work_history_addition/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..2dad8a1b2cea3211e0c7115cb06f8fa54a631d7c --- /dev/null +++ b/tasks/notion/standard/online_resume/work_history_addition/verify.py @@ -0,0 +1,188 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the new work history entry for 'Research Assistant' has been added correctly. + """ + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Online Resume") + if not page_id: + print("Error: Page 'Online Resume' not found.", file=sys.stderr) + return False + + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + def find_image_url_under_heading(blocks, heading_text, notion_client): + heading_index = -1 + for i, block in enumerate(blocks): + block_type = block.get("type") + if block_type == "heading_1": + if heading_text in notion_utils.get_block_plain_text(block): + heading_index = i + break + + if heading_index == -1: + return None + + for i in range(heading_index + 1, len(blocks)): + block = blocks[i] + if block.get("type") in ["heading_1", "heading_2", "heading_3"]: + break + if block.get("type") == "image" and block.get("image", {}).get("file"): + return block.get("image", {}).get("file", {}).get("url") + if block.get("type") == "column_list": + column_list_id = block["id"] + columns = notion_utils.get_all_blocks_recursively( + notion_client, column_list_id + ) + for column in columns: + if column.get("type") == "column": + column_id = column["id"] + column_blocks = notion_utils.get_all_blocks_recursively( + notion_client, column_id + ) + for inner_block in column_blocks: + if inner_block.get("type") == "image" and inner_block.get( + "image", {} + ).get("file"): + return ( + inner_block.get("image", {}) + .get("file", {}) + .get("url") + ) + return None + + def get_block_annotations(block): + block_type = block.get("type") + if not block_type: + return {} + block_content = block.get(block_type) + if not block_content: + return {} + rich_text_list = block_content.get("rich_text", []) + if not rich_text_list: + return {} + return rich_text_list[0].get("annotations", {}) + + education_image_url = find_image_url_under_heading(all_blocks, "Education", notion) + if not education_image_url: + print( + "Error: Could not find the image in the 'Education' section.", + file=sys.stderr, + ) + return False + + heading_text = "Work History" + heading_index = -1 + for i, block in enumerate(all_blocks): + if block.get( + "type" + ) == "heading_1" and heading_text in notion_utils.get_block_plain_text(block): + heading_index = i + break + + if heading_index == -1: + print(f"Error: Could not find the '{heading_text}' heading.", file=sys.stderr) + return False + + for i in range(heading_index + 1, len(all_blocks)): + block = all_blocks[i] + if block.get("type") in ["heading_1", "heading_2", "heading_3"]: + break + + if block.get("type") == "column_list": + column_list_id = block["id"] + columns = notion_utils.get_all_blocks_recursively(notion, column_list_id) + if len(columns) < 2: + continue + + for column in columns: + if column.get("type") == "column": + if column.get("column", {}).get("width_ratio") == 0.125: + image_column = column + elif column.get("column", {}).get("width_ratio") == 0.875: + text_column = column + + image_column_blocks = notion_utils.get_all_blocks_recursively( + notion, image_column["id"] + ) + text_column_blocks = notion_utils.get_all_blocks_recursively( + notion, text_column["id"] + ) + + column_image_url = None + for inner_block in image_column_blocks: + if inner_block.get("type") == "image" and inner_block.get( + "image", {} + ).get("file"): + column_image_url = ( + inner_block.get("image", {}).get("file", {}).get("url") + ) + break + + if ( + not column_image_url + or column_image_url[:100] != education_image_url[:100] + ): + continue + + for j, inner_block in enumerate(text_column_blocks): + if "Research Assistant" in notion_utils.get_block_plain_text( + inner_block + ): + title_annotations = get_block_annotations(inner_block) + if j + 2 < len(text_column_blocks): + date_block = text_column_blocks[j + 1] + description_block = text_column_blocks[j + 2] + + date_text = "January - August 2023" + description_text = "Assisted in conducting user experience research projects at my bachelor’s program, supporting data collection, analyzing user feedback, and preparing research reports. Developed strong skills in research methodologies and improved collaboration with interdisciplinary teams." + + date_annotations = get_block_annotations(date_block) + description_annotations = get_block_annotations( + description_block + ) + + if ( + date_text in notion_utils.get_block_plain_text(date_block) + and description_text + in notion_utils.get_block_plain_text(description_block) + and title_annotations.get("bold") + and date_annotations.get("italic") + and date_annotations.get("color") == "gray" + and description_annotations.get("color") == "default" + and description_annotations.get("italic") != True + and description_annotations.get("bold") != True + ): + print("Success: Verified new work history entry.") + return True + + print("Failure: Could not verify the new work history entry.", file=sys.stderr) + return False + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/self_assessment/faq_column_layout/description.md b/tasks/notion/standard/self_assessment/faq_column_layout/description.md new file mode 100644 index 0000000000000000000000000000000000000000..07b0038ded4d88be1cca9ad393fc3fb9d966c204 --- /dev/null +++ b/tasks/notion/standard/self_assessment/faq_column_layout/description.md @@ -0,0 +1,8 @@ +Navigate to the "Self Assessment" page and reorganize the content under the FAQ toggle as follows: + +**Task Requirements:** +1. Add a column list with two columns inside the FAQ toggle +2. Move the first two existing Q&A pairs from the FAQ to the left column +3. Move the third existing Q&A pair to the right column +4. Add one additional Q&A pair in the right column to match the format, so both columns have exactly 2 Q&A pairs +5. Ensure all Q&A pairs maintain consistent formatting (heading_3 for questions, paragraph for answers) \ No newline at end of file diff --git a/tasks/notion/standard/self_assessment/faq_column_layout/meta.json b/tasks/notion/standard/self_assessment/faq_column_layout/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..cc6c7873eb7dd2ff6d9d0455bd535fcef90f0d9a --- /dev/null +++ b/tasks/notion/standard/self_assessment/faq_column_layout/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "faq_column_layout", + "task_name": "FAQ Column Layout", + "category_id": "self_assessment", + "category_name": "Self Assessment", + "description": "Reorganize the FAQ section content into a two-column layout with balanced Q&A pairs.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "content organization", + "visual formatting", + "template population" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d", + "stateOriginalUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/self_assessment/faq_column_layout/verify.py b/tasks/notion/standard/self_assessment/faq_column_layout/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..6c260082f2c6b8da4840e673bd5a0965901a6a3d --- /dev/null +++ b/tasks/notion/standard/self_assessment/faq_column_layout/verify.py @@ -0,0 +1,143 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the FAQ toggle has been properly reorganized with a column list. + """ + # Start from main_id if provided + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + # Try to find the Self Assessment page + page_id = notion_utils.find_page(notion, "Self Assessment") + + if not page_id: + print("Error: Self Assessment page not found.", file=sys.stderr) + return False + + # Get all blocks recursively from the page + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + + # Find the FAQ toggle block + faq_toggle_block = None + faq_toggle_id = None + for block in all_blocks: + if block.get("type") == "toggle": + block_text = notion_utils.get_block_plain_text(block) + if "FAQ" in block_text: + faq_toggle_block = block + faq_toggle_id = block.get("id") + print(f"Found FAQ toggle block: {block_text}") + break + + if not faq_toggle_block: + print("Error: FAQ toggle block not found.", file=sys.stderr) + return False + + # Find column_list inside the FAQ toggle + column_list_block = None + for block in all_blocks: + if ( + block.get("type") == "column_list" + and block.get("parent", {}).get("block_id") == faq_toggle_id + ): + column_list_block = block + break + + if not column_list_block: + print("Error: No column_list found inside FAQ toggle.", file=sys.stderr) + return False + + # Check that there are no Q&A pairs directly under FAQ toggle (outside column_list) + direct_faq_children = [] + for block in all_blocks: + if block.get("parent", {}).get("block_id") == faq_toggle_id and block.get( + "id" + ) != column_list_block.get("id"): + direct_faq_children.append(block) + + # Check if any of these are heading_3 or paragraph blocks (Q&A content) + for block in direct_faq_children: + if block.get("type") in ["heading_3", "paragraph"]: + print( + f"Error: Found Q&A content outside column_list: {notion_utils.get_block_plain_text(block)[:50]}...", + file=sys.stderr, + ) + return False + + # Find the two columns + columns = [] + column_list_id = column_list_block.get("id") + for block in all_blocks: + if ( + block.get("type") == "column" + and block.get("parent", {}).get("block_id") == column_list_id + ): + columns.append(block) + + if len(columns) != 2: + print(f"Error: Expected 2 columns, found {len(columns)}.", file=sys.stderr) + return False + + # Check each column has exactly 2 Q&A pairs + for i, column in enumerate(columns): + column_id = column.get("id") + + # Find blocks inside this column + column_blocks = [] + for block in all_blocks: + if block.get("parent", {}).get("block_id") == column_id: + column_blocks.append(block) + + # Count Q&A pairs (should be heading_3 followed by paragraph) + qa_pairs = 0 + j = 0 + while j < len(column_blocks): + if ( + column_blocks[j].get("type") == "heading_3" + and j + 1 < len(column_blocks) + and column_blocks[j + 1].get("type") == "paragraph" + ): + qa_pairs += 1 + j += 2 # Skip both question and answer + else: + j += 1 + + if qa_pairs != 2: + print( + f"Error: Column {i + 1} has {qa_pairs} Q&A pairs, expected 2.", + file=sys.stderr, + ) + return False + + print(f"Column {i + 1}: Found {qa_pairs} Q&A pairs ✓") + + print( + "Success: FAQ toggle properly organized with 2 columns, each containing 2 Q&A pairs." + ) + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/description.md b/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/description.md new file mode 100644 index 0000000000000000000000000000000000000000..86f62990c26e55afc07990fd771f662fa9e8ed1a --- /dev/null +++ b/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/description.md @@ -0,0 +1,24 @@ +Go to my Self Assessment page, and then create a hyperfocus analysis report by analyzing sessions with high productivity but significant challenges. + +**Task Requirements:** +1. Create a new page titled "Hyperfocus Analysis Report" as a child of the Self Assessment page. The new page should be located between 'Why Use the Term "Hyperfocus"?' callout and the following divider line. +2. Query the "Hyperfocus Self-Assessment Worksheet" database to find all sessions where: + - Work Completion Rate is greater than 80% (0.8) + - At least one challenge is present in the Challenges field +3. For each qualifying session, create a section with: + - A heading showing the date and activity type (format: YYYY-MM-DD Activity) + - A bullet list containing: + - Focus factors used (e.g., Focus factors: XXX, YYY) + - Energy level and mood (format: "Energy: X/10, Mood: Y/10") + - Challenges faced (e.g., Challenges: XXX, YYY) + - Strategies that helped overcome challenges (e.g., Strategies: XXX, YYY) + - Work completion rate (format: "Completion: XX%") +4. At the top of the page, add a callout block (type: "info") with: + - Title: "Top 2 Most Effective Strategies" + - Content: List the 2 most frequently used strategies from all sessions, each on a new line with format "• Strategy Name (used in X sessions)" + +**Structure Requirements:** +- The page must have the exact title "Hyperfocus Analysis Report" +- Each session section must start with a level 2 heading +- All session details must be in bullet point format +- The summary callout must be at the top of the page before any session details \ No newline at end of file diff --git a/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/meta.json b/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..775ab54ad65b4a0c4a7b7c791541e2eca859a9a2 --- /dev/null +++ b/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "hyperfocus_analysis_report", + "task_name": "Hyperfocus Analysis Report", + "category_id": "self_assessment", + "category_name": "Self Assessment", + "description": "Create a hyperfocus analysis report by analyzing high-productivity sessions with challenges.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "conditional filtering", + "data aggregation", + "report generation", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d", + "stateOriginalUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/verify.py b/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..02556de08e230fcc3179fe97ba5abbde2af565a1 --- /dev/null +++ b/tasks/notion/standard/self_assessment/hyperfocus_analysis_report/verify.py @@ -0,0 +1,416 @@ +import sys +import re +from notion_client import Client +from tasks.utils import notion_utils +from collections import Counter + + +def validate_comma_separated(text: str, expected_items: list) -> bool: + """ + Validates that a comma-separated list contains expected items (case-insensitive). + """ + if not text or not expected_items: + return False + + # Extract items from text + items = [item.strip().lower() for item in text.split(",")] + expected_lower = [item.lower() for item in expected_items] + + # Check if all expected items are present + for expected in expected_lower: + if not any(expected in item or item in expected for item in items): + return False + return True + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the Hyperfocus Analysis Report has been created correctly. + """ + # Find the Self Assessment page + self_assessment_page_id = main_id + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + self_assessment_page_id = found_id + + if not self_assessment_page_id: + # Try to find by name + self_assessment_page_id = notion_utils.find_page(notion, "Self Assessment") + + if not self_assessment_page_id: + print("Error: Self Assessment page not found.", file=sys.stderr) + return False + + # Find the Hyperfocus Analysis Report page + report_page_id = None + report_position = -1 + callout_position = -1 + divider_position = -1 + children = notion.blocks.children.list(block_id=self_assessment_page_id).get( + "results", [] + ) + for i, child in enumerate(children): + # Track position of callout with "Why Use the Term" + if child.get("type") == "callout": + callout_text = notion_utils.get_block_plain_text(child) + if "Why Use the Term" in callout_text and "Hyperfocus" in callout_text: + callout_position = i + + # Track position of divider + elif child.get("type") == "divider": + if callout_position != -1 and divider_position == -1: + divider_position = i + + # Find the report page + elif child.get("type") == "child_page": + page_data = notion.pages.retrieve(page_id=child["id"]) + title_prop = ( + page_data.get("properties", {}).get("title", {}).get("title", []) + ) + if ( + title_prop + and title_prop[0].get("plain_text") == "Hyperfocus Analysis Report" + ): + report_page_id = child["id"] + report_position = i + + if not report_page_id: + print("Error: 'Hyperfocus Analysis Report' page not found.", file=sys.stderr) + return False + + # Verify position + if callout_position == -1: + print( + "Error: Could not find 'Why Use the Term \"Hyperfocus\"?' callout.", + file=sys.stderr, + ) + return False + + if divider_position == -1: + print("Error: Could not find divider after the callout.", file=sys.stderr) + return False + + if not (callout_position < report_position < divider_position): + print( + f"Error: Report page is not positioned between callout and divider. Positions: callout={callout_position}, report={report_position}, divider={divider_position}", + file=sys.stderr, + ) + return False + + # Get all blocks from the report page + all_blocks = notion_utils.get_all_blocks_recursively(notion, report_page_id) + + # Find the database in the Self Assessment page + database_id = None + for block in notion_utils.get_all_blocks_recursively( + notion, self_assessment_page_id + ): + if block.get("type") == "child_database": + db_data = notion.databases.retrieve(database_id=block["id"]) + db_title = "".join( + [t.get("plain_text", "") for t in db_data.get("title", [])] + ) + if "Hyperfocus Self-Assessment Worksheet" in db_title: + database_id = block["id"] + break + + if not database_id: + print( + "Error: Database 'Hyperfocus Self-Assessment Worksheet' not found.", + file=sys.stderr, + ) + return False + + # Query database for sessions with >80% completion rate and challenges + query_results = notion.databases.query( + database_id=database_id, + filter={ + "and": [ + {"property": "Work Completion Rate", "number": {"greater_than": 0.8}}, + {"property": "Challenges", "multi_select": {"is_not_empty": True}}, + ] + }, + ).get("results", []) + + if not query_results: + print( + "Warning: No sessions found with >80% completion rate and challenges.", + file=sys.stderr, + ) + # Still check if the page structure is correct + + # Verify page structure + has_callout = False + has_top_strategies = False + session_count = 0 + found_sessions = {} # Track sessions by date for validation + + # Track strategies for validation - count from ALL sessions + all_sessions = notion.databases.query(database_id=database_id).get("results", []) + all_strategies = [] + for session in all_sessions: + strategies = ( + session.get("properties", {}) + .get("Key Strategies Used", {}) + .get("multi_select", []) + ) + all_strategies.extend([s.get("name") for s in strategies]) + + strategy_counts = Counter(all_strategies) + top_2_strategies = strategy_counts.most_common(2) + + # Build expected sessions from query results with all data + expected_sessions = {} + for result in query_results: + date_prop = result.get("properties", {}).get("Date", {}).get("date", {}) + activity_prop = ( + result.get("properties", {}).get("Activity", {}).get("select", {}) + ) + if date_prop and date_prop.get("start") and activity_prop: + date_str = date_prop["start"] + activity_name = activity_prop.get("name", "") + + # Extract all session data for validation + focus_factors = [ + f.get("name", "") + for f in result.get("properties", {}) + .get("Focus Factors", {}) + .get("multi_select", []) + ] + challenges = [ + c.get("name", "") + for c in result.get("properties", {}) + .get("Challenges", {}) + .get("multi_select", []) + ] + strategies = [ + s.get("name", "") + for s in result.get("properties", {}) + .get("Key Strategies Used", {}) + .get("multi_select", []) + ] + energy = result.get("properties", {}).get("Energy Level", {}).get("number") + mood = result.get("properties", {}).get("Mood", {}).get("number") + completion = ( + result.get("properties", {}) + .get("Work Completion Rate", {}) + .get("number") + ) + + expected_sessions[date_str] = { + "activity": activity_name, + "focus_factors": focus_factors, + "challenges": challenges, + "strategies": strategies, + "energy": energy, + "mood": mood, + "completion": completion, + } + + current_session_date = None + current_session_data = None + session_bullet_points = {} # Track bullet points for each session + + for i, block in enumerate(all_blocks): + block_type = block.get("type") + + # Check for callout at the top + if block_type == "callout" and i < 5: # Should be near the top + callout_text = notion_utils.get_block_plain_text(block) + if "Top 2 Most Effective Strategies" in callout_text: + has_callout = True + # Check if it contains strategy information + s1, n1 = top_2_strategies[0] + s2, n2 = top_2_strategies[1] + t1 = f"{s1} (used in {n1} sessions)" + t2 = f"{s2} (used in {n2} sessions)" + + if t1 in callout_text and t2 in callout_text: + has_top_strategies = True + break + + # Check for session headings with format YYYY-MM-DD Activity + if block_type == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + # Check if heading matches expected format + for date_str, session_data in expected_sessions.items(): + activity = session_data["activity"] + expected_heading = f"{date_str} {activity}" + if expected_heading in heading_text: + found_sessions[date_str] = session_data + session_count += 1 + current_session_date = date_str + current_session_data = session_data + session_bullet_points[date_str] = [] + break + + # Check for bullet points with session details + if block_type == "bulleted_list_item" and current_session_data: + bullet_text = notion_utils.get_block_plain_text(block) + + # Track bullet points for current session + if current_session_date: + session_bullet_points[current_session_date].append(bullet_text) + + # Validate specific bullet point content + if bullet_text.startswith("Focus factors"): + content = bullet_text.split(":", 1)[1].strip() + expected_factors = current_session_data.get("focus_factors", []) + if not validate_comma_separated(content, expected_factors): + print( + f"Error: Focus factors mismatch for {current_session_date}. Expected: {expected_factors}, Found: {content}", + file=sys.stderr, + ) + return False + + elif "Energy" in bullet_text and "Mood" in bullet_text: + # Extract energy and mood values + energy_match = re.search(r"Energy:\s*(\d+)/10", bullet_text) + mood_match = re.search(r"Mood:\s*(\d+)/10", bullet_text) + + if energy_match and mood_match: + found_energy = int(energy_match.group(1)) + found_mood = int(mood_match.group(1)) + expected_energy = current_session_data.get("energy") + expected_mood = current_session_data.get("mood") + + if found_energy != expected_energy or found_mood != expected_mood: + print( + f"Error: Energy/Mood mismatch for {current_session_date}. Expected: Energy: {expected_energy}/10, Mood: {expected_mood}/10", + file=sys.stderr, + ) + return False + else: + print( + f"Error: Invalid Energy/Mood format for {current_session_date}", + file=sys.stderr, + ) + return False + + elif bullet_text.startswith("Challenges"): + content = bullet_text.split(":", 1)[1].strip() + expected_challenges = current_session_data.get("challenges", []) + if not validate_comma_separated(content, expected_challenges): + print( + f"Error: Challenges mismatch for {current_session_date}. Expected: {expected_challenges}, Found: {content}", + file=sys.stderr, + ) + return False + + elif bullet_text.startswith("Strategies"): + content = bullet_text.split(":", 1)[1].strip() + expected_strategies = current_session_data.get("strategies", []) + if len(expected_strategies) > 0 and not validate_comma_separated( + content, expected_strategies + ): + print( + f"Error: Strategies mismatch for {current_session_date}. Expected: {expected_strategies}, Found: {content}", + file=sys.stderr, + ) + return False + + elif bullet_text.startswith("Completion"): + # Extract completion percentage + completion_match = re.search(r"Completion:\s*(\d+)%", bullet_text) + + if completion_match: + found_completion = int(completion_match.group(1)) + expected_completion = int( + current_session_data.get("completion", 0) * 100 + ) + + if found_completion != expected_completion: + print( + f"Error: Completion rate mismatch for {current_session_date}. Expected: {expected_completion}%, Found: {found_completion}%", + file=sys.stderr, + ) + return False + else: + print( + f"Error: Invalid completion format for {current_session_date}", + file=sys.stderr, + ) + return False + + # Verify all sessions have complete bullet points + for date_str, bullets in session_bullet_points.items(): + bullets_text = " ".join(bullets) + required_items = [ + "Focus factors", + "Energy:", + "Mood:", + "Challenges", + "Strategies", + "Completion", + ] + missing_items = [] + + for item in required_items: + if item not in bullets_text: + missing_items.append(item) + + if missing_items: + print( + f"Error: Missing bullet points for session {date_str}: {', '.join(missing_items)}", + file=sys.stderr, + ) + return False + + # Verify all requirements + if not has_callout: + print( + "Error: Missing callout block with 'Top 2 Most Effective Strategies'.", + file=sys.stderr, + ) + return False + + if not has_top_strategies and len(top_2_strategies) > 0: + print("Error: Callout doesn't contain strategy information.", file=sys.stderr) + return False + + if query_results and session_count == 0: + print("Error: No session sections found with proper headings.", file=sys.stderr) + return False + + # Check if all expected sessions are present + missing_sessions = [] + for date_str in expected_sessions.keys(): + if date_str not in found_sessions: + missing_sessions.append(date_str) + + if missing_sessions: + print( + f"Error: Missing session sections for dates: {', '.join(missing_sessions)}", + file=sys.stderr, + ) + return False + + if query_results and session_count < len(query_results): + print( + f"Warning: Found {session_count} session sections but expected {len(query_results)}.", + file=sys.stderr, + ) + + print( + "Success: Hyperfocus Analysis Report created with proper structure and content." + ) + return True + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/self_assessment/numbered_list_emojis/description.md b/tasks/notion/standard/self_assessment/numbered_list_emojis/description.md new file mode 100644 index 0000000000000000000000000000000000000000..6bb2436480f70600b9ce16339c893df9028ecd74 --- /dev/null +++ b/tasks/notion/standard/self_assessment/numbered_list_emojis/description.md @@ -0,0 +1,14 @@ +Please find all numbered list items in the Self Assessment page, use Notion tools to replace the numbers with corresponding emoji numbers (e.g., 1️⃣, 2️⃣, 3️⃣). For example: +Here is the translated and reformatted version of your request: + +If the original numbered list is: + +1. First step +2. Second step +3. Third step + +It should become: + +1️⃣ First step +2️⃣ Second step +3️⃣ Third step \ No newline at end of file diff --git a/tasks/notion/standard/self_assessment/numbered_list_emojis/meta.json b/tasks/notion/standard/self_assessment/numbered_list_emojis/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..2c9d30df5351d38dc5055f58b52ebbdc3bab0086 --- /dev/null +++ b/tasks/notion/standard/self_assessment/numbered_list_emojis/meta.json @@ -0,0 +1,23 @@ +{ + "task_id": "numbered_list_emojis", + "task_name": "Numbered List Emojis", + "category_id": "self_assessment", + "category_name": "Self Assessment", + "description": "Replace numbered list items with corresponding emoji numbers for better visual formatting.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "visual formatting", + "automated migration" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d", + "stateOriginalUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/self_assessment/numbered_list_emojis/verify.py b/tasks/notion/standard/self_assessment/numbered_list_emojis/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..b807a05c020122edead45d1ed663f71095ed4e7c --- /dev/null +++ b/tasks/notion/standard/self_assessment/numbered_list_emojis/verify.py @@ -0,0 +1,97 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that numbered lists have been replaced with emoji numbers. + """ + # Start from main_id if provided, otherwise search for the page + self_assessment_page_id = main_id + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + self_assessment_page_id = found_id + + if not self_assessment_page_id: + # Try to find by name + self_assessment_page_id = notion_utils.find_page(notion, "Self Assessment") + + if not self_assessment_page_id: + print("Error: Self Assessment page not found.", file=sys.stderr) + return False + + # Get all blocks recursively from the main page + all_blocks = notion_utils.get_all_blocks_recursively( + notion, self_assessment_page_id + ) + + # Find all numbered_list_item blocks + numbered_list_items = [] + for block in all_blocks: + if block.get("type") == "numbered_list_item": + numbered_list_items.append(block) + + if len(numbered_list_items) > 0: + print( + f"Error: found {len(numbered_list_items)} numbered list items that should be converted to emoji numbers", + file=sys.stderr, + ) + # return False + + required_items = [ + "1️⃣ Record Each Hyperfocus Session:", + "2️⃣ Review and Reflect:", + "3️⃣ Adjust and Optimize:", + '1️⃣ Harvard Business Review: "The Making of a Corporate Athlete"', + '2️⃣ "Hyperfocus: How to Be More Productive in a World of Distraction" by Chris Bailey', + '3️⃣ "Attention Management: How to Create Success and Gain Productivity Every Day" by Maura Thomas', + '4️⃣ "Deep Work: Rules for Focused Success in a Distracted World" by Cal Newport', + "1️⃣ Record Each Hyperfocus Session:", + "2️⃣ Review and Reflect:", + "3️⃣ Adjust and Optimize:", + "1️⃣ What time of day do you feel most focused?", + "2️⃣ Which environment helps you concentrate the most?", + "3️⃣ What type of tasks do you find yourself getting lost in?", + ] + + # Make a copy to track which items we've found + remaining_items = required_items.copy() + + # Iterate through all blocks to find matching text + for block in all_blocks: + block_text = notion_utils.get_block_plain_text(block).strip() + + # Check if this block's text matches any of our required items + if block_text in remaining_items: + remaining_items.remove(block_text) + print(f"Found: {block_text}") + + # Check if all required items were found + if len(remaining_items) == 0: + print("Success: All numbered lists have been converted to emoji numbers") + return True + else: + print(f"Error: Missing {len(remaining_items)} required items:", file=sys.stderr) + for item in remaining_items: + print(f" - {item}", file=sys.stderr) + return False + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/description.md b/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/description.md new file mode 100644 index 0000000000000000000000000000000000000000..c3bf0ecb2d45c159a50967fd10d3d2381195678a --- /dev/null +++ b/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/description.md @@ -0,0 +1,48 @@ +Using Notion Tools. Complete the SOP template (a notion page titled 'Standard Operating Procedure') by filling in all sections with comprehensive, interconnected content for a "Software Deployment Process" SOP, ensuring all cross-references, terminologies, and procedural steps are properly linked and validated. + +**Task Requirements:** + +1. **Update the SOP header information** (in the left column): + - Change the heading_1 "SOP Title" text to "Software Deployment Process" + - Update the paragraph "Created 2023-10-25" to "Created 2025-01-19" + - Update the paragraph "Responsible department:" to "Responsible department: DevOps Engineering Team" + - Update the People team page's callout to: "DevOps Engineering Team Wiki - Contains team contact information, escalation procedures, and deployment schedules. Access required for all deployment activities." + +2. **Fill the Purpose section** with exactly this content: + - Replace the placeholder paragraph (starts with "↓ Summarize the procedure") with: "This SOP defines the standardized process for deploying software applications to production environments, ensuring zero-downtime deployments, proper rollback procedures, and compliance with security protocols. This procedure applies to all production deployments and must be followed by all engineering teams." + +3. **Complete the Context section** with: + - Replace the placeholder paragraph (starts with "↓ Add any related and useful information") with: "Software deployments are critical operations that can impact system availability and user experience. This process has been developed based on industry best practices and our incident response learnings from Q3 2023. All deployments must go through automated testing pipelines and require approval from designated reviewers." + - Update all THREE child_pages under the "Relevant Docs" toggle: + - First child_page callout (Contacting IT): "Change Management Policy (SOP-001) - Defines approval workflows and change review processes for all production modifications." + - Second child_page callout (Team lunches): "Incident Response Procedures (SOP-003) - Emergency procedures for handling deployment failures and system outages." + - Third child_page callout (Sending swag): "Security Compliance Guidelines (SOP-007) - Security requirements and validation steps for production deployments." + +4. **Define comprehensive Terminologies** by: + - Replace the placeholder paragraph (starts with "↓ Add any unfamiliar or domain specific words") with: "Essential deployment terminology for team understanding:" + - Replace the existing bulleted_list_item "Term: The definition of the term" with these four exact items: + - "Blue-Green Deployment: A deployment strategy that maintains two identical production environments" + - "Rollback Window: The maximum time allowed to revert a deployment (30 minutes)" + - "Smoke Test: Initial verification tests run immediately after deployment" + - "Production Gateway: The approval checkpoint before production release" + +5. **Populate Tools section** with: + - Replace the placeholder paragraph (starts with "↓ Add any relevant tools") with: "Critical tools required for deployment operations:" + - Update the TWO existing child_pages: + - First child_page callout: "Jenkins CI/CD Pipeline - Primary deployment automation tool with integrated testing and approval workflows. Required for all automated deployments." + - Second child_page callout: "Kubernetes Dashboard - Container orchestration monitoring and management interface for deployment verification and rollback operations." + +6. **Complete Roles & responsibilities** with: + - Replace the placeholder paragraph (starts with "↓ Define who will be executing") with: "The following roles are essential for successful deployment execution:" + - Replace the existing empty bulleted_list_item with these four exact items: + - "DevOps Engineer: Executes deployment, monitors system health, initiates rollbacks if needed" + - "Lead Developer: Reviews code changes, approves deployment package, validates functionality" + - "QA Engineer: Verifies smoke tests, confirms user acceptance criteria" + - "Security Officer: Validates security compliance, approves security-sensitive deployments" + +7. **Create detailed Procedure section** with: + - Replace the placeholder paragraph (starts with "↓ Create a step by step procedure") with: "Follow these steps in sequence. Do not skip steps or perform them out of order." + - Replace the THREE existing numbered_list_items with: + - "Pre-deployment: Verify all automated tests pass, obtain required approvals from Lead Developer and Security Officer, confirm rollback plan is documented and tested" + - "Deployment execution: Deploy to staging environment first, run comprehensive smoke tests, obtain final Production Gateway approval, deploy to production using blue-green strategy" + - "Post-deployment: Monitor system metrics for minimum 30 minutes, validate all functionality using automated tests, document deployment results in change log, notify all stakeholders via deployment notification system" \ No newline at end of file diff --git a/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/meta.json b/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..79791a91bcdfa777af7432f13310caf5ee1f0253 --- /dev/null +++ b/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "deployment_process_sop", + "task_name": "Deployment Process SOP", + "category_id": "standard_operating_procedure", + "category_name": "Standard Operating Procedure", + "description": "Complete the SOP template with comprehensive content for a Software Deployment Process with interconnected sections.", + "author": "Xiangyan Liu", + "created_at": "2025-07-27", + "difficulty": "L3", + "tags": [ + "template population", + "cross-reference linking", + "content organization", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Standard-Operating-Procedure-24381626b6d780a8b678f9e62ae5b152", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/standard-operating-procedure" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/verify.py b/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..1e7b67cd518570e9fe94a60840f1a499138abb50 --- /dev/null +++ b/tasks/notion/standard/standard_operating_procedure/deployment_process_sop/verify.py @@ -0,0 +1,476 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies comprehensive SOP template completion with exact content matching. + """ + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id( + notion, main_id + ) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Standard Operating Procedure") + if not page_id: + print("Error: Page 'Standard Operating Procedure' not found.", file=sys.stderr) + return False + + all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + verification_results = [] + + # Check 1: Verify SOP header information updates + sop_title_found = False + created_date_found = False + responsible_dept_found = False + header_callout_found = False + + for block in all_blocks: + if block.get("type") == "heading_1": + heading_text = notion_utils.get_block_plain_text(block) + if "Software Deployment Process" in heading_text: + sop_title_found = True + verification_results.append("✅ SOP Title updated correctly") + + elif block.get("type") == "paragraph": + para_text = notion_utils.get_block_plain_text(block) + if "Created 2025-01-19" in para_text: + created_date_found = True + verification_results.append("✅ Created date updated correctly") + elif "Responsible department: DevOps Engineering Team" in para_text: + responsible_dept_found = True + verification_results.append( + "✅ Responsible department updated correctly" + ) + + elif block.get("type") == "child_page": + # Check child pages recursively for callout content - specifically the People team page + try: + child_page_info = notion.pages.retrieve(page_id=block["id"]) + child_page_title = "" + if ( + "properties" in child_page_info + and "title" in child_page_info["properties"] + ): + title_list = child_page_info["properties"]["title"].get("title", []) + if title_list: + child_page_title = title_list[0].get("plain_text", "") + except: + child_page_title = "" + + child_blocks = notion_utils.get_all_blocks_recursively(notion, block["id"]) + for child_block in child_blocks: + if child_block.get("type") == "callout": + callout_text = notion_utils.get_block_plain_text(child_block) + # Look for the People team page with the DevOps Engineering Team Wiki callout + if ( + "DevOps Engineering Team Wiki" in callout_text + and "deployment schedules" in callout_text + and "deployment activities" in callout_text + ): + header_callout_found = True + verification_results.append( + "✅ Header People team page callout updated correctly" + ) + + # Check 2: Verify Purpose section content + purpose_found = False + expected_purpose = "This SOP defines the standardized process for deploying software applications to production environments" + + for i, block in enumerate(all_blocks): + if block.get("type") == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + if "Purpose" in heading_text: + # Check next paragraph after Purpose heading + for j in range(i + 1, min(i + 5, len(all_blocks))): + next_block = all_blocks[j] + if next_block.get("type") == "paragraph": + para_text = notion_utils.get_block_plain_text(next_block) + if ( + expected_purpose in para_text + and "engineering teams" in para_text + ): + purpose_found = True + verification_results.append( + "✅ Purpose section content updated correctly" + ) + break + break + + # Check 3: Verify Context section and child_page callouts + context_found = False + child_pages_updated = 0 + expected_context = "Software deployments are critical operations that can impact system availability" + expected_child_callouts = [ + ( + "Change Management Policy (SOP-001)", + "Defines approval workflows and change review processes for all production modifications", + "Contacting IT", + ), + ( + "Incident Response Procedures (SOP-003)", + "Emergency procedures for handling deployment failures and system outages", + "Team lunches", + ), + ( + "Security Compliance Guidelines (SOP-007)", + "Security requirements and validation steps for production deployments", + "Sending swag", + ), + ] + + for i, block in enumerate(all_blocks): + if block.get("type") == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + if "Context" in heading_text: + # Check paragraph content + for j in range(i + 1, min(i + 10, len(all_blocks))): + next_block = all_blocks[j] + if next_block.get("type") == "paragraph": + para_text = notion_utils.get_block_plain_text(next_block) + if expected_context in para_text and "Q3 2023" in para_text: + context_found = True + elif next_block.get("type") == "toggle": + # Check child pages under toggle + toggle_blocks = notion_utils.get_all_blocks_recursively( + notion, next_block["id"] + ) + for toggle_child in toggle_blocks: + if toggle_child.get("type") == "child_page": + # Get the child page title to match with expected callouts + try: + child_page_info = notion.pages.retrieve( + page_id=toggle_child["id"] + ) + child_page_title = "" + if ( + "properties" in child_page_info + and "title" in child_page_info["properties"] + ): + title_list = child_page_info["properties"][ + "title" + ].get("title", []) + if title_list: + child_page_title = title_list[0].get( + "plain_text", "" + ) + except: + child_page_title = "" + + child_blocks = notion_utils.get_all_blocks_recursively( + notion, toggle_child["id"] + ) + for child_block in child_blocks: + if child_block.get("type") == "callout": + callout_text = ( + notion_utils.get_block_plain_text( + child_block + ) + ) + for ( + expected_title, + expected_content, + expected_page_title, + ) in expected_child_callouts: + if ( + expected_title in callout_text + and expected_content in callout_text + and expected_page_title + in child_page_title + ): + child_pages_updated += 1 + verification_results.append( + f"✅ Context child_page '{expected_page_title}' updated correctly" + ) + break + + if context_found: + verification_results.append("✅ Context section content updated correctly") + + if child_pages_updated == 3: + verification_results.append( + "✅ All 3 Context child_page callouts updated correctly" + ) + else: + verification_results.append( + f"❌ Only {child_pages_updated}/3 Context child_page callouts updated correctly (Contacting IT, Team lunches, Sending swag)" + ) + + # Check 4: Verify Terminologies section with exact 4 bulleted items + terminologies_found = False + terminology_items = [] + expected_terminologies = [ + "Blue-Green Deployment: A deployment strategy that maintains two identical production environments", + "Rollback Window: The maximum time allowed to revert a deployment (30 minutes)", + "Smoke Test: Initial verification tests run immediately after deployment", + "Production Gateway: The approval checkpoint before production release", + ] + + for i, block in enumerate(all_blocks): + if block.get("type") == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + if "Terminologies" in heading_text: + # Check for intro paragraph + for j in range(i + 1, min(i + 2, len(all_blocks))): + if all_blocks[j].get("type") == "paragraph": + para_text = notion_utils.get_block_plain_text(all_blocks[j]) + if "Essential deployment terminology" in para_text: + terminologies_found = True + break + + # Check bulleted list items + for j in range(i + 1, min(i + 10, len(all_blocks))): + next_block = all_blocks[j] + if next_block.get("type") == "bulleted_list_item": + item_text = notion_utils.get_block_plain_text(next_block) + terminology_items.append(item_text) + elif next_block.get("type") in [ + "heading_1", + "heading_2", + "heading_3", + ]: + break + break + + terminology_matches = sum( + 1 + for expected in expected_terminologies + if any(expected in item for item in terminology_items) + ) + + if terminologies_found and len(terminology_items) == 4 and terminology_matches == 4: + verification_results.append( + "✅ Terminologies section with exactly 4 correct items" + ) + else: + verification_results.append( + f"❌ Terminologies: expected 4 items, found {len(terminology_items)}, {terminology_matches} correct" + ) + + # Check 5: Verify Tools section with 2 child_page callouts + tools_found = False + tools_child_pages = 0 + expected_tools = [ + ("Jenkins CI/CD Pipeline", "automated deployments"), + ("Kubernetes Dashboard", "rollback operations"), + ] + + for i, block in enumerate(all_blocks): + if block.get("type") == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + if "Tools" in heading_text: + # Check intro paragraph + for j in range(i + 1, min(i + 2, len(all_blocks))): + if all_blocks[j].get("type") == "paragraph": + para_text = notion_utils.get_block_plain_text(all_blocks[j]) + if "Critical tools required" in para_text: + tools_found = True + break + + # Check child pages + for j in range(i + 1, min(i + 10, len(all_blocks))): + next_block = all_blocks[j] + if next_block.get("type") == "child_page": + child_blocks = notion_utils.get_all_blocks_recursively( + notion, next_block["id"] + ) + for child_block in child_blocks: + if child_block.get("type") == "callout": + callout_text = notion_utils.get_block_plain_text( + child_block + ) + for expected_title, expected_content in expected_tools: + if ( + expected_title in callout_text + and expected_content in callout_text + ): + tools_child_pages += 1 + break + elif next_block.get("type") in [ + "heading_1", + "heading_2", + "heading_3", + ]: + break + break + + if tools_found and tools_child_pages == 2: + verification_results.append( + "✅ Tools section with 2 correctly updated child_page callouts" + ) + else: + verification_results.append( + f"❌ Tools section: expected 2 child_pages updated, found {tools_child_pages}" + ) + + # Check 6: Verify Roles & responsibilities with exactly 4 bulleted items + roles_found = False + role_items = [] + expected_roles = [ + "DevOps Engineer: Executes deployment, monitors system health, initiates rollbacks if needed", + "Lead Developer: Reviews code changes, approves deployment package, validates functionality", + "QA Engineer: Verifies smoke tests, confirms user acceptance criteria", + "Security Officer: Validates security compliance, approves security-sensitive deployments", + ] + + for i, block in enumerate(all_blocks): + if block.get("type") == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + if "Roles" in heading_text and "responsibilities" in heading_text: + # Check intro paragraph + for j in range(i + 1, min(i + 2, len(all_blocks))): + if all_blocks[j].get("type") == "paragraph": + para_text = notion_utils.get_block_plain_text(all_blocks[j]) + if "essential for successful deployment execution" in para_text: + roles_found = True + break + + # Check bulleted list items + for j in range(i + 1, min(i + 10, len(all_blocks))): + next_block = all_blocks[j] + if next_block.get("type") == "bulleted_list_item": + item_text = notion_utils.get_block_plain_text(next_block) + role_items.append(item_text) + elif next_block.get("type") in [ + "heading_1", + "heading_2", + "heading_3", + ]: + break + break + + role_matches = sum( + 1 for expected in expected_roles if any(expected in item for item in role_items) + ) + + if roles_found and len(role_items) == 4 and role_matches == 4: + verification_results.append( + "✅ Roles & responsibilities section with exactly 4 correct items" + ) + else: + verification_results.append( + f"❌ Roles section: expected 4 items, found {len(role_items)}, {role_matches} correct" + ) + + # Check 7: Verify Procedure section with exactly 3 numbered items + procedure_found = False + procedure_items = [] + expected_procedures = [ + ("Pre-deployment", "Lead Developer and Security Officer", "rollback plan"), + ("Deployment execution", "staging environment first", "blue-green strategy"), + ( + "Post-deployment", + "minimum 30 minutes", + "stakeholders via deployment notification", + ), + ] + + for i, block in enumerate(all_blocks): + if block.get("type") == "heading_2": + heading_text = notion_utils.get_block_plain_text(block) + if "Procedure" in heading_text: + # Check intro paragraph + for j in range(i + 1, min(i + 2, len(all_blocks))): + if all_blocks[j].get("type") == "paragraph": + para_text = notion_utils.get_block_plain_text(all_blocks[j]) + if "Follow these steps in sequence" in para_text: + procedure_found = True + break + + # Check numbered list items + for j in range(i + 1, min(i + 10, len(all_blocks))): + next_block = all_blocks[j] + if next_block.get("type") == "numbered_list_item": + item_text = notion_utils.get_block_plain_text(next_block) + procedure_items.append(item_text) + elif next_block.get("type") in [ + "heading_1", + "heading_2", + "heading_3", + ]: + break + break + + procedure_matches = 0 + for item_text in procedure_items: + for expected_title, expected_content1, expected_content2 in expected_procedures: + if ( + expected_title in item_text + and expected_content1 in item_text + and expected_content2 in item_text + ): + procedure_matches += 1 + break + + if procedure_found and len(procedure_items) == 3 and procedure_matches == 3: + verification_results.append("✅ Procedure section with exactly 3 correct items") + else: + verification_results.append( + f"❌ Procedure: expected 3 items, found {len(procedure_items)}, {procedure_matches} correct" + ) + + # Calculate overall success + total_checks = 14 # Number of major verification points + successful_checks = sum( + 1 for result in verification_results if result.startswith("✅") + ) + + # Print all verification results + print("\n=== SOP Template Verification Results ===", file=sys.stderr) + for result in verification_results: + print(result, file=sys.stderr) + + print(f"\n=== Summary: {successful_checks}/{total_checks} checks passed ===") + + # Must pass ALL checks to succeed + success = ( + sop_title_found + and created_date_found + and responsible_dept_found + and header_callout_found + and purpose_found + and context_found + and child_pages_updated == 3 + and terminologies_found + and len(terminology_items) == 4 + and terminology_matches == 4 + and tools_found + and tools_child_pages == 2 + and roles_found + and len(role_items) == 4 + and role_matches == 4 + and procedure_found + and len(procedure_items) == 3 + and procedure_matches == 3 + ) + + if success: + print("\n🎉 SUCCESS: All SOP template requirements completed correctly!") + return True + else: + print( + f"\n❌ FAILURE: SOP template verification failed. {successful_checks}/{total_checks} requirements met.", + file=sys.stderr, + ) + return False + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tasks/notion/standard/standard_operating_procedure/section_organization/description.md b/tasks/notion/standard/standard_operating_procedure/section_organization/description.md new file mode 100644 index 0000000000000000000000000000000000000000..24e38a9514f0c3e0b4e136fc78843040ea35ac23 --- /dev/null +++ b/tasks/notion/standard/standard_operating_procedure/section_organization/description.md @@ -0,0 +1,19 @@ +# Task: Reorganize Standard Operating Procedure Page Sections + +## Objective +Modify the structure of the Standard Operating Procedure page in Notion by reorganizing sections through swapping and creating a column layout. + +## Requirements + +### Step 1: Swap Sections +- Navigate to the Standard Operating Procedure page +- Swap the positions of the "Terminologies" and "Roles & responsibilities" sections +- Preserve all content within each section exactly as is +- Maintain the original formatting and structure of each section + +### Step 2: Create Column Layout +- After swapping, arrange the "Tools" section and the section immediately below it ("Terminologies") into a 2-column layout +- Position the "Tools" section in the left column +- Position the "Terminologies" section in the right column +- In the "Tools" column, add links to the Notion and Figma pages using appropriate reference blocks +- Preserve the original child pages from the "Tools" section in a toggle block placed below the column layout, with the toggle titled "original pages" \ No newline at end of file diff --git a/tasks/notion/standard/standard_operating_procedure/section_organization/meta.json b/tasks/notion/standard/standard_operating_procedure/section_organization/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..f206f605342ac3edc60b7d40b9dc5da6dc21d5d5 --- /dev/null +++ b/tasks/notion/standard/standard_operating_procedure/section_organization/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "section_organization", + "task_name": "Section Organization", + "category_id": "standard_operating_procedure", + "category_name": "Standard Operating Procedure", + "description": "Reorganize the Standard Operating Procedure page by swapping sections and creating a column layout.", + "author": "Xiangyan Liu", + "created_at": "2025-08-11", + "difficulty": "L3", + "tags": [ + "content organization", + "cross-reference linking", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Standard-Operating-Procedure-24381626b6d780a8b678f9e62ae5b152", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/standard-operating-procedure" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/standard_operating_procedure/section_organization/verify.py b/tasks/notion/standard/standard_operating_procedure/section_organization/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..aa9506e359f0a1a7c89ebb0faf130cf1ee189c8e --- /dev/null +++ b/tasks/notion/standard/standard_operating_procedure/section_organization/verify.py @@ -0,0 +1,267 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the Standard Operating Procedure page has been reorganized correctly. + """ + # Step 1: Find the Standard Operating Procedure page + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not found_id or object_type != 'page': + print("Error: Standard Operating Procedure page not found.", file=sys.stderr) + return False + else: + # Try to find the page by searching + found_id = notion_utils.find_page(notion, "Standard Operating Procedure") + if not found_id: + print("Error: Standard Operating Procedure page not found.", file=sys.stderr) + return False + + print(f"Found Standard Operating Procedure page: {found_id}") + + # Get all blocks from the page + all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id) + print(f"Found {len(all_blocks)} blocks") + + print("Starting verification...") + + # Step 2: Verify the structure and section order + print("2. Checking page structure and section order...") + + # Expected structure after the initial content and dividers + # We'll look for main sections by their headings + roles_index = None + tools_column_index = None + toggle_index = None + procedure_index = None + + for i, block in enumerate(all_blocks): + if block.get("type") == "heading_2": + heading_text = "" + rich_text = block.get("heading_2", {}).get("rich_text", []) + if rich_text: + heading_text = rich_text[0].get("text", {}).get("content", "") + + if heading_text == "Roles & responsibilities": + roles_index = i + print(f"✓ Found 'Roles & responsibilities' section at index {i}") + elif heading_text == "Procedure": + procedure_index = i + print(f"✓ Found 'Procedure' section at index {i}") + + # Check for column_list (containing Tools and Terminologies) + for i, block in enumerate(all_blocks): + if block.get("type") == "column_list": + # Check if this is the right column_list (should be after Roles & responsibilities) + if roles_index and i > roles_index: + tools_column_index = i + print(f"✓ Found column_list at index {i}") + break + + # Check for toggle block with "original pages" + for i, block in enumerate(all_blocks): + if block.get("type") == "toggle": + toggle_text = "" + rich_text = block.get("toggle", {}).get("rich_text", []) + if rich_text: + toggle_text = rich_text[0].get("text", {}).get("content", "") + + if toggle_text.lower() == "original pages": + toggle_index = i + print(f"✓ Found 'original pages' toggle at index {i}") + break + + # Step 3: Verify section order + print("3. Verifying section order...") + + if roles_index is None: + print("Error: 'Roles & responsibilities' section not found.", file=sys.stderr) + return False + + if tools_column_index is None: + print("Error: Column layout not found.", file=sys.stderr) + return False + + if toggle_index is None: + print("Error: 'original pages' toggle not found.", file=sys.stderr) + return False + + if procedure_index is None: + print("Error: 'Procedure' section not found.", file=sys.stderr) + return False + + # Verify order: Roles & responsibilities < column_list < toggle < Procedure + if not (roles_index < tools_column_index < toggle_index < procedure_index): + print("Error: Sections are not in the correct order.", file=sys.stderr) + print(f" Expected order: Roles & responsibilities ({roles_index}) < column_list ({tools_column_index}) < toggle ({toggle_index}) < Procedure ({procedure_index})", file=sys.stderr) + return False + + print("✓ Sections are in the correct order") + + # Step 4: Verify column_list structure + print("4. Verifying column layout structure...") + + column_list_block = all_blocks[tools_column_index] + column_list_id = column_list_block.get("id") + + # Get direct children of column_list (should be columns only) + try: + column_response = notion.blocks.children.list(block_id=column_list_id) + column_children = column_response.get("results", []) + except Exception as e: + print(f"Error getting column children: {e}", file=sys.stderr) + return False + + if len(column_children) < 2: + print(f"Error: Column list should have at least 2 columns, found {len(column_children)}.", file=sys.stderr) + return False + + # Verify left column (Tools) + left_column = column_children[0] + if left_column.get("type") != "column": + print("Error: First child of column_list should be a column.", file=sys.stderr) + return False + + left_column_id = left_column.get("id") + left_column_blocks = notion_utils.get_all_blocks_recursively(notion, left_column_id) + + # Check for Tools heading and link_to_page blocks in left column + tools_heading_found = False + link_to_page_count = 0 + for block in left_column_blocks: + if block.get("type") == "heading_2": + heading_text = block.get("heading_2", {}).get("rich_text", [{}])[0].get("text", {}).get("content", "") + if heading_text == "Tools": + tools_heading_found = True + print("✓ Found 'Tools' heading in left column") + elif block.get("type") == "link_to_page": + link_to_page_count += 1 + + if not tools_heading_found: + print("Error: 'Tools' heading not found in left column.", file=sys.stderr) + return False + + # Check for link_to_page blocks in Tools column + if link_to_page_count < 2: + print(f"Error: Tools column should have at least 2 link_to_page blocks, found {link_to_page_count}.", file=sys.stderr) + return False + + print(f"✓ Found {link_to_page_count} link_to_page blocks in Tools column") + + # Verify right column (Terminologies) + right_column = column_children[1] + if right_column.get("type") != "column": + print("Error: Second child of column_list should be a column.", file=sys.stderr) + return False + + right_column_id = right_column.get("id") + right_column_blocks = notion_utils.get_all_blocks_recursively(notion, right_column_id) + + # Check for Terminologies heading in right column + terminologies_heading_found = False + for block in right_column_blocks: + if block.get("type") == "heading_2": + heading_text = block.get("heading_2", {}).get("rich_text", [{}])[0].get("text", {}).get("content", "") + if heading_text == "Terminologies": + terminologies_heading_found = True + print("✓ Found 'Terminologies' heading in right column") + break + + if not terminologies_heading_found: + print("Error: 'Terminologies' heading not found in right column.", file=sys.stderr) + return False + + # Step 5: Verify toggle block content + print("5. Verifying toggle block content...") + + toggle_block = all_blocks[toggle_index] + toggle_id = toggle_block.get("id") + + # Get direct children of toggle + try: + toggle_response = notion.blocks.children.list(block_id=toggle_id) + toggle_children = toggle_response.get("results", []) + except Exception as e: + print(f"Error getting toggle children: {e}", file=sys.stderr) + return False + + # Check for child_page blocks (Notion and Figma) + notion_page_found = False + figma_page_found = False + + for block in toggle_children: + if block.get("type") == "child_page": + title = block.get("child_page", {}).get("title", "") + if title == "Notion": + notion_page_found = True + print("✓ Found 'Notion' child page in toggle") + elif title == "Figma": + figma_page_found = True + print("✓ Found 'Figma' child page in toggle") + + if not notion_page_found: + print("Error: 'Notion' child page not found in toggle block.", file=sys.stderr) + return False + + if not figma_page_found: + print("Error: 'Figma' child page not found in toggle block.", file=sys.stderr) + return False + + # Step 6: Verify that original sections no longer exist at top level + print("6. Verifying original sections have been removed from top level...") + + # Check that there's no standalone "Terminologies" heading before "Roles & responsibilities" + for i in range(0, roles_index if roles_index else len(all_blocks)): + block = all_blocks[i] + if block.get("type") == "heading_2": + heading_text = block.get("heading_2", {}).get("rich_text", [{}])[0].get("text", {}).get("content", "") + if heading_text == "Terminologies": + print("Error: 'Terminologies' section found before 'Roles & responsibilities'.", file=sys.stderr) + return False + + # Check that there's no standalone "Tools" heading outside the column + tools_outside_column = False + for i, block in enumerate(all_blocks): + if i == tools_column_index: + continue # Skip the column_list itself + if block.get("type") == "heading_2": + heading_text = block.get("heading_2", {}).get("rich_text", [{}])[0].get("text", {}).get("content", "") + if heading_text == "Tools" and i != tools_column_index: + # Check if this is NOT inside the column + parent_id = block.get("parent", {}).get("block_id") + if parent_id != left_column_id: + tools_outside_column = True + break + + if tools_outside_column: + print("Error: Standalone 'Tools' section found outside column layout.", file=sys.stderr) + return False + + print("✓ Original sections have been properly reorganized") + + # Step 7: Final summary + print("\n7. Final verification summary:") + print("✓ 'Roles & responsibilities' and 'Terminologies' sections have been swapped") + print("✓ 'Tools' and 'Terminologies' are in a 2-column layout") + print("✓ Links to Notion and Figma pages are in the Tools column") + print("✓ Original child pages are preserved in 'original pages' toggle") + print("✓ Page structure is correct") + + print("\n✅ All verification checks passed!") + return True + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/standard/team_projects/priority_tasks_table/description.md b/tasks/notion/standard/team_projects/priority_tasks_table/description.md new file mode 100644 index 0000000000000000000000000000000000000000..a6f3e256fdd1b9397298a0d072f5330773c20aef --- /dev/null +++ b/tasks/notion/standard/team_projects/priority_tasks_table/description.md @@ -0,0 +1,21 @@ +Hi! In my Team Projects page, please create a five-column table block that lists all tasks meeting either of the following conditions: + 1. The progress is 50% or less, or + 2. The task has priority P0 but is not yet completed (i.e., progress not at 100%). + +You should query this information from the existing “Projects” database. + +In the newly created table, each row should represent one task, and all information should be stored as plain text (not relations, formulas, or linked properties). + +In the newly created table: + • Each row should represent one task + • All fields should be stored as plain text (not relations, formulas, or linked properties) + • The table should be sorted by expected end date (End Date) in ascending order, so that the first entry is the one with the earliest end date + +The table should include the following headers: + • Project + • Eng Hours + • Progress + • Start Date + • End Date + +Please make sure all relevant tasks are included. Thank you! \ No newline at end of file diff --git a/tasks/notion/standard/team_projects/priority_tasks_table/meta.json b/tasks/notion/standard/team_projects/priority_tasks_table/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..aa20e4823435e333adf31dacbc9cd0ce04df33eb --- /dev/null +++ b/tasks/notion/standard/team_projects/priority_tasks_table/meta.json @@ -0,0 +1,25 @@ +{ + "task_id": "priority_tasks_table", + "task_name": "Priority Tasks Table", + "category_id": "team_projects", + "category_name": "Team Projects", + "description": "Create a five-column table listing tasks with 50% or less progress or P0 priority tasks not completed.", + "author": "Zijian Wu", + "created_at": "2025-08-12", + "difficulty": "L3", + "tags": [ + "conditional filtering", + "database manipulation", + "data aggregation", + "visual formatting" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Team-Projects-24e81626b6d7809c982fdb7a25825898", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/gantt-chart" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/team_projects/priority_tasks_table/verify.py b/tasks/notion/standard/team_projects/priority_tasks_table/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..9394185707a48d0c7744324e1b543a21f6b151f7 --- /dev/null +++ b/tasks/notion/standard/team_projects/priority_tasks_table/verify.py @@ -0,0 +1,221 @@ +import sys +from datetime import datetime +from notion_client import Client +from tasks.utils import notion_utils + +EXPECTED_HEADERS = ["Project", "Eng Hours", "Progress", "Start Date", "End Date"] + +EXPECTED_ROWS = [ + { + "Project": "Improve response times for support requests", + "Eng Hours": 100, + "Progress": 0.33, # 33% + "Start Date": "2024-10-30", + "End Date": "2024-11-17", + }, + { + "Project": "Add a new social media integration", + "Eng Hours": 200, + "Progress": 0.40, # 40% + "Start Date": "2024-11-07", + "End Date": "2024-11-25", + }, + { + "Project": "Integrate with a popular third-party service", + "Eng Hours": 250, + "Progress": 0.20, # 20% + "Start Date": "2024-11-10", + "End Date": "2024-11-18", + }, + { + "Project": "Create customer knowledge base", + "Eng Hours": 150, + "Progress": 0.40, # 40% + "Start Date": "2024-11-19", + "End Date": "2024-11-25", + }, + { + "Project": "Redesign the onboarding process", + "Eng Hours": 300, + "Progress": 0.75, # 75% + "Start Date": "2024-11-20", + "End Date": "2024-12-04", + }, + { + "Project": "Publish support knowledge base", + "Eng Hours": None, # N/A + "Progress": 0.0, # 0% + "Start Date": "2024-11-27", + "End Date": "2024-11-29", + }, +] + +# Sort the expected rows by End Date so we can directly compare order +EXPECTED_ROWS.sort(key=lambda r: r["End Date"]) + + +def _plain_text_from_cell(cell): + """Concatenate plain_text from a single cell (list of rich_text).""" + return "".join(rt.get("plain_text", "") for rt in cell).strip() + + +def _parse_progress(value: str): + """Convert a progress string like '40%', '40.0 %', '0.4' to float in range 0-1.""" + value = value.strip() + if not value: + return None + + has_percent = "%" in value + # Remove percent sign and any spaces + value = value.replace("%", "").strip() + try: + num = float(value) + if has_percent or num > 1: + num /= 100.0 + return num + except ValueError: + return None + + +def _parse_eng_hours(value: str): + value = value.strip().lower() + if value in {"n/a", "na", "", "—", "-"}: + return None + try: + return float(value) + except ValueError: + return None + + +def _parse_date(value: str): + value = value.strip() + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + return None + + +def verify(notion: Client, main_id: str = None) -> bool: + """Verify that the last table in the 'Team Projects' page matches EXPECTED_ROWS and headers.""" + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if found_id and object_type == 'page': + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Team Projects") + if not page_id: + print("Error: Page 'Team Projects' not found.", file=sys.stderr) + return False + + # Fetch all blocks to locate table blocks + blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + table_blocks = [b for b in blocks if b.get("type") == "table"] + if not table_blocks: + print("Error: No table blocks found in 'Team Projects' page.", file=sys.stderr) + return False + + table_block = table_blocks[-1] # Use the last table block + table_id = table_block["id"] + + # Retrieve table rows + rows = notion.blocks.children.list(block_id=table_id).get("results", []) + if not rows: + print("Error: Table block has no rows.", file=sys.stderr) + return False + + # Validate headers + header_cells = rows[0].get("table_row", {}).get("cells", []) + headers = [_plain_text_from_cell(c) for c in header_cells] + if headers != EXPECTED_HEADERS: + print(f"Error: Table headers mismatch. Found {headers}, expected {EXPECTED_HEADERS}.", file=sys.stderr) + return False + + # Parse data rows + data_rows = [] + for r in rows[1:]: + cells = r.get("table_row", {}).get("cells", []) + if len(cells) < 5: + continue # Skip malformed rows + project = _plain_text_from_cell(cells[0]) + eng_hours_raw = _plain_text_from_cell(cells[1]) + progress_raw = _plain_text_from_cell(cells[2]) + start_raw = _plain_text_from_cell(cells[3]) + end_raw = _plain_text_from_cell(cells[4]) + + row_dict = { + "Project": project, + "Eng Hours": _parse_eng_hours(eng_hours_raw), + "Progress": _parse_progress(progress_raw), + "Start Date": start_raw.strip(), + "End Date": end_raw.strip(), + } + data_rows.append(row_dict) + + if len(data_rows) != len(EXPECTED_ROWS): + print(f"Error: Expected {len(EXPECTED_ROWS)} data rows, found {len(data_rows)}.", file=sys.stderr) + return False + + # Verify sorting by End Date ascending + parsed_end_dates = [_parse_date(r["End Date"]) for r in data_rows] + if any(d is None for d in parsed_end_dates): + print("Error: One or more End Date values could not be parsed.", file=sys.stderr) + return False + if parsed_end_dates != sorted(parsed_end_dates): + print("Error: Table is not sorted by End Date ascending.", file=sys.stderr) + return False + + # Create mapping from project -> row for comparison + data_map = {r["Project"]: r for r in data_rows} + + for expected in EXPECTED_ROWS: + proj = expected["Project"] + if proj not in data_map: + print(f"Error: Project '{proj}' not found in table.", file=sys.stderr) + return False + actual = data_map[proj] + + # Compare Eng Hours + expected_hours = expected["Eng Hours"] + actual_hours = actual["Eng Hours"] + if expected_hours is None: + if actual_hours is not None: + print(f"Error: Eng Hours for '{proj}' expected to be empty/N\u204aA but found '{actual_hours}'.", file=sys.stderr) + return False + else: + if actual_hours is None or abs(actual_hours - expected_hours) > 1e-2: + print(f"Error: Eng Hours for '{proj}' mismatch. Expected {expected_hours}, found {actual_hours}.", file=sys.stderr) + return False + + # Compare Progress with tolerance + expected_progress = expected["Progress"] + actual_progress = actual["Progress"] + if actual_progress is None or abs(actual_progress - expected_progress) > 1e-2: + print(f"Error: Progress for '{proj}' mismatch. Expected {expected_progress}, found {actual_progress}.", file=sys.stderr) + return False + + # Compare Start and End Dates (string equality) + if actual["Start Date"] != expected["Start Date"]: + print(f"Error: Start Date for '{proj}' mismatch. Expected {expected['Start Date']}, found {actual['Start Date']}.", file=sys.stderr) + return False + if actual["End Date"] != expected["End Date"]: + print(f"Error: End Date for '{proj}' mismatch. Expected {expected['End Date']}, found {actual['End Date']}.", file=sys.stderr) + return False + + print("Success: Verified table block contents and order successfully.") + return True + + +def main(): + """Execute verification and exit with status code.""" + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/standard/team_projects/swap_tasks/description.md b/tasks/notion/standard/team_projects/swap_tasks/description.md new file mode 100644 index 0000000000000000000000000000000000000000..9008c1278381ea4ef5c3ba530d7286af6a219bec --- /dev/null +++ b/tasks/notion/standard/team_projects/swap_tasks/description.md @@ -0,0 +1 @@ +Go to the Team Projects page, find the person responsible for the most tasks and the person responsible for the fewest tasks, then swap their assigned tasks. \ No newline at end of file diff --git a/tasks/notion/standard/team_projects/swap_tasks/meta.json b/tasks/notion/standard/team_projects/swap_tasks/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..2752f03ad1150f365491f5902f885415b9dc5607 --- /dev/null +++ b/tasks/notion/standard/team_projects/swap_tasks/meta.json @@ -0,0 +1,24 @@ +{ + "task_id": "swap_tasks", + "task_name": "Swap Tasks", + "category_id": "team_projects", + "category_name": "Team Projects", + "description": "Find the person responsible for the most and fewest tasks, then swap their assigned tasks.", + "author": "Xiangyan Liu", + "created_at": "2025-08-12", + "difficulty": "L3", + "tags": [ + "data aggregation", + "automated migration", + "conditional filtering" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Team-Projects-24e81626b6d7809c982fdb7a25825898", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/gantt-chart" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/team_projects/swap_tasks/verify.py b/tasks/notion/standard/team_projects/swap_tasks/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..2c9a8d6c6b4b9327759635915517325b2c97174b --- /dev/null +++ b/tasks/notion/standard/team_projects/swap_tasks/verify.py @@ -0,0 +1,215 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the task assignees have been swapped correctly. + Checks: + 1. "Develop a plan for promotion" and "Evaluate different third-party services" have swapped assignees + 2. The person with most tasks and person with least tasks have swapped all their tasks + """ + # Step 1: Find the Team Projects page + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not found_id or object_type != 'page': + print("Error: Team Projects page not found.", file=sys.stderr) + return False + else: + # Try to find the page by searching + found_id = notion_utils.find_page(notion, "Team Projects") + if not found_id: + print("Error: Team Projects page not found.", file=sys.stderr) + return False + + # Get all blocks from the page to find database references + all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id) + + # Find Tasks database ID from the page + tasks_db_id = None + + for block in all_blocks: + if block and block.get("type") == "child_database": + db_title = block.get("child_database", {}).get("title", "") + if "Tasks" in db_title: + tasks_db_id = block["id"] + break + + if not tasks_db_id: + print("Error: Tasks database not found.", file=sys.stderr) + return False + + print("\n📋 Starting verification...") + + # Step 2: Query all tasks to analyze assignees + + try: + all_tasks_response = notion.databases.query( + database_id=tasks_db_id, + page_size=100 + ) + + if not all_tasks_response.get("results"): + print("Error: No tasks found in Tasks database.", file=sys.stderr) + return False + + tasks = all_tasks_response["results"] + + except Exception as e: + print(f"Error querying Tasks database: {e}", file=sys.stderr) + return False + + # Step 3: Check specific tasks have swapped assignees + + develop_plan_task = None + evaluate_services_task = None + + for task in tasks: + task_name = task["properties"]["Name"]["title"][0]["text"]["content"] + if task_name == "Develop a plan for promotion": + develop_plan_task = task + elif task_name == "Evaluate different third-party services": + evaluate_services_task = task + + if not develop_plan_task or not evaluate_services_task: + print("Error: Could not find both required tasks.", file=sys.stderr) + return False + + # Get assignees for these tasks + develop_plan_assignees = develop_plan_task["properties"]["Assigned"]["people"] + evaluate_services_assignees = evaluate_services_task["properties"]["Assigned"]["people"] + + if not develop_plan_assignees or not evaluate_services_assignees: + print("Error: Tasks don't have assignees.", file=sys.stderr) + return False + + develop_plan_assignee_id = develop_plan_assignees[0]["id"] + evaluate_services_assignee_id = evaluate_services_assignees[0]["id"] + + # These should be different (swapped) + if develop_plan_assignee_id == evaluate_services_assignee_id: + print("Error: Tasks should have different assignees after swap.", file=sys.stderr) + return False + + # Step 4: Count tasks per person + + task_counts = {} + unassigned_count = 0 + + for task in tasks: + assignees = task["properties"]["Assigned"]["people"] + if assignees: + assignee_id = assignees[0]["id"] + if assignee_id not in task_counts: + task_counts[assignee_id] = [] + task_counts[assignee_id].append(task["properties"]["Name"]["title"][0]["text"]["content"]) + else: + unassigned_count += 1 + + # Sort by task count + sorted_assignees = sorted(task_counts.items(), key=lambda x: len(x[1])) + + if len(sorted_assignees) < 2: + print("Error: Need at least 2 people with tasks to verify swap.", file=sys.stderr) + return False + + # Get person with least and most tasks + person_with_least = sorted_assignees[0] + person_with_most = sorted_assignees[-1] + + least_id, least_tasks = person_with_least + most_id, most_tasks = person_with_most + + # Step 5: Verify the swap pattern + + # Original distribution (before swap): + # - 5ac96c02-49a4-4320-8de6-b663ba83126b had 3 tasks (least) + # - ac7a3bd0-c111-4464-8f45-8a857a1abc8a had 10 tasks (most) + + # After complete swap, we expect: + # - 5ac96c02-49a4-4320-8de6-b663ba83126b should have 10 tasks + # - ac7a3bd0-c111-4464-8f45-8a857a1abc8a should have 3 tasks + + original_least_id = "5ac96c02-49a4-4320-8de6-b663ba83126b" + original_most_id = "ac7a3bd0-c111-4464-8f45-8a857a1abc8a" + + # Check if the swap has been completed + swap_completed = False + for assignee_id, assignee_tasks in task_counts.items(): + if assignee_id == original_least_id and len(assignee_tasks) == 10: + # Person who had 3 now has 10 + for other_id, other_tasks in task_counts.items(): + if other_id == original_most_id and len(other_tasks) == 3: + # Person who had 10 now has 3 + swap_completed = True + break + + # Step 6: Summary + print(f"\n📊 Task Distribution:") + print(f" • Total tasks: {len(tasks)}") + print(f" • Assigned tasks: {len(tasks) - unassigned_count}") + print(f" • Unassigned tasks: {unassigned_count}") + print(f" • People with tasks: {len(task_counts)}") + print(f"\n Task counts by person:") + for assignee_id, assignee_tasks in sorted_assignees: + print(f" - {assignee_id[:8]}...: {len(assignee_tasks)} tasks") + + # Step 7: Final verification + print("\n🔍 Verification Results:") + + # Check that the swap has created a significant difference + if len(most_tasks) - len(least_tasks) < 5: + print(f"Warning: Difference between most and least is only {len(most_tasks) - len(least_tasks)} tasks", file=sys.stderr) + + # Verify specific expected outcomes + verification_passed = True + + # Check 1: Specific tasks have been swapped + specific_tasks_swapped = develop_plan_assignee_id != evaluate_services_assignee_id + if specific_tasks_swapped: + print(" ✓ Specific tasks have been swapped") + else: + print(" ✗ Specific tasks were not swapped", file=sys.stderr) + verification_passed = False + + # Check 2: Task distribution shows a complete swap + if swap_completed: + print(" ✓ Complete task swap verified (3↔10 tasks)") + else: + # Show actual distribution for debugging + person1_tasks = len(task_counts.get(original_least_id, [])) + person2_tasks = len(task_counts.get(original_most_id, [])) + print(f" ✗ Swap incomplete! Expected 5ac96c02→10 tasks, ac7a3bd0→3 tasks", file=sys.stderr) + print(f" Actual: 5ac96c02→{person1_tasks} tasks, ac7a3bd0→{person2_tasks} tasks", file=sys.stderr) + verification_passed = False + + # Check 3: Total task count is preserved + total_assigned_tasks = sum(len(tasks) for _, tasks in task_counts.items()) + expected_total = len(tasks) - unassigned_count + + if total_assigned_tasks == expected_total: + print(f" ✓ Total task count preserved ({total_assigned_tasks} assigned)") + else: + print(f" ✗ Task count mismatch: {total_assigned_tasks} vs {expected_total} expected", file=sys.stderr) + verification_passed = False + + if verification_passed: + print("\n✅ All verification checks passed!") + return True + else: + print("\n❌ Verification failed", file=sys.stderr) + return False + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/standard/toronto_guide/change_color/description.md b/tasks/notion/standard/toronto_guide/change_color/description.md new file mode 100644 index 0000000000000000000000000000000000000000..c04fe75cb2004c09e5b01e16ad41923094b9aeff --- /dev/null +++ b/tasks/notion/standard/toronto_guide/change_color/description.md @@ -0,0 +1,8 @@ +Navigate to the Toronto Guide page in Notion and change all pink-colored elements (tags and callout colors) to different colors. + +## Requirements +1. Find and access the Toronto Guide page in Notion +2. Identify and change all pink elements including: + - Pink tags in databases + - Pink callout backgrounds +3. Change all pink colors to any other color of your choice \ No newline at end of file diff --git a/tasks/notion/standard/toronto_guide/change_color/meta.json b/tasks/notion/standard/toronto_guide/change_color/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..d7f787b0fdfc830fed63f2e8302b540a3dbd92e7 --- /dev/null +++ b/tasks/notion/standard/toronto_guide/change_color/meta.json @@ -0,0 +1,23 @@ +{ + "task_id": "change_color", + "task_name": "Change Color", + "category_id": "toronto_guide", + "category_name": "Toronto Guide", + "description": "Navigate to the Toronto Guide page and change all pink-colored elements to different colors.", + "author": "Xiangyan Liu", + "created_at": "2025-08-14", + "difficulty": "L3", + "tags": [ + "visual formatting", + "conditional filtering" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Toronto-Guide-25281626b6d7802caa7cc394647e901c", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/conquering-toronto-a-destination-guide" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/toronto_guide/change_color/verify.py b/tasks/notion/standard/toronto_guide/change_color/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..f80b396ec12623a34edfd0c3ac2f285370b4b9b5 --- /dev/null +++ b/tasks/notion/standard/toronto_guide/change_color/verify.py @@ -0,0 +1,387 @@ +import sys +from notion_client import Client +from tasks.utils import notion_utils + +def get_page_title(page_result): + """Extract title from a page result""" + properties = page_result.get('properties', {}) + for prop_name in ['Name', 'Title', 'title']: + if prop_name in properties: + prop = properties[prop_name] + if prop.get('type') == 'title': + title_array = prop.get('title', []) + if title_array and len(title_array) > 0: + return title_array[0].get('plain_text', '') + return '' + +def get_page_tags(page_result): + """Extract tags from a page result""" + properties = page_result.get('properties', {}) + tags_property = properties.get('Tags', {}) + if tags_property.get('type') == 'multi_select': + tags = tags_property.get('multi_select', []) + return [tag.get('name') for tag in tags] + return [] + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that all pink colors have been changed in the Toronto Guide page. + + Expected pink elements that should be changed: + 1. Callout: "Welcome to Toronto!" with red_background (originally should be pink) + 2. Activities database tags: + - "Parks" tag (High Park, Evergreen Brickworks) + - "Neighbourhood" tag (Ossington Strip, Chinatown, Little Italy, Kensington Market, Queen west, The beaches) + 3. Food database tags: + - "Middle Eastern" (Byblos Downtown) + - "Jamaican" (Crumbs Patties) + - "Indian" (Leela Indian Food Bar) + 4. Cafes database tag: + - "Food" (Cafe Landwer) + + These elements should exist with the same name/content but different colors. + Tag distributions should remain the same. + """ + # Step 1: Find the main Toronto Guide page + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if not found_id or object_type != 'page': + print("Error: Toronto Guide page not found.", file=sys.stderr) + return False + else: + # Try to find the page by searching + found_id = notion_utils.find_page(notion, "Toronto Guide") + if not found_id: + print("Error: Toronto Guide page not found.", file=sys.stderr) + return False + + print(f"Found Toronto Guide page: {found_id}") + + # Get all blocks from the page + all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id) + print(f"Found {len(all_blocks)} blocks") + + # Expected elements and their distributions + expected_pink_elements = { + "callout": { + "text": "Welcome to Toronto!", + "found": False, + "has_pink": False, + "exists": False + }, + "activities_tags": { + "Parks": { + "found": False, + "has_pink": False, + "expected_items": ["High Park", "Evergreen Brickworks"], + "actual_items": [] + }, + "Neighbourhood": { + "found": False, + "has_pink": False, + "expected_items": ["Ossington Strip", "Chinatown", "Little Italy", "Kensington Market", "Queen west", "The beaches"], + "actual_items": [] + } + }, + "food_tags": { + "Middle Eastern": { + "found": False, + "has_pink": False, + "expected_items": ["Byblos Downtown"], + "actual_items": [] + }, + "Jamaican": { + "found": False, + "has_pink": False, + "expected_items": ["Crumbs Patties"], + "actual_items": [] + }, + "Indian": { + "found": False, + "has_pink": False, + "expected_items": ["Leela Indian Food Bar"], + "actual_items": [] + } + }, + "cafes_tags": { + "Food": { + "found": False, + "has_pink": False, + "expected_items": ["Cafe Landwer"], + "actual_items": [] + } + } + } + + # Database IDs + activities_db_id = None + food_db_id = None + cafes_db_id = None + + # Step 2: Check all blocks for callouts and find databases + for block in all_blocks: + if block is None: + continue + + block_type = block.get("type") + + # Check for the specific callout block + if block_type == "callout": + callout_text = notion_utils.get_block_plain_text(block) + if "Welcome to Toronto!" in callout_text: + expected_pink_elements["callout"]["exists"] = True + expected_pink_elements["callout"]["found"] = True + color = block.get("callout", {}).get("color", "") + if "pink" in color.lower(): + expected_pink_elements["callout"]["has_pink"] = True + print(f"✗ Callout 'Welcome to Toronto!' still has pink color: {color}") + else: + print(f"✓ Callout 'Welcome to Toronto!' has non-pink color: {color}") + + # Find child databases + elif block_type == "child_database": + title = block.get("child_database", {}).get("title", "") + block_id = block.get("id") + + if "Activities" in title: + activities_db_id = block_id + print(f"Found Activities database: {block_id}") + elif "Food" in title: + food_db_id = block_id + print(f"Found Food database: {block_id}") + elif "Cafes" in title or "Café" in title: + cafes_db_id = block_id + print(f"Found Cafes database: {block_id}") + + # Step 3: Check Activities database for specific tags and their distributions + if activities_db_id: + try: + # Get database properties + db_info = notion.databases.retrieve(database_id=activities_db_id) + tags_property = db_info.get("properties", {}).get("Tags", {}) + if tags_property.get("type") == "multi_select": + options = tags_property.get("multi_select", {}).get("options", []) + for option in options: + tag_name = option.get("name").strip() + tag_color = option.get("color") + + if tag_name in expected_pink_elements["activities_tags"]: + expected_pink_elements["activities_tags"][tag_name]["found"] = True + if tag_color == "pink": + expected_pink_elements["activities_tags"][tag_name]["has_pink"] = True + print(f"✗ Activities tag '{tag_name}' still has pink color") + else: + print(f"✓ Activities tag '{tag_name}' changed to {tag_color}") + + # Query database to check tag distributions + query_result = notion.databases.query(database_id=activities_db_id) + for page in query_result.get('results', []): + page_title = get_page_title(page).strip() + page_tags = get_page_tags(page) + + for tag_name in expected_pink_elements["activities_tags"]: + if tag_name in page_tags: + expected_pink_elements["activities_tags"][tag_name]["actual_items"].append(page_title) + + except Exception as e: + print(f"Error checking Activities database: {e}", file=sys.stderr) + return False + else: + print("Error: Activities database not found", file=sys.stderr) + return False + + # Step 4: Check Food database for specific tags and their distributions + if food_db_id: + try: + # Get database properties + db_info = notion.databases.retrieve(database_id=food_db_id) + tags_property = db_info.get("properties", {}).get("Tags", {}) + if tags_property.get("type") == "multi_select": + options = tags_property.get("multi_select", {}).get("options", []) + for option in options: + tag_name = option.get("name").strip() + tag_color = option.get("color") + + if tag_name in expected_pink_elements["food_tags"]: + expected_pink_elements["food_tags"][tag_name]["found"] = True + if tag_color == "pink": + expected_pink_elements["food_tags"][tag_name]["has_pink"] = True + print(f"✗ Food tag '{tag_name}' still has pink color") + else: + print(f"✓ Food tag '{tag_name}' changed to {tag_color}") + + # Query database to check tag distributions + query_result = notion.databases.query(database_id=food_db_id) + for page in query_result.get('results', []): + page_title = get_page_title(page).strip() + page_tags = get_page_tags(page) + + for tag_name in expected_pink_elements["food_tags"]: + if tag_name in page_tags: + expected_pink_elements["food_tags"][tag_name]["actual_items"].append(page_title) + + except Exception as e: + print(f"Error checking Food database: {e}", file=sys.stderr) + return False + else: + print("Error: Food database not found", file=sys.stderr) + return False + + # Step 5: Check Cafes database for specific tags and their distributions + if cafes_db_id: + try: + # Get database properties + db_info = notion.databases.retrieve(database_id=cafes_db_id) + tags_property = db_info.get("properties", {}).get("Tags", {}) + if tags_property.get("type") == "multi_select": + options = tags_property.get("multi_select", {}).get("options", []) + for option in options: + tag_name = option.get("name").strip() + tag_color = option.get("color") + + if tag_name in expected_pink_elements["cafes_tags"]: + expected_pink_elements["cafes_tags"][tag_name]["found"] = True + if tag_color == "pink": + expected_pink_elements["cafes_tags"][tag_name]["has_pink"] = True + print(f"✗ Cafes tag '{tag_name}' still has pink color") + else: + print(f"✓ Cafes tag '{tag_name}' changed to {tag_color}") + + # Query database to check tag distributions + query_result = notion.databases.query(database_id=cafes_db_id) + for page in query_result.get('results', []): + page_title = get_page_title(page).strip() + page_tags = get_page_tags(page) + + for tag_name in expected_pink_elements["cafes_tags"]: + if tag_name in page_tags: + expected_pink_elements["cafes_tags"][tag_name]["actual_items"].append(page_title) + + except Exception as e: + print(f"Error checking Cafes database: {e}", file=sys.stderr) + return False + else: + print("Error: Cafes database not found", file=sys.stderr) + return False + + # Step 6: Verify all requirements + print(f"\nVerification Summary:") + + all_passed = True + + # Check callout + if not expected_pink_elements["callout"]["exists"]: + print("✗ 'Welcome to Toronto!' callout not found", file=sys.stderr) + all_passed = False + elif expected_pink_elements["callout"]["has_pink"]: + print("✗ Callout still has pink background", file=sys.stderr) + all_passed = False + else: + print("✓ Callout color changed from pink") + + # Check Activities tags + print("\nActivities Database Tags:") + for tag_name, tag_info in expected_pink_elements["activities_tags"].items(): + if not tag_info["found"]: + print(f"✗ Activities tag '{tag_name}' not found (may have been renamed)", file=sys.stderr) + # Don't fail if tag was renamed, as that's acceptable + elif tag_info["has_pink"]: + print(f"✗ Activities tag '{tag_name}' still has pink color", file=sys.stderr) + all_passed = False + else: + print(f"✓ Activities tag '{tag_name}' color changed from pink") + + # Check distribution + expected_set = set(tag_info["expected_items"]) + actual_set = set(tag_info["actual_items"]) + if tag_info["found"] and expected_set != actual_set: + print(f" ✗ Tag distribution mismatch for '{tag_name}':", file=sys.stderr) + print(f" Expected: {sorted(expected_set)}", file=sys.stderr) + print(f" Actual: {sorted(actual_set)}", file=sys.stderr) + # Note: We don't fail on distribution mismatch if tag was renamed + if not (expected_set - actual_set): # If all expected items are present + print(f" (Additional items found, but all expected items are present)") + elif tag_info["found"]: + print(f" ✓ Tag distribution maintained for '{tag_name}'") + + # Check Food tags + print("\nFood Database Tags:") + for tag_name, tag_info in expected_pink_elements["food_tags"].items(): + if not tag_info["found"]: + print(f"✗ Food tag '{tag_name}' not found (may have been renamed)", file=sys.stderr) + # Don't fail if tag was renamed, as that's acceptable + elif tag_info["has_pink"]: + print(f"✗ Food tag '{tag_name}' still has pink color", file=sys.stderr) + all_passed = False + else: + print(f"✓ Food tag '{tag_name}' color changed from pink") + + # Check distribution + expected_set = set(tag_info["expected_items"]) + actual_set = set(tag_info["actual_items"]) + if tag_info["found"] and expected_set != actual_set: + print(f" ✗ Tag distribution mismatch for '{tag_name}':", file=sys.stderr) + print(f" Expected: {sorted(expected_set)}", file=sys.stderr) + print(f" Actual: {sorted(actual_set)}", file=sys.stderr) + elif tag_info["found"]: + print(f" ✓ Tag distribution maintained for '{tag_name}'") + + # Check Cafes tags + print("\nCafes Database Tags:") + for tag_name, tag_info in expected_pink_elements["cafes_tags"].items(): + if not tag_info["found"]: + print(f"✗ Cafes tag '{tag_name}' not found (may have been renamed)", file=sys.stderr) + # Don't fail if tag was renamed, as that's acceptable + elif tag_info["has_pink"]: + print(f"✗ Cafes tag '{tag_name}' still has pink color", file=sys.stderr) + all_passed = False + else: + print(f"✓ Cafes tag '{tag_name}' color changed from pink") + + # Check distribution + expected_set = set(tag_info["expected_items"]) + actual_set = set(tag_info["actual_items"]) + if tag_info["found"] and expected_set != actual_set: + print(f" ✗ Tag distribution mismatch for '{tag_name}':", file=sys.stderr) + print(f" Expected: {sorted(expected_set)}", file=sys.stderr) + print(f" Actual: {sorted(actual_set)}", file=sys.stderr) + elif tag_info["found"]: + print(f" ✓ Tag distribution maintained for '{tag_name}'") + + # Additional check: ensure no other pink elements exist + print("\nChecking for any other pink elements...") + other_pink_found = False + + # Check all callouts for pink + for block in all_blocks: + if block and block.get("type") == "callout": + color = block.get("callout", {}).get("color", "") + if "pink" in color.lower(): + callout_text = notion_utils.get_block_plain_text(block)[:50] + if "Welcome to Toronto!" not in callout_text: + print(f"✗ Found unexpected pink callout: {callout_text}...", file=sys.stderr) + other_pink_found = True + + if other_pink_found: + all_passed = False + else: + print("✓ No unexpected pink elements found") + + return all_passed + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + + if verify(notion, main_id): + print("\nVerification passed: All expected pink colors have been changed") + sys.exit(0) + else: + print("\nVerification failed: Some pink colors still exist or elements are missing") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tasks/notion/standard/toronto_guide/weekend_adventure_planner/description.md b/tasks/notion/standard/toronto_guide/weekend_adventure_planner/description.md new file mode 100644 index 0000000000000000000000000000000000000000..87ccc8f4d317276d3b9c003e4067100d99319194 --- /dev/null +++ b/tasks/notion/standard/toronto_guide/weekend_adventure_planner/description.md @@ -0,0 +1,21 @@ +Create a comprehensive weekend adventure planner that analyzes the Toronto Guide databases and generates a structured itinerary page. I need you to create a new page called 'Perfect Weekend Adventure' as a child of the main Toronto Guide page. + +**Task Requirements:** +1. Create a new page titled 'Perfect Weekend Adventure' as a child page of the main Toronto Guide page +2. Query the Activities database to identify all activities that have the "Beaches" tag +3. Query the Food database to find all restaurants with "Turkish" or "Hakka" tags +4. Query the Cafes database to retrieve all cafes entries +5. Structure the page with the following specific format: + - Add a heading_1 block with text "🎒 Perfect Weekend Adventure" + - Add a heading_2 block with text "🏖️ Beach Activities" + - Under Beach Activities, create a bulleted list with all activities that have the "Beaches" tag, showing: Name - Google Maps Link (if available) + - Add a heading_2 block with text "🍽️ Cultural Dining Experience" + - Under Cultural Dining, create a numbered list of all restaurants with "Turkish" or "Hakka" tags, formatted as: Restaurant Name (Tag: [actual tag name]) + - Add a heading_2 block with text "☕ Coffee Break Spots" + - Under Coffee Break Spots, create a toggle block titled "Top Cafes to Visit" containing all cafe entries as to-do items (unchecked), each showing just the cafe name + - Add a heading_2 block with text "📊 Weekend Summary" + - Under Weekend Summary, add a paragraph with the exact text: "This weekend includes [X] beach activities, [Y] cultural dining options, and [Z] coffee spots to explore!" where [X], [Y], and [Z] are the actual counts +6. After the summary paragraph, add a divider block +7. Finally, add a callout block with the 💡 emoji containing the text: "Pro tip: Check the Seasons database for the best time to enjoy outdoor activities!" +8. Ensure all headings use the exact emoji and text format specified above +9. The lists must be in the exact format specified (bulleted for beaches, numbered for restaurants, to-do for cafes) \ No newline at end of file diff --git a/tasks/notion/standard/toronto_guide/weekend_adventure_planner/meta.json b/tasks/notion/standard/toronto_guide/weekend_adventure_planner/meta.json new file mode 100644 index 0000000000000000000000000000000000000000..f92652f9c6ce20ee39ab705def3d04164650502b --- /dev/null +++ b/tasks/notion/standard/toronto_guide/weekend_adventure_planner/meta.json @@ -0,0 +1,26 @@ +{ + "task_id": "weekend_adventure_planner", + "task_name": "Weekend Adventure Planner", + "category_id": "toronto_guide", + "category_name": "Toronto Guide", + "description": "Create a comprehensive weekend adventure planner that analyzes Toronto Guide databases and generates a structured itinerary page.", + "author": "Xiangyan Liu", + "created_at": "2025-08-14", + "difficulty": "L3", + "tags": [ + "conditional filtering", + "data aggregation", + "report generation", + "visual formatting", + "status tracking" + ], + "mcp": [ + "notion" + ], + "meta_data": { + "stateType": "url", + "stateContent": null, + "stateUrl": "https://painted-tennis-ebc.notion.site/Toronto-Guide-25281626b6d7802caa7cc394647e901c", + "stateOriginalUrl": "https://www.notion.so/marketplace/templates/conquering-toronto-a-destination-guide" + } +} \ No newline at end of file diff --git a/tasks/notion/standard/toronto_guide/weekend_adventure_planner/verify.py b/tasks/notion/standard/toronto_guide/weekend_adventure_planner/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..68cff9898fdc8885ccb61f314da72bd1591ffe59 --- /dev/null +++ b/tasks/notion/standard/toronto_guide/weekend_adventure_planner/verify.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +from notion_client import Client +from tasks.utils import notion_utils + + +def verify(notion: Client, main_id: str = None) -> bool: + """ + Verifies that the Perfect Weekend Adventure page has been created correctly. + """ + # Find the main Toronto Guide page + page_id = None + if main_id: + found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id) + if found_id and object_type == "page": + page_id = found_id + + if not page_id: + page_id = notion_utils.find_page(notion, "Toronto Guide") + if not page_id: + print("Error: Main 'Toronto Guide' page not found.", file=sys.stderr) + return False + + # Find the Perfect Weekend Adventure child page + adventure_page_id = None + try: + response = notion.search( + query="Perfect Weekend Adventure", + filter={"property": "object", "value": "page"} + ) + + for result in response.get("results", []): + parent = result.get("parent", {}) + if parent.get("type") == "page_id" and parent.get("page_id") == page_id: + adventure_page_id = result["id"] + break + + if not adventure_page_id: + for result in response.get("results", []): + title_list = result.get("properties", {}).get("title", {}).get("title", []) + for title_obj in title_list: + if "Perfect Weekend Adventure" in title_obj.get("plain_text", ""): + adventure_page_id = result["id"] + break + if adventure_page_id: + break + + except Exception as e: + print(f"Error searching for Perfect Weekend Adventure page: {e}", file=sys.stderr) + return False + + if not adventure_page_id: + print("Error: 'Perfect Weekend Adventure' page not found as child of main page.", file=sys.stderr) + return False + + # Get all blocks from the adventure page + all_blocks = notion_utils.get_all_blocks_recursively(notion, adventure_page_id) + + # Get databases from the main Toronto Guide page + activities_db_id = None + food_db_id = None + cafes_db_id = None + + main_blocks = notion_utils.get_all_blocks_recursively(notion, page_id) + for block in main_blocks: + if block.get("type") == "child_database": + title = block.get("child_database", {}).get("title", "") + if "Activities" in title: + activities_db_id = block.get("id") + elif "Food" in title: + food_db_id = block.get("id") + elif "Cafes" in title or "Caf�" in title: + cafes_db_id = block.get("id") + + # Query databases to get expected data + beach_activities = [] + cultural_restaurants = [] + cafes_list = [] + + if activities_db_id: + try: + db_response = notion.databases.query(database_id=activities_db_id) + for page in db_response.get("results", []): + properties = page.get("properties", {}) + tags_prop = properties.get("Tags", {}) + if tags_prop.get("type") == "multi_select": + tags = [tag.get("name") for tag in tags_prop.get("multi_select", [])] + if "Beaches" in tags: + name_prop = properties.get("Name", {}) + if name_prop.get("type") == "title" and name_prop.get("title"): + name = name_prop["title"][0]["plain_text"] + url_prop = properties.get("Google Maps Link", {}) + url = url_prop.get("url", "") if url_prop.get("type") == "url" else "" + beach_activities.append({"name": name, "url": url}) + except Exception as e: + print(f"Error querying Activities database: {e}", file=sys.stderr) + return False + + if food_db_id: + try: + db_response = notion.databases.query(database_id=food_db_id) + for page in db_response.get("results", []): + properties = page.get("properties", {}) + tags_prop = properties.get("Tags", {}) + if tags_prop.get("type") == "multi_select": + tags = [tag.get("name") for tag in tags_prop.get("multi_select", [])] + for tag in tags: + if tag in ["Turkish", "Hakka"]: + name_prop = properties.get("Name", {}) + if name_prop.get("type") == "title" and name_prop.get("title"): + name = name_prop["title"][0]["plain_text"] + cultural_restaurants.append({"name": name, "tag": tag}) + break + except Exception as e: + print(f"Error querying Food database: {e}", file=sys.stderr) + return False + + if cafes_db_id: + try: + db_response = notion.databases.query(database_id=cafes_db_id) + for page in db_response.get("results", []): + properties = page.get("properties", {}) + name_prop = properties.get("Name", {}) + if name_prop.get("type") == "title" and name_prop.get("title"): + name = name_prop["title"][0]["plain_text"] + cafes_list.append(name) + except Exception as e: + print(f"Error querying Cafes database: {e}", file=sys.stderr) + return False + + # Required headings and their types + required_headings = [ + ("🎒 Perfect Weekend Adventure", "heading_1"), + ("🏖️ Beach Activities", "heading_2"), + ("🍽️ Cultural Dining Experience", "heading_2"), + ("☕ Coffee Break Spots", "heading_2"), + ("📊 Weekend Summary", "heading_2") + ] + + # Track verification results + found_headings = set() + found_beach_list = False + found_restaurant_list = False + found_toggle_with_cafes = False + found_summary = False + found_divider = False + found_callout = False + + # Variables to track counts + beach_count = 0 + restaurant_count = 0 + cafe_count = 0 + + current_section = None + is_in_toggle = False + + for block in all_blocks: + block_type = block.get("type") + block_text = notion_utils.get_block_plain_text(block) + + # Check headings + for heading_text, expected_type in required_headings: + if heading_text in block_text and block_type == expected_type: + found_headings.add(heading_text) + current_section = heading_text + + # Check Beach Activities section + if current_section == "🏖️ Beach Activities" and block_type == "bulleted_list_item": + found_beach_list = True + beach_count += 1 + # Verify format includes name and potentially URL + for activity in beach_activities: + if activity["name"] in block_text: + if activity["url"] and activity["url"] not in block_text: + print(f"Warning: Beach activity '{activity['name']}' missing URL", file=sys.stderr) + + # Check Cultural Dining section + elif current_section == "🍽️ Cultural Dining Experience" and block_type == "numbered_list_item": + found_restaurant_list = True + restaurant_count += 1 + # Check format: Restaurant Name (Tag: [tag]) + for restaurant in cultural_restaurants: + if restaurant["name"] in block_text and f"Tag: {restaurant['tag']}" in block_text: + pass # Format is correct + + # Check Coffee Break Spots section + elif current_section == "☕ Coffee Break Spots": + if block_type == "toggle" and "Top Cafes to Visit" in block_text: + is_in_toggle = True + found_toggle_with_cafes = True + elif is_in_toggle and block_type == "to_do": + cafe_count += 1 + # Verify unchecked status + to_do_data = block.get("to_do", {}) + if to_do_data.get("checked", False): + print(f"Error: Cafe to-do item should be unchecked: {block_text}", file=sys.stderr) + return False + elif block_type in ["heading_1", "heading_2", "heading_3"]: + is_in_toggle = False + + # Check Weekend Summary section + elif current_section == "📊 Weekend Summary" and block_type == "paragraph": + expected_text = f"This weekend includes {len(beach_activities)} beach activities, {len(cultural_restaurants)} cultural dining options, and {len(cafes_list)} coffee spots to explore!" + if expected_text in block_text: + found_summary = True + + # Check for divider after summary + if block_type == "divider": + found_divider = True + + # Check for callout with pro tip + if block_type == "callout": + callout_data = block.get("callout", {}) + icon = callout_data.get("icon", {}) + if icon.get("type") == "emoji" and icon.get("emoji") == "💡": + if "Pro tip: Check the Seasons database for the best time to enjoy outdoor activities!" in block_text: + found_callout = True + + # Verify all required elements + all_passed = True + + # Check all headings are present + for heading_text, _ in required_headings: + if heading_text not in found_headings: + print(f"Error: Missing required heading: {heading_text}", file=sys.stderr) + all_passed = False + + # Check beach activities list + if not found_beach_list: + print("Error: Beach activities bulleted list not found", file=sys.stderr) + all_passed = False + elif beach_count != len(beach_activities): + print(f"Error: Expected {len(beach_activities)} beach activities, found {beach_count}", file=sys.stderr) + all_passed = False + + # Check restaurant list + if not found_restaurant_list: + print("Error: Cultural dining numbered list not found", file=sys.stderr) + all_passed = False + elif restaurant_count != len(cultural_restaurants): + print(f"Error: Expected {len(cultural_restaurants)} cultural restaurants, found {restaurant_count}", file=sys.stderr) + all_passed = False + + # Check cafes toggle + if not found_toggle_with_cafes: + print("Error: Toggle block 'Top Cafes to Visit' not found", file=sys.stderr) + all_passed = False + elif cafe_count != len(cafes_list): + print(f"Error: Expected {len(cafes_list)} cafes, found {cafe_count}", file=sys.stderr) + all_passed = False + + # Check summary + if not found_summary: + print("Error: Weekend summary with correct counts not found", file=sys.stderr) + all_passed = False + + # Check divider + if not found_divider: + print("Error: Divider block not found after summary", file=sys.stderr) + all_passed = False + + # Check callout + if not found_callout: + print("Error: Callout with pro tip not found", file=sys.stderr) + all_passed = False + + if all_passed: + print(f"Success: Perfect Weekend Adventure page created with all required elements.") + print(f"- {len(beach_activities)} beach activities") + print(f"- {len(cultural_restaurants)} cultural dining options") + print(f"- {len(cafes_list)} coffee spots") + return True + else: + return False + + +def main(): + """ + Executes the verification process and exits with a status code. + """ + notion = notion_utils.get_notion_client() + main_id = sys.argv[1] if len(sys.argv) > 1 else None + if verify(notion, main_id): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file