diff --git a/.gitignore b/.gitignore index 17b4f22ae18..2156a38f68a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,9 @@ scripts/auto-translate/batch-translate/courses_report.txt scripts/auto-translate/translation_logic/argon-edge-450706-b7-55a6efa2821f.json .obsidian/ .claude/ -CLAUDE.md +/CLAUDE.md sync-errors-summary.md -AGENTS.md +/AGENTS.md .serena .env scripts/pdf_courses diff --git a/content-types.yml b/content-types.yml new file mode 100644 index 00000000000..d3db305bbc9 --- /dev/null +++ b/content-types.yml @@ -0,0 +1,251 @@ +# content-types.yml — Single source of truth for all content types in the repository +# Consumed by: bec CLI, AGENTS.md, validation logic +# ~1,400 tokens — designed for fast agent loading + +content_types: + course: + name: Course + path_pattern: "courses/{id}/" + metadata_file: course.yml + schema: schemas/course-scheme.json + content_schema: schemas/course-content-scheme.json + has_markdown_content: true + has_quizzes: true + example: courses/btc101 + + tutorial: + name: Tutorial + path_pattern: "tutorials/{category}/{id}/" + metadata_file: tutorial.yml + schema: schemas/tutorial-scheme.json + content_schema: schemas/tutorial-content-scheme.json + has_markdown_content: true + example: tutorials/wallet/sparrow + + professor: + name: Professor + path_pattern: "professors/{id}/" + metadata_file: professor.yml + schema: schemas/professor-scheme.json + content_schema: schemas/professor-content-scheme.json + has_markdown_content: true + example: professors/asi0 + + event: + name: Event + path_pattern: "events/{id}/" + metadata_file: event.yml + schema: schemas/event-scheme.json + has_markdown_content: false + example: events/bitcoin-nashville-2024 + + bet: + name: BET + path_pattern: "resources/bet/{id}/" + metadata_file: bet.yml + schema: schemas/bet-scheme.json + content_schema: schemas/bet-content-scheme.json + content_uses_yml: true + example: resources/bet/artist + + book: + name: Book + path_pattern: "resources/books/{id}/" + metadata_file: book.yml + schema: schemas/book-scheme.json + content_schema: schemas/book-content-scheme.json + content_uses_yml: true + example: resources/books/mastering-bitcoin + + channel: + name: Channel + path_pattern: "resources/channels/{id}/" + metadata_file: channel.yml + schema: schemas/channel-scheme.json + has_markdown_content: false + example: resources/channels/robin-seyr + + conference: + name: Conference + path_pattern: "resources/conferences/{id}/" + metadata_file: conference.yml + schema: schemas/conference-scheme.json + has_markdown_content: false + example: resources/conferences/bitcoin-nashville-2024 + + glossary: + name: Glossary Word + path_pattern: "resources/glossary/{id}/" + metadata_file: word.yml + schema: schemas/word-scheme.json + content_schema: schemas/word-content-scheme.json + has_markdown_content: true + example: resources/glossary/51-percent-attack + + movie: + name: Movie + path_pattern: "resources/movies/{id}/" + metadata_file: movie.yml + schema: schemas/movie-scheme.json + has_markdown_content: false + example: resources/movies/cypherpunks-write-code + + newsletter: + name: Newsletter + path_pattern: "resources/newsletters/{id}/" + metadata_file: newsletter.yml + schema: schemas/newsletter-scheme.json + has_markdown_content: false + example: resources/newsletters/bitcoin-in-a-nutshell + + podcast: + name: Podcast + path_pattern: "resources/podcasts/{id}/" + metadata_file: podcast.yml + schema: schemas/podcast-scheme.json + has_markdown_content: false + example: resources/podcasts/arancione + + project: + name: Project + path_pattern: "resources/projects/{id}/" + metadata_file: project.yml + schema: schemas/project-scheme.json + content_schema: schemas/project-content-scheme.json + content_uses_yml: true + example: resources/projects/bitstack + + paper: + name: Paper + path_pattern: "resources/papers/{id}/" + metadata_file: paper.yml + schema: schemas/paper-scheme.json + has_markdown_content: false + example: resources/papers/a-method-for-obtaining-digital-signatures-and-public-key-cryptosystems + +tutorial_categories: + - business + - computer-security + - contribution + - exchange + - mining + - node + - privacy + - wallet + +discipline_codes: + btc: Bitcoin Core + biz: Business + csv: Data Analysis + cyp: Cryptography + dev: Development + eco: Economics + ene: Energy + his: History + lnp: Lightning Network + min: Mining + net: Network + phi: Philosophy + pos: Point of Sale + pro: Protocol + scu: Security + sid: Sidechains & Layers + soc: Sociology + +level_range: + min: 101 + max: 499 + beginner: "101-199" + intermediate: "200-299" + advanced: "300-399" + expert: "400-499" + +tags: + - proof-of-work + - software + - mining + - fees + - hardware + - hardware-wallet-ready + - wallets + - investment + - keys + - market-trends + - finance + - onchain + - offchain + - lightning + - decentralization + - smart-contracts + - DIY + - node + - backup + - guides + - use-case + - user-friendly + - historical + - scalability + - protocols + - layers + - sidechains + - personal-security + - network-security + - privacy + - regulation + - risks + - future-outlook + - adoption + - case-studies + - good-practice + - innovation + - cypherpunk + - self-sovereignty + - DIY-IT + - consensus + - development + - interoperability + - technical-analysis + - update + - legacy + - deep-dive + - high-level + - easy-explain + - experimental + - business + - evaluation + +languages: + - ar + - bg + - cs + - de + - en + - es + - et + - fa + - fi + - fr + - hi + - id + - it + - ja + - ko + - nb-NO + - nl + - pl + - pt + - ru + - si + - sk + - sr-Latn + - sv + - sw + - th + - tr + - vi + - zh-Hans + - zh-Hant + +quiz_schemas: + question: schemas/quizz-question-scheme.json + translation: schemas/quizz-translation-scheme.json diff --git a/docs/50-planb-tags.md b/docs/50-planb-tags.md deleted file mode 100644 index b80771e44d3..00000000000 --- a/docs/50-planb-tags.md +++ /dev/null @@ -1,54 +0,0 @@ -#### List of Plan ₿ Academy tags with description - - 1. proof-of-work: referring to all things needed to prove some job has been done - 2. software: regarding programs, services you use after being installed on servers, PCs and smartphones - 3. mining: all topics related to BTC mining - 4. fees: aspects referring to every cost when it comes to send BTC value (in any way) - 5. hardware: every hardware-related topic, mutually exclusive with the hardware-wallet-ready tag - 6. hardware-wallet-ready: content compatible with hardware wallets, mutually exclusive with the hardware tag - 7. wallets: every tool allowing to manage or spend bitcoin - 8. investment: bitcoin is an investment, but in which way it's up to the topic of the content - 9. keys: every topic regarding storage of the secrets to spend bitcoin - 10. market-trends: the market rules everything, and we may indentify some trends -11. finance: addresses all aspects regarding how money and financial system work -12. onchain: aspects referring to Bitcoin blockchain only -13. offchain: every aspect referring to Bitcoin, with the exclusion of the blockchain -14. lightning: everything that is about Lightining Network -15. decentralization: aspects referring to one of Bitcoin's most important feature -16. smart-contracts: regarding a self-executing contract with the terms of the agreement directly written into code -17. DIY: do it yourself, stands for every hardware porject you can build on your own, with some raw material -18. node: every software agent that validates and relays information, contributing to the functionality of that specific network -19. backup: explore ways how to improve redundacy of secret keys storage -20. guides: step-to-step explanation to do something -21. use-case: openly and directly addressing specific use cases for something -22. user-friendly: when a topic shows content with the goal of being easily understandable by beginner and intermediate bitcoiners -23. historical: content telling about historical bitcoin facts and data. It may also be intended for both general and economic history when dealing with Bitcoin's origin -24. scalability: topics regarding difficulty to scale one of bitcoin-related networks -25. protocols: whenever topics regard separate sets of rules, where each one governs how participants interact and maintain consensus -26. layers: different protocols can lay on different levels between each other and versus Bitcoin mainnet -27. sidechains: it is about independent blockchains that run parallel and bound to the Bitcoin main chain -28. personal-security: topics referring to security aspects with focus on threats suitable for physical people -29. network-security: topics with the focus to security aspects of the network -30. privacy: main focus here would be to preserve end-user sensitive information that can lead to personal identification -31. regulation: sometimes talking about laws, compliance, regulation in general is important -32. risks: highlight potential danger of bitcoin custody and usage -33. future-outlook: information and hypothesis regarding future happening (and problems) that the network could face -34. adoption: topics regarding using bitcoin for everyday purchases, its roles such medium-of-exchange and store of value -35. case-studies: content presented in a way of an "Harvard business case study" -36. good-practice: suggest specific actions when it comes to do something, and explain why -37. innovation: it is about new features over the Bitcoin protocol -38. cypherpunk: refers to content promoting, explaining and applicating any cypherpunk value -39. self-sovereignty: different from cypherpunk, it links content that are still focused on the individual without requiring him/her to be a cryptographic/tech expert. This concept's boundary is laying more on a day-to-day physical level. -40. DIY-IT: do it yourself, stands for every software project you can develop on your own, with some examples and guidance from a reliable source -41. consensus: topics referring to security of the network facing incentive mechanisms that bring order over chaos -42. development: discussion on potential features that may be useful, really experienced users may invest energy into this topic -43. interoperability: topics facing the possibility of mutual interation between parts of network -44. technical-analysis: aspects analyzed from perspective of their technical feasibility -45. update: tracks the presence of updates on tutos and courses over time -46. legacy: meta-tag that identifies old procedures and resources not available anymore -47. deep-dive: the selected tag explains something deep, regardless of its actual bitcoin-grade content -48. high-level: opposite of deep-dive -49. easy-explain: describe if a content needs simplified writing style -50. experimental: meta-tag that addresses features, ideas, concepts that are still not available for immediate use, but that will be better known in the next future -51. business: about companies and startup in the Bitcoin ecosystem -52. evaluation: the content, whatever it would be, is about assessing the skills and abilities of its users diff --git a/docs/PBN-template-repo/README_images_rules.md b/docs/PBN-template-repo/README_images_rules.md deleted file mode 100644 index b133e78e3e4..00000000000 --- a/docs/PBN-template-repo/README_images_rules.md +++ /dev/null @@ -1,55 +0,0 @@ -# Repository - -List of rules regarding images for all the repo, see each chapter for more details. - -## All content - -The rule contained in this chapter apply and refer to every data type in the repo - -**General Image Requirement 1**: images of all types should not have any background shapes e.g. rounded corners. - -**General Image Requirement 2**: do not bake any shapes into the background itself. The visual shape (circle, rounded rectangle, etc.) will be applied by the UI, otherwise we have glitches like in the case of the expanded image view - -### Resources - -The following rules apply to thumbnails and logo files related to the following resource types: -- channels -- newsletters -- podcasts -- projects - -**Image requirements** - -- format: `webp` only (`.webp` file extension) -- shape: square image only -- size: max 800 x 800 pixels -- background colors: - - **do not** use pure black background - please use a dark-grey background of your choice instead - - **do not** use pure white background - please use a light-grey background of your choice instead - - the background must consist of a single color. -- filename: - - `logo.webp` for project, podcast types - - `thumbnail.webp` for channel, newsletter types -- image content: - - whenever available, always put a logomark. Logotypes are deprecated and shall not be used at all. - - don't insert additional shapes and borders in the background - -### Tutorials - -The following rules apply to logo files related to the tutorial files: - -**Image requirements** - -- format: `webp` only (`.webp` file extension) -- shape: square image only -- size: max 800 x 800 pixels -- background colors: - - **do not** use pure black background - please use a dark-grey background of your choice instead - - **do not** use pure white background - please use a light-grey background of your choice instead - - the background must consist of a single color. -- filename: - - `logo.webp` for project, podcast types - - `thumbnail.webp` for channel, newsletter types -- image content: - - whenever available, always put a logomark. Logotypes are deprecated and shall not be used at all. - - don't insert additional shapes and borders in the background \ No newline at end of file diff --git a/docs/PBN-template-repo/courses/course-content-scheme.json b/docs/PBN-template-repo/courses/course-content-scheme.json deleted file mode 100644 index 7947270a2dc..00000000000 --- a/docs/PBN-template-repo/courses/course-content-scheme.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Course Content Markdown", - "description": "Schema for validating course markdown content files (e.g., en.md, fr.md). These files contain YAML front-matter and structured markdown content.", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Short, explicit name of the course. Should be concise but descriptive.", - "minLength": 1, - "maxLength": 200 - }, - "goal": { - "type": "string", - "description": "The main end goal of the course expressed in one sentence. Describes what students will achieve upon completion.", - "minLength": 1 - }, - "objectives": { - "type": "array", - "description": "Learning objectives for the course. Should use action verbs to define skills students will acquire. Recommended: 3-4 objectives.", - "items": { - "type": "string", - "description": "A single learning objective starting with an action verb (e.g., 'Understand', 'Learn', 'Master', 'Implement', 'Analyze').", - "minLength": 1 - }, - "minItems": 1, - "maxItems": 10 - } - }, - "required": ["name", "goal", "objectives"], - "additionalProperties": false, - "content_rules": { - "description": "Structural rules for the markdown body content. JSON Schema cannot fully validate markdown structure, but these rules document the required format.", - "structure": { - "description_section": { - "description": "The content between the YAML front-matter closing '---' and the '+++' separator serves as the course description page.", - "rules": [ - "Must start with an H1 heading (# Title) immediately after the front-matter", - "Contains introductory text about the course", - "Should state approximate time to complete the course", - "Should mention prerequisites if applicable", - "The thumbnail.webp image from assets/ folder will be displayed here" - ] - }, - "separator": { - "description": "The '+++' marker is REQUIRED and separates the description section from the main course content.", - "pattern": "^\\+\\+\\+$", - "required": true - }, - "parts": { - "description": "Parts are major sections of the course, defined by H1 headings (#).", - "rules": [ - "Each part MUST have a UUID tag immediately after the heading", - "UUID format: 8-4-4-4-12 hexadecimal characters (e.g., 97ccb669-12a0-5eed-83ac-c2f51839d998)", - "Parts separate the course into themes or main subjects", - "UUIDs must be identical across translations of the same content" - ], - "example": "# Part Title\n\n97ccb669-12a0-5eed-83ac-c2f51839d998" - }, - "chapters": { - "description": "Chapters are sub-sections within parts, defined by H2 headings (##).", - "rules": [ - "Each chapter MUST have a UUID tag immediately after the heading", - "UUID format: 8-4-4-4-12 hexadecimal characters", - "Each chapter is displayed on its own page on Plan B Academy", - "A part must contain at least one chapter", - "UUIDs must be identical across translations of the same content" - ], - "example": "## Chapter Title\n\n4dc58281-5179-507c-afde-8c9204cbd3fd" - }, - "sections": { - "description": "Sections are optional sub-divisions within chapters, defined by H3 headings (###).", - "rules": [ - "Sections do NOT require UUID tags", - "Used to separate chapter content into sub-topics for clarity", - "Optional - chapters can exist without sections" - ], - "example": "### Section Title" - } - }, - "media": { - "video_embeds": { - "description": "Video content can be embedded using two syntaxes.", - "formats": [ - { - "type": "directive", - "pattern": ":::video id=UUID:::", - "description": "Preferred format for internal video references", - "example": ":::video id=de7236a0-2985-41ef-86f7-3fa0b7f94531:::" - }, - { - "type": "markdown_image", - "pattern": "![description](https://www.youtube.com/watch?v=VIDEO_ID)", - "description": "Alternative format using markdown image syntax for YouTube videos", - "example": "![Awesome music](https://www.youtube.com/watch?v=IO-tUpkygaI)" - } - ] - }, - "images": { - "description": "Images should be stored in the assets/ folder and referenced with relative paths.", - "rules": [ - "Images with text: ./assets/{language_code}/filename.webp (e.g., ./assets/en/diagram.webp)", - "Images without text: ./assets/no-txt/filename.webp", - "Preferred format: WebP for storage efficiency", - "Alternative formats accepted but WebP conversion recommended" - ], - "patterns": { - "with_text": "^\\./assets/[a-z]{2}(-[A-Z]{2})?/.+\\.webp$", - "without_text": "^\\./assets/no-txt/.+\\.webp$" - }, - "examples": [ - "![BTC 101 curriculum](./assets/en/btc101-curriculum.webp)", - "![Plan B Academy logo](./assets/no-txt/PBN-logo.webp)" - ] - } - }, - "special_markers": { - "description": "Special HTML-like tags that mark specific chapter types for platform functionality.", - "markers": { - "isCourseReview": { - "description": "Marks a chapter as the course review section", - "pattern": "true", - "placement": "Immediately after the chapterId tag", - "optional": true - }, - "isCourseExam": { - "description": "Marks a chapter as the final exam section", - "pattern": "true", - "placement": "Immediately after the chapterId tag", - "optional": true - }, - "isCourseConclusion": { - "description": "Marks a chapter as the course conclusion", - "pattern": "true", - "placement": "Immediately after the chapterId tag", - "optional": true - } - }, - "example": "## Course Review\n\nf551b514-6ba5-11f0-833e-b33af48c067b\n\ntrue" - }, - "uuid_format": { - "description": "UUID format specification used for partId and chapterId tags.", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", - "example": "97ccb669-12a0-5eed-83ac-c2f51839d998", - "notes": [ - "UUIDs are version 4 (random)", - "Must be lowercase hexadecimal", - "Python scripts are available to generate UUIDs", - "Reviewers can also add UUIDs during PR review" - ] - } - }, - "presentation_md": { - "description": "Optional presentation.md file for in-person/live courses. Serves as a landing page with marketing and logistics information.", - "applies_when": "Course has format field in course.yml (online, inperson, or hybrid)", - "location": "Course root directory alongside course.yml", - "structure": { - "description": "Pure markdown file without YAML front-matter", - "rules": [ - "Starts with H1 heading (# Title) describing the course", - "Contains marketing/presentation content for the course landing page", - "Should describe target audience and who the course is for", - "Should outline course format and structure (sections, duration)", - "Should list prerequisites (can be 'none required')", - "Should include logistics section with date, time, location, price, and registration info", - "Images can be included from assets/ folder", - "No partId or chapterId tags required - this is not course content" - ], - "recommended_sections": [ - "# Course Title - Main heading with course description", - "### Target Audience - Who should attend", - "### Course Format - Structure and sections outline", - "## Prerequisites - Required knowledge or 'no prerequisites'", - "# Logistics - Date, time, location, price, registration details" - ] - }, - "example_structure": "# Course Title\n\nIntroductory paragraph...\n\n![Course image](assets/image.webp)\n\n### Target Audience\n- Bullet points...\n\n### Course Format\n**Section 1 - ...**\n**Section 2 - ...**\n\n## Prerequisites\nNo prerequisites required...\n\n# Logistics\n**When:** Date and time\n**Where:** Location\n**Price:** Cost or FREE\n" - }, - "examples": [ - { - "description": "Minimal valid front-matter", - "yaml": "---\nname: Introduction to Bitcoin\ngoal: Understand the basics of Bitcoin technology\nobjectives:\n - Learn what Bitcoin is\n - Understand blockchain fundamentals\n - Know how to secure bitcoins\n---" - }, - { - "description": "Complete front-matter with detailed objectives", - "yaml": "---\nname: Advanced Lightning Network Development\ngoal: Master Lightning Network protocol implementation and channel management\nobjectives:\n - Understand the Lightning Network architecture and its relationship with Bitcoin\n - Implement payment channels and HTLCs\n - Deploy and manage Lightning nodes in production\n - Develop applications using Lightning Network APIs\n---" - } - ] -} diff --git a/docs/PBN-template-repo/courses/course-scheme.json b/docs/PBN-template-repo/courses/course-scheme.json deleted file mode 100644 index 5e2015750b7..00000000000 --- a/docs/PBN-template-repo/courses/course-scheme.json +++ /dev/null @@ -1,330 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "course-characteristics", - "description": "Schema for validating course metadata (course.yml)", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the course (UUID format)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "topic": { - "type": "string", - "description": "Main topic of the course (lowercase)", - "enum": ["bitcoin", "business", "mining", "protocol", "security", "sociology", "sovereignty", "social studies"] - }, - "subtopic": { - "type": "string", - "description": "Subtopic of the course (lowercase), depends on the main topic", - "enum": [ - "bitcoin", - "business", - "accounting", - "data-analysis", - "entrepreneurship", - "finance", - "point-of-sale", - "point-of-sales", - "mining", - "energy", - "protocol", - "client-side-validation", - "client side validation", - "script", - "sidechain", - "lightning", - "lightning network", - "network", - "security", - "cryptography", - "sociology", - "economy", - "history", - "philosophy", - "social studies", - "sovereignty", - "do-it-yourself", - "open-source", - "development" - ] - }, - "type": { - "type": "string", - "description": "Type of course content", - "enum": ["theory", "practice"] - }, - "level": { - "type": "string", - "description": "Difficulty level of the course", - "enum": ["beginner", "intermediate", "advanced", "expert", "wizard"] - }, - "hours": { - "type": "integer", - "description": "Estimated number of hours to complete the course", - "minimum": 1 - }, - "teaching_format": { - "type": "string", - "description": "How the course is delivered", - "enum": ["self_paced", "professor_led"] - }, - "professors_id": { - "type": "array", - "description": "List of professor UUIDs who teach this course", - "items": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "minItems": 1 - }, - "project_id": { - "type": "string", - "description": "UUID of the project associated with this course", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "contributor_names": { - "type": "array", - "description": "List of contributor GitHub usernames", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "published_at": { - "type": "string", - "description": "Date when the course was published (YYYY-MM-DD format)", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "original_language": { - "type": "string", - "description": "ISO 639-1 language code of the original course language", - "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" - }, - "proofreading": { - "type": "array", - "description": "Proofreading metadata for each language version", - "items": { - "type": "object", - "properties": { - "language": { - "type": "string", - "description": "ISO 639-1 language code for this proofreading entry", - "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" - }, - "last_contribution_date": { - "type": ["string", "null"], - "description": "Date of the last contribution in YYYY-MM-DD format", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "urgency": { - "type": "integer", - "description": "Level of urgency for proofreading, 1 being low urgency", - "minimum": 1 - }, - "contributor_names": { - "type": ["array", "null"], - "description": "List of contributor GitHub usernames", - "items": { - "type": "string" - } - }, - "reward": { - "type": "number", - "description": "Amount of sats available for reward", - "minimum": 0 - } - }, - "required": ["language", "urgency", "reward"], - "additionalProperties": false - }, - "minItems": 1 - }, - "tags": { - "type": "array", - "description": "List of tags associated with this course from the PlanB Network tag list", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "license": { - "type": "string", - "description": "License of the course content", - "enum": ["CC-BY-SA-V4", "MIT"] - }, - "test_only": { - "type": "boolean", - "description": "If true, course is only published on testnet; if false, on mainnet" - }, - "format": { - "type": "string", - "description": "Delivery format for live/in-person courses (only for courses with presentation.md)", - "enum": ["online", "inperson", "hybrid"] - }, - "address_city_country": { - "type": "string", - "description": "City and country for in-person courses (e.g., 'Paris, France')" - }, - "address_line_2": { - "type": "string", - "description": "Detailed address for in-person courses (e.g., '3 rue de Montyon, 9eme arrondissement')" - }, - "start_date": { - "type": "string", - "description": "Start date of the course in YYYY-MM-DD format", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "end_date": { - "type": "string", - "description": "End date of the course in YYYY-MM-DD format", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "requires_payment": { - "type": "boolean", - "description": "Whether the course requires payment for registration" - }, - "inperson_price_dollars": { - "type": "number", - "description": "Price in USD for in-person attendance (0 for free courses)", - "minimum": 0 - }, - "payment_expiration_date": { - "type": "string", - "description": "Last date for payment/registration in YYYY-MM-DD format", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "available_seats": { - "type": "integer", - "description": "Number of available seats for the course", - "minimum": 1 - }, - "paid_description": { - "type": "string", - "description": "Description shown for paid/registered users" - }, - "is_gdpr_compliance": { - "type": "boolean", - "description": "Whether GDPR compliance is required for registration" - }, - "custom_tc_disclaimer": { - "type": "string", - "description": "Custom terms and conditions disclaimer for registration" - }, - "videos": { - "type": "array", - "description": "Optional list of video content associated with course chapters", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "UUID identifier for the video entry", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" - }, - "youtube": { - "type": "array", - "description": "List of YouTube video IDs by language", - "items": { - "type": "object", - "description": "Language code to YouTube video ID mapping", - "additionalProperties": { - "type": "string", - "description": "YouTube video ID" - } - } - }, - "peertube": { - "type": "array", - "description": "List of PeerTube video IDs by language", - "items": { - "type": "object", - "description": "Language code to PeerTube video ID mapping", - "additionalProperties": { - "type": "string", - "description": "PeerTube video ID" - } - } - }, - "rumble": { - "type": "array", - "description": "List of Rumble video IDs by language", - "items": { - "type": "object", - "description": "Language code to Rumble video ID mapping", - "additionalProperties": { - "type": "string", - "description": "Rumble video ID" - } - } - } - }, - "required": ["id"], - "anyOf": [ - { "required": ["youtube"] }, - { "required": ["peertube"] }, - { "required": ["rumble"] } - ], - "additionalProperties": false - } - } - }, - "required": [ - "id", - "topic", - "subtopic", - "level", - "hours", - "professors_id", - "original_language", - "proofreading" - ], - "additionalProperties": true, - "allOf": [ - { - "if": { - "properties": { - "format": { "enum": ["inperson", "hybrid"] } - }, - "required": ["format"] - }, - "then": { - "required": [ - "address_city_country", - "start_date", - "end_date", - "requires_payment", - "available_seats" - ] - } - }, - { - "if": { - "properties": { - "format": { "const": "online" } - }, - "required": ["format"] - }, - "then": { - "required": [ - "start_date", - "end_date", - "requires_payment" - ] - } - }, - { - "if": { - "properties": { - "requires_payment": { "const": true } - }, - "required": ["requires_payment"] - }, - "then": { - "required": [ - "payment_expiration_date" - ] - } - } - ] -} diff --git a/docs/PBN-template-repo/courses/topic101/assets/en/btc101-curriculum.webp b/docs/PBN-template-repo/courses/topic101/assets/en/btc101-curriculum.webp deleted file mode 100644 index 230a549e4ed..00000000000 Binary files a/docs/PBN-template-repo/courses/topic101/assets/en/btc101-curriculum.webp and /dev/null differ diff --git a/docs/PBN-template-repo/courses/topic101/assets/es/btc101-curriculum.webp b/docs/PBN-template-repo/courses/topic101/assets/es/btc101-curriculum.webp deleted file mode 100644 index 2b615fad88e..00000000000 Binary files a/docs/PBN-template-repo/courses/topic101/assets/es/btc101-curriculum.webp and /dev/null differ diff --git a/docs/PBN-template-repo/courses/topic101/assets/fr/btc101-curriculum.webp b/docs/PBN-template-repo/courses/topic101/assets/fr/btc101-curriculum.webp deleted file mode 100644 index 96e949a1b70..00000000000 Binary files a/docs/PBN-template-repo/courses/topic101/assets/fr/btc101-curriculum.webp and /dev/null differ diff --git a/docs/PBN-template-repo/courses/topic101/assets/no-txt/PBN-logo.webp b/docs/PBN-template-repo/courses/topic101/assets/no-txt/PBN-logo.webp deleted file mode 100644 index 82417c0b522..00000000000 Binary files a/docs/PBN-template-repo/courses/topic101/assets/no-txt/PBN-logo.webp and /dev/null differ diff --git a/docs/PBN-template-repo/courses/topic101/assets/thumbnail.webp b/docs/PBN-template-repo/courses/topic101/assets/thumbnail.webp deleted file mode 100644 index 8fe6090ab69..00000000000 Binary files a/docs/PBN-template-repo/courses/topic101/assets/thumbnail.webp and /dev/null differ diff --git a/docs/PBN-template-repo/courses/topic101/course.yml b/docs/PBN-template-repo/courses/topic101/course.yml deleted file mode 100644 index 18471522b75..00000000000 --- a/docs/PBN-template-repo/courses/topic101/course.yml +++ /dev/null @@ -1,98 +0,0 @@ -id: 4958d6b5-fbeb-4c89-b34c-fd18d1a23747 -## Characteristics of the course - -# topic -# refer to https://github.com/orgs/PlanB-Network/projects/5/views/2 -# Choose between: bitcoin, business, mining, protocol, security, sociology, sovereignty -topic: business - -# access the subtopic list, follow instructions below: -# go to https://github.com/orgs/PlanB-Network/projects/5/views/3 -# on the left bar, click on a topic to see its available list of subtopics -subtopic: finance - -# define the content difficulty level from user's perspective -# Level can be one of following: -# - beginner -# - intermediate -# - advanced -# - expert -level: beginner - -# teaching_format -# Choose between: self_paced, professor_led -teaching_format: self_paced - -# hours -# Evaluate the number of hours that it would take to -# complete the whole course -hours: 2 - -# List who is/are the professor(s) -# They are identified by their UUID -# See /professors folder for examples and real UUID -# Required (At least 1 professor required, you can specify more than 1) -professors_id: - - 17f8bc5b-7034-4ff6-96e0-11484a133642 - -# If the course is related to an existing project you can associate it to the project by copying here the id of the project (optional) -project_id: 77f56919-1875-45ab-8c4c-b8fb26b021de - -# Contributor section -# List all the contributors who help format, review, translate and proofread the content. -# They are identified by their Github usernames -contributor_names: - - Asi0Flammeus - -# Proofreading metadata -# Only the original author needs to add this data related to initial proofreading -# Any further data will be automatically added after each proofreading -# -# This information are provided by PBN team, no need to specify if you are a contributor. -# for PBN staff: an entry is created for each language specifying: -# - language: language to review -# - last_contribution_date: the date when last contribution has been received -# - urgency: level of urgency from 1 (low urgency) to X (high urgency) -# - contributor_names: list of Github contributors -# - reward: amount of USD available for reward -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - user-friendly - - wallets - - high-level - -# List all the videos inside the course, for each video generate its own id (uuid v4) -# make sure for each id there is a public media source (suitable: youtube, peertube, rumble); -# for each media source explicit both the language code and the specific part of the video URL (each provider will have its own, see examples belows) -videos: - - id: 758d7d3b-84e6-4f52-bf43-967a2ce7e7ec - youtube: - - fr: PdiL6_1wbQY - - id: 04ad728c-9a1c-4630-93f8-bc5ea9e0fb9a - youtube: - - fr: ljHLhTzrLsw - - id: 7448d0bf-7d7f-4f55-b012-b7d0b8e04285 - rumble: - - it: b1t4fz4 - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 - -# This parameter is used to control if a course has to be published only on testnet (parameter set to true) or on mainnet (parameter set to false) -# Choose between true or false -# ATTENTION: be careful using this parameter, in case you set it to true for an existing course, -# both THE CONTENT AND ALL THE DATA IN DATABASE relate to the course WILL BE DELETED from mainnet without confirmation or restoring capabilities. -test_only: false #default parameter diff --git a/docs/PBN-template-repo/courses/topic101/en.md b/docs/PBN-template-repo/courses/topic101/en.md deleted file mode 100644 index 22320671224..00000000000 --- a/docs/PBN-template-repo/courses/topic101/en.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -name: This is an explicit but short name of the course -goal: This is the main and end goal of the course -objectives: - - Smaller objectives of the course - - Use of verbs to define the new skill that student will acquire - - better to be between 3 and 4 objectives ---- - -# This Part is used as a Description Page of the Course - -In this course, I will explain how to create a course using the Plan ₿ Academy Format. - -You should state here the approximate time volume that it takes to finish the whole course. -The course should take approximately 1 hour. -Throughout the journey, explanatory scheme and additional resources are available. - -Better to state also the prerequisite here. -You don't need any specific knowledge to get started, the course is accessible to everyone! - -In this description page we will also displayed the `thumbnail.webp` image present in the `assets/` folder. - -+++ - -# This is a Part of the course - -97ccb669-12a0-5eed-83ac-c2f51839d998 - -## This is a Chapter of the course - -4dc58281-5179-507c-afde-8c9204cbd3fd - -### This is a Section of the course - -Courses is composed of 3 different sub-elements that are: - -- Part, defined by the `# ` level and useful to separate the course in theme or main subject -- Chapter, defined by the `## ` level and useful to separate the part content in several pages as on Plan ₿ Academy each chapter is displayed on its own page -- Section, defined by the `### ` level and useful to separate Chapter in sub-subject for clarity - -This three object are used to create the curriculuum on the Plan ₿ Academy platform. Here's an example for [BTC101](https://planb.academy/courses/2b7dc507-81e3-4b70-88e6-41ed44239966) - -![BTC 101 curriculum](./assets/en/btc101-curriculum.webp) - -You can see that here we have used 2 syntaxes from the Markdown syntax to redirect to an link and to displayed an image that is located in the `assets/` folder of the course folder. - -You may noticed that the image is in the subfolder `assets/en/`, it's because it is a picture that contains english text that will be translated in other languages. If the image doesn't contain text it should be in the subfolder `assets/no-txt/`. - -![Plan ₿ Academy logo](./assets/no-txt/PBN-logo.webp) - -Although every image format is valid, we prefer for storage efficiency reason to convert every image into `.webp` format. If you don't know how to do it, we have a python script that do the job and our code revieviers could also do while reviewing your PR. - -## This is 2nd Chapter - -3ff2a050-c311-55ce-a3c6-3cc450cad1ce - -A chapter doesn't necessarily contains sections (`###`) but a part must contains at least one chapter. - -If you want to learn more about markdown syntax, you can read the [github documentation](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) about it. - -A cool trick is to embed video like this : - -![Awesome music from tip_nz](https://www.youtube.com/watch?v=IO-tUpkygaI) - -## Part and Chapter UUIDs - -eac81877-db9b-5027-87e2-2b28b59459a0 - -To better identify each element of the course, we have decided to add UUIDs for each Chapter and Part. Here again there's a python script available for it, but reviewers could do it too. -The UUIDs are the identical between 2 languages of the same content. diff --git a/docs/PBN-template-repo/courses/topic101/es.md b/docs/PBN-template-repo/courses/topic101/es.md deleted file mode 100644 index e4bab6b5641..00000000000 --- a/docs/PBN-template-repo/courses/topic101/es.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: Este es un nombre explícito pero breve del curso -goal: Este es el objetivo principal y final del curso -objectives: - - Objetivos más pequeños del curso - - Uso de verbos para definir la nueva habilidad que adquirirá el estudiante - - mejor estar entre 3 y 4 objetivos ---- - -# Esta Parte se utiliza como Página de Descripción del Curso - -En este curso, explicaré cómo crear un curso utilizando el Formato de Red PlanB. - -Deberías indicar aquí el volumen de tiempo aproximado que se tarda en completar todo el curso. -El curso debería tomar aproximadamente 1 hora. -A lo largo del viaje, se disponen de esquemas explicativos y recursos adicionales. - -También es mejor indicar los requisitos previos aquí. -¡No necesitas ningún conocimiento específico para comenzar, el curso es accesible para todos! - -En esta página de descripción también mostraremos la imagen `thumbnail.webp` presente en la carpeta `assets/`. - - -+++ - -# Esta es una Parte del curso -97ccb669-12a0-5eed-83ac-c2f51839d998 - -## Este es un Capítulo del curso -4dc58281-5179-507c-afde-8c9204cbd3fd - -### Esta es una Sección del curso - -El curso está compuesto por 3 sub-elementos diferentes que son: -- Parte, definida por el nivel `# ` y útil para separar el curso en tema o asunto principal -- Capítulo, definido por el nivel `## ` y útil para separar el contenido de la parte en varias páginas ya que en la Red PlanB cada capítulo se muestra en su propia página -- Sección, definida por el nivel `### ` y útil para separar el Capítulo en subtemas para mayor claridad - -Estos tres objetos se utilizan para crear el currículo en la plataforma Plan ₿ Academy. Aquí hay un ejemplo para [BTC101](https://planb.academy/courses/2b7dc507-81e3-4b70-88e6-41ed44239966) - -![Currículo de BTC 101](./assets/es/btc101-curriculum.webp) - -Puedes ver que aquí hemos utilizado 2 sintaxis del lenguaje Markdown para redirigir a un enlace y para mostrar una imagen que está ubicada en la carpeta `assets/` del curso. - -Puede que hayas notado que la imagen está en la subcarpeta `assets/es/`, es porque es una imagen que contiene texto en inglés que será traducido a otros idiomas. Si la imagen no contiene texto, debería estar en la subcarpeta `assets/no-txt/`. - -![Logo de Plan ₿ Academy](./assets/no-txt/PBN-logo.webp) - -Aunque todos los formatos de imagen son válidos, preferimos por razones de eficiencia de almacenamiento convertir cada imagen en formato `.webp`. Si no sabes cómo hacerlo, tenemos un script de python que hace el trabajo y nuestros revisores de código también podrían hacerlo mientras revisan tu PR. - - -## Este es el 2do Capítulo -3ff2a050-c311-55ce-a3c6-3cc450cad1ce - -Un capítulo no necesariamente contiene secciones (`###`) pero una parte debe contener al menos un capítulo. - -Si quieres aprender más sobre la sintaxis de markdown, puedes leer la [documentación de github](https://docs.github.com/es/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) al respecto. - -Un truco interesante es incrustar videos de esta manera: - -![Música increíble de tip_nz](https://www.youtube.com/watch?v=IO-tUpkygaI) - -## UUIDs de Parte y Capítulo -eac81877-db9b-5027-87e2-2b28b59459a0 - -Para identificar mejor cada elemento del curso, hemos decidido agregar UUIDs para cada Capítulo y Parte. Aquí también hay disponible un script de python para ello, pero los revisores también podrían hacerlo. -Los UUIDs son idénticos entre 2 idiomas del mismo contenido. diff --git a/docs/PBN-template-repo/courses/topic101/fr.md b/docs/PBN-template-repo/courses/topic101/fr.md deleted file mode 100644 index 191a94f7fe1..00000000000 --- a/docs/PBN-template-repo/courses/topic101/fr.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: Voici un nom explicite mais court du cours -goal: Voici l'objectif principal et final du cours -objectives: - - Objectifs secondaires du cours - - Utilisation de verbes pour définir la nouvelle compétence que l'étudiant acquerra - - il est préférable d'avoir entre 3 et 4 objectifs ---- - -# Cette partie est utilisée comme page de description du cours - -Dans ce cours, je vais expliquer comment créer un cours en utilisant le format de réseau PlanB. - -Vous devriez indiquer ici le volume de temps approximatif nécessaire pour terminer l'ensemble du cours. -Le cours devrait prendre environ 1 heure. -Tout au long du parcours, un schéma explicatif et des ressources supplémentaires sont disponibles. - -Il est également préférable de mentionner les prérequis ici. -Vous n'avez besoin d'aucune connaissance spécifique pour commencer, le cours est accessible à tous ! - -Dans cette page de description, nous afficherons également l'image `thumbnail.webp` présente dans le dossier `assets/`. - - -+++ - -# Ceci est une partie du cours -97ccb669-12a0-5eed-83ac-c2f51839d998 - -## Ceci est un chapitre du cours -4dc58281-5179-507c-afde-8c9204cbd3fd - -### Ceci est une section du cours - -Le cours est composé de 3 sous-éléments différents qui sont : -- Partie, définie par le niveau `# ` et utile pour séparer le cours en thème ou sujet principal -- Chapitre, défini par le niveau `## ` et utile pour séparer le contenu de la partie en plusieurs pages comme sur le réseau PlanB où chaque chapitre est affiché sur sa propre page -- Section, définie par le niveau `### ` et utile pour séparer le chapitre en sous-sujet pour plus de clarté - -Ces trois objets sont utilisés pour créer le curriculum sur la plateforme Plan ₿ Academy. Voici un exemple pour [BTC101](https://planb.academy/courses/2b7dc507-81e3-4b70-88e6-41ed44239966) - -![Curriculum BTC 101](./assets/fr/btc101-curriculum.webp) - -Vous pouvez voir que nous avons utilisé ici 2 syntaxes de la syntaxe Markdown pour rediriger vers un lien et pour afficher une image qui se trouve dans le dossier `assets/` du dossier du cours. - -Vous remarquerez que l'image se trouve dans le sous-dossier `assets/fr/`, c'est parce qu'il s'agit d'une image qui contient du texte en anglais qui sera traduit dans d'autres langues. Si l'image ne contient pas de texte, elle devrait être dans le sous-dossier `assets/no-txt/`. - -![Logo Plan ₿ Academy](./assets/no-txt/PBN-logo.webp) - -Bien que tous les formats d'image soient valides, nous préférons, pour des raisons d'efficacité de stockage, convertir chaque image au format `.webp`. Si vous ne savez pas comment faire, nous avons un script python qui fait le travail et nos réviseurs de code pourraient également le faire lors de la révision de votre PR. - - -## Ceci est le 2ème chapitre -3ff2a050-c311-55ce-a3c6-3cc450cad1ce - -Un chapitre ne contient pas nécessairement des sections (`###`) mais une partie doit contenir au moins un chapitre. - -Si vous souhaitez en savoir plus sur la syntaxe markdown, vous pouvez lire la [documentation github](https://docs.github.com/fr/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) à ce sujet. - -Une astuce intéressante est d'intégrer une vidéo comme celle-ci : - -![Musique géniale de tip_nz](https://www.youtube.com/watch?v=IO-tUpkygaI) - -## UUIDs de partie et de chapitre -eac81877-db9b-5027-87e2-2b28b59459a0 - -Pour mieux identifier chaque élément du cours, nous avons décidé d'ajouter des UUIDs pour chaque chapitre et partie. Là encore, un script python est disponible pour cela, mais les réviseurs pourraient le faire aussi. -Les UUIDs sont identiques entre 2 langues du même contenu. diff --git a/docs/PBN-template-repo/courses/topic101/quizz/001/en.yml b/docs/PBN-template-repo/courses/topic101/quizz/001/en.yml deleted file mode 100644 index 2145b42865c..00000000000 --- a/docs/PBN-template-repo/courses/topic101/quizz/001/en.yml +++ /dev/null @@ -1,25 +0,0 @@ -## Question content - -# question -# Write the complete question -question: What is a quizz? - -# answer -# Write the correct answer to the question -answer: It is a multiple choice question with only one answer right. - -# wrong_answers -# List 3 other strictly wrong answer -wrong_answers: - - It is a new type of digital currency - - It is not a multiple choice question with only one answer right. - - It is a method to evaluate if you will become the ruler of this world. - -# explanation -# State additional information, explaining why the answer is true, and/or not the other ones -explanation: | - You will have to trust me on this. But don't forget Bitcoin's saying: don't trust, verify - -# reviewed -# Define if the question has been reviewed by at least 2 different persons. -reviewed: true diff --git a/docs/PBN-template-repo/courses/topic101/quizz/001/es.yml b/docs/PBN-template-repo/courses/topic101/quizz/001/es.yml deleted file mode 100644 index c8b84e04de9..00000000000 --- a/docs/PBN-template-repo/courses/topic101/quizz/001/es.yml +++ /dev/null @@ -1,25 +0,0 @@ -## Contenido de la pregunta - -# pregunta -# Escribe la pregunta completa -question: ¿Qué es un quizz? - -# respuesta -# Escribe la respuesta correcta a la pregunta -answer: Es una pregunta de opción múltiple con solo una respuesta correcta. - -# respuestas_incorrectas -# Lista 3 respuestas estrictamente incorrectas -wrong_answers: - - Es un nuevo tipo de moneda digital - - No es una pregunta de opción múltiple con solo una respuesta correcta. - - Es un método para evaluar si te convertirás en el gobernante de este mundo. - -# explicación -# Proporciona información adicional, explicando por qué la respuesta es verdadera, y/o por qué no lo son las otras -explanation: | - Tendrás que confiar en mí en esto. Pero no olvides el dicho de Bitcoin: no confíes, verifica. - -# revisado -# Define si la pregunta ha sido revisada por al menos 2 personas diferentes. -reviewed: true \ No newline at end of file diff --git a/docs/PBN-template-repo/courses/topic101/quizz/001/fr.yml b/docs/PBN-template-repo/courses/topic101/quizz/001/fr.yml deleted file mode 100644 index 681c2907138..00000000000 --- a/docs/PBN-template-repo/courses/topic101/quizz/001/fr.yml +++ /dev/null @@ -1,25 +0,0 @@ -## Contenu de la question - -# question -# Écrivez la question complète -question: Qu'est-ce qu'un quiz ? - -# answer -# Écrivez la réponse correcte à la question -answer: C'est une question à choix multiples avec une seule réponse correcte. - -# wrong_answers -# Listez 3 autres réponses strictement incorrectes -wrong_answers: - - C'est un nouveau type de monnaie numérique - - Ce n'est pas une question à choix multiples avec une seule réponse correcte. - - C'est une méthode pour évaluer si vous allez devenir le maître du monde. - -# explanation -# Donnez des informations supplémentaires, expliquant pourquoi la réponse est vraie, et/ou pourquoi les autres ne le sont pas -explanation: | - Vous allez devoir me faire confiance sur ce point. Mais n'oubliez pas le dicton de Bitcoin : ne faites confiance à personne, vérifiez par vous-même. - -# reviewed -# Définissez si la question a été révisée par au moins 2 personnes différentes. -reviewed: true \ No newline at end of file diff --git a/docs/PBN-template-repo/courses/topic101/quizz/001/question.yml b/docs/PBN-template-repo/courses/topic101/quizz/001/question.yml deleted file mode 100644 index d3bcec34ccc..00000000000 --- a/docs/PBN-template-repo/courses/topic101/quizz/001/question.yml +++ /dev/null @@ -1,21 +0,0 @@ -## Question's Characteristics - -# chapterId -# Define to which course's chapter this question is related to -# Use the chapterId that has been defined in the associated .md file -chapterId: 4dc58281-5179-507c-afde-8c9204cbd3fd - -# difficulty -# Choose the difficulty of the question between easy, intermediate, hard, expert -difficulty: easy - -# author -# Cite who is the author of the question using the contribution-id -author: another-satoshi - -# tags -# List the associated tags to the author -# The list of tags is composed of 21 items that can be found here (list not yet available) -tags: - - bitcoin - - opensource diff --git a/docs/PBN-template-repo/courses/topic101/quizz/question-content-scheme.json b/docs/PBN-template-repo/courses/topic101/quizz/question-content-scheme.json deleted file mode 100644 index 6def9dd8e2b..00000000000 --- a/docs/PBN-template-repo/courses/topic101/quizz/question-content-scheme.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "quiz-question-content", - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "The complete question text." - }, - "answer": { - "type": "string", - "description": "The correct answer to the question." - }, - "wrong_answers": { - "type": "array", - "description": "A list of three incorrect answers to the question.", - "items": { - "type": "string" - }, - "minItems": 3, - "maxItems": 3 - }, - "explanation": { - "type": "string", - "description": "Additional information explaining why the correct answer is true and why the other answers are incorrect." - }, - "reviewed": { - "type": "boolean", - "description": "Indicates whether the question has been reviewed by at least two different persons." - } - }, - "required": ["question", "answer", "wrong_answers", "explanation", "reviewed"], - "additionalProperties": false -} - diff --git a/docs/PBN-template-repo/courses/topic101/quizz/question-scheme.json b/docs/PBN-template-repo/courses/topic101/quizz/question-scheme.json deleted file mode 100644 index 085664dd574..00000000000 --- a/docs/PBN-template-repo/courses/topic101/quizz/question-scheme.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "quiz-question", - "type": "object", - "properties": { - "chapterId": { - "type": "string", - "description": "Defines the chapter this question is related to, using the chapterId." - }, - "difficulty": { - "type": "string", - "description": "Difficulty level of the question.", - "enum": ["easy", "intermediate", "hard", "expert"] - }, - "author": { - "type": "string", - "description": "The contribution-id of the author of the question." - }, - "tags": { - "type": "array", - "description": "List of tags associated with the question.", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "required": ["chapterId", "difficulty", "author", "tags"], - "additionalProperties": false -} - diff --git a/docs/PBN-template-repo/events/README.md b/docs/PBN-template-repo/events/README.md deleted file mode 100644 index 66528c356a1..00000000000 --- a/docs/PBN-template-repo/events/README.md +++ /dev/null @@ -1,5 +0,0 @@ -Other things you have to know about this folder: once the event date is met, the event itself is going to disappear from the platform in **~2 h**. - -Ideally, every conference type event that is here, should be moved into the resources/conferences subfolder as the event date is reached. - -The new conference-resource entry should be formatted as expected by that resource type. diff --git a/docs/PBN-template-repo/events/awesome-workshop-YYYY/assets/thumbnail.webp b/docs/PBN-template-repo/events/awesome-workshop-YYYY/assets/thumbnail.webp deleted file mode 100644 index 4d5da56282c..00000000000 Binary files a/docs/PBN-template-repo/events/awesome-workshop-YYYY/assets/thumbnail.webp and /dev/null differ diff --git a/docs/PBN-template-repo/events/awesome-workshop-YYYY/event.yml b/docs/PBN-template-repo/events/awesome-workshop-YYYY/event.yml deleted file mode 100644 index eb7b1a4fdb5..00000000000 --- a/docs/PBN-template-repo/events/awesome-workshop-YYYY/event.yml +++ /dev/null @@ -1,61 +0,0 @@ -# generate a random uuid v4 -id: d0e6da8b-afa1-4b61-979e-c32c06f9ea1f - -# date in the following format: -# YYYY-MM-DD hh:mm:ss -start_date: 2026-10-25 11:15:00 -# same as end_date -end_date: 2026-10-25 12:15:00 -# this is mandatory, otherwise both -# the starting and ending dates will be messed up -timezone: Europe/Zurich - -# event location -# this line is the most important -# if this contain an error the event location is wrongly displayed on the map -address_city_country: Palazzo dei Congressi, Lugano, 6900, Switzerland -# additional location lines -address_line_2: 2nd floor -address_line_3: Room No. 7 - -# Complete name of the content -# with year (at the end) to avoid duplication -name: The ₿ Workshop in Lugano Bitcoin Event 2026 - -# define the event category, 5 options: -# - workshop -# - course -# - conference -# - lecture -# - meetup -type: workshop - -# Description -# Write the main description of the content -description: | - The only knowledge game on Bitcoin and related subjects organised in collaboration with Plan ₿ Network. - -# Event Language -# the main language spoken during the event -# Insert 2-digits ISO 639 language code (e.g. en for English, es for Spanish, th for Thai, etc...) -language: - - en - -# links -# list related to content visibility such as website, streaming and recording links -links: - website: - replay_url: - live_url: - -# Associate to a project -# copy here the id of the project that organizes this event -project_id: cd62a137-baad-4133-b90d-711963e510c7 - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - historical - - case-studies - - evaluation diff --git a/docs/PBN-template-repo/events/crowdy-meetup-YYYY-MM/assets/thumbnail.webp b/docs/PBN-template-repo/events/crowdy-meetup-YYYY-MM/assets/thumbnail.webp deleted file mode 100644 index 2f1eae3907e..00000000000 Binary files a/docs/PBN-template-repo/events/crowdy-meetup-YYYY-MM/assets/thumbnail.webp and /dev/null differ diff --git a/docs/PBN-template-repo/events/crowdy-meetup-YYYY-MM/event.yml b/docs/PBN-template-repo/events/crowdy-meetup-YYYY-MM/event.yml deleted file mode 100644 index 0cdd734500d..00000000000 --- a/docs/PBN-template-repo/events/crowdy-meetup-YYYY-MM/event.yml +++ /dev/null @@ -1,60 +0,0 @@ -# generate a random uuid v4 -id: 6e3eb0cd-465f-4902-a2d9-2520dc627eb9 - -# date in the following format: -# YYYY-MM-DD hh:mm:ss -start_date: 2025-06-16 18:00:00 -# same as end_date -end_date: 2025-06-16 22:30:00 -# this is mandatory, otherwise both -# the starting and ending dates will be messed up -timezone: Europe/Zurich - -# event location -# this line is the most important -# if this contain an error the event location is wrongly displayed on the map -address_city_country: Dummy Road 7, Lugano, 6900, Switzerland -# additional location lines -address_line_2: 21st floor -address_line_3: Room No. 2009 - -# Complete name of the content -# with year (at the end) to avoid duplication -name: Satoshi Spritz Lugano Special Edition - -# define the event category, 5 options: -# - workshop -# - course -# - conference -# - lecture -# - meetup -type: meetup - -# Description -# Write the main description of the content -description: | - A Satoshi Spritz is a public event open to everyone, allowing for anonymous participation, where discussions are exclusively focused on Bitcoin, encouraging presentations on complex topics in a flexible format. - -# Event Language -# the main language spoken during the event -# Insert 2-digits ISO 639 language code (e.g. en for English, es for Spanish, th for Thai, etc...) -language: - - en - - it - -# links -# list related to content visibility such as website, streaming and recording links -links: - website: - -# Associate to a project -# copy here the id of the project that organizes this event -project_id: cd62a137-baad-4133-b90d-711963e510c7 - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - adoption - - easy-explain - - use-case diff --git a/docs/PBN-template-repo/events/event-scheme.json b/docs/PBN-template-repo/events/event-scheme.json deleted file mode 100644 index 3c99f73907d..00000000000 --- a/docs/PBN-template-repo/events/event-scheme.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "event-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "Unique identifier for the event, UUID v4 format." - }, - "start_date": { - "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", - "description": "Start date and time of the event, format: YYYY-MM-DD hh:mm:ss." - }, - "end_date": { - "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", - "description": "End date and time of the event, format: YYYY-MM-DD hh:mm:ss." - }, - "timezone": { - "type": "string", - "description": "IANA timezone identifier for the event location (e.g., Europe/Zurich, America/New_York)." - }, - "address_city_country": { - "type": "string", - "description": "Primary address including street, city, postal code, and country. Critical for map display." - }, - "address_line_2": { - "type": "string", - "description": "Additional location details such as floor or building name." - }, - "address_line_3": { - "type": "string", - "description": "Additional location details such as room number." - }, - "name": { - "type": "string", - "description": "Complete name of the event including the year to avoid duplication." - }, - "type": { - "type": "string", - "enum": ["workshop", "course", "conference", "lecture", "meetup"], - "description": "Category of the event." - }, - "description": { - "type": "string", - "description": "Main description of the event content and purpose." - }, - "language": { - "type": "array", - "description": "Languages spoken during the event, using ISO 639-1 language codes.", - "items": { - "type": "string", - "pattern": "^[a-z]{2}$" - }, - "minItems": 1 - }, - "links": { - "type": "object", - "properties": { - "website": { - "type": "string", - "description": "URL of the event's main website." - }, - "replay_url": { - "type": "string", - "description": "URL for the event recording or replay." - }, - "live_url": { - "type": "string", - "description": "URL for live streaming of the event." - } - }, - "additionalProperties": false - }, - "project_id": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "UUID of the project that organizes this event." - }, - "tags": { - "type": "array", - "description": "List of associated tags for the event from the PlanB Network tag list.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "book_online": { - "type": "boolean", - "description": "Whether online booking is available for the event." - }, - "book_in_person": { - "type": "boolean", - "description": "Whether in-person booking is available for the event." - }, - "price_dollars": { - "type": "number", - "minimum": 0, - "description": "Price of the event in US dollars." - } - }, - "required": [ - "id", - "start_date", - "end_date", - "timezone", - "address_city_country", - "name", - "type", - "description", - "language", - "links", - "project_id", - "tags" - ], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/professors/pbn-author/assets/profile.webp b/docs/PBN-template-repo/professors/pbn-author/assets/profile.webp deleted file mode 100644 index 373697d3965..00000000000 Binary files a/docs/PBN-template-repo/professors/pbn-author/assets/profile.webp and /dev/null differ diff --git a/docs/PBN-template-repo/professors/pbn-author/assets/profile_pic_example (this extension is wrong!).png b/docs/PBN-template-repo/professors/pbn-author/assets/profile_pic_example (this extension is wrong!).png deleted file mode 100644 index 373697d3965..00000000000 Binary files a/docs/PBN-template-repo/professors/pbn-author/assets/profile_pic_example (this extension is wrong!).png and /dev/null differ diff --git a/docs/PBN-template-repo/professors/pbn-author/en.yml b/docs/PBN-template-repo/professors/pbn-author/en.yml deleted file mode 100644 index c528ec9a9e5..00000000000 --- a/docs/PBN-template-repo/professors/pbn-author/en.yml +++ /dev/null @@ -1,4 +0,0 @@ -bio: | - This is me, an author who has participated in the creation of some content in the Plan ₿ Network Repo. This decription can be as extended as I desire. - -short_bio: An author of content inside Plan ₿ Network repo diff --git a/docs/PBN-template-repo/professors/pbn-author/es.yml b/docs/PBN-template-repo/professors/pbn-author/es.yml deleted file mode 100644 index 54906045b52..00000000000 --- a/docs/PBN-template-repo/professors/pbn-author/es.yml +++ /dev/null @@ -1,4 +0,0 @@ -bio: | - Este soy yo, un autor que ha participado en la creación de contenido en el Repositorio de Plan ₿ Network. Esta descripción puede ser tan extensa como desee. - -short_bio: Un autor de contenido en el Repositorio de Plan ₿ Network. diff --git a/docs/PBN-template-repo/professors/pbn-author/fr.yml b/docs/PBN-template-repo/professors/pbn-author/fr.yml deleted file mode 100644 index 6c4a403446f..00000000000 --- a/docs/PBN-template-repo/professors/pbn-author/fr.yml +++ /dev/null @@ -1,4 +0,0 @@ -bio: | - C'est moi, un auteur qui a participé à la création de contenu dans le Plan ₿ Network Repo. Cette description peut être aussi longue que je le souhaite. - -short_bio: Un auteur de contenu dans le Plan ₿ Network Repo. diff --git a/docs/PBN-template-repo/professors/pbn-author/professor.yml b/docs/PBN-template-repo/professors/pbn-author/professor.yml deleted file mode 100644 index 2ff383c0ce5..00000000000 --- a/docs/PBN-template-repo/professors/pbn-author/professor.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Generate a random uuid v4 -id: 17f8bc5b-7034-4ff6-96e0-11484a133642 - -## Professor characteristics - -# Name -# Write author's full name -name: Plan ₿ Network Author - -# links -# A list of platforms for online presence, such as Twitter and a personal website. -links: - twitter: https://twitter.com/asi0_flammeus - website: https://awesome.site - nostr: - -# Tags -# Choose what tags to use for your content from the list below -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - cypherpunk - - lightning - -# Tips -# Insert the possibility to directly send a tip to the author -# Only the lightning_address is supported for the moment -tips: - lightning_address: awesome@lnaddress.com - -# Company -# Define which company the author is working with -# Ideally, this company is already present in the [projects folder](https://github.com/PlanB-Network/bitcoin-educational-content/tree/dev/resources/projects) -affiliations: -company: ACME - -# Affiliations -# List the author's closest affiliations with any industry actors or relevant figures. -# Ideally, the mentioned affiliations are present in the [projects folder](https://github.com/PlanB-Network/bitcoin-educational-content/tree/dev/resources/projects) -affiliations: - - uuid - - uuid diff --git a/docs/PBN-template-repo/professors/professor-scheme.json b/docs/PBN-template-repo/professors/professor-scheme.json deleted file mode 100644 index 08f4dc5526d..00000000000 --- a/docs/PBN-template-repo/professors/professor-scheme.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Professor", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Full name of the professor" - }, - "contributor_id": { - "type": "string", - "description": "Unique identifier using two words from the BIP39 dictionary" - }, - "links": { - "type": "object", - "description": "Online presence of the professor", - "properties": { - "twitter": { - "type": "string", - "format": "url", - "description": "Twitter URL of the professor" - }, - "website": { - "type": "string", - "format": "url", - "description": "Personal or professional website of the professor" - } - }, - "additionalProperties": false - }, - "tags": { - "type": "array", - "description": "List of tags associated with the professor's expertise and interests", - "items": { - "type": "string" - } - }, - "tips": { - "type": "object", - "description": "Information for direct tips to the professor", - "properties": { - "lightning_address": { - "type": "string", - "description": "Lightning network address for tips" - } - }, - "additionalProperties": false - }, - "company": { - "type": "string", - "description": "Name of the company the professor is affiliated with" - }, - "affiliations": { - "type": "array", - "description": "List of affiliations the professor has within the industry or elsewhere", - "items": { - "type": "string" - } - }, - "assets_rules": { - "type": "object", - "description": "Documentation of required asset files for professors", - "properties": { - "profile": { - "type": "object", - "description": "Professor profile picture requirements", - "properties": { - "path": { - "type": "string", - "const": "assets/profile.webp", - "description": "Path to the profile image file" - }, - "required": { - "type": "boolean", - "const": true, - "description": "This asset is required" - }, - "format": { - "type": "string", - "const": "webp", - "description": "Image format must be WebP" - }, - "aspect_ratio": { - "type": "string", - "const": "1:1", - "description": "Square aspect ratio required" - }, - "recommended_dimensions": { - "type": "array", - "items": { - "type": "string" - }, - "default": ["400x400", "800x800"], - "description": "Recommended pixel dimensions" - }, - "content": { - "type": "string", - "const": "Professor's face or avatar", - "description": "What the image should contain" - } - } - } - } - } - }, - "required": ["name", "contributor_id", "links", "tags", "tips", "company", "affiliations"], - "additionalProperties": false -} - diff --git a/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/assets/logo.webp b/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/assets/logo.webp deleted file mode 100644 index 83149881d38..00000000000 Binary files a/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/assets/logo.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/bet.yml b/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/bet.yml deleted file mode 100644 index 8a2ed680fe9..00000000000 --- a/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/bet.yml +++ /dev/null @@ -1,47 +0,0 @@ -# generate a random uuid v4 -id: 9456004d-12ca-49e8-8814-3af65edbb90a - -# (updated 2025-07) -# 2 options: Visual Content, Educational Content -type: Educational Content - -links: - # below there is the link embeded into the platform's "download" button - download: # a Plan ₿ Network public link to a folder where people can download the contents - # below there is the content displayed on the platform, under the "view" button - view: - # it can specified for each language the localized file, default displayed file is English - - it: # paste here a Plan ₿ Network public link to a readable document - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author_github_username #insert your github username - -# Associate to a project -# copy here the id of the project that organizes this content -project_id: ccaacfff-12c3-4b5c-80f8-0f077422d62b - -# Proofreading metadata -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - adoption - - use-case - - mining - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 diff --git a/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/en.yml b/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/en.yml deleted file mode 100644 index bd47ff36152..00000000000 --- a/docs/PBN-template-repo/resources/bet/a-game-on-bitcoin/en.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: # insert here the name of this content -description: | - Write like this the description - - You can even put more paragraphs - - always leave two empty spaces at each new line diff --git a/docs/PBN-template-repo/resources/bet/bet-content-scheme.json b/docs/PBN-template-repo/resources/bet/bet-content-scheme.json deleted file mode 100644 index 40fd4e9b1c9..00000000000 --- a/docs/PBN-template-repo/resources/bet/bet-content-scheme.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "bet-content-characteristics", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of this BET content." - }, - "description": { - "type": "string", - "description": "A description of the BET content. Supports multiline text." - } - }, - "required": ["name", "description"], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/resources/bet/bet-scheme.json b/docs/PBN-template-repo/resources/bet/bet-scheme.json deleted file mode 100644 index 72bdcbf6ee3..00000000000 --- a/docs/PBN-template-repo/resources/bet/bet-scheme.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "bet-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the BET content (UUID v4).", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "type": { - "type": "string", - "description": "The type of BET content.", - "enum": ["Educational Content", "Visual Content"] - }, - "links": { - "type": "object", - "description": "Links for downloading and viewing the content.", - "properties": { - "download": { - "type": "string", - "description": "Plan B Network public link to a folder for downloading the contents." - }, - "view": { - "type": "array", - "description": "List of language-specific viewable document links.", - "items": { - "type": "object", - "description": "Language-specific view link where the key is the language code.", - "additionalProperties": { - "type": "string", - "description": "Plan B Network public link to a readable document." - } - } - } - }, - "additionalProperties": false - }, - "contributor_names": { - "type": "array", - "description": "List of contributors who helped format, review, or translate the content, identified by their GitHub usernames.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "project_id": { - "type": "string", - "description": "UUID of the associated project that organizes this content.", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "original_language": { - "type": "string", - "description": "The original language of the content (ISO 639-1 code)." - }, - "proofreading": { - "type": "array", - "description": "Proofreading metadata for each language version.", - "items": { - "type": "object", - "properties": { - "language": { - "type": "string", - "description": "Language code for the version being proofread." - }, - "last_contribution_date": { - "type": "string", - "description": "The date when last contribution was received (YYYY-MM-DD format).", - "pattern": "^(YYYY-MM-DD|[0-9]{4}-[0-9]{2}-[0-9]{2})$" - }, - "urgency": { - "type": "integer", - "description": "Level of urgency from 1 (low) to higher values (high urgency).", - "minimum": 1 - }, - "contributor_names": { - "type": "array", - "description": "List of contributor GitHub usernames for this proofreading.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "reward": { - "type": "integer", - "description": "Amount of sats available for reward.", - "minimum": 0 - } - }, - "required": ["language", "last_contribution_date", "urgency", "contributor_names", "reward"], - "additionalProperties": false - } - }, - "tags": { - "type": "array", - "description": "List of associated tags to this content from the PlanB Network tag list.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "license": { - "type": "string", - "description": "License for the content.", - "enum": ["CC-BY-SA-V4", "MIT"] - } - }, - "assets_rules": { - "description": "Required image assets for BET content.", - "files": [ - { - "path": "assets/logo.webp", - "required": true, - "format": "WebP", - "dimensions": { - "aspect_ratio": "1:1", - "recommended": "800x800 pixels" - }, - "description": "BET content logo/thumbnail" - } - ] - }, - "required": ["id", "type", "contributor_names", "original_language", "proofreading", "tags", "license"], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_en.webp b/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_en.webp deleted file mode 100644 index 4e4d9be6359..00000000000 Binary files a/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_en.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_es.webp b/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_es.webp deleted file mode 100644 index 4e4d9be6359..00000000000 Binary files a/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_es.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_example (extension is wrong!).jpeg b/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_example (extension is wrong!).jpeg deleted file mode 100644 index 4e4d9be6359..00000000000 Binary files a/docs/PBN-template-repo/resources/books/awesome-book/assets/cover_example (extension is wrong!).jpeg and /dev/null differ diff --git a/docs/PBN-template-repo/resources/books/awesome-book/book-content-scheme.json b/docs/PBN-template-repo/resources/books/awesome-book/book-content-scheme.json deleted file mode 100644 index b2bcb9d1074..00000000000 --- a/docs/PBN-template-repo/resources/books/awesome-book/book-content-scheme.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "book-content-characteristics", - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "The complete title of the book." - }, - "publication_year": { - "type": "integer", - "description": "The year when the book was published." - }, - "cover": { - "type": "string", - "description": "The corresponding cover file for the translated cover." - }, - "original": { - "type": "boolean", - "description": "Indicates if the language used in this file is the original language of the book." - }, - "description": { - "type": "string", - "description": "A description of the book." - }, - "contributors": { - "type": "array", - "description": "List of contributors who helped format, review, or translate the book, identified by their UUID.", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "required": ["title", "publication_year", "cover", "original", "description", "contributors"], - "additionalProperties": false -} - diff --git a/docs/PBN-template-repo/resources/books/awesome-book/book-scheme.json b/docs/PBN-template-repo/resources/books/awesome-book/book-scheme.json deleted file mode 100644 index 72c6d3d0e31..00000000000 --- a/docs/PBN-template-repo/resources/books/awesome-book/book-scheme.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "book-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier (UUID) for the book." - }, - "author": { - "type": "string", - "description": "The author of the book." - }, - "level": { - "type": "string", - "description": "The difficulty level of the book.", - "enum": ["beginner", "intermediate", "advanced", "expert", "wizard"] - }, - "original_language": { - "type": "string", - "description": "Original language of the book." - }, - "license": { - "type": "string", - "description": "License of the book." - }, - "tags": { - "type": "array", - "description": "List of tags associated with the book.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "proofreading": { - "type": "array", - "description": "Proofreading status per language.", - "items": { - "type": "object" - } - }, - "contributor_names": { - "type": "array", - "description": "List of contributor names.", - "items": { - "type": "string" - } - }, - "links": { - "type": "object", - "description": "External links for the book (website, amazon, etc.).", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "maxProperties": 1, - "minProperties": 1, - "additionalProperties": { - "type": "string", - "format": "uri" - } - } - } - } - }, - "required": ["id", "author", "level", "original_language", "tags"], - "additionalProperties": false -} - diff --git a/docs/PBN-template-repo/resources/books/awesome-book/book.yml b/docs/PBN-template-repo/resources/books/awesome-book/book.yml deleted file mode 100644 index ce76780c5da..00000000000 --- a/docs/PBN-template-repo/resources/books/awesome-book/book.yml +++ /dev/null @@ -1,43 +0,0 @@ -# UUID of the project -# generate a random uuid v4 -id: 8ed68077-fb71-4611-b3f4-b9d051c19e7c - -# author -# Cite who's the author -author: DerGigi - -# define the content difficulty level from user's perspective -# Level can be one of following: -# - beginner -# - intermediate -# - advanced -# - expert -level: intermediate - -# Proofreading metadata -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - bitcoin - - finance - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author1_github_username #insert your github username - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 diff --git a/docs/PBN-template-repo/resources/books/awesome-book/en.yml b/docs/PBN-template-repo/resources/books/awesome-book/en.yml deleted file mode 100644 index 7b2c9a316af..00000000000 --- a/docs/PBN-template-repo/resources/books/awesome-book/en.yml +++ /dev/null @@ -1,23 +0,0 @@ -## Book's content characteristics - -# title -# Cite the complete title of the book -title: "This is an awesome title" - -# publication_year -# Give the year when this book was published -publication_year: 2009 - -# cover -# Assign the corresponding cover file for the language. This is for translated cover -cover: cover_en.webp - -# original -# Define if the language used in this file is the original language of the book -original: true - -# description -# Write a description of the book -# multiline allowed -description: | - This a description of this fake awesome book. The description can be as long as you want. It must be written on a single line, leave two empty lines at the beginning of the text. diff --git a/docs/PBN-template-repo/resources/books/awesome-book/fr.yml b/docs/PBN-template-repo/resources/books/awesome-book/fr.yml deleted file mode 100644 index b1603cf8d1f..00000000000 --- a/docs/PBN-template-repo/resources/books/awesome-book/fr.yml +++ /dev/null @@ -1,23 +0,0 @@ -## Caractéristiques du contenu du livre - -# titre -# Citez le titre complet du livre -title: "Ceci est un titre génial" - -# année_de_publication -# Indiquez l'année de publication de ce livre -publication_year: 2009 - -# couverture -# Attribuez le fichier de couverture correspondant à la langue. Ceci est pour la couverture traduite -cover: cover_fr.webp - -# original -# Définissez si la langue utilisée dans ce fichier est la langue originale du livre -original: false - -# description -# Rédigez une description du livre -# multiligne autorisée -description: | - Voici la description de ce faux livre génial. La description peut être aussi longue que vous le souhaitez. Elle doit tenir sur une seule ligne, avec deux espaces vides au début de chaque ligne. diff --git a/docs/PBN-template-repo/resources/channels/best-yt-channel-ever/assets/thumbnail.webp b/docs/PBN-template-repo/resources/channels/best-yt-channel-ever/assets/thumbnail.webp deleted file mode 100644 index 4fa928a31ce..00000000000 Binary files a/docs/PBN-template-repo/resources/channels/best-yt-channel-ever/assets/thumbnail.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/channels/best-yt-channel-ever/channel.yml b/docs/PBN-template-repo/resources/channels/best-yt-channel-ever/channel.yml deleted file mode 100644 index 5a1f78b4711..00000000000 --- a/docs/PBN-template-repo/resources/channels/best-yt-channel-ever/channel.yml +++ /dev/null @@ -1,45 +0,0 @@ -# generate a random uuid v4 -id: 56cf0b7a-17f8-46dc-9cd9-01e67a04ce49 - -# Complete name of the channel -name: The Best YouTube Channel - -# Define the main language of the channel -# Insert a 2-digits language code (e.g. en for English, es for Spanish, th for Thai, etc...), -# some languages are multiple-digits language code; e.g. Latin Serbian (sr-Latn), Chinese codes (zh-Hans, zh-Hant) -# see here https://weblate.planb.network/projects/planb-network-website/ the list of all the available language codes (bring your mouse cursor over each language name to see the code) -language: en - -links: - channel: https://www.youtube.com/@PlanBNetwork - trailer: https://www.youtube.com/watch?v=s-aGTK5da4Q #the pinned video on the channel or the most viewed one - -# multiline allowed -description: | - Write like this - the description - - You can even put - more paragraphs - - always leave two empty - spaces at each new line - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author_github_username #insert your github username - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - market-trends - - onchain - - investment - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 diff --git a/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/assets/thumbnail.webp b/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/assets/thumbnail.webp deleted file mode 100644 index e0abe2271f2..00000000000 Binary files a/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/assets/thumbnail.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/conference.yml b/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/conference.yml deleted file mode 100644 index b6ac34ebb97..00000000000 --- a/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/conference.yml +++ /dev/null @@ -1,56 +0,0 @@ -# generate a random uuid v4 -id: 5dba9b01-7c15-40cb-a470-06baa228ac64 - -# Complete name of the content -# with year to avoid duplication -name: Adopting Bitcoin 2021 - -# Year-Month -# Define when was the conference -# format: YYYY-MM -year: 2021-11 - -# Associate to a project -# copy here the id of the project that organizes this conference -project_id: ccaacfff-12c3-4b5c-80f8-0f077422d62b - -# Conference Location -# format: Town, Country -location: San Salvador, El Salvador - -# Conference Language -# the main language(s) spoken during the conference (here multiple languages are allowed!) -# Insert a 2-digits language code (e.g. en for English, es for Spanish, th for Thai, etc...), -# some languages are multiple-digits language code; e.g. Latin Serbian (sr-Latn), Chinese codes (zh-Hans, zh-Hant) -# see here https://weblate.planb.network/projects/planb-network-website/ the list of all the available language codes (bring your mouse cursor over each language name to see the code) -language: - - en - - es - -# links -# list releated online presence like twitter and/or website -links: - website: https://adoptingbitcoin.org - twitter: https://twitter.com/AdoptingBTC - -# Proofreading metadata -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - adoption - - lightning - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 \ No newline at end of file diff --git a/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/en.md b/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/en.md deleted file mode 100644 index 34e1e45a642..00000000000 --- a/docs/PBN-template-repo/resources/conferences/awesome-conference-replay-YYYY/en.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: Awesome Conference 2009 -description: This is a one line description. Thought it can be composed of several sentences ---- - -# This heading level is a "Stage" - -## This heading level is a "replay" - -![video](https://youtu.be/awesome_replay) - -Speaker: John Doe - -You can also more information about the replay, like the YT description or any relevant information. -(for now no need to translate this markdown, so only `en.md` is needed). -A thumbnail is needed in the `/assets/` folder named `thumbnail.webp`. - - -## This heading level is another "replay" - -![video](https://youtu.be/another_awesome_replay) - -Speaker: Jane Doe - -You can also more information about the replay, like the YT description or any relevant information. - -# This heading level is another "Stage" - -## This heading level is a "replay" - -![video](https://youtu.be/awesome_replay) - -Speaker: John Doe - -You can also more information about the replay, like the YT description or any relevant information. - -## This heading level is another "replay" - -![video](https://youtu.be/another_awesome_replay) - -Speaker: Jane Doe - -You can also more information about the replay, like the YT description or any relevant information. diff --git a/docs/PBN-template-repo/resources/glossary/cool-word/en.md b/docs/PBN-template-repo/resources/glossary/cool-word/en.md deleted file mode 100644 index 84c5d6f6987..00000000000 --- a/docs/PBN-template-repo/resources/glossary/cool-word/en.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -term: COOL WORD ---- - -Definition of word here - - -![](../../assets/assetxyz.png) \ No newline at end of file diff --git a/docs/PBN-template-repo/resources/glossary/cool-word/word.yml b/docs/PBN-template-repo/resources/glossary/cool-word/word.yml deleted file mode 100644 index d44d8edaf49..00000000000 --- a/docs/PBN-template-repo/resources/glossary/cool-word/word.yml +++ /dev/null @@ -1,55 +0,0 @@ -# generate a random uuid v4 -id: 0f36318f-42c1-437d-ac90-46f4fc9eef60 - -# Characteristic of a glossary word -# Each word is contained in a folder named after it. E.g.: if the word is "account" there will be a folder named "account" -# with related files inside such folder. - -# en_word -# Write the word in English. It will be the basis for identifying the word in the glossary. -en_word: COOL WORD - -# related_words -# If there are existing words in glossary which are related to this word, it's possible to write them here, one per line. -# Feel free to add as many related words as possible to enrich the glossary experience. -# You have to use the en_word name of the words you want to relate. -# E.g.: if you wan to relate the words https://planb.network/en/resources/word/yprv and https://planb.network/en/resources/word/extended-key -# you should use the following -related_words: -- XPRV -- EXTENDED KEY - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: -- '' - -# Original language -# Write the original language of the word when newly added -original_language: fr - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 - -# Proofreading metadata -# Only the original author needs to add this data related to initial proofreading -# Any further data will be automatically added after each proofreading -# -# This information are provided by PBN team, no need to specify if you are a contributor. -# for PBN staff: an entry is created for each language specifying: -# - language: language to review -# - last_contribution_date: the date when last contribution has been received -# - urgency: level of urgency from 1 (low urgency) to X(high urgency) -# - contributor_names: list of contributors PBN ids -# - reward: amount of sats available for reward -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value diff --git a/docs/PBN-template-repo/resources/glossary/cool-word2/en.md b/docs/PBN-template-repo/resources/glossary/cool-word2/en.md deleted file mode 100644 index 496ce51f1a6..00000000000 --- a/docs/PBN-template-repo/resources/glossary/cool-word2/en.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -term: COOL WORD 2 ---- - -Definition of word here - - -![](../../assets/assetxyz.png) \ No newline at end of file diff --git a/docs/PBN-template-repo/resources/glossary/cool-word2/word.yml b/docs/PBN-template-repo/resources/glossary/cool-word2/word.yml deleted file mode 100644 index 16dd88af768..00000000000 --- a/docs/PBN-template-repo/resources/glossary/cool-word2/word.yml +++ /dev/null @@ -1,53 +0,0 @@ -# generate a random uuid v4 -id: 00d87c5e-4344-4722-969e-34054c76f64b - -# Characteristic of a glossary word -# Each word is contained in a folder named after it. E.g.: if the word is "account" there will be a folder named "account" -# with related files inside such folder. - -# en_word -# Write the word in English. It will be the basis for identifying the word in the glossary. -en_word: COOL WORD 2 - -# related_words -# If there are existing words in glossary which are related to this word, it's possible to write them here, one per line. -# Feel free to add as many related words as possible to enrich the glossary experience. -# You have to use the en_word name of the words you want to relate. -# E.g.: if you wan to relate the word "cool word" you should use the following -related_words: -- COOL WORD - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: -- '' - -# Original language -# Write the original language of the word when newly added -original_language: en - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 - -# Proofreading metadata -# Only the original author needs to add this data related to initial proofreading -# Any further data will be automatically added after each proofreading -# -# This information are provided by PBN team, no need to specify if you are a contributor. -# for PBN staff: an entry is created for each language specifying: -# - language: language to review -# - last_contribution_date: the date when last contribution has been received -# - urgency: level of urgency from 1 (low urgency) to X(high urgency) -# - contributor_names: list of contributors PBN ids -# - reward: amount of sats available for reward -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value diff --git a/docs/PBN-template-repo/resources/glossary/word-content-scheme.json b/docs/PBN-template-repo/resources/glossary/word-content-scheme.json deleted file mode 100644 index bc9cd85a071..00000000000 --- a/docs/PBN-template-repo/resources/glossary/word-content-scheme.json +++ /dev/null @@ -1,246 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "glossary-word-content", - "description": "Schema for validating glossary word markdown files (e.g., en.md, fr.md). These files contain YAML front-matter with the term field and markdown content defining the glossary term.", - "type": "object", - "properties": { - "frontmatter": { - "type": "object", - "description": "YAML front-matter block enclosed by --- delimiters at the start of the file", - "properties": { - "term": { - "type": "string", - "description": "The glossary term in UPPERCASE. May include parenthetical additions for abbreviations, opcodes, or clarifications (e.g., 'SATOSHI (SAT)', 'OP_ROLL (0X7A)', 'HOT WALLET (SOFTWARE)'). Numbers should be written out (e.g., '51 PERCENT ATTACK' not '51%').", - "pattern": "^[A-Z0-9][A-Z0-9 _+-]*(?:\\s*\\([A-Z0-9 ]+\\))?$", - "examples": [ - "WALLET", - "SATOSHI (SAT)", - "51 PERCENT ATTACK", - "OP_ROLL (0X7A)", - "SHA256", - "C++ (PLUS PLUS)", - "API" - ] - } - }, - "required": ["term"], - "additionalProperties": false - }, - "content_rules": { - "type": "object", - "description": "Documentation of markdown content structural requirements. This property describes validation rules that should be enforced by content validators.", - "properties": { - "definition_paragraph": { - "type": "object", - "description": "The primary definition paragraph immediately following the YAML front-matter", - "properties": { - "required": { - "type": "boolean", - "const": true, - "description": "A definition paragraph is required immediately after the front-matter" - }, - "position": { - "type": "string", - "const": "immediately_after_frontmatter", - "description": "Must appear directly after the closing --- of the front-matter" - }, - "guidelines": { - "type": "array", - "description": "Content guidelines for the definition paragraph", - "items": { - "type": "string" - }, - "default": [ - "Defines what the term is in clear, technical language", - "For acronyms, start with: Acronym for \"[Full Name]\".", - "Should be comprehensive but concise", - "Use backticks for technical terms: `scriptPubKey`, `SHA256`" - ] - } - }, - "additionalProperties": false - }, - "additional_paragraphs": { - "type": "object", - "description": "Optional extended explanation paragraphs", - "properties": { - "required": { - "type": "boolean", - "const": false, - "description": "Additional paragraphs are optional" - }, - "guidelines": { - "type": "array", - "description": "Content guidelines for additional paragraphs", - "items": { - "type": "string" - }, - "default": [ - "Provide deeper technical explanation if needed", - "Include examples, use cases, or historical context", - "May include bulleted or numbered lists" - ] - } - }, - "additionalProperties": false - }, - "code_formatting": { - "type": "object", - "description": "Rules for inline code and code blocks", - "properties": { - "inline_code": { - "type": "string", - "const": "backticks", - "description": "Use single backticks for technical terms: `scriptPubKey`, `SHA256`, `HASH160`" - }, - "examples": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "`scriptPubKey`", - "`SHA256`", - "`RIPEMD160`", - "`OP_CHECKSIG`" - ] - } - }, - "additionalProperties": false - }, - "math_formulas": { - "type": "object", - "description": "Rules for mathematical formulas using LaTeX notation", - "properties": { - "inline_format": { - "type": "string", - "const": "$formula$", - "description": "Single dollar signs for inline math" - }, - "block_format": { - "type": "string", - "const": "$$formula$$", - "description": "Double dollar signs for block/display math" - }, - "examples": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "$y^2 = x^3 + 7$", - "$H(m_1) = H(m_2)$", - "$$s_A = n_A + t + H(N_A + T \\parallel P_A \\parallel m_A) \\cdot p_A$$" - ] - } - }, - "additionalProperties": false - }, - "images": { - "type": "object", - "description": "Rules for including images in glossary definitions", - "properties": { - "required": { - "type": "boolean", - "const": false, - "description": "Images are optional" - }, - "format": { - "type": "string", - "const": "![](relative/path/to/image.webp)", - "description": "Standard markdown image syntax with empty alt text" - }, - "path_pattern": { - "type": "string", - "const": "../../dictionnaire/assets/XX.webp", - "description": "Relative path from word directory to assets folder" - }, - "preferred_format": { - "type": "string", - "const": "webp", - "description": "Preferred image format is WebP" - } - }, - "additionalProperties": false - }, - "alternative_names_blockquote": { - "type": "object", - "description": "Optional blockquote at the END of the file containing alternative names, translations, or etymology", - "properties": { - "required": { - "type": "boolean", - "const": false, - "description": "Alternative names blockquote is optional" - }, - "position": { - "type": "string", - "const": "end_of_file", - "description": "MUST appear at the very end of the file if present" - }, - "format": { - "type": "string", - "const": "> *text in italics*", - "description": "Blockquote with arrow symbol and italicized text" - }, - "pattern": { - "type": "string", - "const": "^> ► \\*.*\\*$", - "description": "Regex pattern: starts with '> ► *' and ends with '*'" - }, - "content_types": { - "type": "array", - "description": "Types of content that appear in the alternative names blockquote", - "items": { - "type": "string" - }, - "default": [ - "Alternative English names for the term", - "French translation or equivalent term", - "Etymology or origin notes", - "Related terminology clarifications" - ] - }, - "examples": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "> ► *This attack is also known as the \"Goldfinger attack\".*", - "> ► *In French, this is translated as \"interface de programmation d'applications\" or simply \"interface de programmation.\"*", - "> ► *In English, it is referred to as a \"hash function\".*" - ] - } - }, - "additionalProperties": false - }, - "links": { - "type": "object", - "description": "Rules for hyperlinks in content", - "properties": { - "format": { - "type": "string", - "const": "[text](url)", - "description": "Standard markdown link syntax" - }, - "guidelines": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "Use descriptive link text, not raw URLs", - "Prefer linking to primary sources (BIPs, papers, official repos)", - "External links should point to stable, reliable resources" - ] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "required": ["frontmatter"], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/resources/movies/beautiful-documentary/assets/thumbnail.webp b/docs/PBN-template-repo/resources/movies/beautiful-documentary/assets/thumbnail.webp deleted file mode 100644 index 603cdb751fa..00000000000 Binary files a/docs/PBN-template-repo/resources/movies/beautiful-documentary/assets/thumbnail.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/movies/beautiful-documentary/movie.yml b/docs/PBN-template-repo/resources/movies/beautiful-documentary/movie.yml deleted file mode 100644 index 68821c16b27..00000000000 --- a/docs/PBN-template-repo/resources/movies/beautiful-documentary/movie.yml +++ /dev/null @@ -1,46 +0,0 @@ -# generate a random uuid v4 -id: d2bd90b2-929a-4b1e-9bfb-359863cc08f0 - -# complete title of the content, don't forget the ".." -title: "title_of_the_movie" - -# write the full name of (all) the author(s) -author: Jane Doe - -publication_year: 2009 - -# duration is expressed in minutes -duration: 60 - -# language of original audio of the content -# Insert a 2-digits language code (e.g. en for English, es for Spanish, th for Thai, etc...), -# some languages are multiple-digits language code; e.g. Latin Serbian (sr-Latn), Chinese codes (zh-Hans, zh-Hant) -# see here https://weblate.planb.network/projects/planb-network-website/ the list of all the available language codes (bring your mouse cursor over each language name to see the code) -language: en - -# all the movies posted here must be legit, most of them will be published on hosted services like YouTube, but this is not a rule -links: - platform: https://youtu.be/AxbZq8Zmd88 - trailer: https://youtu.be/AxbZq8Zmd88 - -description: | - Write like this the description - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author1_github_username #insert your github username - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - cypherpunk - - future-outlook - - high-level - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 \ No newline at end of file diff --git a/docs/PBN-template-repo/resources/newsletters/awesome-newsletter/assets/thumbnail.webp b/docs/PBN-template-repo/resources/newsletters/awesome-newsletter/assets/thumbnail.webp deleted file mode 100644 index e4ca7230f1c..00000000000 Binary files a/docs/PBN-template-repo/resources/newsletters/awesome-newsletter/assets/thumbnail.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/newsletters/awesome-newsletter/newsletter.yml b/docs/PBN-template-repo/resources/newsletters/awesome-newsletter/newsletter.yml deleted file mode 100644 index 4d532e8b9e3..00000000000 --- a/docs/PBN-template-repo/resources/newsletters/awesome-newsletter/newsletter.yml +++ /dev/null @@ -1,49 +0,0 @@ -# generate a random uuid v4 -id: ef222a06-cd58-454a-8982-3d8d809aca0a - -# complete title of the content, don't forget the ".." -title: "Satoshi's First News" - -# write the full name of (all) the author(s) -author: John Doe - -# define the content difficulty level from user's perspective -# Level can be one of following: -# - beginner -# - intermediate -# - advanced -# - expert -level: intermediate - -# when the newsletter has started -publication_date: YYYY-MM-DD - -link: - - website: https://newsletter_link_here.com - -## Insert a 2-digits language code (e.g. en for English, es for Spanish, th for Thai, etc...), -# some languages are multiple-digits language code; e.g. Latin Serbian (sr-Latn), Chinese codes (zh-Hans, zh-Hant) -# see here https://weblate.planb.network/projects/planb-network-website/ the list of all the available language codes (bring your mouse cursor over each language name to see the code) -language: en - -description: | - Write like this the description - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author1_github_username #insert your github username - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - market-trends - - onchain - - investment - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 \ No newline at end of file diff --git a/docs/PBN-template-repo/resources/newsletters/newsletter-scheme.json b/docs/PBN-template-repo/resources/newsletters/newsletter-scheme.json deleted file mode 100644 index 209dfdca6c7..00000000000 --- a/docs/PBN-template-repo/resources/newsletters/newsletter-scheme.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "newsletter-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the newsletter in UUID v4 format.", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "title": { - "type": "string", - "description": "Complete title of the newsletter." - }, - "author": { - "type": "string", - "description": "Full name of the author(s) of the newsletter." - }, - "level": { - "type": "string", - "description": "Difficulty level of the newsletter content from user's perspective.", - "enum": ["beginner", "intermediate", "advanced", "expert"] - }, - "publication_date": { - "type": "string", - "description": "Date when the newsletter started, format: YYYY-MM-DD.", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "link": { - "type": "array", - "description": "List of links associated with the newsletter.", - "items": { - "type": "object", - "properties": { - "website": { - "type": "string", - "description": "URL of the newsletter's website." - } - }, - "required": ["website"], - "additionalProperties": false - }, - "minItems": 1 - }, - "language": { - "type": "string", - "description": "Language of the newsletter using ISO 639-1 language codes (e.g., en, es, zh-Hans)." - }, - "description": { - "type": "string", - "description": "Multiline description of the newsletter content." - }, - "contributor_names": { - "type": "array", - "description": "List of contributors identified by their GitHub usernames.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "tags": { - "type": "array", - "description": "List of tags associated with the newsletter from the PlanB tags list.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "license": { - "type": "string", - "description": "License for the newsletter content.", - "enum": ["CC-BY-SA-V4", "MIT"] - } - }, - "assets_rules": { - "description": "Required image assets for newsletters.", - "files": [ - { - "path": "assets/thumbnail.webp", - "required": true, - "format": "WebP", - "dimensions": { - "width": 1280, - "height": 720, - "aspect_ratio": "16:9" - }, - "orientation": "horizontal", - "description": "Newsletter thumbnail image." - } - ] - }, - "required": ["id", "title", "author", "level", "publication_date", "link", "language", "description", "contributor_names", "tags", "license"], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/resources/podcasts/podcast-scheme.json b/docs/PBN-template-repo/resources/podcasts/podcast-scheme.json deleted file mode 100644 index 4d89f2d19f3..00000000000 --- a/docs/PBN-template-repo/resources/podcasts/podcast-scheme.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "podcast-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "Unique identifier using UUID v4 format." - }, - "name": { - "type": "string", - "description": "Complete name of the podcast." - }, - "host": { - "type": "string", - "description": "Names of all hosts of the podcast." - }, - "language": { - "type": "string", - "description": "Language of the podcast using ISO 639-1 language code (e.g. en, es, th) or extended codes (e.g. sr-Latn, zh-Hans)." - }, - "links": { - "type": "object", - "properties": { - "podcast": { - "type": "string", - "description": "Direct link to audio feed via the streaming service platform (e.g. Apple Podcasts, Spotify, Fountain)." - }, - "twitter": { - "type": "string", - "description": "URL of the podcast's Twitter/X profile." - }, - "website": { - "type": "string", - "description": "URL of the podcast's website." - } - }, - "required": ["podcast"], - "additionalProperties": false - }, - "description": { - "type": "string", - "description": "Main description of the podcast content." - }, - "tags": { - "type": "array", - "description": "List of associated tags to the podcast from the PlanB Network tag list.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "contributor_names": { - "type": "array", - "description": "List of contributors identified by their GitHub usernames who helped format, review and translate the content.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "license": { - "type": "string", - "enum": ["CC-BY-SA-V4", "MIT"], - "description": "License for the content. CC-BY-SA-V4 is the default (share-alike clause). MIT allows almost unrestricted use." - } - }, - "assets_rules": { - "description": "Required image assets for podcasts.", - "files": [ - { - "path": "assets/logo.webp", - "required": true, - "format": "WebP", - "dimensions": { - "width": 800, - "height": 800, - "aspect_ratio": "1:1" - }, - "orientation": "square", - "description": "Podcast logo image. Recommended 800x800 pixels." - } - ] - }, - "required": ["id", "name", "host", "language", "links", "description", "tags", "contributor_names", "license"], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/resources/podcasts/podcastname/assets/logo.webp b/docs/PBN-template-repo/resources/podcasts/podcastname/assets/logo.webp deleted file mode 100644 index c8a04314b4a..00000000000 Binary files a/docs/PBN-template-repo/resources/podcasts/podcastname/assets/logo.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/podcasts/podcastname/podcast.yml b/docs/PBN-template-repo/resources/podcasts/podcastname/podcast.yml deleted file mode 100644 index 8015b12280c..00000000000 --- a/docs/PBN-template-repo/resources/podcasts/podcastname/podcast.yml +++ /dev/null @@ -1,55 +0,0 @@ -# generate a random uuid v4 -id: 574fefe7-c787-4ed7-a36e-3203bad80f70 - -## Characteristics of the podcast - -# Name -# Write podcast's complete name -name: PBN podcast - -# Host -# Write the names of all hosts -host: Host 1 and Host 2 - -# Language -# Insert a 2-digits language code (e.g. en for English, es for Spanish, th for Thai, etc...), -# some languages are multiple-digits language code; e.g. Latin Serbian (sr-Latn), Chinese codes (zh-Hans, zh-Hant) -# see here https://weblate.planb.network/projects/planb-network-website/ the list of all the available language codes (bring your mouse cursor over each language name to see the code) -language: en - -# Links -# Please, specify the following complete links (including https://): -# - podcast: direct link to audio feed via the streaming service platform used (E.g.: apple podcast, spotify, fountain, etc...) -# - twitter: link to twitter profile -# - website: link to podcast's website if any -links: - podcast: https://podcasts.apple.com/... - twitter: https://x.com/planb_network - website: https://planb.network - -# Description -# Write the main description of the content -description: | - Keep 2 space after each new line, like you can see here; - the journey from Normie to Bitcoiner. - Normal people tell the story of how they found the greatest money ever invented. - Go to https://planb.network/en/resources/podcasts to see more podcasts - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - adoption - - finance - - easy-explain - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author1_github_username #insert your github username - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 diff --git a/docs/PBN-template-repo/resources/projects/community-builder-scheme.json b/docs/PBN-template-repo/resources/projects/community-builder-scheme.json deleted file mode 100644 index 137b6f7fc4f..00000000000 --- a/docs/PBN-template-repo/resources/projects/community-builder-scheme.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "community-builder-characteristics", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Complete name of the community builder." - }, - "category": { - "type": "string", - "enum": ["communities"], - "description": "The builder category can only be 'communities'." - }, - "address": { - "type": "object", - "properties": { - "address_line_1": { - "type": "string", - "description": "Line 1 of the address, more precise, name of the place." - }, - "address_line_2": { - "type": "string", - "description": "Line 2 of the address, street name." - }, - "address_line_3": { - "type": "string", - "description": "Line 3 of the address, city and country." - } - }, - "required": ["address_line_1", "address_line_2", "address_line_3"], - "additionalProperties": false - }, - "language": { - "type": "array", - "description": "Defines the spoken languages of the community, using ISO 639-1 language codes.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "links": { - "type": "object", - "properties": { - "website": { - "type": "string", - "description": "URL of the builder's main website." - }, - "twitter": { - "type": "string", - "description": "URL of the builder's Twitter profile." - }, - "github": { - "type": "string", - "format": "uri", - "description": "URL of the builder's GitHub profile." - }, - "youtube": { - "type": "string", - "format": "uri", - "description": "URL of the builder's YouTube channel." - } - }, - "required": ["website"], - "additionalProperties": false - }, - "tags": { - "type": "array", - "description": "List of associated tags to the builder. Specific list of tags to be defined.", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "assets_rules": { - "description": "Required image assets for community builder content.", - "files": [ - { - "path": "assets/logo.webp", - "required": true, - "format": "WebP", - "dimensions": { - "aspect_ratio": "1:1", - "recommended": "800x800 pixels" - }, - "description": "Project/community logo" - } - ] - }, - "required": ["name", "category", "address", "language", "links", "tags"], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/resources/projects/community-project/assets/logo.webp b/docs/PBN-template-repo/resources/projects/community-project/assets/logo.webp deleted file mode 100644 index b27e805e56a..00000000000 Binary files a/docs/PBN-template-repo/resources/projects/community-project/assets/logo.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/projects/community-project/en.yml b/docs/PBN-template-repo/resources/projects/community-project/en.yml deleted file mode 100644 index 0f1e81bba34..00000000000 --- a/docs/PBN-template-repo/resources/projects/community-project/en.yml +++ /dev/null @@ -1,8 +0,0 @@ -## Language Specific characteristic's of projects - -# description -# Write a description of the project -description: | - This is used to describe the builder in couple of sentences. - - even new paragraphs, just write like this diff --git a/docs/PBN-template-repo/resources/projects/community-project/fr.yml b/docs/PBN-template-repo/resources/projects/community-project/fr.yml deleted file mode 100644 index ac7d31ef32f..00000000000 --- a/docs/PBN-template-repo/resources/projects/community-project/fr.yml +++ /dev/null @@ -1,8 +0,0 @@ -## Caractéristiques spécifiques à la langue des projets - -# description -# Rédigez une description du projet -description: | - Ceci est utilisé pour décrire le constructeur en quelques phrases. - - Même pour les nouveaux paragraphes, écrivez simplement comme ceci. diff --git a/docs/PBN-template-repo/resources/projects/community-project/project.yml b/docs/PBN-template-repo/resources/projects/community-project/project.yml deleted file mode 100644 index 0ea73b5bb8b..00000000000 --- a/docs/PBN-template-repo/resources/projects/community-project/project.yml +++ /dev/null @@ -1,64 +0,0 @@ -# generate a random uuid v4 -id: d0670bd1-5326-4551-8c3f-ddcef5af490c - -## Community Project Characteristic - -# Complete name of the project -name: Community Project Complete name - -# Define Project category -# Can only be communities -category: communities - -# Define Builder address -# line 1 for more precise, name of the place -address_city_country: PBN HQ -# line 2 for the steet name -address_line_2: 21st Satoshi's Place -# line 3 for the City, Country -address_line_3: CyberTown, CyberSpace - -# Community Language -# Define the spoken language of the community -# Insert a 2-digits language code (e.g. en for English, es for Spanish, th for Thai, etc...), -# some languages are multiple-digits language code; e.g. Latin Serbian (sr-Latn), Chinese codes (zh-Hans, zh-Hant) -# see here https://weblate.planb.network/projects/planb-network-website/ the list of all the available language codes (bring your mouse cursor over each language name to see the code) -language: - - en - - es - -# links -# list releated online presence like twitter and/or website -links: - website: https://planb.network - twitter: - Github: https://github.com/DecouvreBitcoin/sovereign-university-data - youtube: https://www.youtube.com/@PlanBNetwork - -# Proofreading metadata -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - finance - - education - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author1_github_username #insert your github username - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 diff --git a/docs/PBN-template-repo/resources/projects/non-community-builder-content-scheme.json b/docs/PBN-template-repo/resources/projects/non-community-builder-content-scheme.json deleted file mode 100644 index bdfb4e47f59..00000000000 --- a/docs/PBN-template-repo/resources/projects/non-community-builder-content-scheme.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "language-specific-builder-characteristics", - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A detailed description of the builder, which can span multiple lines." - } - }, - "required": ["description"], - "additionalProperties": false -} - diff --git a/docs/PBN-template-repo/resources/projects/non-community-builder-scheme.json b/docs/PBN-template-repo/resources/projects/non-community-builder-scheme.json deleted file mode 100644 index 0c8db373d08..00000000000 --- a/docs/PBN-template-repo/resources/projects/non-community-builder-scheme.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "non-community-builder-characteristics", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Builder's complete name." - }, - "category": { - "type": "string", - "enum": ["wallet", "infrastructure", "exchange", "education", "service", "conference", "privacy", "investment", "node", "mining", "news", "manufacturer"], - "description": "Define Builder category. Only specific categories are allowed." - }, - "links": { - "type": "object", - "properties": { - "website": { - "type": "string", - "description": "URL of the builder's website." - }, - "twitter": { - "type": "string", - "description": "URL of the builder's Twitter profile." - }, - "github": { - "type": "string", - "description": "URL of the builder's Github profile.", - "format": "uri" - }, - "youtube": { - "type": "string", - "description": "URL of the builder's YouTube channel.", - "format": "uri" - } - }, - "required": ["website"], - "additionalProperties": false - }, - "tags": { - "type": "array", - "description": "List of associated tags to the builder. The list of tags is composed of 21 items that can be found in a specified source.", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "assets_rules": { - "description": "Required image assets for non-community builder content.", - "files": [ - { - "path": "assets/logo.webp", - "required": true, - "format": "WebP", - "dimensions": { - "aspect_ratio": "1:1", - "recommended": "800x800 pixels" - }, - "description": "Project logo" - } - ] - }, - "required": ["name", "category", "links", "tags"], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/resources/projects/non-community-project/assets/logo.webp b/docs/PBN-template-repo/resources/projects/non-community-project/assets/logo.webp deleted file mode 100644 index 2e749fe1bfb..00000000000 Binary files a/docs/PBN-template-repo/resources/projects/non-community-project/assets/logo.webp and /dev/null differ diff --git a/docs/PBN-template-repo/resources/projects/non-community-project/en.yml b/docs/PBN-template-repo/resources/projects/non-community-project/en.yml deleted file mode 100644 index 0f1e81bba34..00000000000 --- a/docs/PBN-template-repo/resources/projects/non-community-project/en.yml +++ /dev/null @@ -1,8 +0,0 @@ -## Language Specific characteristic's of projects - -# description -# Write a description of the project -description: | - This is used to describe the builder in couple of sentences. - - even new paragraphs, just write like this diff --git a/docs/PBN-template-repo/resources/projects/non-community-project/fr.yml b/docs/PBN-template-repo/resources/projects/non-community-project/fr.yml deleted file mode 100644 index d4cf50de5e0..00000000000 --- a/docs/PBN-template-repo/resources/projects/non-community-project/fr.yml +++ /dev/null @@ -1,8 +0,0 @@ -## Caractéristiques spécifiques à la langue des projets - -# description -# Rédigez une description du projet -description: | - Ceci est utilisé pour décrire le constructeur en quelques phrases. - - Même pour les nouveaux paragraphes, écrivez simplement comme ceci. \ No newline at end of file diff --git a/docs/PBN-template-repo/resources/projects/non-community-project/project.yml b/docs/PBN-template-repo/resources/projects/non-community-project/project.yml deleted file mode 100644 index 437e3cd48d0..00000000000 --- a/docs/PBN-template-repo/resources/projects/non-community-project/project.yml +++ /dev/null @@ -1,47 +0,0 @@ -# generate a random uuid v4 -id: a22ab511-d00f-4dd2-baa7-d4770ab9ead7 - -## Non-community Project Characteristic - -# Complete name of the project -name: Non-Community Porject Complete name - -# links -# list releated online presence like twitter and/or website -links: - website: https://stackharder.com - twitter: https://twitter.com/stackharder - Github: https://github.com/DecouvreBitcoin/sovereign-university-data - youtube: https://www.youtube.com/@PlanBNetwork - -# Define Project category -# Choose between: conference, education, exchange, infrastructure, investment, manufacturer, mining, news, node, privacy, service, wallet -category: education - -# Proofreading metadata -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - easy-explain - - development - -# Contributor section -# List all the contributors who help format, review and translate the content. -# They are identified by their Github usernames -contributor_names: - - author1_github_username #insert your github username - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 diff --git a/docs/PBN-template-repo/scripts/21-planb-tags.md b/docs/PBN-template-repo/scripts/21-planb-tags.md deleted file mode 100644 index a1b2385c270..00000000000 --- a/docs/PBN-template-repo/scripts/21-planb-tags.md +++ /dev/null @@ -1,24 +0,0 @@ -This is an attempt to reduce the number of tags for PlanB Content to 21: - -1. Fundamentals -2. Technology -3. Mining -4. Wallets -5. Security -6. Protocols -7. Cryptography -8. Open-Source -9. Transactions -10. Network Architecture -11. Economic Impact -12. Regulation and Compliance -13. Historical Context -14. Privacy Enhancements -15. Decentralization -16. Adoption -17. Scaling Solutions -18. Theoretical -19. Practical -20. Investment Strategies -21. Future Developments - diff --git a/docs/PBN-template-repo/scripts/README b/docs/PBN-template-repo/scripts/README deleted file mode 100644 index 72ff4ff3654..00000000000 --- a/docs/PBN-template-repo/scripts/README +++ /dev/null @@ -1,5 +0,0 @@ -## Dependencies - -- ruamel.yaml -- unidecode -- python-frontmatter diff --git a/docs/PBN-template-repo/scripts/__pycache__/schema_validator.cpython-312.pyc b/docs/PBN-template-repo/scripts/__pycache__/schema_validator.cpython-312.pyc deleted file mode 100644 index b5e6dd273f0..00000000000 Binary files a/docs/PBN-template-repo/scripts/__pycache__/schema_validator.cpython-312.pyc and /dev/null differ diff --git a/docs/PBN-template-repo/scripts/book-uniformizer.py b/docs/PBN-template-repo/scripts/book-uniformizer.py deleted file mode 100644 index 4d38d5916e1..00000000000 --- a/docs/PBN-template-repo/scripts/book-uniformizer.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -import yaml - -def process_book_yaml(file_path): - """ - Process a book.yaml file: - - Look for lines that start with "-" - - Add a double space to these lines - """ - - with open(file_path, 'r') as f: - lines = f.readlines() - - lines = [' ' + line if line.startswith('-') else line for line in lines] - - with open(file_path, 'w') as f: - f.writelines(lines) - - -def process_language_file(folder_path, language_code): - """ - Process a {language_code}.yml file according to the provided instructions. - """ - - no_pub_date = [] - file_path = os.path.join(folder_path, f'{language_code}.yml') - - if not os.path.exists(file_path): - return no_pub_date - - with open(file_path, 'r') as f: - data = yaml.safe_load(f) - - with open('tmp.yml', 'w') as tmp: - title = data.get('title', 'N/A') - tmp.write(f'title: "{title}"\n') - - publication_year = data.get('publication_year') - if publication_year: - tmp.write(f"publication_year: {publication_year}\n") - else: - tmp.write("publication_year:\n") - no_pub_date.append(title) - - cover = data.get('cover') - if cover: - tmp.write(f"cover: {cover}\n") - else: - # Check if there's a file named "cover_{language_code}.jpeg" in the assets folder - assets_folder = os.path.join(folder_path, 'assets') - cover_file = f"cover_{language_code}.jpeg" - if cover_file in os.listdir(assets_folder): - tmp.write(f"cover: {cover_file}\n") - else: - tmp.write("cover:\n") - - original = data.get('original', 'N/A') - tmp.write(f"original: {original}\n") - - description = data.get('description', 'N/A').strip().replace('\n', ' ').replace(' ', ' ') - tmp.write("description: |\n") - tmp.write(f" {description}\n") - - tmp.write("contributors:\n") - tmp.write(" - rabbit-hole\n") - - os.replace('tmp.yml', file_path) - - return no_pub_date - -def update_pub_year(folder_path, language_code, title): - """ - Function to update the publication year of a book by asking for input. - """ - - # Ask the user for the publication year - print(f"Please enter the publication year for the book '{title}' in {language_code}: ") - publication_year = input() - - file_path = os.path.join(folder_path, f'{language_code}.yml') - - # Open the yaml file and load its data - with open(file_path, 'r') as f: - data = yaml.safe_load(f) - - # Update the publication year - data['publication_year'] = publication_year - - # Write the data back to the file - with open(file_path, 'w') as f: - yaml.dump(data, f, default_flow_style=False) - - -def main(): - """ - Main function to traverse each directory under "../resources/books/" - """ - - base_dir = "../resources/books/" - language_codes = ["fr", "en"] # Add more language codes if necessary - - for folder in os.listdir(base_dir): - folder_path = os.path.join(base_dir, folder) - - if os.path.isdir(folder_path): - # Process book.yaml - book_yaml_path = os.path.join(folder_path, 'book.yaml') - if os.path.exists(book_yaml_path): - process_book_yaml(book_yaml_path) - - # Process {language_code}.yml for each language - for language_code in language_codes: - no_pub_date_books = process_language_file(folder_path, language_code) - - # For each book without a publication date, ask for the year and update it - for book in no_pub_date_books: - update_pub_year(folder_path, language_code, book) - -if __name__ == "__main__": - main() diff --git a/docs/PBN-template-repo/scripts/convert-to-webp-py/README.md b/docs/PBN-template-repo/scripts/convert-to-webp-py/README.md deleted file mode 100644 index 9f53fae1695..00000000000 --- a/docs/PBN-template-repo/scripts/convert-to-webp-py/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Image to WEBP Converter -**UPDATE:** I've made a complete program for local image conversion with a GUI that's easier to use than this script, supports more formats, and automatically removes unnecessary metadata from images. You can test it here on the repo: https://github.com/LoicPandul/ImagesConverter - -**MISE À JOUR :** J'ai fait un logiciel complet pour la conversion d'image en local avec une GUI plus simple à utiliser que ce script, qui prend en charge plus de formats, et qui supprime automatiquement les métadonnées inutiles des images. Je vous conseille de le tester ici sur son repo : https://github.com/LoicPandul/ImagesConverter - ---- - -EN : This little python script converts PNG, JPEG and JPG images into WEBP images. You can use it to quickly convert visuals and diagrams for your Plan ₿ Academy tutorials. The WEBP format allows you to reduce the size of your assets. The script converts the file and then deletes the source file automatically. - -FR : Ce petit script python convertit les images PNG, JPEG et JPG en images WEBP. Vous pouvez l'utiliser pour convertir rapidement des visuels et des schémas pour vos tutoriels Plan ₿ Academy. Le format WEBP vous permet de réduire la taille de vos images. Le script convertit le fichier et supprime ensuite automatiquement le fichier source. - -## Requirements - -- Python 3 -- Pillow - -## EN - Installation - -1. Navigate to the `convert_to_webp.py` file in the GitHub web interface ; -2. Click on the file to open it ; -3. Above the file's content, click on the `Download raw file` arrow to download the script. - -To install the dependencies, run: -``` -pip install Pillow -``` - -## FR - Installation - -1. Naviguez jusqu'au fichier `convert_to_webp.py` dans l'interface web de GitHub ; -2. Cliquez sur le fichier pour l'ouvrir ; -3. Au-dessus du contenu du fichier, cliquez sur la flèche `Download raw file` pour télécharger le script. - -Pour installer les dépendances, exécutez dans un terminal : -``` -pip install Pillow -``` - -## EN - Usage - -To use the script, open a terminal in the folder where `convert_to_webp.py` is located and execute: - -``` -python convert_to_webp.py -``` - -Drag and drop your image files onto the terminal where the script is being executed, then follow the on-screen instructions. You can drop multiple images at once. Press `enter` to execute. - -## FR - Usage - -Pour utiliser le script, ouvrez un terminal dans le dossier où se trouve `convert_to_webp.py` et exécutez : -``` -python convert_to_webp.py -``` - -Glissez et déposez vos fichiers image sur le terminal où le script est exécuté, puis suivez les instructions à l'écran. Vous pouvez déposer plusieurs images en même temps. Tapez `entrer` pour exécuter. \ No newline at end of file diff --git a/docs/PBN-template-repo/scripts/convert-to-webp-py/convert_to_webp.py b/docs/PBN-template-repo/scripts/convert-to-webp-py/convert_to_webp.py deleted file mode 100644 index 9be0fa210d6..00000000000 --- a/docs/PBN-template-repo/scripts/convert-to-webp-py/convert_to_webp.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -from PIL import Image - -def parse_input(input_files): - image_paths = [] - temp_path = "" # Accumulateur pour les chemins non entourés de guillemets - in_quotes = False # Indicateur pour savoir si on est à l'intérieur de guillemets - - for part in input_files.split(' '): - if part.startswith('"'): - in_quotes = True - temp_path += part[1:] # Enlever le guillemet de début - elif part.endswith('"'): - if in_quotes: - # Ajouter à la liste le chemin complet entre guillemets - image_paths.append(temp_path + " " + part[:-1]) # Enlever le guillemet de fin - temp_path = "" # Réinitialiser pour le prochain chemin - in_quotes = False - else: - # Gérer un cas où un chemin se termine par un guillemet sans commencer par un - temp_path += " " + part - image_paths.append(temp_path) - temp_path = "" - elif in_quotes: - # Accumuler les parties du chemin entre guillemets - temp_path += " " + part - else: - # Ajouter directement les chemins sans guillemets - image_paths.append(part) - - # Gérer le cas où le dernier élément n'est pas suivi d'un guillemet - if temp_path: - image_paths.append(temp_path) - - return image_paths - -def convert_to_webp(image_paths): - for image_path in image_paths: - image_path = image_path.strip() # Nettoyer les espaces blancs éventuels - if not os.path.isfile(image_path): - print(f"Le fichier {image_path} n'existe pas.") - continue - - with Image.open(image_path) as image: - image_output_path = os.path.splitext(image_path)[0] + '.webp' - image.save(image_output_path, 'WEBP') - print(f"{image_path} a été converti en {image_output_path}.") - - os.remove(image_path) - print(f"{image_path} a été supprimé.") - -if __name__ == "__main__": - input_files = input("Drag and drop ! (formats : .png, .jpeg, .jpg) : ") - image_paths = parse_input(input_files) - convert_to_webp(image_paths) \ No newline at end of file diff --git a/docs/PBN-template-repo/scripts/extract-language-specific-content.py b/docs/PBN-template-repo/scripts/extract-language-specific-content.py deleted file mode 100644 index bec5f315340..00000000000 --- a/docs/PBN-template-repo/scripts/extract-language-specific-content.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -import shutil - -# Define the origin and destination directory paths -script_directory = os.path.dirname(os.path.abspath(__file__)) -origin_path = os.path.join(script_directory, './../../sovereign-university-data/') -destination_path = os.path.join(script_directory, '../../LLM-Translator/inputs/planb-content/') - - -# Ensure the destination directory exists -os.makedirs(destination_path, exist_ok=True) - -# Define the subfolders and their corresponding file types -subfolders = { - 'courses': 'en.md', - 'tutorials': 'en.md', - 'resources': 'en.yml', - 'professors': 'en.yml', -} - -# Initialize counters for files and words -file_counts = {key: 0 for key in subfolders.keys()} -word_counts = {key: 0 for key in subfolders.keys()} -total_files = 0 -total_words = 0 - -# Function to process each subfolder -def process_subfolder(subfolder, file_type): - global total_files, total_words - path = os.path.join(origin_path, subfolder) - print(path) - for root, dirs, files in os.walk(path): - print(root) - for file in files: - if file.endswith(file_type): - src_file_path = os.path.join(root, file) - # Construct the new filename and its path - new_filename = root.replace(origin_path, '').replace(os.sep, '_') + "_English" + os.path.splitext(file)[1] - dst_file_path = os.path.join(destination_path, new_filename) - # Copy and rename the file - shutil.copy2(src_file_path, dst_file_path) - file_counts[subfolder] += 1 - total_files += 1 - # Count the words in the current file - with open(src_file_path, 'r', encoding='utf-8') as file: - words = file.read().split() - word_counts[subfolder] += len(words) - total_words += len(words) - -# Process each subfolder -for subfolder, file_type in subfolders.items(): - process_subfolder(subfolder, file_type) - -# Output the number of each content type, the total number of files, and word counts -for subfolder in subfolders.keys(): - print(f"{subfolder}: {file_counts[subfolder]} files, {word_counts[subfolder]} words") -print(f"Total files: {total_files}, Total words: {total_words}") - diff --git a/docs/PBN-template-repo/scripts/generate_combined_report.py b/docs/PBN-template-repo/scripts/generate_combined_report.py deleted file mode 100644 index 3116bdf58bc..00000000000 --- a/docs/PBN-template-repo/scripts/generate_combined_report.py +++ /dev/null @@ -1,601 +0,0 @@ -#!/usr/bin/env python3 -"""Generate HTML report from schema validation results for courses and tutorials.""" - -import json -import re -import sys -from datetime import datetime -from pathlib import Path - -def parse_validation_output(raw_output: str, content_type: str) -> list[dict]: - """Parse the raw validation output into structured data.""" - results = [] - - # Split by content markers (courses/ or tutorials/) - pattern = rf'=== ({content_type}/[^/]+(?:/[^/]+)?/) ===\n' - blocks = re.split(pattern, raw_output) - - # Process pairs of (name, json_output) - for i in range(1, len(blocks), 2): - if i + 1 < len(blocks): - name = blocks[i].strip('/') - json_str = blocks[i + 1].strip() - - try: - start = json_str.find('{') - if start == -1: - continue - - brace_count = 0 - end = start - for idx, char in enumerate(json_str[start:], start): - if char == '{': - brace_count += 1 - elif char == '}': - brace_count -= 1 - if brace_count == 0: - end = idx + 1 - break - - json_data = json.loads(json_str[start:end]) - json_data['name'] = name - json_data['content_type'] = content_type - results.append(json_data) - except json.JSONDecodeError as e: - print(f"Warning: Failed to parse JSON for {name}: {e}", file=sys.stderr) - - return results - -def generate_html_report(courses: list[dict], tutorials: list[dict], output_path: str): - """Generate an HTML report from validation results.""" - - all_results = courses + tutorials - - # Course statistics - course_total = len(courses) - courses_with_errors = sum(1 for r in courses if r.get('total_errors', 0) > 0) - course_files = sum(len(r.get('results', [])) for r in courses) - course_errors = sum(r.get('total_errors', 0) for r in courses) - - # Tutorial statistics - tutorial_total = len(tutorials) - tutorials_with_errors = sum(1 for r in tutorials if r.get('total_errors', 0) > 0) - tutorial_files = sum(len(r.get('results', [])) for r in tutorials) - tutorial_errors = sum(r.get('total_errors', 0) for r in tutorials) - - # Overall statistics - total_items = course_total + tutorial_total - total_errors = course_errors + tutorial_errors - total_files = course_files + tutorial_files - - # Group errors by type - error_types = {} - for result in all_results: - for file_result in result.get('results', []): - for error in file_result.get('errors', []): - error_key = error.split(':')[0] if ':' in error else error[:60] - if error_key not in error_types: - error_types[error_key] = {'courses': [], 'tutorials': []} - entry = { - 'name': result.get('name', 'unknown'), - 'file': file_result.get('file', 'unknown'), - 'full_error': error - } - if result.get('content_type') == 'courses': - error_types[error_key]['courses'].append(entry) - else: - error_types[error_key]['tutorials'].append(entry) - - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - - html = f''' - - - - - Schema Validation Report - PlanB Network - - - -
-
-

Schema Validation Report

-

Generated: {timestamp}

-
- -
-

Overall Summary

-
-
-
{total_items}
-
Total Content
-
-
-
{total_files}
-
Files Validated
-
-
-
{total_items - courses_with_errors - tutorials_with_errors}
-
Passing
-
-
-
{courses_with_errors + tutorials_with_errors}
-
With Errors
-
-
-
{total_errors}
-
Total Errors
-
-
- -
-
-
{course_total}
-
Courses
-
-
-
-
-
-
{course_total - courses_with_errors}
-
Courses Passing
-
-
-
{courses_with_errors}
-
Courses Failed
-
-
-
{course_errors}
-
Course Errors
-
-
- -
-
-
{tutorial_total}
-
Tutorials
-
-
-
-
-
-
{tutorial_total - tutorials_with_errors}
-
Tutorials Passing
-
-
-
{tutorials_with_errors}
-
Tutorials Failed
-
-
-
{tutorial_errors}
-
Tutorial Errors
-
-
-
-''' - - # Error summary section - if error_types: - html += ''' -
-

Error Summary

-''' - sorted_errors = sorted(error_types.items(), key=lambda x: -(len(x[1]['courses']) + len(x[1]['tutorials']))) - - for error_type, occurrences in sorted_errors: - course_count = len(occurrences['courses']) - tutorial_count = len(occurrences['tutorials']) - error_id = abs(hash(error_type)) % 100000 - - html += f''' -
-
- {error_type[:70]}... -
-''' - if course_count > 0: - html += f'{course_count} courses' - if tutorial_count > 0: - html += f'{tutorial_count} tutorials' - html += ''' -
-
-
' - - all_occurrences = occurrences['courses'] + occurrences['tutorials'] - for occ in all_occurrences[:15]: - file_path = occ['file'].replace('/home/asi0/asi0-repos/bitcoin-educational-content/', '') - html += f''' -
-
{file_path}
-
{occ['full_error'][:200]}
-
-''' - if len(all_occurrences) > 15: - html += f'
... and {len(all_occurrences) - 15} more occurrences
' - html += ''' -
-
-''' - html += ''' -
-''' - - # Content details with tabs - html += ''' -
-

Content Details

-
-
Courses ({0})
-
Tutorials ({1})
-
-'''.format(course_total, tutorial_total) - - # Courses tab - html += ''' -
-
-''' - sorted_courses = sorted(courses, key=lambda r: (-r.get('total_errors', 0), r.get('name', ''))) - for result in sorted_courses: - name = result.get('name', 'unknown').replace('courses/', '') - errors = result.get('total_errors', 0) - files = len(result.get('results', [])) - card_class = 'has-errors' if errors > 0 else 'valid' - badge = 'FAILED' if errors > 0 else 'OK' - - html += f''' -
-
{name} {badge}
-
- {files} files -''' - if errors > 0: - html += f'{errors} errors' - html += ''' -
-
-''' - html += ''' -
-
-''' - - # Tutorials tab - html += ''' -
-
-''' - sorted_tutorials = sorted(tutorials, key=lambda r: (-r.get('total_errors', 0), r.get('name', ''))) - for result in sorted_tutorials: - name = result.get('name', 'unknown').replace('tutorials/', '') - errors = result.get('total_errors', 0) - files = len(result.get('results', [])) - card_class = 'has-errors' if errors > 0 else 'valid' - badge = 'FAILED' if errors > 0 else 'OK' - - html += f''' -
-
{name} {badge}
-
- {files} files -''' - if errors > 0: - html += f'{errors} errors' - html += ''' -
-
-''' - html += ''' -
-
-
- - -
- - - - -''' - - with open(output_path, 'w', encoding='utf-8') as f: - f.write(html) - - print(f"Report generated: {output_path}") - print(f"\nSummary:") - print(f" Courses: {course_total} total, {courses_with_errors} with errors, {course_errors} total errors") - print(f" Tutorials: {tutorial_total} total, {tutorials_with_errors} with errors, {tutorial_errors} total errors") - -def main(): - course_file = sys.argv[1] if len(sys.argv) > 1 else '/tmp/validation_results.txt' - tutorial_file = sys.argv[2] if len(sys.argv) > 2 else '/tmp/tutorial_validation_results.txt' - output_file = sys.argv[3] if len(sys.argv) > 3 else 'validation_report.html' - - courses = [] - tutorials = [] - - if Path(course_file).exists(): - with open(course_file, 'r') as f: - courses = parse_validation_output(f.read(), 'courses') - - if Path(tutorial_file).exists(): - with open(tutorial_file, 'r') as f: - tutorials = parse_validation_output(f.read(), 'tutorials') - - generate_html_report(courses, tutorials, output_file) - -if __name__ == '__main__': - main() diff --git a/docs/PBN-template-repo/scripts/generate_validation_report.py b/docs/PBN-template-repo/scripts/generate_validation_report.py deleted file mode 100644 index e7281086e13..00000000000 --- a/docs/PBN-template-repo/scripts/generate_validation_report.py +++ /dev/null @@ -1,554 +0,0 @@ -#!/usr/bin/env python3 -"""Generate HTML report from schema validation results.""" - -import json -import re -import sys -from datetime import datetime -from pathlib import Path - -def parse_validation_output(raw_output: str) -> list[dict]: - """Parse the raw validation output into structured data.""" - results = [] - - # Split by course markers - course_blocks = re.split(r'=== (courses/[^/]+/) ===\n', raw_output) - - # Process pairs of (course_name, json_output) - for i in range(1, len(course_blocks), 2): - if i + 1 < len(course_blocks): - course_name = course_blocks[i].strip('/') - json_str = course_blocks[i + 1].strip() - - # Find the JSON object - try: - # Find the start and end of JSON - start = json_str.find('{') - if start == -1: - continue - - # Find matching closing brace - brace_count = 0 - end = start - for idx, char in enumerate(json_str[start:], start): - if char == '{': - brace_count += 1 - elif char == '}': - brace_count -= 1 - if brace_count == 0: - end = idx + 1 - break - - json_data = json.loads(json_str[start:end]) - json_data['course'] = course_name - results.append(json_data) - except json.JSONDecodeError as e: - print(f"Warning: Failed to parse JSON for {course_name}: {e}", file=sys.stderr) - - return results - -def generate_html_report(results: list[dict], output_path: str): - """Generate an HTML report from validation results.""" - - # Calculate statistics - total_courses = len(results) - courses_with_errors = sum(1 for r in results if r.get('total_errors', 0) > 0) - courses_with_warnings = sum(1 for r in results if r.get('total_warnings', 0) > 0) - total_files = sum(len(r.get('results', [])) for r in results) - total_errors = sum(r.get('total_errors', 0) for r in results) - total_warnings = sum(r.get('total_warnings', 0) for r in results) - - # Group errors by type - error_types = {} - for result in results: - for file_result in result.get('results', []): - for error in file_result.get('errors', []): - # Simplify error message for grouping - error_key = error.split(':')[0] if ':' in error else error[:50] - if error_key not in error_types: - error_types[error_key] = [] - error_types[error_key].append({ - 'course': result.get('course', 'unknown'), - 'file': file_result.get('file', 'unknown'), - 'full_error': error - }) - - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - - html = f''' - - - - - Schema Validation Report - PlanB Network Courses - - - -
-
-

Schema Validation Report

-

Generated: {timestamp}

-
- -
-
-
-
{total_courses}
-
Total Courses
-
-
-
{total_files}
-
Files Validated
-
-
-
{total_courses - courses_with_errors}
-
Passing Courses
-
-
-
{courses_with_errors}
-
Courses with Errors
-
-
-
{total_errors}
-
Total Errors
-
-
-
{total_warnings}
-
Total Warnings
-
-
-
-''' - - # Add error summary section if there are errors - if error_types: - html += ''' -
-

Error Summary

-''' - for error_type, occurrences in sorted(error_types.items(), key=lambda x: -len(x[1])): - error_id = hash(error_type) % 10000 - html += f''' -
-
- {error_type[:80]}... - {len(occurrences)} occurrence(s) -
-
-''' - # Show first 4 occurrences - visible_count = 4 - for occ in occurrences[:visible_count]: - file_path = occ['file'].replace('/home/asi0/asi0-repos/bitcoin-educational-content/', '') - html += f''' -
-
{file_path}
-
{occ['full_error']}
-
-''' - # If more than visible_count, add expandable section - if len(occurrences) > visible_count: - remaining = len(occurrences) - visible_count - html += f''' -
- ... and {remaining} more occurrence(s) - click to expand -
-
-''' - for occ in occurrences[visible_count:]: - file_path = occ['file'].replace('/home/asi0/asi0-repos/bitcoin-educational-content/', '') - html += f''' -
-
{file_path}
-
{occ['full_error']}
-
-''' - html += ''' -
-''' - html += ''' -
-
-''' - html += ''' -
-''' - - # Add course details section - html += ''' -
-

Course Details

-
-''' - - # Sort courses: errors first, then warnings, then valid - sorted_results = sorted(results, key=lambda r: (-r.get('total_errors', 0), -r.get('total_warnings', 0), r.get('course', ''))) - - for result in sorted_results: - course = result.get('course', 'unknown') - errors = result.get('total_errors', 0) - warnings = result.get('total_warnings', 0) - files = len(result.get('results', [])) - - if errors > 0: - card_class = 'has-errors' - badge = 'FAILED' - elif warnings > 0: - card_class = 'has-warnings' - badge = 'WARNINGS' - else: - card_class = 'valid' - badge = 'PASSED' - - html += f''' -
-
{course} {badge}
-
- {files} files -''' - if errors > 0: - html += f'{errors} errors' - if warnings > 0: - html += f'{warnings} warnings' - - html += ''' -
-
-''' - - html += ''' -
-
- - -
- - - - -''' - - with open(output_path, 'w', encoding='utf-8') as f: - f.write(html) - - print(f"Report generated: {output_path}") - print(f"\nSummary:") - print(f" Total courses: {total_courses}") - print(f" Courses with errors: {courses_with_errors}") - print(f" Total errors: {total_errors}") - print(f" Total warnings: {total_warnings}") - -def main(): - # Read input - input_file = sys.argv[1] if len(sys.argv) > 1 else '/tmp/validation_results.txt' - output_file = sys.argv[2] if len(sys.argv) > 2 else 'validation_report.html' - - with open(input_file, 'r') as f: - raw_output = f.read() - - results = parse_validation_output(raw_output) - generate_html_report(results, output_file) - -if __name__ == '__main__': - main() diff --git a/docs/PBN-template-repo/scripts/get-planb-tags.py b/docs/PBN-template-repo/scripts/get-planb-tags.py deleted file mode 100644 index a3d7c5104af..00000000000 --- a/docs/PBN-template-repo/scripts/get-planb-tags.py +++ /dev/null @@ -1,41 +0,0 @@ -import os -import yaml -from collections import defaultdict - -# Initialize a dictionary to store tags and their occurrence count -tag_counts = defaultdict(int) - -# Walk through the directory and its subdirectories -for root, dirs, files in os.walk("."): - for file in files: - # Check if the file is a .yml file - if file.endswith(".yml"): - file_path = os.path.join(root, file) - with open(file_path, 'r') as ymlfile: - try: - # Load the content of the .yml file - content = yaml.safe_load(ymlfile) - if content and 'tags' in content: - # Increment the count for each tag - for tag in content['tags']: - tag_counts[tag] += 1 - except yaml.YAMLError as exc: - print(f"Error parsing YAML file: {file_path}", exc) - -# Sort tags alphabetically and then by their occurrence count -sorted_tags_alphabetically = sorted(tag_counts.items()) -sorted_tags_by_occurrence = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True) - -# Write the sorted tags alphabetically to tags.md -with open("planb-tags.md", "w") as mdfile: - for tag, count in sorted_tags_alphabetically: - mdfile.write(f"- {tag}: {count}\n") - -# Write the sorted tags by occurrence to tag-occurrence-sorted.md -with open("planb-tag-occurrence-sorted.md", "w") as mdfile_occ: - for tag, count in sorted_tags_by_occurrence: - mdfile_occ.write(f"- {tag}: {count}\n") - -print("planb-tags.md file has been created with sorted tags alphabetically.") -print("planb-tag-occurrence-sorted.md file has been created with tags sorted by their occurrences.") - diff --git a/docs/PBN-template-repo/scripts/planb-tag-occurrence-sorted.md b/docs/PBN-template-repo/scripts/planb-tag-occurrence-sorted.md deleted file mode 100644 index 991f98adac1..00000000000 --- a/docs/PBN-template-repo/scripts/planb-tag-occurrence-sorted.md +++ /dev/null @@ -1,852 +0,0 @@ -- Bitcoin: 576 -- Lightning Network: 110 -- bitcoin: 99 -- technology: 69 -- economy: 54 -- bitcoin-only: 53 -- Mining: 53 -- privacy: 51 -- finance: 46 -- open-source: 42 -- Cryptography: 42 -- Cryptocurrency: 39 -- education: 38 -- Economics: 38 -- Hyperinflation: 36 -- HD Wallet: 35 -- Bitcoin Wallet: 33 -- Inflation: 29 -- history: 28 -- Security: 26 -- Bitcoin Protocol: 26 -- Bitcoin Basis: 25 -- self-custodial: 25 -- wallet: 24 -- Money History: 24 -- infrastructure: 23 -- Austrian Economics: 21 -- Cybersecurity: 21 -- Privacy: 16 -- exchange: 15 -- Wallet: 15 -- development: 14 -- lightning-network: 13 -- philosophy: 13 -- Wallets: 13 -- lightning: 12 -- Nodes: 12 -- Currency Basis: 12 -- Wallet security: 12 -- Money: 12 -- Passphrase: 12 -- politics: 11 -- Open-source: 11 -- Transactions: 11 -- ECDSA: 11 -- Transaction: 10 -- Investment: 10 -- Blockchain: 10 -- economics: 9 -- Education: 9 -- node: 9 -- HTLC: 9 -- 2FA: 9 -- Online browsing: 9 -- Hash Functions: 9 -- Private Key: 9 -- SHA256: 9 -- Hodler: 9 -- Online Security: 9 -- Estate planning: 9 -- Cryptoasset inheritance: 9 -- Bitcoin mining: 9 -- Commitment Transaction: 9 -- P2P solutions: 9 -- Monetary Policy: 9 -- Bitcoin Origins: 9 -- Austrian School: 9 -- Business Cycle: 9 -- lightning network: 8 -- security: 8 -- fiction: 8 -- multi-coin: 8 -- KYC: 8 -- lightning-infrastructure: 8 -- Payment Channels: 8 -- Monetary History: 8 -- Channel Opening: 8 -- Backup Procedure: 8 -- Computer security: 8 -- Online security: 8 -- mining: 7 -- trading: 7 -- Miners: 7 -- Mnemonic phrase: 7 -- Fiat Currencies: 7 -- Monetary Dynamics: 7 -- online security: 7 -- cryptography: 6 -- psychology: 6 -- business: 6 -- peer-to-peer: 6 -- bitcoin-only: 6 -- DCA: 6 -- Decentralization: 6 -- Stacker: 6 -- Liquidity Management: 6 -- Proof of Work: 6 -- Public keys: 6 -- Halving: 6 -- Receiving addresses: 6 -- Austrian economics: 6 -- VPN: 6 -- Private key: 6 -- science: 5 -- science fiction: 5 -- self-help: 5 -- custodial: 5 -- Depth: 5 -- Public Key: 5 -- Invoice: 5 -- Self Custody: 5 -- Revocation Key: 5 -- Elliptic Curve: 5 -- Digital Signature: 5 -- Stablecoin: 5 -- rust: 4 -- investing: 4 -- Technology: 4 -- investment: 4 -- peer to peer: 4 -- privact: 4 -- Scams: 4 -- Government response: 4 -- Stablecoins: 4 -- Fiat Currency: 4 -- Currency devaluation: 4 -- Email Attacks: 4 -- Purchasing Power: 4 -- Regulation: 4 -- Seed: 4 -- Monetary history: 4 -- Difficulty Adjustment: 4 -- Network: 4 -- Exchange: 4 -- Subjective Value: 4 -- Currency: 4 -- Public key: 4 -- Centralization: 4 -- password manager: 4 -- Investing: 4 -- coinjoin: 4 -- Samourai: 4 -- cypherpunk: 3 -- RGB: 3 -- hacker: 3 -- entrepreneurship: 3 -- personal finance: 3 -- social issues: 3 -- environment: 3 -- management: 3 -- education;: 3 -- security-solution: 3 -- marchand: 3 -- Lightning: 3 -- OS: 3 -- Node: 3 -- Scalability: 3 -- Fiat currencies: 3 -- Monetary reform: 3 -- Bitcoin Culture: 3 -- Cypherpunks: 3 -- Economy: 3 -- Adoption: 3 -- Virgin Bitcoins: 3 -- Division of Labor: 3 -- Ransomware: 3 -- HMAC SHA-512 algorithm: 3 -- Cold Wallet: 3 -- UTXO: 3 -- Phishing: 3 -- Transaction fees: 3 -- Cheating: 3 -- Cooperative Closure: 3 -- Investment strategy: 3 -- Financial system: 3 -- Bank of England: 3 -- Mnemonic Phrase: 3 -- Protocol: 3 -- Risk: 3 -- Open source: 3 -- Censorship: 3 -- Banking: 3 -- RoboSat: 3 -- Characteristics: 3 -- Scarcity: 3 -- Network security: 3 -- Reward: 3 -- Blocks: 3 -- Forced Closure: 3 -- password: 3 -- Satoshi: 3 -- Antivirus: 3 -- Zimbabwe: 3 -- ASIC: 3 -- Redenomination: 3 -- Savings: 3 -- Gold: 3 -- Extended Keys: 3 -- Environment: 3 -- Division of labor: 3 -- point of sales: 3 -- DIY: 3 -- light-KYC: 3 -- mix: 3 -- payjoin: 3 -- P2EP: 3 -- Breez: 2 -- cryptocurrency: 2 -- ethics: 2 -- banking: 2 -- biography: 2 -- art: 2 -- energy: 2 -- software development: 2 -- programming: 2 -- mathematics: 2 -- technology;: 2 -- internet: 2 -- evolution: 2 -- personal development: 2 -- law: 2 -- cyberpunk: 2 -- finance;: 2 -- anthropology: 2 -- Infrastructure: 2 -- layer-2: 2 -- Data: 2 -- nodes: 2 -- VC: 2 -- Cookies: 2 -- Bitcoin Adoption: 2 -- Misconception: 2 -- Email security: 2 -- Online safety: 2 -- Zombie Companies: 2 -- Blockchain Trilemma: 2 -- Attakai: 2 -- HTTPS: 2 -- Energy management: 2 -- Data Backup: 2 -- Banking system: 2 -- Switzerland: 2 -- Master Private Key: 2 -- Hot Wallet: 2 -- Block war: 2 -- Timelock: 2 -- Volatility: 2 -- BIP32: 2 -- Video games: 2 -- Central Planning: 2 -- Bitcoin Storage: 2 -- Self-Custody: 2 -- Bitcoin History: 2 -- Passwords: 2 -- PBKDF2: 2 -- Deflation: 2 -- FOMO: 2 -- Data encryption: 2 -- Operating systems: 2 -- Barter: 2 -- Multi-signature: 2 -- Human Rights: 2 -- Exchange platform: 2 -- Traditional Finance: 2 -- Germany: 2 -- HodlHodl: 2 -- Segwit Standard: 2 -- Axioms: 2 -- self-hosted password manager: 2 -- LNP2PBot: 2 -- Channel Closure: 2 -- Hashrate: 2 -- Consensus Rules: 2 -- Verification: 2 -- password strength: 2 -- Two-Factor Authentication: 2 -- Economic Calculation: 2 -- BTCMap: 2 -- Checksum: 2 -- Windows: 2 -- HRP: 2 -- Satoshi Nakamoto: 2 -- Password Management: 2 -- Carl Menger: 2 -- Liquidity: 2 -- Multidisciplinary: 2 -- Public trust: 2 -- AMF: 2 -- Hash: 2 -- Time Preference: 2 -- Interest Rates: 2 -- Transaction Routing: 2 -- Cyber Attacks: 2 -- Dollarization: 2 -- Asymetric Cryptography: 2 -- Block size: 2 -- Bitcoin Node: 2 -- Trading: 2 -- Online privacy: 2 -- password management: 2 -- Communication: 2 -- Central Banks: 2 -- Transaction Fees: 2 -- Wallet types: 2 -- Exchange Platform: 2 -- Business: 2 -- Merchant: 2 -- Software: 2 -- HMAC: 2 -- Consensus Algorithm: 2 -- Benefits: 2 -- Mining centralization: 2 -- France: 2 -- Child Keys: 2 -- ATMs: 2 -- Notgeld: 2 -- Weimar Republic: 2 -- Non-KYC: 2 -- Private keys: 2 -- Password manager: 2 -- Regulations: 2 -- merchant: 2 -- Full KYC: 2 -- whirlpool: 2 -- Coinjoin: 2 -- LND: 2 -- cahoots: 2 -- Hacker: 1 -- el salvador: 1 -- Rust: 1 -- educator: 1 -- activist: 1 -- youtuber: 1 -- merchants: 1 -- use cases: 1 -- macroeconomic: 1 -- developer: 1 -- accountability: 1 -- nostr: 1 -- crime: 1 -- religion: 1 -- sience fiction: 1 -- jason fried: 1 -- physics: 1 -- startups: 1 -- quantum computing: 1 -- dystopia: 1 -- marketing: 1 -- growth hacking: 1 -- wealth building: 1 -- psycology: 1 -- interviews: 1 -- poetry;: 1 -- anarchisme: 1 -- mathematique: 1 -- policy: 1 -- motivation: 1 -- creativity: 1 -- probability: 1 -- leadership: 1 -- programming;: 1 -- computer science: 1 -- deflation: 1 -- journalism: 1 -- social issues;: 1 -- strategy: 1 -- commodity: 1 -- social issue: 1 -- graphic novel: 1 -- principles: 1 -- autobiography: 1 -- economic: 1 -- inflation: 1 -- cyberculture: 1 -- biology: 1 -- genetics: 1 -- psychology;: 1 -- opensource: 1 -- open source: 1 -- programmation: 1 -- artificial intelligence: 1 -- sustainability: 1 -- ecology: 1 -- productivity: 1 -- hacking: 1 -- international relations: 1 -- geopolitics: 1 -- novel: 1 -- information theory: 1 -- money: 1 -- econimic: 1 -- hedge funds: 1 -- philosophy;: 1 -- paiement-solution: 1 -- centralize: 1 -- vpn: 1 -- lightning-market: 1 -- educatition: 1 -- books: 1 -- paiement solution: 1 -- conference: 1 -- tools: 1 -- developers: 1 -- podcast-solution: 1 -- Point of sale: 1 -- blog: 1 -- donateur: 1 -- Coach: 1 -- bitcoin-investment: 1 -- sociale media: 1 -- communication: 1 -- news: 1 -- lightning-managment: 1 -- exchanges: 1 -- non-kyc: 1 -- game: 1 -- lightning-wallet: 1 -- Bitcoin-investment: 1 -- research: 1 -- vc: 1 -- 3D printed: 1 -- Art: 1 -- LN wallet: 1 -- sociale: 1 -- life-style: 1 -- afrique: 1 -- Financial System: 1 -- Incentives: 1 -- Raspberry Pi 4: 1 -- Lightning Authentication: 1 -- Decentralized Identities: 1 -- Protection: 1 -- Private browsing: 1 -- Government restrictions: 1 -- Influencers: 1 -- Data breach: 1 -- HMAC-SHA512: 1 -- Course-related: 1 -- Nicaragua: 1 -- Cordoba: 1 -- Bitcoin Blockchain: 1 -- Monetary Decisions: 1 -- Central Bankers: 1 -- Bitcoin Security: 1 -- Address Derivation: 1 -- Compression: 1 -- Spontaneous purchase: 1 -- Password Manager: 1 -- Cyberpunk movement: 1 -- Digital world: 1 -- Browsing: 1 -- Absolute Advantage: 1 -- Revolution: 1 -- pbkdf2 function: 1 -- Seed Generation: 1 -- Concerns: 1 -- Economic Crises: 1 -- Network Knowledge: 1 -- Bank Balance Sheets: 1 -- Debate: 1 -- RNG: 1 -- Cap: 1 -- Course Instructor: 1 -- Trend: 1 -- Purchase Plan: 1 -- Economic Incentive: 1 -- SegWit: 1 -- Peach: 1 -- Dutch East India Company: 1 -- Child Key Pairs: 1 -- Milton Friedman: 1 -- Order books: 1 -- Makers: 1 -- Takers: 1 -- Legal tender: 1 -- Pay2PublicKey: 1 -- Confirmations: 1 -- Bubble: 1 -- Discretion: 1 -- Price Increase: 1 -- Onion routing: 1 -- Ideologies: 1 -- Investment planning: 1 -- Time Value of Money: 1 -- Mainstream Economics: 1 -- Generator Point: 1 -- Ponzi schemes: 1 -- Browser extensions: 1 -- BIP43: 1 -- BIP44: 1 -- Hierarchy: 1 -- Dice: 1 -- Money supply: 1 -- Programming Languages: 1 -- Comparative Advantage: 1 -- Bubbles: 1 -- FUD: 1 -- Master Chain Code: 1 -- Monetary expansion: 1 -- Keynesian Economics: 1 -- Elliptic curves: 1 -- Double coincidence of wants: 1 -- Mining pool: 1 -- Applications: 1 -- Transparency: 1 -- Content creators: 1 -- MMT: 1 -- Peer-to-peer: 1 -- Market Dynamics: 1 -- Sound Money: 1 -- LNURL: 1 -- Bisq: 1 -- Bitcoin Benefit: 1 -- Bolivia: 1 -- Freedom: 1 -- Users: 1 -- KYC light: 1 -- Community: 1 -- Network management: 1 -- Asymetric Cryptoraphy: 1 -- Fiat Bank: 1 -- Kagan's criterion: 1 -- Bankruptcy: 1 -- Rentenmark: 1 -- BCH Checksum: 1 -- Error Detection: 1 -- Energy Cost: 1 -- Fountain: 1 -- Time preferences: 1 -- Money Supply: 1 -- Metal Coin: 1 -- Commodity money: 1 -- Bitwarden: 1 -- KeePass: 1 -- Fractional Reserve System: 1 -- Financial Stability: 1 -- Bitcoin protocol: 1 -- Peer-to-Peer: 1 -- Dictatorial regimes: 1 -- Antwerp Stock Exchange: 1 -- Profit: 1 -- Equation: 1 -- Value: 1 -- Inheritance Plan: 1 -- Mindset: 1 -- HOTP: 1 -- TOTP: 1 -- Technological revolution: 1 -- Florin: 1 -- Price: 1 -- Internet: 1 -- Pragmatism: 1 -- Commitment: 1 -- ASICS: 1 -- Austrian Business Cycle Theory: 1 -- Economic Cycle: 1 -- Dark Web: 1 -- Tools: 1 -- 51% Attack: 1 -- Networking: 1 -- Marginal Utility: 1 -- Human Action: 1 -- Production Activities: 1 -- XPUB: 1 -- Peru: 1 -- Development: 1 -- Electricity: 1 -- Equipment: 1 -- Software update: 1 -- Enterprise VPN: 1 -- Consumer VPN: 1 -- Genesis Block: 1 -- Intertemporal coordination: 1 -- Blockchain Technology: 1 -- Bank accounts: 1 -- CPI: 1 -- Future of finance: 1 -- Browser Extensions: 1 -- Software efficiency: 1 -- ASICs: 1 -- Obtaining Bitcoins: 1 -- Compensation: 1 -- Money laundering: 1 -- Traceability: 1 -- Cloud Storage: 1 -- Base Layer: 1 -- Settlement Layer: 1 -- OpenNode: 1 -- Sats: 1 -- TOR network: 1 -- Interest rates: 1 -- Economic crises: 1 -- Receiving Address: 1 -- secp256k1: 1 -- The Reich's Banker: 1 -- Adoption curve: 1 -- Mining Pools: 1 -- Password Alternatives: 1 -- Passkeys: 1 -- Biometrics: 1 -- Entropy: 1 -- Incentive: 1 -- Creation: 1 -- Payment: 1 -- password replacement: 1 -- Hot wallet: 1 -- Bernholz: 1 -- Certificate System: 1 -- Public Keys: 1 -- Payment models: 1 -- Consumption: 1 -- Open-Source Wallet: 1 -- Monetary policy: 1 -- Economic challenges: 1 -- Economy Basis: 1 -- Keynesian economics: 1 -- Monetarist economics: 1 -- Immutability: 1 -- Methodology: 1 -- Social sciences: 1 -- Layer-2 Solution: 1 -- Public Debt: 1 -- Golden Rule: 1 -- Investment Strategy: 1 -- Practical Experience: 1 -- Capital Misallocation: 1 -- Wealth Inequality: 1 -- Voluntary Exchange: 1 -- Subjective Utility: 1 -- Learning resources: 1 -- Uncertainty: 1 -- Single Sign-On: 1 -- Internet Giants: 1 -- Bernold: 1 -- Keynesian Doctrine: 1 -- Money streaming: 1 -- Key Pair: 1 -- Master Password: 1 -- Physical Security: 1 -- Individual responsibility: 1 -- Regulation and electricity tariffs: 1 -- Modern Monetary Theory: 1 -- Money printing: 1 -- Political goals: 1 -- Goldsmiths: 1 -- Route Searching: 1 -- Legal Tender: 1 -- Course Production: 1 -- Swiss Bitcoin Pay: 1 -- Master Key: 1 -- Learning pace: 1 -- Wi-Fi: 1 -- GDPR: 1 -- Cost: 1 -- Green energy: 1 -- Standard of Living: 1 -- Fractional Reserve Banking: 1 -- Proof of Stake: 1 -- Business opportunities: 1 -- Software updates: 1 -- Economic Cycles: 1 -- CBDCs: 1 -- Financial Freedom: 1 -- Biometric data: 1 -- Receiving Capacity: 1 -- Linux: 1 -- Mac: 1 -- Economic impact: 1 -- Venezuela: 1 -- Digital bolivar: 1 -- BCH Program: 1 -- BIPs: 1 -- Issues: 1 -- Flexibility: 1 -- Adaptability: 1 -- Hardened Derivations: 1 -- Capitalism: 1 -- Entrepreneurs: 1 -- BCH32: 1 -- Base 32: 1 -- Bandwidth: 1 -- Distrust: 1 -- Random Number Generation: 1 -- Threat Monitoring: 1 -- Good Pratice: 1 -- Total supply: 1 -- Mission: 1 -- Censorship resistance: 1 -- Open-source software: 1 -- Traditional Market: 1 -- Bitcoin Incentives: 1 -- LastPass: 1 -- Dashlane: 1 -- Malware: 1 -- Virus: 1 -- USB keys: 1 -- Learning: 1 -- Adaptation: 1 -- Prices: 1 -- Market: 1 -- Route Estimation: 1 -- Brazil: 1 -- Consensus rules: 1 -- Purchase methods: 1 -- Economic Agents: 1 -- Marginal Theory of Value: 1 -- Pruned node: 1 -- BIP38: 1 -- Platforms: 1 -- Strategy: 1 -- One-Time Passwords: 1 -- Mobile Security: 1 -- Cherry-picking: 1 -- AI: 1 -- Future implications: 1 -- Price Drop: 1 -- Best practices: 1 -- User Types: 1 -- Zimbabwean dollar: 1 -- Exchange rate: 1 -- Index: 1 -- BIP39: 1 -- Andreas Antonopoulos: 1 -- Barack Obama: 1 -- Financial Crises: 1 -- Data backup: 1 -- Keysend: 1 -- Extended Private Key: 1 -- Economic reasoning: 1 -- Security Vulnerabilities: 1 -- Human Negligence: 1 -- Task: 1 -- Web browsers: 1 -- Nonce: 1 -- Inflation rate: 1 -- Future vision: 1 -- Exposure: 1 -- BTCpay Server: 1 -- Payment network: 1 -- Legal status: 1 -- Holding: 1 -- Bitcoin wallet: 1 -- Bitcoin Transaction: 1 -- Block Space: 1 -- TOR: 1 -- HashRate: 1 -- Uncensorability: 1 -- Dollar Cost Average: 1 -- DigiCash: 1 -- Skills: 1 -- Argentina: 1 -- Home Browsing: 1 -- Social Cooperation: 1 -- Micro-transactions: 1 -- Government: 1 -- Segwit: 1 -- Addresses: 1 -- Lottery principle: 1 -- Chain Code: 1 -- Mistakes: 1 -- SHA-256: 1 -- Hash Function: 1 -- DNS over HTTPS: 1 -- DNS over TLS: 1 -- Internet Security: 1 -- Preimage Hash: 1 -- Recovery phrase: 1 -- Challenges: 1 -- HMAC function: 1 -- Bitcoin Network: 1 -- Hardware tokens: 1 -- Course Goals: 1 -- Atomic Swap: 1 -- Discrete logarithm problem: 1 -- Transaction routing: 1 -- Anonymity: 1 -- Network integrity: 1 -- Demand Pull: 1 -- Cost Push: 1 -- Bitcoin Whitepaper: 1 -- Investment decisions: 1 -- Inheritance: 1 -- Notary: 1 -- brute force attack: 1 -- DCA platform: 1 -- Elliptic curve: 1 -- Scalar multiplication: 1 -- Salability: 1 -- Ban: 1 -- Forum: 1 -- Game Theory: 1 -- Energy: 1 -- UTXOs: 1 -- technical knowledge: 1 -- Software downloads: 1 -- Asymmetric Cryptography: 1 -- Future: 1 -- Remote Access: 1 -- Hard Fork: 1 -- Data Encryption: 1 -- Renting goods: 1 -- Key Generation: 1 -- Technological Competence: 1 -- SMS: 1 -- Coordination: 1 -- Limit: 1 -- Payment channels: 1 -- Socialism: 1 -- Economic Crisis: 1 -- Liquidity Distribution: 1 -- Capital Goods: 1 -- Prefix: 1 -- ICOs: 1 -- Rewards: 1 -- Randomness Factor: 1 -- Viruses: 1 -- Specialization: 1 -- JBOC: 1 -- Online Scams: 1 -- Domain Name: 1 -- Monetary system: 1 -- Extended Key: 1 -- HMAC-SHA-512: 1 -- Game theory: 1 -- Byzantine Generals' Problem: 1 -- Innovation: 1 -- Disruption: 1 -- Ecosystem: 1 -- infrasctructure: 1 -- POS: 1 -- non-KYC: 1 -- watch-only: 1 -- xpub: 1 -- timelocked: 1 -- multi-sig: 1 -- muli-sig: 1 -- do it yourself: 1 -- Dice Roll: 1 -- GitHub: 1 -- Obsidian: 1 -- Plan ₿ Academy: 1 -- full KYC: 1 -- full YC: 1 -- Raspberry Pi: 1 -- BIP: 1 -- WST: 1 -- Whirlpool: 1 -- entropy: 1 -- Boltzmann: 1 -- coin control: 1 -- analysis: 1 -- Sparrow: 1 diff --git a/docs/PBN-template-repo/scripts/planb-tags.md b/docs/PBN-template-repo/scripts/planb-tags.md deleted file mode 100644 index b20e2601ba9..00000000000 --- a/docs/PBN-template-repo/scripts/planb-tags.md +++ /dev/null @@ -1,852 +0,0 @@ -- 2FA: 9 -- 3D printed: 1 -- 51% Attack: 1 -- AI: 1 -- AMF: 2 -- ASIC: 3 -- ASICS: 1 -- ASICs: 1 -- ATMs: 2 -- Absolute Advantage: 1 -- Adaptability: 1 -- Adaptation: 1 -- Address Derivation: 1 -- Addresses: 1 -- Adoption: 3 -- Adoption curve: 1 -- Andreas Antonopoulos: 1 -- Anonymity: 1 -- Antivirus: 3 -- Antwerp Stock Exchange: 1 -- Applications: 1 -- Argentina: 1 -- Art: 1 -- Asymetric Cryptography: 2 -- Asymetric Cryptoraphy: 1 -- Asymmetric Cryptography: 1 -- Atomic Swap: 1 -- Attakai: 2 -- Austrian Business Cycle Theory: 1 -- Austrian Economics: 21 -- Austrian School: 9 -- Austrian economics: 6 -- Axioms: 2 -- BCH Checksum: 1 -- BCH Program: 1 -- BCH32: 1 -- BIP: 1 -- BIP32: 2 -- BIP38: 1 -- BIP39: 1 -- BIP43: 1 -- BIP44: 1 -- BIPs: 1 -- BTCMap: 2 -- BTCpay Server: 1 -- Backup Procedure: 8 -- Ban: 1 -- Bandwidth: 1 -- Bank Balance Sheets: 1 -- Bank accounts: 1 -- Bank of England: 3 -- Banking: 3 -- Banking system: 2 -- Bankruptcy: 1 -- Barack Obama: 1 -- Barter: 2 -- Base 32: 1 -- Base Layer: 1 -- Benefits: 2 -- Bernholz: 1 -- Bernold: 1 -- Best practices: 1 -- Biometric data: 1 -- Biometrics: 1 -- Bisq: 1 -- Bitcoin: 576 -- Bitcoin Adoption: 2 -- Bitcoin Basis: 25 -- Bitcoin Benefit: 1 -- Bitcoin Blockchain: 1 -- Bitcoin Culture: 3 -- Bitcoin History: 2 -- Bitcoin Incentives: 1 -- Bitcoin Network: 1 -- Bitcoin Node: 2 -- Bitcoin Origins: 9 -- Bitcoin Protocol: 26 -- Bitcoin Security: 1 -- Bitcoin Storage: 2 -- Bitcoin Transaction: 1 -- Bitcoin Wallet: 33 -- Bitcoin Whitepaper: 1 -- Bitcoin mining: 9 -- Bitcoin protocol: 1 -- Bitcoin wallet: 1 -- Bitcoin-investment: 1 -- Bitwarden: 1 -- Block Space: 1 -- Block size: 2 -- Block war: 2 -- Blockchain: 10 -- Blockchain Technology: 1 -- Blockchain Trilemma: 2 -- Blocks: 3 -- Bolivia: 1 -- Boltzmann: 1 -- Brazil: 1 -- Breez: 2 -- Browser Extensions: 1 -- Browser extensions: 1 -- Browsing: 1 -- Bubble: 1 -- Bubbles: 1 -- Business: 2 -- Business Cycle: 9 -- Business opportunities: 1 -- Byzantine Generals' Problem: 1 -- CBDCs: 1 -- CPI: 1 -- Cap: 1 -- Capital Goods: 1 -- Capital Misallocation: 1 -- Capitalism: 1 -- Carl Menger: 2 -- Censorship: 3 -- Censorship resistance: 1 -- Central Bankers: 1 -- Central Banks: 2 -- Central Planning: 2 -- Centralization: 4 -- Certificate System: 1 -- Chain Code: 1 -- Challenges: 1 -- Channel Closure: 2 -- Channel Opening: 8 -- Characteristics: 3 -- Cheating: 3 -- Checksum: 2 -- Cherry-picking: 1 -- Child Key Pairs: 1 -- Child Keys: 2 -- Cloud Storage: 1 -- Coach: 1 -- Coinjoin: 2 -- Cold Wallet: 3 -- Commitment: 1 -- Commitment Transaction: 9 -- Commodity money: 1 -- Communication: 2 -- Community: 1 -- Comparative Advantage: 1 -- Compensation: 1 -- Compression: 1 -- Computer security: 8 -- Concerns: 1 -- Confirmations: 1 -- Consensus Algorithm: 2 -- Consensus Rules: 2 -- Consensus rules: 1 -- Consumer VPN: 1 -- Consumption: 1 -- Content creators: 1 -- Cookies: 2 -- Cooperative Closure: 3 -- Coordination: 1 -- Cordoba: 1 -- Cost: 1 -- Cost Push: 1 -- Course Goals: 1 -- Course Instructor: 1 -- Course Production: 1 -- Course-related: 1 -- Creation: 1 -- Cryptoasset inheritance: 9 -- Cryptocurrency: 39 -- Cryptography: 42 -- Currency: 4 -- Currency Basis: 12 -- Currency devaluation: 4 -- Cyber Attacks: 2 -- Cyberpunk movement: 1 -- Cybersecurity: 21 -- Cypherpunks: 3 -- DCA: 6 -- DCA platform: 1 -- DIY: 3 -- DNS over HTTPS: 1 -- DNS over TLS: 1 -- Dark Web: 1 -- Dashlane: 1 -- Data: 2 -- Data Backup: 2 -- Data Encryption: 1 -- Data backup: 1 -- Data breach: 1 -- Data encryption: 2 -- Debate: 1 -- Decentralization: 6 -- Decentralized Identities: 1 -- Deflation: 2 -- Demand Pull: 1 -- Depth: 5 -- Development: 1 -- Dice: 1 -- Dice Roll: 1 -- Dictatorial regimes: 1 -- Difficulty Adjustment: 4 -- DigiCash: 1 -- Digital Signature: 5 -- Digital bolivar: 1 -- Digital world: 1 -- Discrete logarithm problem: 1 -- Discretion: 1 -- Disruption: 1 -- Distrust: 1 -- Division of Labor: 3 -- Division of labor: 3 -- Dollar Cost Average: 1 -- Dollarization: 2 -- Domain Name: 1 -- Double coincidence of wants: 1 -- Dutch East India Company: 1 -- ECDSA: 11 -- Economic Agents: 1 -- Economic Calculation: 2 -- Economic Crises: 1 -- Economic Crisis: 1 -- Economic Cycle: 1 -- Economic Cycles: 1 -- Economic Incentive: 1 -- Economic challenges: 1 -- Economic crises: 1 -- Economic impact: 1 -- Economic reasoning: 1 -- Economics: 38 -- Economy: 3 -- Economy Basis: 1 -- Ecosystem: 1 -- Education: 9 -- Electricity: 1 -- Elliptic Curve: 5 -- Elliptic curve: 1 -- Elliptic curves: 1 -- Email Attacks: 4 -- Email security: 2 -- Energy: 1 -- Energy Cost: 1 -- Energy management: 2 -- Enterprise VPN: 1 -- Entrepreneurs: 1 -- Entropy: 1 -- Environment: 3 -- Equation: 1 -- Equipment: 1 -- Error Detection: 1 -- Estate planning: 9 -- Exchange: 4 -- Exchange Platform: 2 -- Exchange platform: 2 -- Exchange rate: 1 -- Exposure: 1 -- Extended Key: 1 -- Extended Keys: 3 -- Extended Private Key: 1 -- FOMO: 2 -- FUD: 1 -- Fiat Bank: 1 -- Fiat Currencies: 7 -- Fiat Currency: 4 -- Fiat currencies: 3 -- Financial Crises: 1 -- Financial Freedom: 1 -- Financial Stability: 1 -- Financial System: 1 -- Financial system: 3 -- Flexibility: 1 -- Florin: 1 -- Forced Closure: 3 -- Forum: 1 -- Fountain: 1 -- Fractional Reserve Banking: 1 -- Fractional Reserve System: 1 -- France: 2 -- Freedom: 1 -- Full KYC: 2 -- Future: 1 -- Future implications: 1 -- Future of finance: 1 -- Future vision: 1 -- GDPR: 1 -- Game Theory: 1 -- Game theory: 1 -- Generator Point: 1 -- Genesis Block: 1 -- Germany: 2 -- GitHub: 1 -- Gold: 3 -- Golden Rule: 1 -- Goldsmiths: 1 -- Good Pratice: 1 -- Government: 1 -- Government response: 4 -- Government restrictions: 1 -- Green energy: 1 -- HD Wallet: 35 -- HMAC: 2 -- HMAC SHA-512 algorithm: 3 -- HMAC function: 1 -- HMAC-SHA-512: 1 -- HMAC-SHA512: 1 -- HOTP: 1 -- HRP: 2 -- HTLC: 9 -- HTTPS: 2 -- Hacker: 1 -- Halving: 6 -- Hard Fork: 1 -- Hardened Derivations: 1 -- Hardware tokens: 1 -- Hash: 2 -- Hash Function: 1 -- Hash Functions: 9 -- HashRate: 1 -- Hashrate: 2 -- Hierarchy: 1 -- HodlHodl: 2 -- Hodler: 9 -- Holding: 1 -- Home Browsing: 1 -- Hot Wallet: 2 -- Hot wallet: 1 -- Human Action: 1 -- Human Negligence: 1 -- Human Rights: 2 -- Hyperinflation: 36 -- ICOs: 1 -- Ideologies: 1 -- Immutability: 1 -- Incentive: 1 -- Incentives: 1 -- Index: 1 -- Individual responsibility: 1 -- Inflation: 29 -- Inflation rate: 1 -- Influencers: 1 -- Infrastructure: 2 -- Inheritance: 1 -- Inheritance Plan: 1 -- Innovation: 1 -- Interest Rates: 2 -- Interest rates: 1 -- Internet: 1 -- Internet Giants: 1 -- Internet Security: 1 -- Intertemporal coordination: 1 -- Investing: 4 -- Investment: 10 -- Investment Strategy: 1 -- Investment decisions: 1 -- Investment planning: 1 -- Investment strategy: 3 -- Invoice: 5 -- Issues: 1 -- JBOC: 1 -- KYC: 8 -- KYC light: 1 -- Kagan's criterion: 1 -- KeePass: 1 -- Key Generation: 1 -- Key Pair: 1 -- Keynesian Doctrine: 1 -- Keynesian Economics: 1 -- Keynesian economics: 1 -- Keysend: 1 -- LN wallet: 1 -- LND: 2 -- LNP2PBot: 2 -- LNURL: 1 -- LastPass: 1 -- Layer-2 Solution: 1 -- Learning: 1 -- Learning pace: 1 -- Learning resources: 1 -- Legal Tender: 1 -- Legal status: 1 -- Legal tender: 1 -- Lightning: 3 -- Lightning Authentication: 1 -- Lightning Network: 110 -- Limit: 1 -- Linux: 1 -- Liquidity: 2 -- Liquidity Distribution: 1 -- Liquidity Management: 6 -- Lottery principle: 1 -- MMT: 1 -- Mac: 1 -- Mainstream Economics: 1 -- Makers: 1 -- Malware: 1 -- Marginal Theory of Value: 1 -- Marginal Utility: 1 -- Market: 1 -- Market Dynamics: 1 -- Master Chain Code: 1 -- Master Key: 1 -- Master Password: 1 -- Master Private Key: 2 -- Merchant: 2 -- Metal Coin: 1 -- Methodology: 1 -- Micro-transactions: 1 -- Milton Friedman: 1 -- Mindset: 1 -- Miners: 7 -- Mining: 53 -- Mining Pools: 1 -- Mining centralization: 2 -- Mining pool: 1 -- Misconception: 2 -- Mission: 1 -- Mistakes: 1 -- Mnemonic Phrase: 3 -- Mnemonic phrase: 7 -- Mobile Security: 1 -- Modern Monetary Theory: 1 -- Monetarist economics: 1 -- Monetary Decisions: 1 -- Monetary Dynamics: 7 -- Monetary History: 8 -- Monetary Policy: 9 -- Monetary expansion: 1 -- Monetary history: 4 -- Monetary policy: 1 -- Monetary reform: 3 -- Monetary system: 1 -- Money: 12 -- Money History: 24 -- Money Supply: 1 -- Money laundering: 1 -- Money printing: 1 -- Money streaming: 1 -- Money supply: 1 -- Multi-signature: 2 -- Multidisciplinary: 2 -- Network: 4 -- Network Knowledge: 1 -- Network integrity: 1 -- Network management: 1 -- Network security: 3 -- Networking: 1 -- Nicaragua: 1 -- Node: 3 -- Nodes: 12 -- Non-KYC: 2 -- Nonce: 1 -- Notary: 1 -- Notgeld: 2 -- OS: 3 -- Obsidian: 1 -- Obtaining Bitcoins: 1 -- One-Time Passwords: 1 -- Onion routing: 1 -- Online Scams: 1 -- Online Security: 9 -- Online browsing: 9 -- Online privacy: 2 -- Online safety: 2 -- Online security: 8 -- Open source: 3 -- Open-Source Wallet: 1 -- Open-source: 11 -- Open-source software: 1 -- OpenNode: 1 -- Operating systems: 2 -- Order books: 1 -- P2EP: 3 -- P2P solutions: 9 -- PBKDF2: 2 -- POS: 1 -- Passkeys: 1 -- Passphrase: 12 -- Password Alternatives: 1 -- Password Management: 2 -- Password Manager: 1 -- Password manager: 2 -- Passwords: 2 -- Pay2PublicKey: 1 -- Payment: 1 -- Payment Channels: 8 -- Payment channels: 1 -- Payment models: 1 -- Payment network: 1 -- Peach: 1 -- Peer-to-Peer: 1 -- Peer-to-peer: 1 -- Peru: 1 -- Phishing: 3 -- Physical Security: 1 -- PlanB Network: 1 -- Platforms: 1 -- Point of sale: 1 -- Political goals: 1 -- Ponzi schemes: 1 -- Practical Experience: 1 -- Pragmatism: 1 -- Prefix: 1 -- Preimage Hash: 1 -- Price: 1 -- Price Drop: 1 -- Price Increase: 1 -- Prices: 1 -- Privacy: 16 -- Private Key: 9 -- Private browsing: 1 -- Private key: 6 -- Private keys: 2 -- Production Activities: 1 -- Profit: 1 -- Programming Languages: 1 -- Proof of Stake: 1 -- Proof of Work: 6 -- Protection: 1 -- Protocol: 3 -- Pruned node: 1 -- Public Debt: 1 -- Public Key: 5 -- Public Keys: 1 -- Public key: 4 -- Public keys: 6 -- Public trust: 2 -- Purchase Plan: 1 -- Purchase methods: 1 -- Purchasing Power: 4 -- RGB: 3 -- RNG: 1 -- Random Number Generation: 1 -- Randomness Factor: 1 -- Ransomware: 3 -- Raspberry Pi: 1 -- Raspberry Pi 4: 1 -- Receiving Address: 1 -- Receiving Capacity: 1 -- Receiving addresses: 6 -- Recovery phrase: 1 -- Redenomination: 3 -- Regulation: 4 -- Regulation and electricity tariffs: 1 -- Regulations: 2 -- Remote Access: 1 -- Rentenmark: 1 -- Renting goods: 1 -- Revocation Key: 5 -- Revolution: 1 -- Reward: 3 -- Rewards: 1 -- Risk: 3 -- RoboSat: 3 -- Route Estimation: 1 -- Route Searching: 1 -- Rust: 1 -- SHA-256: 1 -- SHA256: 9 -- SMS: 1 -- Salability: 1 -- Samourai: 4 -- Satoshi: 3 -- Satoshi Nakamoto: 2 -- Sats: 1 -- Savings: 3 -- Scalability: 3 -- Scalar multiplication: 1 -- Scams: 4 -- Scarcity: 3 -- Security: 26 -- Security Vulnerabilities: 1 -- Seed: 4 -- Seed Generation: 1 -- SegWit: 1 -- Segwit: 1 -- Segwit Standard: 2 -- Self Custody: 5 -- Self-Custody: 2 -- Settlement Layer: 1 -- Single Sign-On: 1 -- Skills: 1 -- Social Cooperation: 1 -- Social sciences: 1 -- Socialism: 1 -- Software: 2 -- Software downloads: 1 -- Software efficiency: 1 -- Software update: 1 -- Software updates: 1 -- Sound Money: 1 -- Sparrow: 1 -- Specialization: 1 -- Spontaneous purchase: 1 -- Stablecoin: 5 -- Stablecoins: 4 -- Stacker: 6 -- Standard of Living: 1 -- Strategy: 1 -- Subjective Utility: 1 -- Subjective Value: 4 -- Swiss Bitcoin Pay: 1 -- Switzerland: 2 -- TOR: 1 -- TOR network: 1 -- TOTP: 1 -- Takers: 1 -- Task: 1 -- Technological Competence: 1 -- Technological revolution: 1 -- Technology: 4 -- The Reich's Banker: 1 -- Threat Monitoring: 1 -- Time Preference: 2 -- Time Value of Money: 1 -- Time preferences: 1 -- Timelock: 2 -- Tools: 1 -- Total supply: 1 -- Traceability: 1 -- Trading: 2 -- Traditional Finance: 2 -- Traditional Market: 1 -- Transaction: 10 -- Transaction Fees: 2 -- Transaction Routing: 2 -- Transaction fees: 3 -- Transaction routing: 1 -- Transactions: 11 -- Transparency: 1 -- Trend: 1 -- Two-Factor Authentication: 2 -- USB keys: 1 -- UTXO: 3 -- UTXOs: 1 -- Uncensorability: 1 -- Uncertainty: 1 -- User Types: 1 -- Users: 1 -- VC: 2 -- VPN: 6 -- Value: 1 -- Venezuela: 1 -- Verification: 2 -- Video games: 2 -- Virgin Bitcoins: 3 -- Virus: 1 -- Viruses: 1 -- Volatility: 2 -- Voluntary Exchange: 1 -- WST: 1 -- Wallet: 15 -- Wallet security: 12 -- Wallet types: 2 -- Wallets: 13 -- Wealth Inequality: 1 -- Web browsers: 1 -- Weimar Republic: 2 -- Whirlpool: 1 -- Wi-Fi: 1 -- Windows: 2 -- XPUB: 1 -- Zimbabwe: 3 -- Zimbabwean dollar: 1 -- Zombie Companies: 2 -- accountability: 1 -- activist: 1 -- afrique: 1 -- analysis: 1 -- anarchisme: 1 -- anthropology: 2 -- art: 2 -- artificial intelligence: 1 -- autobiography: 1 -- banking: 2 -- biography: 2 -- biology: 1 -- bitcoin-only: 6 -- bitcoin: 99 -- bitcoin-investment: 1 -- bitcoin-only: 53 -- blog: 1 -- books: 1 -- brute force attack: 1 -- business: 6 -- cahoots: 2 -- centralize: 1 -- coin control: 1 -- coinjoin: 4 -- commodity: 1 -- communication: 1 -- computer science: 1 -- conference: 1 -- creativity: 1 -- crime: 1 -- cryptocurrency: 2 -- cryptography: 6 -- custodial: 5 -- cyberculture: 1 -- cyberpunk: 2 -- cypherpunk: 3 -- deflation: 1 -- developer: 1 -- developers: 1 -- development: 14 -- do it yourself: 1 -- donateur: 1 -- dystopia: 1 -- ecology: 1 -- econimic: 1 -- economic: 1 -- economics: 9 -- economy: 54 -- education: 38 -- education;: 3 -- educatition: 1 -- educator: 1 -- el salvador: 1 -- energy: 2 -- entrepreneurship: 3 -- entropy: 1 -- environment: 3 -- ethics: 2 -- evolution: 2 -- exchange: 15 -- exchanges: 1 -- fiction: 8 -- finance: 46 -- finance;: 2 -- full KYC: 1 -- full YC: 1 -- game: 1 -- genetics: 1 -- geopolitics: 1 -- graphic novel: 1 -- growth hacking: 1 -- hacker: 3 -- hacking: 1 -- hedge funds: 1 -- history: 28 -- inflation: 1 -- information theory: 1 -- infrasctructure: 1 -- infrastructure: 23 -- international relations: 1 -- internet: 2 -- interviews: 1 -- investing: 4 -- investment: 4 -- jason fried: 1 -- journalism: 1 -- law: 2 -- layer-2: 2 -- leadership: 1 -- life-style: 1 -- light-KYC: 3 -- lightning: 12 -- lightning network: 8 -- lightning-infrastructure: 8 -- lightning-managment: 1 -- lightning-market: 1 -- lightning-network: 13 -- lightning-wallet: 1 -- macroeconomic: 1 -- management: 3 -- marchand: 3 -- marketing: 1 -- mathematics: 2 -- mathematique: 1 -- merchant: 2 -- merchants: 1 -- mining: 7 -- mix: 3 -- money: 1 -- motivation: 1 -- muli-sig: 1 -- multi-coin: 8 -- multi-sig: 1 -- news: 1 -- node: 9 -- nodes: 2 -- non-KYC: 1 -- non-kyc: 1 -- nostr: 1 -- novel: 1 -- online security: 7 -- open source: 1 -- open-source: 42 -- opensource: 1 -- paiement solution: 1 -- paiement-solution: 1 -- password: 3 -- password management: 2 -- password manager: 4 -- password replacement: 1 -- password strength: 2 -- payjoin: 3 -- pbkdf2 function: 1 -- peer to peer: 4 -- peer-to-peer: 6 -- personal development: 2 -- personal finance: 3 -- philosophy: 13 -- philosophy;: 1 -- physics: 1 -- podcast-solution: 1 -- poetry;: 1 -- point of sales: 3 -- policy: 1 -- politics: 11 -- principles: 1 -- privact: 4 -- privacy: 51 -- probability: 1 -- productivity: 1 -- programmation: 1 -- programming: 2 -- programming;: 1 -- psychology: 6 -- psychology;: 1 -- psycology: 1 -- quantum computing: 1 -- religion: 1 -- research: 1 -- rust: 4 -- science: 5 -- science fiction: 5 -- secp256k1: 1 -- security: 8 -- security-solution: 3 -- self-custodial: 25 -- self-help: 5 -- self-hosted password manager: 2 -- sience fiction: 1 -- social issue: 1 -- social issues: 3 -- social issues;: 1 -- sociale: 1 -- sociale media: 1 -- software development: 2 -- startups: 1 -- strategy: 1 -- sustainability: 1 -- technical knowledge: 1 -- technology: 69 -- technology;: 2 -- timelocked: 1 -- tools: 1 -- trading: 7 -- use cases: 1 -- vc: 1 -- vpn: 1 -- wallet: 24 -- watch-only: 1 -- wealth building: 1 -- whirlpool: 2 -- xpub: 1 -- youtuber: 1 diff --git a/docs/PBN-template-repo/scripts/relocate_contributors_builders.py b/docs/PBN-template-repo/scripts/relocate_contributors_builders.py deleted file mode 100644 index 598e491d21c..00000000000 --- a/docs/PBN-template-repo/scripts/relocate_contributors_builders.py +++ /dev/null @@ -1,37 +0,0 @@ -import os - -def update_files(): - builder_folder = "../resources/builders" - folders = os.listdir(builder_folder) - total_folders = len(folders) - processed_folders = 0 - - for folder in folders: - folder_path = os.path.join(builder_folder, folder) - if os.path.isdir(folder_path): - builder_file_path = os.path.join(folder_path, "builder.yml") - en_file_path = os.path.join(folder_path, "en.yml") - - # Update builder.yml file - with open(builder_file_path, "r+") as builder_file: - lines = builder_file.readlines() - builder_file.seek(0) - - for line in lines: - if not (line.startswith("contributors:") or line.startswith(" - rabbit-hole")): - builder_file.write(line) - - builder_file.truncate() - - # Update en.yml file - with open(en_file_path, "a") as en_file: - en_file.write("\n") - en_file.write("contributors:\n") - en_file.write(" - rabbit-hole\n") - - processed_folders += 1 - progress_percentage = (processed_folders / total_folders) * 100 - print(f"Progress: {progress_percentage:.2f}%") - -update_files() - diff --git a/docs/PBN-template-repo/scripts/rename-translated-content.py b/docs/PBN-template-repo/scripts/rename-translated-content.py deleted file mode 100644 index e42e2f88499..00000000000 --- a/docs/PBN-template-repo/scripts/rename-translated-content.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - -def rename_and_modify_files(directory): - # Language to code mapping - language_codes = { - 'English': 'en', - 'Spanish': 'es', - 'German': 'de', - 'Italian': 'it', - 'Portuguese': 'pt' - } - - # Iterate over all files in the directory - for filename in os.listdir(directory): - if filename.endswith('.md'): - new_filename = None - # Determine the new file name based on language suffix - for language, code in language_codes.items(): - if filename.endswith(f'_{language}.md'): - new_filename = f'{code}.md' - break - - if new_filename: - new_path = os.path.join(directory, new_filename) - old_path = os.path.join(directory, filename) - - # Read the old file's content and replace '/fr/' with the new code - with open(old_path, 'r', encoding='utf-8') as file: - content = file.read() - - new_content = content.replace('/fr/', f'/{new_filename[:-3]}/') - - # Write the modified content to the new file - with open(new_path, 'w', encoding='utf-8') as file: - file.write(new_content) - - # Remove the old file if it's not the same as new file - if new_path != old_path: - os.remove(old_path) - print(f"Renamed and modified '{filename}' to '{new_filename}'") - - -directory_path = '../../LLM-Translator/output/test/' -rename_and_modify_files(directory_path) - diff --git a/docs/PBN-template-repo/scripts/schema_validator.py b/docs/PBN-template-repo/scripts/schema_validator.py deleted file mode 100644 index eee106ff1a0..00000000000 --- a/docs/PBN-template-repo/scripts/schema_validator.py +++ /dev/null @@ -1,914 +0,0 @@ -#!/usr/bin/env python3 -""" -PlanB Network Content Schema Validator - -Validates content folders against JSON schema definitions. -Checks both YAML metadata files and markdown content files. - -The validator auto-detects the repository root and schema locations. -Works with both absolute paths and relative paths from anywhere in the repo. - -Usage: - python schema_validator.py - python schema_validator.py ../../courses/btc101 - python schema_validator.py /path/to/repo/tutorials/wallet/sparrow-wallet - python schema_validator.py professors/pbn-author -""" - -import argparse -import json -import os -import re -import sys -from dataclasses import dataclass, field -from datetime import date, datetime -from pathlib import Path -from typing import Any, Optional - -try: - import jsonschema - from jsonschema import Draft7Validator, ValidationError as JsonSchemaError -except ImportError: - print("Error: jsonschema package required. Install with: pip install jsonschema") - sys.exit(1) - -try: - import yaml -except ImportError: - print("Error: PyYAML package required. Install with: pip install pyyaml") - sys.exit(1) - -try: - import frontmatter -except ImportError: - print("Error: python-frontmatter package required. Install with: pip install python-frontmatter") - sys.exit(1) - - -# ANSI color codes for terminal output -class Colors: - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - CYAN = "\033[96m" - BOLD = "\033[1m" - END = "\033[0m" - - -@dataclass -class ValidationResult: - """Holds validation results for a single file or content check.""" - file_path: str - errors: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - - @property - def is_valid(self) -> bool: - return len(self.errors) == 0 - - def add_error(self, msg: str): - self.errors.append(msg) - - def add_warning(self, msg: str): - self.warnings.append(msg) - - -@dataclass -class ContentTypeConfig: - """Configuration for a content type.""" - name: str - yaml_file: str # e.g., "tutorial.yml", "professor.yml" - schema_file: str # e.g., "tutorial-scheme.json" - content_schema_file: Optional[str] = None # e.g., "tutorial-content-scheme.json" - has_markdown_content: bool = True - content_uses_frontmatter: bool = True # True for .md files with YAML frontmatter - content_uses_yml: bool = False # True for .yml content files (e.g., books, bet) - has_quizzes: bool = False # True for content types with quizz subfolder - - -# Content type configurations -CONTENT_TYPES: dict[str, ContentTypeConfig] = { - "tutorials": ContentTypeConfig( - name="Tutorial", - yaml_file="tutorial.yml", - schema_file="tutorial-scheme.json", - content_schema_file="tutorial-content-scheme.json", - ), - "courses": ContentTypeConfig( - name="Course", - yaml_file="course.yml", - schema_file="course-scheme.json", - content_schema_file="course-content-scheme.json", - has_quizzes=True, - ), - "professors": ContentTypeConfig( - name="Professor", - yaml_file="professor.yml", - schema_file="professor-scheme.json", - content_schema_file="professor-content-scheme.json", - ), - "events": ContentTypeConfig( - name="Event", - yaml_file="event.yml", - schema_file="event-scheme.json", - has_markdown_content=False, - ), - "resources/bet": ContentTypeConfig( - name="BET", - yaml_file="bet.yml", - schema_file="bet-scheme.json", - content_schema_file="bet-content-scheme.json", - content_uses_yml=True, - ), - "resources/books": ContentTypeConfig( - name="Book", - yaml_file="book.yml", - schema_file="book-scheme.json", - content_schema_file="book-content-scheme.json", - content_uses_yml=True, - ), - "resources/channels": ContentTypeConfig( - name="Channel", - yaml_file="channel.yml", - schema_file="channel-scheme.json", - has_markdown_content=False, - ), - "resources/conferences": ContentTypeConfig( - name="Conference", - yaml_file="conference.yml", - schema_file="conference-scheme.json", - has_markdown_content=False, - ), - "resources/glossary": ContentTypeConfig( - name="Glossary Word", - yaml_file="word.yml", - schema_file="word-scheme.json", - content_schema_file="word-content-scheme.json", - ), - "resources/movies": ContentTypeConfig( - name="Movie", - yaml_file="movie.yml", - schema_file="movie-scheme.json", - has_markdown_content=False, - ), - "resources/newsletters": ContentTypeConfig( - name="Newsletter", - yaml_file="newsletter.yml", - schema_file="newsletter-scheme.json", - has_markdown_content=False, - ), - "resources/podcasts": ContentTypeConfig( - name="Podcast", - yaml_file="podcast.yml", - schema_file="podcast-scheme.json", - has_markdown_content=False, - ), - "resources/projects": ContentTypeConfig( - name="Project", - yaml_file="project.yml", - schema_file="non-community-builder-scheme.json", # Default, may need community detection - content_schema_file="non-community-builder-content-scheme.json", - content_uses_yml=True, - ), -} - - -class SchemaValidator: - """Validates content folders against JSON schemas.""" - - def __init__(self, base_path: str, schema_path: Optional[str] = None): - self.base_path = Path(base_path).resolve() - # Schema path defaults to template repo location - if schema_path: - self.schema_path = Path(schema_path).resolve() - else: - # Auto-detect schema path: look for docs/PBN-template-repo relative to base_path - template_repo = self.base_path / "docs" / "PBN-template-repo" - if template_repo.exists(): - self.schema_path = template_repo - else: - # Try going up to find it - self.schema_path = self._find_template_repo(self.base_path) - self.results: list[ValidationResult] = [] - - def _find_template_repo(self, start_path: Path) -> Path: - """Find the template repo by traversing up the directory tree.""" - current = start_path - for _ in range(10): # Max 10 levels up - template_repo = current / "docs" / "PBN-template-repo" - if template_repo.exists(): - return template_repo - if current.parent == current: - break - current = current.parent - # Fallback to base_path if not found - return start_path - - def _find_repo_root(self, folder_path: Path) -> Path: - """Find the repository root by looking for .git directory (primary) or known directories (fallback).""" - current = folder_path - # First pass: look for .git (most reliable) - for _ in range(15): # Max 15 levels up - if (current / ".git").exists(): - return current - if current.parent == current: - break - current = current.parent - - # Second pass: fallback to known directory structure - current = folder_path - for _ in range(15): - if all((current / d).exists() for d in ["courses", "tutorials", "professors"]): - # Make sure we're not in a template/docs subfolder - if "docs" not in current.parts[-3:] and "PBN-template-repo" not in current.parts: - return current - if current.parent == current: - break - current = current.parent - return folder_path - - def detect_content_type(self, folder_path: Path) -> Optional[ContentTypeConfig]: - """Detect content type from folder path.""" - # First, find the repo root relative to the folder - repo_root = self._find_repo_root(folder_path) - - try: - rel_path = str(folder_path.relative_to(repo_root)) - except ValueError: - # If folder is not relative to found repo root, try using base_path - try: - rel_path = str(folder_path.relative_to(self.base_path)) - except ValueError: - # Last resort: use folder name parts to detect type - rel_path = str(folder_path) - - # Check for nested content types (tutorials have category subfolder) - parts = rel_path.split(os.sep) - - # Try matching from most specific to least specific - for content_path, config in CONTENT_TYPES.items(): - content_parts = content_path.split("/") - if parts[:len(content_parts)] == content_parts: - return config - - return None - - def find_schema_file(self, config: ContentTypeConfig, folder_path: Path) -> Optional[Path]: - """Find the schema file for the content type.""" - # Find the content type path prefix - repo_root = self._find_repo_root(folder_path) - try: - rel_path = folder_path.relative_to(repo_root) - except ValueError: - rel_path = folder_path - - # Determine the content type directory (e.g., "tutorials", "courses", "resources/bet") - for content_path in CONTENT_TYPES.keys(): - content_parts = content_path.split("/") - if list(rel_path.parts)[:len(content_parts)] == content_parts: - # Found the content type - look for schema in template repo - schema_in_template = self.schema_path / content_path / config.schema_file - if schema_in_template.exists(): - return schema_in_template - break - - # Fallback: search in parent directories relative to folder - search_paths = [ - folder_path / config.schema_file, - folder_path.parent / config.schema_file, - folder_path.parent.parent / config.schema_file, - folder_path.parent.parent.parent / config.schema_file, - ] - - # Also check in repo root content type directories - for content_path in CONTENT_TYPES.keys(): - search_paths.append(repo_root / content_path / config.schema_file) - - for path in search_paths: - if path.exists(): - return path - - return None - - def find_content_schema_file(self, config: ContentTypeConfig, folder_path: Path) -> Optional[Path]: - """Find the content schema file for the content type.""" - if not config.content_schema_file: - return None - - # Find the content type path prefix - repo_root = self._find_repo_root(folder_path) - try: - rel_path = folder_path.relative_to(repo_root) - except ValueError: - rel_path = folder_path - - # Determine the content type directory and look in template repo - for content_path in CONTENT_TYPES.keys(): - content_parts = content_path.split("/") - if list(rel_path.parts)[:len(content_parts)] == content_parts: - schema_in_template = self.schema_path / content_path / config.content_schema_file - if schema_in_template.exists(): - return schema_in_template - break - - # Fallback: search in parent directories - search_paths = [ - folder_path / config.content_schema_file, - folder_path.parent / config.content_schema_file, - folder_path.parent.parent / config.content_schema_file, - folder_path.parent.parent.parent / config.content_schema_file, - ] - - # Also check in repo root content type directories - for content_path in CONTENT_TYPES.keys(): - search_paths.append(repo_root / content_path / config.content_schema_file) - - for path in search_paths: - if path.exists(): - return path - - return None - - def load_json_schema(self, schema_path: Path) -> dict: - """Load a JSON schema file.""" - with open(schema_path, 'r', encoding='utf-8') as f: - return json.load(f) - - def load_yaml_file(self, yaml_path: Path) -> dict: - """Load a YAML file.""" - with open(yaml_path, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) or {} - # Convert datetime objects to strings for JSON schema validation - return self._convert_dates_to_strings(data) - - def _convert_dates_to_strings(self, obj: Any) -> Any: - """Recursively convert datetime.date and datetime.datetime objects to ISO format strings.""" - if isinstance(obj, datetime): - return obj.strftime("%Y-%m-%d %H:%M:%S") - elif isinstance(obj, date): - return obj.strftime("%Y-%m-%d") - elif isinstance(obj, dict): - return {k: self._convert_dates_to_strings(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [self._convert_dates_to_strings(item) for item in obj] - return obj - - def validate_yaml_against_schema(self, yaml_data: dict, schema: dict, file_path: str) -> ValidationResult: - """Validate YAML data against a JSON schema.""" - result = ValidationResult(file_path=file_path) - - validator = Draft7Validator(schema) - errors = list(validator.iter_errors(yaml_data)) - - for error in errors: - path = " -> ".join(str(p) for p in error.absolute_path) if error.absolute_path else "root" - result.add_error(f"[{path}] {error.message}") - - return result - - def validate_markdown_content(self, md_path: Path, content_schema: dict) -> ValidationResult: - """Validate markdown content file against content schema.""" - result = ValidationResult(file_path=str(md_path)) - - try: - post = frontmatter.load(md_path) - except Exception as e: - result.add_error(f"Failed to parse markdown file: {e}") - return result - - # Validate frontmatter against schema properties - schema_props = content_schema.get("properties", {}) - required_fields = content_schema.get("required", []) - - # Check required frontmatter fields - for field in required_fields: - if field not in post.metadata: - result.add_error(f"Missing required frontmatter field: '{field}'") - - # Validate frontmatter field types and constraints - for field, value in post.metadata.items(): - if field in schema_props: - field_schema = schema_props[field] - self._validate_field(result, field, value, field_schema) - - # Validate content rules if present - content_rules = content_schema.get("content_rules", {}) - if content_rules: - self._validate_content_rules(result, post.content, content_rules, md_path) - - return result - - def _validate_field(self, result: ValidationResult, field: str, value: Any, schema: dict): - """Validate a single field against its schema.""" - expected_type = schema.get("type") - - type_mapping = { - "string": str, - "integer": int, - "number": (int, float), - "boolean": bool, - "array": list, - "object": dict, - } - - if expected_type and expected_type in type_mapping: - expected = type_mapping[expected_type] - if not isinstance(value, expected): - result.add_error(f"Field '{field}' should be {expected_type}, got {type(value).__name__}") - - # Check minLength/maxLength for strings - if expected_type == "string" and isinstance(value, str): - min_len = schema.get("minLength") - max_len = schema.get("maxLength") - if min_len and len(value) < min_len: - result.add_error(f"Field '{field}' is too short (min {min_len} chars)") - if max_len and len(value) > max_len: - result.add_error(f"Field '{field}' is too long (max {max_len} chars)") - - # Check enum values - if "enum" in schema and value not in schema["enum"]: - result.add_error(f"Field '{field}' has invalid value '{value}'. Allowed: {schema['enum']}") - - def _validate_content_rules(self, result: ValidationResult, content: str, rules: dict, md_path: Path): - """Validate markdown content against content rules.""" - lines = content.split('\n') - - # Check cover image - cover_rule = rules.get("cover_image", {}) - if cover_rule.get("required", False): - # Cover must appear within the first 10 lines after frontmatter - # Pattern: ![any-alt-text](cover.webp) or ![any-alt-text](assets/cover.webp) - cover_pattern = re.compile(r'!\[.*?\]\((?:assets/)?cover\.webp\)') - first_10_lines = '\n'.join(lines[:10]) - if not cover_pattern.search(first_10_lines): - result.add_error("Cover image ![...](cover.webp) or ![...](assets/cover.webp) must appear within the first 10 lines after YAML front-matter") - - # Check headings - heading_rules = rules.get("headings", {}) - h1_rule = heading_rules.get("h1", {}) - if h1_rule.get("allowed") is False: - # Remove code blocks before checking for H1 headings - # Remove fenced code blocks (``` or ~~~) - content_no_code = re.sub(r'```[\s\S]*?```', '', content) - content_no_code = re.sub(r'~~~[\s\S]*?~~~', '', content_no_code) - # Remove indented code blocks (4 spaces or 1 tab at line start) - content_no_code = re.sub(r'^(?: |\t).*$', '', content_no_code, flags=re.MULTILINE) - - h1_matches = re.findall(r'^# [^#]', content_no_code, re.MULTILINE) - if h1_matches: - result.add_error("H1 headings (# Title) are not allowed - title comes from YAML 'name' field") - - # Check list markers - list_rules = rules.get("lists", {}) - unordered_rule = list_rules.get("unordered", {}) - if unordered_rule.get("marker") == "-": - # Check for asterisk list markers - asterisk_lists = re.findall(r'^\* ', content, re.MULTILINE) - if asterisk_lists: - result.add_warning(f"Found {len(asterisk_lists)} asterisk (*) list markers. Use dash (-) instead.") - - # Check images format - image_rules = rules.get("images", {}) - if image_rules.get("format") == "WebP only (.webp extension)": - non_webp = re.findall(r'!\[.*?\]\([^)]+\.(png|jpg|jpeg|gif)\)', content, re.IGNORECASE) - if non_webp: - result.add_error(f"Non-WebP images found. All images must be .webp format.") - - # Check for alt text on images - alt_text_rule = image_rules.get("alt_text", {}) - if alt_text_rule.get("required"): - empty_alt = re.findall(r'!\[\]\(', content) - if empty_alt: - result.add_warning(f"Found {len(empty_alt)} images without alt text") - - # Check assets folder exists - assets_path = md_path.parent / "assets" - if cover_rule.get("required") and not assets_path.exists(): - result.add_error("Assets folder is missing") - - # Check cover.webp exists - if cover_rule.get("required") and assets_path.exists(): - cover_path = assets_path / "cover.webp" - if not cover_path.exists(): - result.add_error("Cover image 'assets/cover.webp' is missing") - - def validate_yml_content(self, yml_path: Path, content_schema: dict) -> ValidationResult: - """Validate YML content files (used by books, bet, etc.).""" - result = ValidationResult(file_path=str(yml_path)) - - try: - data = self.load_yaml_file(yml_path) - except Exception as e: - result.add_error(f"Failed to parse YAML file: {e}") - return result - - # Use JSON schema validation - schema_props = content_schema.get("properties", {}) - required_fields = content_schema.get("required", []) - - for field in required_fields: - if field not in data: - result.add_error(f"Missing required field: '{field}'") - - for field, value in data.items(): - if field in schema_props: - self._validate_field(result, field, value, schema_props[field]) - - return result - - def validate_quiz_folder(self, quiz_folder: Path) -> list[ValidationResult]: - """Validate a single quiz folder (e.g., quizz/000/).""" - results = [] - - # Check for question.yml (required) - question_file = quiz_folder / "question.yml" - if not question_file.exists(): - result = ValidationResult(file_path=str(question_file)) - result.add_error("Missing required file: question.yml") - results.append(result) - return results - - # Validate question.yml structure - result = ValidationResult(file_path=str(question_file)) - try: - data = self.load_yaml_file(question_file) - - # Required fields in question.yml - required_fields = ["id", "chapterId", "difficulty", "duration", "author"] - for field in required_fields: - if field not in data: - result.add_error(f"Missing required field: '{field}'") - - # Validate UUID format for id and chapterId - uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - if "id" in data and not re.match(uuid_pattern, str(data.get("id", "")), re.IGNORECASE): - result.add_error(f"Field 'id' is not a valid UUID: {data.get('id')}") - if "chapterId" in data and not re.match(uuid_pattern, str(data.get("chapterId", "")), re.IGNORECASE): - result.add_error(f"Field 'chapterId' is not a valid UUID: {data.get('chapterId')}") - - # Validate difficulty enum - valid_difficulties = ["easy", "intermediate", "hard"] - if "difficulty" in data and data["difficulty"] not in valid_difficulties: - result.add_error(f"Field 'difficulty' has invalid value '{data['difficulty']}'. Allowed: {valid_difficulties}") - - # Validate duration is a positive number - if "duration" in data: - if not isinstance(data["duration"], (int, float)) or data["duration"] <= 0: - result.add_error(f"Field 'duration' must be a positive number, got: {data['duration']}") - - except Exception as e: - result.add_error(f"Failed to parse question.yml: {e}") - - results.append(result) - - # Check for at least one translation file - translation_files = [f for f in quiz_folder.glob("*.yml") if f.name != "question.yml"] - if not translation_files: - result = ValidationResult(file_path=str(quiz_folder)) - result.add_error("No translation files found (e.g., en.yml, fr.yml)") - results.append(result) - else: - # Validate each translation file structure - for trans_file in translation_files: - trans_result = self._validate_quiz_translation(trans_file) - if trans_result.errors or trans_result.warnings: - results.append(trans_result) - - return results - - def _validate_quiz_translation(self, trans_file: Path) -> ValidationResult: - """Validate a quiz translation file (e.g., en.yml, fr.yml).""" - result = ValidationResult(file_path=str(trans_file)) - - try: - data = self.load_yaml_file(trans_file) - - # Required fields in translation files - required_fields = ["question", "answer", "wrong_answers"] - for field in required_fields: - if field not in data: - result.add_error(f"Missing required field: '{field}'") - - # Validate wrong_answers is a list with at least 1 item - if "wrong_answers" in data: - if not isinstance(data["wrong_answers"], list): - result.add_error("Field 'wrong_answers' must be a list") - elif len(data["wrong_answers"]) < 1: - result.add_error("Field 'wrong_answers' must have at least 1 item") - - except Exception as e: - result.add_error(f"Failed to parse translation file: {e}") - - return result - - def validate_quizzes(self, folder: Path) -> list[ValidationResult]: - """Validate all quizzes in a content folder's quizz/ subdirectory.""" - results = [] - quizz_folder = folder / "quizz" - - if not quizz_folder.exists(): - # Quiz folder is optional - just return empty results - return results - - if not quizz_folder.is_dir(): - result = ValidationResult(file_path=str(quizz_folder)) - result.add_error("'quizz' exists but is not a directory") - return [result] - - # Get all quiz subfolders (numbered folders like 000, 001, etc.) - quiz_subfolders = sorted([d for d in quizz_folder.iterdir() if d.is_dir()]) - - if not quiz_subfolders: - result = ValidationResult(file_path=str(quizz_folder)) - result.add_warning("Quiz folder exists but contains no quiz subfolders") - return [result] - - # Validate each quiz subfolder - for quiz_folder in quiz_subfolders: - quiz_results = self.validate_quiz_folder(quiz_folder) - results.extend(quiz_results) - - # Create summary result for quiz validation - summary_result = ValidationResult(file_path=str(quizz_folder)) - total_quizzes = len(quiz_subfolders) - quizzes_with_errors = sum(1 for r in results if r.errors) - - if quizzes_with_errors == 0: - # All quizzes valid - don't add to results (will show in summary) - pass - else: - summary_result.add_warning(f"Validated {total_quizzes} quizzes, {quizzes_with_errors} with errors") - results.insert(0, summary_result) - - return results - - def validate_folder(self, folder_path: str) -> list[ValidationResult]: - """Validate a content folder.""" - folder = Path(folder_path) - - if not folder.is_absolute(): - # Try resolving from current directory first - resolved_from_cwd = (Path.cwd() / folder).resolve() - - if resolved_from_cwd.exists(): - folder = resolved_from_cwd - else: - # Try resolving from repo root (for paths like "courses/btc101") - resolved_from_repo = (self.base_path / folder).resolve() - if resolved_from_repo.exists(): - folder = resolved_from_repo - else: - # Last try: strip leading "../" and try from repo root - # This handles cases like "../courses/btc101" when user is deep in the repo - clean_path = str(folder) - while clean_path.startswith("../") or clean_path.startswith("..\\"): - clean_path = clean_path[3:] - resolved_clean = (self.base_path / clean_path).resolve() - if resolved_clean.exists(): - folder = resolved_clean - else: - folder = resolved_from_cwd # Use original for error message - else: - folder = folder.resolve() - - if not folder.exists(): - result = ValidationResult(file_path=str(folder)) - result.add_error(f"Folder does not exist. Try using absolute path or run from repo root.") - return [result] - - if not folder.is_dir(): - result = ValidationResult(file_path=str(folder)) - result.add_error("Path is not a directory") - return [result] - - # Detect content type - config = self.detect_content_type(folder) - if not config: - result = ValidationResult(file_path=str(folder)) - result.add_error(f"Could not detect content type for folder. Supported types: {list(CONTENT_TYPES.keys())}") - return [result] - - results = [] - - # Find and validate main YAML file - yaml_path = folder / config.yaml_file - if not yaml_path.exists(): - result = ValidationResult(file_path=str(yaml_path)) - result.add_error(f"Missing required file: {config.yaml_file}") - results.append(result) - else: - schema_path = self.find_schema_file(config, folder) - if schema_path: - schema = self.load_json_schema(schema_path) - yaml_data = self.load_yaml_file(yaml_path) - result = self.validate_yaml_against_schema(yaml_data, schema, str(yaml_path)) - results.append(result) - else: - result = ValidationResult(file_path=str(yaml_path)) - result.add_warning(f"Schema file '{config.schema_file}' not found - skipping validation") - results.append(result) - - # Validate content files if applicable - if config.has_markdown_content: - content_schema_path = self.find_content_schema_file(config, folder) - content_schema = self.load_json_schema(content_schema_path) if content_schema_path else None - - if config.content_uses_yml: - # Validate .yml content files (books, bet, projects) - for yml_file in folder.glob("*.yml"): - if yml_file.name != config.yaml_file: # Skip main metadata file - if content_schema: - result = self.validate_yml_content(yml_file, content_schema) - else: - result = ValidationResult(file_path=str(yml_file)) - result.add_warning("Content schema not found - skipping validation") - results.append(result) - else: - # Validate .md content files - for md_file in folder.glob("*.md"): - if content_schema: - result = self.validate_markdown_content(md_file, content_schema) - else: - result = ValidationResult(file_path=str(md_file)) - result.add_warning("Content schema not found - skipping validation") - results.append(result) - - # Validate quizzes if applicable - if config.has_quizzes: - quiz_results = self.validate_quizzes(folder) - results.extend(quiz_results) - - return results - - def print_results(self, results: list[ValidationResult]): - """Print validation results in a formatted way.""" - total_errors = sum(len(r.errors) for r in results) - total_warnings = sum(len(r.warnings) for r in results) - - print(f"\n{Colors.BOLD}{'='*60}{Colors.END}") - print(f"{Colors.BOLD}Validation Results{Colors.END}") - print(f"{'='*60}\n") - - for result in results: - rel_path = result.file_path - # Try to make path relative to repo root for cleaner output - file_path = Path(result.file_path) - repo_root = self._find_repo_root(file_path.parent if file_path.is_file() else file_path) - try: - rel_path = str(file_path.relative_to(repo_root)) - except ValueError: - try: - rel_path = str(file_path.relative_to(self.base_path)) - except ValueError: - pass - - if result.errors or result.warnings: - status = f"{Colors.RED}FAILED{Colors.END}" if result.errors else f"{Colors.YELLOW}WARNINGS{Colors.END}" - print(f"{Colors.CYAN}{rel_path}{Colors.END} - {status}") - - for error in result.errors: - print(f" {Colors.RED}ERROR:{Colors.END} {error}") - - for warning in result.warnings: - print(f" {Colors.YELLOW}WARNING:{Colors.END} {warning}") - - print() - else: - print(f"{Colors.CYAN}{rel_path}{Colors.END} - {Colors.GREEN}PASSED{Colors.END}") - - print(f"\n{'='*60}") - if total_errors == 0: - print(f"{Colors.GREEN}{Colors.BOLD}All validations passed!{Colors.END}") - else: - print(f"{Colors.RED}{Colors.BOLD}Validation failed:{Colors.END} {total_errors} error(s), {total_warnings} warning(s)") - print(f"{'='*60}\n") - - return total_errors == 0 - - -def main(): - parser = argparse.ArgumentParser( - description="Validate PlanB Network content against JSON schemas", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python schema_validator.py tutorials/this-folder-is-a-tutorial-category/awesome-tutorial - python schema_validator.py professors/pbn-author - python schema_validator.py resources/bet/a-game-on-bitcoin - python schema_validator.py events/awesome-workshop-YYYY - -Supported content types: - - tutorials// - - courses/ - - professors/ - - events/ - - resources/bet/ - - resources/books/ - - resources/channels/ - - resources/conferences/ - - resources/glossary/ - - resources/movies/ - - resources/newsletters/ - - resources/podcasts/ - - resources/projects/ - """ - ) - - parser.add_argument( - "folder", - help="Path to the content folder to validate (relative to repo root or absolute)" - ) - - parser.add_argument( - "--base-path", - default=None, - help="Base path of the repository (auto-detected if not specified)" - ) - - parser.add_argument( - "--schema-path", - default=None, - help="Path to schema files (defaults to docs/PBN-template-repo in repo root)" - ) - - parser.add_argument( - "--json", - action="store_true", - help="Output results as JSON" - ) - - args = parser.parse_args() - - # Resolve the folder path first to help with repo root detection - folder_path = Path(args.folder) - if not folder_path.is_absolute(): - folder_path = Path.cwd() / folder_path - folder_path = folder_path.resolve() - - # Auto-detect repo root from folder path or base-path - def find_repo_root(start_path: Path) -> Path: - """Find repo root by looking for .git directory (primary) or known directories (fallback).""" - current = start_path - # First pass: look for .git (most reliable) - for _ in range(15): # Max 15 levels up - if (current / ".git").exists(): - return current - if current.parent == current: - break - current = current.parent - - # Second pass: fallback to known directory structure (but require .git to not be deeper) - current = start_path - for _ in range(15): - # Check for known directories AND verify this isn't inside a docs/template folder - if all((current / d).exists() for d in ["courses", "tutorials", "professors"]): - # Make sure we're not in a template/docs subfolder - if "docs" not in current.parts[-3:] and "PBN-template-repo" not in current.parts: - return current - if current.parent == current: - break - current = current.parent - return start_path - - if args.base_path: - base_path = Path(args.base_path).resolve() - else: - base_path = find_repo_root(folder_path) - - # Determine schema path - schema_path = args.schema_path - if not schema_path: - # Try to find template repo - template_repo = base_path / "docs" / "PBN-template-repo" - if template_repo.exists(): - schema_path = str(template_repo) - - validator = SchemaValidator(str(base_path), schema_path) - results = validator.validate_folder(args.folder) - - if args.json: - output = { - "folder": args.folder, - "results": [ - { - "file": r.file_path, - "valid": r.is_valid, - "errors": r.errors, - "warnings": r.warnings - } - for r in results - ], - "total_errors": sum(len(r.errors) for r in results), - "total_warnings": sum(len(r.warnings) for r in results), - } - print(json.dumps(output, indent=2)) - success = output["total_errors"] == 0 - else: - success = validator.print_results(results) - - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/docs/PBN-template-repo/scripts/translation_builders.py b/docs/PBN-template-repo/scripts/translation_builders.py deleted file mode 100644 index ac95217400d..00000000000 --- a/docs/PBN-template-repo/scripts/translation_builders.py +++ /dev/null @@ -1,105 +0,0 @@ -import os -import io -import shutil -import yaml -import re -import time -import openai -from openai.error import RateLimitError, Timeout, APIError -from dotenv import load_dotenv -from tqdm import tqdm - - -def translate_text(prompt, temperature=0.1, model_engine="gpt-3.5-turbo"): - """ - Translates the transcript text using OpenAI's GPT API. - - Args: - prompt (str, optional): The prompt to use for text manipulation. - - Returns: - str: The translated transcript in text format. - """ - # Use the OpenAI GPT API to translate the transcript - response = openai.ChatCompletion.create( - model = model_engine, - messages=[ - {"role": "user", "content": prompt} - ], - temperature=temperature - ) - - chatgpt_output = content = response['choices'][0]['message']['content'] - - return chatgpt_output - -def process_files(): - languages = ['french', 'german', 'spanish', 'portuguese', 'italian'] - language_codes = ['fr', 'de', 'es', 'pt', 'it'] - - # Load environment variables from .env file - load_dotenv() - - # Set OpenAI key from environment variable - openai.api_key = os.getenv("OPENAI_API_KEY") - - folder_count = sum([len(d) for r, d, files in os.walk('../resources/builders')]) - - with tqdm(total=folder_count, desc="Folders") as folder_progress: - for root, dirs, files in os.walk('../resources/builders'): - with tqdm(total=len(languages), desc="Languages in a folder", leave=False) as language_progress: - for language, lang_code in zip(languages, language_codes): - file_name = f'{lang_code}.yml' - if 'en.yml' in files: - with open(os.path.join(root, 'en.yml')) as file: - data = yaml.safe_load(file) - - description = data.get('description', '') - contributors = data.get('contributors', []) - - if file_name not in files: - tmp_file = io.StringIO() # create a temporary file in memory - - if description: - # Extract description and use manipulate_text function to translate it in the {language} - prompt = f"translate the following text into {language} :\n '{description}'" - while True: - try: - translation = translate_text(prompt) - break - except RateLimitError as e: - print("Rate limit error occurred. Retrying in 5 seconds...") - time.sleep(5) - except Timeout as e: - print("Timeout error occurred. Retrying in 5 seconds...") - time.sleep(5) - except APIError as e: - print("API error occurred. Retrying in 5 seconds...") - time.sleep(5) - - # Write the translated description to the temporary file - tmp_file.write('description: |\n') - tmp_file.write(" " + '\n '.join(translation.splitlines()) + '\n' ) - - # If contributors field is not present or is not a list, create an empty list - if not isinstance(contributors, list): - contributors = [] - - # Add 'another-satoshi' to the list of contributors - contributors.append('another-satoshi') - - # Write the contributors to the temporary file - tmp_file.write('contributors:\n') - for contributor in contributors: - tmp_file.write(' - ' + contributor + '\n') - - # Save the temporary file as {language_code}.yml - with open(os.path.join(root, file_name), 'w') as file: - file.write(tmp_file.getvalue()) - tmp_file.close() - language_progress.update() - folder_progress.update() - - -# Translate all builders description -process_files() diff --git a/docs/PBN-template-repo/scripts/validate.py b/docs/PBN-template-repo/scripts/validate.py deleted file mode 100644 index 94391cfbe2c..00000000000 --- a/docs/PBN-template-repo/scripts/validate.py +++ /dev/null @@ -1,329 +0,0 @@ -import os -import re -import ruamel.yaml -from typing import List, Dict, Union -from ruamel.yaml.comments import CommentedMap -from ruamel.yaml.scalarstring import PreservedScalarString -from unidecode import unidecode -import frontmatter - -YAML = ruamel.yaml.YAML() -YAML.indent(mapping=2, sequence=4, offset=2) -YAML.width = 100000 - -INVALID_CHARS_PATTERN = re.compile(r"[^\w\d_.-]", re.UNICODE) - - -class ValidationError(Exception): - pass - - -class ValidationMixin: - data: Dict[str, Union[str, int, List[str], Dict[str, str]]] - - REQUIRED_FIELDS = [] - OPTIONAL_FIELDS = [] - - def __init__(self): - self.errors: list[str] = [] - self.warnings: list[str] = [] - - def validate_required_fields(self): - missing_fields = set(self.REQUIRED_FIELDS) - set(self.data.keys()) - if missing_fields: - self.errors.append(f"Missing required fields: {', '.join(missing_fields)}") - - def validate_optional_fields(self): - if self.OPTIONAL_FIELDS: - missing_fields = set(self.OPTIONAL_FIELDS) - set(self.data.keys()) - if missing_fields: - self.warnings.append( - f"Missing optional fields: {', '.join(missing_fields)}" - ) - - def validate_unrecognized_fields(self): - allowed_fields = set(self.REQUIRED_FIELDS + self.OPTIONAL_FIELDS) - unrecognized_fields = set(self.data.keys()) - allowed_fields - if unrecognized_fields: - self.warnings.append( - f"Unrecognized fields: {', '.join(unrecognized_fields)}" - ) - - def fix_order(self): - sorted_fields = [] - unrecognized_fields = [] - - for field in self.REQUIRED_FIELDS: - if field in self.data: - sorted_fields.append(field) - - for field in self.data.keys(): - if field not in self.REQUIRED_FIELDS and field in self.OPTIONAL_FIELDS: - sorted_fields.append(field) - else: - unrecognized_fields.append(field) - - self.data = CommentedMap( - sorted( - self.data.items(), - key=lambda pair: ( - sorted_fields.index(pair[0]) - if pair[0] in sorted_fields - else len(sorted_fields) + self.OPTIONAL_FIELDS.index(pair[0]) - if pair[0] in self.OPTIONAL_FIELDS - else len(sorted_fields) - + len(self.OPTIONAL_FIELDS) - + unrecognized_fields.index(pair[0]), - ), - ) - ) - - def print_report(self, header: str): - if self.errors or self.warnings: - print(header) - - if self.errors: - print(f" Errors:") - for error in self.errors: - print(f" - {error}") - - if self.warnings: - print(f" Warnings:") - for warning in self.warnings: - print(f" - {warning}") - - if self.errors or self.warnings: - print("") - - -class Course(ValidationMixin): - REQUIRED_FIELDS = ["level", "hours", "teacher", "contributors"] - REQUIRED_TRANSLATABLE_FIELDS = ["name", "goal"] - LEVELS = ["beginner", "intermediate", "advanced", "expert"] - - def __init__(self, course_dir: str): - super().__init__() - self.course_dir = course_dir - self.course_yml = os.path.join(course_dir, "course.yml") - self.languages = [] - - self.load_course() - - def load_course(self): - self.data = self.load_course_yml() - self.load_languages() - - def load_course_yml(self) -> CommentedMap: - with open(self.course_yml, encoding="utf-8") as f: - return YAML.load(f) - - def load_languages(self): - for file in os.listdir(self.course_dir): - if file.endswith(".md"): - lang = file[:-3] - self.languages.append(lang) - - def save(self): - with open(self.course_yml, "w", encoding="utf-8") as f: - YAML.dump(self.data, f) - - def validate(self): - self.validate_required_fields() - self.validate_optional_fields() - self.validate_unrecognized_fields() - - if self.data["level"] not in self.LEVELS: - self.errors.append(f"Invalid level: {self.data['level']}") - - if "hours" in self.data and not isinstance(self.data["hours"], int): - self.errors.append(f"'hours' should be an integer: {self.data['hours']}") - - if "teacher" in self.data and not isinstance(self.data["teacher"], str): - self.errors.append(f"'teacher' should be a string: {self.data['teacher']}") - - if "contributors" in self.data and ( - not isinstance(self.data["contributors"], list) - or not all(isinstance(c, str) for c in self.data["contributors"]) - ): - self.errors.append( - f"'contributors' should be a list of strings: {self.data['contributors']}" - ) - - def validate_md_files(self): - for lang in self.languages: - lang_file = os.path.join(self.course_dir, f"{lang}.md") - if not os.path.isfile(lang_file): - self.errors.append(f"Language file '{lang}.md' not found") - continue - - with open(lang_file, "r", encoding="utf-8") as f: - lines = f.readlines() - - post = frontmatter.loads("\n".join(lines)) - if not set(self.REQUIRED_TRANSLATABLE_FIELDS) <= set(post.keys()): - missing_fields = set(self.REQUIRED_TRANSLATABLE_FIELDS) - set( - post.keys() - ) - self.errors.append( - f"Missing required fields in {lang}: {', '.join(missing_fields)}" - ) - - start_line = None - end_line = None - - for i, line in enumerate(lines[1:]): - if line.strip() == "---" and start_line is None: - start_line = i - if line.strip() == "+++" and end_line is None: - end_line = i - break - - start_line = start_line + 1 if start_line is not None else None - end_line = end_line + 1 if end_line is not None else None - - if start_line is None or end_line is None: - self.errors.append(f"Missing excerpt in {lang}.md") - else: - excerpt_lines = lines[start_line + 1 : end_line] - excerpt = "".join(excerpt_lines).strip() - - if not excerpt: - self.warnings.append(f"Empty excerpt in {lang}.md") - - -class Podcast(ValidationMixin): - REQUIRED_FIELDS = ["name", "host", "language"] - OPTIONAL_FIELDS = [ - "links", - "description", - "tags", - "contributors", - ] - - def __init__(self, podcast_dir: str): - super().__init__() - self.podcast_dir = podcast_dir - self.podcast_yml = os.path.join(podcast_dir, "podcast.yml") - - self.load_podcast() - - def load_podcast(self): - self.data = self.load_podcast_yml() - - def load_podcast_yml(self) -> CommentedMap: - with open(self.podcast_yml, encoding="utf-8") as f: - return YAML.load(f) - - def save(self): - with open(self.podcast_yml, "w", encoding="utf-8") as f: - YAML.dump(self.data, f) - - def validate(self): - self.validate_required_fields() - self.validate_unrecognized_fields() - - if "links" not in self.data: - self.warnings.append(f"No links found") - - if "description" not in self.data: - self.warnings.append(f"No description") - - if "links" in self.data: - self.validate_links() - - def validate_links(self): - links = self.data["links"] - - if not isinstance(links, dict): - self.errors.append("'links' should be an object") - - for link_name, link_url in links.items(): - if not isinstance(link_url, str): - self.errors.append(f"'{link_name}' link should be a string: {link_url}") - - -def replace_in_files(files: List[str], old: str, new: str): - for file_path in files: - with open(file_path, "r", encoding="utf-8") as file: - file_data = file.read() - - file_data = file_data.replace(old, new) - - with open(file_path, "w", encoding="utf-8") as file: - file.write(file_data) - - -def find_and_fix_courses(directory: str): - course_dirs = [ - os.path.join(directory, d) - for d in os.listdir(directory) - if os.path.isdir(os.path.join(directory, d)) - ] - - print("🔎 Checking courses") - - for course_dir in course_dirs: - course = Course(course_dir) - course.validate() - course.validate_md_files() - course.fix_order() - course.save() - course.print_report(f"Course {course_dir.split('/')[-1]}") - - print("✅ Finished checking courses\n") - - -def find_and_fix_podcasts(directory: str): - podcast_dirs = [ - os.path.join(directory, d) - for d in os.listdir(directory) - if os.path.isdir(os.path.join(directory, d)) - ] - - print("🔎 Checking podcasts") - - for podcast_dir in podcast_dirs: - podcast = Podcast(podcast_dir) - podcast.validate() - podcast.fix_order() - podcast.save() - podcast.print_report(f"Podcast {podcast_dir.split('/')[-1]}") - - print("✅ Finished checking podcasts\n") - - -def find_and_fix_assets(directory: str): - print("🔎 Checking file names") - - for root, _, files in os.walk(directory): - for file in files: - if file.startswith(".") or "/." in os.path.join(root, file): - continue - - old_file_path = os.path.join(root, file) - new_file_name = re.sub( - "_+|-+", "_", INVALID_CHARS_PATTERN.sub("_", unidecode(file).lower()) - ) - - new_file_path = os.path.join(root, new_file_name) - - if old_file_path != new_file_path: - os.rename(old_file_path, new_file_path) - print(f"Renamed file {old_file_path} to {new_file_path}") - - files = [ - os.path.join(r, f) - for r, _, fs in os.walk(directory) - for f in fs - if f.endswith(".md") or f.endswith(".yml") - ] - replace_in_files(files, file, new_file_name) - - print("✅ Finished checking file names") - - -if __name__ == "__main__": - directory = "." - find_and_fix_courses(os.path.join(directory, "courses")) - find_and_fix_podcasts(os.path.join(directory, "resources/podcasts")) - find_and_fix_assets(directory) diff --git a/docs/PBN-template-repo/scripts/validate_all.py b/docs/PBN-template-repo/scripts/validate_all.py deleted file mode 100755 index 2e13c84f64b..00000000000 --- a/docs/PBN-template-repo/scripts/validate_all.py +++ /dev/null @@ -1,767 +0,0 @@ -#!/usr/bin/env python3 -""" -PlanB Network - Validate All Content - -Simple script to validate all courses and tutorials and generate an HTML report. - -Usage: - python validate_all.py # Validate all, generate HTML report - python validate_all.py --courses-only # Only validate courses - python validate_all.py --tutorials-only # Only validate tutorials - python validate_all.py --no-report # Validate without generating HTML -""" - -import argparse -import json -import shutil -import subprocess -import sys -from datetime import datetime -from pathlib import Path - - -class Colors: - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - CYAN = "\033[96m" - BOLD = "\033[1m" - DIM = "\033[2m" - END = "\033[0m" - - -class ProgressBar: - """Simple progress bar with single-line updates.""" - - def __init__(self, total: int, prefix: str = "", width: int = 30): - self.total = total - self.prefix = prefix - self.width = width - self.current = 0 - self.passed = 0 - self.failed = 0 - self.warnings = 0 - self.is_tty = sys.stdout.isatty() - - def update(self, name: str, errors: int = 0, warnings: int = 0): - """Update progress bar with new item result.""" - self.current += 1 - if errors > 0: - self.failed += 1 - elif warnings > 0: - self.warnings += 1 - else: - self.passed += 1 - - if self.is_tty: - self._draw(name) - - def _draw(self, name: str): - """Draw the progress bar on a single line (TTY only).""" - pct = self.current / self.total - filled = int(self.width * pct) - bar = "█" * filled + "░" * (self.width - filled) - - # Truncate name to fit - max_name = 25 - if len(name) > max_name: - name = name[:max_name-2] + ".." - - line = f"{self.prefix} │{bar}│ {self.current:3d}/{self.total} {name:<{max_name}}" - - sys.stdout.write(f"\r\033[K{line}") - sys.stdout.flush() - - def finish(self): - """Complete the progress bar and show summary.""" - bar = "█" * self.width - - parts = [] - if self.passed > 0: - parts.append(f"{Colors.GREEN}{self.passed} ✓{Colors.END}") - if self.failed > 0: - parts.append(f"{Colors.RED}{self.failed} ✗{Colors.END}") - if self.warnings > 0: - parts.append(f"{Colors.YELLOW}{self.warnings} ⚠{Colors.END}") - - summary = " ".join(parts) - - if self.is_tty: - line = f"{self.prefix} │{Colors.GREEN}{bar}{Colors.END}│ {self.total:3d}/{self.total} Done: {summary}" - sys.stdout.write(f"\r\033[K{line}\n") - else: - # Non-TTY: just print final summary - print(f"{self.prefix} {self.total}/{self.total} - {summary}") - - sys.stdout.flush() - - -def find_repo_root() -> Path: - """Find the repository root by looking for .git directory.""" - current = Path(__file__).resolve().parent - for _ in range(10): - if (current / ".git").exists(): - return current - if current.parent == current: - break - current = current.parent - # Fallback: assume script is in repo_root/scripts/ - return Path(__file__).resolve().parent.parent - - -def find_courses(repo_root: Path) -> list[Path]: - """Find all course folders.""" - courses_dir = repo_root / "courses" - if not courses_dir.exists(): - return [] - - courses = [] - for item in sorted(courses_dir.iterdir()): - if item.is_dir() and not item.name.startswith('.'): - # Check if it has course.yml (it's a valid course) - if (item / "course.yml").exists(): - courses.append(item) - return courses - - -def find_tutorials(repo_root: Path) -> list[Path]: - """Find all tutorial folders (nested under category).""" - tutorials_dir = repo_root / "tutorials" - if not tutorials_dir.exists(): - return [] - - tutorials = [] - for category in sorted(tutorials_dir.iterdir()): - if category.is_dir() and not category.name.startswith('.'): - for tutorial in sorted(category.iterdir()): - if tutorial.is_dir() and not tutorial.name.startswith('.'): - # Check if it has tutorial.yml (it's a valid tutorial) - if (tutorial / "tutorial.yml").exists(): - tutorials.append(tutorial) - return tutorials - - -def validate_folder(folder: Path, validator_script: Path) -> dict: - """Run validator on a folder and return JSON result.""" - try: - result = subprocess.run( - [sys.executable, str(validator_script), str(folder), "--json"], - capture_output=True, - text=True, - timeout=60 - ) - - if result.stdout.strip(): - return json.loads(result.stdout) - else: - return { - "folder": str(folder), - "results": [], - "total_errors": 1 if result.returncode != 0 else 0, - "total_warnings": 0, - "error_message": result.stderr if result.stderr else "No output" - } - except subprocess.TimeoutExpired: - return { - "folder": str(folder), - "results": [], - "total_errors": 1, - "total_warnings": 0, - "error_message": "Validation timed out" - } - except json.JSONDecodeError as e: - return { - "folder": str(folder), - "results": [], - "total_errors": 1, - "total_warnings": 0, - "error_message": f"JSON parse error: {e}" - } - except Exception as e: - return { - "folder": str(folder), - "results": [], - "total_errors": 1, - "total_warnings": 0, - "error_message": str(e) - } - - -def generate_html_report(courses: list[dict], tutorials: list[dict], output_path: Path, repo_root: Path): - """Generate an HTML report from validation results.""" - - # Calculate statistics - course_total = len(courses) - courses_with_errors = sum(1 for r in courses if r.get('total_errors', 0) > 0) - course_errors = sum(r.get('total_errors', 0) for r in courses) - course_warnings = sum(r.get('total_warnings', 0) for r in courses) - - tutorial_total = len(tutorials) - tutorials_with_errors = sum(1 for r in tutorials if r.get('total_errors', 0) > 0) - tutorial_errors = sum(r.get('total_errors', 0) for r in tutorials) - tutorial_warnings = sum(r.get('total_warnings', 0) for r in tutorials) - - total_items = course_total + tutorial_total - total_errors = course_errors + tutorial_errors - total_warnings = course_warnings + tutorial_warnings - - # Group errors by type - error_types = {} - for result in courses + tutorials: - content_type = 'courses' if 'courses' in result.get('folder', '') else 'tutorials' - for file_result in result.get('results', []): - for error in file_result.get('errors', []): - # Extract error type (first part before specific details) - error_key = error.split(']')[0] + ']' if ']' in error else error[:60] - if error_key not in error_types: - error_types[error_key] = {'courses': [], 'tutorials': []} - - entry = { - 'name': result.get('folder', 'unknown'), - 'file': file_result.get('file', 'unknown'), - 'full_error': error - } - error_types[error_key][content_type].append(entry) - - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - - # Build HTML - html = f''' - - - - - Schema Validation Report - PlanB Network - - - -
-
-

Schema Validation Report

-

Generated: {timestamp}

-
- -
-

Overall Summary

-
-
-
{total_items}
-
Total Content
-
-
-
{total_items - courses_with_errors - tutorials_with_errors}
-
Passing
-
-
-
{courses_with_errors + tutorials_with_errors}
-
With Errors
-
-
-
{total_errors}
-
Total Errors
-
-
-
{total_warnings}
-
Total Warnings
-
-
- -
-
-
{course_total}
-
Courses
-
-
-
-
-
-
{course_total - courses_with_errors}
-
Courses Passing
-
-
-
{courses_with_errors}
-
Courses Failed
-
-
- -
-
-
{tutorial_total}
-
Tutorials
-
-
-
-
-
-
{tutorial_total - tutorials_with_errors}
-
Tutorials Passing
-
-
-
{tutorials_with_errors}
-
Tutorials Failed
-
-
-
-''' - - # Error summary section - if error_types: - html += ''' -
-

Error Summary

-''' - sorted_errors = sorted(error_types.items(), key=lambda x: -(len(x[1]['courses']) + len(x[1]['tutorials']))) - - for error_type, occurrences in sorted_errors: - course_count = len(occurrences['courses']) - tutorial_count = len(occurrences['tutorials']) - error_id = abs(hash(error_type)) % 100000 - - html += f''' -
-
- {error_type[:80]} -
-''' - if course_count > 0: - html += f' {course_count} courses\n' - if tutorial_count > 0: - html += f' {tutorial_count} tutorials\n' - html += f'''
-
-
-''' - all_occurrences = occurrences['courses'] + occurrences['tutorials'] - for occ in all_occurrences[:20]: - file_path = occ['file'].replace(str(repo_root) + '/', '') - html += f'''
-
{file_path}
-
{occ['full_error'][:300]}
-
-''' - if len(all_occurrences) > 20: - html += f'
... and {len(all_occurrences) - 20} more occurrences
\n' - html += '''
-
-''' - html += '''
-''' - - # Content details with tabs - html += f''' -
-

Content Details

-
-
Courses ({course_total})
-
Tutorials ({tutorial_total})
-
- -
-
-''' - - # Courses - sorted_courses = sorted(courses, key=lambda r: (-r.get('total_errors', 0), r.get('folder', ''))) - for result in sorted_courses: - name = Path(result.get('folder', 'unknown')).name - errors = result.get('total_errors', 0) - warnings = result.get('total_warnings', 0) - files = len(result.get('results', [])) - - if errors > 0: - card_class = 'has-errors' - badge = 'FAILED' - elif warnings > 0: - card_class = 'has-warnings' - badge = 'WARNINGS' - else: - card_class = 'valid' - badge = 'OK' - - html += f'''
-
{name} {badge}
-
- {files} files -''' - if errors > 0: - html += f' {errors} errors\n' - if warnings > 0: - html += f' {warnings} warnings\n' - html += '''
-
-''' - - html += '''
-
- -
-
-''' - - # Tutorials - sorted_tutorials = sorted(tutorials, key=lambda r: (-r.get('total_errors', 0), r.get('folder', ''))) - for result in sorted_tutorials: - folder = Path(result.get('folder', 'unknown')) - name = f"{folder.parent.name}/{folder.name}" - errors = result.get('total_errors', 0) - warnings = result.get('total_warnings', 0) - files = len(result.get('results', [])) - - if errors > 0: - card_class = 'has-errors' - badge = 'FAILED' - elif warnings > 0: - card_class = 'has-warnings' - badge = 'WARNINGS' - else: - card_class = 'valid' - badge = 'OK' - - html += f'''
-
{name} {badge}
-
- {files} files -''' - if errors > 0: - html += f' {errors} errors\n' - if warnings > 0: - html += f' {warnings} warnings\n' - html += '''
-
-''' - - html += '''
-
-
- - -
- - - - -''' - - output_path.write_text(html, encoding='utf-8') - - -def main(): - parser = argparse.ArgumentParser( - description="Validate all PlanB Network courses and tutorials" - ) - parser.add_argument('--courses-only', action='store_true', help='Only validate courses') - parser.add_argument('--tutorials-only', action='store_true', help='Only validate tutorials') - parser.add_argument('--no-report', action='store_true', help='Skip HTML report generation') - parser.add_argument('--output', '-o', default=None, help='Output HTML file path') - - args = parser.parse_args() - - # Find paths - repo_root = find_repo_root() - template_repo = repo_root / "docs" / "PBN-template-repo" - validator_script = template_repo / "scripts" / "schema_validator.py" - - if not validator_script.exists(): - print(f"{Colors.RED}ERROR: schema_validator.py not found at {validator_script}{Colors.END}") - print(f"Make sure you're running from the bitcoin-educational-content repository.") - sys.exit(1) - - # Default output path - in scripts/reports/ - if args.output: - output_path = Path(args.output) - else: - reports_dir = repo_root / "scripts" / "reports" - reports_dir.mkdir(exist_ok=True) - output_path = reports_dir / "validation_report.html" - - print(f"{Colors.BOLD}PlanB Network Schema Validator{Colors.END}") - print(f"Repository: {repo_root}") - print(f"{'='*60}\n") - - courses = [] - tutorials = [] - course_results = [] - tutorial_results = [] - - # Find content - if not args.tutorials_only: - courses = find_courses(repo_root) - print(f"{Colors.CYAN}Found {len(courses)} courses{Colors.END}") - - if not args.courses_only: - tutorials = find_tutorials(repo_root) - print(f"{Colors.CYAN}Found {len(tutorials)} tutorials{Colors.END}") - - print() - - # Validate courses - if courses: - progress = ProgressBar(len(courses), prefix="Courses ") - for course in courses: - result = validate_folder(course, validator_script) - result['folder'] = str(course) - course_results.append(result) - progress.update( - course.name, - errors=result.get('total_errors', 0), - warnings=result.get('total_warnings', 0) - ) - progress.finish() - - # Validate tutorials - if tutorials: - progress = ProgressBar(len(tutorials), prefix="Tutorials") - for tutorial in tutorials: - name = f"{tutorial.parent.name}/{tutorial.name}" - result = validate_folder(tutorial, validator_script) - result['folder'] = str(tutorial) - tutorial_results.append(result) - progress.update( - name, - errors=result.get('total_errors', 0), - warnings=result.get('total_warnings', 0) - ) - progress.finish() - - # Summary - course_errors = sum(r.get('total_errors', 0) for r in course_results) - course_warnings = sum(r.get('total_warnings', 0) for r in course_results) - tutorial_errors = sum(r.get('total_errors', 0) for r in tutorial_results) - tutorial_warnings = sum(r.get('total_warnings', 0) for r in tutorial_results) - - print(f"{'='*60}") - print(f"{Colors.BOLD}Summary{Colors.END}") - print(f" Courses: {len(course_results)} validated, {course_errors} errors, {course_warnings} warnings") - print(f" Tutorials: {len(tutorial_results)} validated, {tutorial_errors} errors, {tutorial_warnings} warnings") - print(f"{'='*60}\n") - - # Generate HTML report - if not args.no_report: - print(f"Generating HTML report...", end=" ", flush=True) - generate_html_report(course_results, tutorial_results, output_path, repo_root) - print(f"{Colors.GREEN}Done!{Colors.END}") - print(f"Report: {output_path}") - - # Exit code - total_errors = course_errors + tutorial_errors - sys.exit(0 if total_errors == 0 else 1) - - -if __name__ == "__main__": - main() diff --git a/docs/PBN-template-repo/scripts/webp-batch-conversion.py b/docs/PBN-template-repo/scripts/webp-batch-conversion.py deleted file mode 100644 index 6e469858f48..00000000000 --- a/docs/PBN-template-repo/scripts/webp-batch-conversion.py +++ /dev/null @@ -1,83 +0,0 @@ -from PIL import Image -import os -import re -import sys - -def convert_to_webp_and_remove_metadata(root_dir): - total_reduction_percentage = 0 - num_converted_files = 0 - - # Debugging: Print the absolute path to verify correct directory - print("Root directory absolute path:", os.path.abspath(root_dir)) - - total_files = sum([len(files) for r, d, files in os.walk(root_dir) if any(f.lower().endswith(('.png', '.jpg', '.jpeg')) for f in files)]) - print(f"Total image files found: {total_files}") - - for root, dirs, files in os.walk(root_dir): - # Debugging: Print each directory being walked - print("Walking through:", root) - if not files: - print("No files in this directory.") - for file in files: - if file.lower().endswith(('.png', '.jpg', '.jpeg')): - img_path = os.path.join(root, file) - print("Processing file:", img_path) # Debugging: Show which file is being processed - - original_size = os.path.getsize(img_path) # Get original file size - img = Image.open(img_path) - webp_path = f"{os.path.splitext(img_path)[0]}.webp" - - # Save as WebP without metadata - img.save(webp_path, "WEBP", quality=90, optimize=True, method=6, save_all=True, include_metadata=False) - converted_size = os.path.getsize(webp_path) # Get converted file size - - reduction_percentage = (original_size - converted_size) / original_size * 100 - total_reduction_percentage += reduction_percentage - num_converted_files += 1 - - # Remove the original file - os.remove(img_path) - - # Print update progress - sys.stdout.write(f"\rConverted {num_converted_files}/{total_files} files. Last: {file} -> {os.path.basename(webp_path)} | Reduction: {reduction_percentage:.2f}%") - sys.stdout.flush() - - if num_converted_files > 0: - average_reduction = total_reduction_percentage / num_converted_files - print(f"\nAverage size reduction: {average_reduction:.2f}%") - else: - print("\nNo files were converted.") - -def remove_metadata_from_existing_webp(root_dir): - for root, dirs, files in os.walk(root_dir): - for file in files: - if file.lower().endswith('.webp'): - webp_path = os.path.join(root, file) - img = Image.open(webp_path) - - # Save WebP without metadata - img.save(webp_path, "WEBP", quality=90, optimize=True, method=6, save_all=True, include_metadata=False) - print(f"Metadata removed from: {webp_path}") - -def update_md_links(root_dir): - for root, dirs, files in os.walk(root_dir): - for file in files: - if file.lower().endswith('.md'): - md_path = os.path.join(root, file) - with open(md_path, 'r', encoding='utf-8') as md_file: - content = md_file.read() - - # Replace image extensions except for 'thumbnail.png' - updated_content = re.sub(r'\.(png|jpg|jpeg)(?!\.webp)', '.webp', content, flags=re.IGNORECASE) - - with open(md_path, 'w', encoding='utf-8') as md_file: - md_file.write(updated_content) - - print(f"Updated image links in {md_path}") - -if __name__ == "__main__": - root_dir = './../resources/conference/lecture-pierre-rochard/assets/' - convert_to_webp_and_remove_metadata(root_dir) # Converts non-WebP files and strips metadata - remove_metadata_from_existing_webp(root_dir) # Ensures existing WebP files have no metadata - update_md_links(root_dir) # Updates markdown links - diff --git a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/cover.webp b/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/cover.webp deleted file mode 100644 index 5b89a2bc7e7..00000000000 Binary files a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/cover.webp and /dev/null differ diff --git a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/en/01.webp b/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/en/01.webp deleted file mode 100644 index 7d9fac1e620..00000000000 Binary files a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/en/01.webp and /dev/null differ diff --git a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/en/02.webp b/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/en/02.webp deleted file mode 100644 index dbbcb0926d4..00000000000 Binary files a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/en/02.webp and /dev/null differ diff --git a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/logo.webp b/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/logo.webp deleted file mode 100644 index 527e83a988a..00000000000 Binary files a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/assets/logo.webp and /dev/null differ diff --git a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/en.md b/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/en.md deleted file mode 100644 index 7a307237ac2..00000000000 --- a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/en.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: Awesome tutorial - -description: How to use an awesome tool ---- - -![cover](assets/cover.webp) - -**Note**: the cover image should always have a horizontal rectangular shape, like the `cover.webp` above. - -Info: the planb.academy platform will display chapter titles on the right side of each tutorial (on desktop): to create a chapter title, you only need to create a H2 title (using two ##), as you can see for the title below. - -## Chapter Title 1 - Images - -Such chapter titles on the right side can be clicked by the user to jump to the specific chapter. Here below you can see how they appear on the platform (if you use Github web, make sure to click on the `Preview` mode) - -![image](assets/en/01.webp) - -Here, you can see the chapter legend displayed right after the tutorial cover image - -**Attention**: all the images in tutorials must have a horizontal shape, with a size of 1280 x 720; you can insert images with higher resolution, but pay attention to the ratio of the image - -## Chapter Title 2 - Special Text - -To write bold text, follow the two examples below (adding `**` or `_` before and after the word): the "Info" and "Note" words will appear in bold - -**Info**: I strongly advise you fully read this markdown file, as it contains precious examples about how the markdown language is rendered within the Plan ₿ Academy platform. While some of the rules are the same of markdown syntax, others aren't. - -**Note**: _This sentence appears in italic on the platform_ - -## Chapter Title 3 - Quotations - -If you write a paragraph and then press `Enter`, the two paragraphs will remain attached on the platform, like if you only put a space, instead of a new line, even if see a new line on your text editor. To avoid that, you **have to insert an empty line** between the two paragraphs, meaning you need to press the `Enter` button twice to start a new text paragraph. - -Let's see an example (below) regarding how to quote other texts, and insert the name of people who spoke such things, together with other details. - -**Example**: below, a famous statement from Satoshi Nakamoto is shown; it was posted on the Bitcointalk forum on July 30, 2010: - -> If you don't believe me or don't get it, I don't have time to try to convince you, sorry. - -As you can see, no need to use the `"` quote sign, as they will be automatically rendered by the platform when using the symbol `>`. Just provide a brief introduction to the mentioned author, along with any relevant context you deem necessary. - -### Information about explaining words - -Whenever you mention a word specifically used in the Bitcoin space, you may decide to further explain it to be clearer. Well, this is something we encourage, but we kindly ask you not to write such description yourself. If possible, mention the word from the Plan ₿ Academy [Glossary](https://planb.academy/en/resources/glossary). For example, let's say you are going to explain the `Ark` term: - -**Example:** ..if you don't know what Ark is, please take a look at the [following definition](https://planb.academy/resources/glossary/ark) from the Plan ₿ Academy Glossary. - -**Note** be sure to remove the `/en` path (or any other language selected) from the link you copy: https://planb.academy/en/resources/glossary/ark → becomes → https://planb.academy/resources/glossary/ark - -## Chapter Title 4 - Image path - -Some text before an image. Please note how the image paths are written, the image 01 is an image containing no text, or English text only. You should only put English or no-text images inside an English markdown file. - -![image](assets/en/02.webp) - -Other text after an image. - -### A special way to mention other tutorials - -If you want to mention a Plan ₿ Academy tutorial, paste the link as you see below (remember to add empty lines above and below, as if it were a paragraph): - -https://planb.academy/tutorials/computer-security/authentication/bitwarden-0532f569-fb00-4fad-acba-2fcb1bf05de9 - -Insert a new line after the link, then you can go on writing your text.. - -## Chapter Title 5 - Lists - -Please look at this example list: -- this is the first element; -- that's the second one; - -Now an ordered list: -1. First element; -2. Second element; - -Please, never use list with this `*` sign - it will create conflicts with the script taking care of fixing missing bold symbols `**` after the auto translation. Instead, always use the dash `-` for unordered lists. diff --git a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/tutorial.yml b/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/tutorial.yml deleted file mode 100644 index 1a761d7aa27..00000000000 --- a/docs/PBN-template-repo/tutorials/this-folder-is-a-tutorial-category/awesome-tutorial/tutorial.yml +++ /dev/null @@ -1,95 +0,0 @@ -# UUID of the tutorial -# generate a random uuid v4 -id: bf76be99-bf95-4606-8103-e7a072839ee9 - -# Characteristic of tutorial - -# Project -# Write the project name -project_id: uuid_of_the_project_author_of_tutorial - -# define the content difficulty level from user's perspective -# Level can be one of following: -# - beginner -# - intermediate -# - advanced -# - expert -level: beginner - -# Category -# Category can be choosen from the following list (only look at words with lowercase letters): -#Tutorials/ -#├── WALLET/ -#│ ├── desktop -#│ ├── hardware -#│ ├── mobile -#│ └── backup -#├── NODE/ -#│ ├── bitcoin -#│ ├── lightning-network -#│ └── others -#├── MINING/ -#│ ├── hardware -#│ └── pool -#├── BUSINESS/ -#│ ├── point-of-sale -#│ └── others -#├── EXCHANGE/ -#│ ├── peer-to-peer -#│ └── centralized -#├── PRIVACY/ -#│ ├── on-chain -#│ └── analysis -#├── COMPUTER SECURITY/ -#│ ├── authentication -#│ ├── communication -#│ ├── operating system -#│ └── data -#└── CONTRIBUTION/ -# ├── tutorial -# ├── resource -# └── others -category: peer-to-peer - -# Professor -# Insert the UUID of the person that authored this tutorial -professor_id: uuid_of_the_professor_author_of_tutorial - -# Tags -# Choose from the list the associated tags to this content -# The list of tags is composed of 51 items that can be found here (https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md) -tags: - - cypherpunk - - personal-security - - privacy - -# OPTIONAL -# its use is to mention the original source at both the beginning and at the end of the tutorial -credit_link: https://link_to_the_original_tuto.com - -# license field, choose between CC-BY-SA-V4, MIT -# (DEFAULT) CC-BY-SA-V4: is also quite permissive but includes a share-alike clause -# MIT: allows for almost unrestricted use → only for imported content and docs with such license policy -license: CC-BY-SA-V4 - -# Proofreading metadata -# Only the original author needs to add this data related to initial proofreading -# Any further data will be automatically added after each proofreading -# -# This information are provided by PBN team, no need to specify if you are a contributor. -# for PBN staff: an entry is created for each language specifying: -# - language: language to review -# - last_contribution_date: the date when last contribution has been received -# - urgency: level of urgency from 1 (low urgency) to X (high urgency) -# - contributor_names: list of contributors PBN ids -# - reward: amount of sats available for reward -licence: CC-BY-SA-V4 - -original_language: en -proofreading: - - language: en - last_contribution_date: YYYY-MM-DD - urgency: 1 # default value - contributor_names: - - author_github_username #insert your github username - reward: 0 # default value diff --git a/docs/PBN-template-repo/tutorials/tutorial-content-scheme.json b/docs/PBN-template-repo/tutorials/tutorial-content-scheme.json deleted file mode 100644 index 8323e474937..00000000000 --- a/docs/PBN-template-repo/tutorials/tutorial-content-scheme.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "tutorial-content", - "description": "Schema for validating tutorial markdown content files (e.g., en.md, fr.md)", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Tutorial title/name displayed as the main heading", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "type": "string", - "description": "Brief description of what the tutorial covers, used for SEO and previews", - "maxLength": 500 - } - }, - "required": ["name", "description"], - "additionalProperties": false, - "content_rules": { - "description": "Structural requirements for tutorial markdown content following YAML front-matter", - "cover_image": { - "required": true, - "description": "Cover image MUST appear within the first 10 lines after YAML front-matter closing '---'.", - "pattern": "![{any-alt-text}](assets/cover.webp)", - "pattern_regex": "!\\[.*\\]\\(assets/cover\\.webp\\)", - "alt_text": "Any text is allowed as alt text", - "path": "assets/cover.webp (must be exact)", - "format": "WebP only", - "aspect_ratio": "1280x720 (16:9)" - }, - "video_embeds": { - "required": false, - "description": "Optional video embeds using YouTube URLs", - "patterns": [ - "![video](https://youtu.be/VIDEO_ID)", - "![video](https://www.youtube.com/watch?v=VIDEO_ID)" - ] - }, - "headings": { - "h1": { - "allowed": false, - "reason": "Title comes from YAML 'name' field, do NOT use H1 in content", - "exception": "H1 headings inside code blocks (fenced with ``` or ~~~, or indented) are allowed and not validated" - }, - "h2": { - "allowed": true, - "description": "Main chapters - appears in sidebar navigation", - "pattern": "## Chapter Title" - }, - "h3": { - "allowed": true, - "description": "Sub-sections within chapters", - "pattern": "### Sub-section Title" - } - }, - "lists": { - "unordered": { - "marker": "-", - "description": "Use dash (-) for unordered lists, NEVER asterisk (*)", - "reason": "Asterisk conflicts with translation systems and bold formatting" - }, - "ordered": { - "marker": "1.", - "description": "Use numbered format for ordered lists" - } - }, - "notes": { - "description": "Emphasized callout notes using bold prefix", - "patterns": [ - "**Note**:", - "**Info**:", - "**Attention**:", - "**Warning**:", - "**Tip**:" - ] - }, - "internal_links": { - "description": "Links to other PlanB Network content", - "format": "Must be on their own line with empty lines before and after", - "example": "\n\nhttps://planb.network/tutorials/wallet/sparrow\n\n" - }, - "images": { - "format": "WebP only (.webp extension)", - "types": { - "cover": { - "path": "assets/cover.webp", - "required": true, - "dimensions": "1280x720 aspect ratio", - "alt_text": "Any text allowed", - "position": "Must appear within first 10 lines after front-matter" - }, - "language_specific": { - "path_pattern": "assets/{lang}/{number}.webp", - "examples": ["assets/en/01.webp", "assets/fr/01.webp"], - "description": "Screenshots with text that needs translation" - }, - "universal": { - "path_pattern": "assets/notext/{number}.webp", - "examples": ["assets/notext/1.webp", "assets/notext/2.webp"], - "description": "Images without text, shared across all languages" - } - }, - "alt_text": { - "required": true, - "description": "All images should have descriptive alt text" - } - }, - "yaml_front_matter": { - "required": true, - "description": "YAML front-matter block at the start of the file", - "format": "Delimited by --- on separate lines", - "example": "---\nname: Tutorial Name\ndescription: Tutorial description\n---" - } - } -} diff --git a/docs/PBN-template-repo/tutorials/tutorial-scheme.json b/docs/PBN-template-repo/tutorials/tutorial-scheme.json deleted file mode 100644 index dd454ac4368..00000000000 --- a/docs/PBN-template-repo/tutorials/tutorial-scheme.json +++ /dev/null @@ -1,250 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "tutorial-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "UUID v4 identifier of the tutorial" - }, - "project_id": { - "type": "string", - "format": "uuid", - "description": "Optional UUID referencing a project in resources/projects/. Must match the 'id' field of an existing project's builder.yml or project.yml file." - }, - "level": { - "type": "string", - "description": "Difficulty level of the tutorial from user's perspective", - "enum": ["beginner", "intermediate", "advanced", "expert"] - }, - "category": { - "type": "string", - "description": "Category of the tutorial, lowercase only", - "enum": [ - "desktop", - "hardware", - "mobile", - "backup", - "bitcoin", - "lightning-network", - "others", - "pool", - "point-of-sale", - "peer-to-peer", - "centralized", - "on-chain", - "analysis", - "authentication", - "communication", - "operating system", - "data", - "tutorial", - "resource" - ] - }, - "professor_id": { - "type": "string", - "format": "uuid", - "description": "UUID of the professor who authored this tutorial" - }, - "tags": { - "type": "array", - "description": "List of tags associated with this tutorial from the predefined tag list", - "items": { - "type": "string" - } - }, - "credit_link": { - "type": "string", - "format": "uri", - "description": "Optional URL to the original source of the tutorial" - }, - "license": { - "type": "string", - "description": "License of the tutorial content", - "enum": ["CC-BY-SA-V4", "MIT"] - }, - "original_language": { - "type": "string", - "description": "ISO 639-1 language code of the original tutorial language", - "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" - }, - "last_update_date": { - "type": "string", - "description": "Date of the last content update in YYYY-MM-DD format", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "proofreading": { - "type": "array", - "description": "Proofreading metadata for each language version", - "items": { - "type": "object", - "properties": { - "language": { - "type": "string", - "description": "ISO 639-1 language code for this proofreading entry", - "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" - }, - "last_contribution_date": { - "type": ["string", "null"], - "description": "Date of the last contribution in YYYY-MM-DD format", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" - }, - "urgency": { - "type": "integer", - "description": "Level of urgency for proofreading, 1 being low urgency", - "minimum": 1 - }, - "contributor_names": { - "type": ["array", "null"], - "description": "List of contributor GitHub usernames", - "items": { - "type": "string" - } - }, - "reward": { - "type": "number", - "description": "Amount of sats available for reward", - "minimum": 0 - } - }, - "required": ["language", "urgency", "reward"], - "additionalProperties": false - }, - "minItems": 1 - }, - "assets_rules": { - "type": "object", - "description": "Documentation of required image assets for tutorials. This property documents the asset structure but is not validated at runtime.", - "properties": { - "cover": { - "type": "object", - "description": "Required cover image for the tutorial", - "properties": { - "path": { - "type": "string", - "const": "assets/cover.webp", - "description": "File path relative to tutorial directory" - }, - "required": { - "type": "boolean", - "const": true, - "description": "Cover image is mandatory for all tutorials" - }, - "format": { - "type": "string", - "const": "webp", - "description": "Only WebP format is accepted" - }, - "dimensions": { - "type": "object", - "properties": { - "width": { - "type": "integer", - "const": 1280, - "description": "Required width in pixels" - }, - "height": { - "type": "integer", - "const": 720, - "description": "Required height in pixels" - }, - "aspect_ratio": { - "type": "string", - "const": "16:9", - "description": "Aspect ratio (horizontal/landscape)" - } - } - }, - "usage": { - "type": "string", - "const": "Displayed on platform listings and at the top of tutorial content" - } - } - }, - "language_specific": { - "type": "object", - "description": "Language-specific images containing text that requires translation", - "properties": { - "path_pattern": { - "type": "string", - "const": "assets/{lang}/", - "description": "Directory pattern where {lang} is ISO 639-1 code (e.g., assets/en/, assets/fr/)" - }, - "naming_convention": { - "type": "string", - "const": "Sequential numbering with leading zeros: 01.webp, 02.webp, etc.", - "description": "Files must be numbered sequentially" - }, - "format": { - "type": "string", - "const": "webp", - "description": "Only WebP format is accepted" - }, - "recommended_dimensions": { - "type": "object", - "properties": { - "width": { - "type": "integer", - "const": 1280, - "description": "Recommended width in pixels" - }, - "height": { - "type": "integer", - "const": 720, - "description": "Recommended height in pixels" - }, - "orientation": { - "type": "string", - "const": "horizontal", - "description": "Horizontal/landscape orientation recommended" - } - } - }, - "usage": { - "type": "string", - "const": "Screenshots and images containing text that needs translation for each language" - } - } - }, - "universal": { - "type": "object", - "description": "Universal images without text (no translation needed)", - "properties": { - "path": { - "type": "string", - "const": "assets/notext/", - "description": "Directory for language-independent images" - }, - "naming_convention": { - "type": "string", - "const": "Sequential numbering without leading zeros: 1.webp, 2.webp, etc.", - "description": "Files must be numbered sequentially" - }, - "format": { - "type": "string", - "const": "webp", - "description": "Only WebP format is accepted" - }, - "usage": { - "type": "string", - "const": "Diagrams, icons, and images without text that work across all languages" - } - } - } - } - } - }, - "required": [ - "id", - "level", - "category", - "professor_id", - "license", - "original_language", - "last_update_date", - "proofreading" - ], - "additionalProperties": false -} diff --git a/docs/PBN-template-repo/validation_report.html b/docs/PBN-template-repo/validation_report.html deleted file mode 100644 index 232d67708d8..00000000000 --- a/docs/PBN-template-repo/validation_report.html +++ /dev/null @@ -1,2794 +0,0 @@ - - - - - - Schema Validation Report - PlanB Network - - - -
-
-

Schema Validation Report

-

Generated: 2026-01-04 16:00:14

-
- -
-

Overall Summary

-
-
-
354
-
Total Content
-
-
-
0
-
Passing
-
-
-
354
-
With Errors
-
-
-
354
-
Total Errors
-
-
-
0
-
Total Warnings
-
-
- -
-
-
44
-
Courses
-
-
-
-
-
-
0
-
Courses Passing
-
-
-
44
-
Courses Failed
-
-
- -
-
-
310
-
Tutorials
-
-
-
-
-
-
0
-
Tutorials Passing
-
-
-
310
-
Tutorials Failed
-
-
-
- -
-

Content Details

-
-
Courses (44)
-
Tutorials (310)
-
- -
-
-
-
biz101 FAILED
-
- 0 files - 1 errors -
-
-
-
biz205 FAILED
-
- 0 files - 1 errors -
-
-
-
btc101 FAILED
-
- 0 files - 1 errors -
-
-
-
btc102 FAILED
-
- 0 files - 1 errors -
-
-
-
btc202 FAILED
-
- 0 files - 1 errors -
-
-
-
btc204 FAILED
-
- 0 files - 1 errors -
-
-
-
btc208 FAILED
-
- 0 files - 1 errors -
-
-
-
btc303 FAILED
-
- 0 files - 1 errors -
-
-
-
btc304 FAILED
-
- 0 files - 1 errors -
-
-
-
csv402 FAILED
-
- 0 files - 1 errors -
-
-
-
csv404 FAILED
-
- 0 files - 1 errors -
-
-
-
cyp201 FAILED
-
- 0 files - 1 errors -
-
-
-
cyp302 FAILED
-
- 0 files - 1 errors -
-
-
-
dev103 FAILED
-
- 0 files - 1 errors -
-
-
-
dev303 FAILED
-
- 0 files - 1 errors -
-
-
-
eco104 FAILED
-
- 0 files - 1 errors -
-
-
-
eco201 FAILED
-
- 0 files - 1 errors -
-
-
-
eco203 FAILED
-
- 0 files - 1 errors -
-
-
-
eco204 FAILED
-
- 0 files - 1 errors -
-
-
-
ene101 FAILED
-
- 0 files - 1 errors -
-
-
-
his201 FAILED
-
- 0 files - 1 errors -
-
-
-
his203 FAILED
-
- 0 files - 1 errors -
-
-
-
his204 FAILED
-
- 0 files - 1 errors -
-
-
-
his205 FAILED
-
- 0 files - 1 errors -
-
-
-
lnp201 FAILED
-
- 0 files - 1 errors -
-
-
-
lnp202 FAILED
-
- 0 files - 1 errors -
-
-
-
lnp206 FAILED
-
- 0 files - 1 errors -
-
-
-
lnp404 FAILED
-
- 0 files - 1 errors -
-
-
-
min201 FAILED
-
- 0 files - 1 errors -
-
-
-
min306 FAILED
-
- 0 files - 1 errors -
-
-
-
net302 FAILED
-
- 0 files - 1 errors -
-
-
-
phi101 FAILED
-
- 0 files - 1 errors -
-
-
-
phi102 FAILED
-
- 0 files - 1 errors -
-
-
-
phi305 FAILED
-
- 0 files - 1 errors -
-
-
-
pos305 FAILED
-
- 0 files - 1 errors -
-
-
-
pro101 FAILED
-
- 0 files - 1 errors -
-
-
-
pro202 FAILED
-
- 0 files - 1 errors -
-
-
-
scu101 FAILED
-
- 0 files - 1 errors -
-
-
-
scu202 FAILED
-
- 0 files - 1 errors -
-
-
-
sid202 FAILED
-
- 0 files - 1 errors -
-
-
-
sid302 FAILED
-
- 0 files - 1 errors -
-
-
-
sid402 FAILED
-
- 0 files - 1 errors -
-
-
-
sid406 FAILED
-
- 0 files - 1 errors -
-
-
-
soc104 FAILED
-
- 0 files - 1 errors -
-
-
-
- -
-
-
-
business/accounting-payments FAILED
-
- 0 files - 1 errors -
-
-
-
business/be-bop FAILED
-
- 0 files - 1 errors -
-
-
-
business/bitfinex-pro FAILED
-
- 0 files - 1 errors -
-
-
-
business/breez-pos FAILED
-
- 0 files - 1 errors -
-
-
-
business/btc-map FAILED
-
- 0 files - 1 errors -
-
-
-
business/btcpay-server FAILED
-
- 0 files - 1 errors -
-
-
-
business/btcpay-server-umbrel FAILED
-
- 0 files - 1 errors -
-
-
-
business/btcpay-usdt-plugin FAILED
-
- 0 files - 1 errors -
-
-
-
business/coinos FAILED
-
- 0 files - 1 errors -
-
-
-
business/help-spend-btc FAILED
-
- 0 files - 1 errors -
-
-
-
business/ibexpay FAILED
-
- 0 files - 1 errors -
-
-
-
business/kraken-pro FAILED
-
- 0 files - 1 errors -
-
-
-
business/lightning-smoke-machine FAILED
-
- 0 files - 1 errors -
-
-
-
business/lipa-business FAILED
-
- 0 files - 1 errors -
-
-
-
business/lnbits FAILED
-
- 0 files - 1 errors -
-
-
-
business/lnbits-server FAILED
-
- 0 files - 1 errors -
-
-
-
business/open-node FAILED
-
- 0 files - 1 errors -
-
-
-
business/pay-with-flash FAILED
-
- 0 files - 1 errors -
-
-
-
business/speed-business FAILED
-
- 0 files - 1 errors -
-
-
-
business/swiss-bitcoin-pay-2 FAILED
-
- 0 files - 1 errors -
-
-
-
business/tiankii FAILED
-
- 0 files - 1 errors -
-
-
-
business/voltz FAILED
-
- 0 files - 1 errors -
-
-
-
business/wallet-of-satoshi-pos FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/aegis-authenticator FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/alias-vault FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/angry-ip-scanner FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/arch-linux FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/aurora-store FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/authy FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/bitchat FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/bitwarden FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/cryptomator FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/debian FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/delta-chat FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/dietpi FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/elementary-os FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/ente-auth FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/f-droid FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/fedora FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/firefox FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/grapheneos FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/graylog FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/integrity-authenticity FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/ivpn FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/jami FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/kali-linux FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/keepass FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/keet FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/ledger-fido-u2f FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/librewolf FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/lineageos FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/linux-mint FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/ln-vpn FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/luks FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/lynis FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/manjaro FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/matrix FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/microsoft-windows FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/mozilla-vpn FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/mullvad FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/mullvad-browser FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/nmap FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/ntopng FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/olvid FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/opnsense FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/orion-browser FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/pearpass FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/pears FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/pfsense FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/pi-hole FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/picocrypt FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/pop-os FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/proton-authenticator FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/proton-drive FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/proton-mail FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/pureos FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/qubes FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/raspberry-pi-zero FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/security-key FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/seedkeeper-password FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/session FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/shelter FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/signal FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/simple-login FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/simplex-chat FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/sms4sats FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/tails FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/tailscale FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/telegram FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/threema FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/thunderbird FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/tor-browser FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/tox FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/trezor-u2f-fido2 FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/ubuntu FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/veracrypt FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/virtualbox FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/white-noise FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/whonix FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/wireguard FAILED
-
- 0 files - 1 errors -
-
-
-
computer-security/zen-browser FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/add-bet FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/add-book FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/add-builder FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/add-conference-replay FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/add-event FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/add-podcast FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/create-github-account FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/create-teacher-profile FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/github-desktop-work-environment FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/local-deployment-of-pbn-platform FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/pbn-certificate-timestamping FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/pears-plan-b-academy FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/proofreading-guidelines FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/proofreading-review-tutorial FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/weblate-add-new-language FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/weblate-translate-front-end FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/write-tutorials FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/write-tutorials-git-expert FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/write-tutorials-github-desktop-intermediate FAILED
-
- 0 files - 1 errors -
-
-
-
contribution/write-tutorials-github-web-beginner FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/banxaas FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bisq FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bisq-v2 FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitcoin-voucher-bot-p2p FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitcoinvn FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitfinex FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitika FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitrefill FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitspenda FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitstack FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bitstamp FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bittr FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/boltz FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/bull-bitcoin-europe FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/coincards FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/debifi FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/ejara-crypto FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/flash FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/hodlhodl FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/kaleidoswap FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/kraken FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/ln-markets FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/lnp2pbot-v2 FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/moon FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/mostro FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/paymium FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/peach FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/relai-v2 FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/robosats FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/stackinsat FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/swap-market FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/tando FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/thebitcoincompany FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/vexl FAILED
-
- 0 files - 1 errors -
-
-
-
exchange/zeus-swap FAILED
-
- 0 files - 1 errors -
-
-
-
mining/attakai FAILED
-
- 0 files - 1 errors -
-
-
-
mining/bitaxe FAILED
-
- 0 files - 1 errors -
-
-
-
mining/braiins-mini-miner FAILED
-
- 0 files - 1 errors -
-
-
-
mining/braiins-pool FAILED
-
- 0 files - 1 errors -
-
-
-
mining/canaan-avalon-mini FAILED
-
- 0 files - 1 errors -
-
-
-
mining/canaan-avalon-nano FAILED
-
- 0 files - 1 errors -
-
-
-
mining/nerdaxe-gamma FAILED
-
- 0 files - 1 errors -
-
-
-
mining/nerdminer FAILED
-
- 0 files - 1 errors -
-
-
-
mining/nerdqaxe FAILED
-
- 0 files - 1 errors -
-
-
-
mining/ocean-pool FAILED
-
- 0 files - 1 errors -
-
-
-
mining/public-pool FAILED
-
- 0 files - 1 errors -
-
-
-
node/1ml FAILED
-
- 0 files - 1 errors -
-
-
-
node/alby-hub FAILED
-
- 0 files - 1 errors -
-
-
-
node/amboss FAILED
-
- 0 files - 1 errors -
-
-
-
node/bitcoin-core-linux FAILED
-
- 0 files - 1 errors -
-
-
-
node/bitcoin-core-mac-windows FAILED
-
- 0 files - 1 errors -
-
-
-
node/bitcoin-knots FAILED
-
- 0 files - 1 errors -
-
-
-
node/dojo FAILED
-
- 0 files - 1 errors -
-
-
-
node/lapp FAILED
-
- 0 files - 1 errors -
-
-
-
node/lightning-network-daemon-linux FAILED
-
- 0 files - 1 errors -
-
-
-
node/mynode FAILED
-
- 0 files - 1 errors -
-
-
-
node/nakamochi FAILED
-
- 0 files - 1 errors -
-
-
-
node/neutrino FAILED
-
- 0 files - 1 errors -
-
-
-
node/nostr FAILED
-
- 0 files - 1 errors -
-
-
-
node/phoenixd FAILED
-
- 0 files - 1 errors -
-
-
-
node/raspiblitz FAILED
-
- 0 files - 1 errors -
-
-
-
node/rgb FAILED
-
- 0 files - 1 errors -
-
-
-
node/rgb-cli FAILED
-
- 0 files - 1 errors -
-
-
-
node/ride-the-lightning FAILED
-
- 0 files - 1 errors -
-
-
-
node/rln FAILED
-
- 0 files - 1 errors -
-
-
-
node/ronin-dojo FAILED
-
- 0 files - 1 errors -
-
-
-
node/ronin-dojo-v2 FAILED
-
- 0 files - 1 errors -
-
-
-
node/start9 FAILED
-
- 0 files - 1 errors -
-
-
-
node/thunderhub FAILED
-
- 0 files - 1 errors -
-
-
-
node/umbrel FAILED
-
- 0 files - 1 errors -
-
-
-
node/umbrel-lnd FAILED
-
- 0 files - 1 errors -
-
-
-
node/umbrel-nostr FAILED
-
- 0 files - 1 errors -
-
-
-
node/watch-tower FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/ashigaru-ricochet FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/ashigaru-stonewall FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/ashigaru-stonewall-x2 FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/ashigaru-stowaway FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/ashigaru-terminal FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/ashigaru-whirlpool FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/bitfeed FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/blockstream-explorer FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/coin-control FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/joinmarket FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/joinstr FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/mempool-space FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/payjoin FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/paynym-bip47 FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/remix-whirlpool FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/sparrow-stonewall FAILED
-
- 0 files - 1 errors -
-
-
-
privacy/utxo-labelling FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/alby FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/alby-go FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/aqua FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/arkadeos FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/ashigaru FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bacca FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/backup-mnemonic FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bip-85 FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bitbanana FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bitbox02 FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bitcoin-keeper FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bitcoin-keeper-inheritance FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bitkey FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bitkit FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blink FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blitz-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blixt FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blockstream-app-desktop FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blockstream-app-liquid FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blockstream-app-onchain FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blockstream-app-watch-only FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blockstream-green-2FA FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/blue-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/breez FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/bull-bitcoin FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/cake-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/cashu-me FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/coin-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/coldcard-co-sign FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/coldcard-mk4 FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/coldcard-q FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/coldcard-q-advanced FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/coldcard-q-key-teleport FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/dana-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/electrum FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/electrum-airgap FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/electrum-opreturn FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/envoy FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/fedi FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/frostsnap FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/generate-mnemonic-phrase FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/ginger-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/heritage FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/inheritance-plan FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/jade FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/jade-diy-lilygo FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/jade-electrum FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/jade-plus-green FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/jade-plus-sparrow FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/krux FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/ledger FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/ledger-flex FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/ledger-nano-s-plus FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/liana FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/lipa FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/macadamia FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/machankura-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/minibits-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/misty-breez FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/muun FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/ninja-safu FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/nunchuk FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/opendime FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/passphrase FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/passphrase-ledger FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/passport FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/phoenix FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/portal FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/proton-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/recovery-test FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/rumble-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/samourai-recover FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/satochip FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/satodime FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/satscard FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/satsmobi FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/seedkeeper FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/seedkeeper-seedsigner FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/seedsigner FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/seedsigner-passphrase FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/seedsigner-satochip FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/sentinel FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/silentium FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/sparrow FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/sparrow-multisig FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/specter-desktop FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/specter-diy FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/speed-wallet FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/stashpay FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/tapsigner FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/trezor-model-one FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/trezor-passphrase FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/trezor-safe-3 FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/trezor-safe-5 FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/trezor-shamir-backup FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/wallet-of-satoshi FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/wasabi FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/zeus-embedded FAILED
-
- 0 files - 1 errors -
-
-
-
wallet/zeus-embedded-advanced FAILED
-
- 0 files - 1 errors -
-
-
-
-
- - -
- - - - diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 0af155255aa..00000000000 --- a/docs/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# Bitcoin Educational Content Repo Documentation - -This documentation aims to facilitate efficient and effective participation in the Bitcoin Educational Content Repository, ensuring the availability of high-quality, accessible educational resources on Bitcoin. -It covers the following aspects: - -- [Purpose](#purpose) -- [Supported Languages](#supported-languages) -- [Repository Structure](#repository-structure) -- [Expected Format for Different Types of Content](#expected-format-for-different-types-of-content) -- [How to Contribute](#how-to-contribute) - -## Purpose - -The Bitcoin Educational Content Repo is designed to serve as a centralised hub for high-quality educational resources related to Bitcoin. Its key objectives include: - -- Facilitating understanding and adoption of Bitcoin technology. -- Providing access to a wide range of educational materials, software, and hardware recommendations. -- Enabling contribution and collaboration from a diverse community of users and developers. -- Breaking down language barriers to ensure accessibility and inclusivity. For a list of supported languages, refer to the [Supported Languages](#supported-languages) section below. - -## Supported Languages - -Thanks to our contributors, the Plan ₿ Academy content is available in many languages and we are able to keep adding new languages. - -To see which languages are already available, just browse our [website](https://planb.academy) and switch to your preferred language. - -If you want to contribute to any existing content by proofreading it to improve translation quality or you want to add a missing language, please read our [Readme file](https://github.com/PlanB-Network/bitcoin-educational-content?tab=readme-ov-file#how-can-you-contribute) dedicated section, have a look to our [Content Translation & Proofreading Dashboard](https://github.com/orgs/PlanB-Network/projects/3) and engage with us in the [Telegram Content Builder Group](https://t.me/PlanBNetwork_ContentBuilder) - -## Repository Structure - -The repository is organised according to the following hierarchy: - -- **[Courses](./course_documentation.md)**: Course content are stored here and must respect some format specification -- **Professors**: Each professor has a profile with relevant data. You can find complete specifications about professors in the [Plan ₿ Academy Template Repo](/PBN-template-repo/professors/) folder with examples and comments. Professors are identifiead through content by using the associated [Plan₿-UID](./planb-uid.md). -- **Resources**: Five type of resources (book, builder,conference, glossary and podcast) are stored here. You can find complete specifications about each of them in the corresponding subfolder in [Template Repo](/PBN-template-repo/professors/) folder with examples and comments. -- **Scripts**: Scripts are used to perform specific action or to automate some tasks. Usually Plan ₿ Academy staff will use them or ask contributors to use them only if needed. -- **Tutorials**: Tutorials are stored in categories and must respect some format specification - - -Here's a quick visual representation of repository structure: - -``` -bitcoin-educational-content/ -│ -├── courses/ -│ ├── course101/ -│ └── ... -| -├── professors/ -│ ├── professor/-eg -│ └── ... -│ -├── resources/ -│ ├── bet/ -│ ├── books/ -│ │ ├── book-eg/ -│ │ └── ... -│ ├── builders/ -│ │ ├── builder-eg/ -│ │ └── ... -│ ├── conference/ -│ │ ├── conference-eg/ -│ │ └── ... -│ └── glossary/ -│ │ ├── word-eg/ -│ │ └── ... -│ └── podcasts/ -│ ├── podcast-eg/ -│ └── ... -│ -├── scripts/ -│ └── ... -| -├── tutorials/ -│ ├── exchange/ -│ │ ├── tutorial101/ -│ │ └── ... -│ ├── merchant/ -│ ├── mining/ -│ ├── node/ -│ ├── other/ -│ └── privacy/ -│ └── wallet/ - -``` - -## Expected Format for Different Types of Content - -As mentioned in Repository Structure paragraph, each type of content has its own format specifications. -Detailed templates for each type are provided in the folder [template Repo](/PBN-template-repo/) where you can find full examples of folder and files structure and extensive comments inside all files to understand how to properly format your content. - - -## How to Contribute - -Contributions to the Bitcoin Educational Content Repo are encouraged and welcome. - -As we believe in the [value-4-value model](./value-4-value-model.md), we are always looking for contributors that are willing to review content in exchange of some sats (and knowledge). - -If you want to contribute please read our [Readme file](https://github.com/PlanB-Network/bitcoin-educational-content?tab=readme-ov-file#how-can-you-contribute) dedicated section, have a look to our [Content Translation & Proofreading Dashboard](https://github.com/orgs/PlanB-Network/projects/3) and engage with us in the [Telegram Content Builder Group](https://t.me/PlanBNetwork_ContentBuilder) and be part of the Plan ₿ Academy Content Builders 🤙 - ---- - diff --git a/docs/agents/AGENTS.md b/docs/agents/AGENTS.md new file mode 100644 index 00000000000..bd96573d6bc --- /dev/null +++ b/docs/agents/AGENTS.md @@ -0,0 +1,117 @@ +# Bitcoin Educational Content — Agent Orientation + +> Open-source Bitcoin education platform. 14 content types, 31 languages, ~2,600 content items. +> Machine-readable registry: `content-types.yml` (at repo root). Always load it first. + +## Content Types (from content-types.yml) + +| Type | Path Pattern | Metadata | Has Markdown | +|------|-------------|----------|--------------| +| course | `courses/{id}/` | `course.yml` | `{lang}.md` | +| tutorial | `tutorials/{category}/{id}/` | `tutorial.yml` | `{lang}.md` | +| professor | `professors/{id}/` | `professor.yml` | — (YML content) | +| event | `events/{id}/` | `event.yml` | — | +| bet | `resources/bet/{id}/` | `bet.yml` | — (YML content) | +| book | `resources/books/{id}/` | `book.yml` | — (YML content) | +| channel | `resources/channels/{id}/` | `channel.yml` | — | +| conference | `resources/conferences/{id}/` | `conference.yml` | — | +| glossary | `resources/glossary/{id}/` | `word.yml` | `{lang}.md` | +| movie | `resources/movies/{id}/` | `movie.yml` | — | +| newsletter | `resources/newsletters/{id}/` | `newsletter.yml` | — | +| podcast | `resources/podcasts/{id}/` | `podcast.yml` | — | +| project | `resources/projects/{id}/` | `project.yml` | — (YML content) | +| paper | `resources/papers/{id}/` | `paper.yml` | — | + +## bec CLI + +Installed: `pip install -e scripts/bec`. All commands support `--json` output. + +``` +bec validate # Validate single content item +bec validate --all # Validate entire repo (--courses-only, --tutorials-only, --type ) +bec validate --all --json # Machine-readable validation output +bec new course --id btc201 ... # Scaffold new course +bec new tutorial --category wallet --id my-tuto --lang en +bec new professor --id username +bec new event --id event-2025 +bec new resource --type book --id book-name +bec add part --course btc101 --lang en --title "Part Title" +bec add chapter --course btc101 --lang en --title "Chapter Title" +bec add quiz --course btc101 --chapter-id +bec add language --path courses/btc101 --lang fr +bec proofread update --path courses/btc101 --lang fr --contributor user +bec proofread reward --path courses/btc101 +bec proofread status --path courses/btc101 +bec report translation|images|video|proofreading|analytics +bec report --all --output docs/reports/ +bec agent-setup # Symlink orientation files to repo root +``` + +Exit codes: 0 = success, 1 = error, 2 = warning-only. +Omit required args → interactive prompts with enum choices. + +## Content Conventions + +### Course IDs +Format: `{discipline}{number}` — 3-letter discipline code + 3-digit number. +Discipline codes: btc, biz, csv, cyp, dev, eco, ene, his, lnp, min, net, phi, pos, pro, scu, sid, soc. +Number ranges: 101–199 beginner, 200–299 intermediate, 300–399 advanced, 400–499 expert. +Example: `btc101`, `lnp201`, `min301`. + +### Course Markdown Structure +```markdown +--- +name: Course Name +goal: One-line goal +objectives: + - Objective 1 +--- ++++ + +# Part Title + +uuid-here + +## Chapter Title + +uuid-here + +Content here... +``` + +- Parts: `+++` separator, then `# heading`, then `uuid` +- Chapters: `## heading`, then `uuid` +- Chapter IDs: UUIDs (e.g., `27e3fb60-4b50-556b-9e70-c4f5475c121d`) — the LMS importer requires valid UUIDs +- Quizzes: `courses/{id}/quizz/{nnn}/question.yml` + `{lang}.yml` + +### Tutorial Categories +business, computer-security, contribution, exchange, mining, node, privacy, wallet. + +### Images +- Location: `assets/` within content folder +- Translated images: `assets/{lang}/` subfolder +- Language-neutral: `assets/no-txt/` subfolder +- Format: WebP only (`.webp`) +- Thumbnails: `assets/thumbnail.webp` + +### Languages +31 supported codes. See `content-types.yml → languages` for full list. +Language-specific files: `{lang}.md` (markdown) or `{lang}.yml` (YAML content). + +### Proofreading +Tracked in metadata YAML under `proofreading:` key. Each language entry has: +`last_contribution_date`, `urgpiority` (0–5), `contributors_id` (list), `reward` (calculated). + +## Validation + +JSON Schema Draft 7 files in `schemas/` (symlink to `scripts/bec/src/bec/schemas/`). +Always run `bec validate ` after modifying content. Run `bec validate --all` for full repo check. + +## Common Pitfalls + +- YAML dates: Must stay as strings, not Python datetime objects +- Course markdown: Frontmatter must have `name`, `goal`, `objectives` +- Tags: Must be from the 52 valid tags in `content-types.yml` +- Image format: Only `.webp` — other formats will fail validation +- Quiz numbering: Zero-padded 3-digit folders (001, 002, ...) +- Part/chapter order: Parts are `#`, chapters are `##` — never skip levels diff --git a/docs/agents/CLAUDE.md b/docs/agents/CLAUDE.md new file mode 100644 index 00000000000..8d78c5fb702 --- /dev/null +++ b/docs/agents/CLAUDE.md @@ -0,0 +1,66 @@ +# Claude Code — Bitcoin Educational Content + +> Read `docs/agents/AGENTS.md` for full repo orientation (content types, conventions, CLI reference). +> Load `content-types.yml` at repo root for machine-readable content type registry. + +## Quick Start + +```bash +# Activate bec CLI +source scripts/bec/.venv/bin/activate # or: pip install -e scripts/bec + +# Validate before committing +bec validate # Single item +bec validate --all --json # Full repo, machine-readable + +# Scaffold new content +bec new course --id btc201 --topic bitcoin --level intermediate --lang en --professor-id +bec new tutorial --category wallet --id my-tuto --lang en + +# Add structure to courses +bec add part --course btc101 --lang en --title "Part Title" +bec add chapter --course btc101 --lang en --title "Chapter Title" + +# Reports +bec report --all --output docs/reports/ +``` + +## Workflow + +1. **Before editing**: Read the content's metadata YAML and understand its schema +2. **After editing**: Run `bec validate ` — exit code 0 means valid +3. **Before committing**: Run `bec validate --all` for full repo check +4. **Scaffolding**: Always use `bec new` — it generates UUIDs, correct structure, and proofreading metadata + +## Rules + +- All YAML metadata must conform to JSON schemas in `schemas/` +- Course IDs follow `{discipline_code}{level_number}` format (e.g., btc101) +- Images must be `.webp` format +- Do not manually create UUIDs — let `bec new` generate them +- Do not manually create chapter IDs — let `bec add chapter` generate BIP39 words +- Keep `content-types.yml` as single source of truth for valid types, categories, tags, languages +- Proofreading metadata is managed via `bec proofread` — do not edit manually + +## Project Structure + +``` +content-types.yml # Content type registry (load first) +schemas/ # JSON Schema Draft 7 files (symlink) +courses/ # Course content +tutorials/ # Tutorial content (by category) +professors/ # Professor profiles +events/ # Events +resources/ # Books, podcasts, channels, etc. +scripts/bec/ # bec CLI package +docs/agents/ # Agent orientation files +docs/reports/ # Generated HTML reports +``` + +## Commit Convention + +- `feat:` new content or features +- `fix:` corrections to existing content +- `chore:` maintenance, tooling updates +- `docs:` documentation changes +- Content commits: include content type and ID (e.g., `feat(btc101): add chapter 5`) diff --git a/docs/agents/PRD.md b/docs/agents/PRD.md new file mode 100644 index 00000000000..2d252a47300 --- /dev/null +++ b/docs/agents/PRD.md @@ -0,0 +1,239 @@ +## Problem Statement + +The Bitcoin Educational Content repository (2,600+ content items across 14 types, 30 languages) has solid infrastructure — 23 JSON schemas, validation scripts, translation pipeline — but it is poorly discoverable for AI agents and automation. An agent entering the repo has no orientation file, no unified CLI, and must crawl scattered scripts to understand the data model. Documentation is partially stale and human-only. Scripts lack proper CLI interfaces (no argparse, hardcoded paths, interactive-only prompts). There is no machine-readable registry mapping content types to their schemas, paths, and conventions. + +As agents increasingly become the primary content creators and maintainers, the repo needs to be refactored for **agent-first discoverability** without changing any content. + +## Solution + +Create a unified Python CLI tool (`bec` — bitcoin-education-content) that is the single entry point for all repo operations: validation, content scaffolding, atomic content operations, proofreading management, and report generation. Support this with a machine-readable content registry (`content-types.yml`), agent orientation files (`AGENTS.md`, `CLAUDE.md`), and a clean repo structure with obsolete scripts and docs removed. + +The `bec` CLI is installed as a proper Python package (`pip install -e .`) and replaces all existing scattered scripts with a single, consistent interface. + +## User Stories + +1. As an AI agent, I want to read a single orientation file (`AGENTS.md`) so that I understand the repo structure, conventions, and available operations in under 30 seconds +2. As an AI agent, I want to run `bec --help` so that I discover all available operations without crawling the codebase +3. As an AI agent, I want to read `content-types.yml` so that I understand every content type, its path pattern, schema, and metadata file in a machine-parseable format +4. As an AI agent, I want to run `bec validate courses/btc101` so that I verify my changes are valid before committing +5. As an AI agent, I want to run `bec validate --all` so that I check the entire repo for errors in a single command +6. As an AI agent, I want to run `bec validate --all --json` so that I get machine-readable validation results I can parse and act on +7. As a contributor, I want to run `bec new course` so that a valid course skeleton is generated with correct folder structure, UUID, and metadata +8. As a contributor, I want to run `bec new tutorial --category wallet` so that I get a valid tutorial skeleton in the right directory +9. As a contributor, I want to run `bec new resource --type book` so that I get a valid resource skeleton without needing to know the schema +10. As a contributor, I want to run `bec new professor` so that a professor folder is created with a valid UUID and correct YAML structure +11. As a contributor, I want to run `bec new event` so that an event folder is created with valid metadata +12. As an AI agent, I want to run `bec add part --course btc101 --lang en --title "New Part"` so that a properly formatted part separator and heading are appended to the course markdown +13. As an AI agent, I want to run `bec add chapter --course btc101 --lang en --title "New Chapter"` so that a chapter heading with auto-generated BIP39 chapterId is added to the course +14. As an AI agent, I want to run `bec add quiz --course btc101 --chapter-id ` so that a quiz folder skeleton is created with question.yml and a language file +15. As an AI agent, I want to run `bec add language --path courses/btc101 --lang fr` so that a new language markdown file is created with the correct frontmatter structure copied from the original language +16. As a maintainer, I want to run `bec proofread update --path courses/btc101 --lang fr --contributor github-user` so that proofreading metadata is updated programmatically without interactive prompts +17. As a maintainer, I want to run `bec proofread reward --path courses/btc101` so that proofreading rewards are calculated and displayed +18. As a maintainer, I want to run `bec proofread batch-add --contributor github-user --lang fr --paths courses/btc101 courses/btc102` so that multiple content items are updated at once +19. As a maintainer, I want to run `bec report --all --output ./reports/` so that all HTML dashboards are regenerated in one command +20. As a monitoring system, I want to run `bec report translation --json` so that I get machine-readable translation coverage data for automated dashboards +21. As a maintainer, I want to run `bec report proofreading` so that a self-contained HTML dashboard is generated with matrix views, language stats, and leaderboards +22. As a maintainer, I want to run `bec report video` so that YouTube/PeerTube deployment status is analyzed across all courses +23. As a maintainer, I want to run `bec report analytics` so that course structure statistics (word counts, chapters, parts) are generated +24. As a contributor, I want to run `bec agent-setup` so that AGENTS.md and CLAUDE.md are symlinked to the repo root for my AI agent to discover +25. As a contributor, I want to run `pip install -e .` once and have all `bec` dependencies installed automatically +26. As an AI agent, I want `bec new course` to auto-generate a UUID so that I don't need to figure out UUID generation myself +27. As an AI agent, I want `bec new course` to present valid enum choices (topic, subtopic, level) so that I pick from valid values instead of guessing +28. As an AI agent, I want `bec validate` to return exit code 0 (pass), 1 (errors), or 2 (warnings) so that I can use it in automated workflows +29. As a maintainer, I want the tag list with descriptions to live inside the JSON schemas so that there is a single source of truth for valid tags +30. As an AI agent, I want `content-types.yml` to include discipline codes and course ID conventions so that I can generate valid course IDs like BTC101 without reading separate documentation + +## Implementation Decisions + +### `bec` CLI Architecture + +- Single Python package with `pyproject.toml`, installed via `pip install -e .` +- Entry point: `bec` command registered as a console script +- Framework: `click` for subcommands, `--help`, option types, and prompts +- All commands support both interactive mode (prompts when args missing) and non-interactive mode (all values as CLI args) +- All commands that produce output support `--json` flag for machine-readable output +- All commands return proper exit codes (0 = success, 1 = error, 2 = warning) + +### Package Structure + +``` +scripts/bec/ + pyproject.toml + src/bec/ + __init__.py + cli.py # click group, entry point + commands/ + __init__.py + validate.py # rewrite of validation-format/ + new.py # content scaffolding + add.py # atomic operations (part, chapter, quiz, language) + proofread.py # rewrite of proofreading-metadata/ + report.py # rewrite of 5 report generators + agent_setup.py # symlink AGENTS.md/CLAUDE.md + lib/ + __init__.py + schema.py # load JSON schemas, validate data against them + content_types.py # parse content-types.yml registry + yaml_utils.py # safe YAML read/write with date handling, null cleanup + repo.py # find repo root, resolve paths, detect content types + markdown.py # frontmatter parsing, heading manipulation, chapterId generation +``` + +### `content-types.yml` + +- Lives at repo root +- Single fat file (~1,400 tokens) containing: + - All 14 content type definitions (path patterns, metadata files, schemas, content schemas, examples) + - Tutorial categories list + - Course discipline codes with hierarchy + - Course level ranges (101-499) + - Tag names list (52 items, names only — descriptions in JSON schemas) + - Supported languages list +- Consumed by: `bec` CLI, `AGENTS.md`, validation logic +- The `bec` CLI reads `content-types.yml` at startup to know all content types — replaces the hardcoded `CONTENT_TYPES` dict in current `validate.py` + +### `schemas/` Symlink + +- Symlink at repo root: `schemas/ -> scripts/validation-format/schemas/` +- Makes schemas discoverable at top level +- `content-types.yml` references schemas as `schemas/course-scheme.json` +- After `bec` absorbs validation logic, the canonical schema location becomes `scripts/bec/src/bec/schemas/` and the root symlink points there instead + +### Validation Rewrite (`bec validate`) + +- Absorbs logic from `scripts/validation-format/validate.py` (~900 lines) and `validate_all.py` (~240 lines) +- Core logic preserved: JSON Schema Draft 7 validation, YAML metadata, markdown frontmatter, content rules, quiz validation +- Reads content type configs from `content-types.yml` instead of hardcoded dict +- Shared utilities extracted to `lib/` (schema loading, YAML handling, repo root detection) +- Outputs: colored terminal, JSON, and HTML report +- `bec validate ` — single folder +- `bec validate --all` — all content +- `bec validate --all --courses-only` / `--tutorials-only` / `--type resources/books` +- `bec validate --all --json` — machine-readable output +- `bec validate --all --summary-only` — hide individual errors + +### Content Scaffolding (`bec new`) + +- `bec new course` — creates `courses/{id}/` with `course.yml` + `{lang}.md` +- `bec new tutorial` — creates `tutorials/{category}/{id}/` with `tutorial.yml` + `{lang}.md` +- `bec new professor` — creates `professors/{id}/` with `professor.yml` +- `bec new event` — creates `events/{id}/` with `event.yml` +- `bec new resource --type {book|podcast|...}` — creates `resources/{type}/{id}/` with `{type}.yml` +- All commands read JSON schemas for required fields and valid enum values +- All commands auto-generate UUIDs +- Non-interactive mode: `bec new course --id btc201 --topic bitcoin --level intermediate --lang en --professor-id ` +- Interactive mode: prompts for each required field with enum choices when no args provided +- Generated markdown files contain `TODO` placeholders in frontmatter (name, goal, objectives) +- Optional fields present but commented out — validation cleans up unused optional fields + +### Atomic Operations (`bec add`) + +- `bec add part --course --lang --title "Part Title"` — appends `+++\n\n# Part Title` to course markdown +- `bec add chapter --course --lang --title "Chapter Title"` — appends `## Chapter Title\n\nthree-bip39-words` with auto-generated BIP39 chapter ID +- `bec add quiz --course --chapter-id ` — creates next numbered `quizz/{nnn}/` folder with `question.yml` + `{lang}.yml` skeleton +- `bec add language --path --lang ` — creates new language file by copying structure (frontmatter fields, headings, chapterIds) from original language, with `TODO` content placeholders + +### Proofreading Rewrite (`bec proofread`) + +- Absorbs logic from `scripts/proofreading-metadata/` (5 Python files) +- `bec proofread update --path --lang --contributor ` — adds contributor to proofreading metadata, updates date +- `bec proofread reward --path ` — calculates and displays reward amounts +- `bec proofread batch-add --contributor --lang --paths ...` — bulk operation +- `bec proofread status --path ` — shows proofreading status for all languages +- All commands work non-interactively (argparse) — no dependency on `inquirer` +- Interactive mode available as fallback when no args provided + +### Reports Rewrite (`bec report`) + +- Absorbs and rewrites 5 report generators as first-class `bec` subcommands +- `bec report --all` — generates all reports +- `bec report translation` — markdown translation coverage (replaces md_translation_overview) +- `bec report images` — image translation progress (replaces image_translation_overview) +- `bec report video` — video deployment status (replaces video_deployment_overview) +- `bec report proofreading` — proofreading dashboard (replaces proofreading_report) +- `bec report analytics` — course structure statistics (replaces course_report) +- All reports support `--output ` (default: `docs/reports/`) +- All reports support `--json` for machine-readable output alongside HTML +- Designed for scheduled/automated execution (proper exit codes, no interactive prompts) + +### Agent Orientation Files + +- `docs/agents/AGENTS.md` — cross-agent standard, comprehensive orientation: + - Repo purpose and context + - Content type overview (references content-types.yml) + - `bec` CLI command reference + - Content conventions (course IDs, BIP39 chapter IDs, professor UIDs, image formats) + - Formatting rules (heading hierarchy, `+++` part separators, `` tags) + - Validation workflow + - Translation/i18n patterns + - Common pitfalls +- `docs/agents/CLAUDE.md` — Claude Code-specific: + - Imports AGENTS.md context + - Tool permissions + - Slash command hints +- `bec agent-setup` creates symlinks at repo root (both gitignored) + +### Tag Descriptions in Schemas + +- Move the 52 tag descriptions from `docs/50-planb-tags.md` (being deleted) into the JSON schema files +- Add descriptions as comments or a description field in the schema's tags enum +- Tags validated by schema, descriptions available for agents that read schemas + +### Cleanup — `to_delete/` lifecycle + +The repo uses a `to_delete/` staging folder. Items are moved there during the refactor, then permanently deleted by the maintainer. + +**Already in `to_delete/` (moved before PRD implementation begins):** +- `to_delete/scripts/` — 40 items: all old scripts, one-off fixes, empty directories, report generators, PDF tools, etc. +- `to_delete/docs/` — 12 items: stale README, templates, tag lists, category docs, course docs, planning artifacts (GITHUB_ISSUES.md, SCRIPTS_AUDIT.md) + +**To be moved to `to_delete/` during PRD implementation:** +- `scripts/validation-format/` — after its logic is fully absorbed into `bec validate` and tests pass +- `scripts/proofreading-metadata/` — after its logic is fully absorbed into `bec proofread` and tests pass +- `scripts/auto-translate/` — NOT moved (out of scope, stays as-is until translation PRD) +- `docs/agents/REFACTOR_PLAN.md` — working document, superseded by this PRD once implemented +- `docs/how-to-translate-image.md` — evaluate if still needed or absorbed into AGENTS.md +- Old `.venv/` at repo root — replaced by `bec` package's own venv + +**Final cleanup (done by maintainer after all `bec` commands verified working):** +- Delete `to_delete/` entirely +- Delete absorbed `scripts/validation-format/` and `scripts/proofreading-metadata/` +- The `schemas/` directory moves from `scripts/validation-format/schemas/` to `scripts/bec/src/bec/schemas/` (root symlink updated to point to new location) +- Data from deleted docs (tags → JSON schemas, discipline codes → content-types.yml, formatting rules → AGENTS.md) is already absorbed before deletion + +## Testing Decisions + +Good tests verify external behavior through the CLI interface, not internal implementation details. Tests should run `bec` subcommands and check outputs, exit codes, and generated files. + +### What to test + +- **`bec validate`**: Run against known-good content (should pass), known-bad content fixtures (should fail with specific errors), and edge cases (empty folders, missing files). Verify exit codes and JSON output format. +- **`bec new`**: Run each scaffolding command, verify generated folder structure, YAML validity against schema, UUID format, and that generated content passes basic validation structure (even with TODO placeholders). +- **`bec add`**: Run atomic operations on test content, verify markdown structure (correct heading levels, chapterId format, part separators), and that the modified content still has valid structure. +- **`bec proofread`**: Run against test YAML fixtures, verify proofreading metadata is correctly added/updated, dates are set, contributors are appended not overwritten. +- **`bec report`**: Run against test content fixtures, verify HTML output is generated, JSON output parses correctly, and report data matches expected values. + +### Test infrastructure + +- Use `pytest` as the test runner +- Test fixtures: minimal content folders in `tests/fixtures/` with known-valid and known-invalid content +- Integration tests: run `bec` commands via `click.testing.CliRunner` (in-process, no subprocess needed) +- Schema tests: verify all 23 JSON schemas are valid JSON Schema Draft 7 +- `content-types.yml` test: verify all referenced schemas exist, all example paths exist in repo + +## Out of Scope + +- **Translation rewrite**: The `auto-translate/` pipeline (LLM-Translator integration, DeepL/OpenAI/Google providers, glossary management) is explicitly deferred to a follow-up PRD. `bec translate` is not implemented in this PRD. +- **Content changes**: No courses, tutorials, resources, events, or professor content is modified. +- **New content types**: No new content types are added. +- **CI/CD pipeline**: While `bec` supports JSON output and exit codes suitable for CI/CD, setting up GitHub Actions or similar is not part of this PRD. +- **GUI tools**: The `tutorial-related/data-creator/` GUI is not rewritten or replaced. +- **Website/API changes**: The PlanB Network application is not modified. + +## Further Notes + +- The existing `scripts/validation-format/schemas/` directory contains 23 well-maintained JSON Schema Draft 7 files that represent significant prior work. The `bec` rewrite preserves these schemas exactly — it changes how they are loaded and used, not the schemas themselves. +- The repo contains 2,600+ content items: 48 courses, 316 tutorials, 93 professors, 287 events, and ~1,900 resources across 11 subtypes. Any scaffolding or validation changes must be tested against this scale. +- The `content-types.yml` file is approximately 1,400 tokens and is designed to be auto-loaded by agents. This is intentionally compact to minimize context window cost. +- The `bec` package should have minimal dependencies. Core: `click`, `jsonschema`, `pyyaml`, `python-frontmatter`. Reports: `tqdm` (progress bars). No dependency on `inquirer`, `customtkinter`, or other heavy UI libraries. +- BIP39 chapter IDs (3-word identifiers like `father-loop-frog`) require a BIP39 wordlist. This should be bundled as a data file in the package, not fetched at runtime. diff --git a/docs/course_ID_rules.md b/docs/course_ID_rules.md deleted file mode 100644 index 235655b805a..00000000000 --- a/docs/course_ID_rules.md +++ /dev/null @@ -1,47 +0,0 @@ -# Rules - -- the nomenclature of courses is the following: - - 3 uppercase letter to refer to a discipline (see [list of discipline](#Discipline List)) - - 3-digit number to evaluate the difficulty of the course like the university system (the delineation helps ensure appropriate skill levels match course content): - - Courses numbered from 101 to 199 are tailored for beginners - - Those marked from 201 to 299 cater to intermediate learners - - Advanced learners can delve into courses numbered from 301 to 399 - - For developers there are specialized courses ranked from 401 to 499 . - - NOTE: the two last digits are mainly used to index the course but keep in mind that a course with a higher index will most likely represent a higher level of difficulty. This evaluation is left to the author of the course. - - -# Discipline List - -- Bitcoin - - Bitcoin - BTC -- Business - - Business - BIZ - - Accounting - ACC - - Data Analysis - DAT - - Entrepreneurship - ENT - - Finance - FIN - - Point of Sales - POS -- Mining - - Mining - MIN - - Energy - ENR -- Protocol - - Protocol - PRO - - Client Side Validation - CSV - - Script - SCR - - SideChain - SID - - Lightning Network Protocol - LNP - - Network - NET -- Security - - Security - SCU - - Cryptography - CYP - - Cybersecurity - CYB -- Sociology - - Sociology - SOC - - Economy - ECO - - History - HIS - - Philosophy - PHI -- Sovereignty - - Sovereignty - SOV - - Do It Yourself - DIY - - Open-Source - OPE - - Learning Machine - LEA diff --git a/docs/course_documentation.md b/docs/course_documentation.md deleted file mode 100644 index 66d232fd3e3..00000000000 --- a/docs/course_documentation.md +++ /dev/null @@ -1,157 +0,0 @@ -# Introduction - -A course is a structured educational content designed to extensively teach a specific subject matter. In this documentation, we outline the required formatting that all courses must respect -- adhering to these formatting guidelines ensures consistency and clarity across all courses as well as it allows a smoother and more efficient translation and proofreading process. - -The name of folder for any course represents its unique identifier known as the course ID. It is essential that the course ID follows specific rules which are detailed in this [documentation page](./course-id-rules.md). - -This folder has a specific structure that is extensively detailed in the next section. - -You can find complete specifications about courses in the [Plan ₿ Academy Template Repo](/PBN-template-repo/courses/) folder with full examples of folder and files structure and extensive comments inside all files to understand how to properly format your course. - -# Course Folder Structure - -Any course folder should be placed in the `courses/` folder and respect the following structure: - -``` -courses/ - └── btc101/ - ├── assets/ - │ ├── en/ - │ │ ├── image1.webp - English version of image1 - │ │ └── image2.webp - English version of image2 - │ ├── es/ - │ │ ├── image1.webp - Spanish version of image1 - │ │ └── image2.webp - Spanish version of image2 - │ ├── thumbnail.webp - BTC101's thumbnail - │ └── .. - Indicates additional language-specific folders - ├── course.yaml - English version of the main documentation file - ├── en.md - English version of the btc101 course content - ├── es.md - Spanish version of the btc101 course content - └── .. - Indicates language-specific course files -``` - -As an example, we use here a course named BTC101. The folder "btc101", named after the course, contains: - -- a [yaml file](#yaml-course-file) which describes the course -- several language specific [markdown files](#markdown-course-files) that contain the written courses -- an [assets folder](#assets-course-folder) that contains all the images used in the course -- a quizz folder (optional) containing quiz to make a knowledge self-assessment during the course -- an exam folder (not implemented yet) -- an exercise folder (not implemented yet) -- a teachable folder (not implemented yet) - -**NB.** If you are writing a paid course, you will have to additionally read this [section](#Paid-course) to add some needed metadata specific to this type of content. - -## Yaml course file - -We'll used the file present in the btc101 folder as an example: - -```yaml -level: beginner -hours: 20 - -professors: - - rabbit-hole - -contributors: - - another-satoshi -``` - -This yaml file must contains all the relevant metadata of the course which are defined in [the course yml template](/docs/PBN-template-repo/courses/topic101/course.yml). - -## Markdown course files - -Markdown files contain the written course and must be placed in the course folder. Additionally, since it is language-specific, the language code used to name it will reflect the language used in the file. These files MUST respect precise formatting rules to be properly rendered on our website. You can find full examples in [the course md template](/docs/PBN-template-repo/courses/topic101/en.md). - -Let's start describing a markdown course file. -Here again we'll used the beginning of btc101's `es.md` file as an example: - -```md ---- -name: El recorrido de Bitcoin -goal: Descubrir Bitcoin y sus fundamentos con su propuesta de valor monetario, los mineros, las transacciones y las carteras. -objectives: - - Tener una comprensión general de la tecnología Bitcoin - - Comprender cómo comprar y asegurar sus bitcoins - - Tener una comprensión general de la tecnología Blockchain - - Estar familiarizado con el concepto de la Red Lightning - - Comprender el impacto geopolítico y social que representa Bitcoin ---- - -# Tu primera aventura en Bitcoin - -En este curso, te explicaré Bitcoin en 21 capítulos para que puedas entender esta tecnología de manera simple y efectiva. El curso es accesible para todos y explora la industria en su conjunto: mineros, carteras, plataformas de compra/venta, etc. […] - -+++ - -# Introducción - -## Un salto hacia lo desconocido - -father-loop-frog - -### Bienvenido a un nuevo paradigma monetario y tecnológico. - -Bitcoin es una innovación significativa que va más allá de ser simplemente una "moneda de internet". Es una revolución tanto tecnológica como monetaria, capaz de cuestionar nuestra relación con el dinero y la sociedad. Bitcoin es una moneda "neutral" y "descentralizada", lo que significa que no está bajo el control de ninguna entidad o institución. Es una innovación significativa que va más allá de ser simplemente una divisa. Para comprender bien este famoso bitcoin, es importante entender que bitcoin (llamado BTC) es tanto un protocolo informático (Bitcoin) como una unidad monetaria (bitcoin). […] - -### Un currículo completo pero accesible - -En el marco de este curso, discutiremos los aspectos monetarios de Bitcoin, incluyendo cómo comprar y vender bitcoins, cómo mantenerlos seguros en carteras digitales y cómo utilizarlos para realizar transacciones. También examinaremos el papel de los mineros, que son esenciales para la creación de nuevos bitcoins y la seguridad de la red Bitcoin. Además, nos centraremos en el futuro de Bitcoin y cómo la tecnología de la Red Lightning puede mejorar las transacciones de Bitcoin. - -![image](assets/es/chapter0/4.webp) - -[…] -``` - -The markdown course file is divided in 3 parts. The first two parts would be used to build the presentation page of the course (see this [example](https://planb.academy/courses/2b7dc507-81e3-4b70-88e6-41ed44239966)): - -1. The **Course Header** which contains language-specific metadata and is delimited by `---` and must contain the following properties: - - `name`: define the name of the course - - `goal`: define the main goal of the course - - `objectives`: define a list of skills that would be developped - - NOTE: the yaml properties MUST NOT be translated and MUST remain in English -2. The **Course Description** which is composed of a title defined by the heading (`#`) and a short paragraph to present the course. -3. The **Course Content** which is the proper written course using the [markdown syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). - - The first-level heading will be interpreted as a part of the lecture, the second-level as a chapter and the third-level as a sub-chapter. - - Note that this structure is used to create the outline of the course and that each chapter is rendered on a single page, so try to evaluate a user-friendly (not too short and not too long) length for your chapter. - - On the other hand, parts (resp. subchapters) are used as way to better group similar chapters (resp. split paragraphs on slightly different matter) and could be used to better reflect the intentions of the course - - For further detail on the formatting, please look at the next paragraph [Course formatting rules](#course-formatting-rules). - -### Course formatting rules - -These are the rules for properly formatting course content: - -- 1 course is composed of at least 2 parts and 2 chapters per part - - parts are identified by first level heading `#` - - chapters are identified by second level heading`##` - - sub-part, identified by third level heading `###` are optional but they will be displayed on the syllabus course - - sub-sub-part, identified by forth level heading `####` are optional but _not_ displayed on the syllabus course -- each chapter has a unique ID (see [chapter ID section](#Chapter ID)) -- a course contains a header part, delimited by `---` -- a course contains an introduction part, delimited by `+++` -- if a chapter is provided or written by another teacher, you can mention it via the html tag `contributor-id`. Note that the professor must be present in the [professor folder](../professors/) - -#### Chapter ID - -The **Chapter ID** is a unique identifier for each chapter. This ID is a simply three words from the BIP39 wordlist. You can either generate these ids by yourself by following this [tutorial](./how-to-generate-a-bip39-id.md) or ask to one the PlanB reviewers to do it for you. This ID will allow you to link your chapter with questions present in the [quizz folder](#quizz-folder) of your course, but also let you re-order your chapters after the first publication without breaking our database for Plan ₿ Academy website. - -## Assets course folder - -The `/assets` subfolder contains all the images used in the course. In order to facilitate the maintenance and the potential translation of the images you will use 2 types of folders: - -1. a folder for images without any text, called `no-txt/` -2. a folder per language for images with overlay. This folder will be named by the language code assigned to the corresponding language (eg. `en` or `es` for English or Spanish respectively) - - Although you are free to structure language-specific folders, the structure MUST remain the same between two different language. This rule makes it easier to automatically update the image path in the corresponding markdown file. - - If you create a course, you can simply create image with text in English and later on start the [image translation process](how-to-translate-image.md) - -Additionally in the `assets/` folder a `thumbnail.webp` file is required, it will be used as the course's thumbnail, like the one used for the [btc101 course](https://planb.academy/courses/2b7dc507-81e3-4b70-88e6-41ed44239966). - -## Optional folders within course folder - -### Quizz folder - -TBD - -# Paid Course - -TBD diff --git a/docs/planb-uid.md b/docs/planb-uid.md deleted file mode 100644 index fbc25cbd7c9..00000000000 --- a/docs/planb-uid.md +++ /dev/null @@ -1,12 +0,0 @@ -# Introduction - -In order to link a github user to the same user in the Plan ₿ Academy platform, we have decided to defined a 2-words username as an unified UID. - -# What's a BIP39 ID? - It is an identifier made out of 2 words from bip39 dictionary for identifying contributors or professors like ```another-satoshi``` - -# When do I need one? -You need a BIP39 ID if you are a professor or a contributor (like a proofreader, a translator or a content creator) of Plan ₿ Academy. - -# How to generate one? -These words are "randomly" picked by Plan ₿ Academy team from the BIP39 wordlists. \ No newline at end of file diff --git a/docs/reports/bec-lms-alignment-2026-06.md b/docs/reports/bec-lms-alignment-2026-06.md new file mode 100644 index 00000000000..41d8d914de4 --- /dev/null +++ b/docs/reports/bec-lms-alignment-2026-06.md @@ -0,0 +1,45 @@ +# BEC ↔ LMS Schema Alignment — 2026-06 + +> Supersedes the 2026-03-18 comparison: all mismatches have been resolved by +> rewriting the bec schemas. Policy: **LMS wins everywhere** (importer parsers > +> Zod > Drizzle). LMS reference: origin/dev @ 9fc35b345. + +## Result + +| Metric (2,677 items) | Before | After | +|---|---|---| +| Passing | 645 | 2,547 | +| With errors | 1,567 | 87 (all genuine content defects) | +| Warnings-only | 465 | 43 | +| bec tests | 405 | 444 | + +## What changed + +87 adversarially-verified schema edits across all 24 schemas: required +fields the importer treats as optional were relaxed, the tag vocabulary opened +(the importer lowercases and upserts any string — tags-definitions.json remains +as documentation), UUID patterns made version-generic, nullable unions added to +match nullable columns, importer-consumed fields documented (course +assignment/pricing fields, tutorial last_update_date/is_archived/test_only, +professor links/tips/contributor_id, conference id/original_language/ +proofreading, event professor/course_related/exam type), and +word-content-scheme flattened to the standard layout (this alone fixed every +glossary markdown false-error). + +Edit distribution: relax required 25, add field 22, remove constraint 12, type change 11, enum change 7, add required 5, allow additional properties 3, other 2. + +44 confirmed CLI code findings were fixed in the same pass — most notably +chapter/part IDs are now UUIDs (the LMS uuidValidates them; the previous BIP39 +scheme would have broken sync for scaffolded chapters). + +The full interactive report (per-edit LMS evidence, verifier corrections, +remaining-error inventory) is generated from the audit data; see the PR +description for the hosted copy. + +## Follow-ups + +- Validate professor translation .yml files (traversal gap; ~2,451 files unchecked) +- Add a schema for assignment.yml (consumed by the LMS importer, unknown to bec) +- `bec lint` candidate: silent-drop link keys ('Github', 'nost', conference 'youtube') +- Fix courses/dev301 `testnet_only` → `test_only` typo (currently silently ignored) +- Content fixes for the 87 invalid items (dominated by unquoted-colon YAML in machine-translated quizzes) diff --git a/docs/reports/bec-vs-lms-schema-comparison.md b/docs/reports/bec-vs-lms-schema-comparison.md new file mode 100644 index 00000000000..5c2b62a048e --- /dev/null +++ b/docs/reports/bec-vs-lms-schema-comparison.md @@ -0,0 +1,447 @@ +# BEC vs LMS Schema Comparison + +> Generated: 2026-03-18 +> BEC repo: `bitcoin-educational-content` (JSON Schema + bec validate) +> LMS repo: `bitcoin-learning-management-system` (Drizzle ORM + Zod + tRPC) + +## Executive Summary + +The BEC content repo defines **14 content types** via JSON Schema Draft 7, validated by `bec validate`. The LMS app defines **24+ database tables** via Drizzle ORM with Zod runtime validation. While both repos cover the same core content types, there are **systematic mismatches** in required/optional field handling that could cause data integrity issues. + +### Key Patterns + +| Pattern | Count | Severity | +|---------|-------|----------| +| BEC requires, LMS nullable | **38** | High | +| BEC optional, LMS required | **5** | Medium | +| BEC field missing in LMS | **12** | Medium | +| LMS field missing in BEC | **~50+** | Expected (app-specific) | +| Structural differences (tags, proofreading, contributors) | **3 systemic** | Architectural | + +### Systemic Architectural Differences + +These affect **every content type** and are by design, not bugs: + +1. **Tags**: BEC stores tags inline as arrays; LMS uses junction tables (`course_tags`, `tutorial_tags`, `resource_tags`). Both enforce the same 51-tag vocabulary. **No action needed.** +2. **Proofreading**: BEC embeds proofreading metadata in each content YAML (required for most types); LMS has a dedicated `content.proofreading` workflow table. **No action needed** (sync logic should handle the mapping). +3. **Sync metadata**: LMS adds `lastCommit`, `lastUpdated`, `lastSync`, `path`, `isArchived` to every content type. These are app-managed, not content-authored. **Expected.** +4. **Contributors**: BEC requires `contributor_names` (GitHub usernames) on many types; LMS generally handles contributors through a separate `proofreading_contributor` table or omits the field entirely. **Potential data loss during sync.** + +--- + +## Per-Content-Type Comparison + +### 1. COURSE + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (varchar) | Type differs: UUID vs varchar(100) | +| `topic` | Required (enum, 8 values) | Required (text) | LMS has no enum constraint | +| `subtopic` | Required (enum, 30+ values) | Required (text) | LMS has no enum constraint | +| `level` | Required (enum: beginner/intermediate/advanced/expert/wizard) | Required (enum: Beginner/Intermediate/Advanced) | **LMS missing: expert, wizard** | +| `hours` | Required (integer, min 1) | Required (double) | Type: integer vs double | +| `professors_id` | Required (UUID array, min 1) | Not on course table | **Missing in LMS table** (likely junction table) | +| `original_language` | Required | Optional (default 'en') | **BEC required, LMS defaulted** | +| `tags` | Required (min 1) | Junction table | Structural | +| `proofreading` | Required (min 1) | Separate table | Structural | +| `name` (content) | Required (1-200 chars) | Required (localized) | Aligned | +| `goal` (content) | Required | Required (localized) | Aligned | +| `objectives` (content) | Required (1-10 items) | Required (text[], localized) | LMS has no max constraint | +| `type` | Optional (theory/practice) | Optional (Theory/Practice) | Aligned | +| `teaching_format` | Optional | Optional | Aligned | +| `format` | Optional (online/inperson/hybrid) | Optional (Online/InPerson) | **LMS missing: hybrid** | +| `published_at` | Optional | Optional (nullable) | Aligned | +| `project_id` | Optional | Optional (nullable) | Aligned | +| `license` | Optional (CC-BY-SA-V4/MIT) | Not in LMS | **Missing in LMS** | +| `videos` | Optional (complex object) | Separate `videos` table | Structural | + +**LMS-only fields**: `index`, `contact`, `isArchived`, `isPlanbSchool`, `hasAssignment`, `assignmentWeight`, `assignmentStartDate`, `assignmentEndDate`, `assignmentDescription`, `passingGradeThreshold`, `areScoresCalculated`, `isAssignmentGradingPublished`, `hasLogo`, `presentationMarkdown`, `paidVideoLink`, `onlinePriceDollars`, `numberOfRating`, `sumOfAllRating`, `remainingSeats` + +--- + +### 2. TUTORIAL + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (UUID) | Aligned | +| `level` | Required (enum) | Required | Aligned | +| `category` | Required (enum, 20 values) | Required | LMS has no enum constraint | +| `professor_id` | **Required** (UUID) | **Nullable** | **BEC required, LMS optional** | +| `license` | **Required** (enum) | **Not in LMS** | **Missing in LMS** | +| `original_language` | **Required** | Optional (default 'en') | **BEC required, LMS defaulted** | +| `proofreading` | Required (min 1) | Separate table | Structural | +| `project_id` | Optional | Optional (nullable) | Aligned | +| `tags` | Optional | Junction table | Structural | +| `credit_link` | Optional (URI) | Optional (nullable) | Aligned | +| `name` (content) | Required (1-200 chars) | Required (varchar 255) | Aligned | +| `description` (content) | Required (max 1000) | **Nullable** | **BEC required, LMS optional** | + +**LMS-only fields**: `subcategory`, `isArchived`, `logoUrl`, `lastUpdateContent` + +--- + +### 3. PROFESSOR + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (UUID) | Aligned | +| `name` | Required | Required (unique) | Aligned | +| `bio` (content) | **Required** | **Nullable** | **BEC required, LMS optional** | +| `short_bio` (content) | **Required** | **Nullable** | **BEC required, LMS optional** | +| `links.twitter` | Optional | Optional (nullable) | Aligned | +| `links.website` | Optional | Optional (nullable) | Aligned | +| `company` | Optional | Optional (nullable) | Aligned | +| `affiliations` | Optional (string[]) | Optional (uuid[]) | **Type: string[] vs uuid[]** | +| `tips.lightning_address` | Optional | Optional (nullable) | Aligned | +| `tips.silent_payment` | Optional | Optional (nullable) | Aligned | +| `tags` | Optional | Junction table | Structural | + +**LMS-only fields**: `githubUrl`, `linkedinUrl`, `nostr`, `lnurlPay`, `paynym`, `tipsUrl` + +--- + +### 4. EVENT + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (UUID) | Aligned | +| `start_date` | Required (datetime) | Required (timestamp) | Aligned | +| `end_date` | Required (datetime) | Required (timestamp) | Aligned | +| `name` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `type` | **Required** (enum: 5 types) | **Nullable** | **BEC required, LMS optional** | +| `description` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `timezone` | **Required** (IANA) | **Nullable** | **BEC required, LMS optional** | +| `address_city_country` | **Required** | **No direct field** (uses addressLine1-3, all nullable) | **BEC required, LMS missing** | +| `language` | **Required** (array, min 1) | Junction table (`event_languages`) | Structural | +| `links.website` | Optional | Optional (nullable) | Aligned | +| `tags` | Required (min 1) | Junction table | Structural | +| `available_seats` | Optional | Optional (nullable) | Aligned | +| `book_online` | Optional | Optional (default false) | Aligned | +| `book_in_person` | Optional | Optional (default false) | Aligned | +| `price_dollars` | Optional | Optional (nullable) | Aligned | +| `project_id` | Optional | Optional (nullable) | Aligned | + +**LMS-only fields**: `rawDescription`, `courseRelated`, `professor`, `liveUrl`, `chatUrl`, `replayUrl`, `assetUrl`, `remainingSeats` + +--- + +### 5. QUIZ + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `chapterId` | Required | Required | Aligned | +| `difficulty` | Required (easy/intermediate/hard/expert) | Required | LMS uses Easy/Medium/Hard (different enum values) | +| `author` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `tags` | **Required** (min 1) | **Not in LMS** | **Missing in LMS** | +| `question` | Required | Required | Aligned | +| `answer` | Required | Required | Aligned | +| `wrong_answers` | **Required (exactly 3)** | Required (text[], **no count constraint**) | **BEC enforces count, LMS doesn't** | +| `explanation` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `reviewed` | **Required** (boolean) | **Not in LMS** | **Missing in LMS** | + +**LMS-only fields**: `duration`, `disabled`, `courseId` (FK) + +--- + +### 6. BOOK + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `author` | Required | Required | Aligned | +| `level` | **Required** (enum) | **Nullable** | **BEC required, LMS optional** | +| `tags` | Required (min 1) | Junction table | Structural | +| `title` (content) | Required | Required | Aligned | +| `publication_year` (content) | **Required** (integer) | **Nullable** | **BEC required, LMS optional** | +| `cover` (content) | **Required** | **Nullable** | **BEC required, LMS optional** | +| `description` (content) | **Required** | **Nullable** | **BEC required, LMS optional** | +| `original` (content) | Required (boolean) | Required (boolean) | Aligned | +| `contributors` (content) | **Required** (UUID[], min 1) | **Not in LMS** | **Missing in LMS** | +| `original_language` | Optional | Optional (default 'en') | Aligned | +| `license` | Optional | Not in LMS | Missing in LMS | + +**LMS-only fields**: `publisher`, `shopUrl`, `downloadUrl`, `translator`, `summaryText`, `summaryContributorId` + +--- + +### 7. CHANNEL (YouTube Channel) + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (UUID) | Aligned | +| `name` | Required | Required | Aligned | +| `language` | Required | Required | Aligned | +| `links.channel` | Required (URI) | Required (`channel`) | Aligned | +| `description` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `links.trailer` | **Optional** | **Required** | **Reversed: BEC optional, LMS required** | +| `license` | **Required** | **Not in LMS** | **Missing in LMS** | +| `contributor_names` OR `contributors` | **Required** (oneOf) | **Not in LMS** | **Missing in LMS** | +| `tags` | Required (min 1) | Junction table | Structural | +| `project_id` | Optional | Optional (nullable) | Aligned | + +--- + +### 8. CONFERENCE + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `name` | Required | Required | Aligned | +| `year` | Required (YYYY-MM) | Required (text) | LMS has no format constraint | +| `location` | Required | Required | Aligned | +| `builder` | **Required** | **Not in LMS** | **Missing in LMS** | +| `language` | **Required** (array, min 1) | **Nullable** (`languages`) | **BEC required, LMS optional** | +| `links.website` | **Required** | **Nullable** (`websiteUrl`) | **BEC required, LMS optional** | +| `links.twitter` | Optional | Optional (nullable) | Aligned | +| `tags` | Required (min 1) | Junction table | Structural | + +**LMS-only fields**: `description`, `projectId`, conference stages + videos + +--- + +### 9. GLOSSARY WORD + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (FK) | Aligned | +| `en_word` | **Required** | **No direct equivalent** (uses `fileName`) | **Mapping unclear** | +| `original_language` | Required | Optional (default 'en') | **BEC required, LMS defaulted** | +| `license` | **Required** | **Not in LMS** | **Missing in LMS** | +| `term` (content) | Required (UPPERCASE pattern) | Required (`term`, no pattern) | **LMS missing pattern constraint** | +| `definition` (content) | Required (markdown) | Required | Aligned | +| `related_words` | Optional | Optional (nullable) | Aligned | +| `tags` | Optional | Junction table | Structural | + +**LMS-only fields**: `shortDefinition` + +--- + +### 10. MOVIE + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (UUID) | Aligned | +| `title` | Required | Required | Aligned | +| `author` | Required | Required | Aligned | +| `language` | Required | Required | Aligned | +| `links.platform` | Required (URI) | Required (`platform`) | Aligned | +| `publication_year` | **Required** (1900-2100) | **Nullable** | **BEC required, LMS optional** | +| `duration` | **Required** (min 1) | **Nullable** | **BEC required, LMS optional** | +| `description` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `links.trailer` | **Optional** | **Required** | **Reversed: BEC optional, LMS required** | +| `contributor_names` | **Required** (min 1) | **Not in LMS** | **Missing in LMS** | +| `license` | **Required** | **Not in LMS** | **Missing in LMS** | +| `tags` | Required (min 1) | Junction table | Structural | + +--- + +### 11. NEWSLETTER + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (FK) | Aligned | +| `title`/`name` | Required (`title`) | Required (`name`) | **Field name differs** | +| `author` | Required | Required | Aligned | +| `language` | Required | Required | Aligned | +| `level` | **Required** (enum) | **Not in LMS** | **Missing in LMS** | +| `publication_date` | **Required** (YYYY-MM-DD) | **Not in LMS** | **Missing in LMS** | +| `link` | **Required** (array, min 1) | **Nullable** (`websiteUrl`) | **BEC required, LMS optional** | +| `description` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `contributor_names` | **Required** (min 1) | **Nullable** (`contributors`) | **BEC required, LMS optional** | +| `license` | **Required** | **Not in LMS** | **Missing in LMS** | +| `tags` | Required (min 1) | Junction table | Structural | +| `project_id` | Optional | Not in LMS | Missing | + +--- + +### 12. PODCAST + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (FK) | Aligned | +| `name` | Required | Required | Aligned | +| `host` | Required | Required | Aligned | +| `language` | Required | Required | Aligned | +| `links.podcast` | Required | Required (`podcastUrl`) | Aligned | +| `description` | **Required** | **Nullable** | **BEC required, LMS optional** | +| `contributor_names` | **Required** (min 1) | **Not in LMS** | **Missing in LMS** | +| `license` | **Required** | **Not in LMS** | **Missing in LMS** | +| `tags` | Required (min 1) | Junction table | Structural | +| `links.twitter` | Optional | Optional (nullable) | Aligned | +| `links.website` | Optional | Optional (nullable) | Aligned | + +**LMS-only fields**: `nostr` + +--- + +### 13. PAPER (Research Paper) + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (UUID) | Aligned | +| `title` | Required (min 1) | Required | Aligned | +| `authors` | Required (array, min 1) | Required (text[]) | Aligned | +| `abstract` | Required (min 10 chars) | Required | LMS has no min constraint | +| `original_language` | Required | Required (`language`) | Aligned | +| `paper_type` | Required (enum, 7 values) | Required (`type`, text) | **LMS has no enum constraint** | +| `pdf_url` | Required (URI) | Required (`paperUrl`) | **Field name differs** | +| `topics` | **Required** (array, min 1) | **Nullable** | **BEC required, LMS optional** | +| `source` | **Optional** | **Required** | **Reversed: BEC optional, LMS required** | +| `publication_date` | Optional | Optional (nullable) | Aligned | +| `type` (SCI/SCIE/SSCI) | Optional (enum) | Not in LMS | Missing | +| `category` | Optional | Not in LMS | Missing | +| N/A | N/A | Required (`bibUrl`) | **LMS requires field not in BEC** | + +--- + +### 14. PROJECT + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (UUID) | Aligned | +| `name` | Required | Required | Aligned | +| `category` | Required (enum, 15 values) | Required (varchar) | LMS has no enum constraint | +| `links` | **Required** (object) | All URL fields nullable | **BEC requires links object, LMS all nullable** | +| `original_language` | **Required** | Optional (default 'en') | **BEC required, LMS defaulted** | +| `tags` | Required (min 1) | Junction table | Structural | +| `proofreading` | Required (min 1) | Separate table | Structural | +| `licence` | Optional | Not in LMS | Missing | +| `contributor_names` | Optional | Not in LMS | Missing | +| Addresses | Optional | Optional (all nullable) | Aligned | + +--- + +### 15. BET (Bitcoin Education Template) + +| Field | BEC | LMS | Mismatch | +|-------|-----|-----|----------| +| `id` | Required (UUID) | Required (FK) | Aligned | +| `type` | Required (enum) | Required (enum) | Aligned | +| `original_language` | Required | Optional (default 'en') | **BEC required, LMS defaulted** | +| `contributor_names` | **Required** (min 1) | **Not in LMS** | **Missing in LMS** | +| `license` | **Required** | **Not in LMS** | **Missing in LMS** | +| `tags` | Required (min 1) | Junction table | Structural | +| `proofreading` | Required (min 1) | Separate table | Structural | +| `links.download` | **Optional** | **Required** (`downloadUrl`) | **Reversed: BEC optional, LMS required** | +| `project_id` | Optional | Optional (nullable) | Aligned | +| `name` (content) | Required | Required (localized) | Aligned | +| `description` (content) | Required | Required (localized) | Aligned | + +--- + +## LMS-Only Content Types (no BEC equivalent) + +| Type | Purpose | +|------|---------| +| **Lab** | Interactive sessions with professor, sessions, student count | +| **Educator Content** | User-generated content with status workflow (Draft/Published) | +| **B-Certificate** | Exam certification with scores and timestamps | +| **Coupon** | Commerce: discount codes for courses/events | +| **Blog** | Blog posts with localized content | +| **Calendar** | Date-based events with localized titles | +| **Legal Documents** | Legal pages (ToS, Privacy) with localized content | +| **Translation Workflow** | Course translation tracking (Todo/InProgress/Done) | + +--- + +## Summary of All Mismatches + +### BEC Required, LMS Nullable/Optional (HIGH PRIORITY) + +These fields pass `bec validate` but could be NULL in the LMS database: + +| Content Type | Field | Risk | +|-------------|-------|------| +| Tutorial | `professor_id` | Tutorials shown without author | +| Tutorial | `description` (content) | Missing descriptions in UI | +| Professor | `bio` (content) | Empty professor profiles | +| Professor | `short_bio` (content) | Empty professor cards | +| Event | `name` | Unnamed events in UI | +| Event | `type` | Uncategorized events | +| Event | `description` | Empty event pages | +| Event | `timezone` | Time display issues | +| Event | `address_city_country` | No location shown | +| Quiz | `author` | Unattributed questions | +| Quiz | `explanation` | No explanations after quiz | +| Book | `level` | No difficulty indicator | +| Book | `publication_year` | Missing metadata | +| Book | `cover` | No cover image | +| Book | `description` | Empty book pages | +| Channel | `description` | Empty channel pages | +| Conference | `language` | No language filter | +| Conference | `links.website` | No website link | +| Movie | `publication_year` | Missing metadata | +| Movie | `duration` | No duration shown | +| Movie | `description` | Empty movie pages | +| Newsletter | `level` | No difficulty indicator | +| Newsletter | `publication_date` | No date shown | +| Newsletter | `link` | No link to newsletter | +| Newsletter | `description` | Empty descriptions | +| Newsletter | `contributor_names` | No attribution | +| Podcast | `description` | Empty podcast pages | +| Paper | `topics` | No topic categorization | +| Glossary | `en_word` | No English reference key | + +### BEC Optional, LMS Required (MEDIUM PRIORITY) + +These fields could be missing in BEC content but LMS expects them: + +| Content Type | Field | Risk | +|-------------|-------|------| +| Channel | `trailer` | Sync fails if no trailer in BEC | +| Movie | `trailer` | Sync fails if no trailer in BEC | +| Paper | `source` | Sync fails if no source in BEC | +| Paper | `bibUrl` | Field doesn't exist in BEC at all | +| BET | `downloadUrl` | Sync fails if no download link in BEC | + +### Missing in LMS (Fields BEC validates but LMS doesn't store) + +| Field | Affected Types | Impact | +|-------|---------------|--------| +| `license` | Tutorial, Channel, Glossary, Movie, Newsletter, Podcast, BET | License info lost | +| `contributor_names` | Channel, Movie, Newsletter, Podcast, BET | Attribution lost | +| `reviewed` (quiz) | Quiz | Review status lost | +| `builder` | Conference | Organizer info lost | +| `en_word` | Glossary | English reference key unclear | +| `level` | Newsletter | Difficulty not stored | +| `publication_date` | Newsletter | Pub date not stored | +| `tags` (quiz) | Quiz | Quiz categorization lost | + +### Enum Value Mismatches + +| Content Type | Field | BEC Values | LMS Values | Missing in LMS | +|-------------|-------|-----------|------------|----------------| +| Course | `level` | beginner, intermediate, advanced, expert, wizard | Beginner, Intermediate, Advanced | **expert, wizard** | +| Course | `format` | online, inperson, hybrid | Online, InPerson | **hybrid** | +| Quiz | `difficulty` | easy, intermediate, hard, expert | Easy, Medium, Hard | **intermediate->Medium rename, expert missing** | + +--- + +## Recommendations + +### P0 - Data Integrity + +1. **Add NOT NULL constraints in LMS** for fields that BEC requires: `event.name`, `event.type`, `event.description`, `tutorial.professorId`, `professor bio/short_bio`, `quiz.explanation` +2. **Add enum constraints in LMS** for `topic`, `subtopic`, `category` fields that BEC validates against fixed vocabularies +3. **Add `expert` and `wizard` to LMS course level enum** to match BEC +4. **Add `hybrid` to LMS course format enum** to match BEC + +### P1 - Feature Parity + +5. **Add `license` column** to tutorial, channel, glossary, movie, newsletter, podcast, and BET tables (or a shared resource field) +6. **Add `contributor_names` column** or junction table for channel, movie, newsletter, podcast, BET +7. **Add `reviewed` boolean to quiz** questions +8. **Add `builder` to conference** table +9. **Add `level` and `publication_date` to newsletter** table + +### P2 - Sync Safety + +10. **Make LMS `trailer` nullable** on channel and movie (BEC has it optional) +11. **Make LMS `source` nullable** on research papers (BEC has it optional) +12. **Make LMS `bibUrl` nullable** on research papers (BEC doesn't have this field) +13. **Make LMS `downloadUrl` nullable** on BET (BEC has it optional) +14. **Review sync logic** for graceful handling of these mismatches + +### P3 - Constraint Alignment + +15. **Add pattern validation** in LMS Zod schemas for glossary terms (uppercase pattern) +16. **Add count validation** for quiz `wrong_answers` (exactly 3) +17. **Add min/max constraints** matching BEC schemas (e.g., abstract min 10 chars, publication_year 1900-2100) diff --git a/docs/tutorial-categories.md b/docs/tutorial-categories.md deleted file mode 100644 index 7c5275a11ac..00000000000 --- a/docs/tutorial-categories.md +++ /dev/null @@ -1,39 +0,0 @@ -# Introduction - -Every tutorial belongs to a specific category, to make easier for users to browse all available content in the Plan ₿ Nework website. - -The current category tree is the following: - -```` -Tutorials/ -├── WALLET/ -│ ├── Desktop -│ ├── Hardware -│ ├── Mobile -│ └── Backup -├── NODE/ -│ ├── Bitcoin -│ ├── Lightning Network -│ └── Others -├── MINING/ -│ ├── Hardware -│ └── Pool -├── BUSINESS/ -│ ├── Point-of-Sale -│ └── Others -├── EXCHANGE/ -│ ├── Peer-to-Peer -│ └── Centralized -├── PRIVACY/ -│ ├── On-Chain -│ └── Analysis -├── COMPUTER SECURITY/ -│ ├── Authentication -│ ├── Communication -│ ├── Operating System -│ └── Data -└── CONTRIBUTION/ - ├── Tutorial - ├── Resource - └── Others -```` diff --git a/docs/tutorial-creation-guidelines.md b/docs/tutorial-creation-guidelines.md deleted file mode 100644 index 99bf3142d59..00000000000 --- a/docs/tutorial-creation-guidelines.md +++ /dev/null @@ -1,207 +0,0 @@ - -⚠️ ***These guidelines are no longer up to date. You can find the latest guidelines in this tutorial on the platform:*** - -https://planb.academy/tutorials/contribution/content/write-tutorials-git-expert-0ce1e490-c28f-4c51-b7e0-9a6ac9728410 - - -___ - - -## Workflow - -### 1. Configure your local environment - -- You must have your own fork of the [Plan ₿ Academy repository on GitHub](https://github.com/PlanB-Network/bitcoin-educational-content). -- Sync the main branch (`dev`) of your fork with the source repository. -- Update your local clone. - -### 2. Create a new branch - -- Ensure you are on the `dev` branch. -- Create a new branch with a descriptive name (e.g., `tuto-green-wallet-loic`). -- Publish this branch on your online fork. - -### 3. Add the tutorial documents - -- Locate the appropriate folder in the local repository (e.g., `tutorials/wallet`). -- Create a dedicated directory for the tutorial with a clear name (e.g., `sparrow-wallet`). Note that this folder name will also determine the URL path for your tutorial. It must be lowercase, without special characters (except dashes), and without spaces. -- Add the following elements to this directory: - - A subfolder named `assets` containing: - - Two `.webp` images: - - `logo.webp`: The tutorial logo (square format with a background). This logo should represent the software or tool presented. If the tutorial is not specific to a single tool (e.g., a general tutorial on generating a mnemonic phrase), you are free to choose a visual to act as the logo (e.g., a generic icon). - - `cover.webp`: A cover image displayed at the beginning of the tutorial. - - A subfolder named with the tutorial's original language code. For example, if the tutorial is written in English, this subfolder should be named `en`. Place all tutorial visuals (diagrams, images, screenshots, etc.) here. - - A `tutorial.yml` file containing metadata (professor, tags, category, difficulty level, etc.). - - A Markdown file for the tutorial content, named according to the language code (e.g., `fr.md`, `en.md`, etc.). - -### 4. Complete the YAML file - -- Complete the `tutorial.yml` file using the following template: - -```yaml -id: - -builder: - -tags: - - - - - - - -category: - -level: - -credits: - professor: - -# Proofreading metadata - -original_language: -proofreading: - - language: - last_contribution_date: - urgency: - contributors_id: - - - reward: -``` - -- Required fields: - - **id**: A UUID (_Universally Unique Identifier_) for uniquely identifying the tutorial. - - **builder**: The name of the company or organization behind the tool presented in the tutorial [from the builders list](https://github.com/PlanB-Network/bitcoin-educational-content/tree/dev/resources/builders). - - **tags**: 2 or 3 relevant keywords related to the tutorial content, chosen [from Paolo's tag list](https://github.com/PlanB-Network/bitcoin-educational-content/blob/dev/docs/50-planb-tags.md). - - **category**: The subcategory corresponding to the tutorial's content, based on the Plan B site structure (e.g., for wallets: `desktop`, `hardware`, `mobile`, `backup`). - - **level**: The tutorial difficulty level, selected from the following: - - `beginner` - - `intermediate` - - `advanced` - - `expert` - - **professor**: Your `contributor_id` (BIP39 words) as displayed on [your professor profile](https://github.com/PlanB-Network/bitcoin-educational-content/tree/dev/professors). - - **original_language**: The original language of the tutorial (e.g., `fr`, `en`, etc.). - - **proofreading**: Information about the proofreading process. Complete the initial part, as proofreading your own tutorial counts as the first review. - - **language**: Proofreading language code (e.g., `fr`, `en`, etc.). - - **last_contribution_date**: Today's date. - - **urgency**: Leave blank. - - **contributors_id**: Your GitHub ID. - - **reward**: Leave blank. -- Example of a completed `tutorial.yml` file for a tutorial on the Blockstream Green wallet: - -```yaml -id: e84edaa9-fb65-48c1-a357-8a5f27996143 - -builder: Blockstream - -tags: - - wallets - - software - - keys - -category: mobile - -level: beginner - -credits: - professor: pretty-private - -# Proofreading metadata - -original_language: fr -proofreading: - - language: fr - last_contribution_date: 2024-11-20 - urgency: - contributors_id: - - LoicPandul - reward: -``` - -### 5. Write the content - -- Complete the Markdown file properties with: - - The title (`name`). - - A short description (`description`). -- Add the cover image at the top of the tutorial using Markdown syntax (replace "green" with the presented tool name): - -```markdown -![cover-green](assets/cover.webp) -``` - -- Write the tutorial content in Markdown: - - Use well-structured headings (`##`), lists, and paragraphs. - - Insert visuals using Markdown syntax: - -```markdown -![image-name](assets/en/001.webp) -``` - -- Place diagrams and images in the language subfolder within `assets`. - -### 6. Save and submit the tutorial - -- Save your changes locally, creating a commit with a descriptive message. -- Push the changes to your GitHub fork. -- Once complete, create a Pull Request (PR) on GitHub to propose integrating your changes. -- Add a title and brief description for the PR. Mention the corresponding issue number in your PR comment. - -### 7. Review and merge - -- Wait for validation or feedback from an admin. -- If needed, make additional changes and push new commits. -- Once the PR is merged, you can delete your working branch. - -For more detailed information on the process of proposing a new tutorial, you can also refer to this detailed guide, and more broadly, to all the tutorials in the "contribution" section: - -https://planb.academy/tutorials/contribution/content/write-tutorials-4d142a6a-9127-4ffb-9e0a-5aba29f169e2 - -## Content creation standards - -- **Supported formatting on the platform**: - - Classic Markdown: lists, links, images, quotes, bold, italics, etc. - - LaTeX (block only, not inline): Delimited by `$$`. - - Inline code: Single backtick syntax. - - Code blocks: Triple backtick syntax (\`\`\`), e.g.: - -```python - print("Hello, Bitcoin!") -``` - -- **Illustrations and diagrams**: - - All images must be in WebP format. Use this free tool to convert them if needed: [ImagesConverter](https://github.com/LoicPandul/ImagesConverter). - - Name visuals sequentially with 2 or 3 digits (e.g., `001.webp`, `002.webp`). - - When creating a tutorial on a smartphone or hardware wallet, it’s better to use mockups for the images. You can either ask the design team to do it for you in advance or do it yourself. - - Use only visuals you created yourself or those that are free to use under the original license. - - Ensure visuals are relevant and of sufficient quality. - -- **Compliance with the graphic charter**: - - Use the [Rubik font](https://fonts.google.com/specimen/Rubik) for visuals. - - Follow Plan ₿ Academy's color codes: - - Orange: `#FF5C00` - - Black: `#000000` - - White: `#FFFFFF` - -- **Clarity and accessibility**: - - Avoid complex technical words without explanation, especially for beginner tutorials. - - Tailor your content to the indicated level (`beginner`, `intermediate`, etc.). - -## Suggested tools - -- For Markdown file editing: - - **Obsidian** (Free, not open-source) - - **Mark Text** (Free, open-source) - - **Zettlr** (Free, open-source) - - **Typora** (Paid, ~€15, not open-source) - -- For Git: - - **Git** (Free, open-source) - - **GitHub Desktop** (Free, open-source) - - **Sourcetree** (Free, not open-source) - -- For YAML file editing: - - **Visual Studio Code** (Free, open-source) - - **Sublime Text** (Free with limitations, not open-source) - -- For creating diagrams and visuals: - - **Canva** (Free with paid options, not open-source) - - **Inkscape** (Free, open-source) - - **Penpot** (Free, open-source) diff --git a/docs/value-4-value-model.md b/docs/value-4-value-model.md deleted file mode 100644 index e1fbc19bb9d..00000000000 --- a/docs/value-4-value-model.md +++ /dev/null @@ -1,5 +0,0 @@ - -Here we believe in a [value-for-value model](https://dergigi.com/2021/12/30/the-freedom-of-value/) and we'll do our best to reciprocate for your contribution based on your Proof-of-Work. -Moreover, by participating in creating valuable Bitcoin resources for your local community, tips from them could also come your way. - -🙏 Thank you for your time, involvement, and effort in this project. \ No newline at end of file diff --git a/plans/bec-cli.md b/plans/bec-cli.md new file mode 100644 index 00000000000..a08416019be --- /dev/null +++ b/plans/bec-cli.md @@ -0,0 +1,394 @@ +# Plan: `bec` CLI — Agent-First Repo Tooling + +> Source PRD: `docs/agents/PRD.md` + +## Architectural Decisions + +Durable decisions that apply across all phases: + +- **Package location**: `scripts/bec/` with `pyproject.toml`, installed via `pip install -e .` +- **Entry point**: `bec` console script registered in pyproject.toml +- **CLI framework**: `click` — subcommand groups, `--help` auto-generation, option types, prompts +- **Content registry**: `content-types.yml` at repo root (~1,400 tokens) — single source of truth for all 14 content types, path patterns, schemas, tutorial categories, discipline codes, level ranges, tags, supported languages +- **Schemas**: JSON Schema Draft 7 files — initially at `scripts/validation-format/schemas/`, final location `scripts/bec/src/bec/schemas/` — root `schemas/` symlink throughout +- **Core dependencies**: `click`, `jsonschema`, `pyyaml`, `python-frontmatter`, `tqdm` +- **Test framework**: `pytest` with `click.testing.CliRunner` — fixtures in `scripts/bec/tests/fixtures/` +- **Output contract**: All commands support `--json` flag; exit codes: 0 (success), 1 (error), 2 (warning) +- **Dual mode**: All commands work non-interactive (all args on CLI) and interactive (prompts when args missing) +- **Agent files**: `docs/agents/AGENTS.md` + `docs/agents/CLAUDE.md`, symlinked to repo root via `bec agent-setup` + +### Package Structure + +``` +scripts/bec/ + pyproject.toml + src/bec/ + __init__.py + cli.py # click group, entry point + commands/ + __init__.py + validate.py + new.py + add.py + proofread.py + report.py + agent_setup.py + lib/ + __init__.py + schema.py # load JSON schemas, validate data + content_types.py # parse content-types.yml + yaml_utils.py # safe YAML read/write + repo.py # find repo root, resolve paths + markdown.py # frontmatter, headings, chapterId generation + data/ + bip39_wordlist.txt + tests/ + conftest.py + fixtures/ # known-good and known-bad content + test_validate.py + test_new.py + test_add.py + test_proofread.py + test_report.py +``` + +--- + +## Phase 1: Package Skeleton & Content Registry + +**User stories**: US-2 (bec --help), US-3 (content-types.yml), US-25 (pip install -e .), US-30 (discipline codes) + +### What to build + +Create the `bec` Python package that can be installed and invoked. Write `content-types.yml` at repo root containing all 14 content type definitions (path patterns, metadata filenames, schema references, example paths), tutorial categories, discipline codes with hierarchy, level ranges, the 52 tag names, and supported languages list. Implement `lib/repo.py` (find repo root by walking up to find `content-types.yml`), `lib/content_types.py` (parse and expose the registry), and `lib/yaml_utils.py` (safe YAML loading with date handling). Wire up `cli.py` as a click group with placeholder subcommands. Verify with `pip install -e .` and `bec --help`. + +### Acceptance criteria + +- [ ] `pip install -e scripts/bec` succeeds +- [ ] `bec --help` prints help text listing subcommands: validate, new, add, proofread, report, agent-setup +- [ ] `content-types.yml` exists at repo root with all 14 content types, each having: name, path_pattern, metadata_file, schema, content_schema (where applicable), example +- [ ] `content-types.yml` contains tutorial_categories, discipline_codes, level_range, tags (52 items), languages +- [ ] `lib/content_types.py` can load and query content-types.yml (e.g., get schema path for a content type) +- [ ] `lib/repo.py` correctly finds repo root from any subdirectory +- [ ] `lib/yaml_utils.py` loads YAML without converting dates to datetime objects +- [ ] Unit tests pass for lib/content_types.py, lib/repo.py, lib/yaml_utils.py +- [ ] All referenced schema paths in content-types.yml exist on disk +- [ ] All example paths in content-types.yml exist on disk + +--- + +## Phase 2: Schema Infrastructure & Validate Single Path + +**User stories**: US-4 (validate single), US-28 (exit codes) + +### What to build + +Implement `lib/schema.py` (load JSON Schema Draft 7 files, validate dicts against them, return structured errors). Create root `schemas/` symlink pointing to `scripts/validation-format/schemas/`. Implement `commands/validate.py` with `bec validate ` that: detects content type from path, loads the appropriate schema from content-types.yml, validates YAML metadata against schema, validates markdown frontmatter structure, and reports errors. Port core validation logic from `scripts/validation-format/validate.py` (the `validate_content()` function and its helpers). Return exit code 0 for pass, 1 for errors. + +### Acceptance criteria + +- [ ] `schemas/` symlink exists at repo root, pointing to `scripts/validation-format/schemas/` +- [ ] `bec validate courses/btc101` runs and reports validation results +- [ ] `bec validate tutorials/wallet/sparrow` runs and reports validation results +- [ ] `bec validate resources/books/grokking-bitcoin` runs and reports validation results (adapt to actual existing resource) +- [ ] Valid content returns exit code 0 +- [ ] Content with deliberate errors returns exit code 1 +- [ ] Validation checks: YAML metadata against JSON schema, markdown frontmatter required fields, content rules (heading hierarchy, chapterId format for courses) +- [ ] Test fixtures: at least one known-good and one known-bad content folder +- [ ] Tests pass via `pytest` + +--- + +## Phase 3: Validate All & JSON Output + +**User stories**: US-5 (validate --all), US-6 (--json), US-28 (exit codes) + +### What to build + +Extend `bec validate` with `--all` flag that discovers and validates all content in the repo. Add filter flags: `--courses-only`, `--tutorials-only`, `--type `. Add `--json` flag that outputs machine-readable JSON with structured error/warning objects. Add `--summary-only` flag that hides individual errors and shows only counts. Use `tqdm` for progress bars during `--all` runs. Port the discovery logic from `scripts/validation-format/validate_all.py`. Exit code 2 for warnings-only. + +### Acceptance criteria + +- [ ] `bec validate --all` discovers and validates all 2,600+ content items with a progress bar +- [ ] `bec validate --all --courses-only` validates only courses +- [ ] `bec validate --all --tutorials-only` validates only tutorials +- [ ] `bec validate --all --type resources/books` validates only books +- [ ] `bec validate --all --json` outputs valid JSON to stdout with structure: `{summary: {total, passed, errors, warnings}, items: [{path, type, status, errors: [], warnings: []}]}` +- [ ] `bec validate --all --summary-only` shows only aggregate counts +- [ ] Exit code 0 when all pass, 1 when errors exist, 2 when only warnings +- [ ] Performance: full repo validation completes in under 60 seconds +- [ ] Tests cover filter flags and JSON output parsing + +--- + +## Phase 4: Scaffold Course + +**User stories**: US-7 (new course), US-26 (auto UUID), US-27 (enum choices) + +### What to build + +Implement `commands/new.py` with `bec new course` subcommand. In non-interactive mode: `bec new course --id btc201 --topic bitcoin --level intermediate --lang en --professor-id `. In interactive mode: prompt for each required field, showing valid enum values (topics, levels) read from content-types.yml and JSON schemas. Auto-generate UUID for `course.yml`. Create folder `courses/{id}/` with `course.yml` (populated from schema required fields + UUID) and `{lang}.md` (with correct frontmatter structure and TODO placeholders). Generated content should pass `bec validate` structurally (even with TODO values). + +### Acceptance criteria + +- [ ] `bec new course --id test101 --topic bitcoin --level beginner --lang en --professor-id ` creates `courses/test101/` with `course.yml` and `en.md` +- [ ] `course.yml` contains a valid UUID, correct topic, level, and professor reference +- [ ] `en.md` contains correct frontmatter structure (name, goal, objectives as TODO placeholders) +- [ ] Interactive mode prompts with valid enum choices for topic, level, language +- [ ] Course ID format is validated (discipline code + 3-digit number, e.g., BTC101) +- [ ] UUID is auto-generated in valid format +- [ ] Generated course passes `bec validate courses/test101` (structural validity) +- [ ] `--json` flag outputs created file paths as JSON +- [ ] Tests verify folder structure, YAML validity, and markdown structure + +--- + +## Phase 5: Scaffold Tutorial, Professor, Event, Resource + +**User stories**: US-8 (new tutorial), US-9 (new resource), US-10 (new professor), US-11 (new event) + +### What to build + +Extend `commands/new.py` with four more subcommands: `bec new tutorial --category wallet --id my-tuto --lang en`, `bec new professor --id username`, `bec new event --id event-name-2025`, `bec new resource --type book --id book-name`. Each reads its JSON schema for required fields and enum values. All auto-generate UUIDs. All support interactive + non-interactive modes. Tutorial validates category against tutorial_categories from content-types.yml. Resource validates type against the 11 resource subtypes. + +### Acceptance criteria + +- [ ] `bec new tutorial --category wallet --id test-tuto --lang en` creates `tutorials/wallet/test-tuto/` with `tutorial.yml` + `en.md` +- [ ] `bec new professor --id test-prof` creates `professors/test-prof/` with `professor.yml` + language YML +- [ ] `bec new event --id test-event-2025` creates `events/test-event-2025/` with `event.yml` +- [ ] `bec new resource --type book --id test-book` creates `resources/books/test-book/` with `book.yml` +- [ ] `bec new resource --type podcast --id test-pod` creates `resources/podcasts/test-pod/` with `podcast.yml` +- [ ] Invalid tutorial category is rejected with helpful error listing valid categories +- [ ] Invalid resource type is rejected with helpful error listing valid types +- [ ] All generated content passes `bec validate` structurally +- [ ] Interactive mode works for all four content types +- [ ] Tests cover all content types, valid and invalid inputs + +--- + +## Phase 6: Add Part & Chapter + +**User stories**: US-12 (add part), US-13 (add chapter) + +### What to build + +Implement `commands/add.py` with `bec add part` and `bec add chapter`. `bec add part --course btc101 --lang en --title "Part Title"` appends `+++\n\n# Part Title` to the course markdown. `bec add chapter --course btc101 --lang en --title "Chapter Title"` appends `## Chapter Title\n\nthree-bip39-words` with an auto-generated BIP39 3-word chapter ID. Implement `lib/markdown.py` with frontmatter parsing, heading manipulation, and BIP39 chapter ID generation. Bundle BIP39 wordlist as `data/bip39_wordlist.txt`. + +### Acceptance criteria + +- [ ] `bec add part --course btc101 --lang en --title "Test Part"` appends `+++\n\n# Test Part` to `courses/btc101/en.md` +- [ ] `bec add chapter --course btc101 --lang en --title "Test Chapter"` appends `## Test Chapter\n\nword-word-word` to `courses/btc101/en.md` +- [ ] BIP39 chapter IDs are 3 hyphenated BIP39 words (e.g., `father-loop-frog`) +- [ ] BIP39 wordlist is bundled in `scripts/bec/src/bec/data/bip39_wordlist.txt` +- [ ] Part separator is `+++` followed by blank line and `# heading` +- [ ] Chapter heading is `##` level (not `#` or `###`) +- [ ] Modified markdown still passes `bec validate` after additions +- [ ] `--json` flag outputs the appended content details +- [ ] Tests verify markdown structure before and after operations + +--- + +## Phase 7: Add Quiz & Add Language + +**User stories**: US-14 (add quiz), US-15 (add language) + +### What to build + +Extend `commands/add.py` with `bec add quiz --course btc101 --chapter-id ` that creates the next numbered `quizz/{nnn}/` folder with `question.yml` and `{lang}.yml` skeleton. Add `bec add language --path courses/btc101 --lang fr` that creates a new language file by copying structure (frontmatter fields, headings, chapterIds) from the original language, replacing content with TODO placeholders. + +### Acceptance criteria + +- [ ] `bec add quiz --course btc101 --chapter-id ` creates next numbered quiz folder (e.g., `quizz/005/`) +- [ ] Quiz folder contains `question.yml` with correct chapterId reference and `en.yml` skeleton +- [ ] Quiz numbering auto-detects existing quizzes and increments (e.g., if 004 exists, creates 005) +- [ ] `bec add language --path courses/btc101 --lang fr` creates `courses/btc101/fr.md` +- [ ] New language file preserves: frontmatter field structure, heading hierarchy, all `` tags, `+++` part separators +- [ ] New language file replaces: prose content with `TODO` placeholders +- [ ] `bec add language` works for tutorials and other content types too (not just courses) +- [ ] Generated quiz passes `bec validate` structurally +- [ ] Tests verify quiz numbering logic and language file structure preservation + +--- + +## Phase 8: Proofread + +**User stories**: US-16 (update), US-17 (reward), US-18 (batch-add) + +### What to build + +Implement `commands/proofread.py` absorbing logic from `scripts/proofreading-metadata/` (5 Python files). Four subcommands: `bec proofread update --path --lang --contributor ` (add contributor to proofreading metadata, update date), `bec proofread reward --path ` (calculate and display reward amounts), `bec proofread batch-add --contributor --lang --paths ...` (bulk operation), `bec proofread status --path ` (show status for all languages). No dependency on `inquirer` — all CLI args via click options. + +### Acceptance criteria + +- [ ] `bec proofread update --path courses/btc101 --lang fr --contributor test-user` updates proofreading metadata in the YAML +- [ ] Contributor is appended to the list, not overwriting existing contributors +- [ ] Date is updated to current date +- [ ] `bec proofread reward --path courses/btc101` displays reward calculation +- [ ] `bec proofread batch-add --contributor test-user --lang fr --paths courses/btc101 courses/btc102` updates multiple items +- [ ] `bec proofread status --path courses/btc101` shows proofreading status matrix for all languages +- [ ] All subcommands support `--json` output +- [ ] Interactive fallback when no args provided (prompts for required values) +- [ ] No dependency on `inquirer` library +- [ ] Tests with YAML fixtures verify metadata is correctly added/updated + +--- + +## Phase 9: Report — Translation Coverage + +**User stories**: US-19 (report --all), US-20 (translation --json) + +### What to build + +Implement `commands/report.py` with the report group and the first report subcommand: `bec report translation`. Absorbs logic from `scripts/md_translation_overview/generate_report.py`. Generates an HTML dashboard showing markdown translation coverage across all content types and languages. Supports `--output ` (default: `docs/reports/`) and `--json` for machine-readable output. Wire up `bec report --all` to run all report subcommands (only translation for now, extended in later phases). + +### Acceptance criteria + +- [ ] `bec report translation` generates `docs/reports/md_translation_overview.html` +- [ ] `bec report translation --json` outputs valid JSON with per-content-type, per-language coverage percentages +- [ ] `bec report translation --output /tmp/reports/` writes to custom directory +- [ ] HTML report is self-contained (inline CSS/JS, no external dependencies) +- [ ] Report data matches actual repo state (spot-check a few known translations) +- [ ] `bec report --all` runs translation report (and future reports as they're added) +- [ ] Exit code 0 on success +- [ ] Tests verify HTML generation and JSON output structure + +--- + +## Phase 10: Report — Image Translation + +**User stories**: US-19 (report --all) + +### What to build + +Add `bec report images` subcommand. Absorbs logic from `scripts/image_translation_overview/generate_report.py`. Generates an HTML dashboard showing image translation progress — which content items have language-specific images vs fallback images. Supports `--output` and `--json`. Register in `bec report --all`. + +### Acceptance criteria + +- [ ] `bec report images` generates `docs/reports/image_translation_overview.html` +- [ ] `bec report images --json` outputs valid JSON with per-content-type image translation stats +- [ ] HTML report is self-contained +- [ ] Report correctly identifies images in `assets/{lang}/` vs `assets/no-txt/` patterns +- [ ] `bec report --all` now runs both translation and images reports +- [ ] Tests verify output generation + +--- + +## Phase 11: Report — Video Deployment + +**User stories**: US-22 (video) + +### What to build + +Add `bec report video` subcommand. Absorbs logic from `scripts/video_deployment_overview/generate_report.py`. Analyzes YouTube/PeerTube deployment status across all courses by checking video links in course YAML metadata. Supports `--output` and `--json`. Register in `bec report --all`. + +### Acceptance criteria + +- [ ] `bec report video` generates `docs/reports/video_deployment_overview.html` +- [ ] `bec report video --json` outputs valid JSON with per-course video deployment status +- [ ] Report identifies missing video links, broken references, platform coverage +- [ ] HTML report is self-contained +- [ ] `bec report --all` now runs translation, images, and video reports +- [ ] Tests verify output generation + +--- + +## Phase 12: Report — Proofreading Dashboard + +**User stories**: US-21 (proofreading dashboard) + +### What to build + +Add `bec report proofreading` subcommand. Absorbs logic from `scripts/proofreading_report/generate_proofreading_dashboard.py`. Generates a self-contained HTML dashboard with: matrix views (content x language), language statistics, contributor leaderboards, and progress tracking. Supports `--output` and `--json`. Register in `bec report --all`. + +### Acceptance criteria + +- [ ] `bec report proofreading` generates `docs/reports/proofreading_dashboard.html` +- [ ] `bec report proofreading --json` outputs valid JSON with matrix data, language stats, contributor rankings +- [ ] HTML dashboard includes: matrix view, language stats, leaderboard sections +- [ ] HTML report is self-contained (inline CSS/JS) +- [ ] `bec report --all` now runs translation, images, video, and proofreading reports +- [ ] Tests verify output generation and JSON structure + +--- + +## Phase 13: Report — Course Analytics + +**User stories**: US-23 (analytics) + +### What to build + +Add `bec report analytics` subcommand. Absorbs logic from `scripts/course_report/course_analytics.py`. Generates course structure statistics: word counts per course/chapter, chapter and part counts, quiz counts, language coverage per course. Supports `--output` and `--json`. Register in `bec report --all`. With this phase, `bec report --all` runs all 5 reports. + +### Acceptance criteria + +- [ ] `bec report analytics` generates `docs/reports/course_analytics_report.html` +- [ ] `bec report analytics --json` outputs valid JSON with per-course statistics (word counts, chapter counts, quiz counts) +- [ ] HTML report is self-contained +- [ ] Statistics match actual repo state (spot-check btc101) +- [ ] `bec report --all` now runs all 5 reports: translation, images, video, proofreading, analytics +- [ ] `bec report --all --output /tmp/reports/` writes all reports to custom directory +- [ ] Tests verify output generation and JSON structure + +--- + +## Phase 14: Agent Orientation & Setup + +**User stories**: US-1 (AGENTS.md), US-24 (agent-setup) + +### What to build + +Write `docs/agents/AGENTS.md` — the comprehensive agent orientation file covering: repo purpose, content type overview (referencing content-types.yml), `bec` CLI command reference with examples, content conventions (course IDs, BIP39 chapter IDs, professor UIDs, image formats), formatting rules (heading hierarchy, `+++` part separators, `` tags), validation workflow, translation/i18n patterns, common pitfalls. Write `docs/agents/CLAUDE.md` — Claude Code-specific instructions importing AGENTS.md context, tool permissions, slash command hints. Implement `commands/agent_setup.py` with `bec agent-setup` that symlinks both files to repo root (both gitignored). + +### Acceptance criteria + +- [ ] `docs/agents/AGENTS.md` exists with comprehensive orientation (<3,000 tokens for quick loading) +- [ ] AGENTS.md covers: repo purpose, content types (referencing content-types.yml), bec CLI commands, content conventions, formatting rules, validation workflow, translation patterns, common pitfalls +- [ ] `docs/agents/CLAUDE.md` exists with Claude Code-specific instructions +- [ ] CLAUDE.md imports/references AGENTS.md for shared context +- [ ] `bec agent-setup` creates `AGENTS.md` symlink at repo root pointing to `docs/agents/AGENTS.md` +- [ ] `bec agent-setup` creates `CLAUDE.md` symlink at repo root pointing to `docs/agents/CLAUDE.md` +- [ ] Both symlinks are listed in `.gitignore` +- [ ] An agent reading AGENTS.md + content-types.yml can understand the repo in <30 seconds (under 4,000 combined tokens) + +--- + +## Phase 15: Tag Descriptions in Schemas + +**User stories**: US-29 (tag descriptions in schemas) + +### What to build + +Migrate the 52 tag descriptions from `docs/50-planb-tags.md` (currently in `to_delete/docs/`) into the JSON schema files. Add tag descriptions as a `description` field or `x-tag-descriptions` extension in each schema that uses the tags enum. This makes JSON schemas the single source of truth for valid tags and their meanings. Agents reading schemas get both validation and semantics. + +### Acceptance criteria + +- [ ] All 52 tag descriptions are present in the JSON schema files that reference tags +- [ ] Tag descriptions are accessible as a structured field (e.g., `x-tag-descriptions` or individual enum descriptions) +- [ ] JSON schemas remain valid JSON Schema Draft 7 after modification +- [ ] `bec validate` still works correctly with modified schemas (no regression) +- [ ] No tag is missing a description +- [ ] Tests verify schemas are valid and tag descriptions are present + +--- + +## Phase 16: Cleanup & Final Wiring + +**No user story** — housekeeping and verification + +### What to build + +Move `scripts/validation-format/` to `to_delete/scripts/validation-format/` (after confirming `bec validate` fully replaces it). Move `scripts/proofreading-metadata/` to `to_delete/scripts/proofreading-metadata/`. Copy schemas from `scripts/validation-format/schemas/` to their final location at `scripts/bec/src/bec/schemas/`. Update root `schemas/` symlink to point to new location. Remove `docs/agents/REFACTOR_PLAN.md` (superseded by implemented PRD). Evaluate `docs/how-to-translate-image.md` for absorption into AGENTS.md. Run full `bec validate --all` against real repo to confirm no regressions. Run full `bec report --all` to confirm all reports generate correctly. + +### Acceptance criteria + +- [ ] `scripts/validation-format/` moved to `to_delete/` +- [ ] `scripts/proofreading-metadata/` moved to `to_delete/` +- [ ] Schemas live at `scripts/bec/src/bec/schemas/` (canonical location) +- [ ] Root `schemas/` symlink points to `scripts/bec/src/bec/schemas/` +- [ ] `docs/agents/REFACTOR_PLAN.md` moved to `to_delete/` +- [ ] `bec validate --all` passes on real repo content (exit code 0 or 2 for warnings only) +- [ ] `bec report --all` generates all 5 HTML reports successfully +- [ ] `bec new course` / `bec new tutorial` generate valid content that passes validation +- [ ] `bec --help` shows all subcommands with descriptions +- [ ] All pytest tests pass +- [ ] No remaining imports or references to old script paths diff --git a/schemas b/schemas new file mode 120000 index 00000000000..fc6e6de58fc --- /dev/null +++ b/schemas @@ -0,0 +1 @@ +scripts/bec/src/bec/schemas \ No newline at end of file diff --git a/scripts/21-planb-tags.md b/scripts/21-planb-tags.md deleted file mode 100644 index a1b2385c270..00000000000 --- a/scripts/21-planb-tags.md +++ /dev/null @@ -1,24 +0,0 @@ -This is an attempt to reduce the number of tags for PlanB Content to 21: - -1. Fundamentals -2. Technology -3. Mining -4. Wallets -5. Security -6. Protocols -7. Cryptography -8. Open-Source -9. Transactions -10. Network Architecture -11. Economic Impact -12. Regulation and Compliance -13. Historical Context -14. Privacy Enhancements -15. Decentralization -16. Adoption -17. Scaling Solutions -18. Theoretical -19. Practical -20. Investment Strategies -21. Future Developments - diff --git a/scripts/README b/scripts/README deleted file mode 100644 index 72ff4ff3654..00000000000 --- a/scripts/README +++ /dev/null @@ -1,5 +0,0 @@ -## Dependencies - -- ruamel.yaml -- unidecode -- python-frontmatter diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 76e04cf0557..00000000000 --- a/scripts/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# Scripts Directory - -This directory contains analysis and reporting tools for the Bitcoin Educational Content repository. - -## 📊 Overview Report Generators - -Three specialized tools analyze different aspects of course translations: - -### 1. Video Deployment Overview -**Location:** `video_deployment_overview/` - -Analyzes video coverage across courses and languages (YouTube/PeerTube providers). - -- **Languages:** en, fr, es, it, de, ru, zh-Hant -- **Output:** Color-coded table showing video deployment by provider -- **Usage:** `python video_deployment_overview/generate_report.py` - -### 2. Image Translation Overview -**Location:** `image_translation_overview/` - -Tracks image translation progress by detecting language codes in image paths. - -- **Languages:** en, fr, es, it, de, ru, zh-Hant -- **Output:** Visual report of translated vs untranslated images -- **Usage:** `python image_translation_overview/generate_report.py` - -### 3. Markdown Translation Overview -**Location:** `md_translation_overview/` - -Shows which markdown files exist for each course and language. - -- **Languages:** Auto-detected from btc101 (27 languages) -- **Output:** Comprehensive table with word counts -- **Usage:** `python md_translation_overview/generate_report.py` - -## 🚀 Quick Start - -### Generate All Reports at Once - -```bash -# From the scripts directory -cd scripts -python generate_all_reports.py -``` - -This single command will: -- ✅ Run all 3 report generators -- ✅ Create all 3 HTML reports -- ✅ Show summary of results -- ✅ Display file locations - -### Generate Individual Reports - -```bash -# Video deployment -python video_deployment_overview/generate_report.py - -# Image translation -python image_translation_overview/generate_report.py - -# Markdown translation -python md_translation_overview/generate_report.py -``` - -## 📁 Directory Structure - -``` -scripts/ -├── generate_all_reports.py # Master script (runs all 3) -├── README.md # This file -│ -├── reports/ # ⭐ All HTML reports go here -│ ├── video_deployment_overview.html -│ ├── image_translation_overview.html -│ └── md_translation_overview.html -│ -├── video_deployment_overview/ -│ ├── generate_report.py # Video analysis script -│ └── README.md -│ -├── image_translation_overview/ -│ ├── generate_report.py # Image analysis script -│ └── README.md -│ -└── md_translation_overview/ - ├── generate_report.py # Markdown analysis script - └── README.md -``` - -## 🎨 Report Features - -All reports include: -- **Color-coded visualization** for quick status assessment -- **Detailed statistics** (counts, percentages, totals) -- **Responsive tables** that work on all screen sizes -- **Auto-generated timestamps** to track when reports were created -- **Interactive legends** explaining the color schemes - -### Color Coding - -| Color | Meaning | Percentage Range | -|-------|---------|------------------| -| 🟢 Green | Complete | 100% | -| 🟡 Yellow | Partial | 50-99% | -| 🟠 Orange | Low | 1-49% | -| 🔴 Red | None/Missing | 0% | -| ⚫ Gray | N/A | No content | - -## 📋 Requirements - -- **Python 3.6+** (standard library only) -- **PyYAML** for video deployment analysis: `pip install pyyaml` - -## 📍 Report Locations - -All HTML reports are generated in a single location for easy access: - -``` -scripts/reports/ -├── video_deployment_overview.html -├── image_translation_overview.html -└── md_translation_overview.html -``` - -Simply open the HTML files in your browser to view the reports. - -## 🔄 Updating Reports - -Reports are static HTML files generated from current course data. To update: - -1. Make changes to course files (YAML, markdown, images) -2. Re-run the generator scripts -3. Refresh HTML files in browser - -## 💡 Tips - -- **Bookmark the HTML files** for quick access -- **Run after major updates** to track translation progress -- **Share reports** with translators to identify gaps -- **Use master script** for comprehensive analysis - -## 🛠️ Customization - -Each generator can be customized by editing: -- Language lists (in script constants) -- Color schemes (in HTML CSS) -- Statistics displayed (in generation logic) - -See individual README files for script-specific details. - -## 📝 Notes - -- Video deployment analyzes `course.yml` files -- Image translation scans markdown for image references -- Markdown translation checks for `.md` file existence -- All scripts handle missing files gracefully -- Reports are self-contained HTML files (no external dependencies) diff --git a/scripts/add_tuto_title_to_glossary_translation/add_tuto_to_glossary.py b/scripts/add_tuto_title_to_glossary_translation/add_tuto_to_glossary.py deleted file mode 100755 index a96fa6a8b87..00000000000 --- a/scripts/add_tuto_title_to_glossary_translation/add_tuto_to_glossary.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to add tutorial names to the translation glossary. -Scans all tutorials, extracts the 'name' from en.md headers, -and prompts user to add new entries to glossary.yml -""" - -import os -import re -import sys -from pathlib import Path - -# Paths relative to this script's location -SCRIPT_DIR = Path(__file__).parent -TUTORIALS_DIR = SCRIPT_DIR / "../../tutorials" -GLOSSARY_PATH = SCRIPT_DIR / "../auto-translate/translation_logic/glossary.yml" - - -def load_glossary_entries() -> set: - """Load existing non_translatable entries from glossary.yml""" - entries = set() - - if not GLOSSARY_PATH.exists(): - print(f"Error: Glossary file not found at {GLOSSARY_PATH}") - sys.exit(1) - - with open(GLOSSARY_PATH, "r", encoding="utf-8") as f: - content = f.read() - - # Parse entries under non_translatable section - in_section = False - for line in content.split("\n"): - if line.strip().startswith("non_translatable:"): - in_section = True - continue - if in_section: - # Stop if we hit another top-level key - if line and not line.startswith(" ") and not line.startswith("#"): - break - # Extract entry (format: " - EntryName" or " - 'Entry Name'") - match = re.match(r'^\s+-\s+(.+)$', line) - if match: - entry = match.group(1).strip() - # Remove quotes if present - if (entry.startswith('"') and entry.endswith('"')) or \ - (entry.startswith("'") and entry.endswith("'")): - entry = entry[1:-1] - entries.add(entry.lower()) - - return entries - - -def extract_tutorial_name(en_md_path: Path) -> str | None: - """Extract 'name' field from en.md YAML front matter""" - try: - with open(en_md_path, "r", encoding="utf-8") as f: - content = f.read() - except Exception as e: - print(f" Warning: Could not read {en_md_path}: {e}") - return None - - # Check for YAML front matter - if not content.startswith("---"): - return None - - # Find end of front matter - end_match = re.search(r'\n---\s*\n', content[3:]) - if not end_match: - return None - - front_matter = content[3:end_match.start() + 3] - - # Extract name field - name_match = re.search(r'^name:\s*(.+)$', front_matter, re.MULTILINE) - if name_match: - name = name_match.group(1).strip() - # Remove quotes if present - if (name.startswith('"') and name.endswith('"')) or \ - (name.startswith("'") and name.endswith("'")): - name = name[1:-1] - return name - - return None - - -def get_tutorial_friendly_name(path: Path) -> str: - """Get a friendly display name for the tutorial from its path""" - # Path structure: tutorials/category/tutorial-name/en.md - parts = path.parts - try: - tuto_idx = parts.index("tutorials") - category = parts[tuto_idx + 1] if len(parts) > tuto_idx + 1 else "unknown" - tuto_name = parts[tuto_idx + 2] if len(parts) > tuto_idx + 2 else "unknown" - return f"{category}/{tuto_name}" - except (ValueError, IndexError): - return str(path) - - -def add_to_glossary(name: str): - """Add a new entry to the glossary.yml file""" - with open(GLOSSARY_PATH, "r", encoding="utf-8") as f: - lines = f.readlines() - - # Find the last entry in non_translatable section - insert_idx = None - in_section = False - - for i, line in enumerate(lines): - if "non_translatable:" in line: - in_section = True - continue - if in_section: - # Check if this line is an entry - if re.match(r'^\s+-\s+', line): - insert_idx = i + 1 - # Stop if we hit another top-level key or end of entries - elif line.strip() and not line.startswith(" ") and not line.startswith("#"): - break - - if insert_idx is None: - print(f" Error: Could not find insertion point in glossary") - return False - - # Prepare new entry - quote if contains special chars - if " " in name or ":" in name or "-" in name: - new_entry = f' - "{name}"\n' - else: - new_entry = f" - {name}\n" - - lines.insert(insert_idx, new_entry) - - with open(GLOSSARY_PATH, "w", encoding="utf-8") as f: - f.writelines(lines) - - return True - - -def main(): - print("\n" + "=" * 60) - print(" Tutorial Name → Glossary Importer") - print("=" * 60 + "\n") - - # Load existing glossary entries - print("Loading glossary entries...") - existing_entries = load_glossary_entries() - print(f"Found {len(existing_entries)} existing entries\n") - - # Find all en.md files - tutorials_path = TUTORIALS_DIR.resolve() - if not tutorials_path.exists(): - print(f"Error: Tutorials directory not found at {tutorials_path}") - sys.exit(1) - - en_md_files = sorted(tutorials_path.rglob("en.md")) - total_tutorials = len(en_md_files) - print(f"Found {total_tutorials} tutorials to process\n") - print("-" * 60) - - added_count = 0 - skipped_count = 0 - already_exists_count = 0 - - for idx, en_md_path in enumerate(en_md_files, 1): - friendly_name = get_tutorial_friendly_name(en_md_path) - remaining = total_tutorials - idx - - # Extract tutorial name - tuto_name = extract_tutorial_name(en_md_path) - - if not tuto_name: - print(f"[{idx}/{total_tutorials}] {friendly_name}") - print(f" ⚠ No 'name' field found - skipping") - print() - continue - - # Check if already in glossary (case-insensitive) - if tuto_name.lower() in existing_entries: - already_exists_count += 1 - continue # Silent skip for existing entries - - # Prompt user - print(f"\n[{idx}/{total_tutorials}] {friendly_name}") - print(f" 📝 Tutorial name: \033[1;36m{tuto_name}\033[0m") - print(f" 📊 {remaining} tutorials remaining after this") - - while True: - response = input(f" Add to glossary? [Y/n]: ").strip().lower() - - if response == "" or response == "y" or response == "yes": - if add_to_glossary(tuto_name): - existing_entries.add(tuto_name.lower()) - print(f" ✅ Added '{tuto_name}' to glossary") - added_count += 1 - break - elif response == "n" or response == "no": - print(f" ⏭ Skipped") - skipped_count += 1 - break - else: - print(" Please enter 'y' (yes) or 'n' (no)") - - # Summary - print("\n" + "=" * 60) - print(" Summary") - print("=" * 60) - print(f" ✅ Added to glossary: {added_count}") - print(f" ⏭ Skipped by user: {skipped_count}") - print(f" 📋 Already in glossary: {already_exists_count}") - print(f" 📁 Total processed: {total_tutorials}") - print("=" * 60 + "\n") - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\n\n Interrupted by user. Exiting...") - sys.exit(0) diff --git a/scripts/auto-event/generate_builderevent.py b/scripts/auto-event/generate_builderevent.py deleted file mode 100644 index 126aedab533..00000000000 --- a/scripts/auto-event/generate_builderevent.py +++ /dev/null @@ -1,71 +0,0 @@ -import yaml -from datetime import datetime, timedelta -import os - -# Path to the file -file_path = '/home/jramos/sovereign-university-data/resources/conference/pleblab-builderspace-2024/events.yml' - -# Create the directory if it does not exist -os.makedirs(os.path.dirname(file_path), exist_ok=True) - -# Read the existing file or initialize an empty list if it does not exist -if os.path.exists(file_path): - with open(file_path, 'r') as file: - existing_events = yaml.safe_load(file) or [] -else: - existing_events = [] - -# Base event data -base_event = { - 'address_line_1': 'Austin, USA', - 'address_line_2': '', - 'address_line_3': '', - 'name': 'Open Hackerspace Day', - 'type': 'course', - 'book_online': False, - 'book_in_person': False, - 'price_dollars': 0, - 'description': 'Every Tuesday, we open our doors to the Bitcoin Builders, from simple projects and creators to forward-thinking developers. It is a collaborative space where your Bitcoin project or startup takes center stage.', - 'language': ['en'], - 'links': { - 'website': 'https://www.meetup.com/pleb-lab/events/302290112/?utm_medium=referral&utm_campaign=share-btn_savedevents_share_modal&utm_source=link', - 'replay_url': '', - 'live_url': '' - }, - 'tags': ['bitcoiner', 'general', 'hacker'] -} - -# Recurring event configuration -days_interval = 7 # Every week -today = datetime.now().date() - -# Start date for the event -start_date = datetime(2024, 7, 23).date() - -# Find the next Tuesday -if today > start_date: - days_until_next_tuesday = (1 - today.weekday() + 7) % 7 - if days_until_next_tuesday == 0: - days_until_next_tuesday = 7 - next_tuesday = today + timedelta(days=days_until_next_tuesday) -else: - next_tuesday = start_date - -# Check if an event for the next Tuesday already exists -existing_event = next((event for event in existing_events if event['start_date'] == next_tuesday.strftime('%Y-%m-%d 14:00:00')), None) - -if not existing_event: - # Create a new event if it does not exist - new_event = base_event.copy() - new_event['start_date'] = next_tuesday.strftime('%Y-%m-%d 14:00:00') - new_event['end_date'] = next_tuesday.strftime('%Y-%m-%d 22:00:00') # End on the same day - new_event['timezone'] = 'America/Chicago' # Texas timezone - existing_events.append(new_event) - - # Save the updated events - with open(file_path, 'w') as file: - yaml.dump(existing_events, file, default_flow_style=False) - - print(f'An event has been added for the date {new_event["start_date"]}') -else: - print(f'The event for the date {next_tuesday.strftime("%Y-%m-%d 14:00:00")} already exists') diff --git a/scripts/auto-event/generate_events.py b/scripts/auto-event/generate_events.py deleted file mode 100755 index 17293a27f69..00000000000 --- a/scripts/auto-event/generate_events.py +++ /dev/null @@ -1,62 +0,0 @@ -import yaml -from datetime import datetime, timedelta -import os - -# Path to the file -file_path = '/home/jramos/sovereign-university-data/resources/conference/pleblab-hackerspace-2024/events.yml' - -# Create the directory if it does not exist -os.makedirs(os.path.dirname(file_path), exist_ok=True) - -# Read the existing file or initialize an empty list if it does not exist -if os.path.exists(file_path): - with open(file_path, 'r') as file: - existing_events = yaml.safe_load(file) or [] -else: - existing_events = [] - -# Base event data -base_event = { - 'address_line_1': 'Austin, USA', - 'address_line_2': '', - 'address_line_3': '', - 'name': 'Bitcoin Project Day at PlebLab', - 'type': 'course', - 'book_online': False, - 'book_in_person': False, - 'price_dollars': 0, - 'description': 'Every Sunday, we open our doors to the Bitcoin Builders, from simple projects and creators to forward-thinking developers. It is a collaborative space where your Bitcoin project or startup takes center stage.', - 'language': ['en'], - 'links': { - 'website': 'https://www.meetup.com/pleb-lab/events/301693658/?utm_medium=referral&utm_campaign=share-btn_savedevents_share_modal&utm_source=link', - 'replay_url': '', - 'live_url': '' - }, - 'tags': ['bitcoiner', 'general', 'hacker'] -} - -# Recurring event configuration -days_interval = 7 # Every week -today = datetime.now().date() - -# Find the next Sunday -next_sunday = today + timedelta(days=(6 - today.weekday())) - -# Check if an event for the next Sunday already exists -existing_event = next((event for event in existing_events if event['start_date'] == next_sunday.strftime('%Y-%m-%d 14:00:00')), None) - -if not existing_event: - # Create a new event if it does not exist - new_event = base_event.copy() - new_event['start_date'] = next_sunday.strftime('%Y-%m-%d 14:00:00') - new_event['end_date'] = next_sunday.strftime('%Y-%m-%d 22:00:00') # End on the same day - new_event['timezone'] = 'America/Chicago' # Texas timezone - existing_events.append(new_event) - - # Save the updated events - with open(file_path, 'w') as file: - yaml.dump(existing_events, file, default_flow_style=False) - - print(f'An event has been added for the date {new_event["start_date"]}') -else: - print(f'The event for the date {next_sunday.strftime("%Y-%m-%d 14:00:00")} already exists') diff --git a/scripts/bec/.gitignore b/scripts/bec/.gitignore new file mode 100644 index 00000000000..8bd5bb62046 --- /dev/null +++ b/scripts/bec/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ +uv.lock diff --git a/scripts/bec/pyproject.toml b/scripts/bec/pyproject.toml new file mode 100644 index 00000000000..9066763e73c --- /dev/null +++ b/scripts/bec/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68.0", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[project] +name = "bec" +version = "0.1.0" +description = "Bitcoin Educational Content CLI — agent-first repo tooling" +requires-python = ">=3.10" +dependencies = [ + "click>=8.1", + "jsonschema>=4.18", + "referencing>=0.30", + "pyyaml>=6.0", + "python-frontmatter>=1.0", + "tqdm>=4.65", +] + +[project.scripts] +bec = "bec.cli:cli" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +bec = ["schemas/*.json", "data/*.txt"] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] diff --git a/scripts/bec/src/bec/__init__.py b/scripts/bec/src/bec/__init__.py new file mode 100644 index 00000000000..e517bfdeb73 --- /dev/null +++ b/scripts/bec/src/bec/__init__.py @@ -0,0 +1,3 @@ +"""bec — Bitcoin Educational Content CLI.""" + +__version__ = "0.1.0" diff --git a/scripts/bec/src/bec/cli.py b/scripts/bec/src/bec/cli.py new file mode 100644 index 00000000000..5d8048e8b45 --- /dev/null +++ b/scripts/bec/src/bec/cli.py @@ -0,0 +1,384 @@ +"""bec CLI — Bitcoin Educational Content tooling.""" + +import click + +from bec import __version__ + + +@click.group() +@click.version_option(version=__version__, prog_name="bec") +def cli(): + """bec — Bitcoin Educational Content CLI. + + Agent-first tooling for validation, scaffolding, and reporting. + """ + pass + + +@cli.command() +@click.argument("path", required=False) +@click.option("--all", "validate_all", is_flag=True, help="Validate all content in the repo.") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.") +@click.option("--courses-only", is_flag=True, help="Validate only courses (requires --all).") +@click.option("--tutorials-only", is_flag=True, help="Validate only tutorials (requires --all).") +@click.option("--type", "content_type", default=None, help="Validate a specific content type (requires --all).") +@click.option("--summary-only", is_flag=True, help="Show only aggregate counts (requires --all).") +def validate(path, validate_all, json_output, courses_only, tutorials_only, content_type, summary_only): + """Validate content against schemas.""" + if content_type and (courses_only or tutorials_only): + raise click.UsageError("--type cannot be combined with --courses-only/--tutorials-only.") + if courses_only and tutorials_only: + raise click.UsageError("--courses-only and --tutorials-only are mutually exclusive.") + + if validate_all: + from bec.commands.validate import run_validate_all + + type_filter = content_type + if courses_only: + type_filter = "course" + elif tutorials_only: + type_filter = "tutorial" + + run_validate_all( + json_output=json_output, + summary_only=summary_only, + type_filter=type_filter, + ) + else: + from bec.commands.validate import run_validate + + run_validate(path=path, json_output=json_output) + + +@cli.group() +def new(): + """Scaffold new content (course, tutorial, professor, event, resource).""" + pass + + +@new.command("course") +@click.option("--id", "course_id", default=None, help="Course ID (e.g., btc101, dev301).") +@click.option("--topic", default=None, help="Main topic (e.g., bitcoin, mining, security).") +@click.option("--subtopic", default=None, help="Subtopic (e.g., bitcoin, lightning, cryptography).") +@click.option("--level", default=None, help="Difficulty level (beginner, intermediate, advanced, expert).") +@click.option("--lang", default=None, help="Original language code (e.g., en, fr).") +@click.option("--professor-id", default=None, help="Professor UUID.") +@click.option("--json", "json_output", is_flag=True, help="Output created file paths as JSON.") +def new_course(course_id, topic, subtopic, level, lang, professor_id, json_output): + """Scaffold a new course.""" + from bec.commands.new import run_new_course + + run_new_course( + course_id=course_id, + topic=topic, + subtopic=subtopic, + level=level, + lang=lang, + professor_id=professor_id, + json_output=json_output, + ) + + +@new.command("tutorial") +@click.option("--category", "folder_category", default=None, help="Tutorial folder category (e.g., wallet, mining, node).") +@click.option("--id", "tutorial_id", default=None, help="Tutorial ID slug (e.g., sparrow, ledger-flex).") +@click.option("--lang", default=None, help="Original language code (e.g., en, fr).") +@click.option("--level", default=None, help="Difficulty level (beginner, intermediate, advanced, expert).") +@click.option("--professor-id", default=None, help="Professor UUID.") +@click.option("--license", "license_type", default=None, help="License type (CC-BY-SA-V4, MIT).") +@click.option("--tool-type", "schema_category", default=None, help="Tool type category (desktop, hardware, mobile, etc.).") +@click.option("--json", "json_output", is_flag=True, help="Output created file paths as JSON.") +def new_tutorial(folder_category, tutorial_id, lang, level, professor_id, license_type, schema_category, json_output): + """Scaffold a new tutorial.""" + from bec.commands.new import run_new_tutorial + + run_new_tutorial( + folder_category=folder_category, + tutorial_id=tutorial_id, + lang=lang, + level=level, + professor_id=professor_id, + license_type=license_type, + schema_category=schema_category, + json_output=json_output, + ) + + +@new.command("professor") +@click.option("--id", "professor_slug", default=None, help="Professor slug ID (e.g., satoshi-nakamoto).") +@click.option("--name", default=None, help="Professor display name.") +@click.option("--lang", default=None, help="Initial language code (e.g., en, fr).") +@click.option("--json", "json_output", is_flag=True, help="Output created file paths as JSON.") +def new_professor(professor_slug, name, lang, json_output): + """Scaffold a new professor profile.""" + from bec.commands.new import run_new_professor + + run_new_professor( + professor_slug=professor_slug, + name=name, + lang=lang, + json_output=json_output, + ) + + +@new.command("event") +@click.option("--id", "event_id", default=None, help="Event ID slug (e.g., bitcoin-paris-2025).") +@click.option("--name", default=None, help="Event name (include year).") +@click.option("--type", "event_type", default=None, help="Event type (workshop, course, conference, lecture, meetup).") +@click.option("--start-date", default=None, help="Start datetime (YYYY-MM-DD HH:MM:SS).") +@click.option("--end-date", default=None, help="End datetime (YYYY-MM-DD HH:MM:SS).") +@click.option("--timezone", default=None, help="IANA timezone (e.g., Europe/Paris, US/Central).") +@click.option("--city", default=None, help="City and country (e.g., Paris, France).") +@click.option("--lang", default=None, help="Event language code.") +@click.option("--json", "json_output", is_flag=True, help="Output created file paths as JSON.") +def new_event(event_id, name, event_type, start_date, end_date, timezone, city, lang, json_output): + """Scaffold a new event.""" + from bec.commands.new import run_new_event + + run_new_event( + event_id=event_id, + name=name, + event_type=event_type, + start_date=start_date, + end_date=end_date, + timezone=timezone, + city=city, + lang=lang, + json_output=json_output, + ) + + +@new.command("resource") +@click.option("--type", "resource_type", default=None, help="Resource type (book, podcast, channel, conference, movie, newsletter, paper, project, bet, glossary).") +@click.option("--id", "resource_id", default=None, help="Resource ID slug.") +@click.option("--lang", default=None, help="Language code (e.g., en, fr).") +@click.option("--json", "json_output", is_flag=True, help="Output created file paths as JSON.") +def new_resource(resource_type, resource_id, lang, json_output): + """Scaffold a new resource (book, podcast, channel, etc.).""" + from bec.commands.new import run_new_resource + + run_new_resource( + resource_type=resource_type, + resource_id=resource_id, + lang=lang, + json_output=json_output, + ) + + +@cli.group() +def add(): + """Add content parts (part, chapter, quiz, language).""" + pass + + +@add.command("part") +@click.option("--course", default=None, help="Course ID (e.g., btc101).") +@click.option("--lang", default=None, help="Language code (e.g., en, fr).") +@click.option("--title", default=None, help="Part title.") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def add_part(course, lang, title, json_output): + """Add a part separator to a course.""" + from bec.commands.add import run_add_part + + run_add_part(course=course, lang=lang, title=title, json_output=json_output) + + +@add.command("chapter") +@click.option("--course", default=None, help="Course ID (e.g., btc101).") +@click.option("--lang", default=None, help="Language code (e.g., en, fr).") +@click.option("--title", default=None, help="Chapter title.") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def add_chapter(course, lang, title, json_output): + """Add a chapter with auto-generated UUID chapterId.""" + from bec.commands.add import run_add_chapter + + run_add_chapter(course=course, lang=lang, title=title, json_output=json_output) + + +@add.command("quiz") +@click.option("--course", default=None, help="Course ID (e.g., btc101).") +@click.option("--chapter-id", default=None, help="Chapter ID to associate the quiz with.") +@click.option("--lang", default=None, help="Language code (e.g., en, fr).") +@click.option("--difficulty", default=None, help="Difficulty (easy, intermediate, hard, expert).") +@click.option("--author", default=None, help="Author contributor ID.") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def add_quiz(course, chapter_id, lang, difficulty, author, json_output): + """Add a quiz question to a course chapter.""" + from bec.commands.add import run_add_quiz + + run_add_quiz( + course=course, + chapter_id=chapter_id, + lang=lang, + difficulty=difficulty, + author=author, + json_output=json_output, + ) + + +@add.command("language") +@click.option("--path", "content_path", default=None, help="Content path (e.g., courses/btc101, tutorials/wallet/sparrow).") +@click.option("--lang", default=None, help="Target language code (e.g., fr, es).") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def add_language(content_path, lang, json_output): + """Add a new language file to existing content.""" + from bec.commands.add import run_add_language + + run_add_language( + content_path=content_path, + lang=lang, + json_output=json_output, + ) + + +@cli.group() +def proofread(): + """Manage proofreading metadata.""" + pass + + +@proofread.command("update") +@click.option("--path", default=None, help="Content path (e.g., courses/btc101).") +@click.option("--lang", default=None, help="Language code (e.g., en, fr).") +@click.option("--contributor", default=None, help="Contributor GitHub username.") +@click.option("--recalc", is_flag=True, help="Recalculate rewards after update.") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def proofread_update(path, lang, contributor, recalc, json_output): + """Update proofreading metadata for a content item.""" + from bec.commands.proofread import run_proofread_update + + run_proofread_update( + path=path, lang=lang, contributor=contributor, + recalc=recalc, json_output=json_output, + ) + + +@proofread.command("reward") +@click.option("--path", default=None, help="Content path (e.g., courses/btc101).") +@click.option("--lang", default=None, help="Language code (optional, shows all if omitted).") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def proofread_reward(path, lang, json_output): + """Calculate proofreading rewards.""" + from bec.commands.proofread import run_proofread_reward + + run_proofread_reward(path=path, lang=lang, json_output=json_output) + + +@proofread.command("batch-add") +@click.option("--contributor", default=None, help="Contributor GitHub username.") +@click.option("--lang", default=None, help="Language code (e.g., en, fr).") +@click.option("--recalc", is_flag=True, help="Recalculate rewards after update.") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +@click.argument("paths", nargs=-1) +def proofread_batch_add(contributor, lang, recalc, json_output, paths): + """Bulk-update proofreading metadata.""" + from bec.commands.proofread import run_proofread_batch_add + + run_proofread_batch_add( + contributor=contributor, lang=lang, paths=paths, + recalc=recalc, json_output=json_output, + ) + + +@proofread.command("status") +@click.option("--path", default=None, help="Content path (e.g., courses/btc101).") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def proofread_status(path, json_output): + """Show proofreading status for a content item.""" + from bec.commands.proofread import run_proofread_status + + run_proofread_status(path=path, json_output=json_output) + + +@cli.group(invoke_without_command=True) +@click.option("--all", "run_all", is_flag=True, help="Run all reports.") +@click.option("--output", default=None, help="Output directory for HTML reports.") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.") +@click.pass_context +def report(ctx, run_all, output, json_output): + """Generate HTML/JSON reports.""" + ctx.ensure_object(dict) + ctx.obj["output"] = output + ctx.obj["json_output"] = json_output + if run_all: + from bec.commands.report import run_report_all + + run_report_all(output=output, json_output=json_output) + elif ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit(2) + + +@report.command("translation") +@click.option("--output", default=None, help="Output directory for HTML report.") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.") +@click.pass_context +def report_translation(ctx, output, json_output): + """Generate markdown translation coverage report.""" + from bec.commands.report import run_report_translation + + # Allow parent group options to propagate + out = output or ctx.obj.get("output") + j = json_output or ctx.obj.get("json_output", False) + run_report_translation(output=out, json_output=j) + + +@report.command("images") +@click.option("--output", default=None, help="Output directory for HTML report.") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.") +@click.pass_context +def report_images(ctx, output, json_output): + """Generate image translation progress report.""" + from bec.commands.report import run_report_images + + out = output or ctx.obj.get("output") + j = json_output or ctx.obj.get("json_output", False) + run_report_images(output=out, json_output=j) + + +@report.command("video") +@click.option("--output", default=None, help="Output directory for HTML report.") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.") +@click.pass_context +def report_video(ctx, output, json_output): + """Generate video deployment status report.""" + from bec.commands.report import run_report_video + + out = output or ctx.obj.get("output") + j = json_output or ctx.obj.get("json_output", False) + run_report_video(output=out, json_output=j) + + +@report.command("proofreading") +@click.option("--output", default=None, help="Output directory for HTML report.") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.") +@click.pass_context +def report_proofreading(ctx, output, json_output): + """Generate proofreading dashboard.""" + from bec.commands.report import run_report_proofreading + + out = output or ctx.obj.get("output") + j = json_output or ctx.obj.get("json_output", False) + run_report_proofreading(output=out, json_output=j) + + +@report.command("analytics") +@click.option("--output", default=None, help="Output directory for HTML report.") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON.") +@click.pass_context +def report_analytics(ctx, output, json_output): + """Generate course analytics report.""" + from bec.commands.report import run_report_analytics + + out = output or ctx.obj.get("output") + j = json_output or ctx.obj.get("json_output", False) + run_report_analytics(output=out, json_output=j) + + +@cli.command("agent-setup") +@click.option("--json", "json_output", is_flag=True, help="Output result as JSON.") +def agent_setup(json_output): + """Symlink AGENTS.md and CLAUDE.md to repo root.""" + from bec.commands.agent_setup import run_agent_setup + + run_agent_setup(json_output=json_output) diff --git a/scripts/bec/src/bec/commands/__init__.py b/scripts/bec/src/bec/commands/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/scripts/bec/src/bec/commands/add.py b/scripts/bec/src/bec/commands/add.py new file mode 100644 index 00000000000..68785216ed3 --- /dev/null +++ b/scripts/bec/src/bec/commands/add.py @@ -0,0 +1,524 @@ +"""Add content parts to existing course files (part, chapter, quiz, language).""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import date +from pathlib import Path + +import click + +from bec.lib.content_types import load_registry +from bec.lib.markdown import ( + append_to_markdown, + build_chapter_block, + build_part_block, + generate_chapter_id, +) +from bec.lib.repo import find_repo_root +from bec.lib.yaml_utils import dump_yaml + + +def _resolve_course_md(course_id: str, lang: str, repo_root: Path) -> Path: + """Resolve the markdown file path for a course + language. + + Raises click.ClickException if the file doesn't exist. + """ + md_path = repo_root / "courses" / course_id / f"{lang}.md" + if not md_path.is_file(): + raise click.ClickException( + f"Course markdown not found: {md_path.relative_to(repo_root)}" + ) + return md_path + + +def _list_course_ids(repo_root: Path) -> list[str]: + """List available course IDs from the courses/ directory.""" + courses_dir = repo_root / "courses" + if not courses_dir.is_dir(): + return [] + return sorted( + d.name for d in courses_dir.iterdir() + if d.is_dir() and (d / "course.yml").is_file() + ) + + +def _list_course_langs(course_id: str, repo_root: Path) -> list[str]: + """List available language codes for a course (excludes non-language .md files).""" + course_dir = repo_root / "courses" / course_id + if not course_dir.is_dir(): + return [] + languages = load_registry(repo_root).languages + return sorted( + p.stem for p in course_dir.glob("*.md") if p.stem in languages + ) + + +# --- Part --- + + +def run_add_part( + course: str | None, + lang: str | None, + title: str | None, + json_output: bool, +) -> None: + """Add a part separator to a course markdown file.""" + repo_root = find_repo_root() + + # Interactive prompts for missing args + if not course: + ids = _list_course_ids(repo_root) + if ids: + click.echo(f"Available courses: {', '.join(ids)}") + course = click.prompt("Course ID") + + if not lang: + langs = _list_course_langs(course, repo_root) + if langs: + click.echo(f"Available languages: {', '.join(langs)}") + lang = click.prompt("Language code", default="en") + + if not title: + title = click.prompt("Part title") + + md_path = _resolve_course_md(course, lang, repo_root) + + part_id = str(uuid.uuid4()) + block = build_part_block(title, part_id) + append_to_markdown(md_path, block) + + rel_path = str(md_path.relative_to(repo_root)) + if json_output: + click.echo(json.dumps({ + "action": "add_part", + "course": course, + "lang": lang, + "title": title, + "part_id": part_id, + "file": rel_path, + }, indent=2)) + else: + click.echo(f"Added part '{title}' to {rel_path}") + click.echo(f" partId: {part_id}") + + +# --- Chapter --- + + +def run_add_chapter( + course: str | None, + lang: str | None, + title: str | None, + json_output: bool, +) -> None: + """Add a chapter heading with auto-generated UUID chapterId.""" + repo_root = find_repo_root() + + # Interactive prompts for missing args + if not course: + ids = _list_course_ids(repo_root) + if ids: + click.echo(f"Available courses: {', '.join(ids)}") + course = click.prompt("Course ID") + + if not lang: + langs = _list_course_langs(course, repo_root) + if langs: + click.echo(f"Available languages: {', '.join(langs)}") + lang = click.prompt("Language code", default="en") + + if not title: + title = click.prompt("Chapter title") + + md_path = _resolve_course_md(course, lang, repo_root) + + chapter_id = generate_chapter_id() + block = build_chapter_block(title, chapter_id) + append_to_markdown(md_path, block) + + rel_path = str(md_path.relative_to(repo_root)) + if json_output: + click.echo(json.dumps({ + "action": "add_chapter", + "course": course, + "lang": lang, + "title": title, + "chapter_id": chapter_id, + "file": rel_path, + }, indent=2)) + else: + click.echo(f"Added chapter '{title}' to {rel_path}") + click.echo(f" chapterId: {chapter_id}") + + +# --- Quiz --- + + +def _next_quiz_number(quizz_dir: Path) -> int: + """Find the next quiz number by scanning existing quizz/{nnn}/ folders.""" + if not quizz_dir.is_dir(): + return 0 + existing = [] + for d in quizz_dir.iterdir(): + if d.is_dir() and re.match(r"^\d{3}$", d.name): + existing.append(int(d.name)) + return max(existing) + 1 if existing else 0 + + +def _list_chapter_ids(course_id: str, repo_root: Path) -> list[str]: + """Extract all chapterIds from course markdown files.""" + course_dir = repo_root / "courses" / course_id + if not course_dir.is_dir(): + return [] + chapter_ids = [] + for md_path in sorted(course_dir.glob("*.md")): + text = md_path.read_text(encoding="utf-8") + chapter_ids.extend(re.findall(r"(.+?)", text)) + # Deduplicate while preserving order + seen = set() + result = [] + for cid in chapter_ids: + if cid not in seen: + seen.add(cid) + result.append(cid) + return result + + +def run_add_quiz( + course: str | None, + chapter_id: str | None, + lang: str | None, + difficulty: str | None, + author: str | None, + json_output: bool, +) -> None: + """Add a quiz question folder to a course.""" + repo_root = find_repo_root() + + # Interactive prompts for missing args + if not course: + ids = _list_course_ids(repo_root) + if ids: + click.echo(f"Available courses: {', '.join(ids)}") + course = click.prompt("Course ID") + + course_dir = repo_root / "courses" / course + if not course_dir.is_dir(): + raise click.ClickException(f"Course not found: courses/{course}") + + cids = _list_chapter_ids(course, repo_root) + if not chapter_id: + if cids: + click.echo(f"Available chapterIds: {', '.join(cids)}") + chapter_id = click.prompt("Chapter ID") + if chapter_id not in cids: + raise click.ClickException( + f"Chapter ID '{chapter_id}' not found in courses/{course} markdown files" + ) + + registry = load_registry(repo_root) + if not lang: + langs = _list_course_langs(course, repo_root) + if langs: + click.echo(f"Available languages: {', '.join(langs)}") + lang = click.prompt("Language code", default="en") + if lang not in registry.languages: + raise click.ClickException( + f"Invalid language '{lang}'. Choose from: {', '.join(registry.languages)}" + ) + + from bec.lib.schema import load_json_schema + question_schema = load_json_schema(repo_root / registry.quiz_schemas["question"]) + valid_difficulties = question_schema["properties"]["difficulty"]["enum"] + if not difficulty: + difficulty = click.prompt( + f"Difficulty ({', '.join(valid_difficulties)})", default="easy" + ) + if difficulty not in valid_difficulties: + raise click.ClickException( + f"Invalid difficulty '{difficulty}'. Choose from: {', '.join(valid_difficulties)}" + ) + + if not author: + author = click.prompt("Author (contributor ID)", default="TODO") + + # Find next quiz number + quizz_dir = course_dir / "quizz" + next_num = _next_quiz_number(quizz_dir) + quiz_folder_name = f"{next_num:03d}" + quiz_dir = quizz_dir / quiz_folder_name + + # Create quiz folder + quiz_dir.mkdir(parents=True) + + # Build question.yml + question_data = { + "id": str(uuid.uuid4()), + "chapterId": chapter_id, + "difficulty": difficulty, + "author": author, + "tags": ["software"], + } + question_path = quiz_dir / "question.yml" + dump_yaml(question_data, question_path) + + # Build language translation file + translation_data = { + "question": "TODO: Write the question text", + "answer": "TODO: Write the correct answer", + "wrong_answers": [ + "TODO: First wrong answer", + "TODO: Second wrong answer", + "TODO: Third wrong answer", + ], + "explanation": "TODO: Explain why the answer is correct.\n", + "reviewed": False, + } + translation_path = quiz_dir / f"{lang}.yml" + dump_yaml(translation_data, translation_path) + + created_files = [ + str(question_path.relative_to(repo_root)), + str(translation_path.relative_to(repo_root)), + ] + + if json_output: + click.echo(json.dumps({ + "action": "add_quiz", + "course": course, + "quiz_number": quiz_folder_name, + "chapter_id": chapter_id, + "lang": lang, + "difficulty": difficulty, + "directory": str(quiz_dir.relative_to(repo_root)), + "files": created_files, + }, indent=2)) + else: + click.echo(f"Created quiz {quiz_folder_name} in courses/{course}/quizz/") + click.echo(f" chapterId: {chapter_id}") + click.echo(f" difficulty: {difficulty}") + for f in created_files: + click.echo(f" - {f}") + + +# --- Language --- + + +def _detect_content_path(path_str: str, repo_root: Path) -> tuple[Path, str]: + """Resolve a content path and detect its type. + + Returns (content_dir, content_type_key). + Raises click.ClickException on invalid paths. + """ + content_dir = (repo_root / path_str).resolve() + if not content_dir.is_dir(): + raise click.ClickException(f"Content directory not found: {path_str}") + + registry = load_registry(repo_root) + ct = registry.detect_type_from_path(content_dir, repo_root) + if ct is None: + raise click.ClickException(f"Cannot detect content type for: {path_str}") + return content_dir, ct.key + + +def _find_source_lang(content_dir: Path) -> str | None: + """Find the first available language file (md or yml) in a content directory.""" + # Prefer en if available + if (content_dir / "en.md").is_file(): + return "en" + if (content_dir / "en.yml").is_file(): + return "en" + # Otherwise pick the first .md or .yml that looks like a language code + # (including regional codes like zh-Hans, sr-Latn, nb-NO) + lang_re = re.compile(r"^[a-z]{2}(-[A-Za-z]{2,4})?$") + for f in sorted(content_dir.iterdir()): + if f.suffix in (".md", ".yml") and lang_re.match(f.stem): + return f.stem + return None + + +def _create_language_md( + source_path: Path, target_path: Path, target_lang: str +) -> None: + """Create a new language markdown file from a source. + + Preserves: frontmatter keys, heading structure, chapterId/partId tags, + +++ separators. Replaces: prose content with TODO placeholders, + frontmatter values with TODO. + """ + source_text = source_path.read_text(encoding="utf-8") + + # Split frontmatter from body + if source_text.startswith("---\n"): + try: + end_idx = source_text.index("\n---\n", 4) + except ValueError: + raise click.ClickException( + f"Unterminated frontmatter in {source_path}: missing closing '---'" + ) + frontmatter_block = source_text[4:end_idx] + body = source_text[end_idx + 5:] + else: + frontmatter_block = "" + body = source_text + + # Replace frontmatter values with TODO + new_fm_lines = [] + for line in frontmatter_block.splitlines(): + if line.startswith(" - "): + # List item — replace value with TODO + new_fm_lines.append(' - "TODO"') + elif ":" in line and not line.startswith(" "): + key = line.split(":", 1)[0] + value = line.split(":", 1)[1].strip() + if value and value != "|": + new_fm_lines.append(f'{key}: "TODO"') + else: + new_fm_lines.append(line) + else: + new_fm_lines.append(line) + + # Process body: preserve structure, replace prose with TODO + new_body_lines = [] + for line in body.splitlines(): + # Preserve part separators + if line.strip() == "+++": + new_body_lines.append(line) + # Preserve heading structure + elif line.startswith("#"): + new_body_lines.append(line.split(" ", 1)[0] + " TODO") + # Preserve chapterId/partId tags + elif "" in line or "" in line: + new_body_lines.append(line) + # Preserve image references (just update lang path if needed) + elif line.strip().startswith("!["): + new_body_lines.append(line) + # Preserve empty lines + elif not line.strip(): + new_body_lines.append("") + # Preserve video embeds and other special tags + elif line.strip().startswith(":::") or line.strip().startswith("<"): + new_body_lines.append(line) + else: + # Replace prose with TODO + new_body_lines.append("TODO") + + new_frontmatter = "\n".join(new_fm_lines) + new_body = "\n".join(new_body_lines) + + if new_frontmatter: + result = f"---\n{new_frontmatter}\n---\n{new_body}\n" + else: + result = f"{new_body}\n" + + target_path.write_text(result, encoding="utf-8") + + +def _create_language_yml( + source_path: Path, target_path: Path +) -> None: + """Create a new language YML file from a source. + + Replaces string values with TODO, preserves structure. + """ + from bec.lib.yaml_utils import load_yaml + + data = load_yaml(source_path) + if data is None: + data = {} + + def _replace_values(obj): + if isinstance(obj, dict): + return {k: _replace_values(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_replace_values(item) for item in obj] + elif isinstance(obj, str): + return "TODO" + elif isinstance(obj, bool): + return obj + elif isinstance(obj, (int, float)): + return obj + return obj + + new_data = _replace_values(data) + dump_yaml(new_data, target_path) + + +def run_add_language( + content_path: str | None, + lang: str | None, + json_output: bool, +) -> None: + """Add a new language file to existing content.""" + repo_root = find_repo_root() + + if not content_path: + content_path = click.prompt("Content path (e.g., courses/btc101)") + + content_dir, ct_key = _detect_content_path(content_path, repo_root) + rel_dir = str(content_dir.relative_to(repo_root)) + + registry = load_registry(repo_root) + + if not lang: + click.echo(f"Available languages: {', '.join(registry.languages)}") + lang = click.prompt("Target language code") + + if lang not in registry.languages: + raise click.ClickException( + f"Invalid language '{lang}'. Choose from: {', '.join(registry.languages)}" + ) + + # Find source language to copy structure from + source_lang = _find_source_lang(content_dir) + if source_lang is None: + raise click.ClickException( + f"No source language file found in {rel_dir}. " + "Need at least one existing language file to copy structure from." + ) + + created_files = [] + + # Determine file type (md or yml) based on source + source_md = content_dir / f"{source_lang}.md" + source_yml = content_dir / f"{source_lang}.yml" + + if source_md.is_file(): + target_md = content_dir / f"{lang}.md" + if target_md.exists(): + raise click.ClickException( + f"Language file already exists: {target_md.relative_to(repo_root)}" + ) + _create_language_md(source_md, target_md, lang) + created_files.append(str(target_md.relative_to(repo_root))) + + if source_yml.is_file(): + target_yml = content_dir / f"{lang}.yml" + if target_yml.exists(): + raise click.ClickException( + f"Language file already exists: {target_yml.relative_to(repo_root)}" + ) + _create_language_yml(source_yml, target_yml) + created_files.append(str(target_yml.relative_to(repo_root))) + + if not created_files: + raise click.ClickException( + f"No language file found for '{source_lang}' in {rel_dir}" + ) + + if json_output: + click.echo(json.dumps({ + "action": "add_language", + "content_path": rel_dir, + "content_type": ct_key, + "source_lang": source_lang, + "target_lang": lang, + "files": created_files, + }, indent=2)) + else: + click.echo(f"Added language '{lang}' to {rel_dir} (from '{source_lang}')") + for f in created_files: + click.echo(f" - {f}") diff --git a/scripts/bec/src/bec/commands/agent_setup.py b/scripts/bec/src/bec/commands/agent_setup.py new file mode 100644 index 00000000000..305fc400379 --- /dev/null +++ b/scripts/bec/src/bec/commands/agent_setup.py @@ -0,0 +1,89 @@ +"""bec agent-setup — Symlink agent orientation files to repo root.""" + +import json +import sys +from pathlib import Path + +import click + +from bec.lib.repo import find_repo_root + +# Files to symlink: (source relative to repo root, link name at repo root) +AGENT_FILES = [ + ("docs/agents/AGENTS.md", "AGENTS.md"), + ("docs/agents/CLAUDE.md", "CLAUDE.md"), +] + + +def run_agent_setup(*, json_output: bool = False) -> None: + """Create symlinks for AGENTS.md and CLAUDE.md at repo root.""" + try: + repo_root = find_repo_root() + except FileNotFoundError: + click.echo("Error: cannot find repo root (no content-types.yml found).", err=True) + raise SystemExit(1) + + results = [] + + for source_rel, link_name in AGENT_FILES: + source = repo_root / source_rel + link_path = repo_root / link_name + + if not source.exists(): + click.echo(f"Error: source file not found: {source_rel}", err=True) + raise SystemExit(1) + + # Compute relative path from repo root to source + target = Path(source_rel) + + status = _create_symlink(link_path, target, link_name) + results.append({"file": link_name, "target": source_rel, "status": status}) + + if json_output: + click.echo(json.dumps({"symlinks": results}, indent=2)) + else: + for r in results: + icon = "ok" if r["status"] in ("created", "already_correct") else "updated" + click.echo(f" {icon}: {r['file']} -> {r['target']} ({r['status']})") + click.echo("\nAgent orientation files are ready at repo root.") + + +def _backup_path(link_path: Path) -> Path: + """Return a backup path that does not clobber an existing one.""" + backup = link_path.with_name(link_path.name + ".bak") + counter = 1 + while backup.exists() or backup.is_symlink(): + backup = link_path.with_name(f"{link_path.name}.bak.{counter}") + counter += 1 + return backup + + +def _symlink(link_path: Path, target: Path, name: str) -> None: + try: + link_path.symlink_to(target) + except OSError as e: + raise click.ClickException(f"failed to create symlink {name}: {e}") + + +def _create_symlink(link_path: Path, target: Path, name: str) -> str: + """Create or update a symlink. Returns status string.""" + if link_path.is_symlink(): + existing_target = link_path.readlink() + if existing_target == target: + return "already_correct" + # Remove stale symlink and recreate + link_path.unlink() + _symlink(link_path, target, name) + return "updated" + + if link_path.exists(): + # A real file exists — back it up and replace + try: + link_path.rename(_backup_path(link_path)) + except OSError as e: + raise click.ClickException(f"failed to back up {name}: {e}") + _symlink(link_path, target, name) + return "replaced" + + _symlink(link_path, target, name) + return "created" diff --git a/scripts/bec/src/bec/commands/new.py b/scripts/bec/src/bec/commands/new.py new file mode 100644 index 00000000000..049a3bde06a --- /dev/null +++ b/scripts/bec/src/bec/commands/new.py @@ -0,0 +1,1026 @@ +"""Scaffold new content items.""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import date, datetime +from pathlib import Path + +import click + +from bec.lib.content_types import ContentRegistry, load_registry +from bec.lib.repo import find_repo_root +from bec.lib.schema import load_json_schema +from bec.lib.yaml_utils import dump_yaml + +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + +# Minimal valid 1x1 white lossless WebP (VP8L, 38 bytes) for scaffold cover images +import base64 as _b64 +_PLACEHOLDER_WEBP = _b64.b64decode( + b"UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=" +) + +# Resource type keys that live under resources/ +RESOURCE_TYPE_KEYS = frozenset({ + "bet", "book", "channel", "conference", "glossary", + "movie", "newsletter", "paper", "podcast", "project", +}) + +# --- Shared helpers --- + + +def _make_proofreading_entry(lang: str) -> dict: + """Build a single proofreading entry for a language.""" + return { + "language": lang, + "last_contribution_date": date.today().strftime("%Y-%m-%d"), + "urgency": 1, + "contributor_names": ["TODO"], + "reward": 0, + } + + +def _validate_uuid(value: str) -> bool: + return bool(UUID_RE.match(value)) + + +def _validate_slug(value: str) -> str | None: + """Validate a content slug ID. Returns error message or None.""" + if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", value) and len(value) > 1: + return f"Invalid ID '{value}'. Use lowercase letters, digits, and hyphens (e.g., my-content-name)." + if len(value) < 2: + return f"ID '{value}' is too short. Minimum 2 characters." + return None + + +def _prompt_slug(label: str) -> str: + """Prompt for a valid slug ID.""" + while True: + value = click.prompt(f"{label} ID (slug)").strip().lower() + err = _validate_slug(value) + if err: + click.echo(f"Error: {err}", err=True) + continue + return value + + +def _dir_from_path_pattern(path_pattern: str, **kwargs: str) -> str: + """Resolve a path_pattern with the given placeholders.""" + result = path_pattern.rstrip("/") + for key, val in kwargs.items(): + result = result.replace(f"{{{key}}}", val) + return result + + +def _scaffold_output( + content_type: str, + content_id: str, + content_uuid: str, + content_dir: Path, + created_files: list[str], + repo_root: Path, + json_output: bool, + extra_json: dict | None = None, +) -> None: + """Print creation output in human or JSON format.""" + if json_output: + data = { + "type": content_type, + "id": content_id, + "uuid": content_uuid, + "directory": str(content_dir.relative_to(repo_root)), + "files": created_files, + } + if extra_json: + data.update(extra_json) + click.echo(json.dumps(data, indent=2)) + else: + click.echo(f"Created {content_type} '{content_id}' at {content_dir.relative_to(repo_root)}/") + click.echo(f" UUID: {content_uuid}") + for f in created_files: + click.echo(f" - {f}") + click.echo(f"\nNext: bec validate {content_dir.relative_to(repo_root)}") + + +# --- Course ID validation --- + +# Pattern: 3-letter discipline code + 3-digit number (e.g., btc101, dev301) +COURSE_ID_RE = re.compile(r"^[a-z]{3}\d{3}$") + + +def validate_course_id(course_id: str, registry) -> str | None: + """Validate a course ID. Returns error message or None if valid.""" + if not COURSE_ID_RE.match(course_id): + return ( + f"Invalid course ID '{course_id}'. " + "Must be 3-letter discipline code + 3-digit number (e.g., btc101, dev301)." + ) + prefix = course_id[:3] + if prefix not in registry.discipline_codes: + valid = ", ".join(sorted(registry.discipline_codes.keys())) + return ( + f"Unknown discipline code '{prefix}'. " + f"Valid codes: {valid}" + ) + number = int(course_id[3:]) + min_val = int(registry.level_range["min"]) + max_val = int(registry.level_range["max"]) + if number < min_val or number > max_val: + return ( + f"Course number {number} is out of range. " + f"Must be between {min_val} and {max_val}." + ) + return None + + +def level_from_number(number: int, registry) -> str: + """Infer level from the course number using level_range config.""" + for level_name in ("beginner", "intermediate", "advanced", "expert"): + range_str = registry.level_range.get(level_name, "") + if "-" in range_str: + lo, hi = range_str.split("-") + if int(lo) <= number <= int(hi): + return level_name + return "beginner" + + +# --- Schema introspection --- + +def get_enum_values(schema: dict, field_name: str) -> list[str]: + """Extract enum values for a field from a JSON schema.""" + props = schema.get("properties", {}) + field_schema = props.get(field_name, {}) + return field_schema.get("enum", []) + + +# --- YAML generation --- + +def build_course_yml( + course_uuid: str, + topic: str, + subtopic: str, + level: str, + lang: str, + professor_id: str, + hours: int = 1, + course_type: str = "theory", +) -> dict: + """Build the course.yml data dict.""" + return { + "id": course_uuid, + "topic": topic, + "subtopic": subtopic, + "type": course_type, + "level": level, + "hours": hours, + "teaching_format": "self_paced", + "professors_id": [professor_id], + "contributor_names": ["TODO"], + "published_at": date.today().strftime("%Y-%m-%d"), + "original_language": lang, + "proofreading": [ + { + "language": lang, + "last_contribution_date": date.today().strftime("%Y-%m-%d"), + "urgency": 1, + "contributor_names": ["TODO"], + "reward": 0, + } + ], + "tags": ["software"], + } + + +def build_course_md(course_id: str, lang: str) -> str: + """Build the course markdown content with frontmatter and skeleton structure.""" + return f"""--- +name: "TODO: Course Title" +goal: "TODO: Describe the main goal of the course in one sentence" +objectives: + - "TODO: First learning objective" + - "TODO: Second learning objective" + - "TODO: Third learning objective" +--- + +# TODO: Course Description Title + +TODO: Write a description of {course_id.upper()} here. This section appears before the first part separator. + ++++ + +# Part 1 — TODO: Part Title + +{uuid.uuid4()} + +## Chapter 1 — TODO: Chapter Title + +{uuid.uuid4()} + +TODO: Write chapter content here. +""" + + +# --- Interactive prompts --- + +def prompt_course_id(registry) -> str: + """Interactively prompt for a valid course ID.""" + codes = ", ".join(f"{k} ({v})" for k, v in sorted(registry.discipline_codes.items())) + click.echo(f"\nDiscipline codes: {codes}") + click.echo(f"Number range: {registry.level_range['min']}-{registry.level_range['max']}") + + while True: + course_id = click.prompt("Course ID (e.g., btc101)").strip().lower() + err = validate_course_id(course_id, registry) + if err: + click.echo(f"Error: {err}", err=True) + continue + return course_id + + +def prompt_enum(field_name: str, choices: list[str], default: str | None = None) -> str: + """Prompt user to pick from enum values (case-insensitive, returns canonical value).""" + click.echo(f"\n{field_name} options: {', '.join(choices)}") + canonical = {c.lower(): c for c in choices} + while True: + value = click.prompt(field_name, default=default or "").strip().lower() + if value in canonical: + return canonical[value] + click.echo(f"Invalid. Choose from: {', '.join(choices)}", err=True) + + +def prompt_language(registry) -> str: + """Prompt for a valid language code.""" + click.echo(f"\nLanguages: {', '.join(registry.languages)}") + while True: + lang = click.prompt("Language", default="en").strip() + if lang in registry.languages: + return lang + click.echo(f"Invalid language. Choose from: {', '.join(registry.languages)}", err=True) + + +def prompt_uuid(label: str = "Professor ID") -> str: + """Prompt for a valid UUID.""" + while True: + pid = click.prompt(f"{label} (UUID)").strip() + if _validate_uuid(pid): + return pid + click.echo("Invalid UUID format. Expected: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", err=True) + + +# --- Main command logic --- + +def run_new_course( + course_id: str | None, + topic: str | None, + subtopic: str | None, + level: str | None, + lang: str | None, + professor_id: str | None, + json_output: bool, +) -> None: + """Create a new course scaffold.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + # Load schema for enum introspection + schema_path = repo_root / registry.content_types["course"].schema + schema = load_json_schema(schema_path) + + topics = get_enum_values(schema, "topic") + subtopics = get_enum_values(schema, "subtopic") + levels = get_enum_values(schema, "level") + + # Interactive fallback for missing args + if course_id is None: + course_id = prompt_course_id(registry) + else: + course_id = course_id.strip().lower() + err = validate_course_id(course_id, registry) + if err: + click.echo(f"Error: {err}", err=True) + raise SystemExit(1) + + if topic is None: + topic = prompt_enum("topic", topics) + elif topic not in topics: + click.echo(f"Error: invalid topic '{topic}'. Allowed: {', '.join(topics)}", err=True) + raise SystemExit(1) + + if subtopic is None: + subtopic = prompt_enum("subtopic", subtopics, default=topic) + elif subtopic not in subtopics: + click.echo(f"Error: invalid subtopic '{subtopic}'. Allowed: {', '.join(subtopics)}", err=True) + raise SystemExit(1) + + if level is None: + # Infer default from course number + default_level = level_from_number(int(course_id[3:]), registry) + level = prompt_enum("level", levels, default=default_level) + elif level not in levels: + click.echo(f"Error: invalid level '{level}'. Allowed: {', '.join(levels)}", err=True) + raise SystemExit(1) + + if lang is None: + lang = prompt_language(registry) + elif lang not in registry.languages: + click.echo(f"Error: invalid language '{lang}'. Allowed: {', '.join(registry.languages)}", err=True) + raise SystemExit(1) + + if professor_id is None: + professor_id = prompt_uuid("Professor ID") + else: + if not _validate_uuid(professor_id): + click.echo(f"Error: invalid professor ID '{professor_id}'. Must be a UUID.", err=True) + raise SystemExit(1) + + # Check if course directory already exists + course_dir = repo_root / "courses" / course_id + if course_dir.exists(): + click.echo(f"Error: course directory already exists: {course_dir}", err=True) + raise SystemExit(1) + + # Generate UUID for course + course_uuid = str(uuid.uuid4()) + + # Build files + course_yml_data = build_course_yml( + course_uuid=course_uuid, + topic=topic, + subtopic=subtopic, + level=level, + lang=lang, + professor_id=professor_id, + ) + course_md_content = build_course_md(course_id, lang) + + # Write files + course_dir.mkdir(parents=True) + yml_path = course_dir / "course.yml" + md_path = course_dir / f"{lang}.md" + assets_dir = course_dir / "assets" + assets_dir.mkdir() + (assets_dir / ".gitkeep").touch() + + dump_yaml(course_yml_data, yml_path) + md_path.write_text(course_md_content, encoding="utf-8") + + created_files = [ + str(yml_path.relative_to(repo_root)), + str(md_path.relative_to(repo_root)), + str(assets_dir.relative_to(repo_root)) + "/", + ] + + if json_output: + click.echo(json.dumps({ + "course_id": course_id, + "uuid": course_uuid, + "directory": str(course_dir.relative_to(repo_root)), + "files": created_files, + }, indent=2)) + else: + click.echo(f"Created course '{course_id}' at {course_dir.relative_to(repo_root)}/") + click.echo(f" UUID: {course_uuid}") + for f in created_files: + click.echo(f" - {f}") + click.echo("\nNext steps:") + click.echo(f" 1. Edit {yml_path.relative_to(repo_root)} — fill in contributor_names, tags") + click.echo(f" 2. Edit {md_path.relative_to(repo_root)} — write course content") + click.echo(f" 3. Add thumbnail to {assets_dir.relative_to(repo_root)}/thumbnail.webp") + click.echo(f" 4. Run: bec validate courses/{course_id}") + + +# ======================================================================== +# Tutorial scaffolding +# ======================================================================== + + +def build_tutorial_yml( + tutorial_uuid: str, + level: str, + schema_category: str, + professor_id: str, + license_type: str, + lang: str, +) -> dict: + """Build the tutorial.yml data dict.""" + return { + "id": tutorial_uuid, + "level": level, + "category": schema_category, + "professor_id": professor_id, + "license": license_type, + "original_language": lang, + "proofreading": [_make_proofreading_entry(lang)], + "tags": ["software"], + } + + +def build_tutorial_md(tutorial_id: str) -> str: + """Build the tutorial markdown content with frontmatter.""" + return f"""--- +name: "TODO: Tutorial Title" +description: "TODO: Describe {tutorial_id} in one sentence" +--- +![cover](assets/cover.webp) + +## Introduction + +TODO: Write tutorial content here. +""" + + +def run_new_tutorial( + folder_category: str | None, + tutorial_id: str | None, + lang: str | None, + level: str | None, + professor_id: str | None, + license_type: str | None, + schema_category: str | None, + json_output: bool, +) -> None: + """Create a new tutorial scaffold.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + # Load schema for enum introspection + schema_path = repo_root / registry.content_types["tutorial"].schema + schema = load_json_schema(schema_path) + levels = get_enum_values(schema, "level") + categories_schema = get_enum_values(schema, "category") + licenses = get_enum_values(schema, "license") + + valid_folder_categories = registry.tutorial_categories + + # --- Validate / prompt folder category --- + if folder_category is None: + click.echo(f"\nTutorial folder categories: {', '.join(valid_folder_categories)}") + while True: + folder_category = click.prompt("Folder category").strip().lower() + if folder_category in valid_folder_categories: + break + click.echo(f"Invalid. Choose from: {', '.join(valid_folder_categories)}", err=True) + elif folder_category not in valid_folder_categories: + click.echo( + f"Error: invalid tutorial category '{folder_category}'. " + f"Valid categories: {', '.join(valid_folder_categories)}", + err=True, + ) + raise SystemExit(1) + + # --- Validate / prompt ID --- + if tutorial_id is None: + tutorial_id = _prompt_slug("Tutorial") + else: + tutorial_id = tutorial_id.strip().lower() + err = _validate_slug(tutorial_id) + if err: + click.echo(f"Error: {err}", err=True) + raise SystemExit(1) + + # Check duplicate + tuto_dir = repo_root / "tutorials" / folder_category / tutorial_id + if tuto_dir.exists(): + click.echo(f"Error: tutorial directory already exists: {tuto_dir}", err=True) + raise SystemExit(1) + + # --- Validate / prompt remaining fields --- + if lang is None: + lang = prompt_language(registry) + elif lang not in registry.languages: + click.echo(f"Error: invalid language '{lang}'. Allowed: {', '.join(registry.languages)}", err=True) + raise SystemExit(1) + + if level is None: + level = prompt_enum("level", levels, default="beginner") + elif level not in levels: + click.echo(f"Error: invalid level '{level}'. Allowed: {', '.join(levels)}", err=True) + raise SystemExit(1) + + if professor_id is None: + professor_id = prompt_uuid("Professor ID") + elif not _validate_uuid(professor_id): + click.echo(f"Error: invalid professor ID '{professor_id}'. Must be a UUID.", err=True) + raise SystemExit(1) + + if license_type is None: + license_type = prompt_enum("license", licenses, default="CC-BY-SA-V4") + elif license_type not in licenses: + click.echo(f"Error: invalid license '{license_type}'. Allowed: {', '.join(licenses)}", err=True) + raise SystemExit(1) + + if schema_category is None: + schema_category = prompt_enum("tool category", categories_schema, default="desktop") + elif schema_category not in categories_schema: + click.echo( + f"Error: invalid tool category '{schema_category}'. " + f"Allowed: {', '.join(categories_schema)}", + err=True, + ) + raise SystemExit(1) + + # Generate + tuto_uuid = str(uuid.uuid4()) + tuto_yml_data = build_tutorial_yml( + tutorial_uuid=tuto_uuid, + level=level, + schema_category=schema_category, + professor_id=professor_id, + license_type=license_type, + lang=lang, + ) + tuto_md = build_tutorial_md(tutorial_id) + + # Write files + tuto_dir.mkdir(parents=True) + yml_path = tuto_dir / "tutorial.yml" + md_path = tuto_dir / f"{lang}.md" + assets_dir = tuto_dir / "assets" + assets_dir.mkdir() + + dump_yaml(tuto_yml_data, yml_path) + md_path.write_text(tuto_md, encoding="utf-8") + # Placeholder cover so scaffold passes validation + (assets_dir / "cover.webp").write_bytes(_PLACEHOLDER_WEBP) + + created_files = [ + str(yml_path.relative_to(repo_root)), + str(md_path.relative_to(repo_root)), + str(assets_dir.relative_to(repo_root)) + "/", + ] + + _scaffold_output( + content_type="tutorial", + content_id=tutorial_id, + content_uuid=tuto_uuid, + content_dir=tuto_dir, + created_files=created_files, + repo_root=repo_root, + json_output=json_output, + extra_json={"folder_category": folder_category}, + ) + + +# ======================================================================== +# Professor scaffolding +# ======================================================================== + + +def build_professor_yml(professor_uuid: str, name: str) -> dict: + """Build the professor.yml data dict.""" + return { + "id": professor_uuid, + "name": name, + "links": { + "twitter": "https://twitter.com/TODO", + }, + "tags": ["software"], + } + + +def build_professor_lang_yml() -> dict: + """Build a professor language YML (e.g., en.yml).""" + return { + "bio": "TODO: Write professor bio here.\n", + "short_bio": "TODO: Short bio", + } + + +def run_new_professor( + professor_slug: str | None, + name: str | None, + lang: str | None, + json_output: bool, +) -> None: + """Create a new professor profile scaffold.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + if professor_slug is None: + professor_slug = _prompt_slug("Professor") + else: + professor_slug = professor_slug.strip().lower() + err = _validate_slug(professor_slug) + if err: + click.echo(f"Error: {err}", err=True) + raise SystemExit(1) + + prof_dir = repo_root / "professors" / professor_slug + if prof_dir.exists(): + click.echo(f"Error: professor directory already exists: {prof_dir}", err=True) + raise SystemExit(1) + + if name is None: + name = click.prompt("Display name").strip() + if not name: + click.echo("Error: name cannot be empty.", err=True) + raise SystemExit(1) + + if lang is None: + lang = prompt_language(registry) + elif lang not in registry.languages: + click.echo(f"Error: invalid language '{lang}'. Allowed: {', '.join(registry.languages)}", err=True) + raise SystemExit(1) + + prof_uuid = str(uuid.uuid4()) + prof_yml = build_professor_yml(prof_uuid, name) + lang_yml = build_professor_lang_yml() + + prof_dir.mkdir(parents=True) + yml_path = prof_dir / "professor.yml" + lang_path = prof_dir / f"{lang}.yml" + assets_dir = prof_dir / "assets" + assets_dir.mkdir() + (assets_dir / ".gitkeep").touch() + + dump_yaml(prof_yml, yml_path) + dump_yaml(lang_yml, lang_path) + + created_files = [ + str(yml_path.relative_to(repo_root)), + str(lang_path.relative_to(repo_root)), + str(assets_dir.relative_to(repo_root)) + "/", + ] + + _scaffold_output( + content_type="professor", + content_id=professor_slug, + content_uuid=prof_uuid, + content_dir=prof_dir, + created_files=created_files, + repo_root=repo_root, + json_output=json_output, + ) + + +# ======================================================================== +# Event scaffolding +# ======================================================================== + + +def build_event_yml( + event_uuid: str, + name: str, + event_type: str, + start_date: str, + end_date: str, + timezone: str, + city: str, + lang: str, +) -> dict: + """Build the event.yml data dict.""" + return { + "id": event_uuid, + "name": name, + "type": event_type, + "start_date": start_date, + "end_date": end_date, + "timezone": timezone, + "address_city_country": city, + "description": "TODO: Event description", + "language": [lang], + "links": { + "website": "TODO: https://example.com", + "replay_url": None, + "live_url": None, + }, + "tags": ["software"], + "book_online": False, + "book_in_person": False, + "price_dollars": 0, + } + + +def run_new_event( + event_id: str | None, + name: str | None, + event_type: str | None, + start_date: str | None, + end_date: str | None, + timezone: str | None, + city: str | None, + lang: str | None, + json_output: bool, +) -> None: + """Create a new event scaffold.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + # Load schema for enum introspection + schema_path = repo_root / registry.content_types["event"].schema + schema = load_json_schema(schema_path) + event_types = get_enum_values(schema, "type") + + if event_id is None: + event_id = _prompt_slug("Event") + else: + event_id = event_id.strip().lower() + err = _validate_slug(event_id) + if err: + click.echo(f"Error: {err}", err=True) + raise SystemExit(1) + + event_dir = repo_root / "events" / event_id + if event_dir.exists(): + click.echo(f"Error: event directory already exists: {event_dir}", err=True) + raise SystemExit(1) + + if name is None: + name = click.prompt("Event name").strip() + if not name: + click.echo("Error: name cannot be empty.", err=True) + raise SystemExit(1) + + if event_type is None: + event_type = prompt_enum("type", event_types, default="meetup") + elif event_type not in event_types: + click.echo(f"Error: invalid event type '{event_type}'. Allowed: {', '.join(event_types)}", err=True) + raise SystemExit(1) + + if start_date is None: + start_date = click.prompt("Start date (YYYY-MM-DD HH:MM:SS)", default="2025-01-01 09:00:00").strip() + if end_date is None: + end_date = click.prompt("End date (YYYY-MM-DD HH:MM:SS)", default="2025-01-01 17:00:00").strip() + if timezone is None: + timezone = click.prompt("Timezone (IANA)", default="UTC").strip() + if city is None: + city = click.prompt("City, Country").strip() + if not city: + click.echo("Error: city cannot be empty.", err=True) + raise SystemExit(1) + + if lang is None: + lang = prompt_language(registry) + elif lang not in registry.languages: + click.echo(f"Error: invalid language '{lang}'. Allowed: {', '.join(registry.languages)}", err=True) + raise SystemExit(1) + + event_uuid = str(uuid.uuid4()) + event_yml = build_event_yml( + event_uuid=event_uuid, + name=name, + event_type=event_type, + start_date=start_date, + end_date=end_date, + timezone=timezone, + city=city, + lang=lang, + ) + + event_dir.mkdir(parents=True) + yml_path = event_dir / "event.yml" + assets_dir = event_dir / "assets" + assets_dir.mkdir() + (assets_dir / ".gitkeep").touch() + + dump_yaml(event_yml, yml_path) + + created_files = [ + str(yml_path.relative_to(repo_root)), + str(assets_dir.relative_to(repo_root)) + "/", + ] + + _scaffold_output( + content_type="event", + content_id=event_id, + content_uuid=event_uuid, + content_dir=event_dir, + created_files=created_files, + repo_root=repo_root, + json_output=json_output, + ) + + +# ======================================================================== +# Resource scaffolding +# ======================================================================== + + +def _resource_dir_prefix(registry: ContentRegistry, resource_type: str) -> str: + """Get the directory prefix for a resource type (e.g., 'resources/books').""" + ct = registry.content_types[resource_type] + # path_pattern like "resources/books/{id}/" -> "resources/books" + return ct.path_pattern.split("/{id}")[0] + + +def _build_resource_metadata(resource_type: str, resource_uuid: str, lang: str) -> dict: + """Build metadata YAML dict for a given resource type.""" + today = date.today().strftime("%Y-%m-%d") + + builders = { + "bet": lambda: { + "id": resource_uuid, + "type": "Educational Content", + "contributor_names": ["TODO"], + "original_language": lang, + "proofreading": [_make_proofreading_entry(lang)], + "tags": ["software"], + "license": "CC-BY-SA-V4", + }, + "book": lambda: { + "id": resource_uuid, + "author": "TODO: Author Name", + "level": "beginner", + "tags": ["software"], + "original_language": lang, + }, + "channel": lambda: { + "id": resource_uuid, + "name": "TODO: Channel Name", + "language": lang, + "links": {"channel": "TODO: https://youtube.com/..."}, + "description": "TODO: Channel description\n", + "tags": ["software"], + "contributor_names": ["TODO"], + }, + "conference": lambda: { + "name": "TODO: Conference Name 2025", + "year": today[:7], + "builder": "TODO: Builder Name", + "location": "TODO: City, Country", + "language": [lang], + "links": {"website": "TODO: https://example.com"}, + "tags": ["software"], + }, + "glossary": lambda: { + "id": resource_uuid, + "en_word": "TODO", + "original_language": lang, + "proofreading": [_make_proofreading_entry(lang)], + }, + "movie": lambda: { + "id": resource_uuid, + "title": "TODO: Movie Title", + "author": "TODO: Director Name", + "publication_year": date.today().year, + "duration": 90, + "language": lang, + "links": {"platform": "TODO: https://example.com"}, + "description": "TODO: Movie description\n", + "contributor_names": ["TODO"], + "tags": ["software"], + }, + "newsletter": lambda: { + "id": resource_uuid, + "title": "TODO: Newsletter Title", + "author": "TODO: Author Name", + "level": "beginner", + "publication_date": today, + "link": [{"website": "TODO: https://example.com"}], + "language": lang, + "description": "TODO: Newsletter description\n", + "contributor_names": ["TODO"], + "tags": ["software"], + }, + "paper": lambda: { + "id": resource_uuid, + "title": "TODO: Paper Title", + "original_language": lang, + "authors": ["TODO: Author Name"], + "abstract": "TODO: Paper abstract describing the research.", + "paper_type": "whitepaper", + "topics": ["bitcoin"], + "pdf_url": "TODO: https://example.com/paper.pdf", + }, + "podcast": lambda: { + "id": resource_uuid, + "name": "TODO: Podcast Name", + "host": "TODO: Host Name", + "language": lang, + "links": {"podcast": "TODO: https://example.com"}, + "description": "TODO: Podcast description\n", + "tags": ["software"], + "contributor_names": ["TODO"], + }, + "project": lambda: { + "id": resource_uuid, + "name": "TODO: Project Name", + "category": "education", + "links": {"website": "TODO: https://example.com"}, + "original_language": lang, + "proofreading": [_make_proofreading_entry(lang)], + }, + } + return builders[resource_type]() + + +def _build_resource_content(resource_type: str, lang: str) -> dict | str | None: + """Build language content file for resource types that need one. + + Returns dict for YML content, str for MD content, None if no content needed. + """ + builders = { + "bet": lambda: { + "name": "TODO: Content Name", + "description": "TODO: Content description\n", + }, + "book": lambda: { + "title": "TODO: Book Title", + "publication_year": date.today().year, + "cover": f"cover_{lang}.webp", + "original": True, + "description": "TODO: Book description\n", + }, + "glossary": lambda: f"""--- +term: "TODO: Term" +definition: "TODO: Definition of the glossary word" +--- + +TODO: Extended definition and explanation of this term. +""", + "project": lambda: { + "description": "TODO: Project description\n", + }, + } + builder = builders.get(resource_type) + return builder() if builder else None + + +def run_new_resource( + resource_type: str | None, + resource_id: str | None, + lang: str | None, + json_output: bool, +) -> None: + """Create a new resource scaffold.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + # --- Validate / prompt resource type --- + valid_types = sorted(RESOURCE_TYPE_KEYS) + if resource_type is None: + resource_type = prompt_enum("resource type", valid_types) + elif resource_type not in RESOURCE_TYPE_KEYS: + click.echo( + f"Error: invalid resource type '{resource_type}'. " + f"Valid types: {', '.join(valid_types)}", + err=True, + ) + raise SystemExit(1) + + # --- Validate / prompt ID --- + if resource_id is None: + resource_id = _prompt_slug("Resource") + else: + resource_id = resource_id.strip().lower() + err = _validate_slug(resource_id) + if err: + click.echo(f"Error: {err}", err=True) + raise SystemExit(1) + + # Resolve directory + dir_prefix = _resource_dir_prefix(registry, resource_type) + resource_dir = repo_root / dir_prefix / resource_id + if resource_dir.exists(): + click.echo(f"Error: resource directory already exists: {resource_dir}", err=True) + raise SystemExit(1) + + if lang is None: + lang = prompt_language(registry) + elif lang not in registry.languages: + click.echo(f"Error: invalid language '{lang}'. Allowed: {', '.join(registry.languages)}", err=True) + raise SystemExit(1) + + resource_uuid = str(uuid.uuid4()) + metadata = _build_resource_metadata(resource_type, resource_uuid, lang) + content = _build_resource_content(resource_type, lang) + + # Write files + resource_dir.mkdir(parents=True) + meta_filename = registry.content_types[resource_type].metadata_file + yml_path = resource_dir / meta_filename + dump_yaml(metadata, yml_path) + + created_files = [str(yml_path.relative_to(repo_root))] + + # Write content file if applicable + if content is not None: + ct = registry.content_types[resource_type] + if isinstance(content, str): + # Markdown content + content_path = resource_dir / f"{lang}.md" + content_path.write_text(content, encoding="utf-8") + else: + # YML content + content_path = resource_dir / f"{lang}.yml" + dump_yaml(content, content_path) + created_files.append(str(content_path.relative_to(repo_root))) + + assets_dir = resource_dir / "assets" + assets_dir.mkdir() + (assets_dir / ".gitkeep").touch() + created_files.append(str(assets_dir.relative_to(repo_root)) + "/") + + _scaffold_output( + content_type=f"resource/{resource_type}", + content_id=resource_id, + content_uuid=resource_uuid, + content_dir=resource_dir, + created_files=created_files, + repo_root=repo_root, + json_output=json_output, + extra_json={"resource_type": resource_type}, + ) diff --git a/scripts/bec/src/bec/commands/proofread.py b/scripts/bec/src/bec/commands/proofread.py new file mode 100644 index 00000000000..eecf1628363 --- /dev/null +++ b/scripts/bec/src/bec/commands/proofread.py @@ -0,0 +1,329 @@ +"""Proofread command implementations: update, reward, batch-add, status.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import click + +from bec.lib.proofreading import ( + LANGUAGE_FACTORS, + add_contributor, + evaluate_reward_for_language, + find_metadata_file, + get_proofreading_entries, + get_status_matrix, + recalculate_rewards, + update_metadata_file, +) +from bec.lib.repo import find_repo_root, resolve_content_path +from bec.lib.yaml_utils import load_yaml + + +# ---- Helpers ------------------------------------------------------------------ + +def _load_metadata(content_path_str: str) -> tuple[Path, dict]: + """Resolve a content path and load its metadata YAML. + + Returns (metadata_file_path, parsed_data). + Exits with code 1 on failure. + """ + repo_root = find_repo_root() + content_dir = resolve_content_path(content_path_str, repo_root) + + if not content_dir.is_dir(): + click.echo(f"Error: '{content_path_str}' is not a valid directory", err=True) + raise SystemExit(1) + + metadata_path = find_metadata_file(content_dir) + if metadata_path is None: + click.echo(f"Error: no metadata YAML found in '{content_path_str}'", err=True) + raise SystemExit(1) + + data = load_yaml(metadata_path) + if data is None: + click.echo(f"Error: empty metadata file '{metadata_path}'", err=True) + raise SystemExit(1) + + return metadata_path, data + + +def _prompt_if_missing(value: str | None, prompt_text: str, choices: list[str] | None = None) -> str: + """Prompt interactively if value is None.""" + if value is not None: + return value + if choices: + click.echo(f"Available options: {', '.join(sorted(choices))}") + return click.prompt(prompt_text) + + +def _list_content_paths(repo_root: Path) -> list[str]: + """List content directories that have metadata YAML files.""" + results = [] + for top_dir in ["courses", "tutorials", "resources", "events"]: + top = repo_root / top_dir + if not top.is_dir(): + continue + if top_dir == "tutorials": + for category in sorted(top.iterdir()): + if category.is_dir(): + for item in sorted(category.iterdir()): + if item.is_dir() and find_metadata_file(item): + results.append(str(item.relative_to(repo_root))) + elif top_dir == "resources": + for subtype in sorted(top.iterdir()): + if subtype.is_dir(): + for item in sorted(subtype.iterdir()): + if item.is_dir() and find_metadata_file(item): + results.append(str(item.relative_to(repo_root))) + else: + for item in sorted(top.iterdir()): + if item.is_dir() and find_metadata_file(item): + results.append(str(item.relative_to(repo_root))) + return results + + +# ---- Subcommand: update ------------------------------------------------------- + +def run_proofread_update( + path: str | None, + lang: str | None, + contributor: str | None, + recalc: bool, + json_output: bool, +) -> None: + """Add a contributor to a content item's proofreading metadata.""" + repo_root = find_repo_root() + + # Interactive prompts for missing args + if path is None: + paths = _list_content_paths(repo_root) + if paths: + click.echo("Available content paths:") + for i, p in enumerate(paths[:30], 1): + click.echo(f" {i}. {p}") + if len(paths) > 30: + click.echo(f" ... and {len(paths) - 30} more") + path = click.prompt("Content path") + + metadata_path, data = _load_metadata(path) + + # Show available languages from proofreading entries + entries = get_proofreading_entries(data) + available_langs = [e.get("language", "?") for e in entries] + + lang = _prompt_if_missing(lang, "Language code", available_langs) + contributor = _prompt_if_missing(contributor, "Contributor GitHub username") + + # Add contributor + success, message = add_contributor(data, lang, contributor) + + if not success: + if json_output: + click.echo(json.dumps({"success": False, "message": message})) + else: + click.echo(f"Warning: {message}", err=True) + raise SystemExit(1) + + # Optionally recalculate rewards + if recalc: + recalculate_rewards(metadata_path, data) + + # Write back + update_metadata_file(metadata_path, data) + + if json_output: + click.echo(json.dumps({ + "success": True, + "message": message, + "path": path, + "language": lang, + "contributor": contributor, + "file": str(metadata_path), + })) + else: + click.echo(f"{message}") + click.echo(f"Updated: {metadata_path.relative_to(repo_root)}") + + +# ---- Subcommand: reward ------------------------------------------------------- + +def run_proofread_reward( + path: str | None, + lang: str | None, + json_output: bool, +) -> None: + """Display proofreading reward information for a content item.""" + repo_root = find_repo_root() + + if path is None: + path = click.prompt("Content path") + + metadata_path, data = _load_metadata(path) + + entries = get_proofreading_entries(data) + if not entries: + click.echo(f"No proofreading metadata in {path}", err=True) + raise SystemExit(1) + + # If no language specified, show rewards for all languages + if lang is None: + results = [] + for entry in entries: + entry_lang = entry.get("language", "?") + try: + info = evaluate_reward_for_language(metadata_path, data, entry_lang) + except (ValueError, FileNotFoundError) as exc: + click.echo(f"Error: {exc}", err=True) + raise SystemExit(1) + results.append(info) + + if json_output: + click.echo(json.dumps({"path": path, "rewards": results}, indent=2)) + else: + content_name = Path(path).name + click.echo(f"Proofreading rewards for {content_name}:") + click.echo(f"{'Language':<10} {'Reward':>8} {'Iteration':>10} {'Remaining':>10} {'Contributors'}") + click.echo("-" * 65) + for r in results: + if "error" in r: + continue + contribs = ", ".join(r["contributors"]) if r["contributors"] else "-" + click.echo( + f"{r['language']:<10} {r['reward']:>8.2f} {r['iteration']:>10} " + f"{r['remaining_paid_proofreadings']:>10} {contribs}" + ) + else: + try: + info = evaluate_reward_for_language(metadata_path, data, lang) + except (ValueError, FileNotFoundError) as exc: + click.echo(f"Error: {exc}", err=True) + raise SystemExit(1) + if "error" in info: + click.echo(f"Error: {info['error']}", err=True) + raise SystemExit(1) + + if json_output: + click.echo(json.dumps({"path": path, "reward": info}, indent=2)) + else: + click.echo(f"Reward for {Path(path).name} ({lang}):") + click.echo(f" Words: {info['words']}") + click.echo(f" Language factor: {info['language_factor']}") + click.echo(f" Urgency: {info['urgency']}") + click.echo(f" Iteration: {info['iteration']}") + click.echo(f" Reward: {info['reward']}") + click.echo(f" Remaining paid proofreadings: {info['remaining_paid_proofreadings']}") + + +# ---- Subcommand: batch-add ---------------------------------------------------- + +def run_proofread_batch_add( + contributor: str | None, + lang: str | None, + paths: tuple[str, ...], + recalc: bool, + json_output: bool, +) -> None: + """Add a contributor to multiple content items at once.""" + repo_root = find_repo_root() + + contributor = _prompt_if_missing(contributor, "Contributor GitHub username") + lang = _prompt_if_missing(lang, "Language code", sorted(LANGUAGE_FACTORS.keys())) + + if not paths: + paths_input = click.prompt("Content paths (space-separated)") + paths = tuple(paths_input.split()) + + results = [] + success_count = 0 + error_count = 0 + + for content_path in paths: + try: + metadata_path, data = _load_metadata(content_path) + success, message = add_contributor(data, lang, contributor) + if success: + if recalc: + recalculate_rewards(metadata_path, data) + update_metadata_file(metadata_path, data) + success_count += 1 + results.append({ + "path": content_path, + "success": success, + "message": message, + }) + if not json_output: + status = "ok" if success else "skip" + click.echo(f" [{status}] {content_path}: {message}") + except SystemExit: + error_count += 1 + results.append({ + "path": content_path, + "success": False, + "message": "Failed to load metadata", + }) + if not json_output: + click.echo(f" [err] {content_path}: failed to load metadata") + + if json_output: + click.echo(json.dumps({ + "contributor": contributor, + "language": lang, + "total": len(paths), + "success": success_count, + "errors": error_count, + "results": results, + }, indent=2)) + else: + click.echo(f"\nProcessed {len(paths)} items: {success_count} updated, {error_count} errors") + + +# ---- Subcommand: status ------------------------------------------------------- + +def run_proofread_status( + path: str | None, + json_output: bool, +) -> None: + """Show proofreading status for all languages of a content item.""" + repo_root = find_repo_root() + + if path is None: + path = click.prompt("Content path") + + metadata_path, data = _load_metadata(path) + + matrix = get_status_matrix(metadata_path, data) + if not matrix: + click.echo(f"No proofreading metadata in {path}", err=True) + raise SystemExit(1) + + original_lang = data.get("original_language", "?") + + if json_output: + click.echo(json.dumps({ + "path": path, + "original_language": original_lang, + "languages": matrix, + }, indent=2)) + else: + content_name = Path(path).name + click.echo(f"Proofreading status for {content_name} (original: {original_lang}):") + click.echo(f"{'Language':<10} {'Status':<12} {'Reward':>8} {'Urgency':>8} {'Last Date':<12} {'Contributors'}") + click.echo("-" * 80) + for entry in matrix: + contribs = ", ".join(entry["contributors"]) if entry["contributors"] else "-" + if entry["is_original"]: + status = "original" + elif entry["iteration"] >= 2: + status = "complete" + elif entry["iteration"] > 0: + status = f"{entry['iteration']}/2" + else: + status = "pending" + lcd = entry["last_contribution_date"] or "-" + click.echo( + f"{entry['language']:<10} {status:<12} {entry['reward']:>8.2f} " + f"{entry['urgency']:>8} {lcd:<12} {contribs}" + ) diff --git a/scripts/bec/src/bec/commands/report.py b/scripts/bec/src/bec/commands/report.py new file mode 100644 index 00000000000..56e019c8b3e --- /dev/null +++ b/scripts/bec/src/bec/commands/report.py @@ -0,0 +1,2461 @@ +"""Report command implementations: translation, images, video, proofreading, analytics.""" + +from __future__ import annotations + +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +import click + +from bec.lib.content_types import ContentRegistry, ContentType, load_registry +from bec.lib.repo import find_repo_root + + +# --------------------------------------------------------------------------- +# Content discovery (shared across reports) +# --------------------------------------------------------------------------- + +_RESOURCE_DIR_TO_KEY = { + "bet": "bet", + "books": "book", + "channels": "channel", + "conferences": "conference", + "glossary": "glossary", + "movies": "movie", + "newsletters": "newsletter", + "papers": "paper", + "podcasts": "podcast", + "projects": "project", +} + + +def _discover_for_type( + repo_root: Path, + ct: ContentType, +) -> list[tuple[Path, str]]: + """Discover all content folders for a single content type.""" + folders: list[tuple[Path, str]] = [] + pattern = ct.path_pattern + parts = pattern.strip("/").split("/") + base = repo_root / parts[0] + + if not base.exists() or not base.is_dir(): + return folders + + static_parts: list[str] = [] + for p in parts: + if "{" in p: + break + static_parts.append(p) + + depth_after_static = len(parts) - len(static_parts) + prefix_dir = repo_root / "/".join(static_parts) + + if not prefix_dir.exists() or not prefix_dir.is_dir(): + return folders + + if depth_after_static == 1: + for d in sorted(prefix_dir.iterdir()): + if d.is_dir() and not d.name.startswith("."): + folders.append((d, ct.key)) + elif depth_after_static == 2: + for cat_dir in sorted(prefix_dir.iterdir()): + if not cat_dir.is_dir() or cat_dir.name.startswith("."): + continue + for d in sorted(cat_dir.iterdir()): + if d.is_dir() and not d.name.startswith("."): + folders.append((d, ct.key)) + + return folders + + +def _discover_all( + repo_root: Path, + registry: ContentRegistry, +) -> list[tuple[Path, str]]: + """Discover all content folders across all types.""" + folders: list[tuple[Path, str]] = [] + for ct in registry.content_types.values(): + folders.extend(_discover_for_type(repo_root, ct)) + folders.sort(key=lambda t: t[0]) + return folders + + +# --------------------------------------------------------------------------- +# Translation coverage analysis +# --------------------------------------------------------------------------- + +# Content types that use .md for language files +_MD_TYPES = {"course", "tutorial", "glossary"} +# Content types that use .yml for language content files +_YML_CONTENT_TYPES = {"bet", "book", "project"} +# Content types that use .yml for professor profiles +_PROF_TYPE = "professor" +# Content types with no translatable files (metadata-only) +_NO_TRANSLATION_TYPES = {"event", "channel", "conference", "movie", "newsletter", "podcast", "paper"} + + +def _get_language_files(folder: Path, content_type_key: str) -> dict[str, bool]: + """Check which language files exist for a content folder. + + Returns dict mapping lang_code -> exists. + Only checks for languages that actually have files; caller filters. + """ + result: dict[str, bool] = {} + + if content_type_key in _MD_TYPES: + # .md files: en.md, fr.md, etc. + for f in folder.iterdir(): + if f.is_file() and f.suffix == ".md": + result[f.stem] = True + elif content_type_key in _YML_CONTENT_TYPES or content_type_key == _PROF_TYPE: + # .yml files: en.yml, fr.yml, etc. + # Exclude metadata files (course.yml, tutorial.yml, etc.) + metadata_names = { + "bet": "bet.yml", + "book": "book.yml", + "project": "project.yml", + "professor": "professor.yml", + } + meta = metadata_names.get(content_type_key, "") + for f in folder.iterdir(): + if f.is_file() and f.suffix == ".yml" and f.name != meta: + result[f.stem] = True + + return result + + +def count_words(file_path: Path) -> int: + """Count words in a file.""" + try: + return len(file_path.read_text(encoding="utf-8").split()) + except Exception: + return 0 + + +def analyze_translation_coverage( + repo_root: Path, + registry: ContentRegistry, +) -> dict: + """Analyze translation coverage for all translatable content types. + + Returns a dict with: + - by_type: {type_key: {items: [{id, path, languages: {lang: {exists, words}}}], ...}} + - languages: list of all detected languages + - summary: {total_items, total_translations, total_possible, coverage_pct} + """ + # Translatable types: those with markdown or yml content files + translatable_keys = set() + for key, ct in registry.content_types.items(): + if key not in _NO_TRANSLATION_TYPES: + translatable_keys.add(key) + + all_folders = _discover_all(repo_root, registry) + + # Collect all languages seen across the repo + all_languages: set[str] = set() + + # Group analysis by content type + by_type: dict[str, list[dict]] = {} + + for folder, type_key in all_folders: + if type_key not in translatable_keys: + continue + + lang_files = _get_language_files(folder, type_key) + all_languages.update(lang_files.keys()) + + rel_path = str(folder.relative_to(repo_root)) + item_id = folder.name + + item_data: dict = { + "id": item_id, + "path": rel_path, + "languages": {}, + } + + for lang, exists in lang_files.items(): + words = 0 + if exists: + if type_key in _MD_TYPES: + words = count_words(folder / f"{lang}.md") + else: + words = count_words(folder / f"{lang}.yml") + item_data["languages"][lang] = {"exists": True, "words": words} + + by_type.setdefault(type_key, []).append(item_data) + + # Sort languages, putting en first + sorted_langs = sorted(all_languages) + if "en" in sorted_langs: + sorted_langs.remove("en") + sorted_langs.insert(0, "en") + + # Calculate summary statistics + total_items = sum(len(items) for items in by_type.values()) + total_translations = sum( + len(item["languages"]) + for items in by_type.values() + for item in items + ) + total_possible = total_items * len(sorted_langs) if sorted_langs else 0 + coverage_pct = (total_translations / total_possible * 100) if total_possible > 0 else 0 + + # Per-type stats + type_stats = {} + for type_key, items in by_type.items(): + ct = registry.content_types.get(type_key) + type_name = ct.name if ct else type_key + type_translations = sum(len(item["languages"]) for item in items) + type_possible = len(items) * len(sorted_langs) if sorted_langs else 0 + type_stats[type_key] = { + "name": type_name, + "items": len(items), + "translations": type_translations, + "possible": type_possible, + "coverage_pct": (type_translations / type_possible * 100) if type_possible > 0 else 0, + } + + # Per-language stats + lang_stats = {} + for lang in sorted_langs: + count = sum( + 1 + for items in by_type.values() + for item in items + if lang in item["languages"] + ) + lang_stats[lang] = { + "count": count, + "total": total_items, + "coverage_pct": (count / total_items * 100) if total_items > 0 else 0, + } + + return { + "by_type": by_type, + "type_stats": type_stats, + "lang_stats": lang_stats, + "languages": sorted_langs, + "summary": { + "total_items": total_items, + "total_translations": total_translations, + "total_possible": total_possible, + "coverage_pct": round(coverage_pct, 1), + }, + } + + +# --------------------------------------------------------------------------- +# HTML generation +# --------------------------------------------------------------------------- + +def _generate_translation_html(analysis: dict) -> str: + """Generate self-contained HTML report for translation coverage.""" + languages = analysis["languages"] + by_type = analysis["by_type"] + type_stats = analysis["type_stats"] + summary = analysis["summary"] + lang_stats = analysis["lang_stats"] + + # Type display order + type_order = ["course", "tutorial", "professor", "glossary", "book", "bet", "project"] + ordered_types = [t for t in type_order if t in by_type] + # Add any types not in the order list + for t in sorted(by_type.keys()): + if t not in ordered_types: + ordered_types.append(t) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + html_parts = [f""" + + + + + Markdown Translation Overview + + + +
+
+ Back to Reports +

Markdown Translation Overview

+
+
+ Languages detected: {len(languages)} | + Translatable items: {summary["total_items"]} | + Translations: {summary["total_translations"]}/{summary["total_possible"]} ({summary["coverage_pct"]}% overall) +
+ +
"""] + + # Type summary cards + for type_key in ordered_types: + ts = type_stats[type_key] + pct = ts["coverage_pct"] + pct_class = "pct-high" if pct >= 50 else ("pct-mid" if pct >= 20 else "pct-low") + html_parts.append(f""" +
+

{ts["name"]}s

+
{pct:.0f}%
+
{ts["translations"]}/{ts["possible"]} translations
+
{ts["items"]} items
+
""") + + html_parts.append(""" +
+ +
+

Legend

+
+ + Translated (file exists, word count shown) +
+
+ + Missing (no file) +
+
""") + + # Per-type tables + for type_key in ordered_types: + items = by_type[type_key] + ts = type_stats[type_key] + sorted_items = sorted(items, key=lambda x: x["id"]) + + html_parts.append(f""" +

{ts["name"]}s ({ts["items"]} items, {ts["coverage_pct"]:.0f}% coverage)

+ + + + """) + + for lang in languages: + html_parts.append(f" \n") + + html_parts.append(""" + + """) + + for item in sorted_items: + html_parts.append(f""" + + """) + + for lang in languages: + lang_data = item["languages"].get(lang) + if lang_data and lang_data["exists"]: + words = lang_data["words"] + html_parts.append(f""" + """) + else: + html_parts.append(""" + """) + + html_parts.append("\n ") + + html_parts.append(""" + +
{ts["name"]}{lang}
{item["id"]} + + {words:,}w + + +
""") + + html_parts.append(f""" + +
+ + +""") + + return "".join(html_parts) + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- + +def _translation_to_json(analysis: dict) -> dict: + """Convert analysis to JSON-serializable output structure.""" + return { + "summary": analysis["summary"], + "type_stats": analysis["type_stats"], + "lang_stats": analysis["lang_stats"], + "languages": analysis["languages"], + "items": { + type_key: [ + { + "id": item["id"], + "path": item["path"], + "languages": { + lang: data["words"] + for lang, data in item["languages"].items() + }, + } + for item in sorted(items, key=lambda x: x["id"]) + ] + for type_key, items in analysis["by_type"].items() + }, + } + + +# --------------------------------------------------------------------------- +# Command runners +# --------------------------------------------------------------------------- + +def run_report_translation( + output: str | None, + json_output: bool, +) -> None: + """Generate markdown translation coverage report.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + if not json_output: + click.echo("Analyzing translation coverage...", err=True) + analysis = analyze_translation_coverage(repo_root, registry) + + if json_output: + click.echo(json.dumps(_translation_to_json(analysis), indent=2)) + return + + # Determine output directory + output_dir = Path(output) if output else repo_root / "docs" / "reports" + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / "md_translation_overview.html" + + html = _generate_translation_html(analysis) + output_file.write_text(html, encoding="utf-8") + + click.echo(f"Report generated: {output_file}") + click.echo( + f" {analysis['summary']['total_translations']}/{analysis['summary']['total_possible']} " + f"translations ({analysis['summary']['coverage_pct']}% coverage)" + ) + + +# --------------------------------------------------------------------------- +# Image translation analysis +# --------------------------------------------------------------------------- + +_IMAGE_RE = re.compile(r"!\[.*?\]\((.*?)\)") + +# Content types that have markdown with embedded images +_IMAGE_TYPES = {"course", "tutorial"} + + +def _extract_images_from_markdown(md_path: Path) -> list[str]: + """Extract image reference paths from a markdown file.""" + try: + content = md_path.read_text(encoding="utf-8") + except Exception: + return [] + refs = _IMAGE_RE.findall(content) + # Filter out URLs (only keep local paths) + return [r for r in refs if not r.startswith(("http://", "https://", "//"))] + + +def _classify_image(img_path: str, lang: str) -> str: + """Classify an image reference as 'translated' or 'shared'. + + Images with the language code in the path (e.g., assets/fr/001.webp) + are considered translated for that language. + All other images (numbered dirs, no-txt, etc.) are shared/untranslated. + """ + if f"/{lang}/" in img_path: + return "translated" + return "shared" + + +def analyze_image_translation( + repo_root: Path, + registry: ContentRegistry, +) -> dict: + """Analyze image translation status for all content with markdown. + + For each content item and language, counts how many images use + language-specific paths (translated) vs shared/generic paths. + """ + all_folders = _discover_all(repo_root, registry) + + by_type: dict[str, list[dict]] = {} + all_languages: set[str] = set() + + for folder, type_key in all_folders: + if type_key not in _IMAGE_TYPES: + continue + + rel_path = str(folder.relative_to(repo_root)) + item_id = folder.name + + lang_data: dict[str, dict] = {} + + for md_file in sorted(folder.iterdir()): + if not md_file.is_file() or md_file.suffix != ".md": + continue + lang = md_file.stem + all_languages.add(lang) + + images = _extract_images_from_markdown(md_file) + if not images: + lang_data[lang] = { + "total_images": 0, + "translated_images": 0, + "shared_images": 0, + "percentage": 0.0, + } + continue + + translated = sum(1 for img in images if _classify_image(img, lang) == "translated") + shared = len(images) - translated + pct = (translated / len(images) * 100) if images else 0.0 + + lang_data[lang] = { + "total_images": len(images), + "translated_images": translated, + "shared_images": shared, + "percentage": round(pct, 1), + } + + if lang_data: + by_type.setdefault(type_key, []).append({ + "id": item_id, + "path": rel_path, + "languages": lang_data, + }) + + sorted_langs = sorted(all_languages) + if "en" in sorted_langs: + sorted_langs.remove("en") + sorted_langs.insert(0, "en") + + # Summary stats + total_images = 0 + total_translated = 0 + for items in by_type.values(): + for item in items: + for ld in item["languages"].values(): + total_images += ld["total_images"] + total_translated += ld["translated_images"] + + overall_pct = (total_translated / total_images * 100) if total_images > 0 else 0.0 + + # Per-type stats + type_stats = {} + for type_key, items in by_type.items(): + ct = registry.content_types.get(type_key) + type_name = ct.name if ct else type_key + t_imgs = sum(ld["total_images"] for item in items for ld in item["languages"].values()) + t_trans = sum(ld["translated_images"] for item in items for ld in item["languages"].values()) + type_stats[type_key] = { + "name": type_name, + "items": len(items), + "total_images": t_imgs, + "translated_images": t_trans, + "percentage": round((t_trans / t_imgs * 100) if t_imgs > 0 else 0.0, 1), + } + + return { + "by_type": by_type, + "type_stats": type_stats, + "languages": sorted_langs, + "summary": { + "total_images": total_images, + "translated_images": total_translated, + "overall_pct": round(overall_pct, 1), + "content_items": sum(len(items) for items in by_type.values()), + }, + } + + +# --------------------------------------------------------------------------- +# Image report — HTML generation +# --------------------------------------------------------------------------- + +def _generate_images_html(analysis: dict) -> str: + """Generate self-contained HTML for image translation report.""" + languages = analysis["languages"] + by_type = analysis["by_type"] + type_stats = analysis["type_stats"] + summary = analysis["summary"] + + type_order = ["course", "tutorial"] + ordered_types = [t for t in type_order if t in by_type] + for t in sorted(by_type.keys()): + if t not in ordered_types: + ordered_types.append(t) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + html_parts = [f""" + + + + + Image Translation Overview + + + +
+
+ Back to Reports +

Image Translation Overview

+
+
+ Languages detected: {len(languages)} | + Content items: {summary["content_items"]} | + Images: {summary["translated_images"]:,}/{summary["total_images"]:,} translated ({summary["overall_pct"]}%) +
+ +
"""] + + for type_key in ordered_types: + ts = type_stats[type_key] + pct = ts["percentage"] + pct_class = "pct-high" if pct >= 50 else ("pct-mid" if pct >= 20 else "pct-low") + html_parts.append(f""" +
+

{ts["name"]}s

+
{pct:.0f}%
+
{ts["translated_images"]:,}/{ts["total_images"]:,} images
+
{ts["items"]} items
+
""") + + html_parts.append(""" +
+ +
+

Legend

+
+ + Complete (100%) +
+
+ + Partial (50-99%) +
+
+ + Low (1-49%) +
+
+ + None (0%) +
+
+ + N/A (no file or no images) +
+
""") + + for type_key in ordered_types: + items = by_type[type_key] + ts = type_stats[type_key] + sorted_items = sorted(items, key=lambda x: x["id"]) + + html_parts.append(f""" +

{ts["name"]}s ({ts["items"]} items, {ts["percentage"]:.0f}% translated)

+ + + + """) + + for lang in languages: + html_parts.append(f" \n") + + html_parts.append(""" + + """) + + for item in sorted_items: + html_parts.append(f""" + + """) + + for lang in languages: + ld = item["languages"].get(lang) + if not ld: + html_parts.append(""" + """) + elif ld["total_images"] == 0: + html_parts.append(""" + """) + else: + pct = ld["percentage"] + t = ld["translated_images"] + total = ld["total_images"] + if pct == 100: + css = "translation-complete" + elif pct >= 50: + css = "translation-partial" + elif pct > 0: + css = "translation-low" + else: + css = "translation-none" + html_parts.append(f""" + """) + + html_parts.append("\n ") + + html_parts.append(""" + +
{ts["name"]}{lang}
{item["id"]} + N/A + No file + + N/A + No images + + {t}/{total} + {pct:.0f}% +
""") + + html_parts.append(f""" + +
+ + +""") + + return "".join(html_parts) + + +# --------------------------------------------------------------------------- +# Image report — JSON output +# --------------------------------------------------------------------------- + +def _images_to_json(analysis: dict) -> dict: + """Convert image analysis to JSON-serializable output.""" + return { + "summary": analysis["summary"], + "type_stats": analysis["type_stats"], + "languages": analysis["languages"], + "items": { + type_key: [ + { + "id": item["id"], + "path": item["path"], + "languages": { + lang: { + "total": data["total_images"], + "translated": data["translated_images"], + "shared": data["shared_images"], + "percentage": data["percentage"], + } + for lang, data in item["languages"].items() + }, + } + for item in sorted(items, key=lambda x: x["id"]) + ] + for type_key, items in analysis["by_type"].items() + }, + } + + +# --------------------------------------------------------------------------- +# Image report — runner +# --------------------------------------------------------------------------- + +def run_report_images( + output: str | None, + json_output: bool, +) -> None: + """Generate image translation report.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + if not json_output: + click.echo("Analyzing image translation coverage...", err=True) + analysis = analyze_image_translation(repo_root, registry) + + if json_output: + click.echo(json.dumps(_images_to_json(analysis), indent=2)) + return + + output_dir = Path(output) if output else repo_root / "docs" / "reports" + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / "image_translation_overview.html" + + html = _generate_images_html(analysis) + output_file.write_text(html, encoding="utf-8") + + click.echo(f"Report generated: {output_file}") + click.echo( + f" {analysis['summary']['translated_images']:,}/{analysis['summary']['total_images']:,} " + f"images translated ({analysis['summary']['overall_pct']}%)" + ) + + +def run_report_all( + output: str | None, + json_output: bool, +) -> None: + """Run all reports.""" + if json_output: + # Merge all report JSON into a single object + repo_root = find_repo_root() + registry = load_registry(repo_root) + combined: dict = {} + + t_analysis = analyze_translation_coverage(repo_root, registry) + combined["translation"] = _translation_to_json(t_analysis) + + i_analysis = analyze_image_translation(repo_root, registry) + combined["images"] = _images_to_json(i_analysis) + + v_analysis = analyze_video_deployment(repo_root, registry) + combined["video"] = _video_to_json(v_analysis) + + p_analysis = analyze_proofreading(repo_root, registry) + combined["proofreading"] = _proofreading_to_json(p_analysis) + + a_analysis = analyze_course_analytics(repo_root) + combined["analytics"] = _analytics_to_json(a_analysis) + + click.echo(json.dumps(combined, indent=2)) + return + + click.echo("Running all reports...", err=True) + run_report_translation(output=output, json_output=False) + run_report_images(output=output, json_output=False) + run_report_video(output=output, json_output=False) + run_report_proofreading(output=output, json_output=False) + run_report_analytics(output=output, json_output=False) + click.echo("All reports complete.", err=True) + + +# =========================================================================== +# Phase 11: Video deployment report +# =========================================================================== + +# Languages to check for video deployment +_VIDEO_LANGUAGES = ["en", "fr", "es", "it", "de", "ru", "zh-Hant"] + + +def _parse_video_coverage( + videos: list[dict], + languages: list[str], +) -> dict[str, dict]: + """Parse video entries and compute per-language coverage. + + Returns {lang: {youtube: int, peertube: int, both: int, covered: int}} + """ + coverage: dict[str, dict[str, int]] = { + lang: {"youtube": 0, "peertube": 0, "both": 0, "covered": 0} + for lang in languages + } + + for video in videos: + yt_langs: set[str] = set() + pt_langs: set[str] = set() + + for entry in video.get("youtube") or []: + if isinstance(entry, dict): + yt_langs.update(entry.keys()) + + for entry in video.get("peertube") or []: + if isinstance(entry, dict): + pt_langs.update(entry.keys()) + + for entry in video.get("rumble") or []: + if isinstance(entry, dict): + pt_langs.update(entry.keys()) # treat rumble like peertube + + for lang in languages: + has_yt = lang in yt_langs + has_pt = lang in pt_langs + if has_yt and has_pt: + coverage[lang]["both"] += 1 + coverage[lang]["covered"] += 1 + elif has_yt: + coverage[lang]["youtube"] += 1 + coverage[lang]["covered"] += 1 + elif has_pt: + coverage[lang]["peertube"] += 1 + coverage[lang]["covered"] += 1 + + return coverage + + +def analyze_video_deployment( + repo_root: Path, + registry: ContentRegistry, +) -> dict: + """Analyze video deployment status for all courses.""" + from bec.lib.yaml_utils import load_yaml + + ct = registry.content_types.get("course") + if not ct: + return {"courses": [], "languages": _VIDEO_LANGUAGES, "summary": { + "total_courses": 0, "total_videos": 0, + }} + + course_folders = _discover_for_type(repo_root, ct) + languages = list(_VIDEO_LANGUAGES) + + courses: list[dict] = [] + total_videos = 0 + + for folder, _type_key in course_folders: + meta_file = folder / "course.yml" + if not meta_file.is_file(): + continue + data = load_yaml(meta_file) + if not data: + continue + + course_id = folder.name + videos = data.get("videos", []) + if not isinstance(videos, list): + videos = [] + + total_videos += len(videos) + coverage = _parse_video_coverage(videos, languages) + + courses.append({ + "id": course_id, + "path": str(folder.relative_to(repo_root)), + "total_videos": len(videos), + "coverage": coverage, + }) + + courses.sort(key=lambda c: c["id"]) + + return { + "courses": courses, + "languages": languages, + "summary": { + "total_courses": len(courses), + "total_videos": total_videos, + "courses_with_videos": sum(1 for c in courses if c["total_videos"] > 0), + }, + } + + +def _generate_video_html(analysis: dict) -> str: + """Generate self-contained HTML for video deployment report.""" + courses = analysis["courses"] + languages = analysis["languages"] + summary = analysis["summary"] + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + html_parts = [f""" + + + + + Video Deployment Overview + + + +
+
+ Back to Reports +

Video Deployment Overview

+
+
+ Courses: {summary["total_courses"]} | + With videos: {summary["courses_with_videos"]} | + Total videos: {summary["total_videos"]} | + Languages tracked: {len(languages)} +
+ +
+

Legend

+
+ + 100% coverage +
+
+ + 50-99% +
+
+ + 1-49% +
+
+ + 0% +
+
+ + No videos +
+
+ Y YouTube + P PeerTube + B Both +
+
+ + + + + + """] + + for lang in languages: + html_parts.append(f" \n") + + html_parts.append(""" + + """) + + for course in courses: + total = course["total_videos"] + html_parts.append(f""" + + + """) + + for lang in languages: + cov = course["coverage"][lang] + covered = cov["covered"] + if total == 0: + css = "coverage-na" + label = "N/A" + info = "No videos" + elif covered == total: + css = "coverage-complete" + pct = 100 + label = f"{covered}/{total}" + info = f"{pct}%" + elif covered > 0: + pct = round(covered / total * 100) + css = "coverage-partial" if pct >= 50 else "coverage-low" + label = f"{covered}/{total}" + info = f"{pct}%" + else: + css = "coverage-none" + label = f"0/{total}" + info = "0%" + + providers = "" + if cov["youtube"] > 0: + providers += f"Y:{cov['youtube']}" + if cov["peertube"] > 0: + providers += f"P:{cov['peertube']}" + if cov["both"] > 0: + providers += f"B:{cov['both']}" + + html_parts.append(f""" + """) + + html_parts.append("\n ") + + html_parts.append(f""" + +
CourseVideos{lang.upper()}
{course["id"]}{total} + {label} + {info} + {providers} +
+ + +
+ + +""") + + return "".join(html_parts) + + +def _video_to_json(analysis: dict) -> dict: + """Convert video analysis to JSON-serializable output.""" + return { + "summary": analysis["summary"], + "languages": analysis["languages"], + "courses": [ + { + "id": c["id"], + "path": c["path"], + "total_videos": c["total_videos"], + "coverage": { + lang: { + "covered": cov["covered"], + "youtube": cov["youtube"], + "peertube": cov["peertube"], + "both": cov["both"], + "percentage": round(cov["covered"] / c["total_videos"] * 100, 1) + if c["total_videos"] > 0 else 0.0, + } + for lang, cov in c["coverage"].items() + }, + } + for c in analysis["courses"] + ], + } + + +def run_report_video( + output: str | None, + json_output: bool, +) -> None: + """Generate video deployment status report.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + if not json_output: + click.echo("Analyzing video deployment...", err=True) + analysis = analyze_video_deployment(repo_root, registry) + + if json_output: + click.echo(json.dumps(_video_to_json(analysis), indent=2)) + return + + output_dir = Path(output) if output else repo_root / "docs" / "reports" + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / "video_deployment_overview.html" + + html = _generate_video_html(analysis) + output_file.write_text(html, encoding="utf-8") + + click.echo(f"Report generated: {output_file}") + s = analysis["summary"] + click.echo( + f" {s['courses_with_videos']}/{s['total_courses']} courses have videos " + f"({s['total_videos']} total)" + ) + + +# =========================================================================== +# Phase 12: Proofreading dashboard report +# =========================================================================== + +# Content types that have proofreading metadata +_PROOFREAD_TYPES = {"course", "tutorial"} + + +def analyze_proofreading( + repo_root: Path, + registry: ContentRegistry, +) -> dict: + """Analyze proofreading status for courses and tutorials.""" + from bec.lib.yaml_utils import load_yaml + + all_folders: list[tuple[Path, str]] = [] + for type_key in _PROOFREAD_TYPES: + ct = registry.content_types.get(type_key) + if ct: + all_folders.extend(_discover_for_type(repo_root, ct)) + all_folders.sort(key=lambda t: t[0]) + + all_languages: set[str] = set() + contributors_count: dict[str, int] = {} # contributor -> total count + contributors_langs: dict[str, set[str]] = {} # contributor -> set of langs + + by_type: dict[str, list[dict]] = {} + + for folder, type_key in all_folders: + meta_name = "course.yml" if type_key == "course" else "tutorial.yml" + meta_file = folder / meta_name + if not meta_file.is_file(): + continue + data = load_yaml(meta_file) + if not data: + continue + + proofreading = data.get("proofreading", []) + if not isinstance(proofreading, list): + proofreading = [] + + lang_data: dict[str, dict] = {} + for entry in proofreading: + if not isinstance(entry, dict): + continue + lang = entry.get("language", "") + if not lang: + continue + all_languages.add(lang) + + contribs = entry.get("contributor_names", []) or [] + reward = entry.get("reward", 0) or 0 + last_date = entry.get("last_contribution_date", "") + if last_date and not isinstance(last_date, str): + last_date = str(last_date) + + status = len(contribs) # 0, 1, or 2+ + + for c in contribs: + contributors_count[c] = contributors_count.get(c, 0) + 1 + contributors_langs.setdefault(c, set()).add(lang) + + lang_data[lang] = { + "status": min(status, 2), + "contributors": list(contribs), + "reward": float(reward), + "last_date": last_date or None, + } + + item_data = { + "id": folder.name, + "path": str(folder.relative_to(repo_root)), + "type": type_key, + "languages": lang_data, + } + by_type.setdefault(type_key, []).append(item_data) + + sorted_langs = sorted(all_languages) + if "en" in sorted_langs: + sorted_langs.remove("en") + sorted_langs.insert(0, "en") + + # Language-level stats + lang_stats: dict[str, dict] = {} + for lang in sorted_langs: + total = 0 + proofread = 0 + complete = 0 + for items in by_type.values(): + for item in items: + ld = item["languages"].get(lang) + if ld: + total += 1 + if ld["status"] >= 1: + proofread += 1 + if ld["status"] >= 2: + complete += 1 + lang_stats[lang] = { + "total": total, + "proofread": proofread, + "complete": complete, + } + + # Contributor leaderboard (sorted by count desc) + leaderboard = sorted( + [ + { + "name": name, + "count": count, + "languages": sorted(contributors_langs.get(name, set())), + } + for name, count in contributors_count.items() + ], + key=lambda x: x["count"], + reverse=True, + ) + + total_items = sum(len(items) for items in by_type.values()) + total_proofread = sum(ls["proofread"] for ls in lang_stats.values()) + + return { + "by_type": by_type, + "languages": sorted_langs, + "lang_stats": lang_stats, + "leaderboard": leaderboard, + "summary": { + "total_items": total_items, + "total_languages": len(sorted_langs), + "total_proofread": total_proofread, + "total_contributors": len(contributors_count), + }, + } + + +def _generate_proofreading_html(analysis: dict) -> str: + """Generate self-contained HTML for proofreading dashboard.""" + languages = analysis["languages"] + by_type = analysis["by_type"] + lang_stats = analysis["lang_stats"] + leaderboard = analysis["leaderboard"] + summary = analysis["summary"] + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + type_order = ["course", "tutorial"] + ordered_types = [t for t in type_order if t in by_type] + + html_parts = [f""" + + + + + Proofreading Dashboard + + + +
+
+ Back to Reports +

Proofreading Dashboard

+
+
+ Content items: {summary["total_items"]} | + Languages: {summary["total_languages"]} | + Proofread entries: {summary["total_proofread"]} | + Contributors: {summary["total_contributors"]} +
+ +
+

Legend

+
+ + Complete (2+ contributors) +
+
+ + Partial (1 contributor) +
+
+ + No proofreading +
+
+ + N/A +
+
"""] + + # Matrix views per type + for type_key in ordered_types: + items = sorted(by_type[type_key], key=lambda x: x["id"]) + type_name = type_key.capitalize() + + html_parts.append(f""" +

{type_name}s ({len(items)} items)

+ + + + """) + + for lang in languages: + html_parts.append(f" \n") + + html_parts.append(""" + + """) + + for item in items: + html_parts.append(f""" + + """) + + for lang in languages: + ld = item["languages"].get(lang) + if not ld: + html_parts.append(""" + """) + else: + s = ld["status"] + if s >= 2: + css = "status-complete" + elif s == 1: + css = "status-partial" + else: + css = "status-none" + n = len(ld["contributors"]) + html_parts.append(f""" + """) + + html_parts.append("\n ") + + html_parts.append(""" + +
{type_name}{lang}
{item["id"]}- + {n} + {", ".join(ld["contributors"][:2]) if ld["contributors"] else "-"} +
""") + + # Language statistics + html_parts.append(""" +

Language Statistics

+ + + + + + + + + + """) + + for lang in languages: + ls = lang_stats[lang] + html_parts.append(f""" + + + + + + """) + + html_parts.append(""" + +
LanguageItemsProofreadComplete
{lang}{ls["total"]}{ls["proofread"]}{ls["complete"]}
""") + + # Contributor leaderboard + html_parts.append(""" +

Contributor Leaderboard

+ + + + + + + + + + """) + + for i, entry in enumerate(leaderboard[:50], 1): + html_parts.append(f""" + + + + + + """) + + html_parts.append(f""" + +
#ContributorLanguagesCount
{i}{entry["name"]}{", ".join(entry["languages"])}{entry["count"]}
+ + +
+ + +""") + + return "".join(html_parts) + + +def _proofreading_to_json(analysis: dict) -> dict: + """Convert proofreading analysis to JSON-serializable output.""" + return { + "summary": analysis["summary"], + "languages": analysis["languages"], + "lang_stats": analysis["lang_stats"], + "leaderboard": analysis["leaderboard"], + "items": { + type_key: [ + { + "id": item["id"], + "path": item["path"], + "type": item["type"], + "languages": item["languages"], + } + for item in sorted(items, key=lambda x: x["id"]) + ] + for type_key, items in analysis["by_type"].items() + }, + } + + +def run_report_proofreading( + output: str | None, + json_output: bool, +) -> None: + """Generate proofreading dashboard report.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + if not json_output: + click.echo("Analyzing proofreading status...", err=True) + analysis = analyze_proofreading(repo_root, registry) + + if json_output: + click.echo(json.dumps(_proofreading_to_json(analysis), indent=2)) + return + + output_dir = Path(output) if output else repo_root / "docs" / "reports" + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / "proofreading_dashboard.html" + + html = _generate_proofreading_html(analysis) + output_file.write_text(html, encoding="utf-8") + + click.echo(f"Report generated: {output_file}") + s = analysis["summary"] + click.echo( + f" {s['total_proofread']} proofread entries across {s['total_languages']} languages, " + f"{s['total_contributors']} contributors" + ) + + +# =========================================================================== +# Phase 13: Course analytics report +# =========================================================================== + +_TAG_RE = re.compile(r"<(chapterId|partId)>[^<]*|<[^>]+>") +_IMG_RE_CLEAN = re.compile(r"!\[.*?\]\(.*?\)") +_LINK_RE = re.compile(r"\[([^\]]*)\]\([^\)]*\)") +_CODE_BLOCK_RE = re.compile(r"```.*?```", re.DOTALL) +_INLINE_CODE_RE = re.compile(r"`[^`]+`") +_HEADING_MARKER_RE = re.compile(r"^#{1,6}\s+", re.MULTILINE) +_BOLD_ITALIC_RE = re.compile(r"[*_]{1,3}") +_URL_RE = re.compile(r"https?://\S+") +_CHAPTER_ID_RE = re.compile(r"(.*?)") +_PART_ID_RE = re.compile(r"(.*?)") + + +def _clean_text(text: str) -> str: + """Remove markdown formatting, tags, links, etc. to get plain text.""" + t = _CODE_BLOCK_RE.sub("", text) + t = _INLINE_CODE_RE.sub("", t) + t = _TAG_RE.sub("", t) + t = _IMG_RE_CLEAN.sub("", t) + t = _LINK_RE.sub(r"\1", t) + t = _HEADING_MARKER_RE.sub("", t) + t = _BOLD_ITALIC_RE.sub("", t) + t = _URL_RE.sub("", t) + return t + + +def _count_content_words(text: str) -> int: + """Count words in cleaned text.""" + return len(_clean_text(text).split()) + + +def _parse_course_structure(content: str) -> dict: + """Parse a course markdown file into parts and chapters with metrics. + + Format: YAML frontmatter, intro/excerpt, a '+++' separator, then parts + as level-1 headings each containing level-2 chapter headings. + + Returns: + { + "intro_words": int, + "parts": [{"name": str, "chapters": [{"name": str, "words": int}]}], + } + """ + # Split frontmatter + parts_raw = content.split("---", 2) + body = parts_raw[2] if len(parts_raw) >= 3 else content + + # '+++' separates the intro/excerpt from the parts body + intro_text, sep, parts_body = body.partition("+++") + if not sep: + intro_text, parts_body = "", body + intro_words = _count_content_words(intro_text) + + parts: list[dict] = [] + current_chapter_name = "" + current_chapter_lines: list[str] = [] + + def flush_chapter() -> None: + nonlocal current_chapter_name, current_chapter_lines + if current_chapter_name and parts: + parts[-1]["chapters"].append({ + "name": current_chapter_name, + "words": _count_content_words("\n".join(current_chapter_lines)), + }) + current_chapter_name = "" + current_chapter_lines = [] + + for line in parts_body.split("\n"): + stripped = line.strip() + + # Part title: # heading (level 1) + if stripped.startswith("# "): + flush_chapter() + parts.append({"name": stripped[2:].strip(), "chapters": []}) + continue + + # Chapter title: ## heading + if stripped.startswith("## "): + flush_chapter() + current_chapter_name = stripped[3:].strip() + continue + + current_chapter_lines.append(line) + + flush_chapter() + + return { + "intro_words": intro_words, + "parts": parts, + } + + +def analyze_course_analytics(repo_root: Path) -> dict: + """Analyze course structure statistics: word counts, chapters, parts, quizzes.""" + courses_dir = repo_root / "courses" + if not courses_dir.is_dir(): + return {"courses": [], "summary": {}, "aggregated": {}} + + courses: list[dict] = [] + + for course_dir in sorted(courses_dir.iterdir()): + if not course_dir.is_dir() or course_dir.name.startswith("."): + continue + + course_id = course_dir.name + + # Find primary language file (prefer en, then fr, then first found) + md_file = None + lang = None + for try_lang in ["en", "fr"]: + candidate = course_dir / f"{try_lang}.md" + if candidate.is_file(): + md_file = candidate + lang = try_lang + break + if not md_file: + for f in sorted(course_dir.iterdir()): + if f.is_file() and f.suffix == ".md": + md_file = f + lang = f.stem + break + if not md_file: + continue + + content = md_file.read_text(encoding="utf-8") + structure = _parse_course_structure(content) + + # Count quizzes + quiz_dir = course_dir / "quizz" + quiz_count = 0 + if quiz_dir.is_dir(): + quiz_count = sum( + 1 for d in quiz_dir.iterdir() + if d.is_dir() and not d.name.startswith(".") + ) + + # Count language files + lang_files = [ + f.stem for f in course_dir.iterdir() + if f.is_file() and f.suffix == ".md" + ] + + part_count = len(structure["parts"]) + chapter_count = sum(len(p["chapters"]) for p in structure["parts"]) + total_words = structure["intro_words"] + sum( + ch["words"] + for p in structure["parts"] + for ch in p["chapters"] + ) + + words_per_chapter = [ + ch["words"] + for p in structure["parts"] + for ch in p["chapters"] + ] + + chapters_per_part = [len(p["chapters"]) for p in structure["parts"]] + + courses.append({ + "id": course_id, + "language": lang, + "parts": part_count, + "chapters": chapter_count, + "total_words": total_words, + "intro_words": structure["intro_words"], + "quizzes": quiz_count, + "languages": sorted(lang_files), + "words_per_chapter": words_per_chapter, + "chapters_per_part": chapters_per_part, + "parts_detail": [ + { + "name": p["name"], + "chapters": len(p["chapters"]), + "words": sum(ch["words"] for ch in p["chapters"]), + } + for p in structure["parts"] + ], + }) + + # Aggregated stats + def _stats(values: list[int | float]) -> dict: + if not values: + return {"count": 0, "mean": 0, "min": 0, "max": 0, "sum": 0} + from statistics import mean, median + return { + "count": len(values), + "mean": round(mean(values), 1), + "median": round(median(values), 1), + "min": min(values), + "max": max(values), + "sum": sum(values), + } + + all_words = [c["total_words"] for c in courses] + all_chapters = [c["chapters"] for c in courses] + all_parts = [c["parts"] for c in courses] + all_quizzes = [c["quizzes"] for c in courses] + all_words_per_ch = [w for c in courses for w in c["words_per_chapter"]] + all_ch_per_part = [n for c in courses for n in c["chapters_per_part"]] + + aggregated = { + "words_per_course": _stats(all_words), + "chapters_per_course": _stats(all_chapters), + "parts_per_course": _stats(all_parts), + "quizzes_per_course": _stats(all_quizzes), + "words_per_chapter": _stats(all_words_per_ch), + "chapters_per_part": _stats(all_ch_per_part), + } + + return { + "courses": courses, + "summary": { + "total_courses": len(courses), + "total_parts": sum(c["parts"] for c in courses), + "total_chapters": sum(c["chapters"] for c in courses), + "total_words": sum(c["total_words"] for c in courses), + "total_quizzes": sum(c["quizzes"] for c in courses), + }, + "aggregated": aggregated, + } + + +def _generate_analytics_html(analysis: dict) -> str: + """Generate self-contained HTML for course analytics report.""" + courses = analysis["courses"] + summary = analysis["summary"] + aggregated = analysis["aggregated"] + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + html_parts = [f""" + + + + + Course Analytics Report + + + +
+
+ Back to Reports +

Course Analytics Report

+
+ +
+
+

Courses

+
{summary["total_courses"]}
+
+
+

Parts

+
{summary["total_parts"]}
+
+
+

Chapters

+
{summary["total_chapters"]}
+
+
+

Words

+
{summary["total_words"]:,}
+
+
+

Quizzes

+
{summary["total_quizzes"]}
+
+
+ +

Aggregated Statistics

+ + + + + + + + + + + + + """] + + metric_labels = { + "words_per_course": "Words per Course", + "chapters_per_course": "Chapters per Course", + "parts_per_course": "Parts per Course", + "quizzes_per_course": "Quizzes per Course", + "words_per_chapter": "Words per Chapter", + "chapters_per_part": "Chapters per Part", + } + + for key, label in metric_labels.items(): + s = aggregated.get(key, {}) + html_parts.append(f""" + + + + + + + + + """) + + html_parts.append(""" + +
MetricCountMeanMedianMinMaxSum
{label}{s.get("count", 0)}{s.get("mean", 0)}{s.get("median", 0)}{s.get("min", 0)}{s.get("max", 0)}{s.get("sum", 0):,}
+ +

Course Details

+ + + + + + + + + + + + + """) + + for c in courses: + html_parts.append(f""" + + + + + + + + + """) + + html_parts.append(f""" + +
CourseLangPartsChaptersWordsQuizzesLanguages
{c["id"]}{c["language"]}{c["parts"]}{c["chapters"]}{c["total_words"]:,}{c["quizzes"]}{len(c["languages"])}
+ + +
+ + +""") + + return "".join(html_parts) + + +def _analytics_to_json(analysis: dict) -> dict: + """Convert course analytics to JSON-serializable output.""" + return { + "summary": analysis["summary"], + "aggregated": analysis["aggregated"], + "courses": [ + { + "id": c["id"], + "language": c["language"], + "parts": c["parts"], + "chapters": c["chapters"], + "total_words": c["total_words"], + "intro_words": c["intro_words"], + "quizzes": c["quizzes"], + "languages": c["languages"], + "words_per_chapter": c["words_per_chapter"], + "chapters_per_part": c["chapters_per_part"], + "parts_detail": c["parts_detail"], + } + for c in analysis["courses"] + ], + } + + +def run_report_analytics( + output: str | None, + json_output: bool, +) -> None: + """Generate course analytics report.""" + repo_root = find_repo_root() + + if not json_output: + click.echo("Analyzing course structure...", err=True) + analysis = analyze_course_analytics(repo_root) + + if json_output: + click.echo(json.dumps(_analytics_to_json(analysis), indent=2)) + return + + output_dir = Path(output) if output else repo_root / "docs" / "reports" + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / "course_analytics_report.html" + + html = _generate_analytics_html(analysis) + output_file.write_text(html, encoding="utf-8") + + click.echo(f"Report generated: {output_file}") + s = analysis["summary"] + click.echo( + f" {s['total_courses']} courses, {s['total_chapters']} chapters, " + f"{s['total_words']:,} words, {s['total_quizzes']} quizzes" + ) diff --git a/scripts/bec/src/bec/commands/validate.py b/scripts/bec/src/bec/commands/validate.py new file mode 100644 index 00000000000..807421ed5c4 --- /dev/null +++ b/scripts/bec/src/bec/commands/validate.py @@ -0,0 +1,665 @@ +"""bec validate — validate content against JSON schemas.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import click +import yaml + +from bec.lib.content_types import ContentRegistry, ContentType, load_registry +from bec.lib.repo import find_repo_root, resolve_content_path +from bec.lib.schema import ( + ValidationResult, + load_json_schema, + validate_markdown_frontmatter, + validate_yaml_against_schema, + validate_yml_content, +) +from bec.lib.yaml_utils import load_yaml + +# Schema cache — avoids re-reading the same JSON file hundreds of times +_schema_cache: dict[str, dict] = {} + + +def _load_schema_cached(path: Path) -> dict: + """Load a JSON schema with caching.""" + key = str(path) + if key not in _schema_cache: + _schema_cache[key] = load_json_schema(path) + return _schema_cache[key] + + +# ANSI codes +_RED = "\033[91m" +_GREEN = "\033[92m" +_YELLOW = "\033[93m" +_CYAN = "\033[96m" +_BOLD = "\033[1m" +_END = "\033[0m" + +# Maps top-level dir / resource subtype to content type key +_RESOURCE_DIR_TO_KEY = { + "bet": "bet", + "books": "book", + "channels": "channel", + "conferences": "conference", + "glossary": "glossary", + "movies": "movie", + "newsletters": "newsletter", + "papers": "paper", + "podcasts": "podcast", + "projects": "project", +} + + +# --------------------------------------------------------------------------- +# Content discovery +# --------------------------------------------------------------------------- + + +def _discover_content_folders( + repo_root: Path, + registry: ContentRegistry, + type_filter: str | None = None, +) -> list[tuple[Path, str]]: + """Discover all content folders in the repo. + + Args: + repo_root: Repository root. + registry: Loaded content registry. + type_filter: Optional filter — a content type key (e.g. "course", "book"), + a top-level dir ("courses", "tutorials"), or a resource path + ("resources/books"). Special values: "courses", "tutorials". + + Returns: + Sorted list of (folder_path, content_type_key) tuples. + """ + folders: list[tuple[Path, str]] = [] + + # Determine which content type keys to scan + keys_to_scan = _resolve_type_filter(registry, type_filter) + + for key in keys_to_scan: + ct = registry.content_types.get(key) + if ct is None: + continue + folders.extend(_discover_for_type(repo_root, ct)) + + folders.sort(key=lambda t: t[0]) + return folders + + +def _resolve_type_filter( + registry: ContentRegistry, + type_filter: str | None, +) -> list[str]: + """Resolve a type_filter string to a list of content type keys.""" + if type_filter is None: + return list(registry.content_types.keys()) + + f = type_filter.lower().strip("/") + + # Direct key match (e.g. "course", "book", "glossary") + if f in registry.content_types: + return [f] + + # Plural top-level dir match: "courses" → "course", "tutorials" → "tutorial" + singular = f.rstrip("s") if f.endswith("s") and not f.endswith("ss") else f + if singular in registry.content_types: + return [singular] + + # All resource types: "resources" + if f == "resources": + return [k for k in _RESOURCE_DIR_TO_KEY.values() if k in registry.content_types] + + # Resource path: "resources/books" → "book" + if f.startswith("resources/"): + subtype = f.split("/", 1)[1].rstrip("/") + mapped = _RESOURCE_DIR_TO_KEY.get(subtype) + if mapped and mapped in registry.content_types: + return [mapped] + + # If nothing matched, return empty (will produce zero results) + return [] + + +def _discover_for_type( + repo_root: Path, + ct: ContentType, +) -> list[tuple[Path, str]]: + """Discover all content folders for a single content type.""" + folders: list[tuple[Path, str]] = [] + pattern = ct.path_pattern # e.g. "courses/{id}/" or "tutorials/{category}/{id}/" + + parts = pattern.strip("/").split("/") + base = repo_root / parts[0] + + if not base.exists() or not base.is_dir(): + return folders + + # Count how many path segments after the first static prefix + # "courses/{id}" → depth 1 under courses/ + # "tutorials/{category}/{id}" → depth 2 under tutorials/ + # "resources/books/{id}" → depth 1 under resources/books/ + + # Determine the static prefix (dirs before any {placeholder}) + static_parts: list[str] = [] + for p in parts: + if "{" in p: + break + static_parts.append(p) + + depth_after_static = len(parts) - len(static_parts) + prefix_dir = repo_root / "/".join(static_parts) + + if not prefix_dir.exists() or not prefix_dir.is_dir(): + return folders + + if depth_after_static == 1: + # Direct children (courses/{id}, events/{id}, resources/books/{id}, etc.) + for d in sorted(prefix_dir.iterdir()): + if d.is_dir() and not d.name.startswith("."): + folders.append((d, ct.key)) + elif depth_after_static == 2: + # Two levels deep (tutorials/{category}/{id}) + for cat_dir in sorted(prefix_dir.iterdir()): + if not cat_dir.is_dir() or cat_dir.name.startswith("."): + continue + for d in sorted(cat_dir.iterdir()): + if d.is_dir() and not d.name.startswith("."): + folders.append((d, ct.key)) + + return folders + + +# --------------------------------------------------------------------------- +# Single-folder validation (unchanged from Phase 2) +# --------------------------------------------------------------------------- + + +def _parse_yaml_item(path: Path) -> tuple[dict | list | None, ValidationResult | None]: + """Parse a YAML file; on failure return (None, error result) instead of raising.""" + try: + data = load_yaml(path) + except (yaml.YAMLError, OSError) as e: + r = ValidationResult(path=str(path)) + r.add_error(f"Failed to parse YAML: {e}") + return None, r + return data if data is not None else {}, None + + +def _validate_folder( + folder: Path, + registry: ContentRegistry, + repo_root: Path, +) -> list[ValidationResult]: + """Validate a single content folder. Returns a list of results.""" + if not folder.exists(): + r = ValidationResult(path=str(folder)) + r.add_error("Folder does not exist") + return [r] + + if not folder.is_dir(): + r = ValidationResult(path=str(folder)) + r.add_error("Path is not a directory") + return [r] + + ct = registry.detect_type_from_path(folder, repo_root) + if ct is None: + r = ValidationResult(path=str(folder)) + r.add_error( + f"Could not detect content type. Supported: {list(registry.content_types.keys())}" + ) + return [r] + + results: list[ValidationResult] = [] + + # Validate main metadata YAML + yaml_path = folder / ct.metadata_file + if not yaml_path.exists(): + r = ValidationResult(path=str(yaml_path)) + r.add_error(f"Missing metadata file: {ct.metadata_file}") + results.append(r) + return results + + yaml_data, parse_err = _parse_yaml_item(yaml_path) + if parse_err is not None: + results.append(parse_err) + else: + schema_abs = repo_root / ct.schema + if not schema_abs.exists(): + r = ValidationResult(path=str(yaml_path)) + r.add_warning(f"Schema file not found: {ct.schema}") + results.append(r) + else: + schema = _load_schema_cached(schema_abs) + result = validate_yaml_against_schema( + yaml_data, schema, str(yaml_path), schema_dir=schema_abs.parent, + ) + results.append(result) + + # Semantic validation for events + if ct.key == "event": + sem = _validate_event_semantics(yaml_data, str(yaml_path)) + if sem.errors or sem.warnings: + results.append(sem) + + # Validate content files + if ct.has_markdown_content: + content_schema_path = ( + (repo_root / ct.content_schema) if ct.content_schema else None + ) + content_schema = ( + _load_schema_cached(content_schema_path) + if content_schema_path and content_schema_path.exists() + else None + ) + + content_schema_dir = content_schema_path.parent if content_schema else None + + if ct.content_uses_yml: + for yml_file in folder.glob("*.yml"): + if yml_file.name != ct.metadata_file: + if content_schema: + results.append( + validate_yml_content( + yml_file, content_schema, content_schema_dir + ) + ) + else: + r = ValidationResult(path=str(yml_file)) + r.add_warning("Content schema not found — skipping") + results.append(r) + else: + for md_file in folder.glob("*.md"): + if md_file.name == "presentation.md": + continue + if content_schema: + results.append( + validate_markdown_frontmatter( + md_file, content_schema, content_schema_dir + ) + ) + else: + r = ValidationResult(path=str(md_file)) + r.add_warning("Content schema not found — skipping") + results.append(r) + + # Validate quizzes + if ct.has_quizzes: + results.extend(_validate_quizzes(folder, registry, repo_root)) + + return results + + +def _validate_event_semantics(yaml_data: object, file_path: str) -> ValidationResult: + """Semantic checks for events beyond schema type validation.""" + result = ValidationResult(path=file_path) + + if not isinstance(yaml_data, dict): + return result + + booking = yaml_data.get("book_online") is True or yaml_data.get("book_in_person") is True + + if booking and "available_seats" not in yaml_data: + result.add_warning("Booking enabled but available_seats not set") + + if booking and "project_id" not in yaml_data: + result.add_warning("Booking enabled but no project_id") + + price = yaml_data.get("price_dollars") + if ( + not booking + and isinstance(price, (int, float)) + and not isinstance(price, bool) + and price > 0 + ): + result.add_warning("Price set but booking is disabled") + + return result + + +def _validate_quizzes( + folder: Path, + registry: ContentRegistry, + repo_root: Path, +) -> list[ValidationResult]: + """Validate quizz/ subfolder.""" + results: list[ValidationResult] = [] + quizz_dir = folder / "quizz" + + if not quizz_dir.exists(): + return results + + if not quizz_dir.is_dir(): + r = ValidationResult(path=str(quizz_dir)) + r.add_error("'quizz' exists but is not a directory") + return [r] + + # Load quiz schemas + q_schema_path = registry.quiz_schemas.get("question") + t_schema_path = registry.quiz_schemas.get("translation") + q_schema = _load_schema_cached(repo_root / q_schema_path) if q_schema_path else None + t_schema = _load_schema_cached(repo_root / t_schema_path) if t_schema_path else None + + sub_folders = sorted( + d for d in quizz_dir.iterdir() if d.is_dir() and not d.name.startswith(".") + ) + + if not sub_folders: + r = ValidationResult(path=str(quizz_dir)) + r.add_warning("Quiz folder exists but contains no quiz subfolders") + return [r] + + for qf in sub_folders: + question_file = qf / "question.yml" + if not question_file.exists(): + r = ValidationResult(path=str(question_file)) + r.add_error("Missing required file: question.yml") + results.append(r) + continue + + # Validate question.yml against schema + if q_schema: + data, parse_err = _parse_yaml_item(question_file) + if parse_err is not None: + results.append(parse_err) + else: + schemas_dir = (repo_root / q_schema_path).parent + result = validate_yaml_against_schema( + data, q_schema, str(question_file), schema_dir=schemas_dir, + ) + results.append(result) + else: + # Fallback manual validation + results.append(_validate_question_manual(question_file)) + + # Validate translation files + trans_files = [f for f in qf.glob("*.yml") if f.name != "question.yml"] + if not trans_files: + r = ValidationResult(path=str(qf)) + r.add_error("No translation files found (e.g., en.yml, fr.yml)") + results.append(r) + else: + for tf in trans_files: + if t_schema: + data, parse_err = _parse_yaml_item(tf) + if parse_err is not None: + results.append(parse_err) + continue + schemas_dir = (repo_root / t_schema_path).parent + result = validate_yaml_against_schema( + data, t_schema, str(tf), schema_dir=schemas_dir, + ) + results.append(result) + else: + results.append(_validate_quiz_translation_manual(tf)) + + return results + + +def _validate_question_manual(question_file: Path) -> ValidationResult: + """Manual validation when no quiz schema is available.""" + result = ValidationResult(path=str(question_file)) + try: + data = load_yaml(question_file) + if data is None: + data = {} + for f in ("chapterId", "difficulty", "author"): + if f not in data: + result.add_error(f"Missing required field: '{f}'") + except Exception as e: + result.add_error(f"Failed to parse: {e}") + return result + + +def _validate_quiz_translation_manual(trans_file: Path) -> ValidationResult: + """Manual validation for quiz translation files.""" + result = ValidationResult(path=str(trans_file)) + try: + data = load_yaml(trans_file) + if data is None: + data = {} + for f in ("question", "answer", "wrong_answers"): + if f not in data: + result.add_error(f"Missing required field: '{f}'") + wa = data.get("wrong_answers") + if wa is not None and not isinstance(wa, list): + result.add_error("Field 'wrong_answers' must be a list") + except Exception as e: + result.add_error(f"Failed to parse: {e}") + return result + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + + +def _rel_path(path: str, repo_root: Path) -> str: + """Make a path relative to repo root where possible.""" + try: + return str(Path(path).relative_to(repo_root)) + except ValueError: + return path + + +def _print_results( + results: list[ValidationResult], + repo_root: Path, +) -> None: + """Print human-readable validation output.""" + total_errors = sum(len(r.errors) for r in results) + total_warnings = sum(len(r.warnings) for r in results) + + click.echo(f"\n{_BOLD}{'=' * 60}{_END}") + click.echo(f"{_BOLD}Validation Results{_END}") + click.echo(f"{'=' * 60}\n") + + for r in results: + rel = _rel_path(r.path, repo_root) + + if r.errors or r.warnings: + status = f"{_RED}FAILED{_END}" if r.errors else f"{_YELLOW}WARNINGS{_END}" + click.echo(f"{_CYAN}{rel}{_END} — {status}") + for e in r.errors: + click.echo(f" {_RED}ERROR:{_END} {e}") + for w in r.warnings: + click.echo(f" {_YELLOW}WARNING:{_END} {w}") + click.echo() + else: + click.echo(f"{_CYAN}{rel}{_END} — {_GREEN}PASSED{_END}") + + click.echo(f"\n{'=' * 60}") + if total_errors == 0 and total_warnings == 0: + click.echo(f"{_GREEN}{_BOLD}All validations passed!{_END}") + elif total_errors == 0: + click.echo(f"{_YELLOW}{_BOLD}Passed with {total_warnings} warning(s){_END}") + else: + click.echo( + f"{_RED}{_BOLD}Validation failed:{_END} " + f"{total_errors} error(s), {total_warnings} warning(s)" + ) + click.echo(f"{'=' * 60}\n") + + +def _print_summary( + items: list[dict], + repo_root: Path, +) -> None: + """Print summary-only output for --all --summary-only.""" + total = len(items) + passed = sum(1 for i in items if i["status"] == "passed") + with_errors = sum(1 for i in items if i["status"] == "error") + with_warnings = sum(1 for i in items if i["status"] == "warning") + + click.echo(f"\n{_BOLD}{'=' * 60}{_END}") + click.echo(f"{_BOLD}Validation Summary{_END}") + click.echo(f"{'=' * 60}\n") + click.echo(f" Total items: {total}") + click.echo(f" {_GREEN}Passed:{_END} {passed}") + click.echo(f" {_RED}Errors:{_END} {with_errors}") + click.echo(f" {_YELLOW}Warnings:{_END} {with_warnings}") + click.echo(f"\n{'=' * 60}\n") + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + + +def run_validate( + path: str | None, + json_output: bool, +) -> None: + """Core validate logic for a single path, called from the CLI command.""" + repo_root = find_repo_root() + registry = load_registry(repo_root) + + if path is None: + click.echo("Error: provide a content path to validate.", err=True) + raise SystemExit(1) + + folder = resolve_content_path(path, repo_root) + results = _validate_folder(folder, registry, repo_root) + + total_errors = sum(len(r.errors) for r in results) + total_warnings = sum(len(r.warnings) for r in results) + + if json_output: + output = { + "path": path, + "results": [r.to_dict() for r in results], + "total_errors": total_errors, + "total_warnings": total_warnings, + } + click.echo(json.dumps(output, indent=2)) + else: + _print_results(results, repo_root) + + # Exit codes: 0=pass, 1=errors, 2=warnings only + if total_errors > 0: + raise SystemExit(1) + elif total_warnings > 0: + raise SystemExit(2) + + +def run_validate_all( + json_output: bool, + summary_only: bool, + type_filter: str | None, +) -> None: + """Validate all content in the repo with optional filters.""" + from tqdm import tqdm + + repo_root = find_repo_root() + registry = load_registry(repo_root) + + content_folders = _discover_content_folders(repo_root, registry, type_filter) + + if not content_folders: + msg = "No content found" + if type_filter: + msg += f" for type filter '{type_filter}'" + click.echo(msg, err=True) + raise SystemExit(1) + + # Aggregate per-item results + items: list[dict] = [] + total_errors = 0 + total_warnings = 0 + + # Use tqdm for progress (only in non-JSON mode) + iterator = content_folders + if not json_output: + iterator = tqdm( + content_folders, + desc="Validating", + unit="item", + ncols=80, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]", + ) + + for folder, ct_key in iterator: + try: + rel_path = str(folder.relative_to(repo_root)) + except ValueError: + rel_path = str(folder) + + results = _validate_folder(folder, registry, repo_root) + item_errors = sum(len(r.errors) for r in results) + item_warnings = sum(len(r.warnings) for r in results) + total_errors += item_errors + total_warnings += item_warnings + + if item_errors > 0: + status = "error" + elif item_warnings > 0: + status = "warning" + else: + status = "passed" + + items.append({ + "path": rel_path, + "type": ct_key, + "status": status, + "errors": [ + f"{_rel_path(r.path, repo_root)}: {e}" for r in results for e in r.errors + ], + "warnings": [ + f"{_rel_path(r.path, repo_root)}: {w}" for r in results for w in r.warnings + ], + }) + + # Build summary + summary = { + "total": len(items), + "passed": sum(1 for i in items if i["status"] == "passed"), + "errors": sum(1 for i in items if i["status"] == "error"), + "warnings": sum(1 for i in items if i["status"] == "warning"), + } + + if json_output: + output: dict = {"summary": summary, "items": items} + click.echo(json.dumps(output, indent=2)) + elif summary_only: + _print_summary(items, repo_root) + else: + # Print items with issues, then summary + click.echo(f"\n{_BOLD}{'=' * 60}{_END}") + click.echo(f"{_BOLD}Validation Results{_END}") + click.echo(f"{'=' * 60}\n") + + for item in items: + if item["status"] == "error": + click.echo(f"{_CYAN}{item['path']}{_END} [{item['type']}] — {_RED}FAILED{_END}") + for e in item["errors"]: + click.echo(f" {_RED}ERROR:{_END} {e}") + for w in item["warnings"]: + click.echo(f" {_YELLOW}WARNING:{_END} {w}") + click.echo() + elif item["status"] == "warning": + click.echo(f"{_CYAN}{item['path']}{_END} [{item['type']}] — {_YELLOW}WARNINGS{_END}") + for w in item["warnings"]: + click.echo(f" {_YELLOW}WARNING:{_END} {w}") + click.echo() + + # Summary line + click.echo(f"{'=' * 60}") + click.echo( + f"Total: {summary['total']} | " + f"{_GREEN}Passed: {summary['passed']}{_END} | " + f"{_RED}Errors: {summary['errors']}{_END} | " + f"{_YELLOW}Warnings: {summary['warnings']}{_END}" + ) + click.echo(f"{'=' * 60}\n") + + # Exit codes: 0=pass, 1=errors, 2=warnings only + if total_errors > 0: + raise SystemExit(1) + elif total_warnings > 0: + raise SystemExit(2) diff --git a/scripts/bec/src/bec/lib/__init__.py b/scripts/bec/src/bec/lib/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/scripts/bec/src/bec/lib/content_types.py b/scripts/bec/src/bec/lib/content_types.py new file mode 100644 index 00000000000..f433dcdb617 --- /dev/null +++ b/scripts/bec/src/bec/lib/content_types.py @@ -0,0 +1,154 @@ +"""Parse and query the content-types.yml registry.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from bec.lib.repo import find_repo_root +from bec.lib.yaml_utils import load_yaml + +REGISTRY_FILENAME = "content-types.yml" + + +@dataclass +class ContentType: + """A single content type definition.""" + + key: str + name: str + path_pattern: str + metadata_file: str + schema: str + content_schema: str | None = None + has_markdown_content: bool = True + has_quizzes: bool = False + content_uses_yml: bool = False + example: str = "" + + +@dataclass +class ContentRegistry: + """The full parsed content-types.yml registry.""" + + content_types: dict[str, ContentType] = field(default_factory=dict) + tutorial_categories: list[str] = field(default_factory=list) + discipline_codes: dict[str, str] = field(default_factory=dict) + level_range: dict[str, str] = field(default_factory=dict) + tags: list[str] = field(default_factory=list) + languages: list[str] = field(default_factory=list) + quiz_schemas: dict[str, str] = field(default_factory=dict) + + def get_type(self, key: str) -> ContentType: + """Get a content type by key. Raises KeyError if not found.""" + return self.content_types[key] + + def get_schema_path(self, key: str, repo_root: Path | None = None) -> Path: + """Get the absolute schema path for a content type.""" + root = repo_root or find_repo_root() + return root / self.content_types[key].schema + + def get_content_schema_path( + self, key: str, repo_root: Path | None = None + ) -> Path | None: + """Get the absolute content schema path, or None if not applicable.""" + ct = self.content_types[key] + if ct.content_schema is None: + return None + root = repo_root or find_repo_root() + return root / ct.content_schema + + def detect_type_from_path(self, path: Path, repo_root: Path | None = None) -> ContentType | None: + """Detect the content type from a filesystem path. + + Matches the path against known path patterns. + """ + root = repo_root or find_repo_root() + try: + rel = path.resolve().relative_to(root.resolve()) + except ValueError: + return None + + parts = rel.parts + + if not parts: + return None + + # courses/{id} + if parts[0] == "courses" and len(parts) >= 2: + return self.content_types.get("course") + + # tutorials/{category}/{id} + if parts[0] == "tutorials" and len(parts) >= 3: + return self.content_types.get("tutorial") + + # professors/{id} + if parts[0] == "professors" and len(parts) >= 2: + return self.content_types.get("professor") + + # events/{id} + if parts[0] == "events" and len(parts) >= 2: + return self.content_types.get("event") + + # resources/{subtype}/{id} + if parts[0] == "resources" and len(parts) >= 3: + subtype = parts[1] + type_map = { + "bet": "bet", + "books": "book", + "channels": "channel", + "conferences": "conference", + "glossary": "glossary", + "movies": "movie", + "newsletters": "newsletter", + "papers": "paper", + "podcasts": "podcast", + "projects": "project", + } + return self.content_types.get(type_map.get(subtype, "")) + + return None + + +def load_registry(repo_root: Path | None = None) -> ContentRegistry: + """Load and parse the content-types.yml file. + + Args: + repo_root: Repo root directory. Auto-detected if None. + + Returns: + A ContentRegistry with all parsed data. + + Raises: + FileNotFoundError: If content-types.yml is not found. + """ + root = repo_root or find_repo_root() + registry_path = root / REGISTRY_FILENAME + data = load_yaml(registry_path) + if data is None: + raise ValueError(f"Empty registry file: {registry_path}") + + content_types = {} + for key, ct_data in data.get("content_types", {}).items(): + content_types[key] = ContentType( + key=key, + name=ct_data["name"], + path_pattern=ct_data["path_pattern"], + metadata_file=ct_data["metadata_file"], + schema=ct_data["schema"], + content_schema=ct_data.get("content_schema"), + has_markdown_content=ct_data.get("has_markdown_content", True), + has_quizzes=ct_data.get("has_quizzes", False), + content_uses_yml=ct_data.get("content_uses_yml", False), + example=ct_data.get("example", ""), + ) + + return ContentRegistry( + content_types=content_types, + tutorial_categories=data.get("tutorial_categories", []), + discipline_codes=data.get("discipline_codes", {}), + level_range=data.get("level_range", {}), + tags=data.get("tags", []), + languages=data.get("languages", []), + quiz_schemas=data.get("quiz_schemas", {}), + ) diff --git a/scripts/bec/src/bec/lib/markdown.py b/scripts/bec/src/bec/lib/markdown.py new file mode 100644 index 00000000000..da6554a8800 --- /dev/null +++ b/scripts/bec/src/bec/lib/markdown.py @@ -0,0 +1,52 @@ +"""Markdown manipulation and chapter ID generation.""" + +from __future__ import annotations + +import uuid +from pathlib import Path + + +def generate_chapter_id() -> str: + """Generate a UUID chapter ID (the LMS importer requires valid UUIDs).""" + return str(uuid.uuid4()) + + +def build_part_block(title: str, part_id: str) -> str: + """Build the markdown block for a new part. + + Returns: + String like: +++\\n\\n# Title\\n\\nuuid + """ + return f"+++\n\n# {title}\n\n{part_id}\n" + + +def build_chapter_block(title: str, chapter_id: str | None = None) -> str: + """Build the markdown block for a new chapter. + + Args: + title: Chapter heading text. + chapter_id: Optional chapter ID. Auto-generated UUID if None. + + Returns: + String like: ## Title\\n\\nuuid + """ + cid = chapter_id or generate_chapter_id() + return f"## {title}\n\n{cid}\n" + + +def append_to_markdown(filepath: Path, block: str) -> None: + """Append a content block to the end of a markdown file. + + Ensures exactly one blank line before the appended block. + Existing line endings are preserved (no newline translation). + """ + with filepath.open(encoding="utf-8", newline="") as f: + content = f.read() + + # Ensure trailing newline separation + separator = "" + if content and not content.endswith("\n\n"): + separator = "\n" if content.endswith("\n") else "\n\n" + + with filepath.open("a", encoding="utf-8", newline="") as f: + f.write(separator + block) diff --git a/scripts/bec/src/bec/lib/proofreading.py b/scripts/bec/src/bec/lib/proofreading.py new file mode 100644 index 00000000000..cedde65f846 --- /dev/null +++ b/scripts/bec/src/bec/lib/proofreading.py @@ -0,0 +1,331 @@ +"""Proofreading metadata: reward computation, contributor management, status queries.""" + +from __future__ import annotations + +import os +from datetime import date +from pathlib import Path + +from bec.lib.yaml_utils import load_yaml, dump_yaml + +# ---- Constants ---------------------------------------------------------------- + +BASE_FEE = 0.1 +DOLLARS_PER_WORD = 0.001 +MAX_PAID_ITERATIONS = 2 # Reward drops to 0 after this many contributors + +# Files that carry proofreading metadata +METADATA_FILES = { + "course.yml", + "question.yml", + "tutorial.yml", + "book.yml", + "word.yml", + "bet.yml", + "project.yml", + "conference.yml", +} + +# Language difficulty factors (how hard a language is to translate into). +# Keys are lowercase; always look up via get_language_factor(). +LANGUAGE_FACTORS: dict[str, float] = { + "en": 1.0, "fr": 1.0, "de": 1.0, "es": 1.0, "it": 1.0, "pt": 1.0, + "ro": 1.0, "sv": 1.0, + "cs": 1.5, "ru": 1.5, "fi": 1.5, "et": 1.5, "uk": 1.5, "nb-no": 1.5, + "pl": 1.5, "sw": 1.5, "fa": 1.5, "nl": 1.5, "bg": 1.5, + "id": 2.0, "zh-hans": 2.0, "tr": 2.0, "ha": 2.0, "sr-latn": 2.0, + "zh-hant": 2.0, "ko": 2.0, "th": 2.0, + "vi": 2.5, "ja": 2.5, "hi": 2.5, "rn": 2.5, +} + + +def get_language_factor(language: str) -> float: + """Language difficulty factor, matched case-insensitively.""" + return LANGUAGE_FACTORS.get(language.lower(), 1.0) + +# Content difficulty multiplier by level +CONTENT_DIFFICULTY: dict[str, int] = { + "beginner": 1, + "intermediate": 2, + "advanced": 3, + "expert": 4, +} + + +# ---- Core helpers ------------------------------------------------------------- + +def find_metadata_file(content_path: Path) -> Path | None: + """Find the metadata YAML file inside a content directory.""" + for name in METADATA_FILES: + candidate = content_path / name + if candidate.is_file(): + return candidate + return None + + +def count_words(file_path: Path) -> int: + """Count words in a file.""" + text = file_path.read_text(encoding="utf-8") + return len(text.split()) + + +def get_original_word_count(metadata_path: Path, data: dict) -> int: + """Count words in the original-language content file.""" + original = data.get("original_language") + if not original: + raise ValueError(f"No 'original_language' field in {metadata_path}") + + directory = metadata_path.parent + for ext in (".md", ".yml"): + candidate = directory / f"{original}{ext}" + if candidate.is_file(): + return count_words(candidate) + + raise FileNotFoundError( + f"No content file for original language '{original}' in {directory}" + ) + + +def compute_reward( + words: int, + language_factor: float, + urgency: int | float, + proofread_iteration: int, +) -> float: + """Compute the proofreading reward in dollars. + + Formula: (urgency * (0.001 * words * language_factor) + BASE_FEE) * 2^(-iteration) + Returns 0 if iteration >= MAX_PAID_ITERATIONS. + """ + if proofread_iteration >= MAX_PAID_ITERATIONS: + return 0.0 + reward = (urgency * (DOLLARS_PER_WORD * words * language_factor) + BASE_FEE) * 2 ** (-proofread_iteration) + return round(reward, 2) + + +def get_difficulty_factor(data: dict) -> float: + """Get difficulty multiplier: 3.0 for glossary, else from level field.""" + if data.get("en_word"): + return 3.0 + level = data.get("level") + if level and level in CONTENT_DIFFICULTY: + return float(CONTENT_DIFFICULTY[level]) + return 1.0 + + +# ---- Proofreading entry helpers ----------------------------------------------- + +def get_proofreading_entries(data: dict) -> list[dict]: + """Get the proofreading list from metadata, or empty list.""" + entries = data.get("proofreading") + if entries is None: + return [] + return list(entries) + + +def find_language_entry(data: dict, language: str) -> dict | None: + """Find the proofreading entry for a specific language.""" + for entry in get_proofreading_entries(data): + if entry.get("language", "").lower() == language.lower(): + return entry + return None + + +def get_contributor_count(entry: dict) -> int: + """Count existing contributors for a language entry.""" + names = entry.get("contributor_names") + if names is None: + return 0 + return len(names) + + +def add_contributor(data: dict, language: str, contributor: str) -> tuple[bool, str]: + """Add a contributor to a language's proofreading entry. + + Returns (success, message). + """ + entry = find_language_entry(data, language) + if entry is None: + available = [e.get("language", "?") for e in get_proofreading_entries(data)] + return False, f"Language '{language}' not found. Available: {available}" + + if entry.get("contributor_names") is None: + entry["contributor_names"] = [] + + if contributor in entry["contributor_names"]: + return False, f"'{contributor}' already listed for {language}" + + entry["contributor_names"].append(contributor) + entry["last_contribution_date"] = str(date.today()) + return True, f"Added '{contributor}' to {language}" + + +def evaluate_reward_for_language(metadata_path: Path, data: dict, language: str) -> dict: + """Evaluate the proofreading reward for a specific language. + + Returns a dict with reward details. + """ + entry = find_language_entry(data, language) + if entry is None: + return {"error": f"Language '{language}' not in proofreading entries"} + + words = get_original_word_count(metadata_path, data) + lang_factor = get_language_factor(language) + urgency = entry.get("urgency", 1) + iteration = get_contributor_count(entry) + + reward = compute_reward(words, lang_factor, urgency, iteration) + remaining = max(0, MAX_PAID_ITERATIONS - iteration) + + return { + "language": language, + "words": words, + "language_factor": lang_factor, + "urgency": urgency, + "iteration": iteration, + "reward": reward, + "remaining_paid_proofreadings": remaining, + "contributors": entry.get("contributor_names") or [], + } + + +def get_status_matrix(metadata_path: Path, data: dict) -> list[dict]: + """Get proofreading status for all languages in a content item.""" + entries = get_proofreading_entries(data) + if not entries: + return [] + + original_lang = data.get("original_language", "?") + try: + words = get_original_word_count(metadata_path, data) + except (ValueError, FileNotFoundError): + words = 0 + + result = [] + for entry in entries: + lang = entry.get("language", "?") + contributors = entry.get("contributor_names") or [] + iteration = len(contributors) + urgency = entry.get("urgency", 1) + lang_factor = get_language_factor(lang) + reward = compute_reward(words, lang_factor, urgency, iteration) + remaining = max(0, MAX_PAID_ITERATIONS - iteration) + + result.append({ + "language": lang, + "is_original": lang == original_lang, + "contributors": contributors, + "iteration": iteration, + "remaining_paid_proofreadings": remaining, + "urgency": urgency, + "reward": reward, + "last_contribution_date": entry.get("last_contribution_date"), + }) + + return result + + +def update_metadata_file(metadata_path: Path, data: dict) -> None: + """Write updated metadata back to the YAML file. + + Uses line-level manipulation to only rewrite the proofreading section, + preserving the rest of the file (comments, ordering, formatting). + """ + original_content = metadata_path.read_text(encoding="utf-8") + lines = original_content.split("\n") + + # Find proofreading section boundaries + proof_start = -1 + proof_indent = "" + for i, line in enumerate(lines): + stripped = line.strip() + if stripped == "proofreading:" or stripped.startswith("proofreading:"): + proof_start = i + proof_indent = line[: len(line) - len(line.lstrip())] + break + + if proof_start == -1: + # No proofreading section — just dump normally + dump_yaml(data, metadata_path) + return + + # Find where proofreading section ends + base_indent_len = len(proof_indent) + proof_end = len(lines) + for i in range(proof_start + 1, len(lines)): + line = lines[i] + if line.strip() == "": + continue + current_indent = len(line) - len(line.lstrip()) + if current_indent <= base_indent_len and line.strip(): + proof_end = i + break + + # Build new proofreading section + new_lines = [f"{proof_indent}proofreading:"] + for entry in data.get("proofreading", []): + new_lines.append(f"{proof_indent} - language: {entry['language']}") + + # last_contribution_date + lcd = entry.get("last_contribution_date") + if lcd: + new_lines.append(f"{proof_indent} last_contribution_date: '{lcd}'") + else: + new_lines.append(f"{proof_indent} last_contribution_date:") + + urgency = entry.get("urgency", 1) + if urgency is None: + new_lines.append(f"{proof_indent} urgency:") + else: + new_lines.append(f"{proof_indent} urgency: {urgency}") + + # contributor_names + names = entry.get("contributor_names") + if names and len(names) > 0: + new_lines.append(f"{proof_indent} contributor_names:") + for name in names: + new_lines.append(f"{proof_indent} - {name}") + else: + new_lines.append(f"{proof_indent} contributor_names:") + + reward = entry.get("reward", 0) + if reward is None: + new_lines.append(f"{proof_indent} reward:") + else: + new_lines.append(f"{proof_indent} reward: {reward}") + + # Reconstruct + before = "\n".join(lines[:proof_start]) + after = "\n".join(lines[proof_end:]) + new_section = "\n".join(new_lines) + + if before: + result = before + "\n" + new_section + else: + result = new_section + if after.strip(): + result = result + "\n" + after + else: + result = result + "\n" + + metadata_path.write_text(result, encoding="utf-8") + + +def recalculate_rewards(metadata_path: Path, data: dict) -> None: + """Recalculate rewards for all languages in a content item.""" + entries = get_proofreading_entries(data) + if not entries: + return + + try: + words = get_original_word_count(metadata_path, data) + except (ValueError, FileNotFoundError): + return + + for entry in entries: + lang = entry.get("language", "") + lang_factor = get_language_factor(lang) + urgency = entry.get("urgency", 1) + iteration = get_contributor_count(entry) + reward = compute_reward(words, lang_factor, urgency, iteration) + entry["reward"] = reward diff --git a/scripts/bec/src/bec/lib/repo.py b/scripts/bec/src/bec/lib/repo.py new file mode 100644 index 00000000000..eacf76657e8 --- /dev/null +++ b/scripts/bec/src/bec/lib/repo.py @@ -0,0 +1,35 @@ +"""Repo root detection and path resolution.""" + +from pathlib import Path + +MARKER_FILE = "content-types.yml" + + +def find_repo_root(start: Path | None = None) -> Path: + """Walk up from start (default: cwd) to find the repo root. + + The repo root is identified by the presence of content-types.yml. + + Raises: + FileNotFoundError: If no repo root is found. + """ + current = (start or Path.cwd()).resolve() + for directory in [current, *current.parents]: + if (directory / MARKER_FILE).is_file(): + return directory + raise FileNotFoundError( + f"Cannot find repo root (no {MARKER_FILE} found above {start or Path.cwd()})" + ) + + +def resolve_content_path(path_str: str, repo_root: Path | None = None) -> Path: + """Resolve a content path relative to the repo root. + + Accepts both absolute and relative paths. Relative paths are resolved + against the repo root. + """ + path = Path(path_str) + if path.is_absolute(): + return path + root = repo_root or find_repo_root() + return (root / path).resolve() diff --git a/scripts/bec/src/bec/lib/schema.py b/scripts/bec/src/bec/lib/schema.py new file mode 100644 index 00000000000..48aa366bc35 --- /dev/null +++ b/scripts/bec/src/bec/lib/schema.py @@ -0,0 +1,333 @@ +"""Load JSON schemas and validate data against them.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from jsonschema import Draft7Validator +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT7 + +from bec.lib.yaml_utils import load_yaml + +# Module-level registry cache: schemas_dir → Registry +_registry_cache: dict[str, Registry] = {} + + +@dataclass +class ValidationResult: + """Holds validation results for a single file or content check.""" + + path: str + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def is_valid(self) -> bool: + return len(self.errors) == 0 + + def add_error(self, msg: str) -> None: + self.errors.append(msg) + + def add_warning(self, msg: str) -> None: + self.warnings.append(msg) + + def to_dict(self) -> dict: + return { + "path": self.path, + "valid": self.is_valid, + "errors": self.errors, + "warnings": self.warnings, + } + + +def load_json_schema(schema_path: Path) -> dict: + """Load a JSON Schema file from disk.""" + with open(schema_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def _build_registry(schemas_dir: Path) -> Registry: + """Build a referencing Registry from JSON Schema files in the schemas directory. + + Loads tags-definitions.json (and any future shared definitions) so that + $ref pointers like "tags-definitions.json#/definitions/tag_item" resolve. + """ + key = str(schemas_dir) + if key in _registry_cache: + return _registry_cache[key] + + registry = Registry() + tags_path = schemas_dir / "tags-definitions.json" + if tags_path.exists(): + with open(tags_path, "r", encoding="utf-8") as f: + tags_schema = json.load(f) + resource = Resource.from_contents(tags_schema, default_specification=DRAFT7) + registry = registry.with_resource("tags-definitions.json", resource) + + _registry_cache[key] = registry + return registry + + +def _allows_null(schema: Any) -> bool: + """Whether a known schema node accepts null (per jsonschema semantics). + + Untyped nodes accept anything, including null. Unknown nodes (None, i.e. + field not described by the schema) and unresolvable $refs return False so + their nulls keep the legacy strip-and-warn behavior. + """ + if isinstance(schema, bool): + return schema + if not isinstance(schema, dict): + return False + try: + return Draft7Validator(schema).is_valid(None) + except Exception: + return False + + +def _strip_nulls( + obj: Any, + schema: Any = None, + path: str = "", +) -> tuple[Any, list[str]]: + """Recursively remove schema-disallowed null values. + + Returns cleaned data and the paths of stripped nulls. Nulls explicitly + allowed by the schema (type: [x, "null"], enum with null, anyOf/oneOf) + are kept so jsonschema validates them normally. + """ + schema = schema if isinstance(schema, dict) else {} + null_paths: list[str] = [] + + if obj is None: + return None, [path] if path else [] + elif isinstance(obj, dict): + props = schema.get("properties", {}) + additional = schema.get("additionalProperties") + cleaned: dict[str, Any] = {} + for k, v in obj.items(): + current = f"{path} -> {k}" if path else k + sub_schema = props.get(k, additional) + if v is None: + if _allows_null(sub_schema): + cleaned[k] = None + else: + null_paths.append(current) + else: + cleaned_v, sub = _strip_nulls(v, sub_schema, current) + null_paths.extend(sub) + if cleaned_v is not None: + cleaned[k] = cleaned_v + return cleaned, null_paths + elif isinstance(obj, list): + items_schema = schema.get("items") + cleaned_list: list[Any] = [] + for i, item in enumerate(obj): + current = f"{path}[{i}]" + if item is None: + if _allows_null(items_schema): + cleaned_list.append(None) + else: + null_paths.append(current) + else: + cleaned_item, sub = _strip_nulls(item, items_schema, current) + null_paths.extend(sub) + if cleaned_item is not None: + cleaned_list.append(cleaned_item) + return cleaned_list, null_paths + return obj, null_paths + + +def validate_yaml_against_schema( + yaml_data: dict, + schema: dict, + file_path: str, + schema_dir: Path | None = None, +) -> ValidationResult: + """Validate a YAML-loaded dict against a JSON Schema Draft 7. + + Null values are stripped and reported as warnings. + When schema_dir is provided, $ref pointers to sibling schema files are resolved. + """ + result = ValidationResult(path=file_path) + + cleaned, null_paths = _strip_nulls(yaml_data, schema) + for p in null_paths: + result.add_warning(f"[{p}] Empty/null value") + + registry = _build_registry(schema_dir) if schema_dir else Registry() + validator = Draft7Validator(schema, registry=registry) + for error in validator.iter_errors(cleaned): + path_str = ( + " -> ".join(str(p) for p in error.absolute_path) + if error.absolute_path + else "root" + ) + result.add_error(f"[{path_str}] {error.message}") + + return result + + +def validate_markdown_frontmatter( + md_path: Path, + content_schema: dict, + schema_dir: Path | None = None, +) -> ValidationResult: + """Validate markdown frontmatter and content rules. + + Uses python-frontmatter to parse the file, validates the YAML metadata + against the schema's properties/required, then applies content_rules. + """ + import frontmatter + + result = ValidationResult(path=str(md_path)) + + try: + post = frontmatter.load(md_path) + except Exception as e: + result.add_error(f"Failed to parse markdown: {e}") + return result + + _validate_content_fields( + result, + dict(post.metadata), + content_schema, + "Missing required frontmatter field: '{}'", + schema_dir, + ) + + # Apply content_rules if present + content_rules = content_schema.get("content_rules", {}) + if content_rules: + _validate_content_rules(result, post.content, content_rules, md_path) + + return result + + +def validate_yml_content( + yml_path: Path, + content_schema: dict, + schema_dir: Path | None = None, +) -> ValidationResult: + """Validate YML content files (books, bet, projects) against a schema.""" + result = ValidationResult(path=str(yml_path)) + + try: + data = load_yaml(yml_path) + if data is None: + data = {} + except Exception as e: + result.add_error(f"Failed to parse YAML: {e}") + return result + + _validate_content_fields( + result, data, content_schema, "Missing required field: '{}'", schema_dir, + ) + + return result + + +def _validate_content_fields( + result: ValidationResult, + data: Any, + content_schema: dict, + missing_msg: str, + schema_dir: Path | None = None, +) -> None: + """Validate data against a content schema's properties/required. + + Builds a plain object schema (dropping the non-standard content_rules key) + and runs Draft7Validator so pattern/items/nested/additionalProperties and + bool-vs-integer distinctions are all enforced. + """ + schema: dict[str, Any] = { + "type": "object", + "properties": content_schema.get("properties", {}), + "required": content_schema.get("required", []), + } + if "additionalProperties" in content_schema: + schema["additionalProperties"] = content_schema["additionalProperties"] + + registry = _build_registry(schema_dir) if schema_dir else Registry() + validator = Draft7Validator(schema, registry=registry) + for error in validator.iter_errors(data): + if error.validator == "required" and not error.absolute_path: + field_name = error.message.split("'")[1] + result.add_error(missing_msg.format(field_name)) + elif error.absolute_path: + path_str = " -> ".join(str(p) for p in error.absolute_path) + result.add_error(f"Field '{path_str}': {error.message}") + else: + result.add_error(error.message) + + +def _validate_content_rules( + result: ValidationResult, + content: str, + rules: dict, + md_path: Path, +) -> None: + """Validate markdown body against content_rules from the schema.""" + # Check cover image + cover_rule = rules.get("cover_image", {}) + if cover_rule.get("required", False): + cover_pattern = re.compile(r"!\[.*?\]\((?:assets/)?cover\.webp\)") + first_10 = "\n".join(content.split("\n")[:10]) + if not cover_pattern.search(first_10): + result.add_error( + "Cover image ![...](cover.webp) or ![...](assets/cover.webp) " + "must appear within the first 10 lines after YAML front-matter" + ) + + # Check headings + heading_rules = rules.get("headings", {}) + h1_rule = heading_rules.get("h1", {}) + if h1_rule.get("allowed") is False: + # Strip code blocks before checking + no_code = re.sub(r"```[\s\S]*?```", "", content) + no_code = re.sub(r"~~~[\s\S]*?~~~", "", no_code) + no_code = re.sub(r"^(?: |\t).*$", "", no_code, flags=re.MULTILINE) + if re.findall(r"^# [^#]", no_code, re.MULTILINE): + result.add_error( + "H1 headings (# Title) are not allowed - title comes from YAML 'name' field" + ) + + # Check list markers + list_rules = rules.get("lists", {}) + unordered = list_rules.get("unordered", {}) + if unordered.get("marker") == "-": + asterisk = re.findall(r"^\* ", content, re.MULTILINE) + if asterisk: + result.add_warning( + f"Found {len(asterisk)} asterisk (*) list markers. Use dash (-) instead." + ) + + # Check image formats + image_rules = rules.get("images", {}) + if image_rules.get("format") == "WebP only (.webp extension)": + non_webp = re.findall( + r"!\[.*?\]\([^)]+\.(png|jpg|jpeg|gif)\)", content, re.IGNORECASE + ) + if non_webp: + result.add_error("Non-WebP images found. All images must be .webp format.") + + # Check alt text + alt_text_rule = image_rules.get("alt_text", {}) + if alt_text_rule.get("required"): + empty_alt = re.findall(r"!\[\]\(", content) + if empty_alt: + result.add_warning(f"Found {len(empty_alt)} images without alt text") + + # Check assets folder + assets_path = md_path.parent / "assets" + if cover_rule.get("required") and not assets_path.exists(): + result.add_error("Assets folder is missing") + + if cover_rule.get("required") and assets_path.exists(): + if not (assets_path / "cover.webp").exists(): + result.add_error("Cover image 'assets/cover.webp' is missing") diff --git a/scripts/bec/src/bec/lib/yaml_utils.py b/scripts/bec/src/bec/lib/yaml_utils.py new file mode 100644 index 00000000000..724458b85c8 --- /dev/null +++ b/scripts/bec/src/bec/lib/yaml_utils.py @@ -0,0 +1,52 @@ +"""Safe YAML loading with date-preservation.""" + +import datetime +from pathlib import Path + +import yaml + +# Prefer CSafeLoader (C extension, ~10x faster) when available +_BaseLoader = getattr(yaml, "CSafeLoader", yaml.SafeLoader) + + +class _SafeLoaderNoDate(_BaseLoader): + """SafeLoader that keeps dates as strings instead of converting to datetime.""" + + pass + + +# Remove the implicit date resolver so YAML dates stay as strings +_SafeLoaderNoDate.yaml_implicit_resolvers = { + k: [(tag, regexp) for tag, regexp in v if tag != "tag:yaml.org,2002:timestamp"] + for k, v in _BaseLoader.yaml_implicit_resolvers.copy().items() +} + + +def load_yaml(path: Path) -> dict | list | None: + """Load a YAML file without converting dates to datetime objects. + + Returns the parsed YAML data, or None if the file is empty. + + Raises: + FileNotFoundError: If the file does not exist. + yaml.YAMLError: If the file contains invalid YAML. + """ + with open(path, "r", encoding="utf-8") as f: + return yaml.load(f, Loader=_SafeLoaderNoDate) + + +def load_yaml_string(text: str) -> dict | list | None: + """Load YAML from a string without converting dates.""" + return yaml.load(text, Loader=_SafeLoaderNoDate) + + +def dump_yaml(data: dict | list, path: Path) -> None: + """Write data to a YAML file with safe defaults.""" + with open(path, "w", encoding="utf-8") as f: + yaml.dump( + data, + f, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) diff --git a/scripts/validation-format/schemas/bet-content-scheme.json b/scripts/bec/src/bec/schemas/bet-content-scheme.json similarity index 82% rename from scripts/validation-format/schemas/bet-content-scheme.json rename to scripts/bec/src/bec/schemas/bet-content-scheme.json index 40fd4e9b1c9..2ea0509f455 100644 --- a/scripts/validation-format/schemas/bet-content-scheme.json +++ b/scripts/bec/src/bec/schemas/bet-content-scheme.json @@ -12,6 +12,9 @@ "description": "A description of the BET content. Supports multiline text." } }, - "required": ["name", "description"], - "additionalProperties": false + "required": [ + "name", + "description" + ], + "additionalProperties": true } diff --git a/scripts/validation-format/schemas/bet-scheme.json b/scripts/bec/src/bec/schemas/bet-scheme.json similarity index 75% rename from scripts/validation-format/schemas/bet-scheme.json rename to scripts/bec/src/bec/schemas/bet-scheme.json index 72bdcbf6ee3..3d9dbf4b0c9 100644 --- a/scripts/validation-format/schemas/bet-scheme.json +++ b/scripts/bec/src/bec/schemas/bet-scheme.json @@ -6,12 +6,15 @@ "id": { "type": "string", "description": "Unique identifier for the BET content (UUID v4).", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }, "type": { "type": "string", "description": "The type of BET content.", - "enum": ["Educational Content", "Visual Content"] + "enum": [ + "Educational Content", + "Visual Content" + ] }, "links": { "type": "object", @@ -34,7 +37,10 @@ } } }, - "additionalProperties": false + "additionalProperties": false, + "required": [ + "download" + ] }, "contributor_names": { "type": "array", @@ -47,7 +53,7 @@ "project_id": { "type": "string", "description": "UUID of the associated project that organizes this content.", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }, "original_language": { "type": "string", @@ -64,30 +70,41 @@ "description": "Language code for the version being proofread." }, "last_contribution_date": { - "type": "string", - "description": "The date when last contribution was received (YYYY-MM-DD format).", - "pattern": "^(YYYY-MM-DD|[0-9]{4}-[0-9]{2}-[0-9]{2})$" + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, "urgency": { - "type": "integer", + "type": [ + "integer", + "null" + ], "description": "Level of urgency from 1 (low) to higher values (high urgency).", "minimum": 1 }, "contributor_names": { - "type": "array", - "description": "List of contributor GitHub usernames for this proofreading.", + "type": [ + "array", + "null" + ], "items": { "type": "string" - }, - "minItems": 1 + } }, "reward": { - "type": "integer", - "description": "Amount of sats available for reward.", + "type": [ + "number", + "null" + ], "minimum": 0 } }, - "required": ["language", "last_contribution_date", "urgency", "contributor_names", "reward"], + "required": [ + "language", + "reward" + ], "additionalProperties": false } }, @@ -95,14 +112,18 @@ "type": "array", "description": "List of associated tags to this content from the PlanB Network tag list.", "items": { - "type": "string" + "type": "string", + "minLength": 1 }, "minItems": 1 }, "license": { "type": "string", "description": "License for the content.", - "enum": ["CC-BY-SA-V4", "MIT"] + "enum": [ + "CC-BY-SA-V4", + "MIT" + ] } }, "assets_rules": { @@ -120,6 +141,11 @@ } ] }, - "required": ["id", "type", "contributor_names", "original_language", "proofreading", "tags", "license"], + "required": [ + "id", + "type", + "links", + "original_language" + ], "additionalProperties": false } diff --git a/scripts/validation-format/schemas/book-content-scheme.json b/scripts/bec/src/bec/schemas/book-content-scheme.json similarity index 52% rename from scripts/validation-format/schemas/book-content-scheme.json rename to scripts/bec/src/bec/schemas/book-content-scheme.json index fef97b04579..714e1fbd875 100644 --- a/scripts/validation-format/schemas/book-content-scheme.json +++ b/scripts/bec/src/bec/schemas/book-content-scheme.json @@ -30,9 +30,49 @@ "type": "string" }, "minItems": 1 + }, + "translator": { + "type": "string", + "description": "Translator of this language edition." + }, + "publisher": { + "type": "string", + "description": "Publisher of this edition." + }, + "summary": { + "type": "object", + "properties": { + "by": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "shop_url": { + "type": "string", + "description": "URL to buy the book." + }, + "download_url": { + "type": "string", + "description": "URL to download the book." + }, + "contributor_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "GitHub usernames of contributors for this edition." } }, - "required": ["title", "publication_year", "cover", "original", "description"], + "required": [ + "title", + "description" + ], "additionalProperties": false } - diff --git a/scripts/validation-format/schemas/book-scheme.json b/scripts/bec/src/bec/schemas/book-scheme.json similarity index 50% rename from scripts/validation-format/schemas/book-scheme.json rename to scripts/bec/src/bec/schemas/book-scheme.json index 17f3d158126..923827d4c67 100644 --- a/scripts/validation-format/schemas/book-scheme.json +++ b/scripts/bec/src/bec/schemas/book-scheme.json @@ -14,7 +14,13 @@ "level": { "type": "string", "description": "The difficulty level of the book.", - "enum": ["beginner", "intermediate", "expert", "wizard"] + "enum": [ + "beginner", + "intermediate", + "advanced", + "expert", + "wizard" + ] }, "original_language": { "type": "string", @@ -38,7 +44,44 @@ }, "proofreading": { "type": "array", - "description": "Proofreading status for each language." + "description": "Proofreading status for each language.", + "items": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "last_contribution_date": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "urgency": { + "type": [ + "integer", + "null" + ] + }, + "contributor_names": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "reward": { + "type": "number" + } + }, + "required": [ + "language", + "reward" + ] + } }, "contributor_names": { "type": "array", @@ -46,9 +89,16 @@ "items": { "type": "string" } + }, + "website_url": { + "type": "string", + "description": "URL of the book or author website." } }, - "required": ["author", "level", "tags"], + "required": [ + "author", + "id", + "original_language" + ], "additionalProperties": false } - diff --git a/docs/PBN-template-repo/resources/channels/channel-scheme.json b/scripts/bec/src/bec/schemas/channel-scheme.json similarity index 81% rename from docs/PBN-template-repo/resources/channels/channel-scheme.json rename to scripts/bec/src/bec/schemas/channel-scheme.json index 50a0c7de2d5..d17b455d6dd 100644 --- a/docs/PBN-template-repo/resources/channels/channel-scheme.json +++ b/scripts/bec/src/bec/schemas/channel-scheme.json @@ -5,7 +5,7 @@ "properties": { "id": { "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "UUID v4 identifier for the channel." }, "name": { @@ -30,7 +30,10 @@ "description": "URL of the pinned video or most viewed video on the channel." } }, - "required": ["channel"], + "required": [ + "channel", + "trailer" + ], "additionalProperties": false }, "description": { @@ -49,8 +52,7 @@ "type": "array", "description": "List of contributor UUIDs (alternative to contributor_names for main repo).", "items": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + "type": "string" }, "minItems": 1 }, @@ -65,7 +67,15 @@ "license": { "type": "string", "description": "License for the content.", - "enum": ["CC-BY-SA-V4", "MIT"] + "enum": [ + "CC-BY-SA-V4", + "MIT" + ] + }, + "project_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "description": "UUID v4 linking this channel to a PlanB Network project." } }, "assets_rules": { @@ -85,14 +95,11 @@ } ] }, - "required": ["id", "name", "language", "links", "description", "tags"], - "oneOf": [ - { - "required": ["contributor_names"] - }, - { - "required": ["contributors"] - } + "required": [ + "id", + "name", + "language", + "links" ], "additionalProperties": false } diff --git a/docs/PBN-template-repo/resources/conferences/conference-scheme.json b/scripts/bec/src/bec/schemas/conference-scheme.json similarity index 50% rename from docs/PBN-template-repo/resources/conferences/conference-scheme.json rename to scripts/bec/src/bec/schemas/conference-scheme.json index d1e4705eb58..d2bc97d7a64 100644 --- a/docs/PBN-template-repo/resources/conferences/conference-scheme.json +++ b/scripts/bec/src/bec/schemas/conference-scheme.json @@ -8,8 +8,11 @@ "description": "Complete name of the conference including the year to avoid duplication." }, "year": { - "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}$", + "type": [ + "string", + "integer" + ], + "pattern": "^[0-9]{4}(-[0-9]{2})?$", "description": "The year and month when the conference was held, format: YYYY-MM." }, "builder": { @@ -32,15 +35,20 @@ "type": "object", "properties": { "website": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "URL of the conference's main website." }, "twitter": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "URL of the conference's Twitter profile." } }, - "required": ["website"], "additionalProperties": false }, "tags": { @@ -50,6 +58,70 @@ "type": "string" }, "minItems": 1 + }, + "id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "description": "Unique identifier for the conference resource (UUID)." + }, + "original_language": { + "type": "string", + "pattern": "^[a-zA-Z]{2}(-[a-zA-Z0-9]+)?$", + "description": "Original language of the conference content." + }, + "project_id": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "description": "UUID of the project this conference relates to." + }, + "proofreading": { + "type": "array", + "items": { + "type": "object", + "properties": { + "language": { + "type": "string" + }, + "last_contribution_date": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "urgency": { + "type": [ + "integer", + "null" + ] + }, + "contributor_names": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "reward": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "language" + ] + } + }, + "test_only": { + "type": "boolean", + "description": "Whether this is a test conference (skipped on mainnet sync)." } }, "assets_rules": { @@ -69,6 +141,11 @@ } ] }, - "required": ["name", "year", "builder", "location", "language", "links", "tags"], + "required": [ + "id", + "year", + "location", + "original_language" + ], "additionalProperties": false } diff --git a/scripts/validation-format/schemas/course-content-scheme.json b/scripts/bec/src/bec/schemas/course-content-scheme.json similarity index 99% rename from scripts/validation-format/schemas/course-content-scheme.json rename to scripts/bec/src/bec/schemas/course-content-scheme.json index 7947270a2dc..2731dc96e70 100644 --- a/scripts/validation-format/schemas/course-content-scheme.json +++ b/scripts/bec/src/bec/schemas/course-content-scheme.json @@ -27,8 +27,11 @@ "maxItems": 10 } }, - "required": ["name", "goal", "objectives"], - "additionalProperties": false, + "required": [ + "name", + "goal" + ], + "additionalProperties": true, "content_rules": { "description": "Structural rules for the markdown body content. JSON Schema cannot fully validate markdown structure, but these rules document the required format.", "structure": { diff --git a/scripts/validation-format/schemas/course-scheme.json b/scripts/bec/src/bec/schemas/course-scheme.json similarity index 76% rename from scripts/validation-format/schemas/course-scheme.json rename to scripts/bec/src/bec/schemas/course-scheme.json index 5e2015750b7..83c00a4e5c7 100644 --- a/scripts/validation-format/schemas/course-scheme.json +++ b/scripts/bec/src/bec/schemas/course-scheme.json @@ -7,12 +7,21 @@ "id": { "type": "string", "description": "Unique identifier for the course (UUID format)", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + "maxLength": 100 }, "topic": { "type": "string", "description": "Main topic of the course (lowercase)", - "enum": ["bitcoin", "business", "mining", "protocol", "security", "sociology", "sovereignty", "social studies"] + "enum": [ + "bitcoin", + "business", + "mining", + "protocol", + "security", + "sociology", + "sovereignty", + "social studies" + ] }, "subtopic": { "type": "string", @@ -52,22 +61,33 @@ "type": { "type": "string", "description": "Type of course content", - "enum": ["theory", "practice"] + "enum": [ + "theory", + "practice" + ] }, "level": { "type": "string", "description": "Difficulty level of the course", - "enum": ["beginner", "intermediate", "advanced", "expert", "wizard"] + "enum": [ + "beginner", + "intermediate", + "advanced", + "expert" + ] }, "hours": { - "type": "integer", - "description": "Estimated number of hours to complete the course", - "minimum": 1 + "type": "number", + "exclusiveMinimum": 0, + "description": "Estimated number of hours to complete the course" }, "teaching_format": { "type": "string", "description": "How the course is delivered", - "enum": ["self_paced", "professor_led"] + "enum": [ + "self_paced", + "professor_led" + ] }, "professors_id": { "type": "array", @@ -113,7 +133,10 @@ "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" }, "last_contribution_date": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Date of the last contribution in YYYY-MM-DD format", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, @@ -123,7 +146,10 @@ "minimum": 1 }, "contributor_names": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "description": "List of contributor GitHub usernames", "items": { "type": "string" @@ -135,7 +161,10 @@ "minimum": 0 } }, - "required": ["language", "urgency", "reward"], + "required": [ + "language", + "reward" + ], "additionalProperties": false }, "minItems": 1 @@ -144,14 +173,18 @@ "type": "array", "description": "List of tags associated with this course from the PlanB Network tag list", "items": { - "type": "string" + "type": "string", + "minLength": 1 }, "minItems": 1 }, "license": { "type": "string", "description": "License of the course content", - "enum": ["CC-BY-SA-V4", "MIT"] + "enum": [ + "CC-BY-SA-V4", + "MIT" + ] }, "test_only": { "type": "boolean", @@ -160,7 +193,11 @@ "format": { "type": "string", "description": "Delivery format for live/in-person courses (only for courses with presentation.md)", - "enum": ["online", "inperson", "hybrid"] + "enum": [ + "online", + "inperson", + "hybrid" + ] }, "address_city_country": { "type": "string", @@ -185,7 +222,7 @@ "description": "Whether the course requires payment for registration" }, "inperson_price_dollars": { - "type": "number", + "type": "integer", "description": "Price in USD for in-person attendance (0 for free courses)", "minimum": 0 }, @@ -259,14 +296,59 @@ } } }, - "required": ["id"], - "anyOf": [ - { "required": ["youtube"] }, - { "required": ["peertube"] }, - { "required": ["rumble"] } + "required": [ + "id" ], "additionalProperties": false } + }, + "is_archived": { + "type": "boolean", + "description": "Whether the course is archived (hidden from listings)." + }, + "online_price_dollars": { + "type": "integer", + "minimum": 0, + "description": "Price in dollars for the online version." + }, + "paid_video_link": { + "type": "string", + "description": "Link to paid course videos." + }, + "contact": { + "type": "string", + "maxLength": 255, + "description": "Contact for the course." + }, + "is_planb_school": { + "type": "boolean", + "description": "Whether the course is part of the PlanB School program." + }, + "passing_grade_threshold_percentage": { + "type": "integer", + "description": "Passing grade threshold percentage for assignments." + }, + "assignment_weight_percentage": { + "type": "integer", + "description": "Weight of the assignment in the final grade, percent." + }, + "has_assignment": { + "type": "boolean", + "description": "Whether the course has an assignment." + }, + "assignment_start_date": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "description": "Assignment start date." + }, + "assignment_end_date": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "description": "Assignment end date." + }, + "assignment_description": { + "type": "string", + "description": "Assignment description." } }, "required": [ @@ -276,55 +358,7 @@ "level", "hours", "professors_id", - "original_language", - "proofreading" + "original_language" ], - "additionalProperties": true, - "allOf": [ - { - "if": { - "properties": { - "format": { "enum": ["inperson", "hybrid"] } - }, - "required": ["format"] - }, - "then": { - "required": [ - "address_city_country", - "start_date", - "end_date", - "requires_payment", - "available_seats" - ] - } - }, - { - "if": { - "properties": { - "format": { "const": "online" } - }, - "required": ["format"] - }, - "then": { - "required": [ - "start_date", - "end_date", - "requires_payment" - ] - } - }, - { - "if": { - "properties": { - "requires_payment": { "const": true } - }, - "required": ["requires_payment"] - }, - "then": { - "required": [ - "payment_expiration_date" - ] - } - } - ] + "additionalProperties": true } diff --git a/scripts/validation-format/schemas/event-scheme.json b/scripts/bec/src/bec/schemas/event-scheme.json similarity index 66% rename from scripts/validation-format/schemas/event-scheme.json rename to scripts/bec/src/bec/schemas/event-scheme.json index 87c848b9faa..52b4fc48317 100644 --- a/scripts/validation-format/schemas/event-scheme.json +++ b/scripts/bec/src/bec/schemas/event-scheme.json @@ -5,33 +5,45 @@ "properties": { "id": { "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Unique identifier for the event, UUID v4 format." }, "start_date": { "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}(\\s+[0-9]{2}:[0-9]{2}:[0-9]{2})?$", "description": "Start date and time of the event, format: YYYY-MM-DD hh:mm:ss." }, "end_date": { "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}(\\s+[0-9]{2}:[0-9]{2}:[0-9]{2})?$", "description": "End date and time of the event, format: YYYY-MM-DD hh:mm:ss." }, "timezone": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "IANA timezone identifier for the event location (e.g., Europe/Zurich, America/New_York)." }, "address_city_country": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Primary address including street, city, postal code, and country. Critical for map display." }, "address_line_2": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Additional location details such as floor or building name." }, "address_line_3": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Additional location details such as room number." }, "name": { @@ -40,19 +52,32 @@ }, "type": { "type": "string", - "enum": ["workshop", "course", "conference", "lecture", "meetup"], + "enum": [ + "workshop", + "course", + "conference", + "lecture", + "meetup", + "exam" + ], "description": "Category of the event." }, "description": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Main description of the event content and purpose." }, "language": { - "type": "array", + "type": [ + "array", + "null" + ], "description": "Languages spoken during the event, using ISO 639-1 codes or extended codes (zh-hans, zh-hant).", "items": { "type": "string", - "pattern": "^[a-z]{2}(-[a-z]+)?$" + "pattern": "^[a-zA-Z]{2}(-[a-zA-Z0-9]+)?$" }, "minItems": 1 }, @@ -60,22 +85,41 @@ "type": "object", "properties": { "website": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "URL of the event's main website." }, "replay_url": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "URL for the event recording or replay." }, "live_url": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "URL for live streaming of the event." + }, + "chat_url": { + "type": [ + "string", + "null" + ], + "description": "URL of the event chat." } }, "additionalProperties": false }, "project_id": { - "type": "string", + "type": [ + "string", + "null" + ], "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", "description": "UUID of the project that organizes this event." }, @@ -93,7 +137,10 @@ "description": "Number of available seats for the event." }, "custom_tc_disclaimer": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Custom terms and conditions disclaimer for the event." }, "is_gdpr_compliance": { @@ -105,7 +152,10 @@ "description": "Whether this is a test event." }, "tags": { - "type": "array", + "type": [ + "array", + "null" + ], "description": "List of associated tags for the event from the PlanB Network tag list.", "items": { "type": "string" @@ -121,23 +171,34 @@ "description": "Whether in-person booking is available for the event." }, "price_dollars": { - "type": "number", + "type": [ + "number", + "null" + ], "minimum": 0, "description": "Price of the event in US dollars." + }, + "professor": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "description": "UUID of the professor running the event." + }, + "course_related": { + "type": [ + "string", + "null" + ], + "description": "Id of the course related to this event." } }, "required": [ "id", "start_date", "end_date", - "timezone", - "address_city_country", - "name", - "type", - "description", - "language", - "links", - "tags" + "type" ], "additionalProperties": false } diff --git a/docs/PBN-template-repo/resources/movies/movie-scheme.json b/scripts/bec/src/bec/schemas/movie-scheme.json similarity index 86% rename from docs/PBN-template-repo/resources/movies/movie-scheme.json rename to scripts/bec/src/bec/schemas/movie-scheme.json index 31b064d2142..fbd1f091561 100644 --- a/docs/PBN-template-repo/resources/movies/movie-scheme.json +++ b/scripts/bec/src/bec/schemas/movie-scheme.json @@ -45,7 +45,10 @@ "description": "URL to the movie trailer." } }, - "required": ["platform"], + "required": [ + "platform", + "trailer" + ], "additionalProperties": false }, "description": { @@ -70,8 +73,18 @@ }, "license": { "type": "string", - "enum": ["CC-BY-SA-V4", "MIT"], + "enum": [ + "CC-BY-SA-V4", + "MIT" + ], "description": "License for the content. CC-BY-SA-V4 is the default with share-alike clause. MIT allows almost unrestricted use." + }, + "contributors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of contributor identifiers (GitHub usernames or UUIDs)." } }, "assets_rules": { @@ -91,6 +104,12 @@ } ] }, - "required": ["id", "title", "author", "publication_year", "duration", "language", "links", "description", "contributor_names", "tags", "license"], + "required": [ + "id", + "title", + "author", + "language", + "links" + ], "additionalProperties": false } diff --git a/scripts/validation-format/schemas/newsletter-scheme.json b/scripts/bec/src/bec/schemas/newsletter-scheme.json similarity index 83% rename from scripts/validation-format/schemas/newsletter-scheme.json rename to scripts/bec/src/bec/schemas/newsletter-scheme.json index 4703bc82783..baeba7c15a0 100644 --- a/scripts/validation-format/schemas/newsletter-scheme.json +++ b/scripts/bec/src/bec/schemas/newsletter-scheme.json @@ -6,7 +6,7 @@ "id": { "type": "string", "description": "Unique identifier for the newsletter in UUID v4 format.", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }, "title": { "type": "string", @@ -19,7 +19,12 @@ "level": { "type": "string", "description": "Difficulty level of the newsletter content from user's perspective.", - "enum": ["beginner", "intermediate", "advanced", "expert"] + "enum": [ + "beginner", + "intermediate", + "advanced", + "expert" + ] }, "publication_date": { "type": "string", @@ -37,8 +42,7 @@ "description": "URL of the newsletter's website." } }, - "required": ["website"], - "additionalProperties": false + "additionalProperties": true }, "minItems": 1 }, @@ -69,11 +73,14 @@ "license": { "type": "string", "description": "License for the newsletter content.", - "enum": ["CC-BY-SA-V4", "MIT"] + "enum": [ + "CC-BY-SA-V4", + "MIT" + ] }, "project_id": { "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "UUID v4 linking this newsletter to a PlanB Network project." } }, @@ -94,6 +101,9 @@ } ] }, - "required": ["id", "title", "author", "level", "publication_date", "link", "language", "description", "contributor_names", "tags"], + "required": [ + "id", + "author" + ], "additionalProperties": false } diff --git a/scripts/validation-format/schemas/paper-scheme.json b/scripts/bec/src/bec/schemas/paper-scheme.json similarity index 88% rename from scripts/validation-format/schemas/paper-scheme.json rename to scripts/bec/src/bec/schemas/paper-scheme.json index 8c8e6a5bf6b..65a5e124c9f 100644 --- a/scripts/validation-format/schemas/paper-scheme.json +++ b/scripts/bec/src/bec/schemas/paper-scheme.json @@ -9,9 +9,9 @@ "authors", "abstract", "paper_type", - "topics", "pdf_url", - "id" + "id", + "source" ], "properties": { "title": { @@ -54,7 +54,8 @@ "pre-print", "review", "PhD Thesis", - "book" + "book", + "report" ] }, "source": { @@ -65,7 +66,11 @@ "type": { "type": "string", "description": "Academic index type", - "enum": ["SCI", "SCIE", "SSCI"] + "enum": [ + "SCI", + "SCIE", + "SSCI" + ] }, "category": { "type": "string", @@ -89,6 +94,13 @@ "type": "string", "description": "Unique identifier (UUID format)", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional tags, lowercased and stored verbatim by the LMS." } }, "additionalProperties": false diff --git a/scripts/validation-format/schemas/podcast-scheme.json b/scripts/bec/src/bec/schemas/podcast-scheme.json similarity index 88% rename from scripts/validation-format/schemas/podcast-scheme.json rename to scripts/bec/src/bec/schemas/podcast-scheme.json index d7cdfd12685..632a1459056 100644 --- a/scripts/validation-format/schemas/podcast-scheme.json +++ b/scripts/bec/src/bec/schemas/podcast-scheme.json @@ -5,7 +5,7 @@ "properties": { "id": { "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Unique identifier using UUID v4 format." }, "name": { @@ -42,9 +42,12 @@ "youtube": { "type": "string", "description": "URL of the podcast's YouTube channel." + }, + "nostr": { + "type": "string", + "description": "Nostr profile/feed of the podcast." } }, - "required": ["podcast"], "additionalProperties": false }, "description": { @@ -69,7 +72,10 @@ }, "license": { "type": "string", - "enum": ["CC-BY-SA-V4", "MIT"], + "enum": [ + "CC-BY-SA-V4", + "MIT" + ], "description": "License for the content. CC-BY-SA-V4 is the default (share-alike clause). MIT allows almost unrestricted use." } }, @@ -90,6 +96,11 @@ } ] }, - "required": ["id", "name", "host", "language", "links", "description", "tags", "contributor_names"], + "required": [ + "id", + "name", + "host", + "language" + ], "additionalProperties": false } diff --git a/docs/PBN-template-repo/professors/professor-content-scheme.json b/scripts/bec/src/bec/schemas/professor-content-scheme.json similarity index 92% rename from docs/PBN-template-repo/professors/professor-content-scheme.json rename to scripts/bec/src/bec/schemas/professor-content-scheme.json index 45f26d730a6..45c333414b6 100644 --- a/docs/PBN-template-repo/professors/professor-content-scheme.json +++ b/scripts/bec/src/bec/schemas/professor-content-scheme.json @@ -12,6 +12,6 @@ "description": "A short one-line biography or tagline for the professor." } }, - "required": ["bio", "short_bio"], + "required": [], "additionalProperties": false } diff --git a/scripts/validation-format/schemas/professor-scheme.json b/scripts/bec/src/bec/schemas/professor-scheme.json similarity index 65% rename from scripts/validation-format/schemas/professor-scheme.json rename to scripts/bec/src/bec/schemas/professor-scheme.json index e86ba14f9ac..a91d2d08db9 100644 --- a/scripts/validation-format/schemas/professor-scheme.json +++ b/scripts/bec/src/bec/schemas/professor-scheme.json @@ -12,19 +12,33 @@ "format": "uuid", "description": "Unique identifier (UUID) for the professor" }, + "contributor_id": { + "type": ["string", "null"], + "description": "PlanB-UID contributor identifier (two bip39 words, see /docs/planb-uid.md). Ignored by the LMS importer." + }, "links": { "type": "object", "description": "Online presence of the professor", "properties": { "twitter": { - "type": "string", - "format": "url", + "type": ["string", "null"], "description": "Twitter URL of the professor" }, "website": { - "type": "string", - "format": "url", + "type": ["string", "null"], "description": "Personal or professional website of the professor" + }, + "github": { + "type": ["string", "null"], + "description": "GitHub URL of the professor" + }, + "linkedin": { + "type": ["string", "null"], + "description": "LinkedIn URL of the professor" + }, + "nostr": { + "type": ["string", "null"], + "description": "Nostr public key or profile URL of the professor" } }, "additionalProperties": true @@ -33,7 +47,8 @@ "type": "array", "description": "List of tags associated with the professor's expertise and interests", "items": { - "type": "string" + "type": "string", + "minLength": 1 } }, "tips": { @@ -41,25 +56,38 @@ "description": "Information for direct tips to the professor", "properties": { "lightning_address": { - "type": "string", + "type": ["string", "null"], "description": "Lightning network address for tips" }, + "lnurl_pay": { + "type": ["string", "null"], + "description": "LNURL-pay string for tips" + }, + "paynym": { + "type": ["string", "null"], + "description": "PayNym (BIP47) identifier for tips" + }, "silent_payment": { - "type": "string", + "type": ["string", "null"], "description": "Silent payment address for tips" + }, + "url": { + "type": ["string", "null"], + "description": "External tipping page URL" } }, "additionalProperties": true }, "company": { - "type": "string", + "type": ["string", "null"], "description": "Name of the company the professor is affiliated with" }, "affiliations": { - "type": "array", - "description": "List of affiliations the professor has within the industry or elsewhere", + "type": ["array", "null"], + "description": "List of affiliations (UUIDs) the professor has within the industry or elsewhere", "items": { - "type": "string" + "type": "string", + "format": "uuid" } }, "assets_rules": { @@ -95,7 +123,10 @@ "items": { "type": "string" }, - "default": ["400x400", "800x800"], + "default": [ + "400x400", + "800x800" + ], "description": "Recommended pixel dimensions" }, "content": { @@ -108,7 +139,9 @@ } } }, - "required": ["name", "id"], + "required": [ + "name", + "id" + ], "additionalProperties": false } - diff --git a/scripts/validation-format/schemas/project-content-scheme.json b/scripts/bec/src/bec/schemas/project-content-scheme.json similarity index 81% rename from scripts/validation-format/schemas/project-content-scheme.json rename to scripts/bec/src/bec/schemas/project-content-scheme.json index bdfb4e47f59..e154f1a2f53 100644 --- a/scripts/validation-format/schemas/project-content-scheme.json +++ b/scripts/bec/src/bec/schemas/project-content-scheme.json @@ -8,7 +8,8 @@ "description": "A detailed description of the builder, which can span multiple lines." } }, - "required": ["description"], - "additionalProperties": false + "required": [ + "description" + ], + "additionalProperties": true } - diff --git a/scripts/validation-format/schemas/project-scheme.json b/scripts/bec/src/bec/schemas/project-scheme.json similarity index 66% rename from scripts/validation-format/schemas/project-scheme.json rename to scripts/bec/src/bec/schemas/project-scheme.json index db1fe10bcc5..befc3a1db8d 100644 --- a/scripts/validation-format/schemas/project-scheme.json +++ b/scripts/bec/src/bec/schemas/project-scheme.json @@ -16,21 +16,36 @@ "category": { "type": "string", "enum": [ - "wallet", "Wallet", - "infrastructure", "Infrastructure", - "exchange", "Exchange", - "education", "Education", - "service", "Service", - "conference", "Conference", - "privacy", "Privacy", - "investment", "Investment", - "node", "Node", - "mining", "Mining", - "news", "News", - "manufacturer", "Manufacturer", - "communities", "Communities", - "merchant", "Merchant", - "payment", "Payment" + "wallet", + "Wallet", + "infrastructure", + "Infrastructure", + "exchange", + "Exchange", + "education", + "Education", + "service", + "Service", + "conference", + "Conference", + "privacy", + "Privacy", + "investment", + "Investment", + "node", + "Node", + "mining", + "Mining", + "news", + "News", + "manufacturer", + "Manufacturer", + "communities", + "Communities", + "merchant", + "Merchant", + "payment", + "Payment" ], "description": "Define project category. Only specific categories are allowed." }, @@ -39,50 +54,86 @@ "description": "License type for the project content (e.g., CC-BY-SA-V4)." }, "language": { - "type": ["string", "array"], - "description": "Primary language(s) of the project." + "type": "array", + "description": "Primary language(s) of the project.", + "items": { + "type": "string" + } }, "links": { - "type": ["object", "array"], + "type": [ + "object", + "array" + ], "properties": { "website": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "URL of the project's website." }, "twitter": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "URL of the project's Twitter profile." }, "github": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "URL of the project's Github profile." }, "Github": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "URL of the project's Github profile (alternate casing)." }, "youtube": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "URL of the project's YouTube channel." }, "nostr": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Nostr public key or NIP-05 identifier." }, "Nostr": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Nostr public key or NIP-05 identifier (alternate casing)." }, "nost": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Nostr public key (typo variant)." }, "instagram": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "URL of the project's Instagram profile." }, "linkedin": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "URL of the project's LinkedIn profile." }, "tags": { @@ -99,12 +150,13 @@ "type": "array", "description": "List of associated tags to the project.", "items": { - "type": "string" + "type": "string", + "minLength": 1 }, "minItems": 1 }, "original_language": { - "type": ["string", "array"], + "type": "string", "description": "ISO 639-1 language code of the original content language." }, "proofreading": { @@ -119,29 +171,44 @@ "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" }, "last_contribution_date": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Date of last contribution in YYYY-MM-DD format.", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, "urgency": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "description": "Urgency level for proofreading (1-5).", "minimum": 1, "maximum": 5 }, "contributor_names": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "description": "List of contributor usernames for this translation.", "items": { "type": "string" } }, "reward": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "description": "Reward amount for proofreading this translation." } }, - "required": ["language"], + "required": [ + "language", + "reward" + ], "additionalProperties": false }, "minItems": 1 @@ -154,19 +221,31 @@ } }, "address_line_1": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "First line of physical address (for physical locations)." }, "address_line_2": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Second line of physical address." }, "address_line_3": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Third line of physical address." }, "address_city_country": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "City and country of physical address." } }, @@ -185,6 +264,12 @@ } ] }, - "required": ["id", "name", "category", "links", "original_language", "proofreading"], + "required": [ + "id", + "name", + "category", + "links", + "original_language" + ], "additionalProperties": false } diff --git a/scripts/bec/src/bec/schemas/quizz-question-scheme.json b/scripts/bec/src/bec/schemas/quizz-question-scheme.json new file mode 100644 index 00000000000..a511f4158e8 --- /dev/null +++ b/scripts/bec/src/bec/schemas/quizz-question-scheme.json @@ -0,0 +1,107 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "quiz-question", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the quiz question (UUID format). Used as the primary key by the LMS importer.", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "chapterId": { + "type": "string", + "description": "Defines the chapter this question is related to, using the chapterId (UUID). The LMS stores it in a NOT NULL uuid column referencing course_chapters.", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "difficulty": { + "type": "string", + "description": "Difficulty level of the question.", + "enum": [ + "easy", + "intermediate", + "hard", + "expert" + ] + }, + "duration": { + "type": [ + "integer", + "null" + ], + "description": "Expected time to answer the question, in seconds." + }, + "author": { + "type": "string", + "description": "The contribution-id of the author of the question." + }, + "tags": { + "type": "array", + "description": "List of tags associated with the question.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "original_language": { + "type": "string", + "description": "ISO 639-1 language code of the original question language", + "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" + }, + "proofreading": { + "type": "array", + "description": "Proofreading metadata for each language version", + "items": { + "type": "object", + "properties": { + "language": { + "type": "string", + "description": "ISO 639-1 language code for this proofreading entry", + "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" + }, + "last_contribution_date": { + "type": [ + "string", + "null" + ], + "description": "Date of the last contribution in YYYY-MM-DD format", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "urgency": { + "type": "integer", + "description": "Level of urgency for proofreading, 1 being low urgency", + "minimum": 1 + }, + "contributor_names": { + "type": [ + "array", + "null" + ], + "description": "List of contributor GitHub usernames", + "items": { + "type": "string" + } + }, + "reward": { + "type": "number", + "description": "Amount of sats available for reward", + "minimum": 0 + } + }, + "required": [ + "language", + "urgency", + "reward" + ], + "additionalProperties": false + }, + "minItems": 1 + } + }, + "required": [ + "id", + "chapterId", + "difficulty" + ], + "additionalProperties": false +} diff --git a/scripts/bec/src/bec/schemas/quizz-translation-scheme.json b/scripts/bec/src/bec/schemas/quizz-translation-scheme.json new file mode 100644 index 00000000000..8f7219576c3 --- /dev/null +++ b/scripts/bec/src/bec/schemas/quizz-translation-scheme.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "quiz-question-content", + "type": "object", + "properties": { + "question": { + "type": [ + "string", + "number", + "boolean" + ], + "description": "The complete question text." + }, + "answer": { + "type": [ + "string", + "number", + "boolean" + ], + "description": "The correct answer to the question. Usually a string, but numeric or boolean answers occur and are accepted by the LMS (stored as text)." + }, + "wrong_answers": { + "type": "array", + "description": "A list of three incorrect answers to the question.", + "items": { + "type": [ + "string", + "number", + "boolean" + ] + }, + "minItems": 3 + }, + "explanation": { + "type": [ + "string", + "null" + ], + "description": "Additional information explaining why the correct answer is true and why the other answers are incorrect." + }, + "reviewed": { + "type": [ + "boolean", + "string" + ], + "description": "Indicates whether the question has been reviewed by at least two different persons. Not consumed by the LMS; legacy translated values (e.g. 'vero') exist as strings." + } + }, + "required": [ + "question", + "answer", + "wrong_answers" + ], + "additionalProperties": true +} diff --git a/scripts/bec/src/bec/schemas/tags-definitions.json b/scripts/bec/src/bec/schemas/tags-definitions.json new file mode 100644 index 00000000000..68bb7b7b154 --- /dev/null +++ b/scripts/bec/src/bec/schemas/tags-definitions.json @@ -0,0 +1,127 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "tags-definitions", + "description": "Shared tag definitions for PlanB Network content schemas. Single source of truth for valid tags and their meanings.", + "definitions": { + "tag_item": { + "type": "string", + "description": "A tag from the PlanB Network predefined tag list", + "enum": [ + "proof-of-work", + "software", + "mining", + "fees", + "hardware", + "hardware-wallet-ready", + "wallets", + "investment", + "keys", + "market-trends", + "finance", + "onchain", + "offchain", + "lightning", + "decentralization", + "smart-contracts", + "DIY", + "node", + "backup", + "guides", + "use-case", + "user-friendly", + "historical", + "scalability", + "protocols", + "layers", + "sidechains", + "personal-security", + "network-security", + "privacy", + "regulation", + "risks", + "future-outlook", + "adoption", + "case-studies", + "good-practice", + "innovation", + "cypherpunk", + "self-sovereignty", + "DIY-IT", + "consensus", + "development", + "interoperability", + "technical-analysis", + "update", + "legacy", + "deep-dive", + "high-level", + "easy-explain", + "experimental", + "business", + "evaluation" + ] + }, + "tags_array": { + "type": "array", + "description": "List of tags from the PlanB Network predefined tag list", + "items": { + "$ref": "#/definitions/tag_item" + }, + "minItems": 1 + } + }, + "x-tag-descriptions": { + "proof-of-work": "Referring to all things needed to prove some job has been done", + "software": "Regarding programs, services you use after being installed on servers, PCs and smartphones", + "mining": "All topics related to BTC mining", + "fees": "Aspects referring to every cost when it comes to send BTC value (in any way)", + "hardware": "Every hardware-related topic, mutually exclusive with the hardware-wallet-ready tag", + "hardware-wallet-ready": "Content compatible with hardware wallets, mutually exclusive with the hardware tag", + "wallets": "Every tool allowing to manage or spend bitcoin", + "investment": "Bitcoin is an investment, but in which way it's up to the topic of the content", + "keys": "Every topic regarding storage of the secrets to spend bitcoin", + "market-trends": "The market rules everything, and we may identify some trends", + "finance": "Addresses all aspects regarding how money and financial system work", + "onchain": "Aspects referring to Bitcoin blockchain only", + "offchain": "Every aspect referring to Bitcoin, with the exclusion of the blockchain", + "lightning": "Everything that is about Lightning Network", + "decentralization": "Aspects referring to one of Bitcoin's most important feature", + "smart-contracts": "Regarding a self-executing contract with the terms of the agreement directly written into code", + "DIY": "Do it yourself, stands for every hardware project you can build on your own, with some raw material", + "node": "Every software agent that validates and relays information, contributing to the functionality of that specific network", + "backup": "Explore ways how to improve redundancy of secret keys storage", + "guides": "Step-by-step explanation to do something", + "use-case": "Openly and directly addressing specific use cases for something", + "user-friendly": "When a topic shows content with the goal of being easily understandable by beginner and intermediate bitcoiners", + "historical": "Content telling about historical bitcoin facts and data. It may also be intended for both general and economic history when dealing with Bitcoin's origin", + "scalability": "Topics regarding difficulty to scale one of bitcoin-related networks", + "protocols": "Whenever topics regard separate sets of rules, where each one governs how participants interact and maintain consensus", + "layers": "Different protocols can lay on different levels between each other and versus Bitcoin mainnet", + "sidechains": "It is about independent blockchains that run parallel and bound to the Bitcoin main chain", + "personal-security": "Topics referring to security aspects with focus on threats suitable for physical people", + "network-security": "Topics with the focus to security aspects of the network", + "privacy": "Main focus here would be to preserve end-user sensitive information that can lead to personal identification", + "regulation": "Sometimes talking about laws, compliance, regulation in general is important", + "risks": "Highlight potential danger of bitcoin custody and usage", + "future-outlook": "Information and hypothesis regarding future happening (and problems) that the network could face", + "adoption": "Topics regarding using bitcoin for everyday purchases, its roles such medium-of-exchange and store of value", + "case-studies": "Content presented in a way of an Harvard business case study", + "good-practice": "Suggest specific actions when it comes to do something, and explain why", + "innovation": "It is about new features over the Bitcoin protocol", + "cypherpunk": "Refers to content promoting, explaining and applying any cypherpunk value", + "self-sovereignty": "Different from cypherpunk, it links content that are still focused on the individual without requiring him/her to be a cryptographic/tech expert. This concept's boundary is laying more on a day-to-day physical level", + "DIY-IT": "Do it yourself, stands for every software project you can develop on your own, with some examples and guidance from a reliable source", + "consensus": "Topics referring to security of the network facing incentive mechanisms that bring order over chaos", + "development": "Discussion on potential features that may be useful, really experienced users may invest energy into this topic", + "interoperability": "Topics facing the possibility of mutual interaction between parts of network", + "technical-analysis": "Aspects analyzed from perspective of their technical feasibility", + "update": "Tracks the presence of updates on tutos and courses over time", + "legacy": "Meta-tag that identifies old procedures and resources not available anymore", + "deep-dive": "The selected tag explains something deep, regardless of its actual bitcoin-grade content", + "high-level": "Opposite of deep-dive", + "easy-explain": "Describe if a content needs simplified writing style", + "experimental": "Meta-tag that addresses features, ideas, concepts that are still not available for immediate use, but that will be better known in the next future", + "business": "About companies and startup in the Bitcoin ecosystem", + "evaluation": "The content, whatever it would be, is about assessing the skills and abilities of its users" + } +} diff --git a/scripts/validation-format/schemas/tutorial-content-scheme.json b/scripts/bec/src/bec/schemas/tutorial-content-scheme.json similarity index 93% rename from scripts/validation-format/schemas/tutorial-content-scheme.json rename to scripts/bec/src/bec/schemas/tutorial-content-scheme.json index 8516597903f..c332f53344d 100644 --- a/scripts/validation-format/schemas/tutorial-content-scheme.json +++ b/scripts/bec/src/bec/schemas/tutorial-content-scheme.json @@ -16,8 +16,10 @@ "maxLength": 1000 } }, - "required": ["name", "description"], - "additionalProperties": false, + "required": [ + "name" + ], + "additionalProperties": true, "content_rules": { "description": "Structural requirements for tutorial markdown content following YAML front-matter", "cover_image": { @@ -93,12 +95,18 @@ }, "language_specific": { "path_pattern": "assets/{lang}/{number}.webp", - "examples": ["assets/en/01.webp", "assets/fr/01.webp"], + "examples": [ + "assets/en/01.webp", + "assets/fr/01.webp" + ], "description": "Screenshots with text that needs translation" }, "universal": { "path_pattern": "assets/notext/{number}.webp", - "examples": ["assets/notext/1.webp", "assets/notext/2.webp"], + "examples": [ + "assets/notext/1.webp", + "assets/notext/2.webp" + ], "description": "Images without text, shared across all languages" } }, diff --git a/scripts/validation-format/schemas/tutorial-scheme.json b/scripts/bec/src/bec/schemas/tutorial-scheme.json similarity index 88% rename from scripts/validation-format/schemas/tutorial-scheme.json rename to scripts/bec/src/bec/schemas/tutorial-scheme.json index 5f886c6d709..fb1b4691947 100644 --- a/scripts/validation-format/schemas/tutorial-scheme.json +++ b/scripts/bec/src/bec/schemas/tutorial-scheme.json @@ -16,7 +16,12 @@ "level": { "type": "string", "description": "Difficulty level of the tutorial from user's perspective", - "enum": ["beginner", "intermediate", "advanced", "expert"] + "enum": [ + "beginner", + "intermediate", + "advanced", + "expert" + ] }, "category": { "type": "string", @@ -54,7 +59,8 @@ "type": "array", "description": "List of tags associated with this tutorial from the predefined tag list", "items": { - "type": "string" + "type": "string", + "minLength": 1 } }, "credit_link": { @@ -65,7 +71,10 @@ "license": { "type": "string", "description": "License of the tutorial content", - "enum": ["CC-BY-SA-V4", "MIT"] + "enum": [ + "CC-BY-SA-V4", + "MIT" + ] }, "original_language": { "type": "string", @@ -84,7 +93,10 @@ "pattern": "^[a-z]{2}(-[A-Za-z]{2,4})?$" }, "last_contribution_date": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Date of the last contribution in YYYY-MM-DD format", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, @@ -94,7 +106,10 @@ "minimum": 1 }, "contributor_names": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "description": "List of contributor GitHub usernames", "items": { "type": "string" @@ -106,7 +121,10 @@ "minimum": 0 } }, - "required": ["language", "urgency", "reward"], + "required": [ + "language", + "reward" + ], "additionalProperties": false }, "minItems": 1 @@ -231,16 +249,28 @@ } } } + }, + "last_update_date": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "description": "Date of the last content update (YYYY-MM-DD), synced to the LMS as last_update_content." + }, + "is_archived": { + "type": "boolean", + "description": "Whether the tutorial is archived (hidden from listings)." + }, + "test_only": { + "type": "boolean", + "description": "If true, the tutorial is skipped on mainnet sync (test environments only)." } }, "required": [ "id", "level", - "category", - "professor_id", - "license", - "original_language", - "proofreading" + "original_language" ], "additionalProperties": false } diff --git a/scripts/bec/src/bec/schemas/word-content-scheme.json b/scripts/bec/src/bec/schemas/word-content-scheme.json new file mode 100644 index 00000000000..98c60d67150 --- /dev/null +++ b/scripts/bec/src/bec/schemas/word-content-scheme.json @@ -0,0 +1,243 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "glossary-word-content", + "description": "Schema for validating glossary word markdown files (e.g., en.md). Frontmatter fields are validated at the top level, like every other *-content schema.", + "type": "object", + "properties": { + "term": { + "type": "string", + "description": "The glossary term. Conventionally uppercase, possibly with a parenthetical addition for abbreviations or clarifications (e.g., 'SATOSHI (SAT)'). The LMS imports it verbatim.", + "examples": [ + "WALLET", + "SATOSHI (SAT)", + "51 PERCENT ATTACK", + "OP_ROLL (0X7A)", + "SHA256", + "C++ (PLUS PLUS)", + "API" + ] + }, + "definition": { + "type": "string", + "description": "Short one-line definition of the term, imported as short_definition by the LMS (nullable there)." + } + }, + "required": [ + "term" + ], + "additionalProperties": true, + "content_rules": { + "type": "object", + "description": "Documentation of markdown content structural requirements. This property describes validation rules that should be enforced by content validators.", + "properties": { + "definition_paragraph": { + "type": "object", + "description": "The primary definition paragraph immediately following the YAML front-matter", + "properties": { + "required": { + "type": "boolean", + "const": true, + "description": "A definition paragraph is required immediately after the front-matter" + }, + "position": { + "type": "string", + "const": "immediately_after_frontmatter", + "description": "Must appear directly after the closing --- of the front-matter" + }, + "guidelines": { + "type": "array", + "description": "Content guidelines for the definition paragraph", + "items": { + "type": "string" + }, + "default": [ + "Defines what the term is in clear, technical language", + "For acronyms, start with: Acronym for \"[Full Name]\".", + "Should be comprehensive but concise", + "Use backticks for technical terms: `scriptPubKey`, `SHA256`" + ] + } + }, + "additionalProperties": false + }, + "additional_paragraphs": { + "type": "object", + "description": "Optional extended explanation paragraphs", + "properties": { + "required": { + "type": "boolean", + "const": false, + "description": "Additional paragraphs are optional" + }, + "guidelines": { + "type": "array", + "description": "Content guidelines for additional paragraphs", + "items": { + "type": "string" + }, + "default": [ + "Provide deeper technical explanation if needed", + "Include examples, use cases, or historical context", + "May include bulleted or numbered lists" + ] + } + }, + "additionalProperties": false + }, + "code_formatting": { + "type": "object", + "description": "Rules for inline code and code blocks", + "properties": { + "inline_code": { + "type": "string", + "const": "backticks", + "description": "Use single backticks for technical terms: `scriptPubKey`, `SHA256`, `HASH160`" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "`scriptPubKey`", + "`SHA256`", + "`RIPEMD160`", + "`OP_CHECKSIG`" + ] + } + }, + "additionalProperties": false + }, + "math_formulas": { + "type": "object", + "description": "Rules for mathematical formulas using LaTeX notation", + "properties": { + "inline_format": { + "type": "string", + "const": "$formula$", + "description": "Single dollar signs for inline math" + }, + "block_format": { + "type": "string", + "const": "$$formula$$", + "description": "Double dollar signs for block/display math" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "$y^2 = x^3 + 7$", + "$H(m_1) = H(m_2)$", + "$$s_A = n_A + t + H(N_A + T \\parallel P_A \\parallel m_A) \\cdot p_A$$" + ] + } + }, + "additionalProperties": false + }, + "images": { + "type": "object", + "description": "Rules for including images in glossary definitions", + "properties": { + "required": { + "type": "boolean", + "const": false, + "description": "Images are optional" + }, + "format": { + "type": "string", + "const": "![](relative/path/to/image.webp)", + "description": "Standard markdown image syntax with empty alt text" + }, + "path_pattern": { + "type": "string", + "const": "../../dictionnaire/assets/XX.webp", + "description": "Relative path from word directory to assets folder" + }, + "preferred_format": { + "type": "string", + "const": "webp", + "description": "Preferred image format is WebP" + } + }, + "additionalProperties": false + }, + "alternative_names_blockquote": { + "type": "object", + "description": "Optional blockquote at the END of the file containing alternative names, translations, or etymology", + "properties": { + "required": { + "type": "boolean", + "const": false, + "description": "Alternative names blockquote is optional" + }, + "position": { + "type": "string", + "const": "end_of_file", + "description": "MUST appear at the very end of the file if present" + }, + "format": { + "type": "string", + "const": "> *text in italics*", + "description": "Blockquote with arrow symbol and italicized text" + }, + "pattern": { + "type": "string", + "const": "^> ► \\*.*\\*$", + "description": "Regex pattern: starts with '> ► *' and ends with '*'" + }, + "content_types": { + "type": "array", + "description": "Types of content that appear in the alternative names blockquote", + "items": { + "type": "string" + }, + "default": [ + "Alternative English names for the term", + "French translation or equivalent term", + "Etymology or origin notes", + "Related terminology clarifications" + ] + }, + "examples": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "> ► *This attack is also known as the \"Goldfinger attack\".*", + "> ► *In French, this is translated as \"interface de programmation d'applications\" or simply \"interface de programmation.\"*", + "> ► *In English, it is referred to as a \"hash function\".*" + ] + } + }, + "additionalProperties": false + }, + "links": { + "type": "object", + "description": "Rules for hyperlinks in content", + "properties": { + "format": { + "type": "string", + "const": "[text](url)", + "description": "Standard markdown link syntax" + }, + "guidelines": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "Use descriptive link text, not raw URLs", + "Prefer linking to primary sources (BIPs, papers, official repos)", + "External links should point to stable, reliable resources" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } +} diff --git a/docs/PBN-template-repo/resources/glossary/word-scheme.json b/scripts/bec/src/bec/schemas/word-scheme.json similarity index 86% rename from docs/PBN-template-repo/resources/glossary/word-scheme.json rename to scripts/bec/src/bec/schemas/word-scheme.json index 74387a6a755..89e104c3adf 100644 --- a/docs/PBN-template-repo/resources/glossary/word-scheme.json +++ b/scripts/bec/src/bec/schemas/word-scheme.json @@ -13,7 +13,10 @@ "description": "The word in English, typically in uppercase. This is the basis for identifying the word in the glossary" }, "related_words": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "description": "List of related words in the glossary, using their en_word names", "items": { "type": "string" @@ -23,7 +26,8 @@ "type": "array", "description": "List of tags associated with the word, chosen from the predefined PlanB tags list", "items": { - "type": "string" + "type": "string", + "minLength": 1 } }, "original_language": { @@ -34,7 +38,10 @@ "license": { "type": "string", "description": "License under which the content is distributed", - "enum": ["CC-BY-SA-V4", "MIT"] + "enum": [ + "CC-BY-SA-V4", + "MIT" + ] }, "proofreading": { "type": "array", @@ -56,7 +63,10 @@ "minimum": 1 }, "contributor_names": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "description": "List of contributor GitHub usernames who have proofread this language", "items": { "type": "string" @@ -68,11 +78,17 @@ "minimum": 0 } }, - "required": ["language", "urgency", "reward"], + "required": [ + "language", + "reward" + ], "additionalProperties": false } } }, - "required": ["id", "en_word", "original_language", "proofreading"], + "required": [ + "id", + "original_language" + ], "additionalProperties": false } diff --git a/scripts/bec/tests/__init__.py b/scripts/bec/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/scripts/bec/tests/conftest.py b/scripts/bec/tests/conftest.py new file mode 100644 index 00000000000..632a5bf5ed7 --- /dev/null +++ b/scripts/bec/tests/conftest.py @@ -0,0 +1,17 @@ +"""Shared test fixtures for bec tests.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def repo_root() -> Path: + """Return the real repo root (parent of scripts/bec/).""" + return Path(__file__).resolve().parent.parent.parent.parent + + +@pytest.fixture +def fixtures_dir() -> Path: + """Return the test fixtures directory.""" + return Path(__file__).resolve().parent / "fixtures" diff --git a/scripts/bec/tests/fixtures/bad-course/course.yml b/scripts/bec/tests/fixtures/bad-course/course.yml new file mode 100644 index 00000000000..e34af9bdb88 --- /dev/null +++ b/scripts/bec/tests/fixtures/bad-course/course.yml @@ -0,0 +1,4 @@ +id: not-a-valid-uuid +topic: invalid-topic +level: super-easy +hours: -5 diff --git a/scripts/bec/tests/fixtures/bad-course/en.md b/scripts/bec/tests/fixtures/bad-course/en.md new file mode 100644 index 00000000000..0ac1d3d3071 --- /dev/null +++ b/scripts/bec/tests/fixtures/bad-course/en.md @@ -0,0 +1,5 @@ +--- +goal: Missing name field +--- + +Content without required fields. diff --git a/scripts/bec/tests/fixtures/good-course/course.yml b/scripts/bec/tests/fixtures/good-course/course.yml new file mode 100644 index 00000000000..0d0e0d9d085 --- /dev/null +++ b/scripts/bec/tests/fixtures/good-course/course.yml @@ -0,0 +1,24 @@ +id: 2b7dc507-81e3-4b70-88e6-41ed44239966 +topic: bitcoin +subtopic: bitcoin +type: theory +level: beginner +hours: 7 +teaching_format: self_paced +professors_id: + - 2e1b5182-567e-453a-af29-36009340ff02 +project_id: 77f56919-1875-45ab-8c4c-b8fb26b021de +contributor_names: + - TestUser +published_at: 2023-04-29 +original_language: en +proofreading: + - language: en + last_contribution_date: 2025-05-14 + urgency: 1 + contributor_names: + - TestUser + reward: 0 +tags: + - user-friendly + - wallets diff --git a/scripts/bec/tests/fixtures/good-course/en.md b/scripts/bec/tests/fixtures/good-course/en.md new file mode 100644 index 00000000000..b4538aa3ddc --- /dev/null +++ b/scripts/bec/tests/fixtures/good-course/en.md @@ -0,0 +1,23 @@ +--- +name: Test Course +goal: Learn about Bitcoin fundamentals +objectives: + - Understand Bitcoin basics + - Learn how to use a wallet +--- + +# Introduction + +This is the course description. + ++++ + +# Part 1 + +97ccb669-12a0-5eed-83ac-c2f51839d998 + +## Chapter 1 + +4dc58281-5179-507c-afde-8c9204cbd3fd + +Content of chapter 1. diff --git a/scripts/bec/tests/fixtures/good-course/quizz/001/en.yml b/scripts/bec/tests/fixtures/good-course/quizz/001/en.yml new file mode 100644 index 00000000000..c0f7277492a --- /dev/null +++ b/scripts/bec/tests/fixtures/good-course/quizz/001/en.yml @@ -0,0 +1,8 @@ +question: What is Bitcoin? +answer: A decentralized digital currency +wrong_answers: + - A centralized database + - A type of credit card + - A physical coin +explanation: Bitcoin is a decentralized digital currency that operates without a central authority. +reviewed: true diff --git a/scripts/bec/tests/fixtures/good-course/quizz/001/question.yml b/scripts/bec/tests/fixtures/good-course/quizz/001/question.yml new file mode 100644 index 00000000000..f6fe09944b9 --- /dev/null +++ b/scripts/bec/tests/fixtures/good-course/quizz/001/question.yml @@ -0,0 +1,5 @@ +chapterId: 4dc58281-5179-507c-afde-8c9204cbd3fd +difficulty: easy +author: TestUser +tags: + - software diff --git a/scripts/bec/tests/fixtures/good-event/event.yml b/scripts/bec/tests/fixtures/good-event/event.yml new file mode 100644 index 00000000000..b982c1ed4a7 --- /dev/null +++ b/scripts/bec/tests/fixtures/good-event/event.yml @@ -0,0 +1,12 @@ +id: d1e2f3a4-b5c6-7890-1234-567890abcdef +name: Test Bitcoin Event +description: A test event for validation +type: conference +address_city_country: Paris, France +start_date: 2025-06-15 +end_date: 2025-06-16 +links: + website: https://example.com + twitter: https://twitter.com/test +tags: + - adoption diff --git a/scripts/bec/tests/fixtures/good-tutorial/assets/cover.webp b/scripts/bec/tests/fixtures/good-tutorial/assets/cover.webp new file mode 100644 index 00000000000..e69de29bb2d diff --git a/scripts/bec/tests/fixtures/good-tutorial/en.md b/scripts/bec/tests/fixtures/good-tutorial/en.md new file mode 100644 index 00000000000..f40cef035ce --- /dev/null +++ b/scripts/bec/tests/fixtures/good-tutorial/en.md @@ -0,0 +1,14 @@ +--- +name: Test Tutorial +description: A tutorial about testing wallets +--- + +![cover](assets/cover.webp) + +## Getting Started + +This is the tutorial content. + +## Step 2 + +More content here. diff --git a/scripts/bec/tests/fixtures/good-tutorial/tutorial.yml b/scripts/bec/tests/fixtures/good-tutorial/tutorial.yml new file mode 100644 index 00000000000..23eeaa00217 --- /dev/null +++ b/scripts/bec/tests/fixtures/good-tutorial/tutorial.yml @@ -0,0 +1,8 @@ +id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +builder: test-builder +name: Test Tutorial +category: wallet +tags: + - wallets + - user-friendly +level: beginner diff --git a/scripts/bec/tests/test_add.py b/scripts/bec/tests/test_add.py new file mode 100644 index 00000000000..fd84a4076ff --- /dev/null +++ b/scripts/bec/tests/test_add.py @@ -0,0 +1,365 @@ +"""Tests for bec add part / add chapter commands.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from bec.cli import cli +from bec.lib.markdown import ( + append_to_markdown, + build_chapter_block, + build_part_block, + generate_chapter_id, +) + + +# ---- Unit tests for lib/markdown.py ---- + + +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) + + +class TestGenerateChapterId: + def test_is_valid_uuid(self): + """LMS importer requires chapterId to be a valid UUID.""" + import uuid + + cid = generate_chapter_id() + assert UUID_RE.match(cid), f"Expected UUID, got: {cid}" + assert str(uuid.UUID(cid)) == cid + + def test_randomness(self): + """Two generated IDs should (almost certainly) differ.""" + ids = {generate_chapter_id() for _ in range(10)} + assert len(ids) == 10, "Generated IDs collide" + + +class TestBuildPartBlock: + def test_has_separator(self): + block = build_part_block("My Part", "test-uuid") + assert block.startswith("+++\n") + + def test_has_h1(self): + block = build_part_block("My Part", "test-uuid") + assert "# My Part" in block + + def test_has_part_id(self): + block = build_part_block("My Part", "abc-123") + assert "abc-123" in block + + def test_structure(self): + block = build_part_block("Title", "id-1") + lines = block.split("\n") + assert lines[0] == "+++" + assert lines[1] == "" + assert lines[2] == "# Title" + assert lines[3] == "" + assert lines[4] == "id-1" + + +class TestBuildChapterBlock: + def test_has_h2(self): + block = build_chapter_block("My Chapter", "test-id") + assert "## My Chapter" in block + + def test_has_chapter_id(self): + block = build_chapter_block("My Chapter", "test-id") + assert "test-id" in block + + def test_auto_generates_uuid_id(self): + block = build_chapter_block("My Chapter") + match = re.search(r"(.+?)", block) + assert match + assert UUID_RE.match(match.group(1)), f"Expected UUID, got: {match.group(1)}" + + def test_structure(self): + block = build_chapter_block("Title", "cid") + lines = block.split("\n") + assert lines[0] == "## Title" + assert lines[1] == "" + assert lines[2] == "cid" + + +class TestAppendToMarkdown: + def test_appends_with_separation(self, tmp_path): + md = tmp_path / "test.md" + md.write_text("# Heading\n\nContent here.\n", encoding="utf-8") + append_to_markdown(md, "## New Section\n") + result = md.read_text(encoding="utf-8") + assert result.endswith("## New Section\n") + # Should have blank line separation + assert "\n\n## New Section\n" in result + + def test_appends_to_file_without_trailing_newline(self, tmp_path): + md = tmp_path / "test.md" + md.write_text("# Heading\n\nContent", encoding="utf-8") + append_to_markdown(md, "## New\n") + result = md.read_text(encoding="utf-8") + assert "\n\n## New\n" in result + + def test_appends_to_file_with_double_newline(self, tmp_path): + md = tmp_path / "test.md" + md.write_text("# Heading\n\n", encoding="utf-8") + append_to_markdown(md, "## New\n") + result = md.read_text(encoding="utf-8") + assert "# Heading\n\n## New\n" in result + + def test_preserves_crlf_line_endings(self, tmp_path): + md = tmp_path / "test.md" + md.write_bytes(b"# Heading\r\n\r\nContent here.\r\n") + append_to_markdown(md, "## New\n") + raw = md.read_bytes() + assert b"# Heading\r\n\r\nContent here.\r\n" in raw + assert b"## New\n" in raw + + +# ---- CLI integration tests ---- + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def test_course(repo_root): + """Create a temporary course for testing, clean up after.""" + course_dir = repo_root / "courses" / "_test_add_course" + course_dir.mkdir(parents=True, exist_ok=True) + + # Minimal course.yml + (course_dir / "course.yml").write_text( + "id: 00000000-0000-0000-0000-000000000000\ntopic: bitcoin\n", + encoding="utf-8", + ) + + # Minimal en.md + (course_dir / "en.md").write_text( + "---\nname: Test Course\ngoal: Testing\nobjectives:\n - Test\n---\n\n" + "# Description\n\nTest course.\n\n" + "+++\n\n" + "# Part 1\n\n" + "00000000-0000-0000-0000-000000000001\n\n" + "## Chapter 1\n\n" + "00000000-0000-0000-0000-000000000002\n\n" + "Content here.\n", + encoding="utf-8", + ) + + yield course_dir + + # Cleanup + import shutil + + if course_dir.exists(): + shutil.rmtree(course_dir) + + +class TestAddPartCommand: + def test_non_interactive(self, runner, test_course, repo_root): + result = runner.invoke(cli, [ + "add", "part", + "--course", "_test_add_course", + "--lang", "en", + "--title", "New Part", + ]) + assert result.exit_code == 0, result.output + assert "Added part 'New Part'" in result.output + + content = (test_course / "en.md").read_text(encoding="utf-8") + assert "+++\n\n# New Part\n\n" in content + + def test_json_output(self, runner, test_course): + result = runner.invoke(cli, [ + "add", "part", + "--course", "_test_add_course", + "--lang", "en", + "--title", "JSON Part", + "--json", + ]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["action"] == "add_part" + assert data["course"] == "_test_add_course" + assert data["title"] == "JSON Part" + assert "part_id" in data + # part_id should be a UUID + uuid_re = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) + assert uuid_re.match(data["part_id"]) + + def test_part_separator_format(self, runner, test_course): + runner.invoke(cli, [ + "add", "part", + "--course", "_test_add_course", + "--lang", "en", + "--title", "Separator Test", + ]) + content = (test_course / "en.md").read_text(encoding="utf-8") + # Part block should be: +++\n\n# Title\n\nuuid + assert "+++\n\n# Separator Test\n\n" in content + + def test_missing_course_fails(self, runner): + result = runner.invoke(cli, [ + "add", "part", + "--course", "nonexistent999", + "--lang", "en", + "--title", "Nope", + ]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_missing_lang_file_fails(self, runner, test_course): + result = runner.invoke(cli, [ + "add", "part", + "--course", "_test_add_course", + "--lang", "zz", + "--title", "Nope", + ]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_interactive_prompts(self, runner, test_course): + result = runner.invoke( + cli, + ["add", "part"], + input="_test_add_course\nen\nInteractive Part\n", + ) + assert result.exit_code == 0, result.output + assert "Added part 'Interactive Part'" in result.output + + +class TestAddChapterCommand: + def test_non_interactive(self, runner, test_course, repo_root): + result = runner.invoke(cli, [ + "add", "chapter", + "--course", "_test_add_course", + "--lang", "en", + "--title", "New Chapter", + ]) + assert result.exit_code == 0, result.output + assert "Added chapter 'New Chapter'" in result.output + + content = (test_course / "en.md").read_text(encoding="utf-8") + assert "## New Chapter\n\n" in content + + def test_uuid_chapter_id(self, runner, test_course): + result = runner.invoke(cli, [ + "add", "chapter", + "--course", "_test_add_course", + "--lang", "en", + "--title", "UUID Test", + ]) + assert result.exit_code == 0 + assert "chapterId:" in result.output + + content = (test_course / "en.md").read_text(encoding="utf-8") + ids = re.findall(r"(.+?)", content) + assert any(UUID_RE.match(cid) for cid in ids), \ + f"Expected a UUID chapter ID, got: {ids}" + + def test_json_output(self, runner, test_course): + result = runner.invoke(cli, [ + "add", "chapter", + "--course", "_test_add_course", + "--lang", "en", + "--title", "JSON Chapter", + "--json", + ]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["action"] == "add_chapter" + assert data["course"] == "_test_add_course" + assert data["title"] == "JSON Chapter" + assert "chapter_id" in data + # chapter_id should be a UUID (LMS importer requirement) + assert UUID_RE.match(data["chapter_id"]) + + def test_chapter_heading_is_h2(self, runner, test_course): + runner.invoke(cli, [ + "add", "chapter", + "--course", "_test_add_course", + "--lang", "en", + "--title", "H2 Test", + ]) + content = (test_course / "en.md").read_text(encoding="utf-8") + # Should be ## not # or ### + assert "## H2 Test" in content + assert "### H2 Test" not in content + + def test_missing_course_fails(self, runner): + result = runner.invoke(cli, [ + "add", "chapter", + "--course", "nonexistent999", + "--lang", "en", + "--title", "Nope", + ]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_interactive_prompts(self, runner, test_course): + result = runner.invoke( + cli, + ["add", "chapter"], + input="_test_add_course\nen\nInteractive Chapter\n", + ) + assert result.exit_code == 0, result.output + assert "Added chapter 'Interactive Chapter'" in result.output + + def test_multiple_additions(self, runner, test_course): + """Adding multiple parts and chapters preserves structure.""" + runner.invoke(cli, [ + "add", "part", + "--course", "_test_add_course", + "--lang", "en", + "--title", "Part A", + ]) + runner.invoke(cli, [ + "add", "chapter", + "--course", "_test_add_course", + "--lang", "en", + "--title", "Chapter A1", + ]) + runner.invoke(cli, [ + "add", "chapter", + "--course", "_test_add_course", + "--lang", "en", + "--title", "Chapter A2", + ]) + + content = (test_course / "en.md").read_text(encoding="utf-8") + # All should be present in order + assert content.index("# Part A") < content.index("## Chapter A1") + assert content.index("## Chapter A1") < content.index("## Chapter A2") + + def test_modified_md_still_valid_structure(self, runner, test_course): + """After additions, the file still has valid course markdown structure.""" + runner.invoke(cli, [ + "add", "part", + "--course", "_test_add_course", + "--lang", "en", + "--title", "Validation Part", + ]) + runner.invoke(cli, [ + "add", "chapter", + "--course", "_test_add_course", + "--lang", "en", + "--title", "Validation Chapter", + ]) + + content = (test_course / "en.md").read_text(encoding="utf-8") + # Must have frontmatter + assert content.startswith("---\n") + # Must have at least one +++ separator + assert "\n+++\n" in content + # Must have partId and chapterId tags + assert "" in content + assert "" in content diff --git a/scripts/bec/tests/test_add_quiz_language.py b/scripts/bec/tests/test_add_quiz_language.py new file mode 100644 index 00000000000..7c91fccdab2 --- /dev/null +++ b/scripts/bec/tests/test_add_quiz_language.py @@ -0,0 +1,730 @@ +"""Tests for bec add quiz / add language commands.""" + +from __future__ import annotations + +import json +import re +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from bec.cli import cli +from bec.commands.add import ( + _create_language_md, + _create_language_yml, + _find_source_lang, + _next_quiz_number, + _list_chapter_ids, +) + + +# ---- Unit tests for quiz helpers ---- + + +class TestNextQuizNumber: + def test_empty_dir(self, tmp_path): + quizz_dir = tmp_path / "quizz" + quizz_dir.mkdir() + assert _next_quiz_number(quizz_dir) == 0 + + def test_nonexistent_dir(self, tmp_path): + assert _next_quiz_number(tmp_path / "nope") == 0 + + def test_sequential(self, tmp_path): + quizz_dir = tmp_path / "quizz" + quizz_dir.mkdir() + (quizz_dir / "000").mkdir() + (quizz_dir / "001").mkdir() + (quizz_dir / "002").mkdir() + assert _next_quiz_number(quizz_dir) == 3 + + def test_with_gaps(self, tmp_path): + quizz_dir = tmp_path / "quizz" + quizz_dir.mkdir() + (quizz_dir / "000").mkdir() + (quizz_dir / "005").mkdir() + # Should use max + 1, so 6 + assert _next_quiz_number(quizz_dir) == 6 + + def test_ignores_non_numeric_dirs(self, tmp_path): + quizz_dir = tmp_path / "quizz" + quizz_dir.mkdir() + (quizz_dir / "000").mkdir() + (quizz_dir / "readme.txt").touch() + (quizz_dir / "old").mkdir() + assert _next_quiz_number(quizz_dir) == 1 + + +class TestListChapterIds: + def test_extracts_chapter_ids(self, tmp_path, repo_root): + # Use a temp course in the real repo + course_dir = repo_root / "courses" / "_test_chapter_list" + course_dir.mkdir(parents=True, exist_ok=True) + try: + (course_dir / "course.yml").write_text("id: test\n", encoding="utf-8") + (course_dir / "en.md").write_text( + "---\nname: Test\n---\n\n" + "## Ch1\n\naaa-bbb-ccc\n\n" + "## Ch2\n\nddd-eee-fff\n", + encoding="utf-8", + ) + result = _list_chapter_ids("_test_chapter_list", repo_root) + assert result == ["aaa-bbb-ccc", "ddd-eee-fff"] + finally: + shutil.rmtree(course_dir) + + def test_deduplicates(self, tmp_path, repo_root): + course_dir = repo_root / "courses" / "_test_chapter_dedup" + course_dir.mkdir(parents=True, exist_ok=True) + try: + (course_dir / "course.yml").write_text("id: test\n", encoding="utf-8") + # Same chapterId in two language files + for lang in ("en", "fr"): + (course_dir / f"{lang}.md").write_text( + f"---\nname: Test\n---\n\n## Ch1\n\nsame-id\n", + encoding="utf-8", + ) + result = _list_chapter_ids("_test_chapter_dedup", repo_root) + assert result == ["same-id"] + finally: + shutil.rmtree(course_dir) + + def test_missing_course(self, repo_root): + assert _list_chapter_ids("nonexistent999", repo_root) == [] + + +# ---- Unit tests for language helpers ---- + + +class TestFindSourceLang: + def test_prefers_en(self, tmp_path): + (tmp_path / "en.md").touch() + (tmp_path / "fr.md").touch() + assert _find_source_lang(tmp_path) == "en" + + def test_prefers_en_yml(self, tmp_path): + (tmp_path / "en.yml").touch() + (tmp_path / "fr.yml").touch() + assert _find_source_lang(tmp_path) == "en" + + def test_falls_back_to_first(self, tmp_path): + (tmp_path / "fr.md").touch() + (tmp_path / "es.md").touch() + assert _find_source_lang(tmp_path) == "es" # sorted: es < fr + + def test_accepts_regional_codes(self, tmp_path): + (tmp_path / "zh-Hans.md").touch() + assert _find_source_lang(tmp_path) == "zh-Hans" + + def test_accepts_regional_codes_yml(self, tmp_path): + (tmp_path / "nb-NO.yml").touch() + assert _find_source_lang(tmp_path) == "nb-NO" + (tmp_path / "nb-NO.yml").unlink() + (tmp_path / "sr-Latn.yml").touch() + assert _find_source_lang(tmp_path) == "sr-Latn" + + def test_returns_none_if_empty(self, tmp_path): + assert _find_source_lang(tmp_path) is None + + def test_ignores_non_lang_files(self, tmp_path): + (tmp_path / "course.yml").touch() + (tmp_path / "tutorial.yml").touch() + (tmp_path / "README.md").touch() + assert _find_source_lang(tmp_path) is None + + +class TestCreateLanguageMd: + def test_preserves_frontmatter_keys(self, tmp_path): + source = tmp_path / "en.md" + source.write_text( + '---\nname: "Bitcoin 101"\ngoal: "Learn Bitcoin"\nobjectives:\n - "Understand keys"\n - "Use wallets"\n---\n\nContent.\n', + encoding="utf-8", + ) + target = tmp_path / "fr.md" + _create_language_md(source, target, "fr") + text = target.read_text(encoding="utf-8") + assert "---\n" in text + assert 'name: "TODO"' in text + assert 'goal: "TODO"' in text + + def test_preserves_chapter_ids(self, tmp_path): + source = tmp_path / "en.md" + source.write_text( + "---\nname: Test\n---\n\n" + "## Chapter 1\n\n" + "abc-def-ghi\n\n" + "Content here.\n", + encoding="utf-8", + ) + target = tmp_path / "fr.md" + _create_language_md(source, target, "fr") + text = target.read_text(encoding="utf-8") + assert "abc-def-ghi" in text + + def test_preserves_part_ids(self, tmp_path): + source = tmp_path / "en.md" + source.write_text( + "---\nname: Test\n---\n\n" + "+++\n\n" + "# Part 1\n\n" + "part-uuid-here\n\n" + "## Chapter 1\n\n" + "ch-uuid\n\n" + "Content.\n", + encoding="utf-8", + ) + target = tmp_path / "fr.md" + _create_language_md(source, target, "fr") + text = target.read_text(encoding="utf-8") + assert "part-uuid-here" in text + assert "+++" in text + + def test_preserves_separators(self, tmp_path): + source = tmp_path / "en.md" + source.write_text( + "---\nname: Test\n---\n\n+++\n\n# Part\n\nText.\n", + encoding="utf-8", + ) + target = tmp_path / "fr.md" + _create_language_md(source, target, "fr") + text = target.read_text(encoding="utf-8") + assert "+++" in text + + def test_replaces_prose_with_todo(self, tmp_path): + source = tmp_path / "en.md" + source.write_text( + "---\nname: Test\n---\n\n## Heading\n\nSome prose content here.\n", + encoding="utf-8", + ) + target = tmp_path / "fr.md" + _create_language_md(source, target, "fr") + text = target.read_text(encoding="utf-8") + assert "Some prose content here" not in text + assert "TODO" in text + + def test_preserves_images(self, tmp_path): + source = tmp_path / "en.md" + source.write_text( + "---\nname: Test\n---\n\n![cover](assets/cover.webp)\n\nText.\n", + encoding="utf-8", + ) + target = tmp_path / "fr.md" + _create_language_md(source, target, "fr") + text = target.read_text(encoding="utf-8") + assert "![cover](assets/cover.webp)" in text + + def test_unterminated_frontmatter_raises_click_error(self, tmp_path): + import click + + source = tmp_path / "en.md" + source.write_text("---\nname: Broken\nno closing fence\n", encoding="utf-8") + target = tmp_path / "fr.md" + with pytest.raises(click.ClickException, match=r"Unterminated frontmatter.*en\.md"): + _create_language_md(source, target, "fr") + + def test_heading_structure_preserved(self, tmp_path): + source = tmp_path / "en.md" + source.write_text( + "---\nname: Test\n---\n\n" + "# Big Heading\n\n" + "## Sub Heading\n\n" + "### Sub Sub Heading\n\n" + "Paragraph.\n", + encoding="utf-8", + ) + target = tmp_path / "fr.md" + _create_language_md(source, target, "fr") + text = target.read_text(encoding="utf-8") + assert "# TODO\n" in text + assert "## TODO\n" in text + assert "### TODO\n" in text + + +class TestCreateLanguageYml: + def test_replaces_strings(self, tmp_path): + source = tmp_path / "en.yml" + source.write_text( + "title: Bitcoin Book\ndescription: A great book about Bitcoin.\n", + encoding="utf-8", + ) + target = tmp_path / "fr.yml" + _create_language_yml(source, target) + + from bec.lib.yaml_utils import load_yaml + data = load_yaml(target) + assert data["title"] == "TODO" + assert data["description"] == "TODO" + + def test_preserves_booleans(self, tmp_path): + source = tmp_path / "en.yml" + source.write_text("original: true\ntitle: Test\n", encoding="utf-8") + target = tmp_path / "fr.yml" + _create_language_yml(source, target) + + from bec.lib.yaml_utils import load_yaml + data = load_yaml(target) + assert data["original"] is True + assert data["title"] == "TODO" + + def test_preserves_numbers(self, tmp_path): + source = tmp_path / "en.yml" + source.write_text("publication_year: 2024\ntitle: Test\n", encoding="utf-8") + target = tmp_path / "fr.yml" + _create_language_yml(source, target) + + from bec.lib.yaml_utils import load_yaml + data = load_yaml(target) + assert data["publication_year"] == 2024 + assert data["title"] == "TODO" + + +# ---- CLI integration tests ---- + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def quiz_course(repo_root): + """Create a temporary course with chapters for quiz testing.""" + course_dir = repo_root / "courses" / "_test_quiz_course" + course_dir.mkdir(parents=True, exist_ok=True) + + (course_dir / "course.yml").write_text( + "id: 00000000-0000-0000-0000-000000000099\ntopic: bitcoin\n", + encoding="utf-8", + ) + + (course_dir / "en.md").write_text( + "---\nname: Test Quiz Course\ngoal: Testing quizzes\nobjectives:\n - Test\n---\n\n" + "+++\n\n" + "# Part 1\n\n" + "00000000-0000-0000-0000-000000000001\n\n" + "## Chapter 1\n\n" + "test-chapter-id-001\n\n" + "Content.\n", + encoding="utf-8", + ) + + yield course_dir + + if course_dir.exists(): + shutil.rmtree(course_dir) + + +@pytest.fixture +def lang_course(repo_root): + """Create a temporary course for language testing.""" + course_dir = repo_root / "courses" / "_test_lang_course" + course_dir.mkdir(parents=True, exist_ok=True) + + (course_dir / "course.yml").write_text( + "id: 00000000-0000-0000-0000-000000000098\ntopic: bitcoin\n", + encoding="utf-8", + ) + + (course_dir / "en.md").write_text( + '---\nname: "Language Test Course"\ngoal: "Test language addition"\nobjectives:\n - "First objective"\n - "Second objective"\n---\n\n' + "# Course Description\n\n" + "This is the course description.\n\n" + "+++\n\n" + "# Part 1 — Introduction\n\n" + "00000000-0000-0000-0000-000000000010\n\n" + "## Chapter 1 — Getting Started\n\n" + "00000000-0000-0000-0000-000000000011\n\n" + "Chapter content here.\n\n" + "![diagram](assets/en/diagram.webp)\n\n" + "More content.\n", + encoding="utf-8", + ) + + yield course_dir + + if course_dir.exists(): + shutil.rmtree(course_dir) + + +@pytest.fixture +def lang_tutorial(repo_root): + """Create a temporary tutorial for language testing.""" + tuto_dir = repo_root / "tutorials" / "wallet" / "_test_lang_tuto" + tuto_dir.mkdir(parents=True, exist_ok=True) + + (tuto_dir / "tutorial.yml").write_text( + "id: 00000000-0000-0000-0000-000000000097\nlevel: beginner\n", + encoding="utf-8", + ) + + (tuto_dir / "en.md").write_text( + '---\nname: "Test Tutorial"\ndescription: "A test tutorial"\n---\n\n' + "![cover](assets/cover.webp)\n\n" + "## Introduction\n\n" + "Tutorial content here.\n\n" + "## Step 1\n\n" + "Step content.\n", + encoding="utf-8", + ) + + yield tuto_dir + + if tuto_dir.exists(): + shutil.rmtree(tuto_dir) + + +@pytest.fixture +def lang_resource(repo_root): + """Create a temporary book resource for language testing.""" + book_dir = repo_root / "resources" / "books" / "_test_lang_book" + book_dir.mkdir(parents=True, exist_ok=True) + + (book_dir / "book.yml").write_text( + "author: Test Author\nlevel: beginner\ntags:\n - software\n", + encoding="utf-8", + ) + + (book_dir / "en.yml").write_text( + "title: Test Book Title\npublication_year: 2024\ncover: cover_en.webp\noriginal: true\ndescription: |\n A great book about Bitcoin.\n", + encoding="utf-8", + ) + + (book_dir / "assets").mkdir(exist_ok=True) + + yield book_dir + + if book_dir.exists(): + shutil.rmtree(book_dir) + + +# ---- Quiz CLI tests ---- + + +class TestAddQuizCommand: + def test_creates_quiz_folder(self, runner, quiz_course, repo_root): + result = runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "en", + "--difficulty", "easy", + "--author", "test-author", + ]) + assert result.exit_code == 0, result.output + assert "Created quiz 000" in result.output + + quiz_dir = quiz_course / "quizz" / "000" + assert quiz_dir.is_dir() + assert (quiz_dir / "question.yml").is_file() + assert (quiz_dir / "en.yml").is_file() + + def test_question_yml_content(self, runner, quiz_course): + runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "en", + "--difficulty", "intermediate", + "--author", "satoshi", + ]) + from bec.lib.yaml_utils import load_yaml + data = load_yaml(quiz_course / "quizz" / "000" / "question.yml") + # LMS uses question id as primary key — must be a UUID + assert re.match( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + data["id"], + ) + assert data["chapterId"] == "test-chapter-id-001" + assert data["difficulty"] == "intermediate" + assert data["author"] == "satoshi" + assert data["tags"] == ["software"] + + def test_translation_yml_content(self, runner, quiz_course): + runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "en", + "--difficulty", "easy", + "--author", "test", + ]) + from bec.lib.yaml_utils import load_yaml + data = load_yaml(quiz_course / "quizz" / "000" / "en.yml") + assert "question" in data + assert "answer" in data + assert "wrong_answers" in data + assert len(data["wrong_answers"]) == 3 + assert "explanation" in data + assert data["reviewed"] is False + + def test_auto_numbering(self, runner, quiz_course): + """Creating multiple quizzes increments the number.""" + base_args = [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "en", + "--difficulty", "easy", + "--author", "test", + ] + # First quiz + runner.invoke(cli, base_args) + assert (quiz_course / "quizz" / "000").is_dir() + + # Second quiz + runner.invoke(cli, base_args) + assert (quiz_course / "quizz" / "001").is_dir() + + # Third quiz + runner.invoke(cli, base_args) + assert (quiz_course / "quizz" / "002").is_dir() + + def test_auto_numbering_with_existing(self, runner, quiz_course): + """Numbering continues from highest existing number.""" + quizz_dir = quiz_course / "quizz" + quizz_dir.mkdir() + (quizz_dir / "000").mkdir() + (quizz_dir / "003").mkdir() + + result = runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "en", + "--difficulty", "easy", + "--author", "test", + ]) + assert result.exit_code == 0 + assert (quizz_dir / "004").is_dir() + + def test_json_output(self, runner, quiz_course): + result = runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "en", + "--difficulty", "hard", + "--author", "test", + "--json", + ]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["action"] == "add_quiz" + assert data["course"] == "_test_quiz_course" + assert data["quiz_number"] == "000" + assert data["chapter_id"] == "test-chapter-id-001" + assert data["difficulty"] == "hard" + assert "files" in data + assert len(data["files"]) == 2 + + def test_missing_course_fails(self, runner): + result = runner.invoke(cli, [ + "add", "quiz", + "--course", "nonexistent999", + "--chapter-id", "some-id", + "--lang", "en", + ]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_invalid_difficulty_fails(self, runner, quiz_course): + result = runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "en", + "--difficulty", "impossible", + ]) + assert result.exit_code != 0 + assert "invalid difficulty" in result.output.lower() + + def test_unknown_chapter_id_fails(self, runner, quiz_course): + result = runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "does-not-exist", + "--lang", "en", + "--difficulty", "easy", + "--author", "test", + ]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + assert not (quiz_course / "quizz").exists() + + def test_invalid_lang_fails(self, runner, quiz_course): + result = runner.invoke(cli, [ + "add", "quiz", + "--course", "_test_quiz_course", + "--chapter-id", "test-chapter-id-001", + "--lang", "xx", + "--difficulty", "easy", + "--author", "test", + ]) + assert result.exit_code != 0 + assert "invalid language" in result.output.lower() + assert not (quiz_course / "quizz").exists() + + def test_interactive_prompts(self, runner, quiz_course): + result = runner.invoke( + cli, + ["add", "quiz"], + input="_test_quiz_course\ntest-chapter-id-001\nen\neasy\ntest-author\n", + ) + assert result.exit_code == 0, result.output + assert "Created quiz 000" in result.output + + +# ---- Language CLI tests ---- + + +class TestAddLanguageCommand: + def test_course_language(self, runner, lang_course, repo_root): + result = runner.invoke(cli, [ + "add", "language", + "--path", "courses/_test_lang_course", + "--lang", "fr", + ]) + assert result.exit_code == 0, result.output + assert "Added language 'fr'" in result.output + + fr_md = lang_course / "fr.md" + assert fr_md.is_file() + text = fr_md.read_text(encoding="utf-8") + # Frontmatter preserved + assert "---\n" in text + # IDs preserved + assert "00000000-0000-0000-0000-000000000010" in text + assert "00000000-0000-0000-0000-000000000011" in text + # Separators preserved + assert "+++" in text + # Prose replaced + assert "Chapter content here" not in text + + def test_tutorial_language(self, runner, lang_tutorial, repo_root): + result = runner.invoke(cli, [ + "add", "language", + "--path", "tutorials/wallet/_test_lang_tuto", + "--lang", "es", + ]) + assert result.exit_code == 0, result.output + assert "Added language 'es'" in result.output + + es_md = lang_tutorial / "es.md" + assert es_md.is_file() + text = es_md.read_text(encoding="utf-8") + # Frontmatter keys preserved + assert "name:" in text + assert "description:" in text + # Image preserved + assert "![cover](assets/cover.webp)" in text + # Heading structure preserved + assert "## TODO" in text + + def test_resource_yml_language(self, runner, lang_resource, repo_root): + result = runner.invoke(cli, [ + "add", "language", + "--path", "resources/books/_test_lang_book", + "--lang", "fr", + ]) + assert result.exit_code == 0, result.output + assert "Added language 'fr'" in result.output + + fr_yml = lang_resource / "fr.yml" + assert fr_yml.is_file() + + from bec.lib.yaml_utils import load_yaml + data = load_yaml(fr_yml) + assert data["title"] == "TODO" + assert data["publication_year"] == 2024 # number preserved + assert data["original"] is True # bool preserved + + def test_duplicate_language_fails(self, runner, lang_course): + result = runner.invoke(cli, [ + "add", "language", + "--path", "courses/_test_lang_course", + "--lang", "en", + ]) + assert result.exit_code != 0 + assert "already exists" in result.output.lower() + + def test_invalid_language_fails(self, runner, lang_course): + result = runner.invoke(cli, [ + "add", "language", + "--path", "courses/_test_lang_course", + "--lang", "xx", + ]) + assert result.exit_code != 0 + assert "invalid language" in result.output.lower() + + def test_missing_path_fails(self, runner): + result = runner.invoke(cli, [ + "add", "language", + "--path", "courses/nonexistent999", + "--lang", "fr", + ]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_json_output(self, runner, lang_course): + result = runner.invoke(cli, [ + "add", "language", + "--path", "courses/_test_lang_course", + "--lang", "de", + "--json", + ]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["action"] == "add_language" + assert data["source_lang"] == "en" + assert data["target_lang"] == "de" + assert data["content_type"] == "course" + assert len(data["files"]) >= 1 + + def test_preserves_chapter_ids_in_course(self, runner, lang_course): + runner.invoke(cli, [ + "add", "language", + "--path", "courses/_test_lang_course", + "--lang", "ja", + ]) + ja_md = lang_course / "ja.md" + text = ja_md.read_text(encoding="utf-8") + + # All structural IDs must be present + assert "00000000-0000-0000-0000-000000000010" in text + assert "00000000-0000-0000-0000-000000000011" in text + + def test_preserves_images(self, runner, lang_course): + runner.invoke(cli, [ + "add", "language", + "--path", "courses/_test_lang_course", + "--lang", "pt", + ]) + pt_md = lang_course / "pt.md" + text = pt_md.read_text(encoding="utf-8") + assert "![diagram](assets/en/diagram.webp)" in text + + def test_interactive_prompts(self, runner, lang_course): + result = runner.invoke( + cli, + ["add", "language"], + input="courses/_test_lang_course\nit\n", + ) + assert result.exit_code == 0, result.output + assert "Added language 'it'" in result.output + + def test_works_for_tutorials_too(self, runner, lang_tutorial): + """Language addition is not course-specific.""" + result = runner.invoke(cli, [ + "add", "language", + "--path", "tutorials/wallet/_test_lang_tuto", + "--lang", "fr", + ]) + assert result.exit_code == 0, result.output + fr_md = lang_tutorial / "fr.md" + assert fr_md.is_file() + text = fr_md.read_text(encoding="utf-8") + # Tutorial prose replaced with TODO + assert "Tutorial content here" not in text + assert "TODO" in text diff --git a/scripts/bec/tests/test_agent_setup.py b/scripts/bec/tests/test_agent_setup.py new file mode 100644 index 00000000000..3efd6320a72 --- /dev/null +++ b/scripts/bec/tests/test_agent_setup.py @@ -0,0 +1,207 @@ +"""Tests for bec agent-setup command.""" + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from bec.cli import cli +from bec.commands.agent_setup import AGENT_FILES, _create_symlink, run_agent_setup + + +@pytest.fixture +def fake_repo(tmp_path): + """Create a minimal fake repo with content-types.yml and agent docs.""" + # Create content-types.yml marker + (tmp_path / "content-types.yml").write_text("content_types: {}\n") + + # Create agent source files + agents_dir = tmp_path / "docs" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "AGENTS.md").write_text("# AGENTS orientation\n") + (agents_dir / "CLAUDE.md").write_text("# CLAUDE instructions\n") + + return tmp_path + + +# ── Unit tests for _create_symlink ────────────────────────────────────── + + +class TestCreateSymlink: + def test_creates_new_symlink(self, tmp_path): + target = Path("docs/agents/AGENTS.md") + link = tmp_path / "AGENTS.md" + # Create the actual target so readlink works + (tmp_path / "docs" / "agents").mkdir(parents=True) + (tmp_path / "docs" / "agents" / "AGENTS.md").write_text("# test") + + status = _create_symlink(link, target, "AGENTS.md") + + assert status == "created" + assert link.is_symlink() + assert link.readlink() == target + + def test_already_correct_symlink(self, tmp_path): + target = Path("docs/agents/AGENTS.md") + link = tmp_path / "AGENTS.md" + link.symlink_to(target) + + status = _create_symlink(link, target, "AGENTS.md") + + assert status == "already_correct" + assert link.is_symlink() + + def test_updates_stale_symlink(self, tmp_path): + target = Path("docs/agents/AGENTS.md") + link = tmp_path / "AGENTS.md" + link.symlink_to(Path("old/path/AGENTS.md")) + + status = _create_symlink(link, target, "AGENTS.md") + + assert status == "updated" + assert link.readlink() == target + + def test_replaces_real_file(self, tmp_path): + target = Path("docs/agents/CLAUDE.md") + link = tmp_path / "CLAUDE.md" + link.write_text("not a symlink") + + status = _create_symlink(link, target, "CLAUDE.md") + + assert status == "replaced" + assert link.is_symlink() + assert link.readlink() == target + assert (tmp_path / "CLAUDE.md.bak").exists() + + def test_backup_does_not_clobber_existing_bak(self, tmp_path): + target = Path("docs/agents/CLAUDE.md") + link = tmp_path / "CLAUDE.md" + link.write_text("current content") + (tmp_path / "CLAUDE.md.bak").write_text("old backup") + + status = _create_symlink(link, target, "CLAUDE.md") + + assert status == "replaced" + assert (tmp_path / "CLAUDE.md.bak").read_text() == "old backup" + assert (tmp_path / "CLAUDE.md.bak.1").read_text() == "current content" + + def test_symlink_error_raises_click_exception(self, tmp_path, monkeypatch): + import click + + target = Path("docs/agents/AGENTS.md") + link = tmp_path / "AGENTS.md" + + def boom(self, t): + raise OSError("permission denied") + + monkeypatch.setattr(Path, "symlink_to", boom) + with pytest.raises(click.ClickException, match="failed to create symlink AGENTS.md"): + _create_symlink(link, target, "AGENTS.md") + + +# ── CLI integration tests ─────────────────────────────────────────────── + + +class TestAgentSetupCLI: + def test_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["agent-setup", "--help"]) + assert result.exit_code == 0 + assert "Symlink AGENTS.md and CLAUDE.md" in result.output + + def test_creates_symlinks(self, fake_repo, monkeypatch): + monkeypatch.chdir(fake_repo) + runner = CliRunner() + result = runner.invoke(cli, ["agent-setup"]) + + assert result.exit_code == 0 + assert "Agent orientation files are ready" in result.output + assert (fake_repo / "AGENTS.md").is_symlink() + assert (fake_repo / "CLAUDE.md").is_symlink() + + def test_json_output(self, fake_repo, monkeypatch): + monkeypatch.chdir(fake_repo) + runner = CliRunner() + result = runner.invoke(cli, ["agent-setup", "--json"]) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert "symlinks" in data + assert len(data["symlinks"]) == 2 + for entry in data["symlinks"]: + assert "file" in entry + assert "target" in entry + assert "status" in entry + + def test_idempotent(self, fake_repo, monkeypatch): + monkeypatch.chdir(fake_repo) + runner = CliRunner() + + # Run twice + result1 = runner.invoke(cli, ["agent-setup"]) + assert result1.exit_code == 0 + + result2 = runner.invoke(cli, ["agent-setup", "--json"]) + assert result2.exit_code == 0 + data = json.loads(result2.output) + for entry in data["symlinks"]: + assert entry["status"] == "already_correct" + + def test_missing_source_file(self, tmp_path, monkeypatch): + """Error when source agent docs don't exist.""" + (tmp_path / "content-types.yml").write_text("content_types: {}\n") + # Don't create docs/agents/ — sources will be missing + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(cli, ["agent-setup"]) + + assert result.exit_code == 1 + assert "source file not found" in result.output + + +# ── Real repo tests ───────────────────────────────────────────────────── + + +class TestAgentSetupRealRepo: + def test_source_files_exist(self, repo_root): + """Both source files must exist in the real repo.""" + for source_rel, _ in AGENT_FILES: + source = repo_root / source_rel + assert source.exists(), f"Missing: {source_rel}" + + def test_agents_md_content(self, repo_root): + """AGENTS.md should have key sections.""" + content = (repo_root / "docs" / "agents" / "AGENTS.md").read_text() + assert "Content Types" in content + assert "bec CLI" in content + assert "Content Conventions" in content + assert "Common Pitfalls" in content + assert "content-types.yml" in content + + def test_claude_md_content(self, repo_root): + """CLAUDE.md should reference AGENTS.md and have workflow.""" + content = (repo_root / "docs" / "agents" / "CLAUDE.md").read_text() + assert "AGENTS.md" in content + assert "bec validate" in content + assert "Workflow" in content + + def test_agents_md_under_3000_tokens(self, repo_root): + """AGENTS.md should be compact (<3,000 tokens ~ <12,000 chars).""" + content = (repo_root / "docs" / "agents" / "AGENTS.md").read_text() + # Rough token estimate: ~4 chars per token + assert len(content) < 12000, f"AGENTS.md too large: {len(content)} chars" + + def test_combined_under_4000_tokens(self, repo_root): + """AGENTS.md + content-types.yml should be under 4,000 tokens combined.""" + agents = (repo_root / "docs" / "agents" / "AGENTS.md").read_text() + ct = (repo_root / "content-types.yml").read_text() + combined = len(agents) + len(ct) + # ~4 chars per token, 4000 tokens = 16000 chars + assert combined < 16000, f"Combined too large: {combined} chars" + + def test_gitignore_entries(self, repo_root): + """Both symlink names should be gitignored.""" + gitignore = (repo_root / ".gitignore").read_text() + assert "AGENTS.md" in gitignore + assert "CLAUDE.md" in gitignore diff --git a/scripts/bec/tests/test_cli.py b/scripts/bec/tests/test_cli.py new file mode 100644 index 00000000000..7732ee81b7d --- /dev/null +++ b/scripts/bec/tests/test_cli.py @@ -0,0 +1,90 @@ +"""Tests for bec.cli — basic smoke tests.""" + +from click.testing import CliRunner + +from bec.cli import cli + + +def test_help(): + """bec --help should print help text and exit 0.""" + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "bec" in result.output + + +def test_version(): + """bec --version should print version.""" + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.output + + +def test_subcommands_listed(): + """All expected subcommands should appear in help.""" + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + for cmd in ["validate", "new", "add", "proofread", "report", "agent-setup"]: + assert cmd in result.output, f"Subcommand '{cmd}' not in help output" + + +def test_new_subcommands(): + """bec new --help should list scaffold subcommands.""" + runner = CliRunner() + result = runner.invoke(cli, ["new", "--help"]) + assert result.exit_code == 0 + for cmd in ["course", "tutorial", "professor", "event", "resource"]: + assert cmd in result.output + + +def test_add_subcommands(): + """bec add --help should list add subcommands.""" + runner = CliRunner() + result = runner.invoke(cli, ["add", "--help"]) + assert result.exit_code == 0 + for cmd in ["part", "chapter", "quiz", "language"]: + assert cmd in result.output + + +def test_proofread_subcommands(): + """bec proofread --help should list proofread subcommands.""" + runner = CliRunner() + result = runner.invoke(cli, ["proofread", "--help"]) + assert result.exit_code == 0 + for cmd in ["update", "reward", "batch-add", "status"]: + assert cmd in result.output + + +def test_report_subcommands(): + """bec report --help should list report subcommands.""" + runner = CliRunner() + result = runner.invoke(cli, ["report", "--help"]) + assert result.exit_code == 0 + for cmd in ["translation", "images", "video", "proofreading", "analytics"]: + assert cmd in result.output + + +def test_report_no_subcommand_errors(): + """bec report with no subcommand and no --all prints help and exits non-zero.""" + runner = CliRunner() + result = runner.invoke(cli, ["report"]) + assert result.exit_code == 2 + assert "Generate HTML/JSON reports" in result.output + + +def test_validate_type_conflicts_with_shortcut_flags(): + """--type combined with --courses-only/--tutorials-only is an error.""" + runner = CliRunner() + for flag in ["--courses-only", "--tutorials-only"]: + result = runner.invoke(cli, ["validate", "--all", "--type", "course", flag]) + assert result.exit_code == 2 + assert "--type cannot be combined" in result.output + + +def test_validate_shortcut_flags_mutually_exclusive(): + """--courses-only and --tutorials-only cannot be combined.""" + runner = CliRunner() + result = runner.invoke(cli, ["validate", "--all", "--courses-only", "--tutorials-only"]) + assert result.exit_code == 2 + assert "mutually exclusive" in result.output diff --git a/scripts/bec/tests/test_content_types.py b/scripts/bec/tests/test_content_types.py new file mode 100644 index 00000000000..2e78720d742 --- /dev/null +++ b/scripts/bec/tests/test_content_types.py @@ -0,0 +1,138 @@ +"""Tests for bec.lib.content_types.""" + +from pathlib import Path + +import pytest + +from bec.lib.content_types import load_registry, ContentRegistry + + +@pytest.fixture +def registry(repo_root) -> ContentRegistry: + """Load the real content-types.yml registry.""" + return load_registry(repo_root) + + +class TestLoadRegistry: + def test_loads_all_14_content_types(self, registry): + assert len(registry.content_types) == 14 + + def test_content_type_keys(self, registry): + expected = { + "course", "tutorial", "professor", "event", + "bet", "book", "channel", "conference", + "glossary", "movie", "newsletter", "podcast", + "project", "paper", + } + assert set(registry.content_types.keys()) == expected + + def test_each_type_has_required_fields(self, registry): + for key, ct in registry.content_types.items(): + assert ct.name, f"{key} missing name" + assert ct.path_pattern, f"{key} missing path_pattern" + assert ct.metadata_file, f"{key} missing metadata_file" + assert ct.schema, f"{key} missing schema" + + def test_tutorial_categories(self, registry): + assert len(registry.tutorial_categories) == 8 + assert "wallet" in registry.tutorial_categories + assert "mining" in registry.tutorial_categories + + def test_discipline_codes(self, registry): + assert len(registry.discipline_codes) >= 17 + assert registry.discipline_codes["btc"] == "Bitcoin Core" + assert registry.discipline_codes["lnp"] == "Lightning Network" + + def test_tags_count(self, registry): + assert len(registry.tags) == 52 + + def test_languages(self, registry): + assert "en" in registry.languages + assert "fr" in registry.languages + assert len(registry.languages) >= 30 + + def test_quiz_schemas(self, registry): + assert "question" in registry.quiz_schemas + assert "translation" in registry.quiz_schemas + + +class TestGetSchemaPath: + def test_course_schema_path(self, registry, repo_root): + path = registry.get_schema_path("course", repo_root) + assert path.name == "course-scheme.json" + + def test_content_schema_path(self, registry, repo_root): + path = registry.get_content_schema_path("course", repo_root) + assert path is not None + assert path.name == "course-content-scheme.json" + + def test_no_content_schema(self, registry, repo_root): + path = registry.get_content_schema_path("event", repo_root) + assert path is None + + +class TestDetectTypeFromPath: + def test_detect_course(self, registry, repo_root): + path = repo_root / "courses" / "btc101" + ct = registry.detect_type_from_path(path, repo_root) + assert ct is not None + assert ct.key == "course" + + def test_detect_tutorial(self, registry, repo_root): + path = repo_root / "tutorials" / "wallet" / "sparrow" + ct = registry.detect_type_from_path(path, repo_root) + assert ct is not None + assert ct.key == "tutorial" + + def test_detect_professor(self, registry, repo_root): + path = repo_root / "professors" / "asi0" + ct = registry.detect_type_from_path(path, repo_root) + assert ct is not None + assert ct.key == "professor" + + def test_detect_event(self, registry, repo_root): + path = repo_root / "events" / "bitcoin-nashville-2024" + ct = registry.detect_type_from_path(path, repo_root) + assert ct is not None + assert ct.key == "event" + + def test_detect_book(self, registry, repo_root): + path = repo_root / "resources" / "books" / "mastering-bitcoin" + ct = registry.detect_type_from_path(path, repo_root) + assert ct is not None + assert ct.key == "book" + + def test_detect_glossary(self, registry, repo_root): + path = repo_root / "resources" / "glossary" / "51-percent-attack" + ct = registry.detect_type_from_path(path, repo_root) + assert ct is not None + assert ct.key == "glossary" + + def test_detect_unknown(self, registry, repo_root): + path = repo_root / "unknown" / "something" + ct = registry.detect_type_from_path(path, repo_root) + assert ct is None + + +class TestSchemaPathsExist: + def test_all_schema_files_exist(self, registry, repo_root): + for key, ct in registry.content_types.items(): + schema_path = repo_root / ct.schema + assert schema_path.is_file(), f"Schema missing for {key}: {ct.schema}" + + def test_all_content_schema_files_exist(self, registry, repo_root): + for key, ct in registry.content_types.items(): + if ct.content_schema: + schema_path = repo_root / ct.content_schema + assert schema_path.is_file(), f"Content schema missing for {key}: {ct.content_schema}" + + def test_quiz_schema_files_exist(self, registry, repo_root): + for key, path_str in registry.quiz_schemas.items(): + schema_path = repo_root / path_str + assert schema_path.is_file(), f"Quiz schema missing for {key}: {path_str}" + + def test_all_example_paths_exist(self, registry, repo_root): + for key, ct in registry.content_types.items(): + if ct.example: + example_path = repo_root / ct.example + assert example_path.is_dir(), f"Example missing for {key}: {ct.example}" diff --git a/scripts/bec/tests/test_new.py b/scripts/bec/tests/test_new.py new file mode 100644 index 00000000000..2b4194a7bac --- /dev/null +++ b/scripts/bec/tests/test_new.py @@ -0,0 +1,420 @@ +"""Tests for bec new course command.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml +from click.testing import CliRunner + +from bec.cli import cli +from bec.commands.new import ( + COURSE_ID_RE, + build_course_md, + build_course_yml, + level_from_number, + validate_course_id, +) + + +# ---- Unit tests for helpers ---- + + +class TestCourseIdRegex: + def test_valid_ids(self): + assert COURSE_ID_RE.match("btc101") + assert COURSE_ID_RE.match("dev301") + assert COURSE_ID_RE.match("min200") + + def test_invalid_ids(self): + assert not COURSE_ID_RE.match("BTC101") # uppercase + assert not COURSE_ID_RE.match("bt101") # 2 chars + assert not COURSE_ID_RE.match("btc10") # 2 digits + assert not COURSE_ID_RE.match("btc1011") # 4 digits + assert not COURSE_ID_RE.match("101btc") # reversed + assert not COURSE_ID_RE.match("") + + +class TestValidateCourseId: + @pytest.fixture + def registry(self, repo_root): + from bec.lib.content_types import load_registry + + return load_registry(repo_root) + + def test_valid_id(self, registry): + assert validate_course_id("btc101", registry) is None + assert validate_course_id("dev301", registry) is None + + def test_bad_format(self, registry): + err = validate_course_id("BTC101", registry) + assert err is not None + assert "3-letter discipline code" in err + + def test_unknown_discipline(self, registry): + err = validate_course_id("xyz101", registry) + assert err is not None + assert "Unknown discipline" in err + + def test_number_out_of_range(self, registry): + err = validate_course_id("btc001", registry) + assert err is not None + assert "out of range" in err + + def test_number_too_high(self, registry): + err = validate_course_id("btc999", registry) + assert err is not None + assert "out of range" in err + + +class TestLevelFromNumber: + @pytest.fixture + def registry(self, repo_root): + from bec.lib.content_types import load_registry + + return load_registry(repo_root) + + def test_beginner(self, registry): + assert level_from_number(101, registry) == "beginner" + assert level_from_number(199, registry) == "beginner" + + def test_intermediate(self, registry): + assert level_from_number(200, registry) == "intermediate" + assert level_from_number(299, registry) == "intermediate" + + def test_advanced(self, registry): + assert level_from_number(300, registry) == "advanced" + + def test_expert(self, registry): + assert level_from_number(400, registry) == "expert" + + +class TestBuildCourseYml: + def test_required_fields(self): + data = build_course_yml( + course_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + topic="bitcoin", + subtopic="bitcoin", + level="beginner", + lang="en", + professor_id="11111111-2222-3333-4444-555555555555", + ) + assert data["id"] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert data["topic"] == "bitcoin" + assert data["subtopic"] == "bitcoin" + assert data["level"] == "beginner" + assert data["original_language"] == "en" + assert len(data["professors_id"]) == 1 + assert data["professors_id"][0] == "11111111-2222-3333-4444-555555555555" + assert data["hours"] == 1 + assert data["teaching_format"] == "self_paced" + assert len(data["proofreading"]) == 1 + assert data["proofreading"][0]["language"] == "en" + + def test_uuid_format(self): + data = build_course_yml( + course_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + topic="bitcoin", + subtopic="bitcoin", + level="beginner", + lang="en", + professor_id="11111111-2222-3333-4444-555555555555", + ) + uuid_re = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) + assert uuid_re.match(data["id"]) + + +class TestBuildCourseMd: + def test_has_frontmatter(self): + md = build_course_md("btc101", "en") + assert md.startswith("---\n") + assert "\n---\n" in md + + def test_has_separator(self): + md = build_course_md("btc101", "en") + assert "\n+++\n" in md + + def test_has_part_and_chapter(self): + md = build_course_md("btc101", "en") + assert "# Part 1" in md + assert "## Chapter 1" in md + assert "" in md + assert "" in md + + def test_has_frontmatter_fields(self): + md = build_course_md("btc101", "en") + assert "name:" in md + assert "goal:" in md + assert "objectives:" in md + + def test_references_course_id(self): + md = build_course_md("btc101", "en") + assert "BTC101" in md + + +# ---- CLI integration tests ---- + + +class TestNewCourseCommand: + """Integration tests using CliRunner against the real repo.""" + + PROF_UUID = "2e1b5182-567e-453a-af29-36009340ff02" # existing professor + + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def clean_course(self, repo_root): + """Fixture that cleans up any test course after the test.""" + course_dir = repo_root / "courses" / "btc199" + yield course_dir + # Cleanup + if course_dir.exists(): + import shutil + + shutil.rmtree(course_dir) + + def test_non_interactive_creates_files(self, runner, repo_root, clean_course): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 0, result.output + assert clean_course.exists() + assert (clean_course / "course.yml").exists() + assert (clean_course / "en.md").exists() + assert (clean_course / "assets").is_dir() + assert (clean_course / "assets" / ".gitkeep").exists() + + def test_course_yml_valid_structure(self, runner, repo_root, clean_course): + runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + with open(clean_course / "course.yml") as f: + data = yaml.safe_load(f) + # Check required schema fields + assert "id" in data + assert "topic" in data + assert "subtopic" in data + assert "level" in data + assert "hours" in data + assert "professors_id" in data + assert "original_language" in data + assert "proofreading" in data + assert data["topic"] == "bitcoin" + assert data["level"] == "beginner" + # UUID format + uuid_re = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + ) + assert uuid_re.match(data["id"]) + + def test_course_md_structure(self, runner, repo_root, clean_course): + runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + md = (clean_course / "en.md").read_text() + assert md.startswith("---\n") + assert "\n+++\n" in md + assert "" in md + assert "" in md + assert "name:" in md + assert "goal:" in md + assert "objectives:" in md + + def test_json_output(self, runner, repo_root, clean_course): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + "--json", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["course_id"] == "btc199" + assert "uuid" in data + assert "files" in data + assert len(data["files"]) == 3 + + def test_invalid_course_id_rejected(self, runner): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "INVALID", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 1 + assert "Invalid course ID" in result.output + + def test_invalid_topic_rejected(self, runner): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "nosuch", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 1 + assert "invalid topic" in result.output + + def test_invalid_level_rejected(self, runner): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "grandmaster", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 1 + assert "invalid level" in result.output + + def test_invalid_language_rejected(self, runner): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "zz", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 1 + assert "invalid language" in result.output + + def test_invalid_professor_uuid_rejected(self, runner): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", "not-a-uuid", + ], + ) + assert result.exit_code == 1 + assert "invalid professor ID" in result.output + + def test_duplicate_course_rejected(self, runner, repo_root, clean_course): + # Create the first time + runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + # Try again + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc199", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 1 + assert "already exists" in result.output + + def test_unknown_discipline_code_rejected(self, runner): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "xyz101", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 1 + assert "Unknown discipline" in result.output + + def test_number_out_of_range_rejected(self, runner): + result = runner.invoke( + cli, + [ + "new", "course", + "--id", "btc001", + "--topic", "bitcoin", + "--subtopic", "bitcoin", + "--level", "beginner", + "--lang", "en", + "--professor-id", self.PROF_UUID, + ], + ) + assert result.exit_code == 1 + assert "out of range" in result.output diff --git a/scripts/bec/tests/test_new_content.py b/scripts/bec/tests/test_new_content.py new file mode 100644 index 00000000000..3d431733687 --- /dev/null +++ b/scripts/bec/tests/test_new_content.py @@ -0,0 +1,618 @@ +"""Tests for bec new tutorial/professor/event/resource commands (Phase 5).""" + +from __future__ import annotations + +import json +import re +import shutil +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from bec.cli import cli +from bec.commands.new import ( + RESOURCE_TYPE_KEYS, + _PLACEHOLDER_WEBP, + _validate_slug, + build_event_yml, + build_professor_lang_yml, + build_professor_yml, + build_tutorial_md, + build_tutorial_yml, + prompt_enum, +) + +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +PROF_UUID = "2e1b5182-567e-453a-af29-36009340ff02" + + +# ---- Slug validation ---- + + +class TestValidateSlug: + def test_valid_slugs(self): + assert _validate_slug("my-tutorial") is None + assert _validate_slug("sparrow") is None + assert _validate_slug("btc-101") is None + assert _validate_slug("test-event-2025") is None + + def test_invalid_slugs(self): + assert _validate_slug("a") is not None # too short + assert _validate_slug("My-Tutorial") is not None # uppercase + assert _validate_slug("-leading") is not None # leading hyphen + + +# ---- prompt_enum ---- + + +class TestPromptEnum: + def test_case_insensitive_returns_canonical(self): + from unittest.mock import patch + + with patch("click.prompt", return_value="cc-by-sa-v4"): + assert prompt_enum("license", ["CC-BY-SA-V4", "MIT"]) == "CC-BY-SA-V4" + with patch("click.prompt", return_value="MIT"): + assert prompt_enum("license", ["CC-BY-SA-V4", "MIT"]) == "MIT" + + def test_invalid_reprompts(self): + from unittest.mock import patch + + with patch("click.prompt", side_effect=["nope", "mit"]): + assert prompt_enum("license", ["CC-BY-SA-V4", "MIT"]) == "MIT" + + +# ---- Placeholder WebP ---- + + +class TestPlaceholderWebp: + def test_valid_webp_structure(self): + data = _PLACEHOLDER_WEBP + assert data[:4] == b"RIFF" + assert int.from_bytes(data[4:8], "little") == len(data) - 8 + assert data[8:12] == b"WEBP" + assert data[12:16] == b"VP8L" + vp8l_size = int.from_bytes(data[16:20], "little") + assert len(data) == 20 + vp8l_size + (vp8l_size % 2) + assert data[20] == 0x2F # VP8L signature byte + + def test_decodable(self): + import subprocess + import tempfile + + if shutil.which("dwebp") is None: + pytest.skip("dwebp not available") + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "ph.webp" + src.write_bytes(_PLACEHOLDER_WEBP) + result = subprocess.run( + ["dwebp", str(src), "-o", str(Path(tmp) / "ph.png")], + capture_output=True, + ) + assert result.returncode == 0, result.stderr.decode() + + +# ---- Tutorial unit tests ---- + + +class TestBuildTutorialYml: + def test_required_fields(self): + data = build_tutorial_yml( + tutorial_uuid="aaaa-bbbb-cccc-dddd", + level="beginner", + schema_category="desktop", + professor_id=PROF_UUID, + license_type="CC-BY-SA-V4", + lang="en", + ) + assert data["id"] == "aaaa-bbbb-cccc-dddd" + assert data["level"] == "beginner" + assert data["category"] == "desktop" + assert data["professor_id"] == PROF_UUID + assert data["license"] == "CC-BY-SA-V4" + assert data["original_language"] == "en" + assert len(data["proofreading"]) == 1 + assert data["proofreading"][0]["language"] == "en" + + def test_tags_default(self): + data = build_tutorial_yml("x", "beginner", "desktop", PROF_UUID, "CC-BY-SA-V4", "en") + assert data["tags"] == ["software"] + + +class TestBuildTutorialMd: + def test_has_frontmatter(self): + md = build_tutorial_md("my-tuto") + assert md.startswith("---\n") + assert "\n---\n" in md + + def test_has_cover(self): + md = build_tutorial_md("my-tuto") + assert "![cover](assets/cover.webp)" in md + + def test_has_required_frontmatter_fields(self): + md = build_tutorial_md("my-tuto") + assert "name:" in md + assert "description:" in md + + def test_references_tutorial_id(self): + md = build_tutorial_md("my-tuto") + assert "my-tuto" in md + + +# ---- Professor unit tests ---- + + +class TestBuildProfessorYml: + def test_required_fields(self): + data = build_professor_yml("test-uuid", "Satoshi Nakamoto") + assert data["id"] == "test-uuid" + assert data["name"] == "Satoshi Nakamoto" + assert "links" in data + + def test_has_tags(self): + data = build_professor_yml("test-uuid", "Test") + assert "tags" in data + + +class TestBuildProfessorLangYml: + def test_required_fields(self): + data = build_professor_lang_yml() + assert "bio" in data + assert "short_bio" in data + + +# ---- Event unit tests ---- + + +class TestBuildEventYml: + def test_required_fields(self): + data = build_event_yml( + event_uuid="test-uuid", + name="Bitcoin Paris 2025", + event_type="meetup", + start_date="2025-06-15 09:00:00", + end_date="2025-06-15 17:00:00", + timezone="Europe/Paris", + city="Paris, France", + lang="en", + ) + assert data["id"] == "test-uuid" + assert data["name"] == "Bitcoin Paris 2025" + assert data["type"] == "meetup" + assert data["start_date"] == "2025-06-15 09:00:00" + assert data["end_date"] == "2025-06-15 17:00:00" + assert data["timezone"] == "Europe/Paris" + assert data["address_city_country"] == "Paris, France" + assert data["language"] == ["en"] + assert "links" in data + assert "tags" in data + + +# ---- CLI integration tests: Tutorial ---- + + +class TestNewTutorialCommand: + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def clean_tutorial(self, repo_root): + tuto_dir = repo_root / "tutorials" / "wallet" / "test-tuto" + yield tuto_dir + if tuto_dir.exists(): + shutil.rmtree(tuto_dir) + + def test_non_interactive_creates_files(self, runner, clean_tutorial): + result = runner.invoke(cli, [ + "new", "tutorial", + "--category", "wallet", + "--id", "test-tuto", + "--lang", "en", + "--level", "beginner", + "--professor-id", PROF_UUID, + "--license", "CC-BY-SA-V4", + "--tool-type", "desktop", + ]) + assert result.exit_code == 0, result.output + assert clean_tutorial.exists() + assert (clean_tutorial / "tutorial.yml").exists() + assert (clean_tutorial / "en.md").exists() + assert (clean_tutorial / "assets").is_dir() + assert (clean_tutorial / "assets" / "cover.webp").exists() + + def test_tutorial_yml_valid(self, runner, clean_tutorial): + runner.invoke(cli, [ + "new", "tutorial", + "--category", "wallet", "--id", "test-tuto", "--lang", "en", + "--level", "intermediate", "--professor-id", PROF_UUID, + "--license", "CC-BY-SA-V4", "--tool-type", "hardware", + ]) + with open(clean_tutorial / "tutorial.yml") as f: + data = yaml.safe_load(f) + assert UUID_RE.match(data["id"]) + assert data["level"] == "intermediate" + assert data["category"] == "hardware" + assert data["professor_id"] == PROF_UUID + assert data["license"] == "CC-BY-SA-V4" + assert data["original_language"] == "en" + + def test_tutorial_md_structure(self, runner, clean_tutorial): + runner.invoke(cli, [ + "new", "tutorial", + "--category", "wallet", "--id", "test-tuto", "--lang", "en", + "--level", "beginner", "--professor-id", PROF_UUID, + "--license", "CC-BY-SA-V4", "--tool-type", "desktop", + ]) + md = (clean_tutorial / "en.md").read_text() + assert md.startswith("---\n") + assert "name:" in md + assert "description:" in md + assert "![cover](assets/cover.webp)" in md + + def test_json_output(self, runner, clean_tutorial): + result = runner.invoke(cli, [ + "new", "tutorial", + "--category", "wallet", "--id", "test-tuto", "--lang", "en", + "--level", "beginner", "--professor-id", PROF_UUID, + "--license", "CC-BY-SA-V4", "--tool-type", "desktop", + "--json", + ]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["type"] == "tutorial" + assert data["id"] == "test-tuto" + assert "uuid" in data + assert "files" in data + assert data["folder_category"] == "wallet" + + def test_invalid_category_rejected(self, runner): + result = runner.invoke(cli, [ + "new", "tutorial", + "--category", "nosuch", + "--id", "test-tuto", "--lang", "en", + "--level", "beginner", "--professor-id", PROF_UUID, + "--license", "CC-BY-SA-V4", "--tool-type", "desktop", + ]) + assert result.exit_code == 1 + assert "invalid tutorial category" in result.output + + def test_duplicate_rejected(self, runner, clean_tutorial): + args = [ + "new", "tutorial", + "--category", "wallet", "--id", "test-tuto", "--lang", "en", + "--level", "beginner", "--professor-id", PROF_UUID, + "--license", "CC-BY-SA-V4", "--tool-type", "desktop", + ] + runner.invoke(cli, args) + result = runner.invoke(cli, args) + assert result.exit_code == 1 + assert "already exists" in result.output + + def test_validates_structurally(self, runner, clean_tutorial): + """Scaffolded tutorial passes bec validate.""" + runner.invoke(cli, [ + "new", "tutorial", + "--category", "wallet", "--id", "test-tuto", "--lang", "en", + "--level", "beginner", "--professor-id", PROF_UUID, + "--license", "CC-BY-SA-V4", "--tool-type", "desktop", + ]) + result = runner.invoke(cli, ["validate", "tutorials/wallet/test-tuto"]) + assert result.exit_code == 0, result.output + + +# ---- CLI integration tests: Professor ---- + + +class TestNewProfessorCommand: + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def clean_professor(self, repo_root): + prof_dir = repo_root / "professors" / "test-prof" + yield prof_dir + if prof_dir.exists(): + shutil.rmtree(prof_dir) + + def test_non_interactive_creates_files(self, runner, clean_professor): + result = runner.invoke(cli, [ + "new", "professor", + "--id", "test-prof", + "--name", "Test Professor", + "--lang", "en", + ]) + assert result.exit_code == 0, result.output + assert clean_professor.exists() + assert (clean_professor / "professor.yml").exists() + assert (clean_professor / "en.yml").exists() + assert (clean_professor / "assets").is_dir() + assert (clean_professor / "assets" / ".gitkeep").exists() + + def test_professor_yml_valid(self, runner, clean_professor): + runner.invoke(cli, [ + "new", "professor", + "--id", "test-prof", "--name", "Satoshi Nakamoto", "--lang", "en", + ]) + with open(clean_professor / "professor.yml") as f: + data = yaml.safe_load(f) + assert UUID_RE.match(data["id"]) + assert data["name"] == "Satoshi Nakamoto" + + def test_lang_yml_structure(self, runner, clean_professor): + runner.invoke(cli, [ + "new", "professor", + "--id", "test-prof", "--name", "Test", "--lang", "en", + ]) + with open(clean_professor / "en.yml") as f: + data = yaml.safe_load(f) + assert "bio" in data + assert "short_bio" in data + + def test_json_output(self, runner, clean_professor): + result = runner.invoke(cli, [ + "new", "professor", + "--id", "test-prof", "--name", "Test", "--lang", "en", + "--json", + ]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["type"] == "professor" + assert data["id"] == "test-prof" + + def test_duplicate_rejected(self, runner, clean_professor): + args = ["new", "professor", "--id", "test-prof", "--name", "T", "--lang", "en"] + runner.invoke(cli, args) + result = runner.invoke(cli, args) + assert result.exit_code == 1 + assert "already exists" in result.output + + def test_validates_structurally(self, runner, clean_professor): + runner.invoke(cli, [ + "new", "professor", "--id", "test-prof", "--name", "Test", "--lang", "en", + ]) + result = runner.invoke(cli, ["validate", "professors/test-prof"]) + assert result.exit_code == 0, result.output + + +# ---- CLI integration tests: Event ---- + + +class TestNewEventCommand: + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def clean_event(self, repo_root): + event_dir = repo_root / "events" / "test-event-2025" + yield event_dir + if event_dir.exists(): + shutil.rmtree(event_dir) + + def _default_args(self): + return [ + "new", "event", + "--id", "test-event-2025", + "--name", "Test Event 2025", + "--type", "meetup", + "--start-date", "2025-06-15 09:00:00", + "--end-date", "2025-06-15 17:00:00", + "--timezone", "Europe/Paris", + "--city", "Paris, France", + "--lang", "en", + ] + + def test_non_interactive_creates_files(self, runner, clean_event): + result = runner.invoke(cli, self._default_args()) + assert result.exit_code == 0, result.output + assert clean_event.exists() + assert (clean_event / "event.yml").exists() + assert (clean_event / "assets").is_dir() + assert (clean_event / "assets" / ".gitkeep").exists() + + def test_event_yml_valid(self, runner, clean_event): + runner.invoke(cli, self._default_args()) + with open(clean_event / "event.yml") as f: + data = yaml.safe_load(f) + assert UUID_RE.match(data["id"]) + assert data["name"] == "Test Event 2025" + assert data["type"] == "meetup" + assert data["timezone"] == "Europe/Paris" + assert data["address_city_country"] == "Paris, France" + assert data["language"] == ["en"] + + def test_json_output(self, runner, clean_event): + result = runner.invoke(cli, self._default_args() + ["--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["type"] == "event" + assert data["id"] == "test-event-2025" + + def test_schema_enum_type_accepted(self, runner, clean_event): + """Event types come from the event schema enum.""" + args = self._default_args() + args[args.index("meetup")] = "lecture" + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + + def test_invalid_event_type_rejected(self, runner): + result = runner.invoke(cli, [ + "new", "event", + "--id", "test-event-2025", "--name", "T", "--type", "party", + "--start-date", "2025-01-01 09:00:00", "--end-date", "2025-01-01 17:00:00", + "--timezone", "UTC", "--city", "X", "--lang", "en", + ]) + assert result.exit_code == 1 + assert "invalid event type" in result.output + + def test_duplicate_rejected(self, runner, clean_event): + runner.invoke(cli, self._default_args()) + result = runner.invoke(cli, self._default_args()) + assert result.exit_code == 1 + assert "already exists" in result.output + + def test_validates_structurally(self, runner, clean_event): + """Scaffolded event passes bec validate (exit 0 or 2 for warnings).""" + runner.invoke(cli, self._default_args()) + result = runner.invoke(cli, ["validate", "events/test-event-2025"]) + assert result.exit_code in (0, 2), result.output + + +# ---- CLI integration tests: Resource ---- + + +class TestNewResourceCommand: + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def clean_book(self, repo_root): + d = repo_root / "resources" / "books" / "test-book" + yield d + if d.exists(): + shutil.rmtree(d) + + @pytest.fixture + def clean_podcast(self, repo_root): + d = repo_root / "resources" / "podcasts" / "test-pod" + yield d + if d.exists(): + shutil.rmtree(d) + + def test_book_creates_files(self, runner, clean_book): + result = runner.invoke(cli, [ + "new", "resource", "--type", "book", "--id", "test-book", "--lang", "en", + ]) + assert result.exit_code == 0, result.output + assert clean_book.exists() + assert (clean_book / "book.yml").exists() + assert (clean_book / "en.yml").exists() + assert (clean_book / "assets").is_dir() + assert (clean_book / "assets" / ".gitkeep").exists() + + def test_book_yml_valid(self, runner, clean_book): + runner.invoke(cli, [ + "new", "resource", "--type", "book", "--id", "test-book", "--lang", "en", + ]) + with open(clean_book / "book.yml") as f: + data = yaml.safe_load(f) + assert data["author"] == "TODO: Author Name" + assert data["level"] == "beginner" + assert "tags" in data + + def test_book_content_yml(self, runner, clean_book): + runner.invoke(cli, [ + "new", "resource", "--type", "book", "--id", "test-book", "--lang", "en", + ]) + with open(clean_book / "en.yml") as f: + data = yaml.safe_load(f) + assert "title" in data + assert "publication_year" in data + assert "cover" in data + assert "original" in data + assert "description" in data + + def test_podcast_creates_files(self, runner, clean_podcast): + result = runner.invoke(cli, [ + "new", "resource", "--type", "podcast", "--id", "test-pod", "--lang", "en", + ]) + assert result.exit_code == 0, result.output + assert clean_podcast.exists() + assert (clean_podcast / "podcast.yml").exists() + # Podcasts have no content schema -> no language file + assert not (clean_podcast / "en.yml").exists() + + def test_podcast_yml_valid(self, runner, clean_podcast): + runner.invoke(cli, [ + "new", "resource", "--type", "podcast", "--id", "test-pod", "--lang", "en", + ]) + with open(clean_podcast / "podcast.yml") as f: + data = yaml.safe_load(f) + assert UUID_RE.match(data["id"]) + assert data["name"] == "TODO: Podcast Name" + assert "host" in data + assert "links" in data + + def test_invalid_type_rejected(self, runner): + result = runner.invoke(cli, [ + "new", "resource", "--type", "invalid", "--id", "test", "--lang", "en", + ]) + assert result.exit_code == 1 + assert "invalid resource type" in result.output + + def test_json_output(self, runner, clean_book): + result = runner.invoke(cli, [ + "new", "resource", "--type", "book", "--id", "test-book", "--lang", "en", + "--json", + ]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["resource_type"] == "book" + assert data["id"] == "test-book" + assert "files" in data + + def test_duplicate_rejected(self, runner, clean_book): + args = ["new", "resource", "--type", "book", "--id", "test-book", "--lang", "en"] + runner.invoke(cli, args) + result = runner.invoke(cli, args) + assert result.exit_code == 1 + assert "already exists" in result.output + + def test_book_validates(self, runner, clean_book): + runner.invoke(cli, [ + "new", "resource", "--type", "book", "--id", "test-book", "--lang", "en", + ]) + result = runner.invoke(cli, ["validate", "resources/books/test-book"]) + assert result.exit_code == 0, result.output + + def test_podcast_validates(self, runner, clean_podcast): + runner.invoke(cli, [ + "new", "resource", "--type", "podcast", "--id", "test-pod", "--lang", "en", + ]) + result = runner.invoke(cli, ["validate", "resources/podcasts/test-pod"]) + assert result.exit_code == 0, result.output + + +# ---- Resource type coverage ---- + + +class TestResourceTypeCoverage: + """Verify all resource types can be scaffolded.""" + + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture(params=sorted(RESOURCE_TYPE_KEYS)) + def resource_type_and_cleanup(self, request, repo_root): + rtype = request.param + from bec.commands.new import _resource_dir_prefix + from bec.lib.content_types import load_registry + + registry = load_registry(repo_root) + prefix = _resource_dir_prefix(registry, rtype) + test_id = f"test-{rtype}-scaffold" + d = repo_root / prefix / test_id + yield rtype, test_id, d + if d.exists(): + shutil.rmtree(d) + + def test_scaffold_all_types(self, runner, resource_type_and_cleanup): + rtype, test_id, expected_dir = resource_type_and_cleanup + result = runner.invoke(cli, [ + "new", "resource", + "--type", rtype, + "--id", test_id, + "--lang", "en", + "--json", + ]) + assert result.exit_code == 0, f"Failed for {rtype}: {result.output}" + assert expected_dir.exists(), f"Dir not created for {rtype}" + + data = json.loads(result.output) + assert data["resource_type"] == rtype + assert data["id"] == test_id diff --git a/scripts/bec/tests/test_proofread.py b/scripts/bec/tests/test_proofread.py new file mode 100644 index 00000000000..80bb78b3688 --- /dev/null +++ b/scripts/bec/tests/test_proofread.py @@ -0,0 +1,678 @@ +"""Tests for bec proofread commands.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from bec.cli import cli +from bec.lib.proofreading import ( + BASE_FEE, + LANGUAGE_FACTORS, + MAX_PAID_ITERATIONS, + METADATA_FILES, + add_contributor, + compute_reward, + count_words, + evaluate_reward_for_language, + find_metadata_file, + get_contributor_count, + get_difficulty_factor, + get_language_factor, + get_proofreading_entries, + get_status_matrix, + recalculate_rewards, + update_metadata_file, +) +from bec.lib.yaml_utils import load_yaml + + +# ---- Fixtures ----------------------------------------------------------------- + +@pytest.fixture +def course_dir(tmp_path): + """Create a minimal course directory with proofreading metadata.""" + course = tmp_path / "courses" / "btc101" + course.mkdir(parents=True) + + # content-types.yml marker (for repo root detection) + (tmp_path / "content-types.yml").write_text("content_types: {}\n") + + # course.yml with proofreading metadata + (course / "course.yml").write_text( + "id: 00000000-0000-0000-0000-000000000001\n" + "level: beginner\n" + "original_language: en\n" + "proofreading:\n" + " - language: en\n" + " last_contribution_date:\n" + " urgency: 1\n" + " contributor_names:\n" + " reward: 5.1\n" + " - language: fr\n" + " last_contribution_date: '2025-06-01'\n" + " urgency: 1\n" + " contributor_names:\n" + " - alice\n" + " reward: 2.55\n" + " - language: ja\n" + " last_contribution_date:\n" + " urgency: 2\n" + " contributor_names:\n" + " reward: 25.1\n" + ) + + # en.md content file (original language) + (course / "en.md").write_text("This is a test course with some words. " * 125) # ~1000 words + + return course + + +@pytest.fixture +def glossary_dir(tmp_path): + """Create a minimal glossary word directory.""" + word = tmp_path / "resources" / "glossary" / "bitcoin" + word.mkdir(parents=True) + + (tmp_path / "content-types.yml").write_text("content_types: {}\n") + + (word / "word.yml").write_text( + "en_word: bitcoin\n" + "original_language: en\n" + "proofreading:\n" + " - language: en\n" + " last_contribution_date:\n" + " urgency: 1\n" + " contributor_names:\n" + " reward: 0.2\n" + " - language: fr\n" + " last_contribution_date:\n" + " urgency: 1\n" + " contributor_names:\n" + " reward: 0.2\n" + ) + + (word / "en.md").write_text("Bitcoin is a peer-to-peer electronic cash system.\n") + + return word + + +@pytest.fixture +def tutorial_dir(tmp_path): + """Create a minimal tutorial directory.""" + tuto = tmp_path / "tutorials" / "wallet" / "sparrow" + tuto.mkdir(parents=True) + + (tmp_path / "content-types.yml").write_text("content_types: {}\n") + + (tuto / "tutorial.yml").write_text( + "id: 00000000-0000-0000-0000-000000000002\n" + "original_language: en\n" + "proofreading:\n" + " - language: en\n" + " last_contribution_date:\n" + " urgency: 1\n" + " contributor_names:\n" + " reward: 3.0\n" + " - language: es\n" + " last_contribution_date:\n" + " urgency: 1\n" + " contributor_names:\n" + " reward: 3.0\n" + ) + + (tuto / "en.md").write_text("Tutorial content here. " * 150) + + return tuto + + +# ---- Unit tests: compute_reward ----------------------------------------------- + +class TestComputeReward: + def test_basic_reward(self): + # 1000 words, factor 1.0, urgency 1, iteration 0 + r = compute_reward(1000, 1.0, 1, 0) + expected = (1 * (0.001 * 1000 * 1.0) + 0.1) * 1.0 # 1.1 + assert r == round(expected, 2) + + def test_halves_with_iteration(self): + r0 = compute_reward(1000, 1.0, 1, 0) + r1 = compute_reward(1000, 1.0, 1, 1) + assert r1 == round(r0 / 2, 2) + + def test_zero_after_max_iterations(self): + r = compute_reward(1000, 1.0, 1, MAX_PAID_ITERATIONS) + assert r == 0.0 + + def test_language_factor(self): + r_en = compute_reward(1000, 1.0, 1, 0) + r_ja = compute_reward(1000, 2.5, 1, 0) + assert r_ja > r_en + + def test_urgency_multiplier(self): + r_normal = compute_reward(1000, 1.0, 1, 0) + r_urgent = compute_reward(1000, 1.0, 5, 0) + assert r_urgent > r_normal + + def test_zero_words(self): + r = compute_reward(0, 1.0, 1, 0) + assert r == round(BASE_FEE, 2) + + +# ---- Unit tests: get_difficulty_factor ----------------------------------------- + +class TestGetDifficultyFactor: + def test_glossary(self): + data = {"en_word": "bitcoin"} + assert get_difficulty_factor(data) == 3.0 + + def test_beginner(self): + data = {"level": "beginner"} + assert get_difficulty_factor(data) == 1.0 + + def test_intermediate(self): + data = {"level": "intermediate"} + assert get_difficulty_factor(data) == 2.0 + + def test_advanced(self): + data = {"level": "advanced"} + assert get_difficulty_factor(data) == 3.0 + + def test_expert(self): + data = {"level": "expert"} + assert get_difficulty_factor(data) == 4.0 + + def test_no_level(self): + data = {} + assert get_difficulty_factor(data) == 1.0 + + +# ---- Unit tests: find_metadata_file ------------------------------------------- + +class TestFindMetadataFile: + def test_finds_course_yml(self, course_dir): + result = find_metadata_file(course_dir) + assert result is not None + assert result.name == "course.yml" + + def test_finds_tutorial_yml(self, tutorial_dir): + result = find_metadata_file(tutorial_dir) + assert result is not None + assert result.name == "tutorial.yml" + + def test_finds_word_yml(self, glossary_dir): + result = find_metadata_file(glossary_dir) + assert result is not None + assert result.name == "word.yml" + + def test_finds_project_yml(self, tmp_path): + project = tmp_path / "resources" / "projects" / "acinq" + project.mkdir(parents=True) + (project / "project.yml").write_text("original_language: en\n") + result = find_metadata_file(project) + assert result is not None + assert result.name == "project.yml" + + def test_builder_yml_is_not_a_metadata_file(self): + assert "builder.yml" not in METADATA_FILES + + def test_returns_none_for_empty_dir(self, tmp_path): + assert find_metadata_file(tmp_path) is None + + +# ---- Unit tests: get_language_factor ------------------------------------------- + +class TestGetLanguageFactor: + def test_case_insensitive_lookup(self): + assert get_language_factor("zh-Hans") == 2.0 + assert get_language_factor("ZH-HANS") == 2.0 + assert get_language_factor("nb-NO") == 1.5 + assert get_language_factor("sr-latn") == 2.0 + + def test_unknown_language_defaults_to_one(self): + assert get_language_factor("xx") == 1.0 + + +# ---- Unit tests: proofreading entry helpers ------------------------------------ + +class TestProofreadingEntries: + def test_get_entries(self, course_dir): + data = load_yaml(course_dir / "course.yml") + entries = get_proofreading_entries(data) + assert len(entries) == 3 + assert entries[0]["language"] == "en" + + def test_get_entries_empty(self): + data = {"id": "test"} + entries = get_proofreading_entries(data) + assert entries == [] + + def test_contributor_count_none(self): + entry = {"contributor_names": None} + assert get_contributor_count(entry) == 0 + + def test_contributor_count_list(self): + entry = {"contributor_names": ["alice", "bob"]} + assert get_contributor_count(entry) == 2 + + +# ---- Unit tests: add_contributor ----------------------------------------------- + +class TestAddContributor: + def test_add_new_contributor(self, course_dir): + data = load_yaml(course_dir / "course.yml") + success, msg = add_contributor(data, "en", "bob") + assert success is True + assert "bob" in msg + + # Verify the data was modified + entry = next(e for e in data["proofreading"] if e["language"] == "en") + assert "bob" in entry["contributor_names"] + assert entry["last_contribution_date"] is not None + + def test_add_to_existing_list(self, course_dir): + data = load_yaml(course_dir / "course.yml") + success, msg = add_contributor(data, "fr", "bob") + assert success is True + entry = next(e for e in data["proofreading"] if e["language"] == "fr") + assert "alice" in entry["contributor_names"] + assert "bob" in entry["contributor_names"] + + def test_duplicate_contributor(self, course_dir): + data = load_yaml(course_dir / "course.yml") + success, msg = add_contributor(data, "fr", "alice") + assert success is False + assert "already" in msg.lower() + + def test_invalid_language(self, course_dir): + data = load_yaml(course_dir / "course.yml") + success, msg = add_contributor(data, "xx", "bob") + assert success is False + assert "not found" in msg.lower() + + +# ---- Unit tests: evaluate_reward_for_language ---------------------------------- + +class TestEvaluateReward: + def test_reward_for_new_language(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + info = evaluate_reward_for_language(metadata, data, "en") + assert info["iteration"] == 0 + assert info["reward"] > 0 + assert info["remaining_paid_proofreadings"] == 2 + + def test_reward_after_one_iteration(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + info = evaluate_reward_for_language(metadata, data, "fr") + assert info["iteration"] == 1 + assert info["remaining_paid_proofreadings"] == 1 + + def test_invalid_language(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + info = evaluate_reward_for_language(metadata, data, "xx") + assert "error" in info + + def test_language_factor_case_insensitive(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + data["proofreading"].append({ + "language": "zh-Hans", + "urgency": 1, + "contributor_names": None, + "reward": 0, + }) + info = evaluate_reward_for_language(metadata, data, "ZH-HANS") + assert info["language_factor"] == 2.0 + + +# ---- Unit tests: get_status_matrix -------------------------------------------- + +class TestStatusMatrix: + def test_returns_all_languages(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + matrix = get_status_matrix(metadata, data) + assert len(matrix) == 3 + langs = [e["language"] for e in matrix] + assert "en" in langs + assert "fr" in langs + assert "ja" in langs + + def test_identifies_original(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + matrix = get_status_matrix(metadata, data) + en_entry = next(e for e in matrix if e["language"] == "en") + assert en_entry["is_original"] is True + + def test_no_proofreading_section(self): + data = {"id": "test"} + matrix = get_status_matrix(Path("/fake"), data) + assert matrix == [] + + +# ---- Unit tests: update_metadata_file ------------------------------------------ + +class TestUpdateMetadataFile: + def test_preserves_non_proofreading_fields(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + + # Modify proofreading + add_contributor(data, "en", "testuser") + update_metadata_file(metadata, data) + + # Re-read and verify non-proofreading fields preserved + updated = load_yaml(metadata) + assert updated["id"] == "00000000-0000-0000-0000-000000000001" + assert updated["level"] == "beginner" + assert updated["original_language"] == "en" + + def test_writes_contributor_correctly(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + add_contributor(data, "en", "testuser") + update_metadata_file(metadata, data) + + updated = load_yaml(metadata) + en_entry = next(e for e in updated["proofreading"] if e["language"] == "en") + assert "testuser" in en_entry["contributor_names"] + + def test_null_urgency_and_reward_stay_null(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + data["proofreading"][0]["urgency"] = None + data["proofreading"][0]["reward"] = None + update_metadata_file(metadata, data) + + assert "None" not in metadata.read_text() + updated = load_yaml(metadata) + assert updated["proofreading"][0]["urgency"] is None + assert updated["proofreading"][0]["reward"] is None + + +# ---- Unit tests: recalculate_rewards ------------------------------------------- + +class TestRecalculateRewards: + def test_updates_all_rewards(self, course_dir): + metadata = course_dir / "course.yml" + data = load_yaml(metadata) + + # Set all rewards to 0 first + for entry in data["proofreading"]: + entry["reward"] = 0 + + recalculate_rewards(metadata, data) + + for entry in data["proofreading"]: + lang = entry["language"] + iteration = len(entry.get("contributor_names") or []) + if iteration < MAX_PAID_ITERATIONS: + assert entry["reward"] > 0, f"Expected positive reward for {lang}" + + +# ---- CLI integration tests: proofread update ----------------------------------- + +class TestProofreadUpdateCLI: + def test_update_adds_contributor(self, course_dir, monkeypatch): + # cwd must be the tmp repo root BEFORE any invoke, otherwise repo + # root detection escapes to the real repo and mutates real content. + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "update", + "--path", "courses/btc101", + "--lang", "en", + "--contributor", "newuser", + ], catch_exceptions=False) + + assert result.exit_code == 0 + assert "newuser" in result.output + + # Verify file was updated + data = load_yaml(course_dir / "course.yml") + en_entry = next(e for e in data["proofreading"] if e["language"] == "en") + assert "newuser" in en_entry["contributor_names"] + + def test_update_json_output(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "update", + "--path", "courses/btc101", + "--lang", "ja", + "--contributor", "translator1", + "--json", + ], catch_exceptions=False) + + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["success"] is True + assert parsed["contributor"] == "translator1" + + def test_update_duplicate_fails(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "update", + "--path", "courses/btc101", + "--lang", "fr", + "--contributor", "alice", + ], catch_exceptions=False) + + assert result.exit_code == 1 + + +# ---- CLI integration tests: proofread reward ----------------------------------- + +class TestProofreadRewardCLI: + def test_reward_all_languages(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "reward", + "--path", "courses/btc101", + ], catch_exceptions=False) + + assert result.exit_code == 0 + assert "en" in result.output + assert "fr" in result.output + assert "ja" in result.output + + def test_reward_single_language(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "reward", + "--path", "courses/btc101", + "--lang", "en", + ], catch_exceptions=False) + + assert result.exit_code == 0 + assert "Words:" in result.output + assert "Reward:" in result.output + + def test_reward_json_output(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "reward", + "--path", "courses/btc101", + "--json", + ], catch_exceptions=False) + + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert "rewards" in parsed + assert len(parsed["rewards"]) == 3 + + def test_reward_invalid_language(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "reward", + "--path", "courses/btc101", + "--lang", "xx", + ], catch_exceptions=False) + + assert result.exit_code == 1 + + def test_reward_missing_original_content_file(self, course_dir, monkeypatch): + (course_dir / "en.md").unlink() + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "reward", + "--path", "courses/btc101", + "--lang", "en", + ], catch_exceptions=False) + + assert result.exit_code == 1 + assert "Error:" in result.output + assert "Traceback" not in result.output + + +# ---- CLI integration tests: proofread batch-add -------------------------------- + +class TestProofreadBatchAddCLI: + def test_batch_add_single_path(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "batch-add", + "--contributor", "batchuser", + "--lang", "en", + "courses/btc101", + ], catch_exceptions=False) + + assert result.exit_code == 0 + assert "1 updated" in result.output + + def test_batch_add_json_output(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "batch-add", + "--contributor", "batchuser", + "--lang", "en", + "--json", + "courses/btc101", + ], catch_exceptions=False) + + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["success"] == 1 + assert parsed["contributor"] == "batchuser" + + def test_batch_add_multiple_paths(self, course_dir, tutorial_dir, monkeypatch): + # Both course and tutorial are under different tmp_paths, + # so we test with just one path but verify the count logic + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "batch-add", + "--contributor", "multi-user", + "--lang", "en", + "courses/btc101", + ], catch_exceptions=False) + + assert result.exit_code == 0 + assert "1 updated" in result.output + + +# ---- CLI integration tests: proofread status ----------------------------------- + +class TestProofreadStatusCLI: + def test_status_table(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "status", + "--path", "courses/btc101", + ], catch_exceptions=False) + + assert result.exit_code == 0 + assert "original" in result.output # en should show as original + assert "pending" in result.output # ja should show as pending + assert "1/2" in result.output # fr should show as 1/2 + + def test_status_json_output(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "status", + "--path", "courses/btc101", + "--json", + ], catch_exceptions=False) + + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["original_language"] == "en" + assert len(parsed["languages"]) == 3 + + # Verify structure of each entry + for lang_entry in parsed["languages"]: + assert "language" in lang_entry + assert "is_original" in lang_entry + assert "contributors" in lang_entry + assert "reward" in lang_entry + assert "remaining_paid_proofreadings" in lang_entry + + def test_status_invalid_path(self, course_dir, monkeypatch): + monkeypatch.chdir(course_dir.parent.parent) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "status", + "--path", "courses/nonexistent", + ], catch_exceptions=False) + + assert result.exit_code == 1 + + +# ---- Edge case tests ----------------------------------------------------------- + +class TestEdgeCases: + def test_no_proofreading_section(self, tmp_path, monkeypatch): + """Content without proofreading metadata.""" + course = tmp_path / "courses" / "test" + course.mkdir(parents=True) + (tmp_path / "content-types.yml").write_text("content_types: {}\n") + (course / "course.yml").write_text("id: test\noriginal_language: en\n") + (course / "en.md").write_text("content") + + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(cli, [ + "proofread", "status", + "--path", "courses/test", + ], catch_exceptions=False) + + assert result.exit_code == 1 + + def test_reward_formula_matches_original(self): + """Verify our formula matches the original proofreading.py formula.""" + # Original: (urgency * (0.001 * words * language_factor) + base_fee) * 2**(-iteration) + words = 5000 + lang_factor = 1.5 + urgency = 2 + iteration = 1 + expected = (urgency * (0.001 * words * lang_factor) + BASE_FEE) * 2 ** (-iteration) + expected = round(expected, 2) + assert compute_reward(words, lang_factor, urgency, iteration) == expected + + def test_language_factors_completeness(self): + """All languages in the original script should be in LANGUAGE_FACTORS.""" + expected = { + "en", "fr", "de", "es", "it", "cs", "vi", "ja", "pt", + "ru", "fi", "et", "id", "zh-hans", "uk", "nb-no", "pl", + "ro", "ha", "sr-latn", "hi", "zh-hant", "sw", "fa", "sv", + "nl", "tr", "ko", "rn", "bg", "th", + } + assert expected == set(LANGUAGE_FACTORS.keys()) + assert all(k == k.lower() for k in LANGUAGE_FACTORS) diff --git a/scripts/bec/tests/test_repo.py b/scripts/bec/tests/test_repo.py new file mode 100644 index 00000000000..e172048d7fc --- /dev/null +++ b/scripts/bec/tests/test_repo.py @@ -0,0 +1,40 @@ +"""Tests for bec.lib.repo.""" + +from pathlib import Path + +import pytest + +from bec.lib.repo import find_repo_root, resolve_content_path + + +def test_find_repo_root_from_repo(repo_root): + """Finding repo root from within the repo should succeed.""" + found = find_repo_root(repo_root) + assert found == repo_root + assert (found / "content-types.yml").is_file() + + +def test_find_repo_root_from_subdirectory(repo_root): + """Finding repo root from a deep subdirectory should work.""" + subdir = repo_root / "scripts" / "bec" / "src" + found = find_repo_root(subdir) + assert found == repo_root + + +def test_find_repo_root_not_found(tmp_path): + """Should raise FileNotFoundError when no repo root exists.""" + with pytest.raises(FileNotFoundError): + find_repo_root(tmp_path) + + +def test_resolve_content_path_relative(repo_root): + """Relative paths should be resolved against repo root.""" + result = resolve_content_path("courses/btc101", repo_root) + assert result == (repo_root / "courses" / "btc101").resolve() + + +def test_resolve_content_path_absolute(repo_root): + """Absolute paths should be returned as-is.""" + abs_path = repo_root / "courses" / "btc101" + result = resolve_content_path(str(abs_path), repo_root) + assert result == abs_path diff --git a/scripts/bec/tests/test_report.py b/scripts/bec/tests/test_report.py new file mode 100644 index 00000000000..0806c85b8fc --- /dev/null +++ b/scripts/bec/tests/test_report.py @@ -0,0 +1,1343 @@ +"""Tests for bec report commands.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from bec.cli import cli +from bec.commands.report import ( + _classify_image, + _extract_images_from_markdown, + _get_language_files, + _parse_course_structure, + _parse_video_coverage, + analyze_course_analytics, + analyze_image_translation, + analyze_proofreading, + analyze_translation_coverage, + analyze_video_deployment, + count_words, +) +from bec.lib.content_types import load_registry + + +# ---- Fixtures ----------------------------------------------------------------- + +@pytest.fixture +def mini_repo(tmp_path): + """Create a minimal repo structure with translatable content.""" + # content-types.yml marker + (tmp_path / "content-types.yml").write_text( + "content_types:\n" + " course:\n" + " name: Course\n" + " path_pattern: 'courses/{id}/'\n" + " metadata_file: course.yml\n" + " schema: schemas/course-scheme.json\n" + " has_markdown_content: true\n" + " has_quizzes: true\n" + " example: courses/btc101\n" + " tutorial:\n" + " name: Tutorial\n" + " path_pattern: 'tutorials/{category}/{id}/'\n" + " metadata_file: tutorial.yml\n" + " schema: schemas/tutorial-scheme.json\n" + " has_markdown_content: true\n" + " example: tutorials/wallet/sparrow\n" + " professor:\n" + " name: Professor\n" + " path_pattern: 'professors/{id}/'\n" + " metadata_file: professor.yml\n" + " schema: schemas/professor-scheme.json\n" + " content_uses_yml: true\n" + " has_markdown_content: false\n" + " example: professors/satoshi\n" + " event:\n" + " name: Event\n" + " path_pattern: 'events/{id}/'\n" + " metadata_file: event.yml\n" + " schema: schemas/event-scheme.json\n" + " has_markdown_content: false\n" + " example: events/conf-2025\n" + " book:\n" + " name: Book\n" + " path_pattern: 'resources/books/{id}/'\n" + " metadata_file: book.yml\n" + " schema: schemas/book-scheme.json\n" + " content_uses_yml: true\n" + " has_markdown_content: false\n" + " example: resources/books/mastering-bitcoin\n" + " channel:\n" + " name: Channel\n" + " path_pattern: 'resources/channels/{id}/'\n" + " metadata_file: channel.yml\n" + " schema: schemas/channel-scheme.json\n" + " has_markdown_content: false\n" + " example: resources/channels/robin\n" + "languages:\n" + " - en\n" + " - fr\n" + " - es\n" + "tutorial_categories:\n" + " - wallet\n" + "tags: []\n" + "discipline_codes: {}\n" + "level_range: {}\n" + ) + + # Course btc101 with en and fr + btc101 = tmp_path / "courses" / "btc101" + btc101.mkdir(parents=True) + (btc101 / "course.yml").write_text("id: abc\nlevel: beginner\n") + (btc101 / "en.md").write_text("# Bitcoin 101\n\nThis is the English version with some words here.") + (btc101 / "fr.md").write_text("# Bitcoin 101\n\nCeci est la version francaise.") + + # Course btc201 with en only + btc201 = tmp_path / "courses" / "btc201" + btc201.mkdir(parents=True) + (btc201 / "course.yml").write_text("id: def\nlevel: intermediate\n") + (btc201 / "en.md").write_text("# Bitcoin 201\n\nAdvanced bitcoin content here.") + + # Tutorial wallet/sparrow with en, fr, es + tuto = tmp_path / "tutorials" / "wallet" / "sparrow" + tuto.mkdir(parents=True) + (tuto / "tutorial.yml").write_text("id: ghi\nlevel: beginner\n") + (tuto / "en.md").write_text("# Sparrow Tutorial\n\nEnglish tutorial content.") + (tuto / "fr.md").write_text("# Sparrow Tutoriel\n\nContenu francais.") + (tuto / "es.md").write_text("# Tutorial Sparrow\n\nContenido en espanol.") + + # Professor with en.yml and fr.yml + prof = tmp_path / "professors" / "satoshi" + prof.mkdir(parents=True) + (prof / "professor.yml").write_text("id: jkl\n") + (prof / "en.yml").write_text("name: Satoshi\nbio: A mysterious figure.\n") + (prof / "fr.yml").write_text("name: Satoshi\nbio: Un personnage mysterieux.\n") + + # Event (no translations) + evt = tmp_path / "events" / "conf-2025" + evt.mkdir(parents=True) + (evt / "event.yml").write_text("id: mno\nname: Conference 2025\n") + + # Book with en.yml only + book = tmp_path / "resources" / "books" / "mastering-bitcoin" + book.mkdir(parents=True) + (book / "book.yml").write_text("id: pqr\n") + (book / "en.yml").write_text("name: Mastering Bitcoin\nauthor: Andreas\n") + + # Channel (no translations) + chan = tmp_path / "resources" / "channels" / "robin" + chan.mkdir(parents=True) + (chan / "channel.yml").write_text("id: stu\n") + + return tmp_path + + +# ---- Unit tests: count_words -------------------------------------------------- + +def test_count_words_basic(tmp_path): + f = tmp_path / "test.md" + f.write_text("Hello world foo bar baz") + assert count_words(f) == 5 + + +def test_count_words_empty(tmp_path): + f = tmp_path / "empty.md" + f.write_text("") + assert count_words(f) == 0 + + +def test_count_words_nonexistent(tmp_path): + f = tmp_path / "nope.md" + assert count_words(f) == 0 + + +# ---- Unit tests: _get_language_files ------------------------------------------- + +def test_get_language_files_md(mini_repo): + btc101 = mini_repo / "courses" / "btc101" + result = _get_language_files(btc101, "course") + assert "en" in result + assert "fr" in result + assert result["en"] is True + assert result["fr"] is True + + +def test_get_language_files_yml(mini_repo): + prof = mini_repo / "professors" / "satoshi" + result = _get_language_files(prof, "professor") + assert "en" in result + assert "fr" in result + # professor.yml should NOT be treated as a language file + assert "professor" not in result + + +def test_get_language_files_book(mini_repo): + book = mini_repo / "resources" / "books" / "mastering-bitcoin" + result = _get_language_files(book, "book") + assert "en" in result + assert "book" not in result + + +def test_get_language_files_event_returns_nothing(mini_repo): + """Events have no translatable files.""" + evt = mini_repo / "events" / "conf-2025" + result = _get_language_files(evt, "event") + assert result == {} + + +# ---- Unit tests: analyze_translation_coverage --------------------------------- + +def test_analyze_coverage_structure(mini_repo): + os.chdir(mini_repo) + registry = load_registry(mini_repo) + analysis = analyze_translation_coverage(mini_repo, registry) + + assert "by_type" in analysis + assert "languages" in analysis + assert "summary" in analysis + assert "type_stats" in analysis + assert "lang_stats" in analysis + + +def test_analyze_coverage_detects_languages(mini_repo): + os.chdir(mini_repo) + registry = load_registry(mini_repo) + analysis = analyze_translation_coverage(mini_repo, registry) + + # Should detect en, fr, es from the content files + assert "en" in analysis["languages"] + assert "fr" in analysis["languages"] + assert "es" in analysis["languages"] + + +def test_analyze_coverage_courses(mini_repo): + os.chdir(mini_repo) + registry = load_registry(mini_repo) + analysis = analyze_translation_coverage(mini_repo, registry) + + courses = analysis["by_type"].get("course", []) + assert len(courses) == 2 # btc101, btc201 + + btc101 = next(c for c in courses if c["id"] == "btc101") + assert "en" in btc101["languages"] + assert "fr" in btc101["languages"] + assert btc101["languages"]["en"]["words"] > 0 + + btc201 = next(c for c in courses if c["id"] == "btc201") + assert "en" in btc201["languages"] + assert "fr" not in btc201["languages"] + + +def test_analyze_coverage_excludes_events(mini_repo): + """Events have no translatable files and should not appear in by_type.""" + os.chdir(mini_repo) + registry = load_registry(mini_repo) + analysis = analyze_translation_coverage(mini_repo, registry) + + assert "event" not in analysis["by_type"] + + +def test_analyze_coverage_excludes_channels(mini_repo): + """Channels have no translatable files and should not appear in by_type.""" + os.chdir(mini_repo) + registry = load_registry(mini_repo) + analysis = analyze_translation_coverage(mini_repo, registry) + + assert "channel" not in analysis["by_type"] + + +def test_analyze_coverage_summary(mini_repo): + os.chdir(mini_repo) + registry = load_registry(mini_repo) + analysis = analyze_translation_coverage(mini_repo, registry) + + summary = analysis["summary"] + assert summary["total_items"] > 0 + assert summary["total_translations"] > 0 + assert summary["total_possible"] >= summary["total_translations"] + assert 0 <= summary["coverage_pct"] <= 100 + + +def test_analyze_coverage_type_stats(mini_repo): + os.chdir(mini_repo) + registry = load_registry(mini_repo) + analysis = analyze_translation_coverage(mini_repo, registry) + + ts = analysis["type_stats"] + assert "course" in ts + assert ts["course"]["items"] == 2 + assert ts["course"]["translations"] == 3 # btc101: en+fr, btc201: en + + +# ---- CLI integration tests ----------------------------------------------------- + +def test_cli_report_translation_json(mini_repo): + os.chdir(mini_repo) + runner = CliRunner() + result = runner.invoke(cli, ["report", "translation", "--json"]) + assert result.exit_code == 0, result.output + + data = json.loads(result.output) + assert "summary" in data + assert "type_stats" in data + assert "languages" in data + assert "items" in data + assert data["summary"]["total_items"] > 0 + + +def test_cli_report_translation_html(mini_repo, tmp_path): + os.chdir(mini_repo) + runner = CliRunner() + output_dir = tmp_path / "test_output" + result = runner.invoke(cli, ["report", "translation", "--output", str(output_dir)]) + assert result.exit_code == 0, result.output + + html_file = output_dir / "md_translation_overview.html" + assert html_file.exists() + + html = html_file.read_text() + assert "" in html + assert "Markdown Translation Overview" in html + assert "btc101" in html + assert "btc201" in html + assert "sparrow" in html + + +def test_cli_report_translation_html_self_contained(mini_repo, tmp_path): + """HTML report should be self-contained (no external CSS/JS dependencies).""" + os.chdir(mini_repo) + runner = CliRunner() + output_dir = tmp_path / "test_output" + result = runner.invoke(cli, ["report", "translation", "--output", str(output_dir)]) + assert result.exit_code == 0 + + html = (output_dir / "md_translation_overview.html").read_text() + # Should have inline styles + assert " - """ - - # Combine CSS and HTML content - full_html = f""" - - {css} - {html_content} - - """ - - # Convert HTML to PDF - HTML(string=full_html).write_pdf(output_file) - return True - except Exception as e: - print(f"Error converting file: {e}") - return False - -def main(): - # Base path - base_path = os.path.abspath("../../courses") - - # Get available folders - folders = get_folders(base_path) - if not folders: - print("No folders found in the specified path!") - return - - # Ask user to select a folder - questions = [ - inquirer.List('folder', - message="Choose a folder for conversion", - choices=folders) - ] - answers = inquirer.prompt(questions) - - if not answers: - print("No folder selected!") - return - - selected_folder = answers['folder'] - folder_path = os.path.join(base_path, selected_folder) - - # Get markdown files in selected folder - md_files = get_md_files(folder_path) - if not md_files: - print(f"No markdown files found in {selected_folder}!") - return - - # Ask user to select files for conversion - questions = [ - inquirer.Checkbox('files', - message="Select markdown files to convert (space to select, enter to confirm)", - choices=md_files) - ] - answers = inquirer.prompt(questions) - - if not answers or not answers['files']: - print("No files selected!") - return - - # Create output directory in script location if it doesn't exist - output_dir = os.path.join(SCRIPT_DIR, 'pdf_output') - os.makedirs(output_dir, exist_ok=True) - - # Convert selected files - for md_file in answers['files']: - input_path = os.path.join(folder_path, md_file) - output_path = os.path.join(output_dir, f"{os.path.splitext(md_file)[0]}.pdf") - - print(f"\nConverting {md_file}...") - if convert_md_to_pdf(input_path, output_path): - print(f"Successfully converted {md_file} to PDF!") - print(f"PDF saved at: {output_path}") - else: - print(f"Failed to convert {md_file}") - -if __name__ == "__main__": - main() - diff --git a/scripts/convert-to-pdf/pdf_output/en.pdf b/scripts/convert-to-pdf/pdf_output/en.pdf deleted file mode 100644 index c90eaa4afc7..00000000000 Binary files a/scripts/convert-to-pdf/pdf_output/en.pdf and /dev/null differ diff --git a/scripts/convert-to-webp-py/README.md b/scripts/convert-to-webp-py/README.md deleted file mode 100644 index 3f5299da212..00000000000 --- a/scripts/convert-to-webp-py/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Image to WEBP Converter -**UPDATE:** I've made a complete program for local image conversion with a GUI that's easier to use than this script, supports more formats, and automatically removes unnecessary metadata from images. You can test it here on the repo: https://github.com/LoicPandul/ImagesConverter - -**MISE À JOUR :** J'ai fait un logiciel complet pour la conversion d'image en local avec une GUI plus simple à utiliser que ce script, qui prend en charge plus de formats, et qui supprime automatiquement les métadonnées inutiles des images. Je vous conseille de le tester ici sur son repo : https://github.com/LoicPandul/ImagesConverter - ---- - -EN : This little python script converts PNG, JPEG and JPG images into WEBP images. You can use it to quickly convert visuals and diagrams for your PlanB Network tutorials. The WEBP format allows you to reduce the size of your assets. The script converts the file and then deletes the source file automatically. - -FR : Ce petit script python convertit les images PNG, JPEG et JPG en images WEBP. Vous pouvez l'utiliser pour convertir rapidement des visuels et des schémas pour vos tutoriels PlanB Network. Le format WEBP vous permet de réduire la taille de vos images. Le script convertit le fichier et supprime ensuite automatiquement le fichier source. - -## Requirements - -- Python 3 -- Pillow - -## EN - Installation - -1. Navigate to the `convert_to_webp.py` file in the GitHub web interface ; -2. Click on the file to open it ; -3. Above the file's content, click on the `Download raw file` arrow to download the script. - -To install the dependencies, run: -``` -pip install Pillow -``` - -## FR - Installation - -1. Naviguez jusqu'au fichier `convert_to_webp.py` dans l'interface web de GitHub ; -2. Cliquez sur le fichier pour l'ouvrir ; -3. Au-dessus du contenu du fichier, cliquez sur la flèche `Download raw file` pour télécharger le script. - -Pour installer les dépendances, exécutez dans un terminal : -``` -pip install Pillow -``` - -## EN - Usage - -To use the script, open a terminal in the folder where `convert_to_webp.py` is located and execute: - -``` -python convert_to_webp.py -``` - -Drag and drop your image files onto the terminal where the script is being executed, then follow the on-screen instructions. You can drop multiple images at once. Press `enter` to execute. - -## FR - Usage - -Pour utiliser le script, ouvrez un terminal dans le dossier où se trouve `convert_to_webp.py` et exécutez : -``` -python convert_to_webp.py -``` - -Glissez et déposez vos fichiers image sur le terminal où le script est exécuté, puis suivez les instructions à l'écran. Vous pouvez déposer plusieurs images en même temps. Tapez `entrer` pour exécuter. \ No newline at end of file diff --git a/scripts/convert-to-webp-py/convert_to_webp.py b/scripts/convert-to-webp-py/convert_to_webp.py deleted file mode 100644 index 9be0fa210d6..00000000000 --- a/scripts/convert-to-webp-py/convert_to_webp.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -from PIL import Image - -def parse_input(input_files): - image_paths = [] - temp_path = "" # Accumulateur pour les chemins non entourés de guillemets - in_quotes = False # Indicateur pour savoir si on est à l'intérieur de guillemets - - for part in input_files.split(' '): - if part.startswith('"'): - in_quotes = True - temp_path += part[1:] # Enlever le guillemet de début - elif part.endswith('"'): - if in_quotes: - # Ajouter à la liste le chemin complet entre guillemets - image_paths.append(temp_path + " " + part[:-1]) # Enlever le guillemet de fin - temp_path = "" # Réinitialiser pour le prochain chemin - in_quotes = False - else: - # Gérer un cas où un chemin se termine par un guillemet sans commencer par un - temp_path += " " + part - image_paths.append(temp_path) - temp_path = "" - elif in_quotes: - # Accumuler les parties du chemin entre guillemets - temp_path += " " + part - else: - # Ajouter directement les chemins sans guillemets - image_paths.append(part) - - # Gérer le cas où le dernier élément n'est pas suivi d'un guillemet - if temp_path: - image_paths.append(temp_path) - - return image_paths - -def convert_to_webp(image_paths): - for image_path in image_paths: - image_path = image_path.strip() # Nettoyer les espaces blancs éventuels - if not os.path.isfile(image_path): - print(f"Le fichier {image_path} n'existe pas.") - continue - - with Image.open(image_path) as image: - image_output_path = os.path.splitext(image_path)[0] + '.webp' - image.save(image_output_path, 'WEBP') - print(f"{image_path} a été converti en {image_output_path}.") - - os.remove(image_path) - print(f"{image_path} a été supprimé.") - -if __name__ == "__main__": - input_files = input("Drag and drop ! (formats : .png, .jpeg, .jpg) : ") - image_paths = parse_input(input_files) - convert_to_webp(image_paths) \ No newline at end of file diff --git a/scripts/course-related/Scripts Documentation.md b/scripts/course-related/Scripts Documentation.md deleted file mode 100644 index ad5f9d2b1bf..00000000000 --- a/scripts/course-related/Scripts Documentation.md +++ /dev/null @@ -1,142 +0,0 @@ -# Script Documentation - -## Overview - -The 3 Python scripts can be copied and pasted into any other courses to help the writer and automate certain tasks. You just need to copy and paste the `/scripts` folder into your main course folder. - -## Dependencies - -To install the external Python module, run the following command: - -```sh -pip install pyyaml -``` - -## Scripts - -### 1. `add_uuid.py` - -The `add_uuid.py` script is used to create UUIDs below the titles of parts and chapters in the markdown file of your course without removing any existing UUIDs. This allows the writer to execute the script every time they add a new title. - -#### How It Works - -- **Functionality**: Adds UUIDs to part and chapter titles in a markdown file. -- **Input**: If there is only one markdown file, it modifies it directly without asking anything. If there are multiple files, it prompts for input on which file to process. -- **Output**: The script updates the markdown file with UUIDs added to new titles. - -#### Usage - -1. Ensure the markdown file is in the parent directory of the script. -2. Run the script: - -```sh -python add_uuid.py -``` - -or - -```sh -python3 add_uuid.py -``` - -#### Example Output - -If a title does not have a UUID, the script will add one: - -```markdown -# Part 1 - Introduction -123e4567-e89b-12d3-a456-426614174000 -## Chapter 1 - Overview -123e4567-e89b-12d3-a456-426614174001 -``` - -### 2. `plan.py` - -The `plan.py` script generates and updates a `plan.txt` file that includes all titles from your course markdown file. It also adds UUIDs for easy reference. This helps in quickly locating and identifying titles. - -#### How It Works - -- **Functionality**: Creates a clear text file (`plan.txt`) with all titles and their UUIDs. -- **Input**: If there is only one markdown file, it uses it directly without asking anything. If there are multiple files, it prompts for input on which file to use as the source for the plan. -- **Output**: A `plan.txt` file in the parent directory of the script. - -#### Usage - -1. Ensure the markdown file is in the parent directory of the script. -2. Run the script: - -```sh -python plan.py -``` - -or - -```sh -python3 plan.py -``` - -#### Example Output - -The generated `plan.txt` will look like this: - -```txt -Part 0 - Protect your privacy in Bitcoin -Part 1 - Introduction (123e4567-e89b-12d3-a456-426614174000) - Chapter 1 - Overview (123e4567-e89b-12d3-a456-426614174001) -``` - -### 3. `quizz.py` - -The `quizz.py` script creates five new quiz question directories with YAML templates. It prompts the user to input the UUID of the chapter for the questions and automatically fetches the difficulty and author information from the `course.yml` file. - -#### How It Works - -- **Functionality**: Creates directories with question templates for quizzes. -- **Input**: Chapter UUID. -- **Output**: Five new directories with `fr.yml` and `question.yml` files. - -#### Usage - -1. Ensure the `course.yml` file is in the parent directory of the script. -2. Run the script: - -```sh -python quizz.py -``` - -or - -```sh -python3 quizz.py -``` - -3. Input the required chapter UUID when prompted. - -#### Example Output - -The script creates directories named sequentially (e.g., `000`, `001`, etc.) and populates them with the following templates: - -`fr.yml`: - -```yaml -question: -answer: -wrong_answers: - - -explanation: | - -reviewed: false -``` - -`question.yml`: - -```yaml -chapterId: -difficulty: -duration: 15 -author: -tags: - - Bitcoin -``` - -If needed, don't hesitate to contact me! (Loïc) \ No newline at end of file diff --git a/scripts/course-related/add_uuid.py b/scripts/course-related/add_uuid.py deleted file mode 100644 index 2dd1f590881..00000000000 --- a/scripts/course-related/add_uuid.py +++ /dev/null @@ -1,85 +0,0 @@ -import os -import re -import uuid -import sys - -sys.dont_write_bytecode = True - -def trouver_fichier_markdown(): - parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) - fichiers_md = [f for f in os.listdir(parent_dir) if f.endswith(".md")] - - if not fichiers_md: - return None - - if len(fichiers_md) == 1: - return os.path.join(parent_dir, fichiers_md[0]) - - print("Multiple Markdown files found:") - for idx, fichier in enumerate(fichiers_md): - print(f"{idx + 1}. {fichier}") - - choix = int(input("Select the number of the file to be modified: ")) - 1 - return os.path.join(parent_dir, fichiers_md[choix]) - -def lire_fichier_markdown(chemin_md): - with open(chemin_md, 'r', encoding='utf-8') as fichier: - return fichier.readlines() - -def ecrire_fichier_markdown(chemin_md, lignes): - with open(chemin_md, 'w', encoding='utf-8') as fichier: - fichier.writelines(lignes) - -def generer_uuid(): - return str(uuid.uuid4()) - -def ajouter_uuids_si_absents(lignes): - nouvelles_lignes = [] - i = 0 - uuids_ajoutes = [] - while i < len(lignes): - ligne = lignes[i] - nouvelles_lignes.append(ligne) - - if match := re.match(r'^(#+)\s+(.*)', ligne): - niveau = len(match.group(1)) - titre = match.group(2).strip() - if niveau == 1: - balise_id = 'partId' - elif niveau == 2: - balise_id = 'chapterId' - else: - i += 1 - continue - - if i + 1 < len(lignes) and re.match(rf'^<{balise_id}>.+$', lignes[i + 1].strip()): - nouvelles_lignes.append(lignes[i + 1]) - i += 2 - continue - - nouvel_uuid = generer_uuid() - nouvelles_lignes.append(f"<{balise_id}>{nouvel_uuid}\n") - uuids_ajoutes.append((titre, nouvel_uuid)) - i += 1 - - return nouvelles_lignes, uuids_ajoutes - -def main(): - chemin_md = trouver_fichier_markdown() - if not chemin_md: - print("No Markdown file found.") - return - - lignes = lire_fichier_markdown(chemin_md) - nouvelles_lignes, uuids_ajoutes = ajouter_uuids_si_absents(lignes) - ecrire_fichier_markdown(chemin_md, nouvelles_lignes) - - if uuids_ajoutes: - print("Added UUIDs:") - for titre, nouvel_uuid in uuids_ajoutes: - print(f"- Title: {titre}, - UUID: {nouvel_uuid}") - else: - print("No UUIDs were added.") - -if __name__ == "__main__": - main() diff --git a/scripts/course-related/plan.py b/scripts/course-related/plan.py deleted file mode 100644 index 4671671c401..00000000000 --- a/scripts/course-related/plan.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import re - -def trouver_fichier_markdown(): - parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) - fichiers_md = [f for f in os.listdir(parent_dir) if f.endswith(".md")] - - if not fichiers_md: - return None - - if len(fichiers_md) == 1: - return os.path.join(parent_dir, fichiers_md[0]) - - print("Multiple Markdown files found:") - for idx, fichier in enumerate(fichiers_md): - print(f"{idx + 1}. {fichier}") - - choix = int(input("Select the number of the file to be used as a source: ")) - 1 - return os.path.join(parent_dir, fichiers_md[choix]) - -def lire_titres(fichier_md): - titres = [] - current_part = None - current_chapter = None - current_uuid = None - - with open(fichier_md, 'r', encoding='utf-8') as fichier: - for ligne in fichier: - if match := re.match(r'^(#+)\s+(.*)', ligne): - niveau = len(match.group(1)) - titre = match.group(2).strip() - if niveau == 1: - current_part = titre - current_uuid = None - elif niveau == 2: - current_chapter = titre - current_uuid = None - titres.append((niveau, titre, current_uuid)) - elif match := re.match(r'^<(.+Id)>(.+)$', ligne): - current_uuid = match.group(2).strip() - if current_part and not current_chapter: - titres[-1] = (titres[-1][0], titres[-1][1], current_uuid) - elif current_chapter: - titres[-1] = (titres[-1][0], titres[-1][1], current_uuid) - current_uuid = None - - return titres - -def ecrire_plan(titres, fichier_plan): - with open(fichier_plan, 'w', encoding='utf-8') as fichier: - dernier_niveau = 0 - num_partie = -1 - num_chapitre = 0 - for niveau, titre, uuid in titres: - if niveau == 1: - num_partie += 1 - num_chapitre = 0 - prefixe = f"Partie {num_partie} - " - elif niveau == 2: - num_chapitre += 1 - prefixe = f" Chapitre {num_chapitre} - " - else: - prefixe = ' ' * (niveau - 1) + '| ' - - if niveau == 1 and dernier_niveau != 0: - fichier.write('\n') - if uuid: - fichier.write(f"{prefixe}{titre} ({uuid})\n") - else: - fichier.write(f"{prefixe}{titre}\n") - dernier_niveau = niveau - -def main(): - chemin_md = trouver_fichier_markdown() - if not chemin_md: - print("No Markdown file found.") - return - - chemin_plan = os.path.join(os.path.dirname(chemin_md), "plan.txt") - - titres = lire_titres(chemin_md) - - ecrire_plan(titres, chemin_plan) - - print("plan.txt created!") - -if __name__ == "__main__": - main() diff --git a/scripts/course-related/quizz.py b/scripts/course-related/quizz.py deleted file mode 100644 index b5b563a7612..00000000000 --- a/scripts/course-related/quizz.py +++ /dev/null @@ -1,209 +0,0 @@ -import os -import re -import yaml -import uuid -from datetime import date - -def lire_course_yml(base_path): - course_yml_path = os.path.join(base_path, 'course.yml') - if not os.path.exists(course_yml_path): - print(f"Error: {course_yml_path} does not exist.") - return None, None, None - - with open(course_yml_path, 'r', encoding='utf-8') as file: - course_data = yaml.safe_load(file) - - difficulty = course_data.get('level') - - professors_ids = course_data.get('professors_id', []) - professor_id = professors_ids[0] if professors_ids else None - - contributors = [] - proofreading = course_data.get('proofreading', []) or [] - for pr in proofreading: - if isinstance(pr, dict) and str(pr.get('language', '')).lower() == 'fr': - cn = pr.get('contributor_names') or [] - if cn: - contributors = cn - break - - if not contributors: - contributors = course_data.get('contributor_names', []) or [] - - return difficulty, professor_id, contributors - - -def validate_uuid(uuid_string): - """Check if the string is a valid UUID.""" - try: - uuid.UUID(uuid_string) - return True - except ValueError: - return False - -def find_chapter_in_markdown(base_path, chapter_uuid): - """Find chapter UUID in fr.md and return the chapter title.""" - fr_md_path = os.path.join(base_path, 'fr.md') - - if not os.path.exists(fr_md_path): - print(f"Error: {fr_md_path} does not exist.") - return None - - with open(fr_md_path, 'r', encoding='utf-8') as file: - content = file.read() - - # Match: "## Title" then "uuid" - pattern = r'##\s+(.+?)\n([a-f0-9-]+)' - matches = re.findall(pattern, content, re.IGNORECASE) - - for title, uuid in matches: - if uuid.lower() == chapter_uuid.lower(): - return title.strip() - - return None - - -def choose_from_list(options, header, prompt): - options = [o for o in (options or []) if o] - if not options: - return None - if len(options) == 1: - print(f"✓ Using {header}: {options[0]}") - return options[0] - - print(f"\n{header} found:") - for i, opt in enumerate(options, start=1): - print(f"{i}. {opt}") - - while True: - choice = input(prompt).strip() - if choice.isdigit(): - n = int(choice) - if 1 <= n <= len(options): - picked = options[n - 1] - print(f"✓ Selected {header.lower()}: {picked}") - return picked - print("Invalid choice. Please try again.") - - -def create_quiz_directories(base_path, chapter_id, difficulty, author, contributor_name, num_directories=5): - if not os.path.exists(base_path): - os.makedirs(base_path) - - existing_dirs = [d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, d))] - existing_nums = sorted([int(d) for d in existing_dirs if d.isdigit()]) - last_num = existing_nums[-1] if existing_nums else -1 - - fr_template = "" - - # Use professor_id as author - today = date.today().strftime("%Y-%m-%d") - - for i in range(1, num_directories + 1): - new_dir_num = last_num + i - new_dir_name = f"{new_dir_num:03}" - new_dir_path = os.path.join(base_path, new_dir_name) - - os.makedirs(new_dir_path, exist_ok=True) - - # One UUID per question - question_id = str(uuid.uuid4()) - - question_template = f"""id: {question_id} -chapterId: {chapter_id} -difficulty: {difficulty} -duration: 15 -author: {author} -original_language: fr - -# Proofreading metadata - -proofreading: - - language: fr - last_contribution_date: {today} - urgency: 1 - contributor_names: - - {contributor_name} - reward: 0 -""" - - with open(os.path.join(new_dir_path, 'fr.yml'), 'w', encoding='utf-8') as file: - file.write(fr_template) - - with open(os.path.join(new_dir_path, 'question.yml'), 'w', encoding='utf-8') as file: - file.write(question_template) - -def main(): - script_dir = os.path.dirname(os.path.abspath(__file__)) - base_path = os.path.abspath(os.path.join(script_dir, os.pardir)) - quiz_path = os.path.join(base_path, 'quizz') - - _, professor_id, contributor_candidates = lire_course_yml(base_path) - - if not professor_id: - print("Error: Could not find professor_id in course.yml.") - return - - contributor_name = choose_from_list( - contributor_candidates, - "Contributor name(s)", - "Choose a number: " - ) - - if not contributor_name: - print("Error: Could not find contributor_names in course.yml.") - return - - # Ask + validate chapter UUID - while True: - chapter_input = input("Chapter UUID ? ").strip() - - # Extract UUID if pasted as an HTML tag - if '' in chapter_input and '' in chapter_input: - match = re.search(r'([a-f0-9-]+)', chapter_input, re.IGNORECASE) - if match: - chapter_id = match.group(1) - print(f"→ Extracted UUID: {chapter_id}") - else: - print("Error: Could not extract UUID from HTML tag.") - continue - else: - chapter_id = chapter_input - - if not validate_uuid(chapter_id): - print("Error: Invalid UUID format. Please enter a valid UUID.") - continue - - chapter_title = find_chapter_in_markdown(base_path, chapter_id) - if chapter_title: - print(f"✓ Chapter found: '{chapter_title}'") - break - else: - print(f"Error: Chapter UUID '{chapter_id}' not found in fr.md") - continue - - # Pick difficulty - print("\nSelect difficulty level:") - print("1. easy") - print("2. intermediate") - print("3. hard") - - difficulty_map = { - '1': 'easy', - '2': 'intermediate', - '3': 'hard' - } - - while True: - choice = input("Enter your choice (1, 2, or 3): ").strip() - if choice in difficulty_map: - difficulty = difficulty_map[choice] - print(f"✓ Selected difficulty: {difficulty}") - break - else: - print("Invalid choice. Please enter 1, 2, or 3.") - - create_quiz_directories(quiz_path, chapter_id, difficulty, professor_id, contributor_name) - -if __name__ == "__main__": - main() diff --git a/scripts/course-related/update-all-courses-hours.py b/scripts/course-related/update-all-courses-hours.py deleted file mode 100644 index 5044cfbebbc..00000000000 --- a/scripts/course-related/update-all-courses-hours.py +++ /dev/null @@ -1,156 +0,0 @@ -import os -import re -import math -from colorama import Fore, Style, init - -init(autoreset=True) - -def select_md_file(folder_path): - en_md_path = os.path.join(folder_path, 'en.md') - fr_md_path = os.path.join(folder_path, 'fr.md') - if os.path.exists(en_md_path): - return en_md_path - elif os.path.exists(fr_md_path): - return fr_md_path - else: - md_files = [f for f in os.listdir(folder_path) if f.endswith('.md')] - if not md_files: - print(Fore.RED + "No .md file found in the folder.") - return None - else: - print(Fore.CYAN + "Multiple .md files found:") - for idx, md_file in enumerate(md_files): - print(f"{idx + 1}. {md_file}") - while True: - choice = input("Enter the number of the .md file to use: ") - try: - choice = int(choice) - if 1 <= choice <= len(md_files): - return os.path.join(folder_path, md_files[choice - 1]) - else: - print("Invalid selection. Please enter a number from the list.") - except ValueError: - print("Invalid input. Please enter a number.") - -def count_words_images_and_tutorial_links(file_path): - try: - with open(file_path, 'r', encoding='utf-8') as file: - content = file.read() - - words = len(re.findall(r'\b\w+\b', content)) - - images = len(re.findall(r'!\[.*?\]\(.*?\)', content)) - - urls = re.findall(r'https?://[^\s\)"]+', content) - - tutorial_links = sum('/tutorials/' in url for url in urls) - - return words, images, tutorial_links - except Exception as e: - print(f"Error reading the file {file_path}: {e}") - return None, None, None - -def get_difficulty_factor(level): - difficulty_mapping = { - 'beginner': 1, - 'intermediate': 1.5, - 'advanced': 2, - 'wizard': 2.5 - } - return difficulty_mapping.get(level.lower(), 1) - -def calculate_hours(words, images, tutorial_links, difficulty): - base_time_seconds = words * 0.75 + images * 30 - tutorial_time_seconds = tutorial_links * 600 - total_time_seconds = (base_time_seconds + tutorial_time_seconds) * difficulty - return total_time_seconds / 3600 - -def format_duration_in_hours_and_minutes(total_hours): - hours = int(total_hours) - minutes = int(round((total_hours - hours) * 60)) - return hours, minutes - -def update_hours_in_yaml(course_yaml_path, new_hours): - try: - with open(course_yaml_path, 'r', encoding='utf-8') as file: - lines = file.readlines() - with open(course_yaml_path, 'w', encoding='utf-8') as file: - for line in lines: - if line.strip().startswith('hours:'): - indentation = line[:line.index('hours:')] - line = f"{indentation}hours: {new_hours}\n" - file.write(line) - print(Fore.GREEN + f"The 'hours' value has been updated to {new_hours} in {course_yaml_path}") - except Exception as e: - print(Fore.RED + f"Error updating {course_yaml_path}: {e}") - -def main(): - base_path = input("Please enter the path to the 'courses' folder: ").strip() - - if (base_path.startswith('"') and base_path.endswith('"')) or (base_path.startswith("'") and base_path.endswith("'")): - base_path = base_path[1:-1] - - base_path = os.path.normpath(base_path) - - if not os.path.isdir(base_path): - print(Fore.RED + "The base directory does not exist. Please check the path.") - return - - course_folders = [f for f in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, f))] - - for course_folder in course_folders: - folder_path = os.path.join(base_path, course_folder) - while True: - print(Fore.CYAN + f"\nDo you want to process the course '{course_folder}'? (y/n): ", end="") - choice = input().strip().lower() - if choice in ['y', 'n']: - break - else: - print(Fore.RED + "Please enter 'y' for yes or 'n' for no.") - - if choice != 'y': - continue - - course_yaml_path = os.path.join(folder_path, 'course.yml') - if not os.path.exists(course_yaml_path): - print(Fore.RED + f"course.yml not found in {folder_path}. Course skipped.") - continue - - try: - with open(course_yaml_path, 'r', encoding='utf-8') as file: - lines = file.readlines() - level = 'beginner' - for line in lines: - if line.strip().startswith('level:'): - level = line.strip().split(':', 1)[1].strip() - break - except Exception as e: - print(Fore.RED + f"Error reading {course_yaml_path}: {e}") - continue - - difficulty = get_difficulty_factor(level) - - md_file_path = select_md_file(folder_path) - if md_file_path is None: - print(Fore.RED + "No appropriate .md file found. Course skipped.") - continue - - total_words, total_images, total_tutorial_links = count_words_images_and_tutorial_links(md_file_path) - if total_words is None or total_images is None or total_tutorial_links is None: - print(Fore.RED + "Error analyzing markdown content. Course skipped.") - continue - - total_hours = calculate_hours(total_words, total_images, total_tutorial_links, difficulty) - rounded_hours = math.ceil(total_hours) - exact_hours, exact_minutes = format_duration_in_hours_and_minutes(total_hours) - - print(Fore.YELLOW + f"\nTotal words counted: {total_words}") - print(Fore.YELLOW + f"Total images found: {total_images}") - print(Fore.YELLOW + f"Total tutorial links found: {total_tutorial_links}") - print(Fore.BLUE + f"Exact duration: {exact_hours} hours and {exact_minutes} minutes") - print(Fore.GREEN + f"Rounded duration: {rounded_hours} hours") - - update_hours_in_yaml(course_yaml_path, rounded_hours) - -if __name__ == "__main__": - main() diff --git a/scripts/course-to-pdf.py b/scripts/course-to-pdf.py deleted file mode 100755 index 24d88ace915..00000000000 --- a/scripts/course-to-pdf.py +++ /dev/null @@ -1,738 +0,0 @@ -#!/usr/bin/env python3 -""" -Unified Course to PDF Converter -Converts Bitcoin educational course markdown files to PDF with proper formatting. -""" - -import os -import re -import base64 -import subprocess -import sys -from pathlib import Path -from typing import List, Optional, Tuple - -def get_available_courses() -> List[str]: - """Get list of available course codes from the project directory.""" - courses = [] - project_root = Path(__file__).parent.parent - courses_dir = project_root / "courses" - - # Get all course directories - if courses_dir.exists(): - for course_dir in sorted(courses_dir.iterdir()): - if course_dir.is_dir() and not course_dir.name.startswith('.'): - md_file = course_dir / "en.md" - if md_file.exists(): - courses.append(course_dir.name) - - return courses - -def convert_webp_to_data_url(image_path: Path) -> Optional[str]: - """Convert webp image to data URL for embedding in HTML.""" - try: - with open(image_path, 'rb') as img_file: - img_data = img_file.read() - b64_data = base64.b64encode(img_data).decode('utf-8') - return f"data:image/webp;base64,{b64_data}" - except Exception as e: - print(f" ⚠️ Could not load image {image_path}: {e}") - return None - -def clean_content(content: str) -> str: - """Remove unwanted elements from markdown content.""" - # Remove file paths at the bottom of pages - content = re.sub(r'file://[^\s\n]+', '', content) - - # Remove hex IDs like 3cd2ac82-026c-53e1-874a-baf5842adc6d - content = re.sub(r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}', '', content) - - # Remove +++ lines - content = re.sub(r'^\+\+\+\s*$', '', content, flags=re.MULTILINE) - - # Remove "true" or "false" standing alone on lines - content = re.sub(r'^\s*(true|false)\s*$', '', content, flags=re.MULTILINE) - - # Remove --- name: ... --- blocks - content = re.sub(r'^---\s*\n.*?name:.*?\n---\s*$', '', content, flags=re.MULTILINE | re.DOTALL) - - # Remove sections we don't want in PDF - content = re.sub(r'^#+\s*(Conclusion|Final Exam|Reviews & Ratings|Final Section)\s*$.*?(?=^#|\Z)', '', - content, flags=re.MULTILINE | re.DOTALL) - - # Clean up excessive blank lines - content = re.sub(r'\n{3,}', '\n\n', content) - - return content - -def extract_toc(content: str) -> List[Tuple[int, str, str]]: - """Extract table of contents from headers.""" - toc = [] - headers = re.findall(r'^(#{1,4})\s+(.+)$', content, re.MULTILINE) - - for header in headers: - level = len(header[0]) - text = header[1].strip() - # Skip certain headers from TOC - if text.lower() in ['conclusion', 'final exam', 'reviews & ratings', 'final section']: - continue - # Create anchor id from header text - anchor = re.sub(r'[^a-z0-9\-_]', '', text.lower().replace(' ', '-')) - toc.append((level, text, anchor)) - - return toc - -def generate_toc_html(toc: List[Tuple[int, str, str]]) -> str: - """Generate HTML for table of contents.""" - if not toc: - return "" - - html = '
\n

Table of Contents

\n
    \n' - - current_level = 0 - for level, text, anchor in toc: - # Close previous nested lists if going to a higher level - while current_level > level: - html += '
\n' - current_level -= 1 - - # Open new nested lists if going deeper - while current_level < level and current_level > 0: - html += '
    \n' - current_level += 1 - - if current_level == 0: - current_level = level - - html += f'
  • {text}' - - # Check if next item is a sublevel - idx = toc.index((level, text, anchor)) - if idx < len(toc) - 1 and toc[idx + 1][0] > level: - html += '\n
      \n' - current_level += 1 - else: - html += '\n' - - # Close any remaining open lists - while current_level > 1: - html += '
  • \n' - current_level -= 1 - - html += '
\n
\n
\n' - return html - -def convert_tables(text: str) -> str: - """Convert markdown tables to HTML tables with proper structure.""" - table_pattern = r'(\|[^\n]+\|(?:\n\|[-: ]+\|)?(?:\n\|[^\n]+\|)*)' - - def process_table(match): - lines = match.group(0).strip().split('\n') - if len(lines) < 2: - return match.group(0) - - # Check if second line is separator line - is_header = bool(re.match(r'^[\|\s\-:]+$', lines[1])) - - html = '\n' - - if is_header: - # Process header row - html += '\n\n' - cells = [cell.strip() for cell in lines[0].split('|')[1:-1]] - for cell in cells: - html += f'\n' - html += '\n\n' - - # Process body rows - html += '\n' - for line in lines[2:]: - if line.strip(): - html += '\n' - cells = [cell.strip() for cell in line.split('|')[1:-1]] - for cell in cells: - html += f'\n' - html += '\n' - html += '\n' - else: - # All rows are body rows - html += '\n' - for line in lines: - if line.strip(): - html += '\n' - cells = [cell.strip() for cell in line.split('|')[1:-1]] - for cell in cells: - html += f'\n' - html += '\n' - html += '\n' - - html += '
{cell}
{cell}
{cell}
' - return html - - return re.sub(table_pattern, process_table, text, flags=re.MULTILINE) - -def convert_lists(html: str) -> str: - """Improved list conversion.""" - lines = html.split('\n') - new_lines = [] - list_stack = [] - - for line in lines: - # Check for unordered list item - ul_match = re.match(r'^(\s*)([\*\-])\s+(.+)$', line) - # Check for ordered list item - ol_match = re.match(r'^(\s*)(\d+)\.\s+(.+)$', line) - - if ul_match or ol_match: - if ul_match: - indent_level = len(ul_match.group(1)) // 2 - list_type = 'ul' - content = ul_match.group(3) - else: - indent_level = len(ol_match.group(1)) // 2 - list_type = 'ol' - content = ol_match.group(3) - - # Manage list stack - while len(list_stack) > indent_level + 1: - tag = list_stack.pop() - new_lines.append(f'') - - if len(list_stack) == indent_level: - # Start new list - new_lines.append(f'<{list_type}>') - list_stack.append(list_type) - elif len(list_stack) == indent_level + 1: - # Check if we need to switch list type - if list_stack[-1] != list_type: - new_lines.append(f'') - list_stack.pop() - new_lines.append(f'<{list_type}>') - list_stack.append(list_type) - - new_lines.append(f'
  • {content}
  • ') - else: - # Close all open lists - while list_stack: - tag = list_stack.pop() - new_lines.append(f'') - new_lines.append(line) - - # Close any remaining open lists - while list_stack: - tag = list_stack.pop() - new_lines.append(f'') - - return '\n'.join(new_lines) - -def convert_paragraphs(html: str) -> str: - """Convert text into proper paragraphs.""" - blocks = re.split(r'\n\n+', html) - processed_blocks = [] - - for block in blocks: - block = block.strip() - if not block: - continue - - # Check if block is already an HTML element - if (block.startswith('{block}

    ') - - return '\n\n'.join(processed_blocks) - -def convert_markdown_to_html_improved(markdown_text: str, toc: List) -> str: - """Improved markdown to HTML conversion with better table support.""" - html = markdown_text - - # Process tables first (before other conversions) - html = convert_tables(html) - - # Headers with IDs for TOC linking - def add_header_id(match): - level = len(match.group(1)) - text = match.group(2) - anchor = re.sub(r'[^a-z0-9\-_]', '', text.lower().replace(' ', '-')) - return f'{text}' - - html = re.sub(r'^(#{1,4})\s+(.+)$', add_header_id, html, flags=re.MULTILINE) - - # Bold and italic - html = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', html) - html = re.sub(r'\*\*(.+?)\*\*', r'\1', html) - html = re.sub(r'\*(.+?)\*', r'\1', html) - html = re.sub(r'__(.+?)__', r'\1', html) - html = re.sub(r'_(.+?)_', r'\1', html) - - # Links - html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', html) - - # Code blocks - html = re.sub(r'```([^`]+)```', r'
    \1
    ', html, flags=re.DOTALL) - html = re.sub(r'`([^`]+)`', r'\1', html) - - # Blockquotes - html = re.sub(r'^> (.+)$', r'
    \1
    ', html, flags=re.MULTILINE) - - # Lists (improved support) - html = convert_lists(html) - - # Paragraphs - html = convert_paragraphs(html) - - return html - -def process_markdown_to_html(course_code: str, project_root: Path) -> Optional[str]: - """Convert markdown course file to HTML with embedded images.""" - course_dir = project_root / "courses" / course_code.lower() - md_file = course_dir / "en.md" - assets_dir = course_dir / "assets" / "en" - - if not md_file.exists(): - print(f" ❌ Course file {md_file} not found") - return None - - # Read markdown content - with open(md_file, 'r', encoding='utf-8') as f: - content = f.read() - - # Clean content first - content = clean_content(content) - - # Extract TOC before processing - toc = extract_toc(content) - - # Process images - convert relative paths to embedded data URLs - def replace_image(match): - img_path = match.group(2) - # Remove leading ./ or / if present - img_path = img_path.lstrip('./') - - # Check different possible paths - possible_paths = [ - course_dir / img_path, - course_dir / "assets" / "en" / img_path, - course_dir / "assets" / img_path, - project_root / img_path - ] - - # Also check if it's just a filename - if '/' not in img_path: - possible_paths.append(assets_dir / img_path) - - for path in possible_paths: - if path.exists(): - data_url = convert_webp_to_data_url(path) - if data_url: - return f'{match.group(1)}' - break - - # If image not found, return original markdown - print(f" ⚠️ Image not found: {img_path}") - return f'{match.group(1)}' - - # Replace markdown image syntax with HTML img tags - content = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', replace_image, content) - - # Convert markdown to HTML with improved conversion - html_content = convert_markdown_to_html_improved(content, toc) - - # Generate TOC HTML - toc_html = generate_toc_html(toc) - - # Create full HTML document with CSS - html_template = f""" - - - - - {course_code.upper()} - Bitcoin Educational Course - - - - {toc_html} - {html_content} - -""" - - return html_template - -def html_to_pdf_chromium(html_file: Path, pdf_file: Path) -> bool: - """Convert HTML to PDF using Chromium browser.""" - try: - # Use Chromium in headless mode to convert HTML to PDF - cmd = [ - 'chromium-browser', - '--headless', - '--disable-gpu', - '--no-sandbox', - f'--print-to-pdf={pdf_file}', - '--print-to-pdf-no-header', - '--no-margins', - f'file://{html_file.absolute()}' - ] - - result = subprocess.run(cmd, capture_output=True, text=True) - - if result.returncode != 0 and pdf_file.exists(): - # Sometimes chromium returns non-zero but still creates the PDF - return True - - return result.returncode == 0 - except FileNotFoundError: - print(" ⚠️ Chromium not found. Trying alternative browsers...") - return False - -def html_to_pdf_wkhtmltopdf(html_file: Path, pdf_file: Path) -> bool: - """Convert HTML to PDF using wkhtmltopdf as fallback.""" - try: - cmd = [ - 'wkhtmltopdf', - '--enable-local-file-access', - '--no-header-line', - '--no-footer-line', - '--margin-top', '10mm', - '--margin-bottom', '10mm', - '--margin-left', '10mm', - '--margin-right', '10mm', - str(html_file), - str(pdf_file) - ] - - result = subprocess.run(cmd, capture_output=True, text=True) - return result.returncode == 0 - except FileNotFoundError: - return False - -def convert_course_to_pdf(course_code: str, output_dir: Path) -> bool: - """Convert a single course to PDF with course-based naming.""" - project_root = Path(__file__).parent.parent - - print(f"\n📚 Processing {course_code.upper()}...") - - # Generate HTML content - html_content = process_markdown_to_html(course_code, project_root) - if not html_content: - return False - - # Save HTML to temporary file - temp_html = output_dir / f"{course_code}_temp.html" - with open(temp_html, 'w', encoding='utf-8') as f: - f.write(html_content) - print(f" ✓ Generated HTML") - - # Convert HTML to PDF with course-based naming - pdf_file = output_dir / f"{course_code.lower()}_en.pdf" - - print(f" 🔄 Converting to PDF...") - success = html_to_pdf_chromium(temp_html, pdf_file) - - if not success: - print(" ⚠️ Trying wkhtmltopdf as fallback...") - success = html_to_pdf_wkhtmltopdf(temp_html, pdf_file) - - # Clean up temporary HTML file - temp_html.unlink() - - if success and pdf_file.exists(): - size = pdf_file.stat().st_size / (1024 * 1024) # Size in MB - print(f" ✅ Generated PDF: {pdf_file.name} ({size:.2f} MB)") - return True - else: - print(f" ❌ Failed to generate PDF") - return False - -def main(): - """Main function to run the interactive course to PDF converter.""" - print("=" * 60) - print("📖 Bitcoin Educational Content - Course to PDF Converter") - print("=" * 60) - - # Get available courses - courses = get_available_courses() - - if not courses: - print("\n❌ No courses found in the project directory") - sys.exit(1) - - print("\n📋 Available courses:") - for i, course in enumerate(courses, 1): - print(f" {i}. {course.upper()}") - print(f" {len(courses) + 1}. Convert all courses") - - print("\n📝 Instructions:") - print(" • Enter single numbers: 1") - print(" • Enter multiple numbers separated by commas: 1,3,5") - print(" • Enter ranges with dash: 1-5") - print(" • Combine ranges and numbers: 1-3,7,9-11") - print(f" • Enter {len(courses) + 1} for all courses") - print(" • Enter 'q' to quit") - - # Get user choice - while True: - try: - choice = input("\n🎯 Select course(s): ").strip() - - if choice.lower() == 'q': - print("\n👋 Goodbye!") - sys.exit(0) - - selected_indices = [] - - # Check if "all" option was selected - if choice == str(len(courses) + 1): - selected_indices = list(range(1, len(courses) + 1)) - else: - # Parse the input for ranges and individual numbers - parts = choice.split(',') - for part in parts: - part = part.strip() - if '-' in part: - # Handle range - start, end = part.split('-') - start_num = int(start.strip()) - end_num = int(end.strip()) - if 1 <= start_num <= len(courses) and 1 <= end_num <= len(courses): - selected_indices.extend(range(start_num, end_num + 1)) - else: - raise ValueError("Range out of bounds") - else: - # Handle single number - num = int(part) - if 1 <= num <= len(courses): - selected_indices.append(num) - else: - raise ValueError("Number out of bounds") - - # Remove duplicates and sort - selected_indices = sorted(set(selected_indices)) - - if selected_indices: - selected_courses = [courses[idx - 1] for idx in selected_indices] - print(f"\n✓ Selected {len(selected_courses)} course(s): {', '.join([c.upper() for c in selected_courses])}") - break - else: - print(" ❌ No valid courses selected. Please try again.") - except (ValueError, IndexError) as e: - print(f" ❌ Invalid input. Please use the format shown in the instructions.") - - # Create output directory in scripts folder - output_dir = Path(__file__).parent / "pdf_courses" - output_dir.mkdir(exist_ok=True) - print(f"\n📁 Output directory: {output_dir}") - - # Convert selected courses - successful = [] - failed = [] - - for course_code in selected_courses: - if convert_course_to_pdf(course_code, output_dir): - successful.append(course_code) - else: - failed.append(course_code) - - # Print summary - print("\n" + "=" * 60) - print("📊 Conversion Summary:") - print("=" * 60) - - if successful: - print(f"\n✅ Successfully converted ({len(successful)}):") - for course_code in successful: - pdf_path = output_dir / f"{course_code.lower()}_en.pdf" - print(f" - {course_code.upper()} → {pdf_path}") - - if failed: - print(f"\n❌ Failed to convert ({len(failed)}):") - for course_code in failed: - print(f" - {course_code.upper()}") - - print("\n📝 Features included in PDFs:") - print(" ✓ Table of Contents with clickable links") - print(" ✓ Proper table formatting with headers") - print(" ✓ Clean content (no IDs, URLs, or unnecessary sections)") - print(" ✓ Professional layout with embedded images") - print(" ✓ Optimized for both viewing and printing") - - print("\n💡 Tips:") - print(" • PDFs are saved in: scripts/pdf_courses/") - print(" • You can also print from browser for custom settings") - print(" • For best results, ensure Chromium is installed") - print("=" * 60) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/course_report/course_analytics.py b/scripts/course_report/course_analytics.py deleted file mode 100644 index fef82dbccb1..00000000000 --- a/scripts/course_report/course_analytics.py +++ /dev/null @@ -1,1754 +0,0 @@ -#!/usr/bin/env python3 -""" -Course Analytics Script -Analyzes Bitcoin Educational Content courses and generates an HTML report. - -Metrics extracted: -- Number of parts per course -- Number of chapters per course/part -- Number of subsections (###) per chapter -- Word counts at various levels -- Statistical measures (mean, std, median) -""" - -import os -import re -import json -import statistics -from pathlib import Path -from dataclasses import dataclass, field -from typing import Optional -from datetime import datetime - - -@dataclass -class ChapterMetrics: - """Metrics for a single chapter.""" - name: str - word_count: int - subsection_count: int # Number of ### headers - paragraph_count: int - sentence_count: int - - -@dataclass -class PartMetrics: - """Metrics for a single part.""" - name: str - chapters: list[ChapterMetrics] = field(default_factory=list) - - @property - def chapter_count(self) -> int: - return len(self.chapters) - - @property - def total_words(self) -> int: - return sum(c.word_count for c in self.chapters) - - @property - def total_subsections(self) -> int: - return sum(c.subsection_count for c in self.chapters) - - @property - def total_paragraphs(self) -> int: - return sum(c.paragraph_count for c in self.chapters) - - @property - def total_sentences(self) -> int: - return sum(c.sentence_count for c in self.chapters) - - -@dataclass -class CourseMetrics: - """Metrics for a single course.""" - course_id: str - language: str - parts: list[PartMetrics] = field(default_factory=list) - intro_word_count: int = 0 - - @property - def part_count(self) -> int: - return len(self.parts) - - @property - def chapter_count(self) -> int: - return sum(p.chapter_count for p in self.parts) - - @property - def total_words(self) -> int: - return self.intro_word_count + sum(p.total_words for p in self.parts) - - @property - def total_subsections(self) -> int: - return sum(p.total_subsections for p in self.parts) - - @property - def total_paragraphs(self) -> int: - return sum(p.total_paragraphs for p in self.parts) - - @property - def total_sentences(self) -> int: - return sum(p.total_sentences for p in self.parts) - - @property - def words_per_chapter(self) -> list[int]: - return [c.word_count for p in self.parts for c in p.chapters] - - @property - def chapters_per_part(self) -> list[int]: - return [p.chapter_count for p in self.parts] - - @property - def subsections_per_chapter(self) -> list[int]: - return [c.subsection_count for p in self.parts for c in p.chapters] - - @property - def paragraphs_per_chapter(self) -> list[int]: - return [c.paragraph_count for p in self.parts for c in p.chapters] - - @property - def sentences_per_chapter(self) -> list[int]: - return [c.sentence_count for p in self.parts for c in p.chapters] - - -def clean_text_for_analysis(text: str) -> str: - """Clean markdown text for analysis, removing syntax elements.""" - # Remove HTML-like tags (partId, chapterId, etc.) - text = re.sub(r'<[^>]+>', '', text) - # Remove image references - text = re.sub(r'!\[.*?\]\(.*?\)', '', text) - # Remove links but keep text - text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) - # Remove code blocks - text = re.sub(r'```[\s\S]*?```', '', text) - # Remove inline code - text = re.sub(r'`[^`]+`', '', text) - # Remove markdown headers markers - text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) - # Remove bold/italic markers - text = re.sub(r'\*{1,2}([^*]+)\*{1,2}', r'\1', text) - text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text) - # Remove URLs - text = re.sub(r'https?://\S+', '', text) - return text - - -def count_words(text: str) -> int: - """Count words in text, excluding markdown syntax and HTML tags.""" - cleaned = clean_text_for_analysis(text) - words = cleaned.split() - return len(words) - - -def count_paragraphs(text: str) -> int: - """Count paragraphs in text (blocks separated by blank lines).""" - # Clean the text first - cleaned = clean_text_for_analysis(text) - # Split by double newlines (paragraph separator) - # A paragraph is text separated by one or more blank lines - paragraphs = re.split(r'\n\s*\n', cleaned) - # Filter out empty paragraphs and those that are just whitespace - paragraphs = [p.strip() for p in paragraphs if p.strip()] - # Filter out very short "paragraphs" that are likely just headers or list markers - paragraphs = [p for p in paragraphs if len(p.split()) >= 3] - return len(paragraphs) - - -def count_sentences(text: str) -> int: - """Count sentences in text using punctuation-based heuristics.""" - cleaned = clean_text_for_analysis(text) - # Remove list markers and bullet points - cleaned = re.sub(r'^\s*[-*•]\s*', '', cleaned, flags=re.MULTILINE) - cleaned = re.sub(r'^\s*\d+\.\s*', '', cleaned, flags=re.MULTILINE) - - # Sentence endings: . ! ? followed by space or end of string - # But avoid counting abbreviations like "e.g.", "i.e.", "Dr.", "Mr.", etc. - # Simple approach: count sentence-ending punctuation followed by space + capital or end - - # First, normalize multiple spaces - cleaned = re.sub(r'\s+', ' ', cleaned) - - # Count sentences by splitting on sentence-ending punctuation - # This regex matches . ! ? that end sentences (not abbreviations) - sentences = re.split(r'[.!?]+(?:\s|$)', cleaned) - # Filter out empty strings and very short fragments - sentences = [s.strip() for s in sentences if s.strip() and len(s.split()) >= 2] - - return max(len(sentences), 1) if cleaned.strip() else 0 - - -def parse_course_file(filepath: Path) -> Optional[CourseMetrics]: - """Parse a course markdown file and extract metrics.""" - try: - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - except Exception as e: - print(f"Error reading {filepath}: {e}") - return None - - course_id = filepath.parent.name - language = filepath.stem - - metrics = CourseMetrics(course_id=course_id, language=language) - - # Split by +++ to separate intro from main content - if '+++' in content: - parts = content.split('+++', 1) - intro = parts[0] - main_content = parts[1] if len(parts) > 1 else "" - # Remove YAML frontmatter from intro - if intro.strip().startswith('---'): - intro_parts = intro.split('---', 2) - if len(intro_parts) >= 3: - intro = intro_parts[2] - metrics.intro_word_count = count_words(intro) - else: - main_content = content - # Try to remove YAML frontmatter - if content.strip().startswith('---'): - content_parts = content.split('---', 2) - if len(content_parts) >= 3: - main_content = content_parts[2] - - # Parse parts and chapters - # Parts are marked by "# Title" followed by - # Chapters are marked by "## Title" followed by - - # Split content by part headers - part_pattern = r'\n#\s+([^\n]+)\n+[^<]+' - chapter_pattern = r'\n##\s+([^\n]+)\n+[^<]+' - subsection_pattern = r'\n###\s+[^\n]+' - - # Find all parts - part_matches = list(re.finditer(part_pattern, main_content)) - - if not part_matches: - # No parts found, treat entire content as single implicit part - part = PartMetrics(name="Main Content") - chapter_matches = list(re.finditer(chapter_pattern, main_content)) - - for i, match in enumerate(chapter_matches): - chapter_name = match.group(1).strip() - start = match.end() - end = chapter_matches[i + 1].start() if i + 1 < len(chapter_matches) else len(main_content) - chapter_content = main_content[start:end] - - subsection_count = len(re.findall(subsection_pattern, chapter_content)) - word_count = count_words(chapter_content) - paragraph_count = count_paragraphs(chapter_content) - sentence_count = count_sentences(chapter_content) - - part.chapters.append(ChapterMetrics( - name=chapter_name, - word_count=word_count, - subsection_count=subsection_count, - paragraph_count=paragraph_count, - sentence_count=sentence_count - )) - - if part.chapters: - metrics.parts.append(part) - else: - # Process each part - for i, part_match in enumerate(part_matches): - part_name = part_match.group(1).strip() - part = PartMetrics(name=part_name) - - part_start = part_match.end() - part_end = part_matches[i + 1].start() if i + 1 < len(part_matches) else len(main_content) - part_content = main_content[part_start:part_end] - - # Find chapters within this part - chapter_matches = list(re.finditer(chapter_pattern, '\n' + part_content)) - - for j, ch_match in enumerate(chapter_matches): - chapter_name = ch_match.group(1).strip() - ch_start = ch_match.end() - ch_end = chapter_matches[j + 1].start() if j + 1 < len(chapter_matches) else len(part_content) + 1 - chapter_content = ('\n' + part_content)[ch_start:ch_end] - - subsection_count = len(re.findall(subsection_pattern, chapter_content)) - word_count = count_words(chapter_content) - paragraph_count = count_paragraphs(chapter_content) - sentence_count = count_sentences(chapter_content) - - part.chapters.append(ChapterMetrics( - name=chapter_name, - word_count=word_count, - subsection_count=subsection_count, - paragraph_count=paragraph_count, - sentence_count=sentence_count - )) - - metrics.parts.append(part) - - return metrics - - -def find_course_file(course_dir: Path) -> Optional[Path]: - """Find the best course file (en.md preferred, then fr.md).""" - en_file = course_dir / "en.md" - fr_file = course_dir / "fr.md" - - if en_file.exists(): - return en_file - elif fr_file.exists(): - return fr_file - return None - - -def calculate_stats(values: list[int | float]) -> dict: - """Calculate statistical measures for a list of values.""" - if not values: - return {"count": 0, "mean": 0, "std": 0, "median": 0, "min": 0, "max": 0, "sum": 0} - - return { - "count": len(values), - "mean": round(statistics.mean(values), 2), - "std": round(statistics.stdev(values), 2) if len(values) > 1 else 0, - "median": round(statistics.median(values), 2), - "min": min(values), - "max": max(values), - "sum": sum(values) - } - - -def analyze_courses(courses_dir: Path) -> dict: - """Analyze all courses and return aggregated metrics.""" - all_courses: list[CourseMetrics] = [] - - for item in sorted(courses_dir.iterdir()): - if item.is_dir() and not item.name.startswith('.'): - course_file = find_course_file(item) - if course_file: - metrics = parse_course_file(course_file) - if metrics and metrics.part_count > 0: - all_courses.append(metrics) - print(f"✓ Parsed {metrics.course_id} ({metrics.language}): {metrics.part_count} parts, {metrics.chapter_count} chapters, {metrics.total_words} words") - - # Aggregate metrics - all_parts_per_course = [c.part_count for c in all_courses] - all_chapters_per_course = [c.chapter_count for c in all_courses] - all_words_per_course = [c.total_words for c in all_courses] - all_subsections_per_course = [c.total_subsections for c in all_courses] - all_paragraphs_per_course = [c.total_paragraphs for c in all_courses] - all_sentences_per_course = [c.total_sentences for c in all_courses] - - all_chapters_per_part = [cp for c in all_courses for cp in c.chapters_per_part] - all_words_per_chapter = [w for c in all_courses for w in c.words_per_chapter] - all_subsections_per_chapter = [s for c in all_courses for s in c.subsections_per_chapter] - all_paragraphs_per_chapter = [p for c in all_courses for p in c.paragraphs_per_chapter] - all_sentences_per_chapter = [s for c in all_courses for s in c.sentences_per_chapter] - - return { - "generated_at": datetime.now().isoformat(), - "total_courses": len(all_courses), - "courses": [ - { - "id": c.course_id, - "language": c.language, - "parts": c.part_count, - "chapters": c.chapter_count, - "total_words": c.total_words, - "total_subsections": c.total_subsections, - "total_paragraphs": c.total_paragraphs, - "total_sentences": c.total_sentences, - "intro_words": c.intro_word_count, - "chapters_per_part": c.chapters_per_part, - "words_per_chapter": c.words_per_chapter, - "subsections_per_chapter": c.subsections_per_chapter, - "paragraphs_per_chapter": c.paragraphs_per_chapter, - "sentences_per_chapter": c.sentences_per_chapter, - "parts_detail": [ - { - "name": p.name, - "chapters": p.chapter_count, - "words": p.total_words, - "subsections": p.total_subsections, - "paragraphs": p.total_paragraphs, - "sentences": p.total_sentences - } - for p in c.parts - ] - } - for c in all_courses - ], - "aggregated": { - "parts_per_course": calculate_stats(all_parts_per_course), - "chapters_per_course": calculate_stats(all_chapters_per_course), - "words_per_course": calculate_stats(all_words_per_course), - "subsections_per_course": calculate_stats(all_subsections_per_course), - "paragraphs_per_course": calculate_stats(all_paragraphs_per_course), - "sentences_per_course": calculate_stats(all_sentences_per_course), - "chapters_per_part": calculate_stats(all_chapters_per_part), - "words_per_chapter": calculate_stats(all_words_per_chapter), - "subsections_per_chapter": calculate_stats(all_subsections_per_chapter), - "paragraphs_per_chapter": calculate_stats(all_paragraphs_per_chapter), - "sentences_per_chapter": calculate_stats(all_sentences_per_chapter) - }, - "distributions": { - "parts_per_course": all_parts_per_course, - "chapters_per_course": all_chapters_per_course, - "words_per_course": all_words_per_course, - "chapters_per_part": all_chapters_per_part, - "words_per_chapter": all_words_per_chapter, - "subsections_per_chapter": all_subsections_per_chapter, - "paragraphs_per_course": all_paragraphs_per_course, - "sentences_per_course": all_sentences_per_course, - "paragraphs_per_chapter": all_paragraphs_per_chapter, - "sentences_per_chapter": all_sentences_per_chapter - } - } - - -def generate_html_report(data: dict) -> str: - """Generate a standalone HTML report with embedded data and charts.""" - - html = f''' - - - - - Course Analytics Report - - - - -
    -
    -

    📊 Course Analytics Report

    -

    Bitcoin Educational Content Analysis

    -

    - Generated: {data['generated_at'][:19].replace('T', ' ')} -

    -
    - - -
    -
    -
    {data['total_courses']}
    -
    Total Courses
    -
    -
    -
    {data['aggregated']['parts_per_course']['sum']}
    -
    Total Parts
    -
    -
    -
    {data['aggregated']['chapters_per_course']['sum']}
    -
    Total Chapters
    -
    -
    -
    {data['aggregated']['words_per_course']['sum']:,}
    -
    Total Words
    -
    -
    -
    {data['aggregated']['paragraphs_per_course']['sum']:,}
    -
    Total Paragraphs
    -
    -
    -
    {data['aggregated']['sentences_per_course']['sum']:,}
    -
    Total Sentences
    -
    -
    - - -
    -

    📈 Aggregated Statistics

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    MetricCountMeanStd DevMedianMinMax
    Parts per Course{data['aggregated']['parts_per_course']['count']}{data['aggregated']['parts_per_course']['mean']}{data['aggregated']['parts_per_course']['std']}{data['aggregated']['parts_per_course']['median']}{data['aggregated']['parts_per_course']['min']}{data['aggregated']['parts_per_course']['max']}
    Chapters per Course{data['aggregated']['chapters_per_course']['count']}{data['aggregated']['chapters_per_course']['mean']}{data['aggregated']['chapters_per_course']['std']}{data['aggregated']['chapters_per_course']['median']}{data['aggregated']['chapters_per_course']['min']}{data['aggregated']['chapters_per_course']['max']}
    Chapters per Part{data['aggregated']['chapters_per_part']['count']}{data['aggregated']['chapters_per_part']['mean']}{data['aggregated']['chapters_per_part']['std']}{data['aggregated']['chapters_per_part']['median']}{data['aggregated']['chapters_per_part']['min']}{data['aggregated']['chapters_per_part']['max']}
    Subsections (###) per Chapter{data['aggregated']['subsections_per_chapter']['count']}{data['aggregated']['subsections_per_chapter']['mean']}{data['aggregated']['subsections_per_chapter']['std']}{data['aggregated']['subsections_per_chapter']['median']}{data['aggregated']['subsections_per_chapter']['min']}{data['aggregated']['subsections_per_chapter']['max']}
    Words per Chapter{data['aggregated']['words_per_chapter']['count']}{data['aggregated']['words_per_chapter']['mean']}{data['aggregated']['words_per_chapter']['std']}{data['aggregated']['words_per_chapter']['median']}{data['aggregated']['words_per_chapter']['min']}{data['aggregated']['words_per_chapter']['max']}
    Words per Course{data['aggregated']['words_per_course']['count']}{data['aggregated']['words_per_course']['mean']:,.0f}{data['aggregated']['words_per_course']['std']:,.0f}{data['aggregated']['words_per_course']['median']:,.0f}{data['aggregated']['words_per_course']['min']:,}{data['aggregated']['words_per_course']['max']:,}
    Paragraphs per Chapter{data['aggregated']['paragraphs_per_chapter']['count']}{data['aggregated']['paragraphs_per_chapter']['mean']}{data['aggregated']['paragraphs_per_chapter']['std']}{data['aggregated']['paragraphs_per_chapter']['median']}{data['aggregated']['paragraphs_per_chapter']['min']}{data['aggregated']['paragraphs_per_chapter']['max']}
    Paragraphs per Course{data['aggregated']['paragraphs_per_course']['count']}{data['aggregated']['paragraphs_per_course']['mean']:,.0f}{data['aggregated']['paragraphs_per_course']['std']:,.0f}{data['aggregated']['paragraphs_per_course']['median']:,.0f}{data['aggregated']['paragraphs_per_course']['min']:,}{data['aggregated']['paragraphs_per_course']['max']:,}
    Sentences per Chapter{data['aggregated']['sentences_per_chapter']['count']}{data['aggregated']['sentences_per_chapter']['mean']}{data['aggregated']['sentences_per_chapter']['std']}{data['aggregated']['sentences_per_chapter']['median']}{data['aggregated']['sentences_per_chapter']['min']}{data['aggregated']['sentences_per_chapter']['max']}
    Sentences per Course{data['aggregated']['sentences_per_course']['count']}{data['aggregated']['sentences_per_course']['mean']:,.0f}{data['aggregated']['sentences_per_course']['std']:,.0f}{data['aggregated']['sentences_per_course']['median']:,.0f}{data['aggregated']['sentences_per_course']['min']:,}{data['aggregated']['sentences_per_course']['max']:,}
    -
    - - -
    -

    📊 Distributions

    -
    -
    -

    Parts per Course Distribution

    -
    - -
    -
    -
    -

    Chapters per Course Distribution

    -
    - -
    -
    -
    -
    -
    -

    Words per Course Distribution

    -
    - -
    -
    -
    -

    Words per Chapter Distribution

    -
    - -
    -
    -
    -
    -
    -

    Chapters per Part Distribution

    -
    - -
    -
    -
    -

    Subsections per Chapter Distribution

    -
    - -
    -
    -
    -
    -
    -

    Paragraphs per Chapter Distribution

    -
    - -
    -
    -
    -

    Sentences per Chapter Distribution

    -
    - -
    -
    -
    -
    -
    -

    Paragraphs per Course Distribution

    -
    - -
    -
    -
    -

    Sentences per Course Distribution

    -
    - -
    -
    -
    -
    - - -
    -

    📚 Course Comparison

    -
    -
    - -
    -
    -
    - - -
    -

    🔍 Per-Course Analysis

    -
    - - -
    - -
    -

    Select a course above to see detailed chapter-by-chapter analysis

    -
    -
    - - -
    -

    📖 Course Details

    -

    Click on column headers to sort

    -
    - - - - - - - - - - - - - - - - {"".join(f''' - - - - - - - - - - - - ''' for c in sorted(data['courses'], key=lambda x: x['total_words'], reverse=True))} - -
    Course IDLangPartsChaptersSubsectionsParagraphsSentencesWordsAvg Words/Ch
    {c['id']}{c['language'].upper()}{c['parts']}{c['chapters']}{c['total_subsections']}{c['total_paragraphs']}{c['total_sentences']}{c['total_words']:,}{round(c['total_words'] / c['chapters']) if c['chapters'] > 0 else 0:,}
    -
    -
    - -
    -

    Bitcoin Educational Content Analytics • Generated with Python

    -
    -
    - - - - -''' - return html - - -def main(): - """Main entry point.""" - # Determine courses directory - script_dir = Path(__file__).parent - courses_dir = script_dir.parent.parent / "courses" - - if not courses_dir.exists(): - print(f"Error: Courses directory not found at {courses_dir}") - return 1 - - print(f"Analyzing courses in: {courses_dir}") - print("-" * 50) - - # Analyze courses - data = analyze_courses(courses_dir) - - print("-" * 50) - print(f"Total courses analyzed: {data['total_courses']}") - - # Generate HTML report - html_content = generate_html_report(data) - - # Save report - output_path = script_dir / "course_analytics_report.html" - with open(output_path, 'w', encoding='utf-8') as f: - f.write(html_content) - - print(f"\n✅ Report generated: {output_path}") - - # Also save raw JSON data for reference - json_path = script_dir / "course_analytics_data.json" - with open(json_path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2) - - print(f"✅ Raw data saved: {json_path}") - - return 0 - - -if __name__ == "__main__": - exit(main()) diff --git a/scripts/course_report/course_analytics_data.json b/scripts/course_report/course_analytics_data.json deleted file mode 100644 index e930be1c560..00000000000 --- a/scripts/course_report/course_analytics_data.json +++ /dev/null @@ -1,9665 +0,0 @@ -{ - "generated_at": "2025-11-30T12:21:41.234024", - "total_courses": 37, - "courses": [ - { - "id": "biz101", - "language": "en", - "parts": 6, - "chapters": 20, - "total_words": 19184, - "total_subsections": 40, - "total_paragraphs": 379, - "total_sentences": 878, - "intro_words": 164, - "chapters_per_part": [ - 1, - 4, - 3, - 6, - 3, - 3 - ], - "words_per_chapter": [ - 351, - 1776, - 1202, - 616, - 804, - 1750, - 2476, - 1744, - 1605, - 842, - 762, - 588, - 724, - 759, - 1883, - 360, - 775, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 4, - 0, - 0, - 4, - 7, - 6, - 2, - 0, - 0, - 0, - 0, - 2, - 8, - 0, - 4, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 6, - 37, - 17, - 12, - 17, - 50, - 45, - 35, - 24, - 11, - 12, - 10, - 8, - 21, - 47, - 5, - 22, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 15, - 100, - 50, - 29, - 44, - 86, - 95, - 90, - 41, - 33, - 29, - 26, - 29, - 40, - 87, - 16, - 65, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 351, - "subsections": 0, - "paragraphs": 6, - "sentences": 15 - }, - { - "name": "Currency, payment systems, and Bitcoin", - "chapters": 4, - "words": 4398, - "subsections": 7, - "paragraphs": 83, - "sentences": 223 - }, - { - "name": "Bitcoin in treasury", - "chapters": 3, - "words": 5970, - "subsections": 17, - "paragraphs": 130, - "sentences": 271 - }, - { - "name": "Tailored Bitcoin payment solutions for every business", - "chapters": 6, - "words": 5280, - "subsections": 4, - "paragraphs": 86, - "sentences": 198 - }, - { - "name": "Bitcoin Accounting", - "chapters": 3, - "words": 3018, - "subsections": 12, - "paragraphs": 74, - "sentences": 168 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "biz205", - "language": "en", - "parts": 11, - "chapters": 20, - "total_words": 15040, - "total_subsections": 96, - "total_paragraphs": 396, - "total_sentences": 667, - "intro_words": 399, - "chapters_per_part": [ - 1, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 1, - 2 - ], - "words_per_chapter": [ - 278, - 1423, - 839, - 780, - 890, - 1021, - 1002, - 779, - 762, - 748, - 245, - 769, - 710, - 718, - 861, - 1605, - 949, - 260, - 1, - 1 - ], - "subsections_per_chapter": [ - 4, - 8, - 5, - 8, - 6, - 4, - 6, - 7, - 6, - 5, - 0, - 4, - 4, - 6, - 7, - 10, - 5, - 1, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 9, - 35, - 25, - 23, - 26, - 26, - 27, - 20, - 22, - 23, - 3, - 19, - 20, - 20, - 22, - 41, - 26, - 9, - 0, - 0 - ], - "sentences_per_chapter": [ - 13, - 60, - 38, - 36, - 38, - 47, - 42, - 33, - 39, - 31, - 10, - 36, - 33, - 35, - 38, - 82, - 42, - 12, - 1, - 1 - ], - "parts_detail": [ - { - "name": "School Introduction", - "chapters": 1, - "words": 278, - "subsections": 4, - "paragraphs": 9, - "sentences": 13 - }, - { - "name": "Why Bitcoin?", - "chapters": 2, - "words": 2262, - "subsections": 13, - "paragraphs": 60, - "sentences": 98 - }, - { - "name": "How Bitcoin? (Theoretical)", - "chapters": 2, - "words": 1670, - "subsections": 14, - "paragraphs": 49, - "sentences": 74 - }, - { - "name": "How Bitcoin? (Practical)", - "chapters": 2, - "words": 2023, - "subsections": 10, - "paragraphs": 53, - "sentences": 89 - }, - { - "name": "Bitcoin Myths Debunked", - "chapters": 2, - "words": 1541, - "subsections": 13, - "paragraphs": 42, - "sentences": 72 - }, - { - "name": "Business Projects", - "chapters": 2, - "words": 993, - "subsections": 5, - "paragraphs": 26, - "sentences": 41 - }, - { - "name": "Bitcoin Business Cases", - "chapters": 2, - "words": 1479, - "subsections": 8, - "paragraphs": 39, - "sentences": 69 - }, - { - "name": "Bitcoin Business Context", - "chapters": 2, - "words": 1579, - "subsections": 13, - "paragraphs": 42, - "sentences": 73 - }, - { - "name": "Bitcoin Business Ethics", - "chapters": 2, - "words": 2554, - "subsections": 15, - "paragraphs": 67, - "sentences": 124 - }, - { - "name": "School Conclusion", - "chapters": 1, - "words": 260, - "subsections": 1, - "paragraphs": 9, - "sentences": 12 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "btc101", - "language": "en", - "parts": 7, - "chapters": 25, - "total_words": 17855, - "total_subsections": 72, - "total_paragraphs": 436, - "total_sentences": 811, - "intro_words": 102, - "chapters_per_part": [ - 2, - 4, - 4, - 5, - 4, - 3, - 3 - ], - "words_per_chapter": [ - 639, - 748, - 682, - 891, - 1058, - 1147, - 802, - 706, - 479, - 649, - 712, - 923, - 793, - 1399, - 1199, - 764, - 508, - 910, - 404, - 595, - 522, - 1220, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 4, - 3, - 3, - 3, - 4, - 4, - 3, - 2, - 4, - 3, - 5, - 3, - 6, - 4, - 3, - 3, - 5, - 2, - 3, - 1, - 4, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 17, - 22, - 19, - 20, - 33, - 22, - 24, - 19, - 14, - 18, - 17, - 22, - 16, - 27, - 21, - 20, - 15, - 33, - 7, - 15, - 9, - 26, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 23, - 36, - 28, - 40, - 54, - 38, - 34, - 34, - 27, - 27, - 27, - 44, - 35, - 70, - 54, - 33, - 20, - 51, - 21, - 25, - 20, - 67, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 2, - "words": 1387, - "subsections": 4, - "paragraphs": 39, - "sentences": 59 - }, - { - "name": "Money", - "chapters": 4, - "words": 3778, - "subsections": 13, - "paragraphs": 94, - "sentences": 160 - }, - { - "name": "Bitcoin Wallets", - "chapters": 4, - "words": 2636, - "subsections": 13, - "paragraphs": 75, - "sentences": 122 - }, - { - "name": "The Technical Aspects of Bitcoin.", - "chapters": 5, - "words": 5026, - "subsections": 21, - "paragraphs": 103, - "sentences": 230 - }, - { - "name": "How do You Obtain Bitcoin?", - "chapters": 4, - "words": 2586, - "subsections": 13, - "paragraphs": 75, - "sentences": 125 - }, - { - "name": "The Future of Bitcoin: The Lightning Network", - "chapters": 3, - "words": 2337, - "subsections": 8, - "paragraphs": 50, - "sentences": 112 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "btc102", - "language": "en", - "parts": 6, - "chapters": 18, - "total_words": 28325, - "total_subsections": 68, - "total_paragraphs": 708, - "total_sentences": 1453, - "intro_words": 170, - "chapters_per_part": [ - 1, - 3, - 4, - 5, - 2, - 3 - ], - "words_per_chapter": [ - 562, - 2439, - 1311, - 2316, - 2208, - 2247, - 2822, - 1669, - 3344, - 1031, - 1197, - 1138, - 1504, - 3278, - 1086, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 6, - 9, - 7, - 5, - 7, - 3, - 6, - 3, - 3, - 3, - 3, - 7, - 3, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 13, - 52, - 38, - 49, - 55, - 45, - 69, - 38, - 95, - 32, - 32, - 39, - 45, - 72, - 34, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 27, - 117, - 61, - 122, - 99, - 123, - 143, - 80, - 187, - 61, - 65, - 57, - 73, - 178, - 57, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 562, - "subsections": 0, - "paragraphs": 13, - "sentences": 27 - }, - { - "name": "Prerequisites for understanding Bitcoin", - "chapters": 3, - "words": 6066, - "subsections": 18, - "paragraphs": 139, - "sentences": 300 - }, - { - "name": "Understanding what you're getting into", - "chapters": 4, - "words": 8946, - "subsections": 22, - "paragraphs": 207, - "sentences": 445 - }, - { - "name": "Setting up your plan", - "chapters": 5, - "words": 8214, - "subsections": 18, - "paragraphs": 243, - "sentences": 443 - }, - { - "name": "Protecting Your Heirs and Your Wealth", - "chapters": 2, - "words": 4364, - "subsections": 10, - "paragraphs": 106, - "sentences": 235 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "btc202", - "language": "en", - "parts": 6, - "chapters": 21, - "total_words": 22326, - "total_subsections": 85, - "total_paragraphs": 556, - "total_sentences": 1024, - "intro_words": 177, - "chapters_per_part": [ - 5, - 4, - 2, - 3, - 4, - 3 - ], - "words_per_chapter": [ - 545, - 1009, - 187, - 450, - 995, - 2302, - 1062, - 1677, - 2685, - 907, - 2463, - 1341, - 701, - 1515, - 932, - 1184, - 697, - 1494, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 6, - 4, - 0, - 0, - 5, - 6, - 4, - 8, - 6, - 4, - 7, - 5, - 2, - 5, - 5, - 4, - 6, - 8, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 19, - 22, - 5, - 9, - 24, - 46, - 23, - 39, - 62, - 23, - 82, - 33, - 27, - 40, - 24, - 24, - 17, - 37, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 26, - 43, - 8, - 25, - 45, - 100, - 45, - 73, - 117, - 41, - 122, - 65, - 40, - 65, - 46, - 52, - 34, - 74, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 5, - "words": 3186, - "subsections": 15, - "paragraphs": 79, - "sentences": 147 - }, - { - "name": "Become a sovereign bitcoiner", - "chapters": 4, - "words": 7726, - "subsections": 24, - "paragraphs": 170, - "sentences": 335 - }, - { - "name": "Installing a Bitcoin node easily", - "chapters": 2, - "words": 3370, - "subsections": 11, - "paragraphs": 105, - "sentences": 163 - }, - { - "name": "Connecting your wallet to your node", - "chapters": 3, - "words": 3557, - "subsections": 12, - "paragraphs": 100, - "sentences": 170 - }, - { - "name": "Advanced concepts and best practices", - "chapters": 4, - "words": 4307, - "subsections": 23, - "paragraphs": 102, - "sentences": 206 - }, - { - "name": "Final part", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "btc204", - "language": "en", - "parts": 7, - "chapters": 31, - "total_words": 51251, - "total_subsections": 138, - "total_paragraphs": 1268, - "total_sentences": 2315, - "intro_words": 202, - "chapters_per_part": [ - 1, - 3, - 5, - 6, - 6, - 7, - 3 - ], - "words_per_chapter": [ - 907, - 546, - 1395, - 1037, - 976, - 1373, - 1072, - 1537, - 1836, - 1389, - 1648, - 2344, - 1640, - 1067, - 886, - 1347, - 1624, - 1538, - 3113, - 910, - 2067, - 1372, - 1635, - 1178, - 2089, - 1481, - 8197, - 4842, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 2, - 5, - 3, - 5, - 5, - 3, - 5, - 9, - 4, - 6, - 6, - 6, - 3, - 2, - 3, - 4, - 2, - 10, - 5, - 7, - 4, - 7, - 3, - 4, - 3, - 12, - 10, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 21, - 13, - 27, - 22, - 28, - 32, - 27, - 37, - 62, - 37, - 35, - 42, - 39, - 26, - 16, - 28, - 35, - 26, - 75, - 23, - 63, - 32, - 41, - 23, - 42, - 26, - 252, - 138, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 47, - 27, - 74, - 53, - 56, - 66, - 56, - 74, - 88, - 64, - 83, - 117, - 91, - 55, - 38, - 65, - 75, - 69, - 140, - 40, - 59, - 70, - 80, - 53, - 85, - 73, - 354, - 160, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 907, - "subsections": 0, - "paragraphs": 21, - "sentences": 47 - }, - { - "name": "Definitions and key concepts", - "chapters": 3, - "words": 2978, - "subsections": 10, - "paragraphs": 62, - "sentences": 154 - }, - { - "name": "Understanding and protecting against chain analysis", - "chapters": 5, - "words": 6794, - "subsections": 27, - "paragraphs": 186, - "sentences": 340 - }, - { - "name": "Mastering best practices to protect your privacy", - "chapters": 6, - "words": 8974, - "subsections": 27, - "paragraphs": 195, - "sentences": 448 - }, - { - "name": "Understanding coinjoin transactions", - "chapters": 6, - "words": 10599, - "subsections": 31, - "paragraphs": 250, - "sentences": 448 - }, - { - "name": "Understanding the challenges of other advanced confidentiality techniques", - "chapters": 7, - "words": 20794, - "subsections": 43, - "paragraphs": 554, - "sentences": 875 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "btc303", - "language": "en", - "parts": 4, - "chapters": 14, - "total_words": 25961, - "total_subsections": 42, - "total_paragraphs": 501, - "total_sentences": 1204, - "intro_words": 240, - "chapters_per_part": [ - 1, - 4, - 6, - 3 - ], - "words_per_chapter": [ - 290, - 2387, - 2032, - 2856, - 1372, - 3553, - 1376, - 3409, - 3102, - 4615, - 726, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 2, - 5, - 2, - 8, - 2, - 4, - 5, - 8, - 3, - 3, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 8, - 53, - 39, - 55, - 30, - 57, - 32, - 64, - 63, - 90, - 10, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 15, - 109, - 86, - 139, - 64, - 154, - 64, - 155, - 142, - 221, - 52, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 290, - "subsections": 2, - "paragraphs": 8, - "sentences": 15 - }, - { - "name": "Bitcoin Central Values", - "chapters": 4, - "words": 8647, - "subsections": 17, - "paragraphs": 177, - "sentences": 398 - }, - { - "name": "Bitcoin Gouvernance", - "chapters": 6, - "words": 16781, - "subsections": 23, - "paragraphs": 316, - "sentences": 788 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "btc304", - "language": "en", - "parts": 9, - "chapters": 48, - "total_words": 15689, - "total_subsections": 8, - "total_paragraphs": 487, - "total_sentences": 811, - "intro_words": 88, - "chapters_per_part": [ - 4, - 2, - 6, - 4, - 7, - 17, - 3, - 3, - 2 - ], - "words_per_chapter": [ - 708, - 545, - 321, - 560, - 383, - 774, - 242, - 687, - 221, - 178, - 257, - 159, - 190, - 596, - 356, - 418, - 168, - 620, - 471, - 185, - 294, - 133, - 424, - 250, - 524, - 120, - 286, - 328, - 341, - 213, - 243, - 140, - 147, - 183, - 165, - 180, - 209, - 409, - 528, - 277, - 802, - 269, - 142, - 522, - 247, - 184, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 0, - 0, - 0, - 2, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 28, - 25, - 11, - 25, - 18, - 39, - 13, - 32, - 9, - 7, - 8, - 6, - 7, - 21, - 8, - 8, - 5, - 17, - 6, - 3, - 5, - 3, - 9, - 6, - 11, - 2, - 6, - 11, - 11, - 9, - 7, - 5, - 4, - 7, - 6, - 4, - 5, - 10, - 9, - 8, - 15, - 8, - 5, - 12, - 8, - 5, - 0, - 0 - ], - "sentences_per_chapter": [ - 31, - 23, - 19, - 38, - 23, - 25, - 15, - 38, - 9, - 7, - 13, - 7, - 15, - 23, - 20, - 23, - 9, - 30, - 23, - 10, - 18, - 9, - 27, - 16, - 19, - 2, - 20, - 16, - 16, - 11, - 13, - 9, - 8, - 13, - 10, - 10, - 10, - 21, - 34, - 20, - 30, - 16, - 8, - 28, - 15, - 9, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 4, - "words": 2134, - "subsections": 3, - "paragraphs": 89, - "sentences": 111 - }, - { - "name": "Designing a community identity Bitcoin", - "chapters": 2, - "words": 1157, - "subsections": 2, - "paragraphs": 57, - "sentences": 48 - }, - { - "name": "Creating communication channels for your Bitcoin community", - "chapters": 6, - "words": 1744, - "subsections": 3, - "paragraphs": 75, - "sentences": 89 - }, - { - "name": "Launch your social networks", - "chapters": 4, - "words": 1560, - "subsections": 0, - "paragraphs": 44, - "sentences": 81 - }, - { - "name": "Organize a Bitcoin meet-up", - "chapters": 7, - "words": 2295, - "subsections": 0, - "paragraphs": 48, - "sentences": 126 - }, - { - "name": "Examples of projects and initiatives", - "chapters": 17, - "words": 4543, - "subsections": 0, - "paragraphs": 121, - "sentences": 248 - }, - { - "name": "Anticipating the future", - "chapters": 3, - "words": 1213, - "subsections": 0, - "paragraphs": 28, - "sentences": 54 - }, - { - "name": "Bringing bitcoiners together", - "chapters": 3, - "words": 953, - "subsections": 0, - "paragraphs": 25, - "sentences": 52 - }, - { - "name": "Final section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "csv402", - "language": "en", - "parts": 5, - "chapters": 15, - "total_words": 27201, - "total_subsections": 77, - "total_paragraphs": 789, - "total_sentences": 1040, - "intro_words": 160, - "chapters_per_part": [ - 1, - 5, - 4, - 3, - 2 - ], - "words_per_chapter": [ - 350, - 4139, - 3989, - 1094, - 1352, - 2496, - 4884, - 2312, - 1497, - 990, - 1199, - 1716, - 1021, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 9, - 8, - 6, - 14, - 0, - 5, - 4, - 4, - 4, - 8, - 7, - 8, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 16, - 103, - 111, - 28, - 33, - 78, - 122, - 85, - 59, - 46, - 26, - 40, - 42, - 0, - 0 - ], - "sentences_per_chapter": [ - 19, - 140, - 138, - 40, - 52, - 110, - 185, - 101, - 63, - 38, - 53, - 63, - 36, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 350, - "subsections": 0, - "paragraphs": 16, - "sentences": 19 - }, - { - "name": "RGB in theory", - "chapters": 5, - "words": 13070, - "subsections": 37, - "paragraphs": 353, - "sentences": 480 - }, - { - "name": "Programming on RGB", - "chapters": 4, - "words": 9683, - "subsections": 17, - "paragraphs": 312, - "sentences": 387 - }, - { - "name": "Building on RGB", - "chapters": 3, - "words": 3936, - "subsections": 23, - "paragraphs": 108, - "sentences": 152 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "csv404", - "language": "en", - "parts": 6, - "chapters": 19, - "total_words": 14077, - "total_subsections": 58, - "total_paragraphs": 280, - "total_sentences": 671, - "intro_words": 121, - "chapters_per_part": [ - 1, - 3, - 4, - 6, - 3, - 2 - ], - "words_per_chapter": [ - 237, - 913, - 977, - 999, - 861, - 784, - 684, - 568, - 759, - 914, - 759, - 707, - 885, - 746, - 1048, - 886, - 1227, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 3, - 3, - 3, - 3, - 3, - 4, - 4, - 4, - 4, - 3, - 4, - 4, - 5, - 4, - 4, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 10, - 14, - 15, - 15, - 15, - 13, - 12, - 16, - 21, - 18, - 21, - 14, - 16, - 17, - 23, - 18, - 22, - 0, - 0 - ], - "sentences_per_chapter": [ - 14, - 35, - 36, - 40, - 43, - 37, - 31, - 30, - 46, - 44, - 46, - 33, - 40, - 37, - 57, - 47, - 53, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 237, - "subsections": 0, - "paragraphs": 10, - "sentences": 14 - }, - { - "name": "The Taproot Asset Protocol", - "chapters": 3, - "words": 2889, - "subsections": 9, - "paragraphs": 44, - "sentences": 111 - }, - { - "name": "Initial Installation and Configuration", - "chapters": 4, - "words": 2897, - "subsections": 13, - "paragraphs": 56, - "sentences": 141 - }, - { - "name": "First Mints and Transactions", - "chapters": 6, - "words": 4770, - "subsections": 23, - "paragraphs": 107, - "sentences": 246 - }, - { - "name": "Diving deeper into Taproot Assets", - "chapters": 3, - "words": 3161, - "subsections": 13, - "paragraphs": 63, - "sentences": 157 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "cyp201", - "language": "en", - "parts": 6, - "chapters": 22, - "total_words": 23290, - "total_subsections": 66, - "total_paragraphs": 614, - "total_sentences": 914, - "intro_words": 156, - "chapters_per_part": [ - 1, - 3, - 4, - 4, - 7, - 3 - ], - "words_per_chapter": [ - 744, - 1313, - 3189, - 744, - 1369, - 1301, - 1823, - 982, - 856, - 345, - 1185, - 561, - 703, - 1401, - 1642, - 1144, - 698, - 466, - 2665, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 7, - 2, - 3, - 4, - 8, - 3, - 4, - 2, - 5, - 3, - 4, - 4, - 4, - 3, - 2, - 2, - 3, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 10, - 37, - 93, - 24, - 28, - 39, - 64, - 21, - 15, - 10, - 32, - 12, - 24, - 24, - 56, - 31, - 13, - 10, - 71, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 43, - 52, - 105, - 26, - 64, - 49, - 72, - 50, - 40, - 16, - 44, - 30, - 28, - 46, - 47, - 53, - 28, - 20, - 98, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 744, - "subsections": 0, - "paragraphs": 10, - "sentences": 43 - }, - { - "name": "Hash Functions", - "chapters": 3, - "words": 5246, - "subsections": 12, - "paragraphs": 154, - "sentences": 183 - }, - { - "name": "Digital Signatures", - "chapters": 4, - "words": 5475, - "subsections": 18, - "paragraphs": 152, - "sentences": 235 - }, - { - "name": "The mnemonic phrase", - "chapters": 4, - "words": 2947, - "subsections": 14, - "paragraphs": 69, - "sentences": 130 - }, - { - "name": "Creation of Bitcoin Wallets", - "chapters": 7, - "words": 8719, - "subsections": 22, - "paragraphs": 229, - "sentences": 320 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "cyp302", - "language": "en", - "parts": 9, - "chapters": 35, - "total_words": 35794, - "total_subsections": 17, - "total_paragraphs": 759, - "total_sentences": 1801, - "intro_words": 106, - "chapters_per_part": [ - 1, - 3, - 4, - 5, - 10, - 2, - 4, - 3, - 3 - ], - "words_per_chapter": [ - 703, - 822, - 1037, - 457, - 2140, - 1000, - 414, - 1054, - 316, - 1450, - 1599, - 3063, - 510, - 419, - 436, - 539, - 1175, - 1631, - 1307, - 406, - 524, - 653, - 465, - 1832, - 2276, - 922, - 1355, - 1733, - 1344, - 1475, - 1051, - 1577, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 0, - 0, - 0, - 4, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 4, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 24, - 15, - 21, - 7, - 74, - 25, - 11, - 19, - 7, - 27, - 29, - 50, - 11, - 8, - 12, - 10, - 20, - 31, - 31, - 9, - 11, - 17, - 11, - 32, - 46, - 18, - 29, - 34, - 26, - 31, - 31, - 32, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 35, - 37, - 56, - 21, - 105, - 45, - 25, - 59, - 19, - 84, - 72, - 155, - 28, - 23, - 24, - 23, - 59, - 79, - 72, - 20, - 30, - 35, - 29, - 64, - 80, - 48, - 67, - 95, - 74, - 76, - 66, - 93, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 703, - "subsections": 0, - "paragraphs": 24, - "sentences": 35 - }, - { - "name": "What is Cryptography?", - "chapters": 3, - "words": 2316, - "subsections": 0, - "paragraphs": 43, - "sentences": 114 - }, - { - "name": "Mathematical Foundations of Cryptography 1", - "chapters": 4, - "words": 4608, - "subsections": 6, - "paragraphs": 129, - "sentences": 234 - }, - { - "name": "Mathematical Foundations of Cryptography 2", - "chapters": 5, - "words": 6938, - "subsections": 0, - "paragraphs": 124, - "sentences": 358 - }, - { - "name": "Symmetric Cryptography", - "chapters": 10, - "words": 7555, - "subsections": 0, - "paragraphs": 160, - "sentences": 394 - }, - { - "name": "RC4 and AES", - "chapters": 2, - "words": 4108, - "subsections": 8, - "paragraphs": 78, - "sentences": 144 - }, - { - "name": "Asymmetric Cryptography", - "chapters": 4, - "words": 5354, - "subsections": 0, - "paragraphs": 107, - "sentences": 284 - }, - { - "name": "The RSA cryptosystem", - "chapters": 3, - "words": 4103, - "subsections": 3, - "paragraphs": 94, - "sentences": 235 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "dev103", - "language": "en", - "parts": 5, - "chapters": 31, - "total_words": 11992, - "total_subsections": 103, - "total_paragraphs": 781, - "total_sentences": 625, - "intro_words": 131, - "chapters_per_part": [ - 7, - 7, - 7, - 8, - 2 - ], - "words_per_chapter": [ - 335, - 564, - 231, - 302, - 409, - 343, - 1238, - 308, - 332, - 385, - 452, - 291, - 331, - 176, - 295, - 425, - 362, - 362, - 294, - 257, - 339, - 693, - 186, - 449, - 359, - 594, - 401, - 1056, - 90, - 1, - 1 - ], - "subsections_per_chapter": [ - 3, - 4, - 3, - 3, - 8, - 4, - 6, - 4, - 3, - 2, - 5, - 3, - 3, - 2, - 1, - 5, - 2, - 3, - 4, - 3, - 4, - 3, - 2, - 3, - 3, - 5, - 3, - 9, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 27, - 41, - 19, - 25, - 32, - 25, - 80, - 17, - 15, - 27, - 34, - 12, - 16, - 19, - 25, - 31, - 14, - 30, - 25, - 23, - 31, - 25, - 12, - 23, - 26, - 40, - 25, - 58, - 4, - 0, - 0 - ], - "sentences_per_chapter": [ - 20, - 32, - 13, - 11, - 26, - 22, - 68, - 16, - 21, - 24, - 23, - 18, - 17, - 7, - 14, - 18, - 21, - 13, - 16, - 15, - 13, - 30, - 10, - 24, - 19, - 27, - 25, - 55, - 5, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Basic JavaScript", - "chapters": 7, - "words": 3422, - "subsections": 31, - "paragraphs": 249, - "sentences": 192 - }, - { - "name": "Intermediate JavaScript", - "chapters": 7, - "words": 2275, - "subsections": 22, - "paragraphs": 140, - "sentences": 126 - }, - { - "name": "Advanced JavaScript", - "chapters": 7, - "words": 2334, - "subsections": 22, - "paragraphs": 179, - "sentences": 110 - }, - { - "name": "NodeJS", - "chapters": 8, - "words": 3828, - "subsections": 28, - "paragraphs": 213, - "sentences": 195 - }, - { - "name": "Final section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "dev303", - "language": "en", - "parts": 5, - "chapters": 18, - "total_words": 20857, - "total_subsections": 85, - "total_paragraphs": 398, - "total_sentences": 997, - "intro_words": 127, - "chapters_per_part": [ - 1, - 7, - 4, - 4, - 2 - ], - "words_per_chapter": [ - 300, - 1733, - 1429, - 1822, - 1151, - 2061, - 1404, - 965, - 1170, - 1149, - 1195, - 744, - 1100, - 980, - 1103, - 2422, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 6, - 6, - 5, - 5, - 11, - 5, - 4, - 4, - 4, - 5, - 4, - 5, - 5, - 4, - 12, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 7, - 35, - 24, - 39, - 21, - 48, - 25, - 18, - 21, - 18, - 20, - 16, - 20, - 19, - 18, - 49, - 0, - 0 - ], - "sentences_per_chapter": [ - 17, - 88, - 63, - 90, - 52, - 113, - 60, - 50, - 56, - 55, - 53, - 35, - 51, - 47, - 47, - 118, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 300, - "subsections": 0, - "paragraphs": 7, - "sentences": 17 - }, - { - "name": "Learn how to code with the rust book", - "chapters": 7, - "words": 10565, - "subsections": 42, - "paragraphs": 210, - "sentences": 516 - }, - { - "name": "Rust & Bitcoin", - "chapters": 4, - "words": 4258, - "subsections": 17, - "paragraphs": 75, - "sentences": 199 - }, - { - "name": "Developping LNP/BP with SDK", - "chapters": 4, - "words": 5605, - "subsections": 26, - "paragraphs": 106, - "sentences": 263 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "eco104", - "language": "en", - "parts": 6, - "chapters": 23, - "total_words": 34947, - "total_subsections": 30, - "total_paragraphs": 670, - "total_sentences": 1613, - "intro_words": 174, - "chapters_per_part": [ - 1, - 4, - 5, - 5, - 6, - 2 - ], - "words_per_chapter": [ - 702, - 1875, - 3159, - 2908, - 161, - 1956, - 1768, - 2232, - 2173, - 202, - 1420, - 869, - 1774, - 2600, - 163, - 7117, - 1219, - 640, - 1179, - 204, - 450, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 7, - 5, - 0, - 2, - 2, - 2, - 4, - 0, - 0, - 0, - 3, - 0, - 0, - 1, - 0, - 0, - 1, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 18, - 36, - 62, - 61, - 2, - 40, - 30, - 42, - 43, - 2, - 30, - 12, - 39, - 53, - 2, - 130, - 19, - 9, - 29, - 2, - 9, - 0, - 0 - ], - "sentences_per_chapter": [ - 28, - 82, - 150, - 145, - 10, - 91, - 80, - 100, - 114, - 14, - 62, - 42, - 84, - 113, - 9, - 334, - 53, - 24, - 43, - 10, - 23, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 702, - "subsections": 0, - "paragraphs": 18, - "sentences": 28 - }, - { - "name": "The Price of Progress - A Closer Look at Our Financial System", - "chapters": 4, - "words": 8103, - "subsections": 15, - "paragraphs": 161, - "sentences": 387 - }, - { - "name": "Financial Liberation - An Introduction to Bitcoin", - "chapters": 5, - "words": 8331, - "subsections": 10, - "paragraphs": 157, - "sentences": 399 - }, - { - "name": "Stability Amidst Chaos - An Introduction to Tether & the World of Stablecoins", - "chapters": 5, - "words": 6826, - "subsections": 3, - "paragraphs": 136, - "sentences": 310 - }, - { - "name": "Overcoming Doubts - Debunking Common Misconceptions & Real-World Use Cases", - "chapters": 6, - "words": 10809, - "subsections": 2, - "paragraphs": 198, - "sentences": 487 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "eco201", - "language": "en", - "parts": 4, - "chapters": 11, - "total_words": 12497, - "total_subsections": 29, - "total_paragraphs": 204, - "total_sentences": 460, - "intro_words": 145, - "chapters_per_part": [ - 2, - 3, - 3, - 3 - ], - "words_per_chapter": [ - 503, - 1835, - 1934, - 1671, - 1556, - 2143, - 2059, - 648, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 5, - 6, - 4, - 4, - 3, - 6, - 1, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 10, - 33, - 33, - 34, - 26, - 31, - 28, - 9, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 23, - 65, - 64, - 61, - 60, - 77, - 76, - 31, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 2, - "words": 2338, - "subsections": 5, - "paragraphs": 43, - "sentences": 88 - }, - { - "name": "Theoretical Foundations", - "chapters": 3, - "words": 5161, - "subsections": 14, - "paragraphs": 93, - "sentences": 185 - }, - { - "name": "Austrian Economic Perspectives", - "chapters": 3, - "words": 4850, - "subsections": 10, - "paragraphs": 68, - "sentences": 184 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "eco203", - "language": "en", - "parts": 7, - "chapters": 26, - "total_words": 17532, - "total_subsections": 0, - "total_paragraphs": 353, - "total_sentences": 872, - "intro_words": 158, - "chapters_per_part": [ - 3, - 4, - 4, - 4, - 4, - 4, - 3 - ], - "words_per_chapter": [ - 391, - 664, - 769, - 809, - 715, - 784, - 846, - 891, - 698, - 853, - 736, - 913, - 814, - 690, - 729, - 864, - 812, - 750, - 648, - 715, - 729, - 780, - 771, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 8, - 9, - 12, - 15, - 13, - 15, - 16, - 14, - 14, - 20, - 15, - 15, - 16, - 21, - 18, - 15, - 22, - 17, - 15, - 17, - 18, - 15, - 13, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 19, - 29, - 39, - 42, - 32, - 37, - 44, - 39, - 35, - 46, - 40, - 53, - 40, - 42, - 41, - 40, - 40, - 40, - 30, - 34, - 34, - 42, - 31, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 3, - "words": 1824, - "subsections": 0, - "paragraphs": 29, - "sentences": 87 - }, - { - "name": "Influences", - "chapters": 4, - "words": 3154, - "subsections": 0, - "paragraphs": 59, - "sentences": 155 - }, - { - "name": "The Opponents", - "chapters": 4, - "words": 3178, - "subsections": 0, - "paragraphs": 63, - "sentences": 160 - }, - { - "name": "Economic Sophisms", - "chapters": 4, - "words": 3146, - "subsections": 0, - "paragraphs": 70, - "sentences": 176 - }, - { - "name": "Economic Harmonies", - "chapters": 4, - "words": 3074, - "subsections": 0, - "paragraphs": 69, - "sentences": 150 - }, - { - "name": "The Law", - "chapters": 4, - "words": 2995, - "subsections": 0, - "paragraphs": 63, - "sentences": 141 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "eco204", - "language": "en", - "parts": 5, - "chapters": 14, - "total_words": 15665, - "total_subsections": 34, - "total_paragraphs": 347, - "total_sentences": 740, - "intro_words": 114, - "chapters_per_part": [ - 1, - 2, - 6, - 2, - 3 - ], - "words_per_chapter": [ - 235, - 2706, - 2910, - 1040, - 2618, - 1794, - 1907, - 1003, - 21, - 1027, - 287, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 5, - 5, - 4, - 4, - 2, - 8, - 4, - 0, - 2, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 6, - 65, - 62, - 25, - 74, - 44, - 25, - 16, - 1, - 19, - 10, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 16, - 130, - 131, - 45, - 82, - 82, - 113, - 58, - 1, - 59, - 20, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 235, - "subsections": 0, - "paragraphs": 6, - "sentences": 16 - }, - { - "name": "What is inflation?", - "chapters": 2, - "words": 5616, - "subsections": 10, - "paragraphs": 127, - "sentences": 261 - }, - { - "name": "What is hyperinflation?", - "chapters": 6, - "words": 8383, - "subsections": 22, - "paragraphs": 185, - "sentences": 381 - }, - { - "name": "How did we get here?", - "chapters": 2, - "words": 1314, - "subsections": 2, - "paragraphs": 29, - "sentences": 79 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "ene101", - "language": "fr", - "parts": 4, - "chapters": 12, - "total_words": 788, - "total_subsections": 0, - "total_paragraphs": 24, - "total_sentences": 28, - "intro_words": 103, - "chapters_per_part": [ - 3, - 3, - 4, - 2 - ], - "words_per_chapter": [ - 89, - 71, - 67, - 68, - 67, - 66, - 60, - 69, - 62, - 64, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 6, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 0, - 0 - ], - "sentences_per_chapter": [ - 3, - 3, - 3, - 2, - 3, - 3, - 2, - 2, - 2, - 3, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Bitcoin et \u00e9nergie", - "chapters": 3, - "words": 227, - "subsections": 0, - "paragraphs": 10, - "sentences": 9 - }, - { - "name": "Les propri\u00e9t\u00e9s de Bitcoin", - "chapters": 3, - "words": 201, - "subsections": 0, - "paragraphs": 6, - "sentences": 8 - }, - { - "name": "Bitcoin et la concurrence", - "chapters": 4, - "words": 255, - "subsections": 0, - "paragraphs": 8, - "sentences": 9 - }, - { - "name": "Conclusion", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "his201", - "language": "en", - "parts": 6, - "chapters": 17, - "total_words": 34841, - "total_subsections": 73, - "total_paragraphs": 541, - "total_sentences": 1476, - "intro_words": 243, - "chapters_per_part": [ - 1, - 3, - 3, - 3, - 4, - 3 - ], - "words_per_chapter": [ - 1061, - 2763, - 2242, - 3563, - 3195, - 3939, - 2726, - 2385, - 1691, - 2110, - 1631, - 1929, - 2565, - 2795, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 4, - 5, - 8, - 6, - 8, - 4, - 5, - 5, - 6, - 4, - 6, - 6, - 6, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 16, - 41, - 29, - 48, - 43, - 65, - 43, - 39, - 29, - 31, - 25, - 35, - 47, - 50, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 45, - 123, - 98, - 147, - 115, - 142, - 109, - 96, - 74, - 86, - 71, - 99, - 111, - 157, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 1061, - "subsections": 0, - "paragraphs": 16, - "sentences": 45 - }, - { - "name": "The Origins of Bitcoin", - "chapters": 3, - "words": 8568, - "subsections": 17, - "paragraphs": 118, - "sentences": 368 - }, - { - "name": "The Slow Emergence of Bitcoin", - "chapters": 3, - "words": 9860, - "subsections": 18, - "paragraphs": 151, - "sentences": 366 - }, - { - "name": "The First Rise of Bitcoin", - "chapters": 3, - "words": 6186, - "subsections": 16, - "paragraphs": 99, - "sentences": 256 - }, - { - "name": "The Bitcoin Community", - "chapters": 4, - "words": 8920, - "subsections": 22, - "paragraphs": 157, - "sentences": 438 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "his203", - "language": "fr", - "parts": 4, - "chapters": 12, - "total_words": 55239, - "total_subsections": 89, - "total_paragraphs": 774, - "total_sentences": 2197, - "intro_words": 492, - "chapters_per_part": [ - 1, - 4, - 3, - 4 - ], - "words_per_chapter": [ - 2346, - 4956, - 4763, - 4504, - 4065, - 5735, - 4120, - 6001, - 5324, - 5125, - 2662, - 5146 - ], - "subsections_per_chapter": [ - 4, - 8, - 10, - 7, - 6, - 10, - 7, - 12, - 7, - 8, - 4, - 6 - ], - "paragraphs_per_chapter": [ - 26, - 64, - 72, - 57, - 61, - 97, - 59, - 89, - 61, - 75, - 42, - 71 - ], - "sentences_per_chapter": [ - 71, - 192, - 251, - 178, - 155, - 249, - 156, - 227, - 185, - 198, - 113, - 222 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 2346, - "subsections": 4, - "paragraphs": 26, - "sentences": 71 - }, - { - "name": "La grande bulle de 2011", - "chapters": 4, - "words": 18288, - "subsections": 31, - "paragraphs": 254, - "sentences": 776 - }, - { - "name": "Le d\u00e9veloppement technique", - "chapters": 3, - "words": 15856, - "subsections": 29, - "paragraphs": 245, - "sentences": 632 - }, - { - "name": "Les exc\u00e8s de l'utilisation", - "chapters": 4, - "words": 18257, - "subsections": 25, - "paragraphs": 249, - "sentences": 718 - } - ] - }, - { - "id": "his204", - "language": "en", - "parts": 6, - "chapters": 26, - "total_words": 15444, - "total_subsections": 5, - "total_paragraphs": 324, - "total_sentences": 761, - "intro_words": 102, - "chapters_per_part": [ - 1, - 4, - 6, - 4, - 8, - 3 - ], - "words_per_chapter": [ - 353, - 689, - 667, - 713, - 682, - 685, - 773, - 690, - 748, - 739, - 760, - 688, - 827, - 707, - 675, - 682, - 760, - 714, - 688, - 635, - 593, - 386, - 485, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 11, - 15, - 14, - 14, - 12, - 12, - 16, - 16, - 14, - 18, - 18, - 13, - 24, - 15, - 14, - 10, - 17, - 16, - 20, - 11, - 14, - 8, - 2, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 15, - 41, - 26, - 37, - 34, - 32, - 36, - 36, - 37, - 45, - 44, - 44, - 38, - 38, - 33, - 34, - 28, - 47, - 36, - 28, - 26, - 19, - 4, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 353, - "subsections": 0, - "paragraphs": 11, - "sentences": 15 - }, - { - "name": "The Forerunners", - "chapters": 4, - "words": 2751, - "subsections": 0, - "paragraphs": 55, - "sentences": 138 - }, - { - "name": "Reformers and Thinkers of the Early 18th Century", - "chapters": 6, - "words": 4395, - "subsections": 0, - "paragraphs": 94, - "sentences": 230 - }, - { - "name": "The Physiocratic School", - "chapters": 4, - "words": 2897, - "subsections": 5, - "paragraphs": 66, - "sentences": 153 - }, - { - "name": "The Enlightenment and Political Economy", - "chapters": 8, - "words": 4943, - "subsections": 0, - "paragraphs": 98, - "sentences": 222 - }, - { - "name": "Final section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "his205", - "language": "en", - "parts": 9, - "chapters": 46, - "total_words": 42194, - "total_subsections": 137, - "total_paragraphs": 907, - "total_sentences": 2048, - "intro_words": 85, - "chapters_per_part": [ - 1, - 6, - 5, - 4, - 8, - 5, - 8, - 7, - 2 - ], - "words_per_chapter": [ - 529, - 858, - 704, - 728, - 815, - 687, - 828, - 799, - 1330, - 1664, - 1405, - 900, - 462, - 1235, - 616, - 619, - 272, - 898, - 553, - 943, - 641, - 1376, - 1244, - 931, - 582, - 528, - 811, - 1393, - 854, - 1344, - 575, - 1036, - 1983, - 1252, - 723, - 868, - 908, - 1829, - 1154, - 1808, - 668, - 737, - 722, - 1295, - 1, - 1 - ], - "subsections_per_chapter": [ - 3, - 4, - 3, - 4, - 3, - 1, - 4, - 0, - 3, - 2, - 4, - 8, - 2, - 3, - 4, - 3, - 0, - 3, - 2, - 2, - 4, - 4, - 4, - 3, - 2, - 2, - 2, - 3, - 3, - 1, - 6, - 7, - 1, - 0, - 5, - 0, - 1, - 6, - 5, - 3, - 6, - 2, - 6, - 3, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 16, - 14, - 14, - 20, - 22, - 13, - 20, - 16, - 25, - 30, - 28, - 29, - 11, - 18, - 16, - 7, - 5, - 13, - 11, - 10, - 16, - 22, - 20, - 14, - 11, - 10, - 12, - 31, - 19, - 59, - 17, - 19, - 53, - 20, - 19, - 20, - 19, - 42, - 31, - 40, - 18, - 15, - 19, - 23, - 0, - 0 - ], - "sentences_per_chapter": [ - 22, - 49, - 39, - 32, - 37, - 30, - 40, - 44, - 58, - 83, - 63, - 42, - 15, - 47, - 34, - 26, - 15, - 45, - 28, - 47, - 34, - 64, - 55, - 47, - 30, - 28, - 45, - 54, - 36, - 69, - 35, - 56, - 85, - 50, - 51, - 39, - 43, - 93, - 62, - 104, - 41, - 33, - 46, - 50, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 529, - "subsections": 3, - "paragraphs": 16, - "sentences": 22 - }, - { - "name": "The emergence of exchanges", - "chapters": 6, - "words": 4620, - "subsections": 19, - "paragraphs": 103, - "sentences": 227 - }, - { - "name": "Economy and money in ancient civilizations", - "chapters": 5, - "words": 6098, - "subsections": 17, - "paragraphs": 128, - "sentences": 290 - }, - { - "name": "The birth of minted money", - "chapters": 4, - "words": 2932, - "subsections": 12, - "paragraphs": 52, - "sentences": 122 - }, - { - "name": "Different theories on the emergence of money", - "chapters": 8, - "words": 6858, - "subsections": 22, - "paragraphs": 111, - "sentences": 335 - }, - { - "name": "Different definitions of money", - "chapters": 5, - "words": 4168, - "subsections": 12, - "paragraphs": 83, - "sentences": 193 - }, - { - "name": "The fall and rebirth of money", - "chapters": 8, - "words": 8689, - "subsections": 21, - "paragraphs": 226, - "sentences": 428 - }, - { - "name": "The seizure of money by the State", - "chapters": 7, - "words": 8213, - "subsections": 31, - "paragraphs": 188, - "sentences": 429 - }, - { - "name": "Final section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "lnp201", - "language": "en", - "parts": 7, - "chapters": 16, - "total_words": 12041, - "total_subsections": 46, - "total_paragraphs": 303, - "total_sentences": 563, - "intro_words": 180, - "chapters_per_part": [ - 1, - 2, - 4, - 3, - 2, - 1, - 3 - ], - "words_per_chapter": [ - 309, - 697, - 830, - 626, - 632, - 982, - 981, - 1480, - 1443, - 1229, - 1109, - 1027, - 513, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 4, - 4, - 5, - 4, - 5, - 4, - 3, - 4, - 4, - 3, - 3, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 8, - 19, - 20, - 18, - 19, - 25, - 27, - 35, - 38, - 25, - 32, - 21, - 16, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 16, - 39, - 42, - 29, - 32, - 41, - 45, - 72, - 57, - 57, - 55, - 51, - 24, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 309, - "subsections": 0, - "paragraphs": 8, - "sentences": 16 - }, - { - "name": "The Fundamentals", - "chapters": 2, - "words": 1527, - "subsections": 7, - "paragraphs": 39, - "sentences": 81 - }, - { - "name": "Opening and Closing Channels", - "chapters": 4, - "words": 3221, - "subsections": 18, - "paragraphs": 89, - "sentences": 147 - }, - { - "name": "A Liquidity Network", - "chapters": 3, - "words": 4152, - "subsections": 11, - "paragraphs": 98, - "sentences": 186 - }, - { - "name": "The Tools of the Lightning Network", - "chapters": 2, - "words": 2136, - "subsections": 7, - "paragraphs": 53, - "sentences": 106 - }, - { - "name": "Go Further", - "chapters": 1, - "words": 513, - "subsections": 3, - "paragraphs": 16, - "sentences": 24 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "lnp206", - "language": "en", - "parts": 3, - "chapters": 10, - "total_words": 636, - "total_subsections": 0, - "total_paragraphs": 33, - "total_sentences": 21, - "intro_words": 114, - "chapters_per_part": [ - 3, - 5, - 2 - ], - "words_per_chapter": [ - 84, - 53, - 79, - 47, - 69, - 78, - 55, - 55, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 6, - 3, - 5, - 4, - 4, - 4, - 4, - 3, - 0, - 0 - ], - "sentences_per_chapter": [ - 2, - 2, - 2, - 2, - 3, - 4, - 2, - 2, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Theory", - "chapters": 3, - "words": 216, - "subsections": 0, - "paragraphs": 14, - "sentences": 6 - }, - { - "name": "Game & Hands-on", - "chapters": 5, - "words": 304, - "subsections": 0, - "paragraphs": 19, - "sentences": 13 - }, - { - "name": "Conclusion", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "min201", - "language": "en", - "parts": 7, - "chapters": 21, - "total_words": 12428, - "total_subsections": 33, - "total_paragraphs": 222, - "total_sentences": 626, - "intro_words": 171, - "chapters_per_part": [ - 1, - 6, - 3, - 4, - 2, - 2, - 3 - ], - "words_per_chapter": [ - 355, - 1282, - 1309, - 1157, - 963, - 768, - 903, - 1139, - 710, - 468, - 267, - 71, - 359, - 199, - 607, - 367, - 745, - 585, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 4, - 5, - 4, - 5, - 3, - 3, - 2, - 0, - 0, - 0, - 0, - 0, - 4, - 1, - 1, - 0, - 1, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 8, - 14, - 17, - 16, - 14, - 12, - 11, - 19, - 17, - 14, - 12, - 1, - 11, - 8, - 16, - 12, - 12, - 8, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 18, - 59, - 64, - 54, - 46, - 37, - 42, - 56, - 41, - 26, - 20, - 4, - 22, - 14, - 34, - 23, - 33, - 30, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 355, - "subsections": 0, - "paragraphs": 8, - "sentences": 18 - }, - { - "name": "Everything You Need to Know About Mining", - "chapters": 6, - "words": 6382, - "subsections": 24, - "paragraphs": 84, - "sentences": 302 - }, - { - "name": "Home Mining and Heat Reuse", - "chapters": 3, - "words": 2317, - "subsections": 2, - "paragraphs": 50, - "sentences": 123 - }, - { - "name": "Attakai - Modifying the Software of an Antminer S9", - "chapters": 4, - "words": 896, - "subsections": 4, - "paragraphs": 32, - "sentences": 60 - }, - { - "name": "Attakai - Fan Modification", - "chapters": 2, - "words": 974, - "subsections": 2, - "paragraphs": 28, - "sentences": 57 - }, - { - "name": "Attakai - Configuration", - "chapters": 2, - "words": 1330, - "subsections": 1, - "paragraphs": 20, - "sentences": 63 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "min306", - "language": "en", - "parts": 8, - "chapters": 21, - "total_words": 17702, - "total_subsections": 82, - "total_paragraphs": 321, - "total_sentences": 786, - "intro_words": 124, - "chapters_per_part": [ - 1, - 3, - 1, - 4, - 4, - 4, - 2, - 2 - ], - "words_per_chapter": [ - 425, - 1235, - 915, - 1135, - 1236, - 1191, - 1237, - 919, - 663, - 821, - 895, - 735, - 857, - 813, - 940, - 981, - 955, - 817, - 806, - 1, - 1 - ], - "subsections_per_chapter": [ - 6, - 4, - 7, - 5, - 5, - 5, - 4, - 3, - 4, - 4, - 4, - 4, - 4, - 3, - 4, - 4, - 4, - 4, - 4, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 12, - 17, - 20, - 23, - 22, - 20, - 19, - 14, - 14, - 17, - 17, - 14, - 17, - 15, - 19, - 16, - 17, - 14, - 14, - 0, - 0 - ], - "sentences_per_chapter": [ - 22, - 48, - 39, - 53, - 50, - 48, - 51, - 36, - 29, - 40, - 51, - 33, - 43, - 37, - 40, - 41, - 44, - 39, - 40, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 425, - "subsections": 6, - "paragraphs": 12, - "sentences": 22 - }, - { - "name": "Understanding Bitaxe", - "chapters": 3, - "words": 3285, - "subsections": 16, - "paragraphs": 60, - "sentences": 140 - }, - { - "name": "Software and Operations", - "chapters": 1, - "words": 1236, - "subsections": 5, - "paragraphs": 22, - "sentences": 50 - }, - { - "name": "Community and Collaboration", - "chapters": 4, - "words": 4010, - "subsections": 16, - "paragraphs": 67, - "sentences": 164 - }, - { - "name": "Hardware Assembly and Troubleshooting", - "chapters": 4, - "words": 3308, - "subsections": 16, - "paragraphs": 65, - "sentences": 167 - }, - { - "name": "Advanced Customization", - "chapters": 4, - "words": 3689, - "subsections": 15, - "paragraphs": 67, - "sentences": 162 - }, - { - "name": "Performance Optimization", - "chapters": 2, - "words": 1623, - "subsections": 8, - "paragraphs": 28, - "sentences": 79 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "net302", - "language": "en", - "parts": 6, - "chapters": 23, - "total_words": 21944, - "total_subsections": 47, - "total_paragraphs": 546, - "total_sentences": 967, - "intro_words": 217, - "chapters_per_part": [ - 1, - 6, - 6, - 4, - 3, - 3 - ], - "words_per_chapter": [ - 319, - 812, - 726, - 535, - 2580, - 1267, - 739, - 1101, - 581, - 559, - 1433, - 1489, - 355, - 1978, - 656, - 1357, - 752, - 1807, - 1296, - 1382, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 4, - 5, - 0, - 0, - 5, - 0, - 3, - 0, - 0, - 0, - 3, - 3, - 3, - 3, - 0, - 4, - 3, - 3, - 4, - 4, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 10, - 17, - 13, - 9, - 54, - 22, - 18, - 24, - 11, - 10, - 29, - 29, - 20, - 48, - 13, - 38, - 20, - 61, - 48, - 52, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 21, - 39, - 26, - 18, - 108, - 54, - 29, - 39, - 28, - 30, - 70, - 60, - 14, - 91, - 27, - 68, - 38, - 82, - 66, - 56, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 319, - "subsections": 4, - "paragraphs": 10, - "sentences": 21 - }, - { - "name": "TCP/IP protocols", - "chapters": 6, - "words": 6659, - "subsections": 13, - "paragraphs": 133, - "sentences": 274 - }, - { - "name": "IPv4 addressing", - "chapters": 6, - "words": 5518, - "subsections": 9, - "paragraphs": 123, - "sentences": 241 - }, - { - "name": "IPv6 addressing", - "chapters": 4, - "words": 4743, - "subsections": 10, - "paragraphs": 119, - "sentences": 224 - }, - { - "name": "Network diagnostic tools", - "chapters": 3, - "words": 4485, - "subsections": 11, - "paragraphs": 161, - "sentences": 204 - }, - { - "name": "Final part", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "phi101", - "language": "en", - "parts": 8, - "chapters": 29, - "total_words": 28485, - "total_subsections": 65, - "total_paragraphs": 550, - "total_sentences": 1288, - "intro_words": 103, - "chapters_per_part": [ - 1, - 5, - 3, - 5, - 4, - 5, - 3, - 3 - ], - "words_per_chapter": [ - 483, - 1079, - 1005, - 737, - 1428, - 853, - 1794, - 1676, - 1166, - 918, - 880, - 584, - 939, - 1284, - 1154, - 998, - 1801, - 1617, - 1096, - 1634, - 791, - 1086, - 819, - 1077, - 945, - 535, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 2, - 2, - 2, - 2, - 3, - 4, - 5, - 3, - 2, - 2, - 2, - 2, - 3, - 2, - 2, - 5, - 4, - 3, - 2, - 2, - 4, - 2, - 2, - 3, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 9, - 21, - 21, - 13, - 26, - 18, - 32, - 40, - 26, - 21, - 14, - 14, - 13, - 23, - 22, - 23, - 38, - 35, - 20, - 30, - 9, - 20, - 14, - 18, - 21, - 9, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 18, - 59, - 57, - 31, - 64, - 34, - 76, - 77, - 52, - 39, - 50, - 27, - 52, - 54, - 55, - 49, - 68, - 85, - 41, - 62, - 35, - 58, - 27, - 47, - 39, - 29, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 483, - "subsections": 0, - "paragraphs": 9, - "sentences": 18 - }, - { - "name": "Freedom or Power", - "chapters": 5, - "words": 5102, - "subsections": 11, - "paragraphs": 99, - "sentences": 245 - }, - { - "name": "The origins of freedom: Antiquity", - "chapters": 3, - "words": 4636, - "subsections": 12, - "paragraphs": 98, - "sentences": 205 - }, - { - "name": "The origins of freedom: the Middle Ages", - "chapters": 5, - "words": 4605, - "subsections": 11, - "paragraphs": 85, - "sentences": 222 - }, - { - "name": "The Rise of Freedom: From the Renaissance to the Enlightenment", - "chapters": 4, - "words": 5570, - "subsections": 13, - "paragraphs": 118, - "sentences": 257 - }, - { - "name": "Apogee and Decline: From the 19th to the 20th Century", - "chapters": 5, - "words": 5426, - "subsections": 13, - "paragraphs": 93, - "sentences": 223 - }, - { - "name": "The Rise of the Welfare State in the 20th Century", - "chapters": 3, - "words": 2557, - "subsections": 5, - "paragraphs": 48, - "sentences": 115 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "phi305", - "language": "en", - "parts": 5, - "chapters": 14, - "total_words": 9457, - "total_subsections": 0, - "total_paragraphs": 211, - "total_sentences": 321, - "intro_words": 79, - "chapters_per_part": [ - 2, - 2, - 4, - 3, - 3 - ], - "words_per_chapter": [ - 798, - 716, - 690, - 935, - 707, - 1094, - 1023, - 885, - 814, - 870, - 843, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 20, - 17, - 19, - 22, - 14, - 23, - 18, - 19, - 21, - 21, - 17, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 21, - 23, - 33, - 29, - 21, - 36, - 30, - 28, - 29, - 37, - 31, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 2, - "words": 1514, - "subsections": 0, - "paragraphs": 37, - "sentences": 44 - }, - { - "name": "Major works, Ethics and politics", - "chapters": 2, - "words": 1625, - "subsections": 0, - "paragraphs": 41, - "sentences": 62 - }, - { - "name": "The Spinozist revolution, the three illusions", - "chapters": 4, - "words": 3709, - "subsections": 0, - "paragraphs": 74, - "sentences": 115 - }, - { - "name": "From affect theory to Bitcoin", - "chapters": 3, - "words": 2527, - "subsections": 0, - "paragraphs": 59, - "sentences": 97 - }, - { - "name": "Final section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "pos305", - "language": "en", - "parts": 6, - "chapters": 21, - "total_words": 22090, - "total_subsections": 118, - "total_paragraphs": 652, - "total_sentences": 1193, - "intro_words": 101, - "chapters_per_part": [ - 4, - 3, - 4, - 5, - 3, - 2 - ], - "words_per_chapter": [ - 300, - 507, - 992, - 91, - 657, - 533, - 411, - 634, - 587, - 2898, - 1397, - 675, - 3022, - 2711, - 2464, - 2298, - 931, - 492, - 387, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 4, - 2, - 2, - 7, - 10, - 4, - 5, - 6, - 12, - 8, - 10, - 12, - 12, - 7, - 7, - 4, - 3, - 3, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 7, - 13, - 13, - 7, - 23, - 24, - 15, - 15, - 14, - 68, - 49, - 19, - 75, - 94, - 69, - 86, - 31, - 15, - 15, - 0, - 0 - ], - "sentences_per_chapter": [ - 16, - 35, - 41, - 2, - 32, - 35, - 25, - 52, - 31, - 150, - 69, - 38, - 163, - 161, - 109, - 118, - 55, - 37, - 22, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 4, - "words": 1890, - "subsections": 8, - "paragraphs": 40, - "sentences": 94 - }, - { - "name": "Introducing BTCPay Server", - "chapters": 3, - "words": 1601, - "subsections": 21, - "paragraphs": 62, - "sentences": 92 - }, - { - "name": "Introduction to Securing Bitcoin Keys", - "chapters": 4, - "words": 5516, - "subsections": 31, - "paragraphs": 146, - "sentences": 302 - }, - { - "name": "BTCPay Server Interface", - "chapters": 5, - "words": 11170, - "subsections": 48, - "paragraphs": 343, - "sentences": 589 - }, - { - "name": "Configuring BTCPay Server", - "chapters": 3, - "words": 1810, - "subsections": 10, - "paragraphs": 61, - "sentences": 114 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "pro101", - "language": "en", - "parts": 5, - "chapters": 22, - "total_words": 19223, - "total_subsections": 90, - "total_paragraphs": 673, - "total_sentences": 1038, - "intro_words": 130, - "chapters_per_part": [ - 6, - 5, - 3, - 4, - 4 - ], - "words_per_chapter": [ - 222, - 1055, - 621, - 531, - 1960, - 1225, - 657, - 710, - 812, - 878, - 980, - 929, - 1012, - 1220, - 556, - 678, - 1126, - 981, - 1222, - 744, - 928, - 46 - ], - "subsections_per_chapter": [ - 2, - 7, - 5, - 7, - 10, - 5, - 3, - 3, - 2, - 4, - 3, - 5, - 4, - 3, - 4, - 4, - 3, - 5, - 4, - 3, - 4, - 0 - ], - "paragraphs_per_chapter": [ - 4, - 35, - 19, - 18, - 97, - 33, - 24, - 17, - 20, - 24, - 32, - 30, - 50, - 63, - 16, - 21, - 41, - 47, - 37, - 18, - 26, - 1 - ], - "sentences_per_chapter": [ - 9, - 57, - 32, - 30, - 104, - 71, - 38, - 39, - 47, - 48, - 51, - 49, - 62, - 80, - 27, - 34, - 64, - 50, - 56, - 41, - 46, - 3 - ], - "parts_detail": [ - { - "name": "Introduction and Preparatory Courses", - "chapters": 6, - "words": 5614, - "subsections": 36, - "paragraphs": 206, - "sentences": 303 - }, - { - "name": "Bitcoin Fundamentals", - "chapters": 5, - "words": 4037, - "subsections": 15, - "paragraphs": 117, - "sentences": 223 - }, - { - "name": "Layer One Concepts", - "chapters": 3, - "words": 3161, - "subsections": 12, - "paragraphs": 143, - "sentences": 191 - }, - { - "name": "Lightning Network", - "chapters": 4, - "words": 3341, - "subsections": 16, - "paragraphs": 125, - "sentences": 175 - }, - { - "name": "The Challenges of LN", - "chapters": 4, - "words": 2940, - "subsections": 11, - "paragraphs": 82, - "sentences": 146 - } - ] - }, - { - "id": "pro202", - "language": "en", - "parts": 5, - "chapters": 12, - "total_words": 4637, - "total_subsections": 34, - "total_paragraphs": 127, - "total_sentences": 257, - "intro_words": 94, - "chapters_per_part": [ - 1, - 2, - 3, - 3, - 3 - ], - "words_per_chapter": [ - 342, - 420, - 645, - 584, - 601, - 445, - 459, - 442, - 602, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 3, - 4, - 3, - 4, - 4, - 4, - 4, - 4, - 4, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 12, - 11, - 13, - 19, - 13, - 12, - 12, - 13, - 22, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 18, - 25, - 35, - 35, - 30, - 29, - 25, - 25, - 32, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 342, - "subsections": 3, - "paragraphs": 12, - "sentences": 18 - }, - { - "name": "Essential Mathematical Concepts for Bitcoin", - "chapters": 2, - "words": 1065, - "subsections": 7, - "paragraphs": 24, - "sentences": 60 - }, - { - "name": "Bitcoin Transaction Innerworkings", - "chapters": 3, - "words": 1630, - "subsections": 12, - "paragraphs": 44, - "sentences": 94 - }, - { - "name": "Bitcoin Network Innerworkings", - "chapters": 3, - "words": 1503, - "subsections": 12, - "paragraphs": 47, - "sentences": 82 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "scu101", - "language": "en", - "parts": 7, - "chapters": 19, - "total_words": 6362, - "total_subsections": 31, - "total_paragraphs": 159, - "total_sentences": 354, - "intro_words": 129, - "chapters_per_part": [ - 1, - 2, - 2, - 3, - 6, - 2, - 3 - ], - "words_per_chapter": [ - 539, - 596, - 686, - 741, - 473, - 431, - 611, - 544, - 79, - 93, - 82, - 27, - 73, - 89, - 351, - 815, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 4, - 3, - 3, - 4, - 3, - 4, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 3, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 17, - 14, - 11, - 17, - 16, - 11, - 17, - 17, - 1, - 2, - 3, - 1, - 3, - 2, - 12, - 15, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 27, - 31, - 38, - 43, - 30, - 21, - 39, - 29, - 3, - 5, - 4, - 1, - 4, - 4, - 27, - 45, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 539, - "subsections": 0, - "paragraphs": 17, - "sentences": 27 - }, - { - "name": "Everything you need to know about online browsing", - "chapters": 2, - "words": 1282, - "subsections": 7, - "paragraphs": 25, - "sentences": 69 - }, - { - "name": "Best Practices for Computer Use", - "chapters": 2, - "words": 1214, - "subsections": 7, - "paragraphs": 33, - "sentences": 73 - }, - { - "name": "Implementation of solutions.", - "chapters": 3, - "words": 1586, - "subsections": 10, - "paragraphs": 45, - "sentences": 89 - }, - { - "name": "Practical Section", - "chapters": 6, - "words": 443, - "subsections": 0, - "paragraphs": 12, - "sentences": 21 - }, - { - "name": "Go further", - "chapters": 2, - "words": 1166, - "subsections": 7, - "paragraphs": 27, - "sentences": 72 - }, - { - "name": "Final Section", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "scu202", - "language": "en", - "parts": 7, - "chapters": 24, - "total_words": 50145, - "total_subsections": 134, - "total_paragraphs": 1456, - "total_sentences": 2373, - "intro_words": 199, - "chapters_per_part": [ - 2, - 5, - 5, - 3, - 4, - 2, - 3 - ], - "words_per_chapter": [ - 557, - 782, - 1824, - 1671, - 3262, - 2188, - 3950, - 2517, - 1326, - 1337, - 2427, - 939, - 3232, - 2764, - 3418, - 2456, - 2445, - 2982, - 4252, - 2316, - 3298, - 1, - 1, - 1 - ], - "subsections_per_chapter": [ - 7, - 7, - 6, - 5, - 3, - 4, - 8, - 5, - 5, - 2, - 3, - 3, - 6, - 5, - 11, - 4, - 8, - 3, - 24, - 6, - 9, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 22, - 27, - 42, - 36, - 96, - 70, - 189, - 59, - 47, - 54, - 85, - 37, - 71, - 65, - 97, - 56, - 64, - 58, - 132, - 57, - 92, - 0, - 0, - 0 - ], - "sentences_per_chapter": [ - 26, - 42, - 85, - 68, - 160, - 88, - 175, - 109, - 68, - 71, - 129, - 43, - 158, - 120, - 154, - 117, - 123, - 124, - 251, - 99, - 160, - 1, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 2, - "words": 1339, - "subsections": 14, - "paragraphs": 49, - "sentences": 68 - }, - { - "name": "From click to terminal: mastering Linux", - "chapters": 5, - "words": 12895, - "subsections": 26, - "paragraphs": 433, - "sentences": 576 - }, - { - "name": "Securing your computer", - "chapters": 5, - "words": 8546, - "subsections": 18, - "paragraphs": 282, - "sentences": 420 - }, - { - "name": "The browser: an OS within the OS", - "chapters": 3, - "words": 9414, - "subsections": 22, - "paragraphs": 233, - "sentences": 432 - }, - { - "name": "Taking back control of your phone", - "chapters": 4, - "words": 12135, - "subsections": 39, - "paragraphs": 310, - "sentences": 615 - }, - { - "name": "Securing your local network", - "chapters": 2, - "words": 5614, - "subsections": 15, - "paragraphs": 149, - "sentences": 259 - }, - { - "name": "Final part", - "chapters": 3, - "words": 3, - "subsections": 0, - "paragraphs": 0, - "sentences": 3 - } - ] - }, - { - "id": "sid202", - "language": "en", - "parts": 5, - "chapters": 14, - "total_words": 9570, - "total_subsections": 14, - "total_paragraphs": 303, - "total_sentences": 499, - "intro_words": 167, - "chapters_per_part": [ - 2, - 3, - 3, - 4, - 2 - ], - "words_per_chapter": [ - 325, - 454, - 1020, - 428, - 483, - 764, - 1193, - 884, - 1002, - 1646, - 1071, - 131, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 1, - 3, - 1, - 1, - 2, - 0, - 0, - 1, - 3, - 2, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 8, - 12, - 27, - 10, - 12, - 28, - 44, - 32, - 35, - 45, - 47, - 3, - 0, - 0 - ], - "sentences_per_chapter": [ - 18, - 18, - 47, - 17, - 22, - 44, - 75, - 48, - 58, - 91, - 55, - 4, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 2, - "words": 779, - "subsections": 1, - "paragraphs": 20, - "sentences": 36 - }, - { - "name": "Element", - "chapters": 3, - "words": 1931, - "subsections": 5, - "paragraphs": 49, - "sentences": 86 - }, - { - "name": "Using Element Practical Use Case", - "chapters": 3, - "words": 2841, - "subsections": 2, - "paragraphs": 104, - "sentences": 167 - }, - { - "name": "Element Federation", - "chapters": 4, - "words": 3850, - "subsections": 6, - "paragraphs": 130, - "sentences": 208 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - }, - { - "id": "sid302", - "language": "en", - "parts": 4, - "chapters": 10, - "total_words": 651, - "total_subsections": 0, - "total_paragraphs": 12, - "total_sentences": 28, - "intro_words": 125, - "chapters_per_part": [ - 1, - 6, - 1, - 2 - ], - "words_per_chapter": [ - 245, - 53, - 36, - 34, - 35, - 38, - 44, - 39, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_chapter": [ - 5, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0 - ], - "sentences_per_chapter": [ - 10, - 3, - 2, - 2, - 2, - 2, - 3, - 2, - 1, - 1 - ], - "parts_detail": [ - { - "name": "Introduction", - "chapters": 1, - "words": 245, - "subsections": 0, - "paragraphs": 5, - "sentences": 10 - }, - { - "name": "Fundamentals", - "chapters": 6, - "words": 240, - "subsections": 0, - "paragraphs": 6, - "sentences": 14 - }, - { - "name": "Technical", - "chapters": 1, - "words": 39, - "subsections": 0, - "paragraphs": 1, - "sentences": 2 - }, - { - "name": "Final Section", - "chapters": 2, - "words": 2, - "subsections": 0, - "paragraphs": 0, - "sentences": 2 - } - ] - } - ], - "aggregated": { - "parts_per_course": { - "count": 37, - "mean": 6.11, - "std": 1.7, - "median": 6, - "min": 3, - "max": 11, - "sum": 226 - }, - "chapters_per_course": { - "count": 37, - "mean": 21.08, - "std": 8.8, - "median": 20, - "min": 10, - "max": 48, - "sum": 780 - }, - "words_per_course": { - "count": 37, - "mean": 20901.62, - "std": 13614.86, - "median": 17855, - "min": 636, - "max": 55239, - "sum": 773360 - }, - "subsections_per_course": { - "count": 37, - "mean": 55.3, - "std": 41.15, - "median": 47, - "min": 0, - "max": 138, - "sum": 2046 - }, - "paragraphs_per_course": { - "count": 37, - "mean": 488.22, - "std": 315.23, - "median": 436, - "min": 12, - "max": 1456, - "sum": 18064 - }, - "sentences_per_course": { - "count": 37, - "mean": 965.41, - "std": 612.58, - "median": 872, - "min": 21, - "max": 2373, - "sum": 35720 - }, - "chapters_per_part": { - "count": 226, - "mean": 3.45, - "std": 1.94, - "median": 3.0, - "min": 1, - "max": 17, - "sum": 780 - }, - "words_per_chapter": { - "count": 780, - "mean": 983.93, - "std": 999.76, - "median": 755.5, - "min": 1, - "max": 8197, - "sum": 767468 - }, - "subsections_per_chapter": { - "count": 780, - "mean": 2.62, - "std": 2.82, - "median": 3.0, - "min": 0, - "max": 24, - "sum": 2046 - }, - "paragraphs_per_chapter": { - "count": 780, - "mean": 23.16, - "std": 23.5, - "median": 18.0, - "min": 0, - "max": 252, - "sum": 18064 - }, - "sentences_per_chapter": { - "count": 780, - "mean": 45.79, - "std": 43.96, - "median": 37.0, - "min": 1, - "max": 354, - "sum": 35720 - } - }, - "distributions": { - "parts_per_course": [ - 6, - 11, - 7, - 6, - 6, - 7, - 4, - 9, - 5, - 6, - 6, - 9, - 5, - 5, - 6, - 4, - 7, - 5, - 4, - 6, - 4, - 6, - 9, - 7, - 3, - 7, - 8, - 6, - 8, - 5, - 6, - 5, - 5, - 7, - 7, - 5, - 4 - ], - "chapters_per_course": [ - 20, - 20, - 25, - 18, - 21, - 31, - 14, - 48, - 15, - 19, - 22, - 35, - 31, - 18, - 23, - 11, - 26, - 14, - 12, - 17, - 12, - 26, - 46, - 16, - 10, - 21, - 21, - 23, - 29, - 14, - 21, - 22, - 12, - 19, - 24, - 14, - 10 - ], - "words_per_course": [ - 19184, - 15040, - 17855, - 28325, - 22326, - 51251, - 25961, - 15689, - 27201, - 14077, - 23290, - 35794, - 11992, - 20857, - 34947, - 12497, - 17532, - 15665, - 788, - 34841, - 55239, - 15444, - 42194, - 12041, - 636, - 12428, - 17702, - 21944, - 28485, - 9457, - 22090, - 19223, - 4637, - 6362, - 50145, - 9570, - 651 - ], - "chapters_per_part": [ - 1, - 4, - 3, - 6, - 3, - 3, - 1, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 1, - 2, - 2, - 4, - 4, - 5, - 4, - 3, - 3, - 1, - 3, - 4, - 5, - 2, - 3, - 5, - 4, - 2, - 3, - 4, - 3, - 1, - 3, - 5, - 6, - 6, - 7, - 3, - 1, - 4, - 6, - 3, - 4, - 2, - 6, - 4, - 7, - 17, - 3, - 3, - 2, - 1, - 5, - 4, - 3, - 2, - 1, - 3, - 4, - 6, - 3, - 2, - 1, - 3, - 4, - 4, - 7, - 3, - 1, - 3, - 4, - 5, - 10, - 2, - 4, - 3, - 3, - 7, - 7, - 7, - 8, - 2, - 1, - 7, - 4, - 4, - 2, - 1, - 4, - 5, - 5, - 6, - 2, - 2, - 3, - 3, - 3, - 3, - 4, - 4, - 4, - 4, - 4, - 3, - 1, - 2, - 6, - 2, - 3, - 3, - 3, - 4, - 2, - 1, - 3, - 3, - 3, - 4, - 3, - 1, - 4, - 3, - 4, - 1, - 4, - 6, - 4, - 8, - 3, - 1, - 6, - 5, - 4, - 8, - 5, - 8, - 7, - 2, - 1, - 2, - 4, - 3, - 2, - 1, - 3, - 3, - 5, - 2, - 1, - 6, - 3, - 4, - 2, - 2, - 3, - 1, - 3, - 1, - 4, - 4, - 4, - 2, - 2, - 1, - 6, - 6, - 4, - 3, - 3, - 1, - 5, - 3, - 5, - 4, - 5, - 3, - 3, - 2, - 2, - 4, - 3, - 3, - 4, - 3, - 4, - 5, - 3, - 2, - 6, - 5, - 3, - 4, - 4, - 1, - 2, - 3, - 3, - 3, - 1, - 2, - 2, - 3, - 6, - 2, - 3, - 2, - 5, - 5, - 3, - 4, - 2, - 3, - 2, - 3, - 3, - 4, - 2, - 1, - 6, - 1, - 2 - ], - "words_per_chapter": [ - 351, - 1776, - 1202, - 616, - 804, - 1750, - 2476, - 1744, - 1605, - 842, - 762, - 588, - 724, - 759, - 1883, - 360, - 775, - 1, - 1, - 1, - 278, - 1423, - 839, - 780, - 890, - 1021, - 1002, - 779, - 762, - 748, - 245, - 769, - 710, - 718, - 861, - 1605, - 949, - 260, - 1, - 1, - 639, - 748, - 682, - 891, - 1058, - 1147, - 802, - 706, - 479, - 649, - 712, - 923, - 793, - 1399, - 1199, - 764, - 508, - 910, - 404, - 595, - 522, - 1220, - 1, - 1, - 1, - 562, - 2439, - 1311, - 2316, - 2208, - 2247, - 2822, - 1669, - 3344, - 1031, - 1197, - 1138, - 1504, - 3278, - 1086, - 1, - 1, - 1, - 545, - 1009, - 187, - 450, - 995, - 2302, - 1062, - 1677, - 2685, - 907, - 2463, - 1341, - 701, - 1515, - 932, - 1184, - 697, - 1494, - 1, - 1, - 1, - 907, - 546, - 1395, - 1037, - 976, - 1373, - 1072, - 1537, - 1836, - 1389, - 1648, - 2344, - 1640, - 1067, - 886, - 1347, - 1624, - 1538, - 3113, - 910, - 2067, - 1372, - 1635, - 1178, - 2089, - 1481, - 8197, - 4842, - 1, - 1, - 1, - 290, - 2387, - 2032, - 2856, - 1372, - 3553, - 1376, - 3409, - 3102, - 4615, - 726, - 1, - 1, - 1, - 708, - 545, - 321, - 560, - 383, - 774, - 242, - 687, - 221, - 178, - 257, - 159, - 190, - 596, - 356, - 418, - 168, - 620, - 471, - 185, - 294, - 133, - 424, - 250, - 524, - 120, - 286, - 328, - 341, - 213, - 243, - 140, - 147, - 183, - 165, - 180, - 209, - 409, - 528, - 277, - 802, - 269, - 142, - 522, - 247, - 184, - 1, - 1, - 350, - 4139, - 3989, - 1094, - 1352, - 2496, - 4884, - 2312, - 1497, - 990, - 1199, - 1716, - 1021, - 1, - 1, - 237, - 913, - 977, - 999, - 861, - 784, - 684, - 568, - 759, - 914, - 759, - 707, - 885, - 746, - 1048, - 886, - 1227, - 1, - 1, - 744, - 1313, - 3189, - 744, - 1369, - 1301, - 1823, - 982, - 856, - 345, - 1185, - 561, - 703, - 1401, - 1642, - 1144, - 698, - 466, - 2665, - 1, - 1, - 1, - 703, - 822, - 1037, - 457, - 2140, - 1000, - 414, - 1054, - 316, - 1450, - 1599, - 3063, - 510, - 419, - 436, - 539, - 1175, - 1631, - 1307, - 406, - 524, - 653, - 465, - 1832, - 2276, - 922, - 1355, - 1733, - 1344, - 1475, - 1051, - 1577, - 1, - 1, - 1, - 335, - 564, - 231, - 302, - 409, - 343, - 1238, - 308, - 332, - 385, - 452, - 291, - 331, - 176, - 295, - 425, - 362, - 362, - 294, - 257, - 339, - 693, - 186, - 449, - 359, - 594, - 401, - 1056, - 90, - 1, - 1, - 300, - 1733, - 1429, - 1822, - 1151, - 2061, - 1404, - 965, - 1170, - 1149, - 1195, - 744, - 1100, - 980, - 1103, - 2422, - 1, - 1, - 702, - 1875, - 3159, - 2908, - 161, - 1956, - 1768, - 2232, - 2173, - 202, - 1420, - 869, - 1774, - 2600, - 163, - 7117, - 1219, - 640, - 1179, - 204, - 450, - 1, - 1, - 503, - 1835, - 1934, - 1671, - 1556, - 2143, - 2059, - 648, - 1, - 1, - 1, - 391, - 664, - 769, - 809, - 715, - 784, - 846, - 891, - 698, - 853, - 736, - 913, - 814, - 690, - 729, - 864, - 812, - 750, - 648, - 715, - 729, - 780, - 771, - 1, - 1, - 1, - 235, - 2706, - 2910, - 1040, - 2618, - 1794, - 1907, - 1003, - 21, - 1027, - 287, - 1, - 1, - 1, - 89, - 71, - 67, - 68, - 67, - 66, - 60, - 69, - 62, - 64, - 1, - 1, - 1061, - 2763, - 2242, - 3563, - 3195, - 3939, - 2726, - 2385, - 1691, - 2110, - 1631, - 1929, - 2565, - 2795, - 1, - 1, - 1, - 2346, - 4956, - 4763, - 4504, - 4065, - 5735, - 4120, - 6001, - 5324, - 5125, - 2662, - 5146, - 353, - 689, - 667, - 713, - 682, - 685, - 773, - 690, - 748, - 739, - 760, - 688, - 827, - 707, - 675, - 682, - 760, - 714, - 688, - 635, - 593, - 386, - 485, - 1, - 1, - 1, - 529, - 858, - 704, - 728, - 815, - 687, - 828, - 799, - 1330, - 1664, - 1405, - 900, - 462, - 1235, - 616, - 619, - 272, - 898, - 553, - 943, - 641, - 1376, - 1244, - 931, - 582, - 528, - 811, - 1393, - 854, - 1344, - 575, - 1036, - 1983, - 1252, - 723, - 868, - 908, - 1829, - 1154, - 1808, - 668, - 737, - 722, - 1295, - 1, - 1, - 309, - 697, - 830, - 626, - 632, - 982, - 981, - 1480, - 1443, - 1229, - 1109, - 1027, - 513, - 1, - 1, - 1, - 84, - 53, - 79, - 47, - 69, - 78, - 55, - 55, - 1, - 1, - 355, - 1282, - 1309, - 1157, - 963, - 768, - 903, - 1139, - 710, - 468, - 267, - 71, - 359, - 199, - 607, - 367, - 745, - 585, - 1, - 1, - 1, - 425, - 1235, - 915, - 1135, - 1236, - 1191, - 1237, - 919, - 663, - 821, - 895, - 735, - 857, - 813, - 940, - 981, - 955, - 817, - 806, - 1, - 1, - 319, - 812, - 726, - 535, - 2580, - 1267, - 739, - 1101, - 581, - 559, - 1433, - 1489, - 355, - 1978, - 656, - 1357, - 752, - 1807, - 1296, - 1382, - 1, - 1, - 1, - 483, - 1079, - 1005, - 737, - 1428, - 853, - 1794, - 1676, - 1166, - 918, - 880, - 584, - 939, - 1284, - 1154, - 998, - 1801, - 1617, - 1096, - 1634, - 791, - 1086, - 819, - 1077, - 945, - 535, - 1, - 1, - 1, - 798, - 716, - 690, - 935, - 707, - 1094, - 1023, - 885, - 814, - 870, - 843, - 1, - 1, - 1, - 300, - 507, - 992, - 91, - 657, - 533, - 411, - 634, - 587, - 2898, - 1397, - 675, - 3022, - 2711, - 2464, - 2298, - 931, - 492, - 387, - 1, - 1, - 222, - 1055, - 621, - 531, - 1960, - 1225, - 657, - 710, - 812, - 878, - 980, - 929, - 1012, - 1220, - 556, - 678, - 1126, - 981, - 1222, - 744, - 928, - 46, - 342, - 420, - 645, - 584, - 601, - 445, - 459, - 442, - 602, - 1, - 1, - 1, - 539, - 596, - 686, - 741, - 473, - 431, - 611, - 544, - 79, - 93, - 82, - 27, - 73, - 89, - 351, - 815, - 1, - 1, - 1, - 557, - 782, - 1824, - 1671, - 3262, - 2188, - 3950, - 2517, - 1326, - 1337, - 2427, - 939, - 3232, - 2764, - 3418, - 2456, - 2445, - 2982, - 4252, - 2316, - 3298, - 1, - 1, - 1, - 325, - 454, - 1020, - 428, - 483, - 764, - 1193, - 884, - 1002, - 1646, - 1071, - 131, - 1, - 1, - 245, - 53, - 36, - 34, - 35, - 38, - 44, - 39, - 1, - 1 - ], - "subsections_per_chapter": [ - 0, - 3, - 4, - 0, - 0, - 4, - 7, - 6, - 2, - 0, - 0, - 0, - 0, - 2, - 8, - 0, - 4, - 0, - 0, - 0, - 4, - 8, - 5, - 8, - 6, - 4, - 6, - 7, - 6, - 5, - 0, - 4, - 4, - 6, - 7, - 10, - 5, - 1, - 0, - 0, - 0, - 4, - 3, - 3, - 3, - 4, - 4, - 3, - 2, - 4, - 3, - 5, - 3, - 6, - 4, - 3, - 3, - 5, - 2, - 3, - 1, - 4, - 0, - 0, - 0, - 0, - 3, - 6, - 9, - 7, - 5, - 7, - 3, - 6, - 3, - 3, - 3, - 3, - 7, - 3, - 0, - 0, - 0, - 6, - 4, - 0, - 0, - 5, - 6, - 4, - 8, - 6, - 4, - 7, - 5, - 2, - 5, - 5, - 4, - 6, - 8, - 0, - 0, - 0, - 0, - 2, - 5, - 3, - 5, - 5, - 3, - 5, - 9, - 4, - 6, - 6, - 6, - 3, - 2, - 3, - 4, - 2, - 10, - 5, - 7, - 4, - 7, - 3, - 4, - 3, - 12, - 10, - 0, - 0, - 0, - 2, - 5, - 2, - 8, - 2, - 4, - 5, - 8, - 3, - 3, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 2, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 9, - 8, - 6, - 14, - 0, - 5, - 4, - 4, - 4, - 8, - 7, - 8, - 0, - 0, - 0, - 3, - 3, - 3, - 3, - 3, - 3, - 4, - 4, - 4, - 4, - 3, - 4, - 4, - 5, - 4, - 4, - 0, - 0, - 0, - 3, - 7, - 2, - 3, - 4, - 8, - 3, - 4, - 2, - 5, - 3, - 4, - 4, - 4, - 3, - 2, - 2, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 4, - 0, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0, - 3, - 4, - 3, - 3, - 8, - 4, - 6, - 4, - 3, - 2, - 5, - 3, - 3, - 2, - 1, - 5, - 2, - 3, - 4, - 3, - 4, - 3, - 2, - 3, - 3, - 5, - 3, - 9, - 0, - 0, - 0, - 0, - 6, - 6, - 5, - 5, - 11, - 5, - 4, - 4, - 4, - 5, - 4, - 5, - 5, - 4, - 12, - 0, - 0, - 0, - 3, - 7, - 5, - 0, - 2, - 2, - 2, - 4, - 0, - 0, - 0, - 3, - 0, - 0, - 1, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 5, - 6, - 4, - 4, - 3, - 6, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 5, - 4, - 4, - 2, - 8, - 4, - 0, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 5, - 8, - 6, - 8, - 4, - 5, - 5, - 6, - 4, - 6, - 6, - 6, - 0, - 0, - 0, - 4, - 8, - 10, - 7, - 6, - 10, - 7, - 12, - 7, - 8, - 4, - 6, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 5, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 3, - 4, - 3, - 4, - 3, - 1, - 4, - 0, - 3, - 2, - 4, - 8, - 2, - 3, - 4, - 3, - 0, - 3, - 2, - 2, - 4, - 4, - 4, - 3, - 2, - 2, - 2, - 3, - 3, - 1, - 6, - 7, - 1, - 0, - 5, - 0, - 1, - 6, - 5, - 3, - 6, - 2, - 6, - 3, - 0, - 0, - 0, - 3, - 4, - 4, - 5, - 4, - 5, - 4, - 3, - 4, - 4, - 3, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 5, - 4, - 5, - 3, - 3, - 2, - 0, - 0, - 0, - 0, - 0, - 4, - 1, - 1, - 0, - 1, - 0, - 0, - 0, - 6, - 4, - 7, - 5, - 5, - 5, - 4, - 3, - 4, - 4, - 4, - 4, - 4, - 3, - 4, - 4, - 4, - 4, - 4, - 0, - 0, - 4, - 5, - 0, - 0, - 5, - 0, - 3, - 0, - 0, - 0, - 3, - 3, - 3, - 3, - 0, - 4, - 3, - 3, - 4, - 4, - 0, - 0, - 0, - 0, - 2, - 2, - 2, - 2, - 3, - 4, - 5, - 3, - 2, - 2, - 2, - 2, - 3, - 2, - 2, - 5, - 4, - 3, - 2, - 2, - 4, - 2, - 2, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 2, - 2, - 7, - 10, - 4, - 5, - 6, - 12, - 8, - 10, - 12, - 12, - 7, - 7, - 4, - 3, - 3, - 0, - 0, - 2, - 7, - 5, - 7, - 10, - 5, - 3, - 3, - 2, - 4, - 3, - 5, - 4, - 3, - 4, - 4, - 3, - 5, - 4, - 3, - 4, - 0, - 3, - 4, - 3, - 4, - 4, - 4, - 4, - 4, - 4, - 0, - 0, - 0, - 0, - 4, - 3, - 3, - 4, - 3, - 4, - 3, - 0, - 0, - 0, - 0, - 0, - 0, - 4, - 3, - 0, - 0, - 0, - 7, - 7, - 6, - 5, - 3, - 4, - 8, - 5, - 5, - 2, - 3, - 3, - 6, - 5, - 11, - 4, - 8, - 3, - 24, - 6, - 9, - 0, - 0, - 0, - 0, - 1, - 3, - 1, - 1, - 2, - 0, - 0, - 1, - 3, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "paragraphs_per_course": [ - 379, - 396, - 436, - 708, - 556, - 1268, - 501, - 487, - 789, - 280, - 614, - 759, - 781, - 398, - 670, - 204, - 353, - 347, - 24, - 541, - 774, - 324, - 907, - 303, - 33, - 222, - 321, - 546, - 550, - 211, - 652, - 673, - 127, - 159, - 1456, - 303, - 12 - ], - "sentences_per_course": [ - 878, - 667, - 811, - 1453, - 1024, - 2315, - 1204, - 811, - 1040, - 671, - 914, - 1801, - 625, - 997, - 1613, - 460, - 872, - 740, - 28, - 1476, - 2197, - 761, - 2048, - 563, - 21, - 626, - 786, - 967, - 1288, - 321, - 1193, - 1038, - 257, - 354, - 2373, - 499, - 28 - ], - "paragraphs_per_chapter": [ - 6, - 37, - 17, - 12, - 17, - 50, - 45, - 35, - 24, - 11, - 12, - 10, - 8, - 21, - 47, - 5, - 22, - 0, - 0, - 0, - 9, - 35, - 25, - 23, - 26, - 26, - 27, - 20, - 22, - 23, - 3, - 19, - 20, - 20, - 22, - 41, - 26, - 9, - 0, - 0, - 17, - 22, - 19, - 20, - 33, - 22, - 24, - 19, - 14, - 18, - 17, - 22, - 16, - 27, - 21, - 20, - 15, - 33, - 7, - 15, - 9, - 26, - 0, - 0, - 0, - 13, - 52, - 38, - 49, - 55, - 45, - 69, - 38, - 95, - 32, - 32, - 39, - 45, - 72, - 34, - 0, - 0, - 0, - 19, - 22, - 5, - 9, - 24, - 46, - 23, - 39, - 62, - 23, - 82, - 33, - 27, - 40, - 24, - 24, - 17, - 37, - 0, - 0, - 0, - 21, - 13, - 27, - 22, - 28, - 32, - 27, - 37, - 62, - 37, - 35, - 42, - 39, - 26, - 16, - 28, - 35, - 26, - 75, - 23, - 63, - 32, - 41, - 23, - 42, - 26, - 252, - 138, - 0, - 0, - 0, - 8, - 53, - 39, - 55, - 30, - 57, - 32, - 64, - 63, - 90, - 10, - 0, - 0, - 0, - 28, - 25, - 11, - 25, - 18, - 39, - 13, - 32, - 9, - 7, - 8, - 6, - 7, - 21, - 8, - 8, - 5, - 17, - 6, - 3, - 5, - 3, - 9, - 6, - 11, - 2, - 6, - 11, - 11, - 9, - 7, - 5, - 4, - 7, - 6, - 4, - 5, - 10, - 9, - 8, - 15, - 8, - 5, - 12, - 8, - 5, - 0, - 0, - 16, - 103, - 111, - 28, - 33, - 78, - 122, - 85, - 59, - 46, - 26, - 40, - 42, - 0, - 0, - 10, - 14, - 15, - 15, - 15, - 13, - 12, - 16, - 21, - 18, - 21, - 14, - 16, - 17, - 23, - 18, - 22, - 0, - 0, - 10, - 37, - 93, - 24, - 28, - 39, - 64, - 21, - 15, - 10, - 32, - 12, - 24, - 24, - 56, - 31, - 13, - 10, - 71, - 0, - 0, - 0, - 24, - 15, - 21, - 7, - 74, - 25, - 11, - 19, - 7, - 27, - 29, - 50, - 11, - 8, - 12, - 10, - 20, - 31, - 31, - 9, - 11, - 17, - 11, - 32, - 46, - 18, - 29, - 34, - 26, - 31, - 31, - 32, - 0, - 0, - 0, - 27, - 41, - 19, - 25, - 32, - 25, - 80, - 17, - 15, - 27, - 34, - 12, - 16, - 19, - 25, - 31, - 14, - 30, - 25, - 23, - 31, - 25, - 12, - 23, - 26, - 40, - 25, - 58, - 4, - 0, - 0, - 7, - 35, - 24, - 39, - 21, - 48, - 25, - 18, - 21, - 18, - 20, - 16, - 20, - 19, - 18, - 49, - 0, - 0, - 18, - 36, - 62, - 61, - 2, - 40, - 30, - 42, - 43, - 2, - 30, - 12, - 39, - 53, - 2, - 130, - 19, - 9, - 29, - 2, - 9, - 0, - 0, - 10, - 33, - 33, - 34, - 26, - 31, - 28, - 9, - 0, - 0, - 0, - 8, - 9, - 12, - 15, - 13, - 15, - 16, - 14, - 14, - 20, - 15, - 15, - 16, - 21, - 18, - 15, - 22, - 17, - 15, - 17, - 18, - 15, - 13, - 0, - 0, - 0, - 6, - 65, - 62, - 25, - 74, - 44, - 25, - 16, - 1, - 19, - 10, - 0, - 0, - 0, - 6, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 0, - 0, - 16, - 41, - 29, - 48, - 43, - 65, - 43, - 39, - 29, - 31, - 25, - 35, - 47, - 50, - 0, - 0, - 0, - 26, - 64, - 72, - 57, - 61, - 97, - 59, - 89, - 61, - 75, - 42, - 71, - 11, - 15, - 14, - 14, - 12, - 12, - 16, - 16, - 14, - 18, - 18, - 13, - 24, - 15, - 14, - 10, - 17, - 16, - 20, - 11, - 14, - 8, - 2, - 0, - 0, - 0, - 16, - 14, - 14, - 20, - 22, - 13, - 20, - 16, - 25, - 30, - 28, - 29, - 11, - 18, - 16, - 7, - 5, - 13, - 11, - 10, - 16, - 22, - 20, - 14, - 11, - 10, - 12, - 31, - 19, - 59, - 17, - 19, - 53, - 20, - 19, - 20, - 19, - 42, - 31, - 40, - 18, - 15, - 19, - 23, - 0, - 0, - 8, - 19, - 20, - 18, - 19, - 25, - 27, - 35, - 38, - 25, - 32, - 21, - 16, - 0, - 0, - 0, - 6, - 3, - 5, - 4, - 4, - 4, - 4, - 3, - 0, - 0, - 8, - 14, - 17, - 16, - 14, - 12, - 11, - 19, - 17, - 14, - 12, - 1, - 11, - 8, - 16, - 12, - 12, - 8, - 0, - 0, - 0, - 12, - 17, - 20, - 23, - 22, - 20, - 19, - 14, - 14, - 17, - 17, - 14, - 17, - 15, - 19, - 16, - 17, - 14, - 14, - 0, - 0, - 10, - 17, - 13, - 9, - 54, - 22, - 18, - 24, - 11, - 10, - 29, - 29, - 20, - 48, - 13, - 38, - 20, - 61, - 48, - 52, - 0, - 0, - 0, - 9, - 21, - 21, - 13, - 26, - 18, - 32, - 40, - 26, - 21, - 14, - 14, - 13, - 23, - 22, - 23, - 38, - 35, - 20, - 30, - 9, - 20, - 14, - 18, - 21, - 9, - 0, - 0, - 0, - 20, - 17, - 19, - 22, - 14, - 23, - 18, - 19, - 21, - 21, - 17, - 0, - 0, - 0, - 7, - 13, - 13, - 7, - 23, - 24, - 15, - 15, - 14, - 68, - 49, - 19, - 75, - 94, - 69, - 86, - 31, - 15, - 15, - 0, - 0, - 4, - 35, - 19, - 18, - 97, - 33, - 24, - 17, - 20, - 24, - 32, - 30, - 50, - 63, - 16, - 21, - 41, - 47, - 37, - 18, - 26, - 1, - 12, - 11, - 13, - 19, - 13, - 12, - 12, - 13, - 22, - 0, - 0, - 0, - 17, - 14, - 11, - 17, - 16, - 11, - 17, - 17, - 1, - 2, - 3, - 1, - 3, - 2, - 12, - 15, - 0, - 0, - 0, - 22, - 27, - 42, - 36, - 96, - 70, - 189, - 59, - 47, - 54, - 85, - 37, - 71, - 65, - 97, - 56, - 64, - 58, - 132, - 57, - 92, - 0, - 0, - 0, - 8, - 12, - 27, - 10, - 12, - 28, - 44, - 32, - 35, - 45, - 47, - 3, - 0, - 0, - 5, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0 - ], - "sentences_per_chapter": [ - 15, - 100, - 50, - 29, - 44, - 86, - 95, - 90, - 41, - 33, - 29, - 26, - 29, - 40, - 87, - 16, - 65, - 1, - 1, - 1, - 13, - 60, - 38, - 36, - 38, - 47, - 42, - 33, - 39, - 31, - 10, - 36, - 33, - 35, - 38, - 82, - 42, - 12, - 1, - 1, - 23, - 36, - 28, - 40, - 54, - 38, - 34, - 34, - 27, - 27, - 27, - 44, - 35, - 70, - 54, - 33, - 20, - 51, - 21, - 25, - 20, - 67, - 1, - 1, - 1, - 27, - 117, - 61, - 122, - 99, - 123, - 143, - 80, - 187, - 61, - 65, - 57, - 73, - 178, - 57, - 1, - 1, - 1, - 26, - 43, - 8, - 25, - 45, - 100, - 45, - 73, - 117, - 41, - 122, - 65, - 40, - 65, - 46, - 52, - 34, - 74, - 1, - 1, - 1, - 47, - 27, - 74, - 53, - 56, - 66, - 56, - 74, - 88, - 64, - 83, - 117, - 91, - 55, - 38, - 65, - 75, - 69, - 140, - 40, - 59, - 70, - 80, - 53, - 85, - 73, - 354, - 160, - 1, - 1, - 1, - 15, - 109, - 86, - 139, - 64, - 154, - 64, - 155, - 142, - 221, - 52, - 1, - 1, - 1, - 31, - 23, - 19, - 38, - 23, - 25, - 15, - 38, - 9, - 7, - 13, - 7, - 15, - 23, - 20, - 23, - 9, - 30, - 23, - 10, - 18, - 9, - 27, - 16, - 19, - 2, - 20, - 16, - 16, - 11, - 13, - 9, - 8, - 13, - 10, - 10, - 10, - 21, - 34, - 20, - 30, - 16, - 8, - 28, - 15, - 9, - 1, - 1, - 19, - 140, - 138, - 40, - 52, - 110, - 185, - 101, - 63, - 38, - 53, - 63, - 36, - 1, - 1, - 14, - 35, - 36, - 40, - 43, - 37, - 31, - 30, - 46, - 44, - 46, - 33, - 40, - 37, - 57, - 47, - 53, - 1, - 1, - 43, - 52, - 105, - 26, - 64, - 49, - 72, - 50, - 40, - 16, - 44, - 30, - 28, - 46, - 47, - 53, - 28, - 20, - 98, - 1, - 1, - 1, - 35, - 37, - 56, - 21, - 105, - 45, - 25, - 59, - 19, - 84, - 72, - 155, - 28, - 23, - 24, - 23, - 59, - 79, - 72, - 20, - 30, - 35, - 29, - 64, - 80, - 48, - 67, - 95, - 74, - 76, - 66, - 93, - 1, - 1, - 1, - 20, - 32, - 13, - 11, - 26, - 22, - 68, - 16, - 21, - 24, - 23, - 18, - 17, - 7, - 14, - 18, - 21, - 13, - 16, - 15, - 13, - 30, - 10, - 24, - 19, - 27, - 25, - 55, - 5, - 1, - 1, - 17, - 88, - 63, - 90, - 52, - 113, - 60, - 50, - 56, - 55, - 53, - 35, - 51, - 47, - 47, - 118, - 1, - 1, - 28, - 82, - 150, - 145, - 10, - 91, - 80, - 100, - 114, - 14, - 62, - 42, - 84, - 113, - 9, - 334, - 53, - 24, - 43, - 10, - 23, - 1, - 1, - 23, - 65, - 64, - 61, - 60, - 77, - 76, - 31, - 1, - 1, - 1, - 19, - 29, - 39, - 42, - 32, - 37, - 44, - 39, - 35, - 46, - 40, - 53, - 40, - 42, - 41, - 40, - 40, - 40, - 30, - 34, - 34, - 42, - 31, - 1, - 1, - 1, - 16, - 130, - 131, - 45, - 82, - 82, - 113, - 58, - 1, - 59, - 20, - 1, - 1, - 1, - 3, - 3, - 3, - 2, - 3, - 3, - 2, - 2, - 2, - 3, - 1, - 1, - 45, - 123, - 98, - 147, - 115, - 142, - 109, - 96, - 74, - 86, - 71, - 99, - 111, - 157, - 1, - 1, - 1, - 71, - 192, - 251, - 178, - 155, - 249, - 156, - 227, - 185, - 198, - 113, - 222, - 15, - 41, - 26, - 37, - 34, - 32, - 36, - 36, - 37, - 45, - 44, - 44, - 38, - 38, - 33, - 34, - 28, - 47, - 36, - 28, - 26, - 19, - 4, - 1, - 1, - 1, - 22, - 49, - 39, - 32, - 37, - 30, - 40, - 44, - 58, - 83, - 63, - 42, - 15, - 47, - 34, - 26, - 15, - 45, - 28, - 47, - 34, - 64, - 55, - 47, - 30, - 28, - 45, - 54, - 36, - 69, - 35, - 56, - 85, - 50, - 51, - 39, - 43, - 93, - 62, - 104, - 41, - 33, - 46, - 50, - 1, - 1, - 16, - 39, - 42, - 29, - 32, - 41, - 45, - 72, - 57, - 57, - 55, - 51, - 24, - 1, - 1, - 1, - 2, - 2, - 2, - 2, - 3, - 4, - 2, - 2, - 1, - 1, - 18, - 59, - 64, - 54, - 46, - 37, - 42, - 56, - 41, - 26, - 20, - 4, - 22, - 14, - 34, - 23, - 33, - 30, - 1, - 1, - 1, - 22, - 48, - 39, - 53, - 50, - 48, - 51, - 36, - 29, - 40, - 51, - 33, - 43, - 37, - 40, - 41, - 44, - 39, - 40, - 1, - 1, - 21, - 39, - 26, - 18, - 108, - 54, - 29, - 39, - 28, - 30, - 70, - 60, - 14, - 91, - 27, - 68, - 38, - 82, - 66, - 56, - 1, - 1, - 1, - 18, - 59, - 57, - 31, - 64, - 34, - 76, - 77, - 52, - 39, - 50, - 27, - 52, - 54, - 55, - 49, - 68, - 85, - 41, - 62, - 35, - 58, - 27, - 47, - 39, - 29, - 1, - 1, - 1, - 21, - 23, - 33, - 29, - 21, - 36, - 30, - 28, - 29, - 37, - 31, - 1, - 1, - 1, - 16, - 35, - 41, - 2, - 32, - 35, - 25, - 52, - 31, - 150, - 69, - 38, - 163, - 161, - 109, - 118, - 55, - 37, - 22, - 1, - 1, - 9, - 57, - 32, - 30, - 104, - 71, - 38, - 39, - 47, - 48, - 51, - 49, - 62, - 80, - 27, - 34, - 64, - 50, - 56, - 41, - 46, - 3, - 18, - 25, - 35, - 35, - 30, - 29, - 25, - 25, - 32, - 1, - 1, - 1, - 27, - 31, - 38, - 43, - 30, - 21, - 39, - 29, - 3, - 5, - 4, - 1, - 4, - 4, - 27, - 45, - 1, - 1, - 1, - 26, - 42, - 85, - 68, - 160, - 88, - 175, - 109, - 68, - 71, - 129, - 43, - 158, - 120, - 154, - 117, - 123, - 124, - 251, - 99, - 160, - 1, - 1, - 1, - 18, - 18, - 47, - 17, - 22, - 44, - 75, - 48, - 58, - 91, - 55, - 4, - 1, - 1, - 10, - 3, - 2, - 2, - 2, - 2, - 3, - 2, - 1, - 1 - ] - } -} \ No newline at end of file diff --git a/scripts/course_report/course_analytics_report.html b/scripts/course_report/course_analytics_report.html deleted file mode 100644 index 75a0bb54885..00000000000 --- a/scripts/course_report/course_analytics_report.html +++ /dev/null @@ -1,11399 +0,0 @@ - - - - - - Course Analytics Report - - - - -
    -
    -

    📊 Course Analytics Report

    -

    Bitcoin Educational Content Analysis

    -

    - Generated: 2025-11-30 12:21:41 -

    -
    - - -
    -
    -
    37
    -
    Total Courses
    -
    -
    -
    226
    -
    Total Parts
    -
    -
    -
    780
    -
    Total Chapters
    -
    -
    -
    773,360
    -
    Total Words
    -
    -
    -
    18,064
    -
    Total Paragraphs
    -
    -
    -
    35,720
    -
    Total Sentences
    -
    -
    - - -
    -

    📈 Aggregated Statistics

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    MetricCountMeanStd DevMedianMinMax
    Parts per Course376.111.76311
    Chapters per Course3721.088.8201048
    Chapters per Part2263.451.943.0117
    Subsections (###) per Chapter7802.622.823.0024
    Words per Chapter780983.93999.76755.518197
    Words per Course3720,90213,61517,85563655,239
    Paragraphs per Chapter78023.1623.518.00252
    Paragraphs per Course37488315436121,456
    Sentences per Chapter78045.7943.9637.01354
    Sentences per Course37965613872212,373
    -
    - - -
    -

    📊 Distributions

    -
    -
    -

    Parts per Course Distribution

    -
    - -
    -
    -
    -

    Chapters per Course Distribution

    -
    - -
    -
    -
    -
    -
    -

    Words per Course Distribution

    -
    - -
    -
    -
    -

    Words per Chapter Distribution

    -
    - -
    -
    -
    -
    -
    -

    Chapters per Part Distribution

    -
    - -
    -
    -
    -

    Subsections per Chapter Distribution

    -
    - -
    -
    -
    -
    -
    -

    Paragraphs per Chapter Distribution

    -
    - -
    -
    -
    -

    Sentences per Chapter Distribution

    -
    - -
    -
    -
    -
    -
    -

    Paragraphs per Course Distribution

    -
    - -
    -
    -
    -

    Sentences per Course Distribution

    -
    - -
    -
    -
    -
    - - -
    -

    📚 Course Comparison

    -
    -
    - -
    -
    -
    - - -
    -

    🔍 Per-Course Analysis

    -
    - - -
    - -
    -

    Select a course above to see detailed chapter-by-chapter analysis

    -
    -
    - - -
    -

    📖 Course Details

    -

    Click on column headers to sort

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Course IDLangPartsChaptersSubsectionsParagraphsSentencesWordsAvg Words/Ch
    his203FR41289774219755,2394,603
    btc204EN7311381268231551,2511,653
    scu202EN7241341456237350,1452,089
    his205EN946137907204842,194917
    cyp302EN93517759180135,7941,023
    eco104EN62330670161334,9471,519
    his201EN61773541147634,8412,049
    phi101EN82965550128828,485982
    btc102EN61868708145328,3251,574
    csv402EN51577789104027,2011,813
    btc303EN41442501120425,9611,854
    cyp201EN6226661491423,2901,059
    btc202EN62185556102422,3261,063
    pos305EN621118652119322,0901,052
    net302EN6234754696721,944954
    dev303EN5188539899720,8571,159
    pro101EN52290673103819,223874
    biz101EN6204037987819,184959
    btc101EN7257243681117,855714
    min306EN8218232178617,702843
    eco203EN726035387217,532674
    btc304EN948848781115,689327
    eco204EN5143434774015,6651,119
    his204EN626532476115,444594
    biz205EN11209639666715,040752
    csv404EN6195828067114,077741
    eco201EN4112920446012,4971,136
    min201EN7213322262612,428592
    lnp201EN7164630356312,041753
    dev103EN53110378162511,992387
    sid202EN514143034999,570684
    phi305EN51402113219,457676
    scu101EN719311593546,362335
    pro202EN512341272574,637386
    ene101FR4120242878866
    sid302EN4100122865165
    lnp206EN3100332163664
    -
    -
    - -
    -

    Bitcoin Educational Content Analytics • Generated with Python

    -
    -
    - - - - diff --git a/scripts/demote_headings.py b/scripts/demote_headings.py deleted file mode 100644 index fceddfcf7a9..00000000000 --- a/scripts/demote_headings.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to demote markdown headings by one level in tutorial files. -Only processes files where: -1. H1 headings exist outside code blocks -2. The first heading IS an H1 (otherwise headings make sense already) - -If H1 is not the first heading, only demote from that H1 onward. -""" - -import os -import re -import sys -from pathlib import Path -from dataclasses import dataclass -from typing import List, Tuple, Optional - -@dataclass -class HeadingInfo: - line_num: int - level: int # 1 for #, 2 for ##, etc. - text: str - -def parse_headings_outside_code_blocks(content: str) -> List[HeadingInfo]: - """ - Parse all markdown headings that are outside code blocks. - Returns list of HeadingInfo with line numbers (0-indexed). - """ - lines = content.split('\n') - headings = [] - in_code_block = False - - for i, line in enumerate(lines): - # Detect code block start/end (``` with optional language) - stripped = line.strip() - if stripped.startswith('```'): - in_code_block = not in_code_block - continue - - if in_code_block: - continue - - # Match markdown heading: 1-6 # followed by space and text - match = re.match(r'^(#{1,6}) (.+)$', line) - if match: - level = len(match.group(1)) - text = match.group(2) - headings.append(HeadingInfo(line_num=i, level=level, text=text)) - - return headings - -def should_process_file(headings: List[HeadingInfo]) -> Tuple[bool, Optional[int]]: - """ - Determine if file should be processed and from which line. - - Returns: - (should_process, start_line_index) - - If first heading is H1: (True, 0) - demote all - - If H1 appears later: (True, index_of_first_h1) - demote from H1 onward - - If no H1: (False, None) - don't process - """ - if not headings: - return False, None - - # Find first H1 - first_h1_idx = None - for i, h in enumerate(headings): - if h.level == 1: - first_h1_idx = i - break - - if first_h1_idx is None: - # No H1 found - return False, None - - if first_h1_idx == 0: - # First heading is H1 - demote all headings - return True, 0 - else: - # H1 is not first - only demote from H1 onward - return True, first_h1_idx - -def demote_headings(content: str, headings: List[HeadingInfo], start_idx: int) -> str: - """ - Demote headings starting from start_idx in the headings list. - """ - lines = content.split('\n') - - # Get line numbers to demote - lines_to_demote = {h.line_num for h in headings[start_idx:]} - - result = [] - for i, line in enumerate(lines): - if i in lines_to_demote: - # Add one # to demote - match = re.match(r'^(#{1,6}) (.+)$', line) - if match: - hashes = match.group(1) - text = match.group(2) - result.append(f'#{hashes} {text}') - else: - result.append(line) - else: - result.append(line) - - return '\n'.join(result) - -def process_file(filepath: Path, dry_run: bool = False, verbose: bool = False) -> bool: - """ - Process a single file. Returns True if file was modified. - """ - try: - content = filepath.read_text(encoding='utf-8') - except Exception as e: - print(f"Error reading {filepath}: {e}") - return False - - headings = parse_headings_outside_code_blocks(content) - should_process, start_idx = should_process_file(headings) - - if not should_process: - if verbose: - print(f"Skipped (no H1 or H1 not first): {filepath}") - return False - - if verbose: - first_heading = headings[0] if headings else None - if start_idx == 0: - print(f"Processing (first heading is H1): {filepath}") - else: - print(f"Processing (H1 at index {start_idx}, demoting from there): {filepath}") - - new_content = demote_headings(content, headings, start_idx) - - if dry_run: - print(f"Would modify: {filepath}") - # Show what would change - if verbose: - for h in headings[start_idx:start_idx+3]: - print(f" Line {h.line_num+1}: {'#'*h.level} -> {'#'*(h.level+1)}") - return True - - try: - filepath.write_text(new_content, encoding='utf-8') - print(f"Modified: {filepath}") - return True - except Exception as e: - print(f"Error writing {filepath}: {e}") - return False - -def main(): - tutorials_dir = Path('/home/asi0/asi0-repos/bitcoin-educational-content/tutorials') - - dry_run = '--dry-run' in sys.argv - verbose = '--verbose' in sys.argv or '-v' in sys.argv - - if dry_run: - print("DRY RUN - No files will be modified\n") - - modified_count = 0 - skipped_count = 0 - total_files = 0 - - for md_file in sorted(tutorials_dir.rglob('*.md')): - total_files += 1 - if process_file(md_file, dry_run, verbose): - modified_count += 1 - else: - skipped_count += 1 - - print(f"\n{'Would modify' if dry_run else 'Modified'}: {modified_count} files") - print(f"Skipped: {skipped_count} files") - print(f"Total: {total_files} files") - -if __name__ == '__main__': - main() diff --git a/scripts/extract-language-specific-content.py b/scripts/extract-language-specific-content.py deleted file mode 100644 index bec5f315340..00000000000 --- a/scripts/extract-language-specific-content.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -import shutil - -# Define the origin and destination directory paths -script_directory = os.path.dirname(os.path.abspath(__file__)) -origin_path = os.path.join(script_directory, './../../sovereign-university-data/') -destination_path = os.path.join(script_directory, '../../LLM-Translator/inputs/planb-content/') - - -# Ensure the destination directory exists -os.makedirs(destination_path, exist_ok=True) - -# Define the subfolders and their corresponding file types -subfolders = { - 'courses': 'en.md', - 'tutorials': 'en.md', - 'resources': 'en.yml', - 'professors': 'en.yml', -} - -# Initialize counters for files and words -file_counts = {key: 0 for key in subfolders.keys()} -word_counts = {key: 0 for key in subfolders.keys()} -total_files = 0 -total_words = 0 - -# Function to process each subfolder -def process_subfolder(subfolder, file_type): - global total_files, total_words - path = os.path.join(origin_path, subfolder) - print(path) - for root, dirs, files in os.walk(path): - print(root) - for file in files: - if file.endswith(file_type): - src_file_path = os.path.join(root, file) - # Construct the new filename and its path - new_filename = root.replace(origin_path, '').replace(os.sep, '_') + "_English" + os.path.splitext(file)[1] - dst_file_path = os.path.join(destination_path, new_filename) - # Copy and rename the file - shutil.copy2(src_file_path, dst_file_path) - file_counts[subfolder] += 1 - total_files += 1 - # Count the words in the current file - with open(src_file_path, 'r', encoding='utf-8') as file: - words = file.read().split() - word_counts[subfolder] += len(words) - total_words += len(words) - -# Process each subfolder -for subfolder, file_type in subfolders.items(): - process_subfolder(subfolder, file_type) - -# Output the number of each content type, the total number of files, and word counts -for subfolder in subfolders.keys(): - print(f"{subfolder}: {file_counts[subfolder]} files, {word_counts[subfolder]} words") -print(f"Total files: {total_files}, Total words: {total_words}") - diff --git a/scripts/fix-symbols/correction_summary.md b/scripts/fix-symbols/correction_summary.md deleted file mode 100644 index 69c2f37ffb4..00000000000 --- a/scripts/fix-symbols/correction_summary.md +++ /dev/null @@ -1,7 +0,0 @@ -# Correction Summary (2025-09-15T15:18:52.635461) - -- Total files analyzed: 48895 -- Files corrected: 424 -- Total corrections made: 17828 -- Average corrections per corrected file: 42.05 -- Average attempts per correction: 1.37 diff --git a/scripts/fix-symbols/fix_symbols.py b/scripts/fix-symbols/fix_symbols.py deleted file mode 100644 index ccb9f8f8ff9..00000000000 --- a/scripts/fix-symbols/fix_symbols.py +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env python3 -""" -Parallel Hybrid Markdown Fixer (Stable Bars via Thread Binding) ---------------------------------------------------------------- -- ThreadPoolExecutor with 8 workers -- Each real thread permanently mapped to its own tqdm bar -- 9 total bars: 1 global + 8 workers -- Bars reset on each new file for that thread -- Suspicion detection: balance + regex + parser -""" - -import os -import re -import threading -from pathlib import Path -from dataclasses import dataclass -from datetime import datetime -from concurrent.futures import ThreadPoolExecutor, as_completed -from markdown_it import MarkdownIt -from tqdm import tqdm - -try: - import anthropic -except ImportError: - print("Error: please install anthropic (pip install anthropic)") - exit(1) - - -@dataclass -class Stats: - files_analyzed: int = 0 - files_corrected: int = 0 - total_corrections: int = 0 - total_attempts: int = 0 - - -class MarkdownFixerHybrid: - def __init__(self, anthropic_api_key: str): - self.client = anthropic.Anthropic(api_key=anthropic_api_key) - self.model = "claude-sonnet-4-20250514" - self.md = MarkdownIt() - - def _is_suspicious(self, line: str) -> bool: - if "*" not in line: - return False - - # 1. Balance check - double = line.count("**") - single = line.count("*") - 2 * double - if single % 2 != 0 or double % 2 != 0: - return True - - # 2. Regex overlaps - regex_sus = bool( - re.search(r"\*\*[^*]+\*", line) or re.search(r"\*[^*]+\*\*", line) - ) - if not regex_sus: - return False - - # 3. Parser confirmation - try: - tokens = self.md.parse(line) - except Exception: - return True - - for t in tokens: - if t.type == "text" and "*" in t.content: - return True - return False - - def _fix_with_claude(self, line: str) -> str: - system_prompt = ( - "You are a Markdown formatting expert. " - "Fix unbalanced, mismatched, or nested * or ** markers. " - "Rules:\n" - "- Balance all markers\n" - "- Preserve original wording\n" - "- Use ** for bold, * for italic\n" - "Return ONLY the corrected line." - ) - message = self.client.messages.create( - model=self.model, - max_tokens=300, - temperature=0.1, - system=system_prompt, - messages=[{"role": "user", "content": line}], - ) - return message.content[0].text.strip() - - def _validate(self, line: str) -> bool: - return not self._is_suspicious(line) - - -# ---------------------- -# Thread → worker bar mapping (Option A) -# ---------------------- -thread_to_wid = {} -thread_lock = threading.Lock() # guard mapping - - -def worker_task(file_path: Path, repo_root: Path, top_level: str, - fixer: MarkdownFixerHybrid, worker_bars): - """Worker function: each thread bound once to a persistent bar.""" - tid = threading.get_ident() - - # Assign persistent wid for this thread - with thread_lock: - if tid not in thread_to_wid: - wid = len(thread_to_wid) + 1 - thread_to_wid[tid] = wid - else: - wid = thread_to_wid[tid] - - bar = worker_bars[wid - 1] - - stats = Stats() - stats.files_analyzed = 1 - corrected = False - - try: - lines = file_path.read_text(encoding="utf-8").splitlines() - except UnicodeDecodeError: - return stats - - new_lines = lines[:] - - # relative path without top-level - rel_path = file_path.relative_to(repo_root / top_level) - - # reset worker bar for this file - bar.reset(total=len(lines)) - bar.set_description(f"Worker {wid}: {rel_path}") - - for i, line in enumerate(lines): - if not fixer._is_suspicious(line): - bar.update(1) - continue - - attempts = 0 - while attempts < 3: - attempts += 1 - stats.total_attempts += 1 - proposal = fixer._fix_with_claude(line) - if fixer._validate(proposal): - new_lines[i] = proposal - stats.total_corrections += 1 - corrected = True - break - bar.update(1) - - if corrected: - stats.files_corrected = 1 - file_path.write_text("\n".join(new_lines), encoding="utf-8") - - return stats - - -# ---------------------- -# Stats merging & report -# ---------------------- -def merge_stats(all_stats): - merged = Stats() - for s in all_stats: - merged.files_analyzed += s.files_analyzed - merged.files_corrected += s.files_corrected - merged.total_corrections += s.total_corrections - merged.total_attempts += s.total_attempts - return merged - - -def generate_report(stats: Stats): - avg_per_file = ( - stats.total_corrections / stats.files_corrected - if stats.files_corrected - else 0 - ) - avg_attempts = ( - stats.total_attempts / stats.total_corrections - if stats.total_corrections - else 0 - ) - report = f"""# Correction Summary ({datetime.now().isoformat()}) - -- Total files analyzed: {stats.files_analyzed} -- Files corrected: {stats.files_corrected} -- Total corrections made: {stats.total_corrections} -- Average corrections per corrected file: {avg_per_file:.2f} -- Average attempts per correction: {avg_attempts:.2f} -""" - Path("correction_summary.md").write_text(report, encoding="utf-8") - print("\n=== Summary written to correction_summary.md ===") - print(report) - - -# ---------------------- -# Main -# ---------------------- -def main(): - import argparse - from dotenv import load_dotenv - - load_dotenv() - - parser = argparse.ArgumentParser() - parser.add_argument("--repo-root", default="../..") - parser.add_argument("--api-key", help="Anthropic API key") - parser.add_argument("--workers", type=int, default=8, - help="Number of worker threads") - args = parser.parse_args() - - api_key = args.api_key or os.environ.get("ANTHROPIC_API_KEY") - if not api_key: - print("Error: Anthropic API key required.") - exit(1) - - choice = input("Select folder to check (courses/tutorials/resources): ").strip() - if choice not in ["courses", "tutorials", "resources"]: - print("Invalid choice. Exiting.") - return - - repo_root = Path(args.repo_root) - files = sorted((repo_root / choice).rglob("*.md")) + sorted( - (repo_root / choice).rglob("*.yml") - ) - if not files: - print("No files found in", choice) - return - - fixer = MarkdownFixerHybrid(api_key) - all_stats = [] - - # global bar - global_bar = tqdm(total=len(files), desc="Global Progress", - position=0, leave=True, ncols=100) - - # persistent worker bars (fixed positions) - worker_bars = [ - tqdm(total=1, desc=f"Worker {wid}", position=wid, - leave=True, ncols=100) - for wid in range(1, args.workers + 1) - ] - - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = {executor.submit(worker_task, - f, repo_root, choice, - fixer, worker_bars): f - for f in files} - - for done in as_completed(futures): - stats = done.result() - all_stats.append(stats) - global_bar.update(1) - - global_bar.close() - for bar in worker_bars: - bar.close() - - merged = merge_stats(all_stats) - generate_report(merged) - - -if __name__ == "__main__": - main() diff --git a/scripts/fix_project_descriptions.py b/scripts/fix_project_descriptions.py deleted file mode 100644 index 77c2256b7fd..00000000000 --- a/scripts/fix_project_descriptions.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 -""" -Fix project description formatting in YAML files. - -This script rewrites descriptions that span multiple lines with indentation -to be single-line paragraphs. Empty lines between text indicate separate -paragraphs and are preserved. - -List items (lines starting with -, *, or numbers) are preserved as-is. -""" - -import os -import re -from pathlib import Path - - -def is_list_item(line: str) -> bool: - """Check if a line is a list item (bullet or numbered).""" - stripped = line.lstrip() - # Bullet lists (-, *) - if stripped.startswith(('- ', '* ')): - return True - # Numbered lists (1. 2. etc) - if re.match(r'^\d+\.\s', stripped): - return True - return False - - -def needs_fixing(description_lines: list) -> bool: - """ - Check if description actually needs fixing. - - Returns True only if there are lines that continue a paragraph - (non-empty, non-list lines that don't start a new paragraph). - """ - if len(description_lines) <= 1: - return False - - # Check if there are any wrapped lines (lines within a paragraph) - in_paragraph = False - has_wrapped_lines = False - - for line in description_lines: - if line == '': - in_paragraph = False - elif is_list_item(line): - in_paragraph = False - else: - if in_paragraph: - # This is a continuation line - has_wrapped_lines = True - break - in_paragraph = True - - return has_wrapped_lines - - -def fix_description(content: str) -> tuple[str, bool]: - """ - Fix description formatting in YAML content. - - Merges lines within paragraphs while preserving paragraph breaks and lists. - - Returns: (fixed_content, was_modified) - """ - lines = content.split('\n') - result = [] - i = 0 - was_modified = False - - while i < len(lines): - line = lines[i] - - # Check if this is the description field - if line.strip() == 'description: |': - result.append(line) - i += 1 - - # Collect all description lines - description_lines = [] - base_indent = None - - while i < len(lines): - current = lines[i] - - # Check if we've moved past the description block - # (non-indented line or different field) - if current and not current.startswith(' '): - break - - # Get the content after the indentation - if current.strip(): - # Determine base indentation from first non-empty line - if base_indent is None: - base_indent = len(current) - len(current.lstrip()) - - # Extract content (removing base indentation) - if len(current) >= base_indent: - content_part = current[base_indent:] - description_lines.append(content_part) - else: - description_lines.append(current.strip()) - else: - # Empty line indicates paragraph break - description_lines.append('') - - i += 1 - - # Check if this description actually needs fixing - if not needs_fixing(description_lines): - # Write back as-is - for line in description_lines: - if line: - result.append(f' {line}') - else: - result.append('') - continue - - # Process description lines into paragraphs - paragraphs = [] - current_paragraph = [] - - for line in description_lines: - if line == '': - # Empty line - end current paragraph if any - if current_paragraph: - # Join lines in paragraph with a space - paragraph_text = ' '.join(current_paragraph) - paragraphs.append(paragraph_text) - current_paragraph = [] - # Keep the empty line to separate paragraphs - paragraphs.append('') - elif is_list_item(line): - # List item - end current paragraph and add list item as-is - if current_paragraph: - paragraph_text = ' '.join(current_paragraph) - paragraphs.append(paragraph_text) - current_paragraph = [] - paragraphs.append(line.strip()) - else: - # Regular text - add to current paragraph - current_paragraph.append(line.strip()) - - # Don't forget the last paragraph - if current_paragraph: - paragraph_text = ' '.join(current_paragraph) - paragraphs.append(paragraph_text) - - # Remove trailing empty lines - while paragraphs and paragraphs[-1] == '': - paragraphs.pop() - - # Write the reformatted description - for paragraph in paragraphs: - if paragraph: - result.append(f' {paragraph}') - else: - result.append('') - - was_modified = True - - else: - result.append(line) - i += 1 - - return '\n'.join(result), was_modified - - -def process_file(file_path: Path) -> bool: - """ - Process a single YAML file. - - Returns True if the file was modified, False otherwise. - """ - try: - with open(file_path, 'r', encoding='utf-8') as f: - original_content = f.read() - - # Skip if no description field - if 'description: |' not in original_content: - return False - - fixed_content, was_modified = fix_description(original_content) - - # Only write if content was actually modified - if was_modified: - with open(file_path, 'w', encoding='utf-8') as f: - f.write(fixed_content) - return True - - return False - - except Exception as e: - print(f"Error processing {file_path}: {e}") - return False - - -def main(): - """Main function to process all project YAML files.""" - # Get the repository root - script_dir = Path(__file__).parent - repo_root = script_dir.parent - projects_dir = repo_root / 'resources' / 'projects' - - if not projects_dir.exists(): - print(f"Projects directory not found: {projects_dir}") - return - - # Find all YAML files in project directories - yaml_files = list(projects_dir.glob('*/*.yml')) - - # Exclude project.yml files, only process language files - language_files = [f for f in yaml_files if f.name != 'project.yml'] - - print(f"Found {len(language_files)} language YAML files to check") - - modified_count = 0 - - for file_path in sorted(language_files): - if process_file(file_path): - print(f"✓ Fixed: {file_path.relative_to(repo_root)}") - modified_count += 1 - - print(f"\nCompleted! Modified {modified_count} files.") - - -if __name__ == '__main__': - main() diff --git a/scripts/generate_all_reports.py b/scripts/generate_all_reports.py deleted file mode 100755 index 5922fdc6724..00000000000 --- a/scripts/generate_all_reports.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -""" -Master Report Generator - -Runs all overview report generators and creates all HTML reports in one go. - -Usage: - python generate_all_reports.py - -This will generate: -1. Video Deployment Overview -2. Image Translation Overview -3. Markdown Translation Overview -""" - -import sys -import subprocess -from pathlib import Path -from datetime import datetime - -def run_script(script_path, description): - """Run a Python script and return success status.""" - print(f"\n{'='*60}") - print(f"🚀 {description}") - print(f"{'='*60}\n") - - try: - result = subprocess.run( - [sys.executable, str(script_path)], - cwd=script_path.parent, - capture_output=False, - text=True - ) - - if result.returncode == 0: - print(f"\n✅ {description} completed successfully!") - return True - else: - print(f"\n❌ {description} failed with exit code {result.returncode}") - return False - - except Exception as e: - print(f"\n❌ Error running {description}: {e}") - return False - -def main(): - """Main execution function.""" - print("=" * 60) - print("🎯 Master Report Generator") - print("=" * 60) - print(f"📅 Started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print() - - script_dir = Path(__file__).parent.resolve() - - # Define all scripts to run - scripts = [ - { - 'path': script_dir / 'video_deployment_overview' / 'generate_report.py', - 'description': 'Video Deployment Overview' - }, - { - 'path': script_dir / 'image_translation_overview' / 'generate_report.py', - 'description': 'Image Translation Overview' - }, - { - 'path': script_dir / 'md_translation_overview' / 'generate_report.py', - 'description': 'Markdown Translation Overview' - } - ] - - # Verify all scripts exist - missing_scripts = [] - for script_info in scripts: - if not script_info['path'].exists(): - missing_scripts.append(script_info['path']) - - if missing_scripts: - print("❌ Error: The following scripts were not found:") - for script in missing_scripts: - print(f" - {script}") - return 1 - - # Run all scripts - results = [] - for script_info in scripts: - success = run_script(script_info['path'], script_info['description']) - results.append({ - 'description': script_info['description'], - 'success': success, - 'path': script_info['path'] - }) - - # Print summary - print("\n" + "=" * 60) - print("📊 Summary") - print("=" * 60) - - all_successful = True - for result in results: - status = "✅ SUCCESS" if result['success'] else "❌ FAILED" - print(f"{status} - {result['description']}") - if not result['success']: - all_successful = False - - print("\n" + "=" * 60) - print("📁 Generated Reports") - print("=" * 60) - - # List generated HTML files (all in central reports folder) - reports_dir = script_dir / 'reports' - html_files = [ - reports_dir / 'video_deployment_overview.html', - reports_dir / 'image_translation_overview.html', - reports_dir / 'md_translation_overview.html' - ] - - for html_file in html_files: - if html_file.exists(): - size = html_file.stat().st_size / 1024 # Size in KB - print(f"✓ {html_file.name}") - print(f" └─ {html_file} ({size:.1f} KB)") - else: - print(f"✗ {html_file.name} (not found)") - - print() - print(f"📅 Completed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - print("=" * 60) - - if all_successful: - print("\n🎉 All reports generated successfully!") - print("\n💡 Tip: Open the HTML files in your browser to view the reports.") - return 0 - else: - print("\n⚠️ Some reports failed to generate. Check the output above for details.") - return 1 - -if __name__ == '__main__': - exit(main()) diff --git a/scripts/get-planb-tags.py b/scripts/get-planb-tags.py deleted file mode 100644 index a3d7c5104af..00000000000 --- a/scripts/get-planb-tags.py +++ /dev/null @@ -1,41 +0,0 @@ -import os -import yaml -from collections import defaultdict - -# Initialize a dictionary to store tags and their occurrence count -tag_counts = defaultdict(int) - -# Walk through the directory and its subdirectories -for root, dirs, files in os.walk("."): - for file in files: - # Check if the file is a .yml file - if file.endswith(".yml"): - file_path = os.path.join(root, file) - with open(file_path, 'r') as ymlfile: - try: - # Load the content of the .yml file - content = yaml.safe_load(ymlfile) - if content and 'tags' in content: - # Increment the count for each tag - for tag in content['tags']: - tag_counts[tag] += 1 - except yaml.YAMLError as exc: - print(f"Error parsing YAML file: {file_path}", exc) - -# Sort tags alphabetically and then by their occurrence count -sorted_tags_alphabetically = sorted(tag_counts.items()) -sorted_tags_by_occurrence = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True) - -# Write the sorted tags alphabetically to tags.md -with open("planb-tags.md", "w") as mdfile: - for tag, count in sorted_tags_alphabetically: - mdfile.write(f"- {tag}: {count}\n") - -# Write the sorted tags by occurrence to tag-occurrence-sorted.md -with open("planb-tag-occurrence-sorted.md", "w") as mdfile_occ: - for tag, count in sorted_tags_by_occurrence: - mdfile_occ.write(f"- {tag}: {count}\n") - -print("planb-tags.md file has been created with sorted tags alphabetically.") -print("planb-tag-occurrence-sorted.md file has been created with tags sorted by their occurrences.") - diff --git a/scripts/image_translation_overview/README.md b/scripts/image_translation_overview/README.md deleted file mode 100644 index 496cf5e7dd3..00000000000 --- a/scripts/image_translation_overview/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Image Translation Overview Generator - -This tool analyzes image translation progress across all courses and generates an HTML report showing which images have been translated for each language. - -## Features - -- Analyzes all courses in the `courses/` directory -- Tracks image translations for 7 languages: **en, fr, es, it, de, ru, zh-Hant** -- Counts total images vs translated images per language -- Color-coded visualization: - - 🟢 Green = 100% translated - - 🟡 Yellow = 50-99% translated - - 🟠 Orange = 1-49% translated - - 🔴 Red = 0% translated - - ⚫ Gray = N/A (no content or no images) - -## How It Works - -The script analyzes each course's markdown files and: - -1. **Extracts image references** from markdown files (e.g., `![alt](path)`) -2. **Checks if images are translated** by looking for language code in the path - - Translated: `assets/fr/001.webp` (contains `/fr/`) - - Untranslated: `assets/001.webp` (no language code) -3. **Calculates translation percentage** for each language -4. **Generates color-coded HTML table** with statistics - -### Translation Detection - -An image is considered **translated** if its path contains the language code in one of these formats: -- `/en/`, `/fr/`, `/es/`, `/it/`, `/de/`, `/ru/`, `/zh-Hant/` -- `-en-`, `-fr-`, `-es-`, etc. -- `_en_`, `_fr_`, `_es_`, etc. - -## Requirements - -No external dependencies required - uses only Python standard library. - -## Usage - -```bash -cd scripts/image_translation_overview -python generate_report.py -``` - -Or from the project root: - -```bash -python scripts/image_translation_overview/generate_report.py -``` - -## Output - -The script generates `image_translation_overview.html` in the central `scripts/reports/` folder. Open it in your browser to view the interactive report. - -## Report Structure - -| Course | EN | FR | ES | IT | DE | RU | ZH-HANT | -|--------|----|----|----|----|----|----|---------| -| btc101 | 23/23 (100%) | 23/23 (100%) | 15/23 (65%) | ... | ... | ... | ... | - -Each cell shows: -- **X/Y** = X translated images out of Y total images -- **Percentage** = Translation completion percentage -- **Color coding** = Visual indicator of translation status - -## Example - -For a course `btc101` with French translation: - -**en.md:** -```markdown -![image](assets/en/001.webp) -![image](assets/en/002.webp) -``` - -**fr.md:** -```markdown -![image](assets/fr/001.webp) -![image](assets/fr/002.webp) -``` - -Result: **2/2 (100%)** 🟢 for both English and French - -## Statistics - -The report includes: -- Total courses analyzed -- Total images across all courses -- Overall translation percentage -- Per-language, per-course breakdown diff --git a/scripts/image_translation_overview/generate_report.py b/scripts/image_translation_overview/generate_report.py deleted file mode 100755 index ec331c56d70..00000000000 --- a/scripts/image_translation_overview/generate_report.py +++ /dev/null @@ -1,436 +0,0 @@ -#!/usr/bin/env python3 -""" -Image Translation Overview Generator - -Analyzes image translation progress across all courses and languages. -Generates an HTML report showing translated vs untranslated images. - -Usage: - python generate_report.py - -The HTML report will be saved in the same directory as this script. -""" - -import re -from pathlib import Path -from datetime import datetime -from collections import defaultdict - -# Languages to check -LANGUAGES = ['en', 'fr', 'es', 'it', 'de', 'ru', 'zh-Hant'] - -# Regex pattern to match markdown image references -IMAGE_PATTERN = re.compile(r'!\[.*?\]\((.*?)\)') - -def get_courses_directory(): - """Get the courses directory path relative to this script.""" - script_dir = Path(__file__).parent.resolve() - courses_dir = script_dir / '../../courses' - return courses_dir.resolve() - -def extract_images_from_markdown(md_file_path): - """Extract all image references from a markdown file.""" - if not md_file_path.exists(): - return [] - - try: - with open(md_file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Find all image references - images = IMAGE_PATTERN.findall(content) - return images - - except Exception as e: - print(f"⚠️ Error reading {md_file_path}: {e}") - return [] - -def analyze_course_images(course_path): - """Analyze image translation status for a course.""" - course_id = course_path.name - - # Check if course has markdown files - md_files = list(course_path.glob('*.md')) - if not md_files: - return None - - analysis = { - 'course_id': course_id, - 'languages': {} - } - - for lang in LANGUAGES: - md_file = course_path / f'{lang}.md' - - if not md_file.exists(): - analysis['languages'][lang] = { - 'total_images': 0, - 'translated_images': 0, - 'percentage': 0, - 'exists': False - } - continue - - # Extract images from the markdown file - images = extract_images_from_markdown(md_file) - - if not images: - analysis['languages'][lang] = { - 'total_images': 0, - 'translated_images': 0, - 'percentage': 0, - 'exists': True - } - continue - - # Count translated images (those with language code in path) - translated_count = 0 - for img_path in images: - # Check if the image path contains the language code - # Patterns: assets/en/, /en/, -en-, _en_, etc. - if f'/{lang}/' in img_path or f'-{lang}-' in img_path or f'_{lang}_' in img_path: - translated_count += 1 - - percentage = (translated_count / len(images) * 100) if len(images) > 0 else 0 - - analysis['languages'][lang] = { - 'total_images': len(images), - 'translated_images': translated_count, - 'percentage': percentage, - 'exists': True - } - - return analysis - -def generate_html_report(all_analyses, output_file): - """Generate HTML report with color-coded table.""" - - # Calculate summary statistics - total_courses = len(all_analyses) - total_images = sum( - sum(data['total_images'] for data in analysis['languages'].values()) - for analysis in all_analyses - ) - total_translated = sum( - sum(data['translated_images'] for data in analysis['languages'].values()) - for analysis in all_analyses - ) - - html = f""" - - - - - Image Translation Overview - - - -
    -
    - Back to Reports -

    🖼️ Image Translation Overview

    -
    -
    - Languages analyzed: {", ".join(LANGUAGES)}
    - Total courses: {total_courses} | - Total images: {total_images} | - Translated: {total_translated} ({(total_translated/total_images*100):.1f}% overall) -
    - -
    -

    Legend

    -
    - - Complete (100%) -
    -
    - - Partial (50-99%) -
    -
    - - Low (1-49%) -
    -
    - - None (0%) -
    -
    - - N/A (No content) -
    -
    - - - - - -""" - - for lang in LANGUAGES: - html += f" \n" - - html += """ - - -""" - - # Sort courses by ID - sorted_analyses = sorted(all_analyses, key=lambda x: x['course_id']) - - for analysis in sorted_analyses: - course_id = analysis['course_id'] - - html += f" \n" - html += f" \n" - - for lang in LANGUAGES: - lang_data = analysis['languages'][lang] - - if not lang_data['exists']: - # Language file doesn't exist - html += f" \n" - continue - - total_images = lang_data['total_images'] - translated_images = lang_data['translated_images'] - percentage = lang_data['percentage'] - - # Determine color class - if total_images == 0: - color_class = 'translation-na' - display_text = 'N/A' - info_text = 'No images' - elif percentage == 100: - color_class = 'translation-complete' - display_text = f'{translated_images}/{total_images}' - info_text = '100%' - elif percentage >= 50: - color_class = 'translation-partial' - display_text = f'{translated_images}/{total_images}' - info_text = f'{percentage:.0f}%' - elif percentage > 0: - color_class = 'translation-low' - display_text = f'{translated_images}/{total_images}' - info_text = f'{percentage:.0f}%' - else: - color_class = 'translation-none' - display_text = f'{translated_images}/{total_images}' - info_text = '0%' - - html += f" \n" - - html += " \n" - - html += f""" -
    Course{lang.upper()}
    {course_id}\n" - html += f" N/A\n" - html += f" No content\n" - html += f" \n" - html += f" {display_text}\n" - html += f" {info_text}\n" - html += f"
    - - -
    - - -""" - - with open(output_file, 'w', encoding='utf-8') as f: - f.write(html) - - print(f"✅ HTML report generated: {output_file}") - -def main(): - """Main execution function.""" - print("🚀 Image Translation Overview Generator") - print("=" * 50) - - # Get script directory and reports folder for output - script_dir = Path(__file__).parent.resolve() - reports_dir = script_dir.parent / 'reports' - reports_dir.mkdir(exist_ok=True) - output_file = reports_dir / 'image_translation_overview.html' - - # Get courses directory - courses_dir = get_courses_directory() - - if not courses_dir.exists(): - print(f"❌ Error: Courses directory not found at {courses_dir}") - return 1 - - print(f"📂 Courses directory: {courses_dir}") - print(f"📄 Output file: {output_file}") - print() - - # Find all course directories - course_dirs = [d for d in courses_dir.iterdir() - if d.is_dir() and not d.name.startswith('.')] - - print(f"📂 Found {len(course_dirs)} course directories") - print() - - all_analyses = [] - - for course_dir in sorted(course_dirs): - print(f"🔍 Analyzing {course_dir.name}...") - analysis = analyze_course_images(course_dir) - if analysis: - all_analyses.append(analysis) - - print() - print(f"✅ Successfully analyzed {len(all_analyses)} courses") - print() - - # Generate HTML report - generate_html_report(all_analyses, output_file) - - print() - print(f"🎉 Done! Open the following file in your browser:") - print(f" {output_file}") - - return 0 - -if __name__ == '__main__': - exit(main()) diff --git a/scripts/md_translation_overview/README.md b/scripts/md_translation_overview/README.md deleted file mode 100644 index 037229a3795..00000000000 --- a/scripts/md_translation_overview/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Markdown Translation Overview Generator - -This tool analyzes markdown translation progress across all courses and generates an HTML report showing which language files exist for each course. - -## Features - -- Analyzes all courses in the `courses/` directory -- Auto-detects all supported languages from `btc101` folder -- Shows presence/absence of markdown files for each language -- Displays word count for existing translations -- Color-coded visualization: - - 🟢 Green = Translation exists - - 🔴 Red = Translation missing - -## How It Works - -The script: - -1. **Detects supported languages** from `btc101` folder (reference course) -2. **Scans each course** for language-specific markdown files -3. **Counts content** (lines and words) for existing translations -4. **Generates HTML table** showing translation status - -### Language Detection - -Supported languages are automatically detected by scanning `.md` files in the `btc101` folder. Currently includes: - -``` -cs, de, en, es, et, fa, fi, fr, hi, id, it, ja, ko, nb-NO, nl, -pl, pt, rn, ru, si, sr-Latn, sv, sw, tr, vi, zh-Hans, zh-Hant -``` - -## Requirements - -No external dependencies required - uses only Python standard library. - -## Usage - -```bash -cd scripts/md_translation_overview -python generate_report.py -``` - -Or from the project root: - -```bash -python scripts/md_translation_overview/generate_report.py -``` - -## Output - -The script generates `md_translation_overview.html` in the central `scripts/reports/` folder. Open it in your browser to view the interactive report. - -## Report Structure - -| Course | EN | FR | ES | ... | ZH-HANT | -|--------|----|----|----|----|---------| -| btc101 | ✓ 12,345w | ✓ 11,234w | ✓ 10,987w | ... | ✓ 9,876w | -| btc102 | ✓ 8,765w | ✗ | ✗ | ... | ✗ | - -Each cell shows: -- **✓** = Translation exists (green) -- **✗** = Translation missing (red) -- **Word count** = Number of words in the file - -## Statistics - -The report includes: -- Total courses analyzed -- Total translations vs possible translations -- Overall translation percentage -- Per-language, per-course breakdown with word counts diff --git a/scripts/md_translation_overview/generate_report.py b/scripts/md_translation_overview/generate_report.py deleted file mode 100755 index 1bc41842d51..00000000000 --- a/scripts/md_translation_overview/generate_report.py +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env python3 -""" -Markdown Translation Overview Generator - -Analyzes markdown translation progress across all courses and languages. -Generates an HTML report showing which courses have been translated. - -Usage: - python generate_report.py - -The HTML report will be saved in the same directory as this script. -""" - -from pathlib import Path -from datetime import datetime - -def get_courses_directory(): - """Get the courses directory path relative to this script.""" - script_dir = Path(__file__).parent.resolve() - courses_dir = script_dir / '../../courses' - return courses_dir.resolve() - -def get_supported_languages(courses_dir): - """Get all supported languages from btc101 folder.""" - btc101_dir = courses_dir / 'btc101' - if not btc101_dir.exists(): - return [] - - # Get all .md files and extract language codes - languages = [] - for md_file in sorted(btc101_dir.glob('*.md')): - lang = md_file.stem # filename without extension - languages.append(lang) - - return languages - -def count_md_content(md_file_path): - """Count lines and words in a markdown file.""" - if not md_file_path.exists(): - return 0, 0 - - try: - with open(md_file_path, 'r', encoding='utf-8') as f: - content = f.read() - - lines = len(content.splitlines()) - words = len(content.split()) - - return lines, words - - except Exception as e: - return 0, 0 - -def analyze_course_translations(course_path, languages): - """Analyze translation status for a course.""" - course_id = course_path.name - - analysis = { - 'course_id': course_id, - 'languages': {} - } - - for lang in languages: - md_file = course_path / f'{lang}.md' - lines, words = count_md_content(md_file) - - analysis['languages'][lang] = { - 'exists': md_file.exists(), - 'lines': lines, - 'words': words - } - - return analysis - -def generate_html_report(all_analyses, languages, output_file): - """Generate HTML report with color-coded table.""" - - # Calculate summary statistics - total_courses = len(all_analyses) - total_translations = sum( - sum(1 for data in analysis['languages'].values() if data['exists']) - for analysis in all_analyses - ) - total_possible = total_courses * len(languages) - - html = f""" - - - - - Markdown Translation Overview - - - -
    -
    - Back to Reports -

    📝 Markdown Translation Overview

    -
    -
    - Languages analyzed: {len(languages)} languages
    - Total courses: {total_courses} | - Translations: {total_translations}/{total_possible} ({(total_translations/total_possible*100):.1f}% overall) -
    - -
    -

    Legend

    -
    - - ✓ Translated (file exists) -
    -
    - - ✗ Missing (no file) -
    -
    - - - - - -""" - - for lang in languages: - html += f" \n" - - html += """ - - -""" - - # Sort courses by ID - sorted_analyses = sorted(all_analyses, key=lambda x: x['course_id']) - - for analysis in sorted_analyses: - course_id = analysis['course_id'] - - html += f" \n" - html += f" \n" - - for lang in languages: - lang_data = analysis['languages'][lang] - - if lang_data['exists']: - words = lang_data['words'] - lines = lang_data['lines'] - - html += f" \n" - else: - html += f" \n" - - html += " \n" - - html += f""" -
    Course{lang.upper()}
    {course_id}\n" - html += f" \n" - html += f" {words:,}w\n" - html += f" \n" - html += f" \n" - html += f"
    - - -
    - - -""" - - with open(output_file, 'w', encoding='utf-8') as f: - f.write(html) - - print(f"✅ HTML report generated: {output_file}") - -def main(): - """Main execution function.""" - print("🚀 Markdown Translation Overview Generator") - print("=" * 50) - - # Get script directory and reports folder for output - script_dir = Path(__file__).parent.resolve() - reports_dir = script_dir.parent / 'reports' - reports_dir.mkdir(exist_ok=True) - output_file = reports_dir / 'md_translation_overview.html' - - # Get courses directory - courses_dir = get_courses_directory() - - if not courses_dir.exists(): - print(f"❌ Error: Courses directory not found at {courses_dir}") - return 1 - - print(f"📂 Courses directory: {courses_dir}") - print(f"📄 Output file: {output_file}") - print() - - # Get supported languages from btc101 - languages = get_supported_languages(courses_dir) - if not languages: - print("❌ Error: Could not detect supported languages from btc101 folder") - return 1 - - print(f"🌍 Detected {len(languages)} supported languages:") - print(f" {', '.join(languages)}") - print() - - # Find all course directories - course_dirs = [d for d in courses_dir.iterdir() - if d.is_dir() and not d.name.startswith('.')] - - print(f"📂 Found {len(course_dirs)} course directories") - print() - - all_analyses = [] - - for course_dir in sorted(course_dirs): - print(f"🔍 Analyzing {course_dir.name}...") - analysis = analyze_course_translations(course_dir, languages) - all_analyses.append(analysis) - - print() - print(f"✅ Successfully analyzed {len(all_analyses)} courses") - print() - - # Generate HTML report - generate_html_report(all_analyses, languages, output_file) - - print() - print(f"🎉 Done! Open the following file in your browser:") - print(f" {output_file}") - - return 0 - -if __name__ == '__main__': - exit(main()) diff --git a/scripts/planb-tag-occurrence-sorted.md b/scripts/planb-tag-occurrence-sorted.md deleted file mode 100644 index eeef39ce0fb..00000000000 --- a/scripts/planb-tag-occurrence-sorted.md +++ /dev/null @@ -1,852 +0,0 @@ -- Bitcoin: 576 -- Lightning Network: 110 -- bitcoin: 99 -- technology: 69 -- economy: 54 -- bitcoin-only: 53 -- Mining: 53 -- privacy: 51 -- finance: 46 -- open-source: 42 -- Cryptography: 42 -- Cryptocurrency: 39 -- education: 38 -- Economics: 38 -- Hyperinflation: 36 -- HD Wallet: 35 -- Bitcoin Wallet: 33 -- Inflation: 29 -- history: 28 -- Security: 26 -- Bitcoin Protocol: 26 -- Bitcoin Basis: 25 -- self-custodial: 25 -- wallet: 24 -- Money History: 24 -- infrastructure: 23 -- Austrian Economics: 21 -- Cybersecurity: 21 -- Privacy: 16 -- exchange: 15 -- Wallet: 15 -- development: 14 -- lightning-network: 13 -- philosophy: 13 -- Wallets: 13 -- lightning: 12 -- Nodes: 12 -- Currency Basis: 12 -- Wallet security: 12 -- Money: 12 -- Passphrase: 12 -- politics: 11 -- Open-source: 11 -- Transactions: 11 -- ECDSA: 11 -- Transaction: 10 -- Investment: 10 -- Blockchain: 10 -- economics: 9 -- Education: 9 -- node: 9 -- HTLC: 9 -- 2FA: 9 -- Online browsing: 9 -- Hash Functions: 9 -- Private Key: 9 -- SHA256: 9 -- Hodler: 9 -- Online Security: 9 -- Estate planning: 9 -- Cryptoasset inheritance: 9 -- Bitcoin mining: 9 -- Commitment Transaction: 9 -- P2P solutions: 9 -- Monetary Policy: 9 -- Bitcoin Origins: 9 -- Austrian School: 9 -- Business Cycle: 9 -- lightning network: 8 -- security: 8 -- fiction: 8 -- multi-coin: 8 -- KYC: 8 -- lightning-infrastructure: 8 -- Payment Channels: 8 -- Monetary History: 8 -- Channel Opening: 8 -- Backup Procedure: 8 -- Computer security: 8 -- Online security: 8 -- mining: 7 -- trading: 7 -- Miners: 7 -- Mnemonic phrase: 7 -- Fiat Currencies: 7 -- Monetary Dynamics: 7 -- online security: 7 -- cryptography: 6 -- psychology: 6 -- business: 6 -- peer-to-peer: 6 -- bitcoin-only: 6 -- DCA: 6 -- Decentralization: 6 -- Stacker: 6 -- Liquidity Management: 6 -- Proof of Work: 6 -- Public keys: 6 -- Halving: 6 -- Receiving addresses: 6 -- Austrian economics: 6 -- VPN: 6 -- Private key: 6 -- science: 5 -- science fiction: 5 -- self-help: 5 -- custodial: 5 -- Depth: 5 -- Public Key: 5 -- Invoice: 5 -- Self Custody: 5 -- Revocation Key: 5 -- Elliptic Curve: 5 -- Digital Signature: 5 -- Stablecoin: 5 -- rust: 4 -- investing: 4 -- Technology: 4 -- investment: 4 -- peer to peer: 4 -- privact: 4 -- Scams: 4 -- Government response: 4 -- Stablecoins: 4 -- Fiat Currency: 4 -- Currency devaluation: 4 -- Email Attacks: 4 -- Purchasing Power: 4 -- Regulation: 4 -- Seed: 4 -- Monetary history: 4 -- Difficulty Adjustment: 4 -- Network: 4 -- Exchange: 4 -- Subjective Value: 4 -- Currency: 4 -- Public key: 4 -- Centralization: 4 -- password manager: 4 -- Investing: 4 -- coinjoin: 4 -- Samourai: 4 -- cypherpunk: 3 -- RGB: 3 -- hacker: 3 -- entrepreneurship: 3 -- personal finance: 3 -- social issues: 3 -- environment: 3 -- management: 3 -- education;: 3 -- security-solution: 3 -- marchand: 3 -- Lightning: 3 -- OS: 3 -- Node: 3 -- Scalability: 3 -- Fiat currencies: 3 -- Monetary reform: 3 -- Bitcoin Culture: 3 -- Cypherpunks: 3 -- Economy: 3 -- Adoption: 3 -- Virgin Bitcoins: 3 -- Division of Labor: 3 -- Ransomware: 3 -- HMAC SHA-512 algorithm: 3 -- Cold Wallet: 3 -- UTXO: 3 -- Phishing: 3 -- Transaction fees: 3 -- Cheating: 3 -- Cooperative Closure: 3 -- Investment strategy: 3 -- Financial system: 3 -- Bank of England: 3 -- Mnemonic Phrase: 3 -- Protocol: 3 -- Risk: 3 -- Open source: 3 -- Censorship: 3 -- Banking: 3 -- RoboSat: 3 -- Characteristics: 3 -- Scarcity: 3 -- Network security: 3 -- Reward: 3 -- Blocks: 3 -- Forced Closure: 3 -- password: 3 -- Satoshi: 3 -- Antivirus: 3 -- Zimbabwe: 3 -- ASIC: 3 -- Redenomination: 3 -- Savings: 3 -- Gold: 3 -- Extended Keys: 3 -- Environment: 3 -- Division of labor: 3 -- point of sales: 3 -- DIY: 3 -- light-KYC: 3 -- mix: 3 -- payjoin: 3 -- P2EP: 3 -- Breez: 2 -- cryptocurrency: 2 -- ethics: 2 -- banking: 2 -- biography: 2 -- art: 2 -- energy: 2 -- software development: 2 -- programming: 2 -- mathematics: 2 -- technology;: 2 -- internet: 2 -- evolution: 2 -- personal development: 2 -- law: 2 -- cyberpunk: 2 -- finance;: 2 -- anthropology: 2 -- Infrastructure: 2 -- layer-2: 2 -- Data: 2 -- nodes: 2 -- VC: 2 -- Cookies: 2 -- Bitcoin Adoption: 2 -- Misconception: 2 -- Email security: 2 -- Online safety: 2 -- Zombie Companies: 2 -- Blockchain Trilemma: 2 -- Attakai: 2 -- HTTPS: 2 -- Energy management: 2 -- Data Backup: 2 -- Banking system: 2 -- Switzerland: 2 -- Master Private Key: 2 -- Hot Wallet: 2 -- Block war: 2 -- Timelock: 2 -- Volatility: 2 -- BIP32: 2 -- Video games: 2 -- Central Planning: 2 -- Bitcoin Storage: 2 -- Self-Custody: 2 -- Bitcoin History: 2 -- Passwords: 2 -- PBKDF2: 2 -- Deflation: 2 -- FOMO: 2 -- Data encryption: 2 -- Operating systems: 2 -- Barter: 2 -- Multi-signature: 2 -- Human Rights: 2 -- Exchange platform: 2 -- Traditional Finance: 2 -- Germany: 2 -- HodlHodl: 2 -- Segwit Standard: 2 -- Axioms: 2 -- self-hosted password manager: 2 -- LNP2PBot: 2 -- Channel Closure: 2 -- Hashrate: 2 -- Consensus Rules: 2 -- Verification: 2 -- password strength: 2 -- Two-Factor Authentication: 2 -- Economic Calculation: 2 -- BTCMap: 2 -- Checksum: 2 -- Windows: 2 -- HRP: 2 -- Satoshi Nakamoto: 2 -- Password Management: 2 -- Carl Menger: 2 -- Liquidity: 2 -- Multidisciplinary: 2 -- Public trust: 2 -- AMF: 2 -- Hash: 2 -- Time Preference: 2 -- Interest Rates: 2 -- Transaction Routing: 2 -- Cyber Attacks: 2 -- Dollarization: 2 -- Asymetric Cryptography: 2 -- Block size: 2 -- Bitcoin Node: 2 -- Trading: 2 -- Online privacy: 2 -- password management: 2 -- Communication: 2 -- Central Banks: 2 -- Transaction Fees: 2 -- Wallet types: 2 -- Exchange Platform: 2 -- Business: 2 -- Merchant: 2 -- Software: 2 -- HMAC: 2 -- Consensus Algorithm: 2 -- Benefits: 2 -- Mining centralization: 2 -- France: 2 -- Child Keys: 2 -- ATMs: 2 -- Notgeld: 2 -- Weimar Republic: 2 -- Non-KYC: 2 -- Private keys: 2 -- Password manager: 2 -- Regulations: 2 -- merchant: 2 -- Full KYC: 2 -- whirlpool: 2 -- Coinjoin: 2 -- LND: 2 -- cahoots: 2 -- Hacker: 1 -- el salvador: 1 -- Rust: 1 -- educator: 1 -- activist: 1 -- youtuber: 1 -- merchants: 1 -- use cases: 1 -- macroeconomic: 1 -- developer: 1 -- accountability: 1 -- nostr: 1 -- crime: 1 -- religion: 1 -- sience fiction: 1 -- jason fried: 1 -- physics: 1 -- startups: 1 -- quantum computing: 1 -- dystopia: 1 -- marketing: 1 -- growth hacking: 1 -- wealth building: 1 -- psycology: 1 -- interviews: 1 -- poetry;: 1 -- anarchisme: 1 -- mathematique: 1 -- policy: 1 -- motivation: 1 -- creativity: 1 -- probability: 1 -- leadership: 1 -- programming;: 1 -- computer science: 1 -- deflation: 1 -- journalism: 1 -- social issues;: 1 -- strategy: 1 -- commodity: 1 -- social issue: 1 -- graphic novel: 1 -- principles: 1 -- autobiography: 1 -- economic: 1 -- inflation: 1 -- cyberculture: 1 -- biology: 1 -- genetics: 1 -- psychology;: 1 -- opensource: 1 -- open source: 1 -- programmation: 1 -- artificial intelligence: 1 -- sustainability: 1 -- ecology: 1 -- productivity: 1 -- hacking: 1 -- international relations: 1 -- geopolitics: 1 -- novel: 1 -- information theory: 1 -- money: 1 -- econimic: 1 -- hedge funds: 1 -- philosophy;: 1 -- paiement-solution: 1 -- centralize: 1 -- vpn: 1 -- lightning-market: 1 -- educatition: 1 -- books: 1 -- paiement solution: 1 -- conference: 1 -- tools: 1 -- developers: 1 -- podcast-solution: 1 -- Point of sale: 1 -- blog: 1 -- donateur: 1 -- Coach: 1 -- bitcoin-investment: 1 -- sociale media: 1 -- communication: 1 -- news: 1 -- lightning-managment: 1 -- exchanges: 1 -- non-kyc: 1 -- game: 1 -- lightning-wallet: 1 -- Bitcoin-investment: 1 -- research: 1 -- vc: 1 -- 3D printed: 1 -- Art: 1 -- LN wallet: 1 -- sociale: 1 -- life-style: 1 -- afrique: 1 -- Financial System: 1 -- Incentives: 1 -- Raspberry Pi 4: 1 -- Lightning Authentication: 1 -- Decentralized Identities: 1 -- Protection: 1 -- Private browsing: 1 -- Government restrictions: 1 -- Influencers: 1 -- Data breach: 1 -- HMAC-SHA512: 1 -- Course-related: 1 -- Nicaragua: 1 -- Cordoba: 1 -- Bitcoin Blockchain: 1 -- Monetary Decisions: 1 -- Central Bankers: 1 -- Bitcoin Security: 1 -- Address Derivation: 1 -- Compression: 1 -- Spontaneous purchase: 1 -- Password Manager: 1 -- Cyberpunk movement: 1 -- Digital world: 1 -- Browsing: 1 -- Absolute Advantage: 1 -- Revolution: 1 -- pbkdf2 function: 1 -- Seed Generation: 1 -- Concerns: 1 -- Economic Crises: 1 -- Network Knowledge: 1 -- Bank Balance Sheets: 1 -- Debate: 1 -- RNG: 1 -- Cap: 1 -- Course Instructor: 1 -- Trend: 1 -- Purchase Plan: 1 -- Economic Incentive: 1 -- SegWit: 1 -- Peach: 1 -- Dutch East India Company: 1 -- Child Key Pairs: 1 -- Milton Friedman: 1 -- Order books: 1 -- Makers: 1 -- Takers: 1 -- Legal tender: 1 -- Pay2PublicKey: 1 -- Confirmations: 1 -- Bubble: 1 -- Discretion: 1 -- Price Increase: 1 -- Onion routing: 1 -- Ideologies: 1 -- Investment planning: 1 -- Time Value of Money: 1 -- Mainstream Economics: 1 -- Generator Point: 1 -- Ponzi schemes: 1 -- Browser extensions: 1 -- BIP43: 1 -- BIP44: 1 -- Hierarchy: 1 -- Dice: 1 -- Money supply: 1 -- Programming Languages: 1 -- Comparative Advantage: 1 -- Bubbles: 1 -- FUD: 1 -- Master Chain Code: 1 -- Monetary expansion: 1 -- Keynesian Economics: 1 -- Elliptic curves: 1 -- Double coincidence of wants: 1 -- Mining pool: 1 -- Applications: 1 -- Transparency: 1 -- Content creators: 1 -- MMT: 1 -- Peer-to-peer: 1 -- Market Dynamics: 1 -- Sound Money: 1 -- LNURL: 1 -- Bisq: 1 -- Bitcoin Benefit: 1 -- Bolivia: 1 -- Freedom: 1 -- Users: 1 -- KYC light: 1 -- Community: 1 -- Network management: 1 -- Asymetric Cryptoraphy: 1 -- Fiat Bank: 1 -- Kagan's criterion: 1 -- Bankruptcy: 1 -- Rentenmark: 1 -- BCH Checksum: 1 -- Error Detection: 1 -- Energy Cost: 1 -- Fountain: 1 -- Time preferences: 1 -- Money Supply: 1 -- Metal Coin: 1 -- Commodity money: 1 -- Bitwarden: 1 -- KeePass: 1 -- Fractional Reserve System: 1 -- Financial Stability: 1 -- Bitcoin protocol: 1 -- Peer-to-Peer: 1 -- Dictatorial regimes: 1 -- Antwerp Stock Exchange: 1 -- Profit: 1 -- Equation: 1 -- Value: 1 -- Inheritance Plan: 1 -- Mindset: 1 -- HOTP: 1 -- TOTP: 1 -- Technological revolution: 1 -- Florin: 1 -- Price: 1 -- Internet: 1 -- Pragmatism: 1 -- Commitment: 1 -- ASICS: 1 -- Austrian Business Cycle Theory: 1 -- Economic Cycle: 1 -- Dark Web: 1 -- Tools: 1 -- 51% Attack: 1 -- Networking: 1 -- Marginal Utility: 1 -- Human Action: 1 -- Production Activities: 1 -- XPUB: 1 -- Peru: 1 -- Development: 1 -- Electricity: 1 -- Equipment: 1 -- Software update: 1 -- Enterprise VPN: 1 -- Consumer VPN: 1 -- Genesis Block: 1 -- Intertemporal coordination: 1 -- Blockchain Technology: 1 -- Bank accounts: 1 -- CPI: 1 -- Future of finance: 1 -- Browser Extensions: 1 -- Software efficiency: 1 -- ASICs: 1 -- Obtaining Bitcoins: 1 -- Compensation: 1 -- Money laundering: 1 -- Traceability: 1 -- Cloud Storage: 1 -- Base Layer: 1 -- Settlement Layer: 1 -- OpenNode: 1 -- Sats: 1 -- TOR network: 1 -- Interest rates: 1 -- Economic crises: 1 -- Receiving Address: 1 -- secp256k1: 1 -- The Reich's Banker: 1 -- Adoption curve: 1 -- Mining Pools: 1 -- Password Alternatives: 1 -- Passkeys: 1 -- Biometrics: 1 -- Entropy: 1 -- Incentive: 1 -- Creation: 1 -- Payment: 1 -- password replacement: 1 -- Hot wallet: 1 -- Bernholz: 1 -- Certificate System: 1 -- Public Keys: 1 -- Payment models: 1 -- Consumption: 1 -- Open-Source Wallet: 1 -- Monetary policy: 1 -- Economic challenges: 1 -- Economy Basis: 1 -- Keynesian economics: 1 -- Monetarist economics: 1 -- Immutability: 1 -- Methodology: 1 -- Social sciences: 1 -- Layer-2 Solution: 1 -- Public Debt: 1 -- Golden Rule: 1 -- Investment Strategy: 1 -- Practical Experience: 1 -- Capital Misallocation: 1 -- Wealth Inequality: 1 -- Voluntary Exchange: 1 -- Subjective Utility: 1 -- Learning resources: 1 -- Uncertainty: 1 -- Single Sign-On: 1 -- Internet Giants: 1 -- Bernold: 1 -- Keynesian Doctrine: 1 -- Money streaming: 1 -- Key Pair: 1 -- Master Password: 1 -- Physical Security: 1 -- Individual responsibility: 1 -- Regulation and electricity tariffs: 1 -- Modern Monetary Theory: 1 -- Money printing: 1 -- Political goals: 1 -- Goldsmiths: 1 -- Route Searching: 1 -- Legal Tender: 1 -- Course Production: 1 -- Swiss Bitcoin Pay: 1 -- Master Key: 1 -- Learning pace: 1 -- Wi-Fi: 1 -- GDPR: 1 -- Cost: 1 -- Green energy: 1 -- Standard of Living: 1 -- Fractional Reserve Banking: 1 -- Proof of Stake: 1 -- Business opportunities: 1 -- Software updates: 1 -- Economic Cycles: 1 -- CBDCs: 1 -- Financial Freedom: 1 -- Biometric data: 1 -- Receiving Capacity: 1 -- Linux: 1 -- Mac: 1 -- Economic impact: 1 -- Venezuela: 1 -- Digital bolivar: 1 -- BCH Program: 1 -- BIPs: 1 -- Issues: 1 -- Flexibility: 1 -- Adaptability: 1 -- Hardened Derivations: 1 -- Capitalism: 1 -- Entrepreneurs: 1 -- BCH32: 1 -- Base 32: 1 -- Bandwidth: 1 -- Distrust: 1 -- Random Number Generation: 1 -- Threat Monitoring: 1 -- Good Pratice: 1 -- Total supply: 1 -- Mission: 1 -- Censorship resistance: 1 -- Open-source software: 1 -- Traditional Market: 1 -- Bitcoin Incentives: 1 -- LastPass: 1 -- Dashlane: 1 -- Malware: 1 -- Virus: 1 -- USB keys: 1 -- Learning: 1 -- Adaptation: 1 -- Prices: 1 -- Market: 1 -- Route Estimation: 1 -- Brazil: 1 -- Consensus rules: 1 -- Purchase methods: 1 -- Economic Agents: 1 -- Marginal Theory of Value: 1 -- Pruned node: 1 -- BIP38: 1 -- Platforms: 1 -- Strategy: 1 -- One-Time Passwords: 1 -- Mobile Security: 1 -- Cherry-picking: 1 -- AI: 1 -- Future implications: 1 -- Price Drop: 1 -- Best practices: 1 -- User Types: 1 -- Zimbabwean dollar: 1 -- Exchange rate: 1 -- Index: 1 -- BIP39: 1 -- Andreas Antonopoulos: 1 -- Barack Obama: 1 -- Financial Crises: 1 -- Data backup: 1 -- Keysend: 1 -- Extended Private Key: 1 -- Economic reasoning: 1 -- Security Vulnerabilities: 1 -- Human Negligence: 1 -- Task: 1 -- Web browsers: 1 -- Nonce: 1 -- Inflation rate: 1 -- Future vision: 1 -- Exposure: 1 -- BTCpay Server: 1 -- Payment network: 1 -- Legal status: 1 -- Holding: 1 -- Bitcoin wallet: 1 -- Bitcoin Transaction: 1 -- Block Space: 1 -- TOR: 1 -- HashRate: 1 -- Uncensorability: 1 -- Dollar Cost Average: 1 -- DigiCash: 1 -- Skills: 1 -- Argentina: 1 -- Home Browsing: 1 -- Social Cooperation: 1 -- Micro-transactions: 1 -- Government: 1 -- Segwit: 1 -- Addresses: 1 -- Lottery principle: 1 -- Chain Code: 1 -- Mistakes: 1 -- SHA-256: 1 -- Hash Function: 1 -- DNS over HTTPS: 1 -- DNS over TLS: 1 -- Internet Security: 1 -- Preimage Hash: 1 -- Recovery phrase: 1 -- Challenges: 1 -- HMAC function: 1 -- Bitcoin Network: 1 -- Hardware tokens: 1 -- Course Goals: 1 -- Atomic Swap: 1 -- Discrete logarithm problem: 1 -- Transaction routing: 1 -- Anonymity: 1 -- Network integrity: 1 -- Demand Pull: 1 -- Cost Push: 1 -- Bitcoin Whitepaper: 1 -- Investment decisions: 1 -- Inheritance: 1 -- Notary: 1 -- brute force attack: 1 -- DCA platform: 1 -- Elliptic curve: 1 -- Scalar multiplication: 1 -- Salability: 1 -- Ban: 1 -- Forum: 1 -- Game Theory: 1 -- Energy: 1 -- UTXOs: 1 -- technical knowledge: 1 -- Software downloads: 1 -- Asymmetric Cryptography: 1 -- Future: 1 -- Remote Access: 1 -- Hard Fork: 1 -- Data Encryption: 1 -- Renting goods: 1 -- Key Generation: 1 -- Technological Competence: 1 -- SMS: 1 -- Coordination: 1 -- Limit: 1 -- Payment channels: 1 -- Socialism: 1 -- Economic Crisis: 1 -- Liquidity Distribution: 1 -- Capital Goods: 1 -- Prefix: 1 -- ICOs: 1 -- Rewards: 1 -- Randomness Factor: 1 -- Viruses: 1 -- Specialization: 1 -- JBOC: 1 -- Online Scams: 1 -- Domain Name: 1 -- Monetary system: 1 -- Extended Key: 1 -- HMAC-SHA-512: 1 -- Game theory: 1 -- Byzantine Generals' Problem: 1 -- Innovation: 1 -- Disruption: 1 -- Ecosystem: 1 -- infrasctructure: 1 -- POS: 1 -- non-KYC: 1 -- watch-only: 1 -- xpub: 1 -- timelocked: 1 -- multi-sig: 1 -- muli-sig: 1 -- do it yourself: 1 -- Dice Roll: 1 -- GitHub: 1 -- Obsidian: 1 -- PlanB Network: 1 -- full KYC: 1 -- full YC: 1 -- Raspberry Pi: 1 -- BIP: 1 -- WST: 1 -- Whirlpool: 1 -- entropy: 1 -- Boltzmann: 1 -- coin control: 1 -- analysis: 1 -- Sparrow: 1 diff --git a/scripts/planb-tags.md b/scripts/planb-tags.md deleted file mode 100644 index b20e2601ba9..00000000000 --- a/scripts/planb-tags.md +++ /dev/null @@ -1,852 +0,0 @@ -- 2FA: 9 -- 3D printed: 1 -- 51% Attack: 1 -- AI: 1 -- AMF: 2 -- ASIC: 3 -- ASICS: 1 -- ASICs: 1 -- ATMs: 2 -- Absolute Advantage: 1 -- Adaptability: 1 -- Adaptation: 1 -- Address Derivation: 1 -- Addresses: 1 -- Adoption: 3 -- Adoption curve: 1 -- Andreas Antonopoulos: 1 -- Anonymity: 1 -- Antivirus: 3 -- Antwerp Stock Exchange: 1 -- Applications: 1 -- Argentina: 1 -- Art: 1 -- Asymetric Cryptography: 2 -- Asymetric Cryptoraphy: 1 -- Asymmetric Cryptography: 1 -- Atomic Swap: 1 -- Attakai: 2 -- Austrian Business Cycle Theory: 1 -- Austrian Economics: 21 -- Austrian School: 9 -- Austrian economics: 6 -- Axioms: 2 -- BCH Checksum: 1 -- BCH Program: 1 -- BCH32: 1 -- BIP: 1 -- BIP32: 2 -- BIP38: 1 -- BIP39: 1 -- BIP43: 1 -- BIP44: 1 -- BIPs: 1 -- BTCMap: 2 -- BTCpay Server: 1 -- Backup Procedure: 8 -- Ban: 1 -- Bandwidth: 1 -- Bank Balance Sheets: 1 -- Bank accounts: 1 -- Bank of England: 3 -- Banking: 3 -- Banking system: 2 -- Bankruptcy: 1 -- Barack Obama: 1 -- Barter: 2 -- Base 32: 1 -- Base Layer: 1 -- Benefits: 2 -- Bernholz: 1 -- Bernold: 1 -- Best practices: 1 -- Biometric data: 1 -- Biometrics: 1 -- Bisq: 1 -- Bitcoin: 576 -- Bitcoin Adoption: 2 -- Bitcoin Basis: 25 -- Bitcoin Benefit: 1 -- Bitcoin Blockchain: 1 -- Bitcoin Culture: 3 -- Bitcoin History: 2 -- Bitcoin Incentives: 1 -- Bitcoin Network: 1 -- Bitcoin Node: 2 -- Bitcoin Origins: 9 -- Bitcoin Protocol: 26 -- Bitcoin Security: 1 -- Bitcoin Storage: 2 -- Bitcoin Transaction: 1 -- Bitcoin Wallet: 33 -- Bitcoin Whitepaper: 1 -- Bitcoin mining: 9 -- Bitcoin protocol: 1 -- Bitcoin wallet: 1 -- Bitcoin-investment: 1 -- Bitwarden: 1 -- Block Space: 1 -- Block size: 2 -- Block war: 2 -- Blockchain: 10 -- Blockchain Technology: 1 -- Blockchain Trilemma: 2 -- Blocks: 3 -- Bolivia: 1 -- Boltzmann: 1 -- Brazil: 1 -- Breez: 2 -- Browser Extensions: 1 -- Browser extensions: 1 -- Browsing: 1 -- Bubble: 1 -- Bubbles: 1 -- Business: 2 -- Business Cycle: 9 -- Business opportunities: 1 -- Byzantine Generals' Problem: 1 -- CBDCs: 1 -- CPI: 1 -- Cap: 1 -- Capital Goods: 1 -- Capital Misallocation: 1 -- Capitalism: 1 -- Carl Menger: 2 -- Censorship: 3 -- Censorship resistance: 1 -- Central Bankers: 1 -- Central Banks: 2 -- Central Planning: 2 -- Centralization: 4 -- Certificate System: 1 -- Chain Code: 1 -- Challenges: 1 -- Channel Closure: 2 -- Channel Opening: 8 -- Characteristics: 3 -- Cheating: 3 -- Checksum: 2 -- Cherry-picking: 1 -- Child Key Pairs: 1 -- Child Keys: 2 -- Cloud Storage: 1 -- Coach: 1 -- Coinjoin: 2 -- Cold Wallet: 3 -- Commitment: 1 -- Commitment Transaction: 9 -- Commodity money: 1 -- Communication: 2 -- Community: 1 -- Comparative Advantage: 1 -- Compensation: 1 -- Compression: 1 -- Computer security: 8 -- Concerns: 1 -- Confirmations: 1 -- Consensus Algorithm: 2 -- Consensus Rules: 2 -- Consensus rules: 1 -- Consumer VPN: 1 -- Consumption: 1 -- Content creators: 1 -- Cookies: 2 -- Cooperative Closure: 3 -- Coordination: 1 -- Cordoba: 1 -- Cost: 1 -- Cost Push: 1 -- Course Goals: 1 -- Course Instructor: 1 -- Course Production: 1 -- Course-related: 1 -- Creation: 1 -- Cryptoasset inheritance: 9 -- Cryptocurrency: 39 -- Cryptography: 42 -- Currency: 4 -- Currency Basis: 12 -- Currency devaluation: 4 -- Cyber Attacks: 2 -- Cyberpunk movement: 1 -- Cybersecurity: 21 -- Cypherpunks: 3 -- DCA: 6 -- DCA platform: 1 -- DIY: 3 -- DNS over HTTPS: 1 -- DNS over TLS: 1 -- Dark Web: 1 -- Dashlane: 1 -- Data: 2 -- Data Backup: 2 -- Data Encryption: 1 -- Data backup: 1 -- Data breach: 1 -- Data encryption: 2 -- Debate: 1 -- Decentralization: 6 -- Decentralized Identities: 1 -- Deflation: 2 -- Demand Pull: 1 -- Depth: 5 -- Development: 1 -- Dice: 1 -- Dice Roll: 1 -- Dictatorial regimes: 1 -- Difficulty Adjustment: 4 -- DigiCash: 1 -- Digital Signature: 5 -- Digital bolivar: 1 -- Digital world: 1 -- Discrete logarithm problem: 1 -- Discretion: 1 -- Disruption: 1 -- Distrust: 1 -- Division of Labor: 3 -- Division of labor: 3 -- Dollar Cost Average: 1 -- Dollarization: 2 -- Domain Name: 1 -- Double coincidence of wants: 1 -- Dutch East India Company: 1 -- ECDSA: 11 -- Economic Agents: 1 -- Economic Calculation: 2 -- Economic Crises: 1 -- Economic Crisis: 1 -- Economic Cycle: 1 -- Economic Cycles: 1 -- Economic Incentive: 1 -- Economic challenges: 1 -- Economic crises: 1 -- Economic impact: 1 -- Economic reasoning: 1 -- Economics: 38 -- Economy: 3 -- Economy Basis: 1 -- Ecosystem: 1 -- Education: 9 -- Electricity: 1 -- Elliptic Curve: 5 -- Elliptic curve: 1 -- Elliptic curves: 1 -- Email Attacks: 4 -- Email security: 2 -- Energy: 1 -- Energy Cost: 1 -- Energy management: 2 -- Enterprise VPN: 1 -- Entrepreneurs: 1 -- Entropy: 1 -- Environment: 3 -- Equation: 1 -- Equipment: 1 -- Error Detection: 1 -- Estate planning: 9 -- Exchange: 4 -- Exchange Platform: 2 -- Exchange platform: 2 -- Exchange rate: 1 -- Exposure: 1 -- Extended Key: 1 -- Extended Keys: 3 -- Extended Private Key: 1 -- FOMO: 2 -- FUD: 1 -- Fiat Bank: 1 -- Fiat Currencies: 7 -- Fiat Currency: 4 -- Fiat currencies: 3 -- Financial Crises: 1 -- Financial Freedom: 1 -- Financial Stability: 1 -- Financial System: 1 -- Financial system: 3 -- Flexibility: 1 -- Florin: 1 -- Forced Closure: 3 -- Forum: 1 -- Fountain: 1 -- Fractional Reserve Banking: 1 -- Fractional Reserve System: 1 -- France: 2 -- Freedom: 1 -- Full KYC: 2 -- Future: 1 -- Future implications: 1 -- Future of finance: 1 -- Future vision: 1 -- GDPR: 1 -- Game Theory: 1 -- Game theory: 1 -- Generator Point: 1 -- Genesis Block: 1 -- Germany: 2 -- GitHub: 1 -- Gold: 3 -- Golden Rule: 1 -- Goldsmiths: 1 -- Good Pratice: 1 -- Government: 1 -- Government response: 4 -- Government restrictions: 1 -- Green energy: 1 -- HD Wallet: 35 -- HMAC: 2 -- HMAC SHA-512 algorithm: 3 -- HMAC function: 1 -- HMAC-SHA-512: 1 -- HMAC-SHA512: 1 -- HOTP: 1 -- HRP: 2 -- HTLC: 9 -- HTTPS: 2 -- Hacker: 1 -- Halving: 6 -- Hard Fork: 1 -- Hardened Derivations: 1 -- Hardware tokens: 1 -- Hash: 2 -- Hash Function: 1 -- Hash Functions: 9 -- HashRate: 1 -- Hashrate: 2 -- Hierarchy: 1 -- HodlHodl: 2 -- Hodler: 9 -- Holding: 1 -- Home Browsing: 1 -- Hot Wallet: 2 -- Hot wallet: 1 -- Human Action: 1 -- Human Negligence: 1 -- Human Rights: 2 -- Hyperinflation: 36 -- ICOs: 1 -- Ideologies: 1 -- Immutability: 1 -- Incentive: 1 -- Incentives: 1 -- Index: 1 -- Individual responsibility: 1 -- Inflation: 29 -- Inflation rate: 1 -- Influencers: 1 -- Infrastructure: 2 -- Inheritance: 1 -- Inheritance Plan: 1 -- Innovation: 1 -- Interest Rates: 2 -- Interest rates: 1 -- Internet: 1 -- Internet Giants: 1 -- Internet Security: 1 -- Intertemporal coordination: 1 -- Investing: 4 -- Investment: 10 -- Investment Strategy: 1 -- Investment decisions: 1 -- Investment planning: 1 -- Investment strategy: 3 -- Invoice: 5 -- Issues: 1 -- JBOC: 1 -- KYC: 8 -- KYC light: 1 -- Kagan's criterion: 1 -- KeePass: 1 -- Key Generation: 1 -- Key Pair: 1 -- Keynesian Doctrine: 1 -- Keynesian Economics: 1 -- Keynesian economics: 1 -- Keysend: 1 -- LN wallet: 1 -- LND: 2 -- LNP2PBot: 2 -- LNURL: 1 -- LastPass: 1 -- Layer-2 Solution: 1 -- Learning: 1 -- Learning pace: 1 -- Learning resources: 1 -- Legal Tender: 1 -- Legal status: 1 -- Legal tender: 1 -- Lightning: 3 -- Lightning Authentication: 1 -- Lightning Network: 110 -- Limit: 1 -- Linux: 1 -- Liquidity: 2 -- Liquidity Distribution: 1 -- Liquidity Management: 6 -- Lottery principle: 1 -- MMT: 1 -- Mac: 1 -- Mainstream Economics: 1 -- Makers: 1 -- Malware: 1 -- Marginal Theory of Value: 1 -- Marginal Utility: 1 -- Market: 1 -- Market Dynamics: 1 -- Master Chain Code: 1 -- Master Key: 1 -- Master Password: 1 -- Master Private Key: 2 -- Merchant: 2 -- Metal Coin: 1 -- Methodology: 1 -- Micro-transactions: 1 -- Milton Friedman: 1 -- Mindset: 1 -- Miners: 7 -- Mining: 53 -- Mining Pools: 1 -- Mining centralization: 2 -- Mining pool: 1 -- Misconception: 2 -- Mission: 1 -- Mistakes: 1 -- Mnemonic Phrase: 3 -- Mnemonic phrase: 7 -- Mobile Security: 1 -- Modern Monetary Theory: 1 -- Monetarist economics: 1 -- Monetary Decisions: 1 -- Monetary Dynamics: 7 -- Monetary History: 8 -- Monetary Policy: 9 -- Monetary expansion: 1 -- Monetary history: 4 -- Monetary policy: 1 -- Monetary reform: 3 -- Monetary system: 1 -- Money: 12 -- Money History: 24 -- Money Supply: 1 -- Money laundering: 1 -- Money printing: 1 -- Money streaming: 1 -- Money supply: 1 -- Multi-signature: 2 -- Multidisciplinary: 2 -- Network: 4 -- Network Knowledge: 1 -- Network integrity: 1 -- Network management: 1 -- Network security: 3 -- Networking: 1 -- Nicaragua: 1 -- Node: 3 -- Nodes: 12 -- Non-KYC: 2 -- Nonce: 1 -- Notary: 1 -- Notgeld: 2 -- OS: 3 -- Obsidian: 1 -- Obtaining Bitcoins: 1 -- One-Time Passwords: 1 -- Onion routing: 1 -- Online Scams: 1 -- Online Security: 9 -- Online browsing: 9 -- Online privacy: 2 -- Online safety: 2 -- Online security: 8 -- Open source: 3 -- Open-Source Wallet: 1 -- Open-source: 11 -- Open-source software: 1 -- OpenNode: 1 -- Operating systems: 2 -- Order books: 1 -- P2EP: 3 -- P2P solutions: 9 -- PBKDF2: 2 -- POS: 1 -- Passkeys: 1 -- Passphrase: 12 -- Password Alternatives: 1 -- Password Management: 2 -- Password Manager: 1 -- Password manager: 2 -- Passwords: 2 -- Pay2PublicKey: 1 -- Payment: 1 -- Payment Channels: 8 -- Payment channels: 1 -- Payment models: 1 -- Payment network: 1 -- Peach: 1 -- Peer-to-Peer: 1 -- Peer-to-peer: 1 -- Peru: 1 -- Phishing: 3 -- Physical Security: 1 -- PlanB Network: 1 -- Platforms: 1 -- Point of sale: 1 -- Political goals: 1 -- Ponzi schemes: 1 -- Practical Experience: 1 -- Pragmatism: 1 -- Prefix: 1 -- Preimage Hash: 1 -- Price: 1 -- Price Drop: 1 -- Price Increase: 1 -- Prices: 1 -- Privacy: 16 -- Private Key: 9 -- Private browsing: 1 -- Private key: 6 -- Private keys: 2 -- Production Activities: 1 -- Profit: 1 -- Programming Languages: 1 -- Proof of Stake: 1 -- Proof of Work: 6 -- Protection: 1 -- Protocol: 3 -- Pruned node: 1 -- Public Debt: 1 -- Public Key: 5 -- Public Keys: 1 -- Public key: 4 -- Public keys: 6 -- Public trust: 2 -- Purchase Plan: 1 -- Purchase methods: 1 -- Purchasing Power: 4 -- RGB: 3 -- RNG: 1 -- Random Number Generation: 1 -- Randomness Factor: 1 -- Ransomware: 3 -- Raspberry Pi: 1 -- Raspberry Pi 4: 1 -- Receiving Address: 1 -- Receiving Capacity: 1 -- Receiving addresses: 6 -- Recovery phrase: 1 -- Redenomination: 3 -- Regulation: 4 -- Regulation and electricity tariffs: 1 -- Regulations: 2 -- Remote Access: 1 -- Rentenmark: 1 -- Renting goods: 1 -- Revocation Key: 5 -- Revolution: 1 -- Reward: 3 -- Rewards: 1 -- Risk: 3 -- RoboSat: 3 -- Route Estimation: 1 -- Route Searching: 1 -- Rust: 1 -- SHA-256: 1 -- SHA256: 9 -- SMS: 1 -- Salability: 1 -- Samourai: 4 -- Satoshi: 3 -- Satoshi Nakamoto: 2 -- Sats: 1 -- Savings: 3 -- Scalability: 3 -- Scalar multiplication: 1 -- Scams: 4 -- Scarcity: 3 -- Security: 26 -- Security Vulnerabilities: 1 -- Seed: 4 -- Seed Generation: 1 -- SegWit: 1 -- Segwit: 1 -- Segwit Standard: 2 -- Self Custody: 5 -- Self-Custody: 2 -- Settlement Layer: 1 -- Single Sign-On: 1 -- Skills: 1 -- Social Cooperation: 1 -- Social sciences: 1 -- Socialism: 1 -- Software: 2 -- Software downloads: 1 -- Software efficiency: 1 -- Software update: 1 -- Software updates: 1 -- Sound Money: 1 -- Sparrow: 1 -- Specialization: 1 -- Spontaneous purchase: 1 -- Stablecoin: 5 -- Stablecoins: 4 -- Stacker: 6 -- Standard of Living: 1 -- Strategy: 1 -- Subjective Utility: 1 -- Subjective Value: 4 -- Swiss Bitcoin Pay: 1 -- Switzerland: 2 -- TOR: 1 -- TOR network: 1 -- TOTP: 1 -- Takers: 1 -- Task: 1 -- Technological Competence: 1 -- Technological revolution: 1 -- Technology: 4 -- The Reich's Banker: 1 -- Threat Monitoring: 1 -- Time Preference: 2 -- Time Value of Money: 1 -- Time preferences: 1 -- Timelock: 2 -- Tools: 1 -- Total supply: 1 -- Traceability: 1 -- Trading: 2 -- Traditional Finance: 2 -- Traditional Market: 1 -- Transaction: 10 -- Transaction Fees: 2 -- Transaction Routing: 2 -- Transaction fees: 3 -- Transaction routing: 1 -- Transactions: 11 -- Transparency: 1 -- Trend: 1 -- Two-Factor Authentication: 2 -- USB keys: 1 -- UTXO: 3 -- UTXOs: 1 -- Uncensorability: 1 -- Uncertainty: 1 -- User Types: 1 -- Users: 1 -- VC: 2 -- VPN: 6 -- Value: 1 -- Venezuela: 1 -- Verification: 2 -- Video games: 2 -- Virgin Bitcoins: 3 -- Virus: 1 -- Viruses: 1 -- Volatility: 2 -- Voluntary Exchange: 1 -- WST: 1 -- Wallet: 15 -- Wallet security: 12 -- Wallet types: 2 -- Wallets: 13 -- Wealth Inequality: 1 -- Web browsers: 1 -- Weimar Republic: 2 -- Whirlpool: 1 -- Wi-Fi: 1 -- Windows: 2 -- XPUB: 1 -- Zimbabwe: 3 -- Zimbabwean dollar: 1 -- Zombie Companies: 2 -- accountability: 1 -- activist: 1 -- afrique: 1 -- analysis: 1 -- anarchisme: 1 -- anthropology: 2 -- art: 2 -- artificial intelligence: 1 -- autobiography: 1 -- banking: 2 -- biography: 2 -- biology: 1 -- bitcoin-only: 6 -- bitcoin: 99 -- bitcoin-investment: 1 -- bitcoin-only: 53 -- blog: 1 -- books: 1 -- brute force attack: 1 -- business: 6 -- cahoots: 2 -- centralize: 1 -- coin control: 1 -- coinjoin: 4 -- commodity: 1 -- communication: 1 -- computer science: 1 -- conference: 1 -- creativity: 1 -- crime: 1 -- cryptocurrency: 2 -- cryptography: 6 -- custodial: 5 -- cyberculture: 1 -- cyberpunk: 2 -- cypherpunk: 3 -- deflation: 1 -- developer: 1 -- developers: 1 -- development: 14 -- do it yourself: 1 -- donateur: 1 -- dystopia: 1 -- ecology: 1 -- econimic: 1 -- economic: 1 -- economics: 9 -- economy: 54 -- education: 38 -- education;: 3 -- educatition: 1 -- educator: 1 -- el salvador: 1 -- energy: 2 -- entrepreneurship: 3 -- entropy: 1 -- environment: 3 -- ethics: 2 -- evolution: 2 -- exchange: 15 -- exchanges: 1 -- fiction: 8 -- finance: 46 -- finance;: 2 -- full KYC: 1 -- full YC: 1 -- game: 1 -- genetics: 1 -- geopolitics: 1 -- graphic novel: 1 -- growth hacking: 1 -- hacker: 3 -- hacking: 1 -- hedge funds: 1 -- history: 28 -- inflation: 1 -- information theory: 1 -- infrasctructure: 1 -- infrastructure: 23 -- international relations: 1 -- internet: 2 -- interviews: 1 -- investing: 4 -- investment: 4 -- jason fried: 1 -- journalism: 1 -- law: 2 -- layer-2: 2 -- leadership: 1 -- life-style: 1 -- light-KYC: 3 -- lightning: 12 -- lightning network: 8 -- lightning-infrastructure: 8 -- lightning-managment: 1 -- lightning-market: 1 -- lightning-network: 13 -- lightning-wallet: 1 -- macroeconomic: 1 -- management: 3 -- marchand: 3 -- marketing: 1 -- mathematics: 2 -- mathematique: 1 -- merchant: 2 -- merchants: 1 -- mining: 7 -- mix: 3 -- money: 1 -- motivation: 1 -- muli-sig: 1 -- multi-coin: 8 -- multi-sig: 1 -- news: 1 -- node: 9 -- nodes: 2 -- non-KYC: 1 -- non-kyc: 1 -- nostr: 1 -- novel: 1 -- online security: 7 -- open source: 1 -- open-source: 42 -- opensource: 1 -- paiement solution: 1 -- paiement-solution: 1 -- password: 3 -- password management: 2 -- password manager: 4 -- password replacement: 1 -- password strength: 2 -- payjoin: 3 -- pbkdf2 function: 1 -- peer to peer: 4 -- peer-to-peer: 6 -- personal development: 2 -- personal finance: 3 -- philosophy: 13 -- philosophy;: 1 -- physics: 1 -- podcast-solution: 1 -- poetry;: 1 -- point of sales: 3 -- policy: 1 -- politics: 11 -- principles: 1 -- privact: 4 -- privacy: 51 -- probability: 1 -- productivity: 1 -- programmation: 1 -- programming: 2 -- programming;: 1 -- psychology: 6 -- psychology;: 1 -- psycology: 1 -- quantum computing: 1 -- religion: 1 -- research: 1 -- rust: 4 -- science: 5 -- science fiction: 5 -- secp256k1: 1 -- security: 8 -- security-solution: 3 -- self-custodial: 25 -- self-help: 5 -- self-hosted password manager: 2 -- sience fiction: 1 -- social issue: 1 -- social issues: 3 -- social issues;: 1 -- sociale: 1 -- sociale media: 1 -- software development: 2 -- startups: 1 -- strategy: 1 -- sustainability: 1 -- technical knowledge: 1 -- technology: 69 -- technology;: 2 -- timelocked: 1 -- tools: 1 -- trading: 7 -- use cases: 1 -- vc: 1 -- vpn: 1 -- wallet: 24 -- watch-only: 1 -- wealth building: 1 -- whirlpool: 2 -- xpub: 1 -- youtuber: 1 diff --git a/scripts/proofreading-metadata/Archive/check_proofreading_section.py b/scripts/proofreading-metadata/Archive/check_proofreading_section.py deleted file mode 100644 index cf78667aced..00000000000 --- a/scripts/proofreading-metadata/Archive/check_proofreading_section.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -from proofreading import * - - -def check_files_for_original_language(root_dir): - counter = 0 - - # Walk through all directories and subdirectories - for dirpath, dirnames, filenames in os.walk(root_dir): - # Exclude the 'docs' directory - dirnames[:] = [d for d in dirnames if d != 'docs'] - - for specific_file in specific_files: - if specific_file in filenames: - file_path = os.path.join(dirpath, specific_file) - - # Check if the file contains a line starting with 'original_language:' - with open(file_path, 'r', encoding='utf-8') as file: - contains_original_language = any(line.startswith('original_language:') for line in file) - - if not contains_original_language: - counter += 1 - print(f"File {specific_file} in {dirpath} does not contain 'original_language:'. Adding the section.") - - # Add the specified section to the end of the file - with open(file_path, 'a', encoding='utf-8') as file: - file.write('\n\n') - file.write('# Proofreading metadata\n') - file.write('original_language: fr\n') - file.write('proofreading:\n') - file.write(' - language: fr\n') - file.write(' last_contribution_date: 2024-08-31\n') - file.write(' urgency: 1\n') - file.write(' contributor_names:\n') - file.write(' - LoicPandul\n') - file.write(' reward:\n') - - print(f"Total files updated with 'original_language:' section: {counter}") - return counter - -# Replace '/path/to/your/root/directory' with the path to your root directory -root_dir = '../../' -check_files_for_original_language(root_dir) - diff --git a/scripts/proofreading-metadata/Archive/get_proofreading_progress.py b/scripts/proofreading-metadata/Archive/get_proofreading_progress.py deleted file mode 100644 index 4e063d8dc13..00000000000 --- a/scripts/proofreading-metadata/Archive/get_proofreading_progress.py +++ /dev/null @@ -1,27 +0,0 @@ -import os -from proofreading import * - -def ask_which_content_type(specific_files): - content_types = [file_name.replace('.yml', '') for file_name in specific_files] - - print("Please choose a content type by entering the corresponding number:") - for index, content_type in enumerate(content_types, start=1): - print(f"{index}. {content_type}") - - while True: - try: - choice = input("Enter the number of your choice: ") - choice = int(choice) - if 1 <= choice <= len(content_types): - selected_content_type = content_types[choice - 1] - print(f"You selected: {selected_content_type}") - return selected_content_type - else: - print("Please enter a valid number from the list.") - except ValueError: - print("Invalid input. Please enter a number.") - -def ask_folder_content(subfolders_path, selected_content_type): - -# Example usage -selected_content_type = ask_which_content_type(specific_files) diff --git a/scripts/proofreading-metadata/Archive/set-proofreading-reward.py b/scripts/proofreading-metadata/Archive/set-proofreading-reward.py deleted file mode 100644 index 4e2609012f0..00000000000 --- a/scripts/proofreading-metadata/Archive/set-proofreading-reward.py +++ /dev/null @@ -1,160 +0,0 @@ -import os -from math import floor -from ruamel.yaml import YAML - -BASE_FEE = .1 -yaml = YAML() -yaml.preserve_quotes = True - -language_factors = { - 'en': 1.00, # English - 'fr': 1.00, # French - 'de': 1.00, # German - 'es': 1.50, # Spanish - 'it': 1.50, # Italian - 'cs': 2.00, # Czech - 'vi': 3.00, # Vietnamese - 'ja': 3.00, # Japanese - 'pt': 1.00, # Portuguese - 'ru': 2.00, # Russian - 'fi': 2.00 # Finnish -} -languages = list(language_factors.keys()) - -# Dictionary mapping difficulty levels to their difficulty factors -difficulty_factors = { - 'beginner': 1, - 'intermediate': 2, - 'advanced': 3, - 'expert': 4 -} - - -specific_files = {"course.yml", - "question.yml", - "tutorial.yml", - "book.yml", - # "podcast.yml", - "word.yml", - "bet.yml", - "builder.yml", - "conference.yml" - } - -def compute_reward(words, difficulty_factor, language_factor, urgency, base_fee, proofread_iteration): - euros_per_word = 0.0006 - reward = (urgency * (euros_per_word * words * difficulty_factor * language_factor) + base_fee) * 2**(-proofread_iteration) - reward = round(reward, 2) - return reward - -def update_reward_property(file_path, language, reward): - data = get_yml_content(file_path) - # Update the reward property for the specified language - data['proofreading'][language]['reward'] = reward - - with open(file_path, 'w') as file: - yaml.dump(data, file) - -def get_proofread_iteration(reviewers_id): - if reviewers_id == None: - proofread_iteration = 0 - else: - proofread_iteration = len(reviewers_id) - return proofread_iteration - -def get_words(file_path): - data = get_yml_content(file_path) - original = get_orignal(data) - if not original: - raise ValueError(f"The 'original' property is not found in {file_path}.") - - directory = os.path.dirname(file_path) - md_file = os.path.join(directory, f"{original}.md") - yml_file = os.path.join(directory, f"{original}.yml") - - if os.path.exists(md_file): - file_to_check = md_file - elif os.path.exists(yml_file): - file_to_check = yml_file - else: - raise FileNotFoundError(f"Neither {md_file} nor {yml_file} exists.") - - with open(file_to_check, 'r', encoding='utf-8') as file: - content = file.read() - word_count = len(content.split()) - - return word_count - -def get_orignal(data): - original = data.get('original_language') - return original - - -def get_difficulty_factor(data, is_from_glossary=False): - level = data.get('level') - - if not level: - # for all content without the mention of level - # ie. all resources except those from glossary - if is_from_glossary: - difficulty_factor = 3.00 - else: - difficulty_factor = 1.00 - else: - difficulty_factor = difficulty_factors[level] - return difficulty_factor - -def is_from_glossary(data): - is_term = data.get('en_word') - if not is_term: - glossary = False - else: - glossary = True - return glossary - -def get_yml_content(file_path): - with open(file_path, 'r', encoding='utf-8') as file: - data = yaml.load(file) - return data - - - - -def is_original(data, language) - return data['original_language'] == language - -def get_data_languages(data): - data_languages = list(data['proofreading'].keys()) - return data_languages - - -root_directory = '../../' -subfolders = ["courses", "tutorials", "resources"] - -print("let's start the reward computation!") -for subfolder in subfolders: - full_path = os.path.join(root_directory, subfolder) - if os.path.exists(full_path): - for root, dirs, files in os.walk(full_path): - for file in files: - if file in specific_files: - file_path = os.path.join(root, file) - # print(file_path) - data = get_yml_content(file_path) - if data.get('proofreading'): - words = get_words(file_path) - difficulty_factor = get_difficulty_factor(data) - data_languages = get_data_languages(data) - for language in data_languages: - language_factor = language_factors[language] - reviewers_id = data['proofreading'][language]['contributors_id'] - proofread_iteration = get_proofread_iteration(reviewers_id) - urgency = data['proofreading'][language]['urgency'] - original = is_original(data, language) - reward = compute_reward(words, difficulty_factor, language_factor, urgency, BASE_FEE, proofread_iteration, original) - update_reward_property(file_path, language, reward) - # print(f"{file_path} reward updated in {language} to {reward}") - print(f"rewards set for {subfolder}") -print("reward update process finished!") - - diff --git a/scripts/proofreading-metadata/add-proofreading-metadata.py b/scripts/proofreading-metadata/add-proofreading-metadata.py deleted file mode 100644 index 6e644b7ac3f..00000000000 --- a/scripts/proofreading-metadata/add-proofreading-metadata.py +++ /dev/null @@ -1,79 +0,0 @@ -import os - -# List of languages you might include in the YAML files -languages = ["fr", "en", "cs", "de", "es", "fi", "it", "ja", "pt", "vi", "ru"] -languages.sort() # Sorts the language list alphabetically - -# Part of the template that is always added -base_template = """ -# Proofreading metadata -original_language: -proofreading:""" -def template_language_specific(lang): - content = f""" - - {lang} - - last_contridution_date: - - urgency: - - contributor_names: - - reward: """ - return content - -def append_to_yaml(file_path, content): - """Append the specified content to a YAML file.""" - with open(file_path, 'a') as file: - file.write(content) - -def check_language_files(root, languages): - """Check each language to see if there are files starting with '{lang}.'""" - lang_presence = {} - for lang in languages: - lang_presence[lang] = any(f.startswith(lang + ".") for f in os.listdir(root) if os.path.isfile(os.path.join(root, f))) - return lang_presence - - -specific_files = {"course.yml", - "question.yml", - "tutorial.yml", - "book.yml", - # "podcast.yml", - "word.yml", - "bet.yml", - "builder.yml", - "conference.yml" - } - - -def process_directory(root_dir, subfolders): - """Process each YAML file in the specified subdirectories.""" - for subfolder in subfolders: - full_path = os.path.join(root_dir, subfolder) - if os.path.exists(full_path): - for root, dirs, files in os.walk(full_path): - for file in files: - if file in specific_files: - file_path = os.path.join(root, file) - lang_presence = check_language_files(root, languages) - content_to_append = base_template - # Check each language and append if relevant files are found - for lang in languages: - print(lang_presence[lang]) - if lang_presence[lang]: - content_to_append += f""" - - {lang} - - last_contribution_date: - - urgency: - - contributor_names: - - reward: """ - append_to_yaml(file_path, content_to_append) - print(f"Processed {file_path}") - else: - print(f"Subfolder {subfolder} does not exist in the root directory.") - -# Root directory for the specific subfolders -root_directory = '../../' -# root_directory = '../../test-bec/' -subfolders = ["courses", "tutorials", "resources"] - -# Call the function with the list of subfolders to process -process_directory(root_directory, subfolders) - diff --git a/scripts/proofreading-metadata/add_batch_author.py b/scripts/proofreading-metadata/add_batch_author.py deleted file mode 100644 index abab63320a6..00000000000 --- a/scripts/proofreading-metadata/add_batch_author.py +++ /dev/null @@ -1,36 +0,0 @@ -import os - -from proofreading import * -from datetime import datetime - -current_date = datetime.now() -current_date = current_date.date() - - -root_directory = "../../" -# -for root, dirs, files in os.walk(root_directory): - if 'docs' in dirs: - dirs.remove('docs') - for file in files: - if file in specific_files: - - yml_file_path = os.path.join(root, file) - print(yml_file_path) - data = get_yml_content(yml_file_path) - - language = get_orignal(data) - state = get_proofreading_state(data, language) - if state == 0: - add_proofreading_contributor(data, language, 'Asi0Flammeus') - - date = '2024-09-01' - date = datetime.strptime(date, "%Y-%m-%d").date() - update_proofreading_inline_property(data, language, - 'last_contribution_date', date) - update_yml_data(yml_file_path, data) - print(data) - print() - print(f'{yml_file_path} has been updated') -# -# diff --git a/scripts/proofreading-metadata/batch_add_contributor_names.py b/scripts/proofreading-metadata/batch_add_contributor_names.py deleted file mode 100644 index c60530e9c62..00000000000 --- a/scripts/proofreading-metadata/batch_add_contributor_names.py +++ /dev/null @@ -1,841 +0,0 @@ -import os -import yaml -from datetime import datetime -from pathlib import Path -import re - -def add_contributor_to_yaml(file_path, language, contributor_name): - """Add a contributor name to a YAML file while preserving original formatting and structure.""" - try: - # Read the original file content as text - with open(file_path, 'r', encoding='utf-8') as file: - original_content = file.read() - - # Parse the YAML content - data = yaml.safe_load(original_content) - - # Get current date in YYYY-MM-DD format - current_date = datetime.now().strftime('%Y-%m-%d') - - # Find the element for the specified language - language_found = False - updated = False - - # Normalize language for comparison (strip whitespace, handle case) - target_language = language.strip() - - for item in data.get('proofreading', []): - item_language = item.get('language', '').strip() - - # Case-insensitive comparison for language matching - if item_language.lower() == target_language.lower(): - language_found = True - - # Initialize contributor_names if it doesn't exist or is null - if 'contributor_names' not in item or item['contributor_names'] is None: - item['contributor_names'] = [] - - # Add contributor name if not already present (case-insensitive check) - existing_contributors = [name.strip() for name in item['contributor_names']] - if contributor_name not in existing_contributors: - item['contributor_names'].append(contributor_name) - updated = True - else: - print(f" ℹ {contributor_name} already exists in {item_language} contributors") - - # Update last contribution date - item['last_contribution_date'] = current_date - break - - if not language_found: - print(f" ⚠ Language '{target_language}' not found in proofreading section") - # Debug: Show all available languages - available_languages = [item.get('language', 'N/A') for item in data.get('proofreading', [])] - print(f" 📋 Available languages: {available_languages}") - return False - - if not updated: - return True # No changes needed, but operation was successful - - # Now we need to update the file while preserving the original structure - # We'll use string manipulation to only update the proofreading section - lines = original_content.split('\n') - - # Find the proofreading section - proofreading_start = -1 - proofreading_indent = "" - - for i, line in enumerate(lines): - if line.strip() == 'proofreading:' or line.strip().startswith('proofreading:'): - proofreading_start = i - # Determine indentation level - proofreading_indent = line[:len(line) - len(line.lstrip())] - break - - if proofreading_start == -1: - print(f" ❌ Could not find proofreading section in {file_path}") - return False - - # Find where proofreading section ends - proofreading_end = len(lines) - base_indent_len = len(proofreading_indent) - - for i in range(proofreading_start + 1, len(lines)): - line = lines[i] - if line.strip() == '': - continue - - # Check if this line is at the same level or less indented than "proofreading:" - current_indent_len = len(line) - len(line.lstrip()) - if current_indent_len <= base_indent_len and line.strip() and not line.lstrip().startswith('-') and not line.lstrip().startswith(' '): - proofreading_end = i - break - - # Generate new proofreading section - new_proofreading_lines = [f"{proofreading_indent}proofreading:"] - - for item in data.get('proofreading', []): - new_proofreading_lines.append(f"{proofreading_indent} - language: {item['language']}") - - # Handle last_contribution_date - if item.get('last_contribution_date'): - date_value = item['last_contribution_date'] - if isinstance(date_value, str) and ('-' in date_value or date_value.startswith('2025')): - new_proofreading_lines.append(f"{proofreading_indent} last_contribution_date: '{date_value}'") - else: - new_proofreading_lines.append(f"{proofreading_indent} last_contribution_date: {date_value}") - else: - new_proofreading_lines.append(f"{proofreading_indent} last_contribution_date:") - - new_proofreading_lines.append(f"{proofreading_indent} urgency: {item.get('urgency', 1)}") - - # Handle contributor_names - if item.get('contributor_names') and len(item['contributor_names']) > 0: - new_proofreading_lines.append(f"{proofreading_indent} contributor_names:") - for name in item['contributor_names']: - new_proofreading_lines.append(f"{proofreading_indent} - {name}") - else: - new_proofreading_lines.append(f"{proofreading_indent} contributor_names:") - - new_proofreading_lines.append(f"{proofreading_indent} reward: {item.get('reward', 0)}") - - # Reconstruct the file content - new_content = ( - '\n'.join(lines[:proofreading_start]) + '\n' + - '\n'.join(new_proofreading_lines) + '\n' + - '\n'.join(lines[proofreading_end:]) - ).strip() + '\n' - - # Write the updated content back to the file - with open(file_path, 'w', encoding='utf-8') as file: - file.write(new_content) - - print(f" ✅ File successfully written") - return True - - except FileNotFoundError: - print(f" ❌ File not found: {file_path}") - return False - except yaml.YAMLError as e: - print(f" ❌ YAML parsing error in {file_path}: {str(e)}") - return False - except Exception as e: - print(f" ❌ Error modifying file {file_path}: {str(e)}") - return False - -def process_subfolders(folder_path, language, contributor_name, selected_subfolders=None): - """Process selected subfolders and add contributor name to YAML files.""" - folder_path = Path(folder_path) - - # Get all subfolders - try: - subfolders = [f.name for f in folder_path.iterdir() if f.is_dir()] - except (FileNotFoundError, PermissionError) as e: - print(f"Error accessing folder {folder_path}: {str(e)}") - return - - if not subfolders: - print("No subfolders found in the selected folder.") - return - - # Sort subfolders alphabetically for consistent processing - subfolders.sort() - - # Determine which subfolders to process - if selected_subfolders is None: - # Show list of subfolders for interactive selection - print(f"\nSubfolders in '{folder_path.name}':") - for i, subfolder in enumerate(subfolders): - print(f"{i + 1:2d}. {subfolder}") - - # Input for subfolders to modify - try: - selection = input("\nEnter subfolder numbers to modify (comma-separated, or 'all' for all): ").strip() - - if selection.lower() == 'all': - folders_to_process = subfolders - else: - selected_indices = [int(num.strip()) for num in selection.split(',')] - folders_to_process = [subfolders[i - 1] for i in selected_indices if 1 <= i <= len(subfolders)] - - if not folders_to_process: - print("No valid selections made.") - return - - except (ValueError, IndexError): - print("Invalid input. Processing all subfolders.") - folders_to_process = subfolders - else: - # Use provided subfolder list, filtering to only existing ones - folders_to_process = [sf for sf in selected_subfolders if sf in subfolders] - if not folders_to_process: - print("None of the specified subfolders were found.") - return - - print(f"\nProcessing {len(folders_to_process)} subfolder(s)...") - - # Process each selected subfolder - success_count = 0 - for subfolder in folders_to_process: - subfolder_path = folder_path / subfolder - yaml_file = subfolder_path / 'tutorial.yml' - - print(f"\nProcessing: {subfolder}") - if yaml_file.exists(): - if add_contributor_to_yaml(yaml_file, language, contributor_name): - success_count += 1 - else: - print(f" ⚠ tutorial.yml not found in {subfolder}") - - print(f"\n✅ Successfully processed {success_count} out of {len(folders_to_process)} subfolders.") - -def get_subfolders(folder_path): - """Return a list of subfolders within a specified folder.""" - folder_path = Path(folder_path) - - if not folder_path.exists(): - return [] - - try: - return [f.name for f in folder_path.iterdir() if f.is_dir()] - except PermissionError: - print(f"Permission denied accessing {folder_path}") - return [] - -def validate_inputs(contributor_name, language): - """Validate user inputs.""" - if not contributor_name.strip(): - print("Error: Contributor name cannot be empty.") - return False - - if not language.strip(): - print("Error: Language cannot be empty.") - return False - - # Updated validation for language code to handle complex codes like zh-Hans, sr-Latn, nb-NO - # Allow 2-10 characters, letters, hyphens, and allow uppercase letters for script codes - if not re.match(r'^[a-zA-Z]{2,3}(-[a-zA-Z]{2,4})*$', language) or len(language) < 2 or len(language) > 10: - print("Warning: Language should be a valid language code (e.g., 'en', 'fr', 'es', 'pt-br', 'zh-Hans', 'sr-Latn')") - proceed = input("Continue anyway? (y/n): ").lower().startswith('y') - if not proceed: - return False - - return True - -def process_alphabetical_range(main_folder_path, language, contributor_name): - """Process subfolders in alphabetical range from start to end folder.""" - all_subfolders = get_subfolders(main_folder_path) - if not all_subfolders: - print("No subfolders found in the selected folder.") - return - - all_subfolders.sort() - - print(f"\nAvailable subfolders in '{main_folder_path.name}':") - for i, subfolder in enumerate(all_subfolders): - print(f"{i + 1:2d}. {subfolder}") - - try: - start_choice = int(input(f"\nSelect starting subfolder (1-{len(all_subfolders)}): ")) - end_choice = int(input(f"Select ending subfolder (1-{len(all_subfolders)}): ")) - - if not (1 <= start_choice <= len(all_subfolders)) or not (1 <= end_choice <= len(all_subfolders)): - print("Invalid selection.") - return - - if start_choice > end_choice: - print("Starting subfolder must come before or be the same as ending subfolder in alphabetical order.") - return - - selected_subfolders = all_subfolders[start_choice-1:end_choice] - print(f"\nSelected range: {selected_subfolders[0]} to {selected_subfolders[-1]}") - print(f"This will process {len(selected_subfolders)} subfolder(s).") - - confirm = input("Continue? (y/n): ").lower().startswith('y') - if confirm: - process_subfolders(main_folder_path, language, contributor_name, selected_subfolders) - else: - print("Operation cancelled.") - - except ValueError: - print("Invalid input. Please enter numbers.") - -def process_glossary_subfolders(folder_path, language, contributor_name, selected_subfolders=None): - """Process selected glossary subfolders and add contributor name to word.yml files.""" - folder_path = Path(folder_path) - - # Get all subfolders - try: - subfolders = [f.name for f in folder_path.iterdir() if f.is_dir()] - except (FileNotFoundError, PermissionError) as e: - print(f"Error accessing folder {folder_path}: {str(e)}") - return - - if not subfolders: - print("No subfolders found in the selected folder.") - return - - # Sort subfolders alphabetically for consistent processing - subfolders.sort() - - # Determine which subfolders to process - if selected_subfolders is None: - # Show list of subfolders for interactive selection - print(f"\nSubfolders in '{folder_path.name}':") - for i, subfolder in enumerate(subfolders): - print(f"{i + 1:2d}. {subfolder}") - - # Input for subfolders to modify - try: - selection = input("\nEnter subfolder numbers to modify (comma-separated, or 'all' for all): ").strip() - - if selection.lower() == 'all': - folders_to_process = subfolders - else: - selected_indices = [int(num.strip()) for num in selection.split(',')] - folders_to_process = [subfolders[i - 1] for i in selected_indices if 1 <= i <= len(subfolders)] - - if not folders_to_process: - print("No valid selections made.") - return - - except (ValueError, IndexError): - print("Invalid input. Processing all subfolders.") - folders_to_process = subfolders - else: - # Use provided subfolder list, filtering to only existing ones - folders_to_process = [sf for sf in selected_subfolders if sf in subfolders] - if not folders_to_process: - print("None of the specified subfolders were found.") - return - - print(f"\nProcessing {len(folders_to_process)} subfolder(s)...") - - # Process each selected subfolder - success_count = 0 - for subfolder in folders_to_process: - subfolder_path = folder_path / subfolder - yaml_file = subfolder_path / 'word.yml' # Different filename for glossary - - print(f"\nProcessing: {subfolder}") - if yaml_file.exists(): - if add_contributor_to_yaml(yaml_file, language, contributor_name): - success_count += 1 - else: - print(f" ⚠ word.yml not found in {subfolder}") - - print(f"\n✅ Successfully processed {success_count} out of {len(folders_to_process)} subfolders.") - -def process_alphabetical_range_glossary(main_folder_path, language, contributor_name): - """Process glossary subfolders in alphabetical range from start to end folder.""" - all_subfolders = get_subfolders(main_folder_path) - if not all_subfolders: - print("No subfolders found in the selected folder.") - return - - all_subfolders.sort() - - print(f"\nAvailable subfolders in '{main_folder_path.name}':") - for i, subfolder in enumerate(all_subfolders): - print(f"{i + 1:2d}. {subfolder}") - - try: - start_choice = int(input(f"\nSelect starting subfolder (1-{len(all_subfolders)}): ")) - end_choice = int(input(f"Select ending subfolder (1-{len(all_subfolders)}): ")) - - if not (1 <= start_choice <= len(all_subfolders)) or not (1 <= end_choice <= len(all_subfolders)): - print("Invalid selection.") - return - - if start_choice > end_choice: - print("Starting subfolder must come before or be the same as ending subfolder in alphabetical order.") - return - - selected_subfolders = all_subfolders[start_choice-1:end_choice] - print(f"\nSelected range: {selected_subfolders[0]} to {selected_subfolders[-1]}") - print(f"This will process {len(selected_subfolders)} subfolder(s).") - - confirm = input("Continue? (y/n): ").lower().startswith('y') - if confirm: - process_glossary_subfolders(main_folder_path, language, contributor_name, selected_subfolders) - else: - print("Operation cancelled.") - - except ValueError: - print("Invalid input. Please enter numbers.") - -def process_quiz_subfolders(course_path, language, contributor_name, selected_subfolders=None): - """Process selected quiz subfolders and add contributor name to question.yml files.""" - quiz_path = course_path / 'quizz' - - if not quiz_path.exists(): - print(f" ⚠ No 'quizz' folder found in {course_path.name}") - return - - # Get all quiz subfolders - try: - subfolders = [f.name for f in quiz_path.iterdir() if f.is_dir()] - except (FileNotFoundError, PermissionError) as e: - print(f"Error accessing folder {quiz_path}: {str(e)}") - return - - if not subfolders: - print("No quiz subfolders found.") - return - - # Sort subfolders numerically for proper quiz order - def sort_key(x): - try: - return int(x) - except ValueError: - return float('inf') # Put non-numeric folders at the end - - subfolders.sort(key=sort_key) - - # Determine which subfolders to process - if selected_subfolders is None: - # Show list of subfolders for interactive selection - print(f"\nQuiz subfolders in '{course_path.name}/quizz':") - for i, subfolder in enumerate(subfolders): - print(f"{i + 1:2d}. {subfolder}") - - # Input for subfolders to modify - try: - selection = input("\nEnter quiz numbers to modify (comma-separated, or 'all' for all): ").strip() - - if selection.lower() == 'all': - folders_to_process = subfolders - else: - selected_indices = [int(num.strip()) for num in selection.split(',')] - folders_to_process = [subfolders[i - 1] for i in selected_indices if 1 <= i <= len(subfolders)] - - if not folders_to_process: - print("No valid selections made.") - return - - except (ValueError, IndexError): - print("Invalid input. Processing all subfolders.") - folders_to_process = subfolders - else: - # Use provided subfolder list, filtering to only existing ones - folders_to_process = [sf for sf in selected_subfolders if sf in subfolders] - if not folders_to_process: - print("None of the specified quiz subfolders were found.") - return - - print(f"\nProcessing {len(folders_to_process)} quiz subfolder(s)...") - - # Process each selected subfolder - success_count = 0 - for subfolder in folders_to_process: - subfolder_path = quiz_path / subfolder - yaml_file = subfolder_path / 'question.yml' - - print(f"\nProcessing quiz: {subfolder}") - if yaml_file.exists(): - if add_contributor_to_yaml(yaml_file, language, contributor_name): - success_count += 1 - else: - print(f" ⚠ question.yml not found in quiz {subfolder}") - - print(f"\n✅ Successfully processed {success_count} out of {len(folders_to_process)} quiz subfolders.") - -def process_course_folders(content_path, language, contributor_name, selected_courses=None): - """Process selected course folders and add contributor name to course.yml files.""" - - # Get all course folders - try: - course_folders = [f.name for f in content_path.iterdir() if f.is_dir()] - except (FileNotFoundError, PermissionError) as e: - print(f"Error accessing folder {content_path}: {str(e)}") - return - - if not course_folders: - print("No course folders found.") - return - - # Sort course folders alphabetically for consistent processing - course_folders.sort() - - # Determine which courses to process - if selected_courses is None: - # Show list of courses for interactive selection - print(f"\nCourse folders in '{content_path.name}':") - for i, course in enumerate(course_folders): - print(f"{i + 1:2d}. {course}") - - # Input for courses to modify - try: - selection = input("\nEnter course numbers to modify (comma-separated, or 'all' for all): ").strip() - - if selection.lower() == 'all': - courses_to_process = course_folders - else: - selected_indices = [int(num.strip()) for num in selection.split(',')] - courses_to_process = [course_folders[i - 1] for i in selected_indices if 1 <= i <= len(course_folders)] - - if not courses_to_process: - print("No valid selections made.") - return - - except (ValueError, IndexError): - print("Invalid input. Processing all courses.") - courses_to_process = course_folders - else: - # Use provided course list, filtering to only existing ones - courses_to_process = [cf for cf in selected_courses if cf in course_folders] - if not courses_to_process: - print("None of the specified courses were found.") - return - - print(f"\nProcessing {len(courses_to_process)} course(s)...") - - # Process each selected course - success_count = 0 - for course in courses_to_process: - course_path = content_path / course - yaml_file = course_path / 'course.yml' - - print(f"\nProcessing course: {course}") - if yaml_file.exists(): - if add_contributor_to_yaml(yaml_file, language, contributor_name): - success_count += 1 - else: - print(f" ⚠ course.yml not found in {course}") - - print(f"\n✅ Successfully processed {success_count} out of {len(courses_to_process)} courses.") - -def process_alphabetical_range_course(content_path, language, contributor_name): - """Process course folders in alphabetical range from start to end.""" - try: - all_courses = [f.name for f in content_path.iterdir() if f.is_dir()] - except PermissionError: - print(f"Permission denied accessing {content_path}") - return - - if not all_courses: - print("No course folders found.") - return - - all_courses.sort() - - print(f"\nAvailable courses in '{content_path.name}':") - for i, course in enumerate(all_courses): - print(f"{i + 1:2d}. {course}") - - try: - start_choice = int(input(f"\nSelect starting course (1-{len(all_courses)}): ")) - end_choice = int(input(f"Select ending course (1-{len(all_courses)}): ")) - - if not (1 <= start_choice <= len(all_courses)) or not (1 <= end_choice <= len(all_courses)): - print("Invalid selection.") - return - - if start_choice > end_choice: - print("Starting course must come before or be the same as ending course in alphabetical order.") - return - - selected_courses = all_courses[start_choice-1:end_choice] - print(f"\nSelected range: {selected_courses[0]} to {selected_courses[-1]}") - print(f"This will process {len(selected_courses)} course(s).") - - confirm = input("Continue? (y/n): ").lower().startswith('y') - if confirm: - process_course_folders(content_path, language, contributor_name, selected_courses) - else: - print("Operation cancelled.") - - except ValueError: - print("Invalid input. Please enter numbers.") - -def process_alphabetical_range_quiz(course_path, language, contributor_name): - """Process quiz subfolders in numerical range from start to end.""" - quiz_path = course_path / 'quizz' - - if not quiz_path.exists(): - print(f" ⚠ No 'quizz' folder found in {course_path.name}") - return - - try: - all_subfolders = [f.name for f in quiz_path.iterdir() if f.is_dir()] - except PermissionError: - print(f"Permission denied accessing {quiz_path}") - return - - if not all_subfolders: - print("No quiz subfolders found.") - return - - # Sort numerically - def sort_key(x): - try: - return int(x) - except ValueError: - return float('inf') - - all_subfolders.sort(key=sort_key) - - print(f"\nAvailable quiz subfolders in '{course_path.name}/quizz':") - for i, subfolder in enumerate(all_subfolders): - print(f"{i + 1:2d}. {subfolder}") - - try: - start_choice = int(input(f"\nSelect starting quiz (1-{len(all_subfolders)}): ")) - end_choice = int(input(f"Select ending quiz (1-{len(all_subfolders)}): ")) - - if not (1 <= start_choice <= len(all_subfolders)) or not (1 <= end_choice <= len(all_subfolders)): - print("Invalid selection.") - return - - if start_choice > end_choice: - print("Starting quiz must come before or be the same as ending quiz in numerical order.") - return - - selected_subfolders = all_subfolders[start_choice-1:end_choice] - print(f"\nSelected range: {selected_subfolders[0]} to {selected_subfolders[-1]}") - print(f"This will process {len(selected_subfolders)} quiz subfolder(s).") - - confirm = input("Continue? (y/n): ").lower().startswith('y') - if confirm: - process_quiz_subfolders(course_path, language, contributor_name, selected_subfolders) - else: - print("Operation cancelled.") - - except ValueError: - print("Invalid input. Please enter numbers.") - -def main(): - print("=== Contributor Management Script ===\n") - - while True: # Loop to allow adding multiple contributors - # Content type selection - print("Content type options:") - print("1. Tutorials") - print("2. Glossary") - print("3. Quizzes") - print("4. Courses") - - try: - content_choice = int(input("Select content type to update (1-4): ")) - if content_choice not in [1, 2, 3, 4]: - print("Invalid selection.") - continue - except ValueError: - print("Invalid input. Please enter a number.") - continue - - # User input - contributor_name = input("Enter the proofreader name to add: ").strip() - language = input("Enter the language code (e.g., en, fr, es, pt-br, zh-Hans, sr-Latn): ").strip() - - # Don't convert language to lowercase automatically - preserve original case - # This is important for script codes like "Hans" in "zh-Hans" - - # Validate inputs - if not validate_inputs(contributor_name, language): - continue - - # Base path - use pathlib for better path handling - script_path = Path(__file__).resolve() - base_path = script_path.parent.parent.parent # Go up 3 levels to bitcoin-educational-content - - if content_choice == 1: - # Tutorials - content_path = base_path / 'tutorials' - yaml_filename = 'tutorial.yml' - content_type = 'tutorials' - elif content_choice == 2: - # Glossary - content_path = base_path / 'resources' / 'glossary' - yaml_filename = 'word.yml' - content_type = 'glossary' - elif content_choice == 3: - # Quizzes - content_path = base_path / 'courses' - yaml_filename = 'question.yml' - content_type = 'quizzes' - else: - # Courses - content_path = base_path / 'courses' - yaml_filename = 'course.yml' - content_type = 'courses' - - # Verify the content directory exists - if not content_path.exists(): - print(f"Error: {content_type.capitalize()} directory not found at {content_path}") - print("Please ensure the script is in the correct location within the project structure.") - continue - - if content_choice == 1: - # Tutorials - show main folders for selection - main_folders = get_subfolders(content_path) - if not main_folders: - print(f"No main folders found in the '{content_type}' directory.") - continue - - print(f"\nMain folders in '{content_type}' directory:") - for i, folder in enumerate(sorted(main_folders)): - print(f"{i + 1:2d}. {folder}") - - # Input for main folder selection - try: - main_folder_choice = int(input(f"\nSelect a main folder (1-{len(main_folders)}): ")) - if 1 <= main_folder_choice <= len(main_folders): - selected_main_folder = sorted(main_folders)[main_folder_choice - 1] - main_folder_path = content_path / selected_main_folder - else: - print("Invalid selection.") - continue - except ValueError: - print("Invalid input. Please enter a number.") - continue - - print(f"\nSelected folder: {selected_main_folder}") - elif content_choice == 2: - # Glossary - work directly with the glossary folder - main_folder_path = content_path - print(f"\nWorking with glossary directory") - elif content_choice == 3: - # Quizzes - show course folders for selection (multi-select) - course_folders = get_subfolders(content_path) - if not course_folders: - print(f"No course folders found in the '{content_type}' directory.") - continue - - sorted_courses = sorted(course_folders) - print(f"\nCourse folders in '{content_type}' directory:") - for i, folder in enumerate(sorted_courses): - print(f"{i + 1:2d}. {folder}") - - # Input for course folder selection (supports multiple) - try: - selection = input(f"\nSelect course folders (comma-separated numbers, or 'all'): ").strip() - - if selection.lower() == 'all': - selected_quiz_courses = sorted_courses - else: - selected_indices = [int(num.strip()) for num in selection.split(',')] - selected_quiz_courses = [sorted_courses[i - 1] for i in selected_indices if 1 <= i <= len(sorted_courses)] - - if not selected_quiz_courses: - print("No valid selections made.") - continue - except (ValueError, IndexError): - print("Invalid input.") - continue - - selected_course_paths = [content_path / course for course in selected_quiz_courses] - # Set main_folder_path to first for compatibility with single-course processing modes - main_folder_path = selected_course_paths[0] - print(f"\nSelected {len(selected_quiz_courses)} course(s): {', '.join(selected_quiz_courses)}") - else: - # Courses - work directly with the courses folder - main_folder_path = content_path - print(f"\nWorking with courses directory") - - # Processing mode selection - print("Processing options:") - print("1. Process all subfolders automatically (alphabetical order)") - print("2. Select specific subfolders interactively") - - try: - mode_choice = int(input("Choose processing mode (1-2): ")) - if mode_choice == 1: - # Alphabetical processing with sub-options - print("\nAlphabetical processing options:") - print("1. Process ALL subfolders") - print("2. Process subfolders from a specific range (start to end)") - - try: - alpha_choice = int(input("Choose alphabetical option (1-2): ")) - if alpha_choice == 1: - # Process all subfolders automatically - if content_choice == 1: - all_subfolders = get_subfolders(main_folder_path) - process_subfolders(main_folder_path, language, contributor_name, all_subfolders) - elif content_choice == 2: - all_subfolders = get_subfolders(main_folder_path) - process_glossary_subfolders(main_folder_path, language, contributor_name, all_subfolders) - elif content_choice == 3: - # For quizzes, process all selected courses - for course_path in selected_course_paths: - print(f"\n--- Course: {course_path.name} ---") - quiz_path = course_path / 'quizz' - if quiz_path.exists(): - all_quiz_subfolders = get_subfolders(quiz_path) - process_quiz_subfolders(course_path, language, contributor_name, all_quiz_subfolders) - else: - print(f" ⚠ No 'quizz' folder found in {course_path.name}") - else: # content_choice == 4 - # For courses, get all course folders - all_courses = get_subfolders(main_folder_path) - process_course_folders(main_folder_path, language, contributor_name, all_courses) - elif alpha_choice == 2: - # Process alphabetical range - if content_choice == 1: - process_alphabetical_range(main_folder_path, language, contributor_name) - elif content_choice == 2: - process_alphabetical_range_glossary(main_folder_path, language, contributor_name) - elif content_choice == 3: - for course_path in selected_course_paths: - print(f"\n--- Course: {course_path.name} ---") - process_alphabetical_range_quiz(course_path, language, contributor_name) - else: # content_choice == 4 - process_alphabetical_range_course(main_folder_path, language, contributor_name) - else: - print("Invalid selection.") - continue - except ValueError: - print("Invalid input. Please enter a number.") - continue - - elif mode_choice == 2: - # Interactive subfolder selection - if content_choice == 1: - process_subfolders(main_folder_path, language, contributor_name) - elif content_choice == 2: - process_glossary_subfolders(main_folder_path, language, contributor_name) - elif content_choice == 3: - for course_path in selected_course_paths: - print(f"\n--- Course: {course_path.name} ---") - process_quiz_subfolders(course_path, language, contributor_name) - else: # content_choice == 4 - process_course_folders(main_folder_path, language, contributor_name) - else: - print("Invalid selection.") - continue - except ValueError: - print("Invalid input. Please enter a number.") - continue - - # Ask if user wants to add another contributor - print("\n" + "="*50) - add_another = input("Do you want to add another contributor? (y/n): ").lower().startswith('y') - if not add_another: - break - print() # Add blank line for readability - - print("\n=== Script completed ===") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/proofreading-metadata/content_difficulty.yml b/scripts/proofreading-metadata/content_difficulty.yml deleted file mode 100644 index 1ef5564c8f5..00000000000 --- a/scripts/proofreading-metadata/content_difficulty.yml +++ /dev/null @@ -1,4 +0,0 @@ -beginner: 1 -intermediate: 2 -advanced: 3 -expert: 4 diff --git a/scripts/proofreading-metadata/courses_tutorials_words_reward/pending_rewards.py b/scripts/proofreading-metadata/courses_tutorials_words_reward/pending_rewards.py deleted file mode 100644 index 79ef26f48e6..00000000000 --- a/scripts/proofreading-metadata/courses_tutorials_words_reward/pending_rewards.py +++ /dev/null @@ -1,254 +0,0 @@ -import os -from pathlib import Path -import pandas as pd -import openpyxl -from openpyxl.styles import Font, numbers - -def count_words_in_file(file_path): - try: - with open(file_path, 'r', encoding='utf-8') as file: - return len(file.read().split()) - except Exception as e: - print(f"Error reading {file_path}: {e}") - return 0 - -def read_yaml_file(yaml_path): - """ - Read the yaml file (course.yml or tutorial.yml) and extract all language-reward pairs, - WITHOUT applying any multipliers regardless of contributor count. - """ - try: - with open(yaml_path, 'r', encoding='utf-8') as file: - lines = [line.rstrip() for line in file.readlines()] - language_rewards = {} - current_language = None - i = 0 - - while i < len(lines): - line = lines[i].strip() - - if line.startswith('- language:'): - current_language = line.split('- language:')[1].strip() - i += 1 # Move to the next line - continue - - # If we find a reward line while we have a current language - if line.startswith('reward:') and current_language: - try: - reward = float(line.split('reward:')[1].strip()) - language_rewards[current_language] = reward - print(f"Language: {current_language}, Reward: ${reward:.2f}") - - # Reset current_language - current_language = None - except ValueError: - print(f"Warning: Invalid reward value in {yaml_path} for language {current_language}") - - i += 1 - - return language_rewards - except Exception as e: - print(f"Error reading YAML {yaml_path}: {e}") - return {} - -def process_courses(courses_path): - results = [] - languages = set() # Set to store unique languages - course_data = [] # List to store course data - - # Check if base_path exists - if not courses_path.exists(): - print(f"Warning: Courses path {courses_path} does not exist") - return [], set() - - print(f"Looking for courses in: {courses_path}") - - # First pass: collect all languages and course data - for folder in courses_path.iterdir(): - if folder.is_dir(): - yaml_file = folder / 'course.yml' - md_file = folder / 'en.md' - - if yaml_file.exists(): - print(f"\nProcessing {yaml_file}") - language_rewards = read_yaml_file(yaml_file) - languages.update(language_rewards.keys()) - - course_data.append({ - 'Folder': folder.name, - 'Word Count': count_words_in_file(md_file) if md_file.exists() else 0, - 'language_rewards': language_rewards - }) - - # Print all found languages for verification - print(f"\nFound {len(course_data)} courses") - print("All found languages:", sorted(languages)) - - # Create the final data structure - for course in course_data: - row_data = { - 'Folder': course['Folder'], - 'Word Count': course['Word Count'] - } - # Add the reward under the corresponding language column - for lang in languages: - row_data[lang] = course['language_rewards'].get(lang) - - results.append(row_data) - - return results, languages - -def process_tutorials(tutorials_path): - results = [] - languages = set() # Set to store unique languages - tutorial_data = [] # List to store tutorial data - - # Check if base_path exists - if not tutorials_path.exists(): - print(f"Warning: Tutorials path {tutorials_path} does not exist") - return [], set() - - print(f"Looking for tutorials in: {tutorials_path}") - tutorial_count = 0 - - # Process level 2 directories (categories) - for category_dir in tutorials_path.iterdir(): - if category_dir.is_dir(): - category_name = category_dir.name - print(f"\nScanning category: {category_name}") - - # Process level 3 directories (tutorial names) - for tutorial_dir in category_dir.iterdir(): - if tutorial_dir.is_dir(): - yaml_file = tutorial_dir / 'tutorial.yml' - md_file = tutorial_dir / 'en.md' - - if yaml_file.exists(): - tutorial_count += 1 - print(f" Processing tutorial: {tutorial_dir.name}") - language_rewards = read_yaml_file(yaml_file) - languages.update(language_rewards.keys()) - - # Use both category and tutorial name for better identification - folder_name = f"{category_name}/{tutorial_dir.name}" - - tutorial_data.append({ - 'Folder': folder_name, - 'Word Count': count_words_in_file(md_file) if md_file.exists() else 0, - 'language_rewards': language_rewards - }) - - print(f"\nFound {tutorial_count} tutorials") - print("All found languages in tutorials:", sorted(languages)) - - # Create the final data structure - for tutorial in tutorial_data: - row_data = { - 'Folder': tutorial['Folder'], - 'Word Count': tutorial['Word Count'] - } - # Add the reward under the corresponding language column - for lang in languages: - row_data[lang] = tutorial['language_rewards'].get(lang) - - results.append(row_data) - - return results, languages - -def main(): - try: - # Use the direct relative paths as specified - script_dir = Path(__file__).resolve().parent - courses_path = script_dir / '../../../courses' - tutorials_path = script_dir / '../../../tutorials' - - print(f"Script directory: {script_dir.resolve()}") - print(f"Looking for courses in: {courses_path.resolve()}") - print(f"Looking for tutorials in: {tutorials_path.resolve()}") - - # Process courses and tutorials - courses_results, course_languages = process_courses(courses_path) - tutorials_results, tutorial_languages = process_tutorials(tutorials_path) - - # Combine results - results = [] - results.extend(courses_results) - results.extend(tutorials_results) - - # Combine all found languages - all_languages = course_languages.union(tutorial_languages) - - if not results: - print("No data found. Please check if the paths are correct.") - return - - # Save results in the same directory as the script - output_path = script_dir / 'pending_rewards.xlsx' - - # Create DataFrame - df = pd.DataFrame(results) - - if 'Word Count' in df.columns: - # Rename 'Word Count' to 'Word Count EN' - df = df.rename(columns={'Word Count': 'Word Count EN'}) - elif not df.empty and 'Word Count EN' not in df.columns: - # If neither column exists, add Word Count EN - df['Word Count EN'] = None - - # Ensure all language columns exist - for lang in all_languages: - if lang not in df.columns: - df[lang] = None - - # Sort the columns to ensure Folder and Word Count EN are first, followed by alphabetically sorted languages - columns = ['Folder', 'Word Count EN'] + sorted([col for col in df.columns if col not in ['Folder', 'Word Count EN']]) - df = df[columns] - - # Calculate total - total = {} - total['Folder'] = 'Total' - total['Word Count EN'] = None - for col in columns[2:]: # Skip Folder and Word Count EN - total[col] = df[col].sum() - - # Append total row to the DataFrame - df = pd.concat([df, pd.DataFrame([total])], ignore_index=True) - - # Create Excel file with formatting - with pd.ExcelWriter(output_path, engine='openpyxl') as writer: - # Write the DataFrame to Excel - df.to_excel(writer, index=False) - - # Get the worksheet - worksheet = writer.sheets['Sheet1'] - - # Apply dollar formatting to all language columns (excluding Folder and Word Count EN) - for idx, column_name in enumerate(columns[2:], start=3): # Start from 3rd column (index 2 + 1) - col_letter = openpyxl.utils.get_column_letter(idx) - for row in range(2, worksheet.max_row + 1): # Start from second row (skip header) - cell = worksheet[f"{col_letter}{row}"] - if cell.value is not None and isinstance(cell.value, (int, float)): - cell.number_format = '$#,##0.00' - - # Find the row index of "Total" - total_row = None - for idx, row in enumerate(worksheet.iter_rows(min_row=1, max_row=worksheet.max_row), 1): - if row[0].value == 'Total': - total_row = idx - break - - # Apply bold formatting to the entire row - if total_row: - for cell in worksheet[total_row]: - cell.font = Font(bold=True) - - print(f"\nResults saved to {output_path}") - - except FileNotFoundError as e: - print("Error: Make sure this script is placed in the correct location relative to courses and tutorials folders") - print(e) - except Exception as e: - print(f"An error occurred: {e}") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/proofreading-metadata/courses_tutorials_words_reward/readme.md b/scripts/proofreading-metadata/courses_tutorials_words_reward/readme.md deleted file mode 100644 index 8558f2238ff..00000000000 --- a/scripts/proofreading-metadata/courses_tutorials_words_reward/readme.md +++ /dev/null @@ -1,21 +0,0 @@ -These scripts are designed to calculate the word count for all courses and tutorials in the repository, enabling the extraction of rewards for any language. The results are then saved in an Excel file. - -Specifically: - -- **pending_rewards.py** generates a report of all available rewards for the content. -- **total_reward_budget.py** provides the total budget allocated for a specific language. - -To use these scripts, follow these steps: - -- Activate the environment as outlined in the "README" file of the previous directory. -- Install the necessary dependencies for Excel file handling by running the following command in your terminal: - -``` -pip install pandas openpyxl -``` - -Install the dependency for reading YAML files with this command: - -``` -pip install pyyaml -``` \ No newline at end of file diff --git a/scripts/proofreading-metadata/courses_tutorials_words_reward/total_reward_budget.py b/scripts/proofreading-metadata/courses_tutorials_words_reward/total_reward_budget.py deleted file mode 100644 index 111b7d1e617..00000000000 --- a/scripts/proofreading-metadata/courses_tutorials_words_reward/total_reward_budget.py +++ /dev/null @@ -1,320 +0,0 @@ -import os -from pathlib import Path -import pandas as pd -import openpyxl -from openpyxl.styles import Font, numbers - -def count_words_in_file(file_path): - try: - with open(file_path, 'r', encoding='utf-8') as file: - return len(file.read().split()) - except Exception as e: - print(f"Error reading {file_path}: {e}") - return 0 - -def calculate_reward_multiplier(lines, start_idx): - """ - Calculate reward multiplier based on number of contributors - between contributor_names: and reward: - """ - contributor_count = 0 - idx = start_idx + 1 # Start from next line after contributors_id - - while idx < len(lines) and not lines[idx].strip().startswith('reward:'): - if lines[idx].strip().startswith('-'): - contributor_count += 1 - idx += 1 - - if contributor_count == 1: - return 2 - elif contributor_count >= 2: # Changed to handle multiple contributors - return 4 - return 1 - -def read_yaml_file(yaml_path): - """ - Read the yaml file (course.yml or tutorial.yml) and extract all language-reward pairs, - applying multipliers based on contributor count. - """ - try: - with open(yaml_path, 'r', encoding='utf-8') as file: - lines = [line.rstrip() for line in file.readlines()] - language_rewards = {} - current_language = None - i = 0 - - while i < len(lines): - line = lines[i].strip() - - if line.startswith('- language:'): - current_language = line.split('- language:')[1].strip() - i += 1 # Move to the next line - continue - - if line.startswith('contributor_names:') and current_language: - # Find reward multiplier based on contributors - multiplier = calculate_reward_multiplier(lines, i) - - # Find the reward value - reward_idx = i - while reward_idx < len(lines): - if lines[reward_idx].strip().startswith('reward:'): - try: - base_reward = float(lines[reward_idx].split('reward:')[1].strip()) - final_reward = base_reward * multiplier - language_rewards[current_language] = final_reward - print(f"Language: {current_language}, Base Reward: ${base_reward:.2f}, " - f"Multiplier: {multiplier}, Final Reward: ${final_reward:.2f}") - break - except ValueError: - print(f"Warning: Invalid reward value in {yaml_path} for language {current_language}") - break - reward_idx += 1 - - # Move to the next language section - current_language = None - - # If we encounter a reward directly (without contributors_id) - elif line.startswith('reward:') and current_language: - try: - base_reward = float(line.split('reward:')[1].strip()) - # No multiplier in this case - language_rewards[current_language] = base_reward - print(f"Language: {current_language}, Reward: ${base_reward:.2f} (no multiplier)") - - # Reset current_language - current_language = None - except ValueError: - print(f"Warning: Invalid reward value in {yaml_path} for language {current_language}") - - i += 1 - - return language_rewards - except Exception as e: - print(f"Error reading YAML {yaml_path}: {e}") - return {} - -def process_courses(courses_path): - results = [] - languages = set() # Set to store unique languages - course_data = [] # List to store course data - - # Check if base_path exists - if not courses_path.exists(): - print(f"Warning: Courses path {courses_path} does not exist") - return [], set() - - print(f"Looking for courses in: {courses_path}") - - # First pass: collect all languages and course data - for folder in courses_path.iterdir(): - if folder.is_dir(): - yaml_file = folder / 'course.yml' - md_file = folder / 'en.md' - - if yaml_file.exists(): - print(f"\nProcessing {yaml_file}") - language_rewards = read_yaml_file(yaml_file) - languages.update(language_rewards.keys()) - - course_data.append({ - 'Folder': folder.name, - 'Word Count': count_words_in_file(md_file) if md_file.exists() else 0, - 'language_rewards': language_rewards - }) - - # Print all found languages for verification - print(f"\nFound {len(course_data)} courses") - print("All found languages:", sorted(languages)) - - # Create the final data structure - for course in course_data: - row_data = { - 'Folder': course['Folder'], - 'Word Count': course['Word Count'] - } - # Add the reward under the corresponding language column - for lang in languages: - row_data[lang] = course['language_rewards'].get(lang) - - results.append(row_data) - - return results, languages - -def process_tutorials(tutorials_path): - results = [] - languages = set() # Set to store unique languages - tutorial_data = [] # List to store tutorial data - - # Check if base_path exists - if not tutorials_path.exists(): - print(f"Warning: Tutorials path {tutorials_path} does not exist") - return [], set() - - print(f"Looking for tutorials in: {tutorials_path}") - tutorial_count = 0 - - # Process level 2 directories (categories) - for category_dir in tutorials_path.iterdir(): - if category_dir.is_dir(): - category_name = category_dir.name - print(f"\nScanning category: {category_name}") - - # Process level 3 directories (tutorial names) - for tutorial_dir in category_dir.iterdir(): - if tutorial_dir.is_dir(): - yaml_file = tutorial_dir / 'tutorial.yml' - md_file = tutorial_dir / 'en.md' - - if yaml_file.exists(): - tutorial_count += 1 - print(f" Processing tutorial: {tutorial_dir.name}") - language_rewards = read_yaml_file(yaml_file) - languages.update(language_rewards.keys()) - - # Use both category and tutorial name for better identification - folder_name = f"{category_name}/{tutorial_dir.name}" - - tutorial_data.append({ - 'Folder': folder_name, - 'Word Count': count_words_in_file(md_file) if md_file.exists() else 0, - 'language_rewards': language_rewards - }) - - print(f"\nFound {tutorial_count} tutorials") - print("All found languages in tutorials:", sorted(languages)) - - # Create the final data structure - for tutorial in tutorial_data: - row_data = { - 'Folder': tutorial['Folder'], - 'Word Count': tutorial['Word Count'] - } - # Add the reward under the corresponding language column - for lang in languages: - row_data[lang] = tutorial['language_rewards'].get(lang) - - results.append(row_data) - - return results, languages - -def main(): - try: - # Use the direct relative paths as specified - script_dir = Path(__file__).resolve().parent - courses_path = script_dir / '../../../courses' - tutorials_path = script_dir / '../../../tutorials' - - print(f"Script directory: {script_dir.resolve()}") - print(f"Looking for courses in: {courses_path.resolve()}") - print(f"Looking for tutorials in: {tutorials_path.resolve()}") - - # Process courses and tutorials - courses_results, course_languages = process_courses(courses_path) - tutorials_results, tutorial_languages = process_tutorials(tutorials_path) - - # Combine results - results = [] - results.extend(courses_results) - results.extend(tutorials_results) - - # Combine all found languages - all_languages = course_languages.union(tutorial_languages) - - if not results: - print("No data found. Please check if the paths are correct.") - return - - # Save results in the same directory as the script - output_path = script_dir / 'word_counts.xlsx' - - # Create DataFrame - df = pd.DataFrame(results) - - if 'Word Count' in df.columns: - # Rename 'Word Count' to 'Word Count EN' - df = df.rename(columns={'Word Count': 'Word Count EN'}) - elif not df.empty and 'Word Count EN' not in df.columns: - # If neither column exists, add Word Count EN - df['Word Count EN'] = None - - # Ensure all language columns exist - for lang in all_languages: - if lang not in df.columns: - df[lang] = None - - # Sort the columns to ensure Folder and Word Count EN are first, followed by alphabetically sorted languages - columns = ['Folder', 'Word Count EN'] + sorted([col for col in df.columns if col not in ['Folder', 'Word Count EN']]) - df = df[columns] - - # Calculate totals for proofreading - first_proofreading = {} - first_proofreading['Folder'] = 'Total for first proofreading' - first_proofreading['Word Count EN'] = None - for col in columns[2:]: # Skip Folder and Word Count EN - first_proofreading[col] = df[col].sum() - - # Add empty row - empty_row = {col: None for col in columns} - - # Calculate second proofreading - second_proofreading = {} - second_proofreading['Folder'] = 'Total for second proofreading' - second_proofreading['Word Count EN'] = None - for col in columns[2:]: - second_proofreading[col] = first_proofreading[col] / 2 - - # Calculate total for all proofreading (2 rounds now) - total_all = {} - total_all['Folder'] = 'Total for all the proofreading' - total_all['Word Count EN'] = None - for col in columns[2:]: - total_all[col] = first_proofreading[col] + second_proofreading[col] - - # Append all new rows to the DataFrame - df = pd.concat([df, - pd.DataFrame([first_proofreading]), - pd.DataFrame([empty_row]), - pd.DataFrame([second_proofreading]), - pd.DataFrame([empty_row]), - pd.DataFrame([total_all])], - ignore_index=True) - - # Create Excel file with formatting - with pd.ExcelWriter(output_path, engine='openpyxl') as writer: - # Write the DataFrame to Excel - df.to_excel(writer, index=False) - - # Get the worksheet - worksheet = writer.sheets['Sheet1'] - - # Apply dollar formatting to all language columns (excluding Folder and Word Count EN) - for idx, column_name in enumerate(columns[2:], start=3): # Start from 3rd column (index 2 + 1) - col_letter = openpyxl.utils.get_column_letter(idx) - for row in range(2, worksheet.max_row + 1): # Start from second row (skip header) - cell = worksheet[f"{col_letter}{row}"] - if cell.value is not None and isinstance(cell.value, (int, float)): - cell.number_format = '$#,##0.00' - - # Find the row index of "Total for all the proofreading" - total_row = None - for idx, row in enumerate(worksheet.iter_rows(min_row=1, max_row=worksheet.max_row), 1): - if row[0].value == 'Total for all the proofreading': - total_row = idx - break - - # Apply bold formatting to the entire row - if total_row: - for cell in worksheet[total_row]: - cell.font = Font(bold=True) - - print(f"\nResults saved to {output_path}") - - except FileNotFoundError as e: - print("Error: Make sure this script is placed in the correct location relative to courses and tutorials folders") - print(e) - except Exception as e: - print(f"An error occurred: {e}") - -if __name__ == "__main__": - main() diff --git a/scripts/proofreading-metadata/evaluate_proofreading_reward.py b/scripts/proofreading-metadata/evaluate_proofreading_reward.py deleted file mode 100644 index caeb01ded81..00000000000 --- a/scripts/proofreading-metadata/evaluate_proofreading_reward.py +++ /dev/null @@ -1,75 +0,0 @@ -import os -import inquirer -from tqdm import tqdm -from proofreading import * -from update_proofreading_metadata import * - -def select_language(content_path): - content_languages = get_language_list_for_content(content_path) - question = [ - inquirer.List('language_choice', - message="Select the language to update", - choices=sorted(content_languages), - carousel=True) - ] - - selected_language = inquirer.prompt(question)['language_choice'] - print(f"You have selected: {selected_language}") - return selected_language - -def get_manual_input(): - words = int(input("Enter the number of words: ")) - urgency = float(input("Enter the urgency factor (e.g., 1.0 for normal, 1.5 for urgent): ")) - proofreading_iteration = int(input("Enter the number of proofreading iterations: ")) - language_factor = float(input("Enter the language factor: ")) - return words, urgency, proofreading_iteration, language_factor - -def main(): - print("Let's update proofreading data!") - root_directory = '../../' - while True: - question = "Do you want to know the proofreading reward of an existing content? " - user_response = ask_yes_no_question(question) - if user_response == 'y': - selected_subfolder_path = ask_for_subfolder(root_directory, specific_files) - if selected_subfolder_path: - print(f"Selected subfolder: {selected_subfolder_path}") - - file_path = get_existing_file_path(selected_subfolder_path, specific_files) - content_name = os.path.basename(selected_subfolder_path) - data = get_yml_content(file_path) - custom_language_question = "Do you want to evaluate for custom language factors?" - custom_language = ask_yes_no_question(custom_language_question) - if custom_language == 'y': - selected_language = "Custom language" - language_factor = float(input("What's the language factor? ")) - urgency = float(input("What's the urgency? ")) - words = get_words(file_path) - proofread_iteration = 0 - reward = compute_reward(words, language_factor, urgency, BASE_FEE, proofread_iteration) - - else: - selected_language = select_language(selected_subfolder_path) - proofread_iteration = get_proofreading_state(data, selected_language) - reward = get_proofreading_property(data, selected_language, - "reward") - print() - print(f"For {content_name}:") - print(f"The proofreading reward is: {reward} sats") - left = 2 - proofread_iteration - print(f"{left} proofreading(s) are/is missing for {selected_language}") - print() - - else: - print("No valid subfolder with specific files was found.") - elif user_response == 'n': - words, urgency, proofreading_iteration, language_factor = get_manual_input() - reward = compute_reward(words, language_factor, urgency, BASE_FEE, proofreading_iteration) - print(f"\nThe calculated proofreading reward is: {reward} sats") - - continue_question = "Do you want to calculate another reward? " - if ask_yes_no_question(continue_question) == 'n': - break - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/proofreading-metadata/evaluate_quizz_glossary_reward.py b/scripts/proofreading-metadata/evaluate_quizz_glossary_reward.py deleted file mode 100644 index e6258cc5b9b..00000000000 --- a/scripts/proofreading-metadata/evaluate_quizz_glossary_reward.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env python3 - -import os -import yaml -import glob -from decimal import Decimal, getcontext -from pathlib import Path - -# Set high precision for decimal calculations -getcontext().prec = 50 - -def get_courses_directory(): - """Get the path to the courses directory from the script location""" - script_dir = Path(__file__).parent - courses_dir = script_dir / ".." / ".." / "courses" - return courses_dir.resolve() - -def get_glossary_directory(): - """Get the path to the glossary directory from the script location""" - script_dir = Path(__file__).parent - glossary_dir = script_dir / ".." / ".." / "resources" / "glossary" - return glossary_dir.resolve() - -def get_available_courses(): - """Get list of available course codes from the courses directory""" - courses_dir = get_courses_directory() - if not courses_dir.exists(): - return [] - - courses = [] - for item in courses_dir.iterdir(): - if item.is_dir(): - # Check if it has a quizz subfolder - quizz_dir = item / "quizz" - if quizz_dir.exists() and quizz_dir.is_dir(): - courses.append(item.name) - - return sorted(courses) - -def find_question_files(course_code): - """Find all question.yml files in the specified course's quizz folder""" - courses_dir = get_courses_directory() - quizz_dir = courses_dir / course_code / "quizz" - - if not quizz_dir.exists(): - return [] - - return list(quizz_dir.glob("**/question.yml")) - -def find_word_files(): - """Find all word.yml files in the glossary directory""" - glossary_dir = get_glossary_directory() - - if not glossary_dir.exists(): - return [] - - return list(glossary_dir.glob("**/word.yml")) - -def extract_reward_for_language(file_path, target_language): - """Extract reward for a specific language from a YAML file""" - try: - with open(file_path, 'r', encoding='utf-8') as file: - data = yaml.safe_load(file) - - # Check if proofreading section exists - if 'proofreading' not in data: - return None - - # Look for the target language in proofreading entries - for entry in data['proofreading']: - if entry.get('language') == target_language: - reward = entry.get('reward') - if reward is not None: - return Decimal(str(reward)) - - return None - - except Exception as e: - print(f"Error processing {file_path}: {e}") - return None - -def get_available_languages_for_course(course_code): - """Get a list of all available languages for a specific course""" - languages = set() - files = find_question_files(course_code) - - for file_path in files: - try: - with open(file_path, 'r', encoding='utf-8') as file: - data = yaml.safe_load(file) - - if 'proofreading' in data: - for entry in data['proofreading']: - lang = entry.get('language') - if lang: - languages.add(lang) - except: - continue - - return sorted(list(languages)) - -def get_available_languages_for_glossary(): - """Get a list of all available languages across all word files""" - languages = set() - files = find_word_files() - - for file_path in files: - try: - with open(file_path, 'r', encoding='utf-8') as file: - data = yaml.safe_load(file) - - if 'proofreading' in data: - for entry in data['proofreading']: - lang = entry.get('language') - if lang: - languages.add(lang) - except: - continue - - return sorted(list(languages)) - -def select_evaluation_type(): - """Prompt user to select between glossary and quiz evaluation""" - print("Select evaluation type:") - print("1. Glossary (word.yml files)") - print("2. Quiz (question.yml files)") - - while True: - try: - user_input = input("\nEnter your choice (1 or 2): ").strip() - - if user_input == "1": - return "glossary" - elif user_input == "2": - return "quiz" - else: - print("Invalid choice. Please enter 1 or 2.") - continue - - except KeyboardInterrupt: - print("\nOperation cancelled.") - return None - except: - print("Invalid input. Please try again.") - -def select_course(): - """Prompt user to select a course""" - available_courses = get_available_courses() - - if not available_courses: - print("No courses with quizz folders found in ../../courses/") - return None - - print(f"Available courses ({len(available_courses)}):") - for i, course in enumerate(available_courses, 1): - print(f"{i:2d}. {course}") - - while True: - try: - print(f"\nEnter the course code you want to analyze:") - print("(You can type the code directly or enter a number from the list above)") - user_input = input("Course: ").strip() - - # Check if user entered a number - if user_input.isdigit(): - index = int(user_input) - 1 - if 0 <= index < len(available_courses): - return available_courses[index] - else: - print("Invalid number. Please try again.") - continue - else: - # Check if the entered course code exists - if user_input in available_courses: - return user_input - else: - print(f"Course '{user_input}' not found. Please try again.") - continue - - except KeyboardInterrupt: - print("\nOperation cancelled.") - return None - except: - print("Invalid input. Please try again.") - -def select_language(available_languages, context=""): - """Prompt user to select a language""" - if not available_languages: - print(f"No languages found{context}.") - return None - - print(f"\nAvailable languages{context} ({len(available_languages)}):") - for i, lang in enumerate(available_languages, 1): - print(f"{i:2d}. {lang}") - - while True: - try: - print(f"\nEnter the language code you want to calculate rewards for:") - print("(You can type the code directly or enter a number from the list above)") - user_input = input("Language: ").strip() - - # Check if user entered a number - if user_input.isdigit(): - index = int(user_input) - 1 - if 0 <= index < len(available_languages): - return available_languages[index] - else: - print("Invalid number. Please try again.") - continue - else: - # Check if the entered language code exists - if user_input in available_languages: - return user_input - else: - print(f"Language '{user_input}' not found. Please try again.") - continue - - except KeyboardInterrupt: - print("\nOperation cancelled.") - return None - except: - print("Invalid input. Please try again.") - -def evaluate_glossary_rewards(): - """Evaluate rewards for glossary word files""" - print(f"Glossary directory: {get_glossary_directory()}") - - # Find word files - files = find_word_files() - if not files: - print("No word.yml files found in glossary directory.") - return - - print(f"Found {len(files)} word.yml files in glossary") - - # Select language - available_languages = get_available_languages_for_glossary() - target_language = select_language(available_languages, " in glossary") - if not target_language: - return - - print(f"\nCalculating rewards for glossary, language: '{target_language}'") - print("-" * 60) - - # Process files and calculate total reward - total_reward = Decimal('0') - processed_files = 0 - found_files = 0 - - for file_path in files: - # Show relative path from glossary folder for cleaner output - glossary_dir = get_glossary_directory() - relative_path = file_path.relative_to(glossary_dir) - - print(f"Processing file: ./{relative_path}") - processed_files += 1 - - reward = extract_reward_for_language(file_path, target_language) - if reward is not None: - print(f"Found reward: {reward}") - total_reward += reward - found_files += 1 - - # Output results - print("-" * 60) - print(f"Evaluation type: Glossary") - print(f"Language: {target_language}") - print(f"Total files processed: {processed_files}") - print(f"Files with {target_language} language: {found_files}") - print(f"Total reward for language '{target_language}': {total_reward}") - - if found_files > 0: - print(f"Average reward per file: {total_reward / found_files:.4f}") - print(f"Total reward with extended precision: {total_reward}") - else: - print(f"No files found with language '{target_language}'") - -def evaluate_quiz_rewards(): - """Evaluate rewards for quiz question files""" - print(f"Courses directory: {get_courses_directory()}") - - # Select course - course_code = select_course() - if not course_code: - return - - # Find question files for the selected course - files = find_question_files(course_code) - if not files: - print(f"No question.yml files found in course '{course_code}' quizz folder.") - return - - print(f"\nFound {len(files)} question.yml files in course '{course_code}'") - - # Select language - available_languages = get_available_languages_for_course(course_code) - target_language = select_language(available_languages, f" for course '{course_code}'") - if not target_language: - return - - print(f"\nCalculating rewards for course '{course_code}', language: '{target_language}'") - print("-" * 60) - - # Process files and calculate total reward - total_reward = Decimal('0') - processed_files = 0 - found_files = 0 - - for file_path in files: - # Show relative path from quizz folder for cleaner output - courses_dir = get_courses_directory() - quizz_dir = courses_dir / course_code / "quizz" - relative_path = file_path.relative_to(quizz_dir) - - print(f"Processing file: ./{relative_path}") - processed_files += 1 - - reward = extract_reward_for_language(file_path, target_language) - if reward is not None: - print(f"Found reward: {reward}") - total_reward += reward - found_files += 1 - - # Output results - print("-" * 60) - print(f"Evaluation type: Quiz") - print(f"Course: {course_code}") - print(f"Language: {target_language}") - print(f"Total files processed: {processed_files}") - print(f"Files with {target_language} language: {found_files}") - print(f"Total reward for language '{target_language}': {total_reward}") - - if found_files > 0: - print(f"Average reward per file: {total_reward / found_files:.4f}") - print(f"Total reward with extended precision: {total_reward}") - else: - print(f"No files found with language '{target_language}'") - -def ask_continue(): - """Ask user if they want to calculate another reward""" - while True: - try: - print("\n" + "=" * 60) - user_input = input("Do you want to calculate another reward? (y/n): ").strip().lower() - - if user_input in ['y', 'yes']: - return True - elif user_input in ['n', 'no']: - return False - else: - print("Please enter 'y' for yes or 'n' for no.") - continue - - except KeyboardInterrupt: - print("\nOperation cancelled.") - return False - except: - print("Invalid input. Please try again.") - -def main(): - print("=== Unified Language Reward Calculator ===") - print(f"Working directory: {os.getcwd()}") - - while True: - print() - - # Select evaluation type - evaluation_type = select_evaluation_type() - if not evaluation_type: - break - - print() - if evaluation_type == "glossary": - evaluate_glossary_rewards() - elif evaluation_type == "quiz": - evaluate_quiz_rewards() - - # Ask if user wants to continue - if not ask_continue(): - break - - print("\nThank you for using the Unified Language Reward Calculator!") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/proofreading-metadata/proofreading.py b/scripts/proofreading-metadata/proofreading.py deleted file mode 100644 index 642bada0256..00000000000 --- a/scripts/proofreading-metadata/proofreading.py +++ /dev/null @@ -1,187 +0,0 @@ -import os -from math import floor -from ruamel.yaml import YAML -from datetime import date - -BASE_FEE = .1 -yaml = YAML() -yaml.preserve_quotes = True -yaml.indent(mapping=2, sequence=4, offset=2) -yaml.width = 10000 # Increase the width to avoid line wrapping - -specific_files = {"course.yml", - "question.yml", - "tutorial.yml", - "book.yml", - # "podcast.yml", - "word.yml", - "bet.yml", - "builder.yml", - "conference.yml" - } - -def get_existing_file_path(content_path, specific_files): - for file_name in specific_files: - file_path = os.path.join(content_path, file_name) - if os.path.isfile(file_path): - return file_path - return None - -def load_supported_languages(): - with open('./supported_languages.yml', 'r') as file: - languages_dict = yaml.load(file) - return languages_dict - -def save_supported_languages(languages_dict): - with open('./supported_languages.yml', 'w') as file: - yaml.dump(languages_dict, file) - -def get_languages_list(languages_dict): - return list(languages_dict.keys()) - -def get_language_list_for_content(file_path): - supported_languages = load_supported_languages() - detected_languages = [] - for file_name in os.listdir(file_path): - base_name = os.path.splitext(file_name)[0] - if base_name in supported_languages: - detected_languages.append(base_name) - return detected_languages - -def load_difficulty_dict(): - with open('./content_difficulty.yml', 'r') as file: - difficulty_dict = yaml.load(file) - return difficulty_dict - - -def compute_reward(words, language_factor, urgency, base_fee, proofread_iteration): - dollars_per_word = 0.001 - reward = (urgency * (dollars_per_word * words * language_factor) + base_fee) * 2**(-proofread_iteration) - reward = round(reward, 2) - return reward - - -def get_yml_content(file_path): - with open(file_path, 'r', encoding='utf-8') as file: - data = yaml.load(file) - return data - -def get_difficulty_factor(data, is_from_glossary=False): - level = data.get('level') - if not level: - # for all content without the mention of level - # ie. all resources except those from glossary - if is_from_glossary: - difficulty_factor = 3.00 - else: - difficulty_factor = 1.00 - else: - difficulty_factors = load_difficulty_dict() - difficulty_factor = difficulty_factors[level] - return difficulty_factor - -def is_from_glossary(data): - is_term = data.get('en_word') - if not is_term: - glossary = False - else: - glossary = True - return glossary - -def get_proofreading_data(data): - proofreading_data = list(data['proofreading']) - return proofreading_data - -def get_words(file_path): - data = get_yml_content(file_path) - original = get_orignal(data) - if not original: - raise ValueError(f"The 'original' property is not found in {file_path}.") - - directory = os.path.dirname(file_path) - md_file = os.path.join(directory, f"{original}.md") - yml_file = os.path.join(directory, f"{original}.yml") - - if os.path.exists(md_file): - file_to_check = md_file - elif os.path.exists(yml_file): - file_to_check = yml_file - else: - raise FileNotFoundError(f"Neither {md_file} nor {yml_file} exists.") - - with open(file_to_check, 'r', encoding='utf-8') as file: - content = file.read() - word_count = len(content.split()) - return word_count - -def get_orignal(data): - original = data.get('original_language') - return original - -def update_proofreading_inline_property(data, language, property_name, property_value): - # TODO: add check for type of input depending of the property name - # date or interger - for entry in data['proofreading']: - if entry['language'] == language: - entry[property_name] = property_value - break - -def is_original(data, language): - return data['original_language'] == language - -def evaluate_proofreading_reward(file_path, language): - data = get_yml_content(file_path) - words = get_words(file_path) - - language_factors = load_supported_languages() - language_factor = language_factors[language] - - proofread_iteration = get_proofreading_state(data, language) - urgency = get_proofreading_property(data, language, 'urgency') - reward = compute_reward(words, language_factor, urgency, BASE_FEE, proofread_iteration) - return reward - -def update_proofreading_reward(file_path, language, evaluated_reward): - data = get_yml_content(file_path) - proofread_iteration = get_proofreading_state(data, language) - - if proofread_iteration < 2: - reward = evaluated_reward - else: - reward = 0 - update_proofreading_inline_property(data, language, 'reward', reward) - update_yml_data(file_path, data) - -def update_yml_data(file_path, data): - current_data = get_yml_content(file_path) - current_data.update(data) - with open(file_path, 'w') as file: - yaml.dump(current_data, file) - -def get_proofreading_property(data, language, property_name): - for entry in data['proofreading']: - if entry['language'] == language: - return entry[property_name] - -def check_language_existence(data, language): - exist = False - for entry in data['proofreading']: - if entry['language'] == language: - exist = True - return exist - -def get_proofreading_state(data, language): - contributor_names = get_proofreading_property(data, language, 'contributor_names') - if contributor_names == None: - return 0 - else: - return len(contributor_names) - -def add_proofreading_contributor(data, language, contributor_names): - for entry in data['proofreading']: - if entry['language'] == language: - if entry['contributor_names'] is None: - entry['contributor_names'] = [] - - entry['contributor_names'].append(contributor_names) - break \ No newline at end of file diff --git a/scripts/proofreading-metadata/proofreading_template.yml b/scripts/proofreading-metadata/proofreading_template.yml deleted file mode 100644 index 481d875dbf9..00000000000 --- a/scripts/proofreading-metadata/proofreading_template.yml +++ /dev/null @@ -1,20 +0,0 @@ -original_language: en -proofreading: - - language: fr - last_contribution_date: 2024-06-19 - urgency: 1 - contributor_names: - - user123 - - user234 - reward: 20000 - - language: en # content reviewed once - last_contribution_date: 2024-06-19 - urgency: 1 - contributor_names: - - user123 - reward: 20000 - - language: es # language present but never reviewed - last_contribution_date: - urgency: 5 # can be set to 0, 1, 2, 5, 10 - contributor_names: - reward: 80000 diff --git a/scripts/proofreading-metadata/readme.md b/scripts/proofreading-metadata/readme.md deleted file mode 100644 index e9f51b46870..00000000000 --- a/scripts/proofreading-metadata/readme.md +++ /dev/null @@ -1,67 +0,0 @@ -## Proofreading Scripts - -As the proofreading progress for each educational content has been added to the associated `yml` file. It would be far easier to manage it via in -CLI-interactive script, hence `update_proofreading_metadata.py`. - -In the near future a complementary script for reporting the progress of any -content in any language would be available. - -## Usage - -Note that these scripts have only been tested on a linux distro. - -### Set python environment - -Use the below command to create a env in the `./scripts/proofreading_metadata` -folder. - -``` -python3 -m venv env -``` - -Use this command to activate the env - -``` -source env/bin/activate -``` - -And use `deactivate` to quit the environment. - -Then make sure you have the required librairies with the following command - -``` -pip3 install -r requirements.txt -``` - -### Run the interactive Proofreading Update Script - -Please verify that you're environment is activated before running the below -command: - -``` -python3 update_proofreading_metadata.py -``` - -Then follow the workflow by answering to the prompted questions via the CLI. - -### Run the interactive Proofreading Reward Calculator Script - -Please verify that you're environment is activated before running the below -command: - -``` -python3 evaluate_proofreading_reward.py -``` - -Then follow the workflow by answering to the prompted question via the CLI. - -## Miscellouneous notes - -- note that `proofreading.py` is used as a common 'library' for functions - related to proofreading propreties -- note that the automatic process in `update_proofreading_metadata.py` is quite - slow, especially when the full update (reward re-evaluation) is chosen. - - would be cool in the future to replace that part with a dedicated C script - to do the full update in a way more faster way. - - the use of a `.sh` file would be used to run the C script and then the - python script diff --git a/scripts/proofreading-metadata/requirements.txt b/scripts/proofreading-metadata/requirements.txt deleted file mode 100644 index f2386098343..00000000000 --- a/scripts/proofreading-metadata/requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -attrs==24.2.0 -blessed==1.20.0 -editor==1.6.6 -fuzzysearch==0.7.3 -inquirer==3.4.0 -readchar==4.2.0 -ruamel.yaml==0.18.6 -ruamel.yaml.clib==0.2.8 -runs==1.2.2 -six==1.16.0 -tqdm==4.66.5 -wcwidth==0.2.13 -xmod==1.8.1 diff --git a/scripts/proofreading-metadata/supported_languages.yml b/scripts/proofreading-metadata/supported_languages.yml deleted file mode 100644 index 799d2ff1017..00000000000 --- a/scripts/proofreading-metadata/supported_languages.yml +++ /dev/null @@ -1,31 +0,0 @@ -en: 1.0 -fr: 1.0 -de: 1.0 -es: 1.0 -it: 1.0 -cs: 1.5 -vi: 2.5 -ja: 2.5 -pt: 1.0 -ru: 1.5 -fi: 1.5 -et: 1.5 -id: 2.0 -zh-Hans: 2.0 -uk: 1.5 -nb-NO: 1.5 -pl: 1.5 -ro: 1.0 -ha: 2.0 -sr-Latn: 2.0 -hi: 2.5 -zh-Hant: 2.0 -sw: 1.5 -fa: 1.5 -sv: 1.0 -nl: 1.5 -tr: 2.0 -ko: 2.0 -rn: 2.5 -bg: 1.5 -th: 2.0 diff --git a/scripts/proofreading-metadata/update_proofreading_metadata.py b/scripts/proofreading-metadata/update_proofreading_metadata.py deleted file mode 100644 index a6a0cf596d1..00000000000 --- a/scripts/proofreading-metadata/update_proofreading_metadata.py +++ /dev/null @@ -1,311 +0,0 @@ -import os -import inquirer -from tqdm import tqdm -from proofreading import * -from datetime import datetime - - -def get_subfolder_choices(root_directory): - subfolders = [ - dirname for dirname in os.listdir(root_directory) - if os.path.isdir(os.path.join(root_directory, dirname)) and not dirname.startswith('.') - ] - return sorted(subfolders) - -def has_specific_file(subfolder_path, specific_files): - for file_name in os.listdir(subfolder_path): - if file_name in specific_files: - return True - return False - -def ask_for_subfolder(root_directory, specific_files): - while True: - subfolders = get_subfolder_choices(root_directory) - if not subfolders: - print(f"No subfolders found in {root_directory}.") - return None - question = [ - inquirer.List('subfolder', - message=f"Select a subfolder in {root_directory}", - choices=subfolders, - carousel=True, - ), - ] - answers = inquirer.prompt(question) - selected_subfolder = answers['subfolder'] - selected_subfolder_path = os.path.join(root_directory, selected_subfolder) - if has_specific_file(selected_subfolder_path, specific_files): - print(f"The subfolder '{selected_subfolder}' contains a specific file.") - return selected_subfolder_path - else: - print(f"Recursing into subfolder: {selected_subfolder}") - return ask_for_subfolder(selected_subfolder_path, specific_files) - -def select_language_to_update(content_path): - content_languages = get_language_list_for_content(content_path) - question = [ - inquirer.List('language_choice', - message="Select the language to update", - choices=sorted(content_languages), - carousel=True) - ] - - selected_language = inquirer.prompt(question)['language_choice'] - print(f"You selected to update the content for: {selected_language}") - return selected_language - -def add_new_contribution_to(content_path): - selected_language = select_language_to_update(content_path) - file_path = get_existing_file_path(content_path, specific_files) - data = get_yml_content(file_path) - content_name = os.path.basename(content_path) - - question_contributor = f"Do you want to add a new contributor to {content_name} in {selected_language}?" - new_contribution = ask_yes_no_question(question_contributor) - print() - if new_contribution == 'y': - contributor_id = input("Enter the github username of the contributor: ") - add_proofreading_contributor(data, selected_language, contributor_id) - - current_date = datetime.now() - current_date = current_date.date() - update_proofreading_inline_property(data, selected_language, - 'last_contribution_date', current_date) - - update_yml_data(file_path, data) - print(f"{contributor_id} added as new proofreader of {content_name} in {selected_language}") - reward = get_proofreading_property(data, selected_language, 'reward') - print(f"{contributor_id} has won {reward} sats for that proofreading") - - question_urgency = f"Do you want to change the urgency of the proofreading of {content_name} in {selected_language}" - change_urgency = ask_yes_no_question(question_urgency) - if change_urgency == 'y': - current_urgency = get_proofreading_property(data, selected_language, 'urgency') - print(f"Current urgency is: {current_urgency}") - - while True: - new_urgency = input("Enter the new urgency value (integer): ") - try: - new_urgency = int(new_urgency) - break - except ValueError: - print("Please enter a valid integer.") - update_proofreading_inline_property(data, selected_language, 'urgency', new_urgency) - evaluated_reward = evaluate_proofreading_reward(file_path, selected_language) - update_proofreading_reward(file_path, selected_language, evaluated_reward) - - data = get_yml_content(file_path) - reward = get_proofreading_property(data, selected_language, 'reward') - state = get_proofreading_state(data, selected_language) - if state < 3: - left = 3-state - print(f"{content_name} requires {left} proofreading(s) in {selected_language}") - print(f"Next reward for this proofreading is {reward} sats!") - - else: - print(f"Congrats! {content_name} requires no more proofreading in {selected_language}") - -def ask_yes_no_question(question): - while True: - print() - response = input(question+" [y/n]: ").strip().lower() - if response in ['y', 'n']: - return response - else: - print("Please enter 'y' for yes or 'n' for no.") - - -def update_proofreading(root_dir, specific_files): - print('Automatic Update for Proofreading section in progress...') - - full_update_question = "Do you want to check all the rewards? (NB. long process)" - full_reward_update = ask_yes_no_question(full_update_question) - - # Internal helper: fix formatting issues in existing YAML to ensure 'proofreading' section is properly ordered - def fix_proofreading_formatting(file_path): - try: - data = get_yml_content(file_path) - # Only rewrite if a proofreading section exists to normalize formatting - if 'proofreading' in data: - update_yml_data(file_path, data) - except Exception as e: - # log and continue - print(f"Warning: could not normalize formatting for {file_path}: {e}") - # Internal helper: remove blank lines inside the proofreading list - def cleanup_blank_lines_in_proofreading(file_path): - try: - with open(file_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - new_lines = [] - in_block = False - for line in lines: - if not in_block: - new_lines.append(line) - # detect start of proofreading block at top-level - if line.startswith('proofreading:'): - in_block = True - else: - # inside proofreading block: skip blank lines - if line.strip() == '': - continue - # detect end of block: no indent - if not line.startswith((' ', '\t')): - in_block = False - new_lines.append(line) - else: - new_lines.append(line) - with open(file_path, 'w', encoding='utf-8') as f: - f.writelines(new_lines) - except Exception as e: - print(f"Warning: could not clean blank lines in proofreading of {file_path}: {e}") - - # Gather all directories and files to process - all_dirs = [] - failed_files = [] # List to store files that failed to update - for dirpath, dirnames, filenames in os.walk(root_dir): - dirnames[:] = [d for d in dirnames if d != 'docs'] - if any(f in filenames for f in specific_files): - all_dirs.append((dirpath, [f for f in filenames if f in specific_files])) - - progress_bar = tqdm(total=len(all_dirs), desc="Updating Proofreading", unit="file") - - all_dirs = sorted(all_dirs) - for dirpath, translated_content in all_dirs: - yml_filepath = get_existing_file_path(dirpath, specific_files) - # normalize existing formatting before processing - fix_proofreading_formatting(yml_filepath) - - try: - data = get_yml_content(yml_filepath) - # If no proofreading section, initialize it at top-level (will be dumped in place) - if 'proofreading' not in data: - original_language = data.get('original_language') - if original_language: - # seed initial proofreading entry - current_date = datetime.now().date() - data['proofreading'] = [{ - 'language': original_language, - 'last_contribution_date': current_date, - 'urgency': 1, - 'contributor_names': ['Asi0Flammeus'], - 'reward': 0 - }] - # write updated YAML - update_yml_data(yml_filepath, data) - else: - failed_files.append((yml_filepath, "Could not determine original language")) - progress_bar.update(1) - continue - - existing_languages = get_language_list_for_content(dirpath) - existing_languages = sorted(existing_languages) - - for language in existing_languages: - try: - reward_already_update = False - language_file_yml = f'{language}.yml' - language_file_md = f'{language}.md' - - language_file_path_yml = os.path.join(dirpath, language_file_yml) - language_file_path_md = os.path.join(dirpath, language_file_md) - - if os.path.isfile(language_file_path_yml) or os.path.isfile(language_file_path_md): - # new translation exists but no proofreading entry: append in-memory and dump - if not check_language_existence(data, language): - # prepare new proofreading entry - entry = { - 'language': language, - 'last_contribution_date': None, - 'urgency': 1, - 'contributor_names': None, - 'reward': None - } - data['proofreading'].append(entry) - # write updated YAML with new entry - update_yml_data(yml_filepath, data) - # evaluate and update reward - evaluated_reward = evaluate_proofreading_reward(yml_filepath, language) - update_proofreading_reward(yml_filepath, language, evaluated_reward) - # reload data for subsequent operations - data = get_yml_content(yml_filepath) - reward_already_update = True - - if full_reward_update == 'y' and not reward_already_update: - current_reward = get_proofreading_property(data, language, 'reward') - if current_reward == None: - current_reward = 0 - evaluated_reward = evaluate_proofreading_reward(yml_filepath, language) - if current_reward != evaluated_reward: - update_proofreading_reward(yml_filepath, language, evaluated_reward) - - except Exception as e: - failed_files.append((yml_filepath, f"Error processing language {language}: {str(e)}")) - - except Exception as e: - failed_files.append((yml_filepath, str(e))) - - # Remove any blank lines inside the proofreading list - cleanup_blank_lines_in_proofreading(yml_filepath) - progress_bar.update(1) - - progress_bar.close() - print('Automatic update done!') - - # Report failed files - if failed_files: - print("\nThe following files had errors during processing:") - for file_path, error in failed_files: - print(f"\nFile: {file_path}") - print(f"Error: {error}") - else: - print("\nAll files were processed successfully!") - - - -def add_new_supported_language(code_language, language_difficulty): - try: - language_difficulty = round(float(language_difficulty), 2) - except ValueError: - print("Language difficulty must be a numeric value.") - return - - languages_dict = load_supported_languages() - - if code_language in languages_dict: - print(f"{code_language} is already in the list of supported languages.") - else: - languages_dict[code_language] = language_difficulty - save_supported_languages(languages_dict) - print(f"Added {code_language} with difficulty {language_difficulty:.2f} to the list of supported languages.") - -def main(): - print("Let's update proofreading data!") - root_directory = '../../' - while True: - question_language = "Do you want to add a new supported language?" - new_language = ask_yes_no_question(question_language) - if new_language == 'y': - code_language = input("Enter the code language:") - language_difficulty = input(f"Enter the difficulty of {code_language}:") - add_new_supported_language(code_language, language_difficulty) - else: - break - - update_proofreading(root_directory, specific_files) - while True: - question = "Do you want to modify a proofreading section of a content? (new contributor or urgency change)" - user_response = ask_yes_no_question(question) - if user_response == 'y': - selected_subfolder_path = ask_for_subfolder(root_directory, specific_files) - if selected_subfolder_path: - print(f"Selected subfolder: {selected_subfolder_path}") - add_new_contribution_to(selected_subfolder_path) - else: - print("No valid subfolder with specific files was found.") - elif user_response == 'n': - print("Exiting the contribution loop. No more contributions to add.") - break - -if __name__ == "__main__": - main() - diff --git a/scripts/proofreading_report/.env.example b/scripts/proofreading_report/.env.example deleted file mode 100644 index 6f63eee2514..00000000000 --- a/scripts/proofreading_report/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# GitHub Personal Access Token -# Generate at: https://github.com/settings/tokens -# Required scopes: public_repo (for public repositories) -GITHUB_TOKEN=ghp_your_token_here diff --git a/scripts/proofreading_report/.gitignore b/scripts/proofreading_report/.gitignore deleted file mode 100644 index f09e4468bcd..00000000000 --- a/scripts/proofreading_report/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Environment files with secrets -.env - -# Cache files -github_pr_cache.json - -# Generated dashboard (optional - may want to commit this) -# proofreading_dashboard.html diff --git a/scripts/proofreading_report/README.md b/scripts/proofreading_report/README.md deleted file mode 100644 index cc7852195a2..00000000000 --- a/scripts/proofreading_report/README.md +++ /dev/null @@ -1,420 +0,0 @@ -# Proofreading Dashboard - -A comprehensive, self-contained HTML dashboard for tracking proofreading status across all Bitcoin Educational Content courses and tutorials. - -## Overview - -The dashboard provides: -- **Matrix Overview**: Visual table showing completion status for each content × language (like a progress heatmap) -- **Top Languages**: Statistics showing proofreading activity and contributors per language -- **Contribution Finder**: Interactive tool showing top 10 most-needed contributions -- **Leaderboards**: Separate rankings for course and tutorial contributors - -## Files - -- `generate_proofreading_dashboard.py` - Python script that generates the dashboard -- `proofreading_dashboard.html` - Generated dashboard (self-contained, no dependencies) - -## Quick Start - -### Generate the Dashboard - -```bash -cd scripts/proofreading_report/ -python3 generate_proofreading_dashboard.py -``` - -This will: -1. Parse all 34 courses from `../../courses/*/course.yml` -2. Parse all 277 tutorials from `../../tutorials/*/*/tutorial.yml` -3. Calculate proofreading status and contributor statistics -4. Generate `proofreading_dashboard.html` (approximately 1.9 MB) - -### View the Dashboard - -**Option 1: Open locally** -```bash -# Open in your default browser -xdg-open proofreading_dashboard.html - -# Or manually open the file in any browser -firefox proofreading_dashboard.html -``` - -**Option 2: Upload to web server** -Simply upload `proofreading_dashboard.html` to any web hosting service. The file is completely self-contained and requires no server-side processing. - -## Dashboard Features - -### Section 1: Matrix Overview - -A visual table showing completion status for all content across all languages (similar to the course overview table). - -**Features:** -- **Content Type Selector**: Choose between Courses or Tutorials -- **Filters**: - - Topic/Category: Filter by specific course topic or tutorial category - - Difficulty: Beginner, Intermediate, Advanced, Expert, Wizard - - Search: Find specific content by ID or name - -**Matrix Display:** -- Each row represents a piece of content (course or tutorial) -- Each column represents a language -- Each cell shows: - - Completion ratio (e.g., "31/31" = 31 sections complete out of 31) - - Percentage (e.g., "100%") - - Color coding: - - 🟢 Green: 100% complete - - 🟡 Yellow: Partially complete - - 🔴 Red: 0% complete - - ⚫ Gray: N/A (no content available in that language) - -### Section 2: Top Languages by Proofreading Activity - -A comprehensive table showing language statistics across all content: - -**Display:** -- Language code and name -- Total proofreadings (number of content items with contributions in that language) -- Unique contributors (number of different people who contributed in that language) -- Sorted by total proofreadings (most active languages first) - -**Purpose:** -- Identify which languages have the most activity -- See which languages have strong contributor communities -- Understand language diversity in the project - -**Example:** -``` -Language Total Proofreadings Unique Contributors -EN (English) 245 45 -FR (French) 198 28 -DE (German) 156 18 -``` - -### Section 3: Contribution Finder - -Find where your contributions are most needed (limited to top 10 results): - -**Filters:** -1. **Select Your Language**: Choose from 26 languages -2. **Content Type**: Courses | Tutorials | Both -3. **Topic/Category**: Filter by specific topics or categories -4. **Difficulty**: Filter by content difficulty level - -**Results Display:** -- Ranked by urgency (Status 0 first) and reward amount -- Shows for each item: - - Content name and ID - - Current status badge - - Difficulty level badge - - Contributors needed (3 - current count) - - Available reward - - Last update date - -**Status Meanings:** -- 🔴 **Status 0**: No contributors (urgent) -- 🟠 **Status 1**: One contributor (needs review) -- 🟡 **Status 2**: Two contributors (in progress) -- 🟢 **Status 3**: Three or more contributors (complete) - -### Section 4: Leaderboards - -**Two Separate Leaderboards:** - -**1. Top Course Contributors** -- Top 20 contributors ranked by course contributions -- Shows: GitHub avatar, username, course count, languages -- Medals for top 3 (🥇🥈🥉) - -**2. Top Tutorial Contributors** -- Top 20 contributors ranked by tutorial contributions -- Shows: GitHub avatar, username, tutorial count, languages -- Medals for top 3 (🥇🥈🥉) - -**Excluded Contributors:** -The following contributors are excluded from leaderboards (as requested): -- LoicPandul -- Asi0Flammeus -- heyolaniran -- Pivii -- PaoloFoti - -## Data Structure - -### Status Calculation - -Status is calculated based on the number of contributors: -```python -contributors_count = 0 → status = 0 (No contributors) -contributors_count = 1 → status = 1 (One contributor) -contributors_count = 2 → status = 2 (Two contributors) -contributors_count ≥ 3 → status = 3 (Complete) -``` - -### Completion Calculation - -For the matrix view, completion is calculated by counting sections/parts: -- Total parts: Number of sections in the content (counted from markdown headers) -- Completed parts: Total parts if status = 3, otherwise 0 -- Percentage: (completed_parts / total_parts) × 100 - -### Embedded Data Format - -The dashboard embeds a complete dataset as a JavaScript constant: - -```javascript -const PROOFREADING_DATA = { - "metadata": { - "generated_at": "2025-11-03T13:04:00+00:00", - "total_courses": 34, - "total_tutorials": 277, - "total_languages": 26, - "excluded_contributors": ["LoicPandul", "Asi0Flammeus", ...] - }, - "courses": [...], - "tutorials": [...], - "languages": [...], - "topics": [...], - "categories": [...], - "course_contributor_stats": {...}, - "tutorial_contributor_stats": {...} -}; -``` - -## Updating the Dashboard - -To update the dashboard with the latest proofreading data: - -```bash -cd scripts/proofreading_report/ -python3 generate_proofreading_dashboard.py -``` - -**Important:** Each run completely regenerates the HTML file with fresh data from the YAML files. - -### Update Frequency Recommendations - -- **Daily**: For active development periods -- **Weekly**: For regular maintenance -- **After bulk changes**: When multiple proofreading entries are updated -- **Before presentations**: When showcasing project status - -## Technical Details - -### Requirements - -**Python Dependencies:** -- Python 3.8+ -- `PyYAML` (install: `pip install pyyaml`) - -**No Browser Dependencies:** -- Pure HTML + CSS + JavaScript -- No external libraries or frameworks -- Works offline -- Compatible with all modern browsers - -### File Size - -The generated HTML file is approximately **1.9 MB** because it contains: -- Complete data for 311 content items (34 courses + 277 tutorials) -- 26 languages per item -- Section/part counting for matrix display -- Separate contributor statistics for courses and tutorials -- Embedded CSS and JavaScript -- All styling and functionality - -### Performance - -- **Initial Load**: < 2 seconds (local file or fast server) -- **Matrix Rendering**: Handles 300+ rows × 26 columns efficiently -- **Filtering**: Real-time, no lag -- **Content Type Switching**: Instant -- **Search**: Live search with no delay - -### Browser Compatibility - -Tested and working on: -- Chrome/Chromium 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -## Key Improvements (v2.1) - -### Matrix Overview -- ✅ Visual table format like image provided -- ✅ Content type selector (Courses vs Tutorials) -- ✅ Shows completion ratio (X/Y) and percentage based on actual quiz sections -- ✅ Color-coded cells (green/yellow/red/gray) -- ✅ Topic/category filter added -- ✅ Difficulty filter added -- ✅ **Alphabetical sorting** of all content - -### Top Languages Section (NEW) -- ✅ **New section showing language statistics** -- ✅ Total proofreadings per language -- ✅ Unique contributors per language -- ✅ Sorted by proofreading activity (most active first) -- ✅ Excludes specified contributors from counts - -### Contribution Finder -- ✅ Limited to top 10 results (instead of all) -- ✅ Difficulty filter added -- ✅ Shows difficulty level badge for each item - -### Leaderboards -- ✅ Split into separate Course and Tutorial leaderboards -- ✅ Excludes specified contributors: LoicPandul, Asi0Flammeus, heyolaniran, Pivii, PaoloFoti -- ✅ Separate statistics tracking for courses vs tutorials - -### Bug Fixes -- ✅ **Fixed completion counting**: Now counts actual quiz sections for courses -- ✅ Courses sorted alphabetically by ID -- ✅ Tutorials sorted alphabetically by ID - -### File Organization -- ✅ Moved to dedicated `proofreading_report/` subfolder -- ✅ Clean separation from other scripts - -## Customization - -### Modifying Excluded Contributors - -Edit the `EXCLUDED_CONTRIBUTORS` list in the Python script: - -```python -EXCLUDED_CONTRIBUTORS = ['LoicPandul', 'Asi0Flammeus', 'heyolaniran', 'Pivii', 'PaoloFoti'] -``` - -### Changing Top Results Limit - -In the JavaScript section of `generate_html()`, find: - -```javascript -// Limit to top 10 -allItems = allItems.slice(0, 10); -``` - -Change `10` to any number you prefer. - -### Modifying Status Calculation - -Edit the `calculate_status` function in the Python script: - -```python -def calculate_status(contributors): - # Modify logic here - pass -``` - -### Adding More Languages - -Update the `LANGUAGE_NAMES` dictionary: - -```python -LANGUAGE_NAMES = { - 'en': 'English', - # Add more languages -} -``` - -## Troubleshooting - -### Script Errors - -**Error: `ModuleNotFoundError: No module named 'yaml'`** -```bash -pip install pyyaml -``` - -**Error: `FileNotFoundError: [Errno 2] No such file or directory`** -Ensure you're running the script from the `scripts/proofreading_report/` directory: -```bash -cd scripts/proofreading_report/ -python3 generate_proofreading_dashboard.py -``` - -**Warning about deprecation** -The script uses modern `datetime.now(timezone.utc)` to avoid deprecation warnings. - -### Dashboard Display Issues - -**Matrix not rendering properly** -- Check browser console for JavaScript errors -- Verify the embedded data is valid JSON -- Try regenerating the dashboard - -**Filters not working** -- Clear browser cache -- Try a different browser -- Regenerate the dashboard - -**Content type selector not switching** -- Verify JavaScript is enabled in your browser -- Check browser console for errors - -**GitHub avatars not loading** -- Normal behavior - uses fallback placeholder -- Check internet connection for GitHub CDN access -- Fallback to default avatar is automatic - -## Statistics (Current) - -Based on latest generation: - -- **Total Courses**: 34 -- **Total Tutorials**: 277 -- **Total Content Items**: 311 -- **Supported Languages**: 26 -- **Course Contributors**: ~53 (after exclusions) -- **Tutorial Contributors**: ~33 (after exclusions) -- **Excluded Contributors**: 5 users - -## Data Sources - -All data is extracted from: -- `../../courses/*/course.yml` - Course metadata and proofreading info -- `../../tutorials/*/*/tutorial.yml` - Tutorial metadata and proofreading info - -The script uses the `proofreading` section from each YAML file: - -```yaml -proofreading: - - language: fr - last_contribution_date: 2025-08-18 - urgency: 1 - contributor_names: - - Asi0Flammeus - - ProfScofield21 - reward: 4.13 -``` - -Additionally, the script counts sections/parts from the actual content markdown files to calculate completion percentages for the matrix view. - -## License - -Same as the main Bitcoin Educational Content repository. - -## Support - -For issues or questions: -1. Check this README -2. Review the Python script comments -3. Open an issue in the repository -4. Contact the development team - ---- - -**Last Updated**: 2025-11-03 -**Version**: 2.1 -**Generated Dashboard Size**: ~1.9 MB -**Major Changes from v2.0**: -- **Fixed completion counting**: Now accurately counts quiz sections -- **Added Top Languages section**: Shows proofreading activity and contributors per language -- **Alphabetical sorting**: All content now sorted by ID -- Matrix overview with completion tracking -- Split leaderboards (courses vs tutorials) -- Limited contribution finder to top 10 -- Added difficulty filters -- Excluded specific contributors from leaderboards diff --git a/scripts/proofreading_report/generate_proofreading_dashboard.py b/scripts/proofreading_report/generate_proofreading_dashboard.py deleted file mode 100644 index fffdcb01ce0..00000000000 --- a/scripts/proofreading_report/generate_proofreading_dashboard.py +++ /dev/null @@ -1,3286 +0,0 @@ -#!/usr/bin/env python3 -""" -Bitcoin Educational Content - Proofreading Dashboard Generator - -Extracts proofreading data from course.yml and tutorial.yml files -and generates a self-contained HTML dashboard with embedded data. -""" - -import yaml -import json -import glob -import os -import re -import urllib.request -import urllib.error -from datetime import datetime, timezone, timedelta -from pathlib import Path -from collections import defaultdict - -# Load environment variables from .env file -def load_dotenv(): - """Load environment variables from .env file if it exists.""" - env_path = Path(__file__).parent / '.env' - if env_path.exists(): - with open(env_path, 'r') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#') and '=' in line: - key, value = line.split('=', 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - os.environ.setdefault(key, value) - -load_dotenv() - -# GitHub API configuration -GITHUB_REPO = "PlanB-Network/bitcoin-educational-content" -GITHUB_API_BASE = "https://api.github.com" -GITHUB_CACHE_FILE = "github_pr_cache.json" - -# Excluded contributors from monthly leaderboard (repo managers) -MONTHLY_EXCLUDED_CONTRIBUTORS = ['MariJJhodl'] - -# Language names mapping -LANGUAGE_NAMES = { - 'en': 'English', 'fr': 'French', 'de': 'German', 'es': 'Spanish', - 'it': 'Italian', 'pt': 'Portuguese', 'nl': 'Dutch', 'sv': 'Swedish', - 'nb-NO': 'Norwegian', 'cs': 'Czech', 'pl': 'Polish', 'ru': 'Russian', - 'sr-Latn': 'Serbian', 'et': 'Estonian', 'ja': 'Japanese', - 'zh-Hans': 'Chinese (Simplified)', 'zh-Hant': 'Chinese (Traditional)', - 'ko': 'Korean', 'vi': 'Vietnamese', 'hi': 'Hindi', 'id': 'Indonesian', - 'fa': 'Persian', 'tr': 'Turkish', 'sw': 'Swahili', 'rn': 'Kirundi', - 'bg': 'Bulgarian', 'th': 'Thai' -} - -# Excluded contributors from leaderboards -EXCLUDED_CONTRIBUTORS = ['LoicPandul', 'Asi0Flammeus', 'heyolaniran', 'Pivii', 'PaoloFoti'] - - -def calculate_status(contributors): - """Calculate proofreading status based on contributor count.""" - if not contributors: - return 0 - count = len(contributors) - if count == 1: - return 1 - else: # 2 or more - return 2 - - -def extract_english_title(content_dir): - """Extract the English title from en.md file.""" - en_file = Path(content_dir) / 'en.md' - try: - if en_file.exists(): - with open(en_file, 'r', encoding='utf-8') as f: - content = f.read() - # Extract YAML front matter - if content.startswith('---'): - parts = content.split('---', 2) - if len(parts) >= 3: - yaml_content = parts[1] - yaml_data = yaml.safe_load(yaml_content) - return yaml_data.get('name', '') - return '' - except Exception as e: - print(f"Error extracting title from {en_file}: {e}") - return '' - - -def parse_course_file(filepath, base_dir): - """Parse a course.yml file and extract proofreading data.""" - try: - with open(filepath, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - - # Extract course ID from path - course_id = Path(filepath).parent.name - - # Extract English title from en.md file - course_dir = Path(filepath).parent - english_title = extract_english_title(course_dir) - - course_data = { - 'id': course_id, - 'name': data.get('name', course_id.upper()), - 'english_title': english_title, - 'type': 'course', - 'topic': data.get('topic', 'unknown'), - 'subtopic': data.get('subtopic', ''), - 'level': data.get('level', 'unknown'), - 'original_language': data.get('original_language', 'en'), - 'proofreading': [] - } - - # Process proofreading data - proofreading = data.get('proofreading', []) - for pr in proofreading: - contributors = pr.get('contributor_names', []) or [] - last_date = pr.get('last_contribution_date') - # Convert date object to string if needed - if last_date and not isinstance(last_date, str): - last_date = last_date.isoformat() - - language = pr.get('language', '') - status = calculate_status(contributors) - - course_data['proofreading'].append({ - 'language': language, - 'status': status, - 'contributors': contributors, - 'last_date': last_date or None, - 'reward': float(pr.get('reward', 0)), - 'urgency': pr.get('urgency', 1) - }) - - return course_data - except Exception as e: - print(f"Error parsing {filepath}: {e}") - return None - - -def parse_tutorial_file(filepath, base_dir): - """Parse a tutorial.yml file and extract proofreading data.""" - try: - with open(filepath, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - - # Extract category and tutorial name from path - parts = Path(filepath).parts - category = parts[-3] # tutorials/category/name/tutorial.yml - tutorial_name = parts[-2] - - # Extract English title from en.md file - tutorial_dir = Path(filepath).parent - english_title = extract_english_title(tutorial_dir) - - tutorial_data = { - 'id': f"{category}/{tutorial_name}", - 'name': data.get('name', tutorial_name.replace('-', ' ').title()), - 'english_title': english_title, - 'type': 'tutorial', - 'category': category, - 'level': data.get('level', 'unknown'), - 'original_language': data.get('original_language', 'en'), - 'proofreading': [] - } - - # Process proofreading data - proofreading = data.get('proofreading', []) - for pr in proofreading: - contributors = pr.get('contributor_names', []) or [] - last_date = pr.get('last_contribution_date') - # Convert date object to string if needed - if last_date and not isinstance(last_date, str): - last_date = last_date.isoformat() - - language = pr.get('language', '') - status = calculate_status(contributors) - - tutorial_data['proofreading'].append({ - 'language': language, - 'status': status, - 'contributors': contributors, - 'last_date': last_date or None, - 'reward': float(pr.get('reward', 0)), - 'urgency': pr.get('urgency', 1) - }) - - return tutorial_data - except Exception as e: - print(f"Error parsing {filepath}: {e}") - return None - - -def calculate_contributor_stats(courses, tutorials): - """Calculate contributor statistics across all content, excluding certain users.""" - course_stats = defaultdict(lambda: { - 'total_contributions': 0, - 'languages': set(), - 'courses': 0, - 'tutorials': 0 - }) - - tutorial_stats = defaultdict(lambda: { - 'total_contributions': 0, - 'languages': set(), - 'courses': 0, - 'tutorials': 0 - }) - - # Process courses - for course in courses: - for pr in course['proofreading']: - for contributor in pr['contributors']: - if contributor not in EXCLUDED_CONTRIBUTORS: - course_stats[contributor]['total_contributions'] += 1 - course_stats[contributor]['languages'].add(pr['language']) - course_stats[contributor]['courses'] += 1 - - # Process tutorials - for tutorial in tutorials: - for pr in tutorial['proofreading']: - for contributor in pr['contributors']: - if contributor not in EXCLUDED_CONTRIBUTORS: - tutorial_stats[contributor]['total_contributions'] += 1 - tutorial_stats[contributor]['languages'].add(pr['language']) - tutorial_stats[contributor]['tutorials'] += 1 - - # Convert sets to sorted lists - course_result = {} - for contributor, data in course_stats.items(): - course_result[contributor] = { - 'total_contributions': data['total_contributions'], - 'languages': sorted(list(data['languages'])), - 'courses': data['courses'], - 'tutorials': 0 - } - - tutorial_result = {} - for contributor, data in tutorial_stats.items(): - tutorial_result[contributor] = { - 'total_contributions': data['total_contributions'], - 'languages': sorted(list(data['languages'])), - 'courses': 0, - 'tutorials': data['tutorials'] - } - - return course_result, tutorial_result - - -def calculate_language_stats(courses, tutorials): - """Calculate statistics per language: total proofreadings and unique contributors.""" - lang_stats = defaultdict(lambda: { - 'total_proofreadings': 0, - 'contributors': set() - }) - - # Process courses - for course in courses: - for pr in course['proofreading']: - if pr['contributors']: # Only count if there are contributors - lang_stats[pr['language']]['total_proofreadings'] += 1 - for contributor in pr['contributors']: - if contributor not in EXCLUDED_CONTRIBUTORS: - lang_stats[pr['language']]['contributors'].add(contributor) - - # Process tutorials - for tutorial in tutorials: - for pr in tutorial['proofreading']: - if pr['contributors']: # Only count if there are contributors - lang_stats[pr['language']]['total_proofreadings'] += 1 - for contributor in pr['contributors']: - if contributor not in EXCLUDED_CONTRIBUTORS: - lang_stats[pr['language']]['contributors'].add(contributor) - - # Convert to final format - result = {} - for lang, data in lang_stats.items(): - result[lang] = { - 'total_proofreadings': data['total_proofreadings'], - 'unique_contributors': len(data['contributors']), - 'contributors': sorted(list(data['contributors'])) - } - - return result - - -def load_github_cache(): - """Load cached GitHub PR data if available and fresh (less than 1 hour old).""" - cache_path = Path(__file__).parent / GITHUB_CACHE_FILE - if cache_path.exists(): - try: - with open(cache_path, 'r', encoding='utf-8') as f: - cache = json.load(f) - # Check if cache is fresh (less than 1 hour old) - cached_at = datetime.fromisoformat(cache.get('cached_at', '2000-01-01T00:00:00')) - if datetime.now(timezone.utc) - cached_at.replace(tzinfo=timezone.utc) < timedelta(hours=1): - print(f"Using cached GitHub data (cached at {cache['cached_at']})") - return cache.get('data') - else: - print("Cache expired, fetching fresh data...") - except (json.JSONDecodeError, KeyError, ValueError) as e: - print(f"Cache invalid: {e}") - return None - - -def save_github_cache(data): - """Save GitHub PR data to cache.""" - cache_path = Path(__file__).parent / GITHUB_CACHE_FILE - cache = { - 'cached_at': datetime.now(timezone.utc).isoformat(), - 'data': data - } - try: - with open(cache_path, 'w', encoding='utf-8') as f: - json.dump(cache, f, indent=2, default=str) - print(f"Cached GitHub data to {cache_path}") - except IOError as e: - print(f"Failed to save cache: {e}") - - -def check_github_api_status(): - """Check if GitHub API is accessible and show rate limit status.""" - print("\n🔍 Checking GitHub API connection...") - - headers = { - 'User-Agent': 'Bitcoin-Educational-Content-Dashboard', - 'Accept': 'application/vnd.github.v3+json' - } - - github_token = os.environ.get('GITHUB_TOKEN') - if github_token: - headers['Authorization'] = f'token {github_token}' - print(" ✓ GITHUB_TOKEN found in environment") - else: - print(" ⚠ No GITHUB_TOKEN found - using unauthenticated access (60 req/hour)") - - try: - req = urllib.request.Request(f"{GITHUB_API_BASE}/rate_limit", headers=headers) - with urllib.request.urlopen(req, timeout=10) as response: - data = json.loads(response.read().decode('utf-8')) - - core = data['resources']['core'] - search = data['resources']['search'] - - core_remaining = core['remaining'] - core_limit = core['limit'] - search_remaining = search['remaining'] - search_limit = search['limit'] - - reset_time = datetime.fromtimestamp(core['reset']) - - print(f" ✓ GitHub API is accessible") - print(f" 📊 Core API: {core_remaining}/{core_limit} requests remaining") - print(f" 📊 Search API: {search_remaining}/{search_limit} requests remaining") - print(f" ⏰ Rate limit resets at: {reset_time.strftime('%H:%M:%S')}") - - if core_remaining < 50: - print(f" ⚠ Warning: Low API quota remaining!") - - return True, core_remaining, search_remaining - - except urllib.error.HTTPError as e: - if e.code == 401: - print(f" ✗ Invalid GITHUB_TOKEN - authentication failed") - else: - print(f" ✗ GitHub API error: {e.code} - {e.reason}") - return False, 0, 0 - except urllib.error.URLError as e: - print(f" ✗ Network error: {e.reason}") - return False, 0, 0 - except Exception as e: - print(f" ✗ Unexpected error: {e}") - return False, 0, 0 - - -def github_api_request(url, headers=None, retry_count=3, silent=False): - """Make a request to the GitHub API with rate limit handling.""" - import time - - if headers is None: - headers = {} - headers['User-Agent'] = 'Bitcoin-Educational-Content-Dashboard' - headers['Accept'] = 'application/vnd.github.v3+json' - - # Check for GitHub token in environment - github_token = os.environ.get('GITHUB_TOKEN') - if github_token: - headers['Authorization'] = f'token {github_token}' - - for attempt in range(retry_count): - req = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(req, timeout=30) as response: - return json.loads(response.read().decode('utf-8')) - except urllib.error.HTTPError as e: - if e.code == 403 or e.code == 429: - # Rate limited - check for retry-after header - retry_after = e.headers.get('Retry-After', '60') - try: - wait_time = int(retry_after) - except ValueError: - wait_time = 60 - - if attempt < retry_count - 1: - # Use shorter wait for retries, max 5 seconds between retries - actual_wait = min(wait_time, 2 * (attempt + 1)) - if not silent: - print(f" ⏳ Rate limited, waiting {actual_wait}s before retry {attempt + 2}/{retry_count}...") - time.sleep(actual_wait) - continue - else: - if not silent: - print(f" ✗ Rate limit exceeded after {retry_count} attempts") - if not github_token: - print(" TIP: Add GITHUB_TOKEN to .env file for higher rate limits") - return None - else: - if not silent: - print(f" ✗ GitHub API error: {e.code} - {e.reason}") - return None - except urllib.error.URLError as e: - if not silent: - print(f" ✗ Network error: {e.reason}") - if attempt < retry_count - 1: - time.sleep(2) - continue - return None - - return None - - -def extract_language_from_path(file_path): - """Extract language code from a file path. - - Patterns: - - courses/course-name/fr.md -> fr - - tutorials/category/tutorial-name/fr.md -> fr - - courses/course-name/assets/fr/... -> fr - """ - # Match language code in path (2-letter or with region like zh-Hans) - # Common patterns: /fr.md, /fr/, /de.md, /zh-Hans.md, etc. - - # Pattern for direct language file (e.g., fr.md, de.md, zh-Hans.md) - lang_file_match = re.search(r'/([a-z]{2}(?:-[A-Za-z]+)?)\.md$', file_path) - if lang_file_match: - return lang_file_match.group(1).lower() - - # Pattern for language folder (e.g., /fr/, /de/, /zh-Hans/) - lang_folder_match = re.search(r'/([a-z]{2}(?:-[A-Za-z]+)?)/[^/]+$', file_path) - if lang_folder_match: - return lang_folder_match.group(1).lower() - - # Pattern for assets with language subfolder - assets_match = re.search(r'/assets/([a-z]{2}(?:-[A-Za-z]+)?)/', file_path) - if assets_match: - return assets_match.group(1).lower() - - return None - - -def detect_content_type_from_path(file_path): - """Detect if the file is from a course or tutorial.""" - if file_path.startswith('courses/'): - return 'COURSE' - elif file_path.startswith('tutorials/'): - return 'TUTORIAL' - return 'OTHER' - - -# Mapping of language names to language codes for title/label extraction -LANGUAGE_NAME_TO_CODE = { - # English variations - 'english': 'en', 'en': 'en', 'eng': 'en', - # French variations - 'french': 'fr', 'fr': 'fr', 'français': 'fr', 'francais': 'fr', 'fre': 'fr', - # German variations - 'german': 'de', 'de': 'de', 'deutsch': 'de', 'ger': 'de', - # Spanish variations - 'spanish': 'es', 'es': 'es', 'español': 'es', 'espanol': 'es', 'spa': 'es', - # Italian variations - 'italian': 'it', 'it': 'it', 'italiano': 'it', 'ita': 'it', - # Portuguese variations - 'portuguese': 'pt', 'pt': 'pt', 'português': 'pt', 'portugues': 'pt', 'por': 'pt', - # Dutch variations - 'dutch': 'nl', 'nl': 'nl', 'nederlands': 'nl', - # Swedish variations - 'swedish': 'sv', 'sv': 'sv', 'svenska': 'sv', - # Norwegian variations - 'norwegian': 'nb-NO', 'nb-no': 'nb-NO', 'norsk': 'nb-NO', - # Czech variations - 'czech': 'cs', 'cs': 'cs', 'čeština': 'cs', 'cestina': 'cs', 'cz': 'cs', - # Polish variations - 'polish': 'pl', 'pl': 'pl', 'polski': 'pl', 'pol': 'pl', - # Russian variations - 'russian': 'ru', 'ru': 'ru', 'русский': 'ru', - # Serbian variations - 'serbian': 'sr-Latn', 'sr-latn': 'sr-Latn', 'sr latin': 'sr-Latn', 'srpski': 'sr-Latn', - # Estonian variations - 'estonian': 'et', 'et': 'et', 'eesti': 'et', - # Japanese variations - 'japanese': 'ja', 'ja': 'ja', '日本語': 'ja', 'jpn': 'ja', - # Chinese variations - Simplified - 'chinese': 'zh-Hans', 'zh-hans': 'zh-Hans', 'simplified chinese': 'zh-Hans', - 'simplified': 'zh-Hans', 'zh hans': 'zh-Hans', - # Chinese variations - Traditional - 'traditional chinese': 'zh-Hant', 'zh-hant': 'zh-Hant', 'traditional': 'zh-Hant', - 'zh hant': 'zh-Hant', - # Korean variations - 'korean': 'ko', 'ko': 'ko', '한국어': 'ko', 'kor': 'ko', - # Vietnamese variations - 'vietnamese': 'vi', 'vi': 'vi', 'tiếng việt': 'vi', 'viet': 'vi', 'vietnam': 'vi', - # Hindi variations - 'hindi': 'hi', 'hi': 'hi', 'हिन्दी': 'hi', - # Indonesian variations - 'indonesian': 'id', 'id': 'id', 'bahasa indonesia': 'id', 'indo': 'id', - # Persian variations - 'persian': 'fa', 'fa': 'fa', 'farsi': 'fa', 'فارسی': 'fa', - # Turkish variations - 'turkish': 'tr', 'tr': 'tr', 'türkçe': 'tr', 'turkce': 'tr', 'tur': 'tr', - # Swahili variations - 'swahili': 'sw', 'sw': 'sw', 'kiswahili': 'sw', - # Kirundi variations - 'kirundi': 'rn', 'rn': 'rn', 'rundi': 'rn', - # Bulgarian variations - 'bulgarian': 'bg', 'bg': 'bg', 'български': 'bg', 'bul': 'bg', -} - - -def extract_language_from_title(title): - """Extract language from PR title. - - Looks for language names at the end of title, typically after a dash. - Examples: - - [PROOFREADING] BTC101 - French -> fr - - [PROOFREADING] SCU101 + quiz - Bulgarian -> bg - - [PROOFREADING] BTC102 - Rundi -> rn - - [PROOFREADING] BTC101 only quizzes - Vietnamese -> vi - - [PROOFREADING] BTC202 + Quiz - Simplified Chinese -> zh-Hans - """ - if not title: - return None - - title_lower = title.lower() - - # Try to find language after last dash or hyphen - parts = re.split(r'\s*[-–—]\s*', title_lower) - if len(parts) >= 2: - last_part = parts[-1].strip() - # Remove common suffixes/extras like #1234, (123), .md, quizzes, only, &, +, section, numbers - last_part_cleaned = re.sub(r'\s*(#\d+|\(\d+\)|\.md|quizz?e?s?|only|&|\+|section|\d+).*$', '', last_part).strip() - - if last_part_cleaned in LANGUAGE_NAME_TO_CODE: - return LANGUAGE_NAME_TO_CODE[last_part_cleaned] - - # Also check the uncleaned last part for multi-word languages like "Simplified Chinese" - if last_part in LANGUAGE_NAME_TO_CODE: - return LANGUAGE_NAME_TO_CODE[last_part] - - # Check if it starts with a known language (e.g., "simplified chi..." truncated) - for lang_name in sorted(LANGUAGE_NAME_TO_CODE.keys(), key=len, reverse=True): - if last_part_cleaned.startswith(lang_name) or last_part.startswith(lang_name): - return LANGUAGE_NAME_TO_CODE[lang_name] - - # Check full title for language names (longer names first to match "simplified chinese" before "chinese") - for lang_name in sorted(LANGUAGE_NAME_TO_CODE.keys(), key=len, reverse=True): - # Look for language name as a whole word (not part of another word) - if re.search(rf'\b{re.escape(lang_name)}\b', title_lower): - return LANGUAGE_NAME_TO_CODE[lang_name] - - return None - - -def extract_language_from_labels(labels): - """Extract language from PR labels. - - Labels are typically like: 'lang:fr', 'language:french', 'French', etc. - """ - if not labels: - return None - - for label in labels: - label_name = label.get('name', '').lower() if isinstance(label, dict) else str(label).lower() - - # Check for lang: prefix - lang_prefix_match = re.match(r'^lang(?:uage)?[:\-]?\s*(.+)$', label_name) - if lang_prefix_match: - lang_value = lang_prefix_match.group(1).strip() - if lang_value in LANGUAGE_NAME_TO_CODE: - return LANGUAGE_NAME_TO_CODE[lang_value] - - # Check if label itself is a language name - if label_name in LANGUAGE_NAME_TO_CODE: - return LANGUAGE_NAME_TO_CODE[label_name] - - return None - - -def fetch_proofreading_prs_from_github(num_months=3): - """Fetch merged PRs with [PROOFREADING] in title from GitHub API. - - Uses the Pulls API directly which includes merged_at date, avoiding - the need for individual PR detail fetches. - """ - print("\n🔄 Fetching proofreading PRs from GitHub API...") - - # Calculate date range - now = datetime.now(timezone.utc) - - # Get first day of the month num_months ago - target_month = now.month - num_months + 1 - target_year = now.year - while target_month <= 0: - target_month += 12 - target_year -= 1 - - since_date = datetime(target_year, target_month, 1, tzinfo=timezone.utc) - since_date_str = since_date.strftime('%Y-%m-%d') - print(f" 📆 Searching for PRs since {since_date.strftime('%B %Y')}") - - all_prs = [] - page = 1 - per_page = 100 - - # Use Pulls API endpoint which returns merged_at directly - # Filter by state=closed and we'll check merged_at ourselves - while True: - print(f" 🔍 Fetching page {page}...", end='', flush=True) - - # List closed PRs sorted by update time (most recent first) - pulls_url = ( - f"{GITHUB_API_BASE}/repos/{GITHUB_REPO}/pulls?" - f"state=closed&sort=updated&direction=desc&per_page={per_page}&page={page}" - ) - - result = github_api_request(pulls_url, silent=True) - if not result: - print(" ✗ (API error)") - break - - if not result: - print(" (no more results)") - break - - print(f" found {len(result)} closed PRs", end='', flush=True) - - prs_added = 0 - oldest_pr_date = None - - for pr in result: - # Check if PR was merged (not just closed) - merged_at_str = pr.get('merged_at') - if not merged_at_str: - continue - - merged_at = datetime.fromisoformat(merged_at_str.replace('Z', '+00:00')) - oldest_pr_date = merged_at - - # Skip if before our date range - if merged_at < since_date: - continue - - # Check if title contains [PROOFREADING] - title = pr.get('title', '') - if '[PROOFREADING]' not in title.upper(): - continue - - all_prs.append({ - 'number': pr['number'], - 'title': title, - 'merged_at': merged_at, - 'user': pr['user']['login'], - 'html_url': pr['html_url'], - 'labels': pr.get('labels', []) # Include labels for language detection - }) - prs_added += 1 - - print(f" ✓ +{prs_added} proofreading PRs") - - # Stop if we've gone past our date range - if oldest_pr_date and oldest_pr_date < since_date: - print(f" 📆 Reached PRs from before {since_date.strftime('%B %Y')}, stopping") - break - - # Stop if we got fewer results than requested (last page) - if len(result) < per_page: - break - - page += 1 - - # Safety limit - if page > 20: - print(" ⚠️ Reached page limit, stopping pagination") - break - - print(f"\n ✅ Found {len(all_prs)} proofreading PRs in the last {num_months} months") - return all_prs - - -def fetch_pr_files_and_contributors(pr_number): - """Fetch the files changed and contributors for a specific PR.""" - # Get files changed - files_url = f"{GITHUB_API_BASE}/repos/{GITHUB_REPO}/pulls/{pr_number}/files" - files_data = github_api_request(files_url) - - files = [] - if files_data: - files = [f['filename'] for f in files_data] - - # Get PR details for author - pr_url = f"{GITHUB_API_BASE}/repos/{GITHUB_REPO}/pulls/{pr_number}" - pr_data = github_api_request(pr_url) - - contributors = set() - if pr_data: - # Add PR author - author = pr_data.get('user', {}).get('login') - if author and author not in MONTHLY_EXCLUDED_CONTRIBUTORS: - contributors.add(author) - - return files, list(contributors) - - -def calculate_monthly_language_stats(courses=None, tutorials=None, num_months=3, skip_api=False): - """Calculate monthly language team statistics from GitHub PRs. - - Args: - courses: Not used (kept for backwards compatibility) - tutorials: Not used (kept for backwards compatibility) - num_months: Number of months to fetch - skip_api: If True, only use cached data, don't make API calls - """ - - # Try to load from cache first - cached_data = load_github_cache() - if cached_data: - return cached_data - - if skip_api: - print("⏭️ Skipping GitHub API calls (--skip-github-api flag)") - print(" To fetch fresh data, remove the flag or wait for rate limit reset") - return [] - - # Check API status first - api_ok, core_remaining, search_remaining = check_github_api_status() - if not api_ok: - print("\n❌ Cannot access GitHub API. Monthly leaderboard will be empty.") - return [] - - if core_remaining < 10 or search_remaining < 5: - print("\n⚠️ Insufficient API quota remaining. Skipping GitHub fetch.") - return [] - - # Fetch PRs from GitHub - prs = fetch_proofreading_prs_from_github(num_months) - - if not prs: - print("❌ No PRs found or API error, returning empty stats") - return [] - - # Organize by month - now = datetime.now(timezone.utc) - monthly_data = {} - - for month_offset in range(num_months): - # Calculate target month - total_months = now.year * 12 + now.month - month_offset - year = total_months // 12 - month = total_months % 12 - if month == 0: - month = 12 - year -= 1 - - month_key = f"{year}-{month:02d}" - month_name = datetime(year, month, 1).strftime('%B %Y') - - monthly_data[month_key] = { - 'month_name': month_name, - 'year': year, - 'month': month, - 'lang_counts': defaultdict(lambda: { - 'count': 0, - 'contributors': set(), - 'content_types': defaultdict(int), - 'pr_numbers': [] - }) - } - - # Group PRs by month for better logging - prs_by_month = defaultdict(list) - for pr in prs: - month_key = f"{pr['merged_at'].year}-{pr['merged_at'].month:02d}" - if month_key in monthly_data: - prs_by_month[month_key].append(pr) - - # Process PRs month by month - total_processed = 0 - for month_key in sorted(prs_by_month.keys(), reverse=True): - month_prs = prs_by_month[month_key] - month_name = monthly_data[month_key]['month_name'] - - print(f"\n📅 Processing {month_name} ({len(month_prs)} PRs)...") - - for i, pr in enumerate(month_prs): - total_processed += 1 - # Show progress inline - print(f" [{i+1}/{len(month_prs)}] PR #{pr['number']}: {pr['title'][:45]}...", end='', flush=True) - - # Fetch files and contributors - files, contributors = fetch_pr_files_and_contributors(pr['number']) - - # Detect languages from files - languages_in_pr = set() - content_types_in_pr = defaultdict(set) - - for file_path in files: - lang = extract_language_from_path(file_path) - if lang: - # Normalize language code - lang = lang.lower() - # Map common variations - if lang == 'zh-hans': - lang = 'zh-Hans' - elif lang == 'zh-hant': - lang = 'zh-Hant' - elif lang == 'nb-no': - lang = 'nb-NO' - elif lang == 'sr-latn': - lang = 'sr-Latn' - - languages_in_pr.add(lang) - content_type = detect_content_type_from_path(file_path) - content_types_in_pr[lang].add(content_type) - - # If no language detected from files, try title and labels - lang_source = "files" - if not languages_in_pr: - # Try title first - title_lang = extract_language_from_title(pr['title']) - if title_lang: - languages_in_pr.add(title_lang) - lang_source = "title" - else: - # Try labels - label_lang = extract_language_from_labels(pr.get('labels', [])) - if label_lang: - languages_in_pr.add(label_lang) - lang_source = "label" - - # Update monthly counts - lang_counts = monthly_data[month_key]['lang_counts'] - for lang in languages_in_pr: - lang_counts[lang]['count'] += 1 - lang_counts[lang]['pr_numbers'].append(pr['number']) - - for contributor in contributors: - lang_counts[lang]['contributors'].add(contributor) - - for content_type in content_types_in_pr[lang]: - if content_type in ('COURSE', 'TUTORIAL'): - lang_counts[lang]['content_types'][content_type] += 1 - - # Show detected languages - if languages_in_pr: - source_indicator = f" ({lang_source})" if lang_source != "files" else "" - print(f" ✓ [{', '.join(sorted(languages_in_pr))}]{source_indicator}") - else: - print(f" (no language detected)") - - # Summary for this month - month_langs = monthly_data[month_key]['lang_counts'] - if month_langs: - top_langs = sorted(month_langs.items(), key=lambda x: x[1]['count'], reverse=True)[:3] - top_str = ', '.join([f"{l[0].upper()}:{l[1]['count']}" for l in top_langs]) - print(f" ✅ {month_name} complete - Top languages: {top_str}") - - # Convert to final format - monthly_stats = [] - for month_key in sorted(monthly_data.keys(), reverse=True): - data = monthly_data[month_key] - lang_counts = data['lang_counts'] - - languages = [] - for lang_code, counts in sorted(lang_counts.items(), key=lambda x: x[1]['count'], reverse=True): - languages.append({ - 'language': lang_code, - 'total_prs': counts['count'], - 'contributors': sorted(list(counts['contributors'])), - 'contributor_count': len(counts['contributors']), - 'courses': counts['content_types'].get('COURSE', 0), - 'tutorials': counts['content_types'].get('TUTORIAL', 0), - 'pr_numbers': counts['pr_numbers'] - }) - - monthly_stats.append({ - 'month_name': data['month_name'], - 'year': data['year'], - 'month': data['month'], - 'languages': languages - }) - - # Final summary - print(f"\n📊 Monthly Leaderboard Summary:") - for month in monthly_stats: - if month['languages']: - top = month['languages'][0] - print(f" {month['month_name']}: {top['language'].upper()} leads with {top['total_prs']} PRs ({len(month['languages'])} languages active)") - else: - print(f" {month['month_name']}: No activity") - - # Save to cache for future runs - save_github_cache(monthly_stats) - - return monthly_stats - - -def get_recent_contributions(courses, tutorials, limit=10): - """Get the most recent contributions with dates across all content.""" - recent_contributions = [] - - # Process courses - for course in courses: - for pr in course['proofreading']: - if pr['last_date'] and pr['contributors']: - for contributor in pr['contributors']: - if contributor not in EXCLUDED_CONTRIBUTORS: - recent_contributions.append({ - 'contributor': contributor, - 'content_id': course['id'], - 'content_name': course['name'], - 'content_type': 'course', - 'language': pr['language'], - 'date': pr['last_date'], - 'status': pr['status'] - }) - - # Process tutorials - for tutorial in tutorials: - for pr in tutorial['proofreading']: - if pr['last_date'] and pr['contributors']: - for contributor in pr['contributors']: - if contributor not in EXCLUDED_CONTRIBUTORS: - recent_contributions.append({ - 'contributor': contributor, - 'content_id': tutorial['id'], - 'content_name': tutorial['name'], - 'content_type': 'tutorial', - 'language': pr['language'], - 'date': pr['last_date'], - 'status': pr['status'] - }) - - # Sort by date (most recent first) and limit to specified number - recent_contributions.sort(key=lambda x: x['date'], reverse=True) - return recent_contributions[:limit] - - -def extract_all_data(skip_github_api=False): - """Extract all proofreading data from courses and tutorials. - - Args: - skip_github_api: If True, skip GitHub API calls for monthly leaderboard - """ - print("Extracting proofreading data...") - - # Get base directory - base_dir = Path(__file__).parent.parent.parent - - # Parse courses - courses = [] - course_files = glob.glob(str(base_dir / 'courses' / '*' / 'course.yml')) - print(f"Found {len(course_files)} courses") - - for filepath in course_files: - course_data = parse_course_file(filepath, base_dir) - if course_data: - courses.append(course_data) - - # Sort courses alphabetically by ID - courses.sort(key=lambda x: x['id'].lower()) - - # Parse tutorials - tutorials = [] - tutorial_files = glob.glob(str(base_dir / 'tutorials' / '*' / '*' / 'tutorial.yml')) - print(f"Found {len(tutorial_files)} tutorials") - - for filepath in tutorial_files: - tutorial_data = parse_tutorial_file(filepath, base_dir) - if tutorial_data: - tutorials.append(tutorial_data) - - # Sort tutorials alphabetically by ID - tutorials.sort(key=lambda x: x['id'].lower()) - - # Calculate contributor statistics - course_contributor_stats, tutorial_contributor_stats = calculate_contributor_stats(courses, tutorials) - - # Calculate language statistics - language_stats = calculate_language_stats(courses, tutorials) - - # Get recent contributions - recent_contributions = get_recent_contributions(courses, tutorials, limit=10) - - # Calculate monthly language team statistics for the last 3 months (from GitHub API) - monthly_language_stats = calculate_monthly_language_stats( - courses, tutorials, num_months=3, skip_api=skip_github_api - ) - - # Prepare languages list - languages = [{'code': code, 'name': name} for code, name in sorted(LANGUAGE_NAMES.items())] - - # Extract unique topics and categories - topics = sorted(list(set(c['topic'] for c in courses))) - categories = sorted(list(set(t['category'] for t in tutorials))) - - # Compile full dataset - data = { - 'metadata': { - 'generated_at': datetime.now(timezone.utc).isoformat(), - 'total_courses': len(courses), - 'total_tutorials': len(tutorials), - 'total_languages': len(LANGUAGE_NAMES), - 'excluded_contributors': EXCLUDED_CONTRIBUTORS - }, - 'courses': courses, - 'tutorials': tutorials, - 'languages': languages, - 'topics': topics, - 'categories': categories, - 'course_contributor_stats': course_contributor_stats, - 'tutorial_contributor_stats': tutorial_contributor_stats, - 'language_stats': language_stats, - 'recent_contributions': recent_contributions, - 'monthly_language_stats': monthly_language_stats - } - - print(f"Extracted {len(courses)} courses and {len(tutorials)} tutorials") - print(f"Found {len(course_contributor_stats)} course contributors and {len(tutorial_contributor_stats)} tutorial contributors") - - return data - - -def generate_html(data): - """Generate complete HTML dashboard with embedded data.""" - - # Convert data to JSON string with proper escaping - json_data = json.dumps(data, indent=2, ensure_ascii=False) - - html = f""" - - - - - Bitcoin Educational Content - Proofreading Dashboard - - - - -
    -

    📚 Bitcoin Educational Content - Proofreading Dashboard

    - -
    - -
    - - - - -
    -

    📊 Overview

    -
    -
    -
    0
    -
    Courses
    -
    -
    -
    0
    -
    Tutorials
    -
    -
    -
    0
    -
    Fully Proofread (Status 3)
    -
    -
    -
    0
    -
    Needs Proofreading (Status 0)
    -
    -
    -
    - - -
    -

    📋 Content Matrix Overview

    - -
    - - -
    - -
    -
    - -
    - -
    -
    - - -
    -
    -
    -
    -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    - -
    - - - - - - - - -
    Content
    -
    -
    - - -
    -

    🎯 Contribution Finder - Where You're Needed Most (Top 10)

    - -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    - -
    -
    - Select a language to see where contributions are needed most. -
    -
    -
    - - -
    -

    🕒 Last Proofreading Contributions

    -
    -
    - - -
    -

    🏆 Leaderboards

    - -
    - - - -
    - - -
    -
    -
    - - -
    -
    -
    - - -
    -
    - - - - - - - - - - -
    LanguageTotal ProofreadingsUnique Contributors
    -
    -
    -
    - - -
    -

    📅 Monthly Language Team Leaderboard

    - -
    -
    - -
    - -
    - -
    -
    -
    -
    - - - - -
    - -
    - - -
    -

    🙏 Thank You!

    -

    Thank you to everyone who contributes to making Bitcoin education accessible worldwide.

    -

    Join the Plan ₿ Community and start proofreading now!

    -

    You only need to tag the admin to receive your first proofreading task!

    - -
    - - - - -""" - - return html - - -def main(): - """Main function to generate the dashboard.""" - import argparse - - parser = argparse.ArgumentParser( - description='Generate Bitcoin Educational Content Proofreading Dashboard' - ) - parser.add_argument( - '--skip-github-api', - action='store_true', - help='Skip GitHub API calls (use cached data only for monthly leaderboard)' - ) - parser.add_argument( - '--clear-cache', - action='store_true', - help='Clear the GitHub PR cache before fetching' - ) - args = parser.parse_args() - - print("=" * 60) - print("Bitcoin Educational Content - Proofreading Dashboard Generator") - print("=" * 60) - - # Clear cache if requested - if args.clear_cache: - cache_path = Path(__file__).parent / GITHUB_CACHE_FILE - if cache_path.exists(): - cache_path.unlink() - print("Cleared GitHub PR cache") - - # Extract data - data = extract_all_data(skip_github_api=args.skip_github_api) - - # Generate HTML - print("\nGenerating HTML dashboard...") - html = generate_html(data) - - # Write to file - output_file = 'proofreading_dashboard.html' - with open(output_file, 'w', encoding='utf-8') as f: - f.write(html) - - print(f"\n✅ Dashboard generated successfully: {output_file}") - print(f"📊 File size: {len(html) / 1024:.1f} KB") - print("\nTo view the dashboard, open the HTML file in your browser:") - print(f" file://{os.path.abspath(output_file)}") - print("\nOr upload it to any web server.") - print("=" * 60) - - -if __name__ == '__main__': - main() diff --git a/scripts/proofreading_report/proofreading_dashboard.html b/scripts/proofreading_report/proofreading_dashboard.html deleted file mode 100644 index 5271b42d288..00000000000 --- a/scripts/proofreading_report/proofreading_dashboard.html +++ /dev/null @@ -1,88281 +0,0 @@ - - - - - - Bitcoin Educational Content - Proofreading Dashboard - - - - -
    -

    📚 Bitcoin Educational Content - Proofreading Dashboard

    - -
    - -
    - - - - -
    -

    📊 Overview

    -
    -
    -
    0
    -
    Courses
    -
    -
    -
    0
    -
    Tutorials
    -
    -
    -
    0
    -
    Fully Proofread (Status 3)
    -
    -
    -
    0
    -
    Needs Proofreading (Status 0)
    -
    -
    -
    - - -
    -

    📋 Content Matrix Overview

    - -
    - - -
    - -
    -
    - -
    - -
    -
    - - -
    -
    -
    -
    -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    - -
    - - - - - - - - -
    Content
    -
    -
    - - -
    -

    🎯 Contribution Finder - Where You're Needed Most (Top 10)

    - -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    - -
    -
    - Select a language to see where contributions are needed most. -
    -
    -
    - - -
    -

    🕒 Last Proofreading Contributions

    -
    -
    - - -
    -

    🏆 Leaderboards

    - -
    - - - -
    - - -
    -
    -
    - - -
    -
    -
    - - -
    -
    - - - - - - - - - - -
    LanguageTotal ProofreadingsUnique Contributors
    -
    -
    -
    - - -
    -

    📅 Monthly Language Team Leaderboard

    - -
    -
    - -
    - -
    - -
    -
    -
    -
    - - - - -
    - -
    - - -
    -

    🙏 Thank You!

    -

    Thank you to everyone who contributes to making Bitcoin education accessible worldwide.

    -

    Join the Plan ₿ Community and start proofreading now!

    -

    You only need to tag the admin to receive your first proofreading task!

    - -
    - - - - - \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/README.md b/scripts/quizz_to_pdfs/README.md deleted file mode 100644 index 0883919dbc2..00000000000 --- a/scripts/quizz_to_pdfs/README.md +++ /dev/null @@ -1,198 +0,0 @@ -# Quiz to PDF Generator - -Converts quiz YML files to PDF with randomized answer order and answer key. - -## Requirements - -- Python 3.6+ -- PyYAML -- XeLaTeX or LuaLaTeX (for IBM Plex font support) -- **IBM Plex fonts included** in the repository (no additional font installation needed) - -## Installation - -1. Install Python dependencies: -```bash -pip install -r requirements.txt -``` - -2. Install LaTeX (if not already installed): - - **Ubuntu/Debian**: - ```bash - sudo apt-get install texlive-xetex texlive-luatex - ``` - - **macOS**: - ```bash - brew install mactex - ``` - - **Windows**: Download from [miktex.org](https://miktex.org/) - - XeLaTeX/LuaLaTeX is included in the default installation - -## Usage - -### Basic Usage - -Generate quiz PDF for a course in English (default): -```bash -python quizz_to_pdf.py btc101 -``` - -### With Language Selection - -Generate quiz in French: -```bash -python quizz_to_pdf.py btc101 -l fr -``` - -Generate quiz in Spanish: -```bash -python quizz_to_pdf.py eco204 -l es -``` - -### Custom Output Directory - -```bash -python quizz_to_pdf.py btc101 -l en -o ./my_output -``` - -### List Available Courses - -```bash -python quizz_to_pdf.py --list-courses -``` - -## Available Languages - -The script supports all languages available in the quiz files using **IBM Plex fonts** (included in repository): - -### Latin Script Languages -Uses **IBM Plex Sans**: -- `en` - English (default) -- `fr` - French -- `es` - Spanish -- `de` - German -- `it` - Italian -- `pt` - Portuguese -- `nl` - Dutch -- `pl` - Polish -- `cs` - Czech -- `sv` - Swedish -- `fi` - Finnish -- `et` - Estonian -- `tr` - Turkish -- `ru` - Russian (Cyrillic) -- And others... - -### Non-Latin Script Languages -Uses **IBM Plex specialized variants**: -- `hi` - Hindi (Devanagari) - IBM Plex Sans Devanagari -- `ja` - Japanese - IBM Plex Sans JP -- `ko` - Korean - IBM Plex Sans KR -- `zh-Hans` - Chinese (Simplified) - IBM Plex Sans TC -- `zh-Hant` - Chinese (Traditional) - IBM Plex Sans TC -- `ar` - Arabic - IBM Plex Sans Arabic -- `fa` - Persian - IBM Plex Sans Arabic -- `th` - Thai - IBM Plex Sans Thai - -## Output - -The script generates: -1. **Quiz questions** with randomized answer order (A, B, C, D) -2. **Answer key** at the end with correct answers and explanations - -Output files: -- `{course}_quiz_{language}.tex` - LaTeX source -- `{course}_quiz_{language}.pdf` - Final PDF - -## Examples - -### Single Course -```bash -# Generate BTC101 quiz in English -python3 quizz_to_pdf.py btc101 - -# Generate ECO204 quiz in French -python3 quizz_to_pdf.py eco204 -l fr - -# Custom output location -python3 quizz_to_pdf.py btc101 -o ~/Desktop/quizzes -``` - -### Multiple Courses (Batch) -```bash -# Generate multiple courses in English -./batch_generate.sh btc101 btc102 btc202 - -# Generate multiple courses in French -./batch_generate.sh -l fr btc101 eco204 his201 - -# Custom output directory -./batch_generate.sh -l es -o ./spanish_quizzes btc101 btc102 -``` - -## Features - -- ✅ Automatic quiz discovery from course directories -- ✅ Answer randomization for each question -- ✅ Answer key with explanations -- ✅ Professional PDF formatting with IBM Plex fonts -- ✅ Multi-language support (Latin and non-Latin scripts) -- ✅ Consistent typography across all languages -- ✅ Page numbering and headers -- ✅ Clean auxiliary file cleanup -- ✅ No additional font installation required (fonts included) - -## Troubleshooting - -**Error: PyYAML is required** -```bash -pip install pyyaml -``` - -**Error: Neither xelatex nor lualatex found** -- Install Unicode LaTeX engines: - - Ubuntu/Debian: `sudo apt-get install texlive-xetex texlive-luatex` - - macOS: `brew install mactex` - - Windows: Install MikTeX from [miktex.org](https://miktex.org/) - -**Characters showing as boxes** -- IBM Plex fonts are included in the `fonts/` directory and load automatically -- Ensure XeLaTeX or LuaLaTeX is installed (required for fontspec package) -- Check that the `fonts/TrueType/` directory exists and contains IBM Plex fonts - -**Error: No quiz questions found** -- Verify the course code is correct -- Check if quiz files exist for the specified language -- Use `--list-courses` to see available courses - -## Directory Structure - -Expected quiz file structure: -``` -courses/ -├── btc101/ -│ └── quizz/ -│ ├── 000/ -│ │ ├── en.yml -│ │ ├── fr.yml -│ │ └── ... -│ ├── 001/ -│ └── ... -├── eco204/ -└── ... -``` - -## Quiz File Format - -Each quiz YML file should contain: -```yaml -question: "Question text here?" -answer: "Correct answer" -wrong_answers: - - "Wrong answer 1" - - "Wrong answer 2" - - "Wrong answer 3" -explanation: | - Optional explanation text that appears in the answer key. -reviewed: true -``` diff --git a/scripts/quizz_to_pdfs/batch_generate.sh b/scripts/quizz_to_pdfs/batch_generate.sh deleted file mode 100755 index 637152d1c3e..00000000000 --- a/scripts/quizz_to_pdfs/batch_generate.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash -# Batch generate quiz PDFs for multiple courses - -# Default values -LANGUAGE="en" -OUTPUT_DIR="./output" - -# Usage message -usage() { - echo "Usage: $0 [-l language] [-o output_dir] [course1 course2 ...]" - echo " -l: Language code (default: en)" - echo " -o: Output directory (default: ./output)" - echo "" - echo "Examples:" - echo " $0 btc101 btc102 btc202" - echo " $0 -l fr btc101 eco204" - echo " $0 -l es -o ./spanish_quizzes btc101 btc102" - exit 1 -} - -# Parse options -while getopts "l:o:h" opt; do - case $opt in - l) LANGUAGE="$OPTARG" ;; - o) OUTPUT_DIR="$OPTARG" ;; - h) usage ;; - *) usage ;; - esac -done -shift $((OPTIND-1)) - -# Check if any courses specified -if [ $# -eq 0 ]; then - echo "Error: No courses specified" - usage -fi - -# Create output directory -mkdir -p "$OUTPUT_DIR" - -# Counter for statistics -total=0 -success=0 -failed=0 - -echo "========================================" -echo "Batch Quiz PDF Generation" -echo "========================================" -echo "Language: $LANGUAGE" -echo "Output: $OUTPUT_DIR" -echo "Courses: $@" -echo "========================================" -echo "" - -# Generate PDFs for each course -for course in "$@"; do - total=$((total + 1)) - echo "[$total] Generating $course..." - - if python3 quizz_to_pdf.py "$course" -l "$LANGUAGE" -o "$OUTPUT_DIR" > /dev/null 2>&1; then - success=$((success + 1)) - echo " ✓ Success: ${course}_quiz_${LANGUAGE}.pdf" - else - failed=$((failed + 1)) - echo " ✗ Failed: $course" - fi - echo "" -done - -# Print summary -echo "========================================" -echo "Summary" -echo "========================================" -echo "Total courses: $total" -echo "Successful: $success" -echo "Failed: $failed" -echo "========================================" - -exit $failed diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Bold.ttf deleted file mode 100644 index b1ce96ad21c..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-BoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-BoldItalic.ttf deleted file mode 100644 index 4c0dd3194d1..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-BoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ExtraLight.ttf deleted file mode 100644 index f6fdb69ab8a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ExtraLightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ExtraLightItalic.ttf deleted file mode 100644 index 109b20d31bd..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ExtraLightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Italic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Italic.ttf deleted file mode 100644 index 9721b326a3e..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Italic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Light.ttf deleted file mode 100644 index e07b7b2d492..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-LightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-LightItalic.ttf deleted file mode 100644 index 74430407896..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-LightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Medium.ttf deleted file mode 100644 index f24cbab268a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-MediumItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-MediumItalic.ttf deleted file mode 100644 index 9e2aa1f14fe..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-MediumItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Regular.ttf deleted file mode 100644 index f7300999fe3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-SemiBold.ttf deleted file mode 100644 index 58ef401191b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-SemiBoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-SemiBoldItalic.ttf deleted file mode 100644 index c5c307ae758..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-SemiBoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Text.ttf deleted file mode 100644 index 2bcd561b1c8..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-TextItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-TextItalic.ttf deleted file mode 100644 index c30d5cf0093..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-TextItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Thin.ttf deleted file mode 100644 index ac2ca9e84d5..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ThinItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ThinItalic.ttf deleted file mode 100644 index 4933b1118b9..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/IBMPlexMono-ThinItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Mono/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Bold.ttf deleted file mode 100644 index d67d9e8b38d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-ExtraLight.ttf deleted file mode 100644 index 671f54fad8a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Light.ttf deleted file mode 100644 index 3f7ff73b20c..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Medium.ttf deleted file mode 100644 index 09ec8dafa45..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Regular.ttf deleted file mode 100644 index 5e192840f08..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-SemiBold.ttf deleted file mode 100644 index 6b48c75042b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Text.ttf deleted file mode 100644 index e77f997d2c9..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Thin.ttf deleted file mode 100644 index b3d14afba83..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/IBMPlexSansArabic-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Arabic/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Bold.ttf deleted file mode 100644 index 8641058a283..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-BoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-BoldItalic.ttf deleted file mode 100644 index 9b97aba3576..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-BoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ExtraLight.ttf deleted file mode 100644 index 9ef5948c9ae..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ExtraLightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ExtraLightItalic.ttf deleted file mode 100644 index 353752fbfce..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ExtraLightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Italic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Italic.ttf deleted file mode 100644 index f25605cad1c..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Italic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Light.ttf deleted file mode 100644 index f0935b1098a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-LightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-LightItalic.ttf deleted file mode 100644 index a8f0db2ddff..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-LightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Medium.ttf deleted file mode 100644 index 620ebaada79..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-MediumItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-MediumItalic.ttf deleted file mode 100644 index ad5c2fd2a73..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-MediumItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Regular.ttf deleted file mode 100644 index 66c832caa95..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-SemiBold.ttf deleted file mode 100644 index 6186dddc042..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-SemiBoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-SemiBoldItalic.ttf deleted file mode 100644 index fb88b9982f3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-SemiBoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Text.ttf deleted file mode 100644 index 718b4662f9d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-TextItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-TextItalic.ttf deleted file mode 100644 index c8a1c37cced..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-TextItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Thin.ttf deleted file mode 100644 index 7818c5da5e1..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ThinItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ThinItalic.ttf deleted file mode 100644 index 867cfb788e8..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/IBMPlexSansCondensed-ThinItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Condensed/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Bold.ttf deleted file mode 100644 index bfff28169f5..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-ExtraLight.ttf deleted file mode 100644 index d143860c5a3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Light.ttf deleted file mode 100644 index 080c35b916d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Medium.ttf deleted file mode 100644 index 5b9f7c16849..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Regular.ttf deleted file mode 100644 index 351bc34df33..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-SemiBold.ttf deleted file mode 100644 index 768a3084123..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Text.ttf deleted file mode 100644 index ed49ed435a1..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Thin.ttf deleted file mode 100644 index 7fd4e64e42b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/IBMPlexSansDevanagari-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Devanagari/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Bold.ttf deleted file mode 100644 index acddec718d9..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-ExtraLight.ttf deleted file mode 100644 index 4847d0dafb3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Light.ttf deleted file mode 100644 index af07f0acee9..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Medium.ttf deleted file mode 100644 index cf09949bc18..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Regular.ttf deleted file mode 100644 index 402c6681698..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-SemiBold.ttf deleted file mode 100644 index 5544f2cf9d9..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Text.ttf deleted file mode 100644 index e6f4ab15114..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Thin.ttf deleted file mode 100644 index 72d2d2ef4d2..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/IBMPlexSansHebrew-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Hebrew/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Bold.ttf deleted file mode 100644 index c85ebe055a0..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-ExtraLight.ttf deleted file mode 100644 index 1de629ba9c3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Light.ttf deleted file mode 100644 index f38fc373195..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Medium.ttf deleted file mode 100644 index 73c4b1304ad..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Regular.ttf deleted file mode 100644 index b909aec909b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-SemiBold.ttf deleted file mode 100644 index 6ab3c48c7c0..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Text.ttf deleted file mode 100644 index fd679b413fc..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Thin.ttf deleted file mode 100644 index f11d9e0a619..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/IBMPlexSansJP-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/hinted/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Bold.ttf deleted file mode 100644 index b73cd974ea3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-ExtraLight.ttf deleted file mode 100644 index ea147607a9e..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Light.ttf deleted file mode 100644 index 3d6bc72b8b8..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Medium.ttf deleted file mode 100644 index 6401e4e650b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Regular.ttf deleted file mode 100644 index 75144959c4f..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-SemiBold.ttf deleted file mode 100644 index 655ef8148d3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Text.ttf deleted file mode 100644 index 4e1fd2c6a81..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Thin.ttf deleted file mode 100644 index 9f8baf63667..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/IBMPlexSansJP-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-JP/unhinted/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Bold.ttf deleted file mode 100644 index 3d7636e0b2f..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-ExtraLight.ttf deleted file mode 100644 index 7509a8cfe10..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Light.ttf deleted file mode 100644 index b53bc48e02c..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Medium.ttf deleted file mode 100644 index dcef9b20ae3..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Regular.ttf deleted file mode 100644 index 30df0b63757..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-SemiBold.ttf deleted file mode 100644 index 693fc802d52..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Text.ttf deleted file mode 100644 index 75759f0f450..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Thin.ttf deleted file mode 100644 index d640ea92aec..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/IBMPlexSansKR-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/hinted/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Bold.ttf deleted file mode 100644 index e553f0b24aa..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-ExtraLight.ttf deleted file mode 100644 index 8ac74cbca9a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Light.ttf deleted file mode 100644 index c3e878b1d5d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Medium.ttf deleted file mode 100644 index fdb8631af66..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Regular.ttf deleted file mode 100644 index 4ca42abd92e..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-SemiBold.ttf deleted file mode 100644 index c31dc368c36..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Text.ttf deleted file mode 100644 index 64c0fd20ebb..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Thin.ttf deleted file mode 100644 index 75ddc108ca4..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/IBMPlexSansKR-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-KR/unhinted/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Bold.ttf deleted file mode 100644 index 2e930fcef84..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-ExtraLight.ttf deleted file mode 100644 index 6fa8ca8d578..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Light.ttf deleted file mode 100644 index 5c832b5071f..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Medium.ttf deleted file mode 100644 index 0b855087761..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Regular.ttf deleted file mode 100644 index 83da513829e..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-SemiBold.ttf deleted file mode 100644 index 88112e7589d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Text.ttf deleted file mode 100644 index 39ab5b495e5..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Thin.ttf deleted file mode 100644 index bc33008748c..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/IBMPlexSansTC-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/hinted/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Bold.ttf deleted file mode 100644 index 79ede0ddff1..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-ExtraLight.ttf deleted file mode 100644 index d73a9d36d88..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Light.ttf deleted file mode 100644 index 0a920c38662..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Medium.ttf deleted file mode 100644 index c7f36db4e0b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Regular.ttf deleted file mode 100644 index aa065ea8e95..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-SemiBold.ttf deleted file mode 100644 index cb995ab3869..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Text.ttf deleted file mode 100644 index 1e9b48cc663..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Thin.ttf deleted file mode 100644 index 1f321b76d46..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/IBMPlexSansTC-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-TC/unhinted/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Bold.ttf deleted file mode 100644 index 02b34637b17..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-ExtraLight.ttf deleted file mode 100644 index e80809bcc28..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Light.ttf deleted file mode 100644 index 42c2bb176cc..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Medium.ttf deleted file mode 100644 index c1bec87f04b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Regular.ttf deleted file mode 100644 index 9613733017a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-SemiBold.ttf deleted file mode 100644 index 94b44893f1a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Text.ttf deleted file mode 100644 index a556111c31d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Thin.ttf deleted file mode 100644 index a739f8b7403..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/IBMPlexSansThaiLooped-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai-Looped/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Bold.ttf deleted file mode 100644 index e9494f3e0f0..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-ExtraLight.ttf deleted file mode 100644 index c3ccc9ba1ad..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Light.ttf deleted file mode 100644 index 5272228df5d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Medium.ttf deleted file mode 100644 index c9869f30745..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Regular.ttf deleted file mode 100644 index 623712118b9..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-SemiBold.ttf deleted file mode 100644 index 2e43b4c9964..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Text.ttf deleted file mode 100644 index 96d25536711..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Thin.ttf deleted file mode 100644 index db335bbb0de..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/IBMPlexSansThai-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans-Thai/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Bold.ttf deleted file mode 100644 index 1d66b1a2e96..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-BoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-BoldItalic.ttf deleted file mode 100644 index e07bc1f527d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-BoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ExtraLight.ttf deleted file mode 100644 index 39218006610..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ExtraLightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ExtraLightItalic.ttf deleted file mode 100644 index baf0e47d13a..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ExtraLightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Italic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Italic.ttf deleted file mode 100644 index efe8a1fb9de..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Italic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Light.ttf deleted file mode 100644 index 62292926854..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-LightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-LightItalic.ttf deleted file mode 100644 index 42fb3c855db..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-LightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Medium.ttf deleted file mode 100644 index a3826c4e9ab..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-MediumItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-MediumItalic.ttf deleted file mode 100644 index 43e115ef030..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-MediumItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Regular.ttf deleted file mode 100644 index bd6817d5202..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-SemiBold.ttf deleted file mode 100644 index 09ec8cdae82..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-SemiBoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-SemiBoldItalic.ttf deleted file mode 100644 index a9c14fede24..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-SemiBoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Text.ttf deleted file mode 100644 index d71489c33e4..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-TextItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-TextItalic.ttf deleted file mode 100644 index 4d65c4d94ea..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-TextItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Thin.ttf deleted file mode 100644 index 4e3fedd566e..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ThinItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ThinItalic.ttf deleted file mode 100644 index 5e72258beb5..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/IBMPlexSans-ThinItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Sans/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Bold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Bold.ttf deleted file mode 100644 index 108b4a4ef2d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Bold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-BoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-BoldItalic.ttf deleted file mode 100644 index 0da105609ba..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-BoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ExtraLight.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ExtraLight.ttf deleted file mode 100644 index be7ced4a4ec..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ExtraLight.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ExtraLightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ExtraLightItalic.ttf deleted file mode 100644 index 91af05d895b..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ExtraLightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Italic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Italic.ttf deleted file mode 100644 index f050835b0a0..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Italic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Light.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Light.ttf deleted file mode 100644 index 7077844d4d4..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Light.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-LightItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-LightItalic.ttf deleted file mode 100644 index 7efb09ddacb..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-LightItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Medium.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Medium.ttf deleted file mode 100644 index 8aa5569232d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Medium.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-MediumItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-MediumItalic.ttf deleted file mode 100644 index 1c6a8de19be..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-MediumItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Regular.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Regular.ttf deleted file mode 100644 index 6265dfe413d..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Regular.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-SemiBold.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-SemiBold.ttf deleted file mode 100644 index 39c3da5e595..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-SemiBold.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-SemiBoldItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-SemiBoldItalic.ttf deleted file mode 100644 index f8255ea3fda..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-SemiBoldItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Text.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Text.ttf deleted file mode 100644 index eae99e4dce5..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Text.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-TextItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-TextItalic.ttf deleted file mode 100644 index f1d3c230363..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-TextItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Thin.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Thin.ttf deleted file mode 100644 index 9ddcb602145..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-Thin.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ThinItalic.ttf b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ThinItalic.ttf deleted file mode 100644 index 617e3d5b7c4..00000000000 Binary files a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/IBMPlexSerif-ThinItalic.ttf and /dev/null differ diff --git a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/license.txt b/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/license.txt deleted file mode 100644 index f72f76504cd..00000000000 --- a/scripts/quizz_to_pdfs/fonts/TrueType/IBM-Plex-Serif/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/quizz_to_pdfs/quizz_to_pdf.py b/scripts/quizz_to_pdfs/quizz_to_pdf.py deleted file mode 100755 index 5919316ff74..00000000000 --- a/scripts/quizz_to_pdfs/quizz_to_pdf.py +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env python3 -""" -Quiz to PDF Generator -Converts quiz YML files to PDF with randomized answer order and answer key. -""" - -import argparse -import os -import random -import subprocess -import sys -from pathlib import Path -from typing import List, Dict, Tuple - -try: - import yaml -except ImportError: - print("Error: PyYAML is required. Install with: pip install pyyaml") - sys.exit(1) - - -class QuizQuestion: - """Represents a single quiz question with randomized answers.""" - - def __init__(self, question: str, correct_answer: str, wrong_answers: List[str], - explanation: str = "", quiz_number: str = ""): - self.question = question - self.correct_answer = correct_answer - self.wrong_answers = wrong_answers - self.explanation = explanation - self.quiz_number = quiz_number - self.answers = [] - self.correct_index = -1 - self._randomize_answers() - - def _randomize_answers(self): - """Randomize the order of answers and track correct answer position.""" - all_answers = [self.correct_answer] + self.wrong_answers - random.shuffle(all_answers) - self.answers = all_answers - self.correct_index = self.answers.index(self.correct_answer) - - def get_correct_letter(self) -> str: - """Return the letter (A, B, C, D) of the correct answer.""" - return chr(65 + self.correct_index) # 65 is ASCII 'A' - - -def escape_latex(text) -> str: - """Escape special LaTeX characters.""" - # Convert to string if not already - text = str(text) if text is not None else "" - - # Order matters! Backslash must be escaped first - replacements = [ - ('\\', r'\textbackslash{}'), - ('{', r'\{'), - ('}', r'\}'), - ('&', r'\&'), - ('%', r'\%'), - ('$', r'\$'), - ('#', r'\#'), - ('_', r'\_'), - ('~', r'\textasciitilde{}'), - ('^', r'\^{}'), - ] - for old, new in replacements: - text = text.replace(old, new) - return text - - -def find_course_dirs(base_path: Path) -> List[str]: - """Find all course directories.""" - courses = [] - if base_path.exists(): - for item in base_path.iterdir(): - if item.is_dir() and (item / "quizz").exists(): - courses.append(item.name) - return sorted(courses) - - -def load_quiz_files(course: str, language: str, base_path: Path) -> List[QuizQuestion]: - """Load all quiz files for a given course and language.""" - quiz_dir = base_path / course / "quizz" - questions = [] - - if not quiz_dir.exists(): - print(f"Error: Quiz directory not found: {quiz_dir}") - return questions - - # Find all 3-digit directories - quiz_numbers = sorted([d.name for d in quiz_dir.iterdir() - if d.is_dir() and d.name.isdigit() and len(d.name) == 3]) - - for quiz_num in quiz_numbers: - quiz_file = quiz_dir / quiz_num / f"{language}.yml" - if quiz_file.exists(): - try: - with open(quiz_file, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - if data and 'question' in data and 'answer' in data: - question = QuizQuestion( - question=data['question'], - correct_answer=data['answer'], - wrong_answers=data.get('wrong_answers', []), - explanation=data.get('explanation', ''), - quiz_number=quiz_num - ) - questions.append(question) - except Exception as e: - print(f"Warning: Failed to load {quiz_file}: {e}") - - return questions - - -def generate_latex(questions: List[QuizQuestion], course: str, language: str) -> str: - """Generate LaTeX document from quiz questions.""" - - # Map languages to IBM Plex font directories - font_map = { - 'hi': 'IBM-Plex-Sans-Devanagari', # Hindi - 'ja': 'IBM-Plex-Sans-JP', # Japanese - 'ko': 'IBM-Plex-Sans-KR', # Korean - 'zh-Hans': 'IBM-Plex-Sans-TC', # Chinese Simplified (using TC) - 'zh-Hant': 'IBM-Plex-Sans-TC', # Chinese Traditional - 'ar': 'IBM-Plex-Sans-Arabic', # Arabic - 'fa': 'IBM-Plex-Sans-Arabic', # Persian (uses Arabic script) - 'th': 'IBM-Plex-Sans-Thai', # Thai - } - - # Detect if language needs special Unicode support (non-Latin scripts) - needs_unicode = language in font_map or language in ['ru', 'sr-Latn'] - - # Get base fonts directory - fonts_base = Path(__file__).parent / "fonts" / "TrueType" - - # Determine font configuration - if language in font_map: - # Use specific IBM Plex variant for this language - font_dir = fonts_base / font_map[language] - font_name = font_map[language].replace('-', '') # IBMPlexSansDevanagari, etc. - else: - # Use IBM Plex Sans for Latin scripts and Russian/Serbian - font_dir = fonts_base / "IBM-Plex-Sans" - font_name = "IBMPlexSans" - - # Generate LaTeX header using fontspec (works for all languages with XeLaTeX/LuaLaTeX) - latex = r"""\documentclass[12pt,a4paper]{article} -\usepackage{fontspec} -\usepackage{geometry} -\usepackage{enumitem} -\usepackage{fancyhdr} -\usepackage{lastpage} -\usepackage{hyperref} - -% Configure IBM Plex Sans font family -\setmainfont{""" + font_name + r"""}[ - Path=""" + str(font_dir) + r"""/, - Extension=.ttf, - UprightFont=*-Regular, - BoldFont=*-Bold, - Scale=1.0 -] - -\geometry{margin=2.5cm} -\pagestyle{fancy} -\fancyhf{} -\rhead{Page \thepage\ of \pageref{LastPage}} -\lhead{""" + f"{course.upper()} - Quiz ({language})" + r"""} -\renewcommand{\headrulewidth}{0.4pt} - -\title{""" + f"Quiz: {course.upper()}" + r"""} -\author{Language: """ + language + r"""} -\date{\today} - -\begin{document} - -\maketitle -\thispagestyle{fancy} - -\section*{Instructions} -Answer all questions by selecting the best option (A, B, C, or D). The answer key is provided at the end of this document. - -\bigskip - -""" - - # Add questions (no escaping needed with fontspec/Unicode) - for idx, q in enumerate(questions, 1): - latex += f"\n\\subsection*{{Question {idx}}}\n" - latex += q.question + "\n\n" - latex += "\\begin{enumerate}[label=\\Alph*., leftmargin=*]\n" - - for answer in q.answers: - latex += f"\\item {answer}\n" - - latex += "\\end{enumerate}\n\n" - latex += "\\bigskip\n\n" - - # Add answer key - latex += r""" -\newpage -\section*{Answer Key} - -\begin{enumerate} -""" - - for idx, q in enumerate(questions, 1): - latex += f"\\item \\textbf{{Question {idx}:}} {q.get_correct_letter()}\n" - if q.explanation: - # Clean explanation: remove extra newlines and normalize whitespace - explanation = ' '.join(q.explanation.strip().split()) - # Always escape % to prevent LaTeX comments - explanation = explanation.replace('%', r'\%') - latex += f"\\\\ \\textit{{{explanation}}}\n" - latex += "\n" - - latex += r""" -\end{enumerate} - -\end{document} -""" - - return latex - - -def compile_pdf(latex_content: str, output_name: str, output_dir: Path, language: str) -> bool: - """Compile LaTeX to PDF using xelatex or lualatex (fontspec requires Unicode engine).""" - - # Create output directory if it doesn't exist - output_dir.mkdir(parents=True, exist_ok=True) - - tex_file = output_dir / f"{output_name}.tex" - - # Write LaTeX file - with open(tex_file, 'w', encoding='utf-8') as f: - f.write(latex_content) - - print(f"Generated LaTeX file: {tex_file}") - - # Choose LaTeX engine (try xelatex first, fallback to lualatex) - # All languages now use fontspec, which requires XeLaTeX or LuaLaTeX - latex_cmd = None - for cmd in ['xelatex', 'lualatex']: - try: - subprocess.run([cmd, '--version'], capture_output=True, check=True) - latex_cmd = cmd - break - except (subprocess.CalledProcessError, FileNotFoundError): - continue - - if not latex_cmd: - print(f"Error: Neither xelatex nor lualatex found. IBM Plex fonts require XeLaTeX or LuaLaTeX.") - print(f"Install with: sudo apt-get install texlive-xetex texlive-luatex") - return False - - print(f"Using {latex_cmd} for compilation...") - - # Compile with chosen LaTeX engine (run 3 times to resolve all references) - try: - for _ in range(3): - subprocess.run( - [latex_cmd, '-interaction=nonstopmode', '-output-directory', str(output_dir), str(tex_file)], - capture_output=True, - check=False - ) - - pdf_file = output_dir / f"{output_name}.pdf" - - # Check if PDF was created (even if return code was non-zero) - if pdf_file.exists(): - print(f"✓ PDF generated: {pdf_file}") - - # Clean up auxiliary files (keep .log for debugging if needed) - for ext in ['.aux', '.out']: - aux_file = output_dir / f"{output_name}{ext}" - if aux_file.exists(): - aux_file.unlink() - - return True - else: - print("Error: PDF file was not created") - return False - - except FileNotFoundError: - print(f"Error: {latex_cmd} not found. Please install texlive-xetex or texlive-luatex.") - return False - - -def main(): - parser = argparse.ArgumentParser( - description="Generate PDF quiz from YML files with randomized answers", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - %(prog)s btc101 # Generate quiz for btc101 in English - %(prog)s btc101 -l fr # Generate quiz for btc101 in French - %(prog)s eco204 -l es -o ./output # Generate quiz with custom output directory - %(prog)s --list-courses # List all available courses - """ - ) - - parser.add_argument('course', nargs='?', help='Course code (e.g., btc101, eco204)') - parser.add_argument('-l', '--language', default='en', - help='Language code (default: en)') - parser.add_argument('-o', '--output', default='./output', - help='Output directory (default: ./output)') - parser.add_argument('--list-courses', action='store_true', - help='List all available courses') - parser.add_argument('--base-path', type=Path, - default=Path(__file__).parent.parent.parent / 'courses', - help='Base path to courses directory') - - args = parser.parse_args() - - # List courses if requested - if args.list_courses: - courses = find_course_dirs(args.base_path) - print("Available courses:") - for course in courses: - print(f" - {course}") - return - - # Validate course argument - if not args.course: - parser.error("course is required (or use --list-courses)") - - print(f"Generating quiz PDF for {args.course} in {args.language}...") - - # Load quiz questions - questions = load_quiz_files(args.course, args.language, args.base_path) - - if not questions: - print(f"Error: No quiz questions found for {args.course} in {args.language}") - sys.exit(1) - - print(f"Loaded {len(questions)} questions") - - # Generate LaTeX - latex_content = generate_latex(questions, args.course, args.language) - - # Compile to PDF - output_dir = Path(args.output) - output_name = f"{args.course}_quiz_{args.language}" - - success = compile_pdf(latex_content, output_name, output_dir, args.language) - - if success: - print(f"\n✓ Quiz PDF generated successfully!") - sys.exit(0) - else: - print(f"\n✗ Failed to generate PDF") - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/scripts/quizz_to_pdfs/requirements.txt b/scripts/quizz_to_pdfs/requirements.txt deleted file mode 100644 index 3aecde93d82..00000000000 --- a/scripts/quizz_to_pdfs/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pyyaml>=6.0 diff --git a/scripts/relocate_contributors_builders.py b/scripts/relocate_contributors_builders.py deleted file mode 100644 index 598e491d21c..00000000000 --- a/scripts/relocate_contributors_builders.py +++ /dev/null @@ -1,37 +0,0 @@ -import os - -def update_files(): - builder_folder = "../resources/builders" - folders = os.listdir(builder_folder) - total_folders = len(folders) - processed_folders = 0 - - for folder in folders: - folder_path = os.path.join(builder_folder, folder) - if os.path.isdir(folder_path): - builder_file_path = os.path.join(folder_path, "builder.yml") - en_file_path = os.path.join(folder_path, "en.yml") - - # Update builder.yml file - with open(builder_file_path, "r+") as builder_file: - lines = builder_file.readlines() - builder_file.seek(0) - - for line in lines: - if not (line.startswith("contributors:") or line.startswith(" - rabbit-hole")): - builder_file.write(line) - - builder_file.truncate() - - # Update en.yml file - with open(en_file_path, "a") as en_file: - en_file.write("\n") - en_file.write("contributors:\n") - en_file.write(" - rabbit-hole\n") - - processed_folders += 1 - progress_percentage = (processed_folders / total_folders) * 100 - print(f"Progress: {progress_percentage:.2f}%") - -update_files() - diff --git a/scripts/rename-translated-content.py b/scripts/rename-translated-content.py deleted file mode 100644 index e42e2f88499..00000000000 --- a/scripts/rename-translated-content.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - -def rename_and_modify_files(directory): - # Language to code mapping - language_codes = { - 'English': 'en', - 'Spanish': 'es', - 'German': 'de', - 'Italian': 'it', - 'Portuguese': 'pt' - } - - # Iterate over all files in the directory - for filename in os.listdir(directory): - if filename.endswith('.md'): - new_filename = None - # Determine the new file name based on language suffix - for language, code in language_codes.items(): - if filename.endswith(f'_{language}.md'): - new_filename = f'{code}.md' - break - - if new_filename: - new_path = os.path.join(directory, new_filename) - old_path = os.path.join(directory, filename) - - # Read the old file's content and replace '/fr/' with the new code - with open(old_path, 'r', encoding='utf-8') as file: - content = file.read() - - new_content = content.replace('/fr/', f'/{new_filename[:-3]}/') - - # Write the modified content to the new file - with open(new_path, 'w', encoding='utf-8') as file: - file.write(new_content) - - # Remove the old file if it's not the same as new file - if new_path != old_path: - os.remove(old_path) - print(f"Renamed and modified '{filename}' to '{new_filename}'") - - -directory_path = '../../LLM-Translator/output/test/' -rename_and_modify_files(directory_path) - diff --git a/scripts/rename_image/rename_images.py b/scripts/rename_image/rename_images.py deleted file mode 100755 index 3b36c3f826c..00000000000 --- a/scripts/rename_image/rename_images.py +++ /dev/null @@ -1,546 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to rename image references in markdown files and/or rename actual image files. -Works with course directories and includes fuzzy search for course selection. -""" - -import os -import re -from pathlib import Path -import sys - -def fuzzy_search(query, choices): - """Simple fuzzy search implementation for course names.""" - query = query.lower() - results = [] - - for choice in choices: - choice_lower = choice.lower() - score = 0 - - # Exact match - if query == choice_lower: - score = 1000 - # Starts with query - elif choice_lower.startswith(query): - score = 100 - # Contains query - elif query in choice_lower: - score = 50 - # All characters from query are in choice (in order) - else: - query_idx = 0 - for char in choice_lower: - if query_idx < len(query) and char == query[query_idx]: - query_idx += 1 - score += 1 - if query_idx != len(query): - score = 0 - - if score > 0: - results.append((choice, score)) - - # Sort by score (highest first) - results.sort(key=lambda x: x[1], reverse=True) - return [r[0] for r in results] - -def select_course(): - """Let user select a course directory with fuzzy search.""" - # Try different possible paths for the courses directory - possible_paths = [ - Path('../../courses'), # From scripts/rename_image - Path('../courses'), # From scripts - Path('courses'), # From root - ] - - courses_dir = None - for path in possible_paths: - if path.exists() and path.is_dir(): - courses_dir = path - break - - if not courses_dir: - print("Error: courses directory not found.") - print("Make sure the courses directory exists relative to this script.") - return None - - courses_dir = Path('../../courses') - - if not courses_dir.exists(): - print("Error: courses directory not found. Make sure you're running this from the scripts/rename_image directory.") - return None - - # Get all course directories - course_dirs = [d.name for d in courses_dir.iterdir() if d.is_dir()] - - if not course_dirs: - print("No course directories found in ../courses/") - return None - - print("\n" + "=" * 60) - print("Course Selection") - print("=" * 60) - print(f"\nFound {len(course_dirs)} courses available:") - - # Show first 10 courses - for course in sorted(course_dirs)[:10]: - print(f" • {course}") - if len(course_dirs) > 10: - print(f" ... and {len(course_dirs) - 10} more") - - while True: - print("\nEnter course name (or part of it for fuzzy search):") - query = input("Course: ").strip() - - if not query: - print("No input provided. Exiting.") - return None - - # Perform fuzzy search - matches = fuzzy_search(query, course_dirs) - - if not matches: - print(f"No courses found matching '{query}'") - retry = input("Try again? (y/n): ").strip().lower() - if retry != 'y': - return None - continue - - if len(matches) == 1: - selected = matches[0] - print(f"\n✓ Selected course: {selected}") - confirm = input("Is this correct? (y/n): ").strip().lower() - if confirm == 'y': - return courses_dir / selected - else: - print(f"\nFound {len(matches)} matches:") - for i, match in enumerate(matches[:10], 1): - print(f" {i}. {match}") - if len(matches) > 10: - print(f" ... and {len(matches) - 10} more") - - choice = input("\nEnter number to select (or 'r' to retry): ").strip() - - if choice.lower() == 'r': - continue - - try: - idx = int(choice) - 1 - if 0 <= idx < len(matches): - selected = matches[idx] - print(f"\n✓ Selected course: {selected}") - return courses_dir / selected - else: - print("Invalid selection.") - except ValueError: - print("Invalid input.") - - retry = input("Try again? (y/n): ").strip().lower() - if retry != 'y': - return None - -def find_all_image_references(content): - """Find all .webp image references in the content, preserving order.""" - pattern = r'(\./)?assets/([a-zA-Z-]+)/(\d+)\.webp' - matches = re.finditer(pattern, content) - - all_refs = [] - for match in matches: - all_refs.append({ - 'start': match.start(), - 'end': match.end(), - 'full_match': match.group(0), - 'prefix': match.group(1) or '', - 'lang': match.group(2), - 'num': match.group(3) - }) - - return all_refs - -def rename_references_in_file(file_path, course_dir): - """Rename all image references in a single markdown file.""" - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - all_refs = find_all_image_references(content) - - if not all_refs: - print(f" No .webp images found in {file_path.name}") - return 0 - - languages_found = set(ref['lang'] for ref in all_refs) - print(f" Found {len(all_refs)} total .webp image references") - print(f" Original language codes: {', '.join(sorted(languages_found))}") - - total_images = len(all_refs) - digit_format = 2 if total_images < 100 else 3 - - modified_content = content - - for i, ref in enumerate(reversed(all_refs), 1): - seq_num = total_images - i + 1 - new_num = str(seq_num).zfill(digit_format) - new_path = f"{ref['prefix']}assets/en/{new_num}.webp" - - modified_content = ( - modified_content[:ref['start']] + - new_path + - modified_content[ref['end']:] - ) - - if content != modified_content: - with open(file_path, 'w', encoding='utf-8') as f: - f.write(modified_content) - - print(f" ✅ Renamed {len(all_refs)} image references to assets/en/XX.webp ({digit_format} digits)") - print(f" Sequential numbering from 01 to {str(total_images).zfill(digit_format)}") - - example_refs = all_refs[:3] - for i, ref in enumerate(example_refs, 1): - new_num = str(i).zfill(digit_format) - print(f" {ref['full_match']} → assets/en/{new_num}.webp") - if len(all_refs) > 3: - print(f" ... and {len(all_refs) - 3} more references") - - return len(all_refs) - else: - print(f" No changes needed") - - return 0 - -def process_single_file(course_dir): - """Process a single markdown file in the course directory.""" - file_name = input("Enter the markdown filename (e.g., fr.md): ").strip() - md_file = course_dir / file_name - - if not md_file.exists(): - print(f"Error: File '{file_name}' not found in {course_dir.name}.") - return - - if not md_file.suffix == '.md': - print(f"Error: '{file_name}' is not a markdown file.") - return - - print(f"\nProcessing {md_file.name}...") - renamed_count = rename_references_in_file(md_file, course_dir) - - print("\n" + "=" * 50) - print(f"Complete! Processed {md_file.name}") - if renamed_count > 0: - print(f"All {renamed_count} image references now use sequential assets/en/ paths") - -def process_all_files(course_dir): - """Process all markdown files in the course directory.""" - md_files = list(course_dir.glob('*.md')) - - if not md_files: - print(f"No markdown files found in {course_dir.name}.") - return - - print(f"Found {len(md_files)} markdown files to process") - print("Will convert all .webp images to sequential assets/en/ paths") - print("-" * 50) - - total_renamed = 0 - files_modified = 0 - - for md_file in sorted(md_files): - print(f"\nProcessing {md_file.name}...") - renamed_count = rename_references_in_file(md_file, course_dir) - if renamed_count > 0: - total_renamed += renamed_count - files_modified += 1 - - print("\n" + "=" * 50) - print(f"Complete! Processed {len(md_files)} files") - print(f"Modified {files_modified} files with {total_renamed} total image references") - -def find_all_webp_files_recursive(directory): - """Find all .webp files recursively in directory and subdirectories.""" - image_files = [] - - for file_path in Path(directory).rglob('*.webp'): - filename = file_path.name - matches = re.findall(r'\d+', filename) - - if matches: - number = matches[-1] - - if filename == f"{number.zfill(2)}.webp" or filename == f"{number.zfill(3)}.webp" or filename == f"{number}.webp": - continue - - if len(number) == 1: - new_name = f"0{number}.webp" - else: - new_name = f"{number.zfill(len(number))}.webp" - - image_files.append({ - 'path': file_path, - 'directory': file_path.parent, - 'name': filename, - 'number': number, - 'new_name': new_name, - 'relative_path': file_path.relative_to(directory) - }) - - return sorted(image_files, key=lambda x: (str(x['directory']), int(x['number']))) - -def rename_image_files(course_dir): - """Rename actual image files in the course's assets directory.""" - print("\n" + "=" * 60) - print("Image File Renaming (Recursive)") - print("=" * 60) - - assets_dir = course_dir / 'assets' - - if not assets_dir.exists(): - print(f"No assets directory found in {course_dir.name}") - return - - print(f"\nScanning {assets_dir} and all subdirectories for .webp files...") - - files_to_rename = find_all_webp_files_recursive(assets_dir) - - if not files_to_rename: - print(f"\nNo .webp files need renaming in {assets_dir}") - return - - files_by_dir = {} - for file_info in files_to_rename: - dir_key = str(file_info['directory']) - if dir_key not in files_by_dir: - files_by_dir[dir_key] = [] - files_by_dir[dir_key].append(file_info) - - print(f"\nFound {len(files_to_rename)} files to rename across {len(files_by_dir)} directories:") - print("-" * 60) - - for dir_path, files in sorted(files_by_dir.items())[:10]: - try: - rel_dir = Path(dir_path).relative_to(course_dir) - except ValueError: - rel_dir = Path(dir_path).name - print(f"\n📁 {rel_dir}/ ({len(files)} files)") - for file_info in files[:3]: - print(f" {file_info['name']} → {file_info['new_name']}") - if len(files) > 3: - print(f" ... and {len(files) - 3} more files") - - if len(files_by_dir) > 10: - print(f"\n... and {len(files_by_dir) - 10} more directories") - - print("\n" + "-" * 60) - print(f"Total: {len(files_to_rename)} files across {len(files_by_dir)} directories") - - confirm = input(f"\nRename all these files? (y/n): ").strip().lower() - - if confirm != 'y': - print("Operation cancelled.") - return - - print("\nRenaming files...") - renamed_count = 0 - errors = [] - - for file_info in files_to_rename: - old_path = file_info['path'] - new_path = old_path.parent / file_info['new_name'] - - try: - if new_path.exists() and new_path != old_path: - errors.append(f"Cannot rename {file_info['relative_path']} → {file_info['new_name']}: target already exists") - else: - old_path.rename(new_path) - renamed_count += 1 - if renamed_count <= 10 or renamed_count % 10 == 0: - print(f" ✓ [{renamed_count}/{len(files_to_rename)}] {file_info['relative_path']} → {file_info['new_name']}") - except Exception as e: - errors.append(f"Error renaming {file_info['relative_path']}: {str(e)}") - - print("\n" + "=" * 50) - print(f"✅ Successfully renamed {renamed_count} files") - - if errors: - print(f"\n⚠️ {len(errors)} errors occurred:") - for error in errors[:5]: - print(f" - {error}") - if len(errors) > 5: - print(f" ... and {len(errors) - 5} more errors") - -def update_language_references(course_dir): - """Update image references in markdown files to use their corresponding language folders.""" - print("\n" + "=" * 60) - print("Update Language References in Markdown Files") - print("=" * 60) - - assets_dir = course_dir / 'assets' - - if not assets_dir.exists(): - print(f"No assets directory found in {course_dir.name}") - return - - md_files = list(course_dir.glob('*.md')) - - if not md_files: - print(f"No markdown files found in {course_dir.name}") - return - - print(f"\nFound {len(md_files)} markdown files") - print("Checking for corresponding language folders in assets/...") - print("-" * 50) - - files_to_update = [] - - for md_file in sorted(md_files): - lang_code = md_file.stem - - if lang_code == 'en': - print(f" ✓ Skipping {md_file.name} (reference language)") - continue - - lang_assets_dir = assets_dir / lang_code - - if lang_assets_dir.exists() and lang_assets_dir.is_dir(): - webp_files = list(lang_assets_dir.glob('*.webp')) - if webp_files: - files_to_update.append({ - 'file': md_file, - 'lang': lang_code, - 'assets_dir': lang_assets_dir, - 'webp_count': len(webp_files) - }) - print(f" ✓ {md_file.name} → assets/{lang_code}/ exists ({len(webp_files)} .webp files)") - else: - print(f" ⚠ {md_file.name} → assets/{lang_code}/ exists but has no .webp files") - else: - print(f" ✗ {md_file.name} → no assets/{lang_code}/ folder found") - - if not files_to_update: - print("\nNo markdown files need updating.") - return - - print(f"\n{len(files_to_update)} files will be updated:") - for info in files_to_update: - print(f" • {info['file'].name}: /en/ → /{info['lang']}/") - - confirm = input(f"\nUpdate these {len(files_to_update)} files? (y/n): ").strip().lower() - - if confirm != 'y': - print("Operation cancelled.") - return - - print("\nUpdating files...") - updated_count = 0 - - for info in files_to_update: - md_file = info['file'] - lang_code = info['lang'] - - with open(md_file, 'r', encoding='utf-8') as f: - content = f.read() - - en_refs = len(re.findall(r'assets/en/', content)) - - if en_refs == 0: - print(f" ⚠ {md_file.name}: No /en/ references found") - continue - - modified_content = content.replace('assets/en/', f'assets/{lang_code}/') - - with open(md_file, 'w', encoding='utf-8') as f: - f.write(modified_content) - - updated_count += 1 - print(f" ✅ {md_file.name}: Replaced {en_refs} references (/en/ → /{lang_code}/)") - - print("\n" + "=" * 50) - print(f"✅ Successfully updated {updated_count} files") - - if updated_count > 0: - print("\nSample verification (first updated file):") - sample_file = files_to_update[0]['file'] - sample_lang = files_to_update[0]['lang'] - - with open(sample_file, 'r', encoding='utf-8') as f: - content = f.read() - - pattern = rf'assets/{sample_lang}/\d+\.webp' - matches = re.findall(pattern, content)[:3] - - if matches: - print(f" First references in {sample_file.name}:") - for match in matches: - print(f" • {match}") - -def main(): - """Main function with course selection and operation menu.""" - print("=" * 60) - print("Course Image Reference and File Renaming Script") - print("=" * 60) - - # Select course - course_dir = select_course() - - if not course_dir: - print("No course selected. Exiting.") - return - - print(f"\n📚 Working with course: {course_dir.name}") - print("=" * 60) - - while True: - print("\nMain Menu:") - print("1. Rename references in a SINGLE markdown file to sequential numbers") - print("2. Rename references in ALL markdown files to sequential numbers") - print("3. Rename ALL IMAGE FILES recursively (remove any prefix, keep only numbers)") - print("4. Update language references (replace /en/ with appropriate language)") - print("5. Change course") - print("6. Exit") - - choice = input("\nEnter your choice (1-6): ").strip() - - if choice == '1': - process_single_file(course_dir) - elif choice == '2': - confirm = input("\nThis will process ALL .md files. Continue? (y/n): ").strip().lower() - if confirm == 'y': - process_all_files(course_dir) - else: - print("Operation cancelled.") - elif choice == '3': - rename_image_files(course_dir) - elif choice == '4': - update_language_references(course_dir) - elif choice == '5': - course_dir = select_course() - if not course_dir: - print("No course selected. Returning to previous course.") - else: - print(f"\n📚 Now working with course: {course_dir.name}") - elif choice == '6': - print("Exiting. Goodbye!") - break - else: - print("Invalid choice. Please enter 1-6.") - -if __name__ == "__main__": - # Check for courses directory in various locations - possible_paths = [ - Path('../../courses'), # From scripts/rename_image - Path('../courses'), # From scripts - Path('courses'), # From root - ] - - courses_found = False - for path in possible_paths: - if path.exists() and path.is_dir(): - courses_found = True - break - - if not courses_found: - print("Error: Could not find the courses directory.") - print("This script should be run from the scripts/rename_image directory.") - print("Current directory:", os.getcwd()) - sys.exit(1) - - main() \ No newline at end of file diff --git a/scripts/reports/README.md b/scripts/reports/README.md deleted file mode 100644 index 421085d892b..00000000000 --- a/scripts/reports/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Translation Overview Reports - -This folder contains auto-generated HTML reports that provide comprehensive overviews of translation progress across all courses. - -## 🏠 Start Here - -**[index.html](index.html)** - Main navigation page with links to all reports - -Open this file first for a clean overview and easy access to all translation reports. - -## 📊 Available Reports - -### 1. video_deployment_overview.html -**Video Deployment Status** - -Shows which videos have been deployed on YouTube and/or PeerTube for each course and language. - -- **Languages tracked**: en, fr, es, it, de, ru, zh-Hant -- **Data source**: `course.yml` files in each course folder -- **Updates**: Run `python ../video_deployment_overview/generate_report.py` - -### 2. image_translation_overview.html -**Image Translation Progress** - -Tracks which images have been translated by checking for language codes in image paths. - -- **Languages tracked**: en, fr, es, it, de, ru, zh-Hant -- **Data source**: Image references in markdown files -- **Updates**: Run `python ../image_translation_overview/generate_report.py` - -### 3. md_translation_overview.html -**Markdown Translation Coverage** - -Shows which markdown files exist for each course and language, with word counts. - -- **Languages tracked**: All 27 languages detected from btc101 -- **Data source**: `.md` files in course folders -- **Updates**: Run `python ../md_translation_overview/generate_report.py` - -## 🔄 Updating Reports - -To regenerate all reports at once: - -```bash -cd .. -python generate_all_reports.py -``` - -This will update all three HTML files with the latest data from the courses. - -## 📖 How to Use - -1. **Open in browser**: Double-click any HTML file to open it in your default browser -2. **Bookmark for quick access**: Add the file paths to your browser bookmarks -3. **Share with translators**: Send the HTML files to help coordinate translation efforts -4. **Track progress**: Re-generate reports regularly to monitor translation progress - -## 🎨 Features - -All reports include: -- ✅ Color-coded tables for quick visual assessment -- 📊 Detailed statistics and percentages -- 📱 Responsive design (works on mobile/tablet/desktop) -- 🔍 Easy-to-scan layouts with clear legends -- 📅 Auto-generated timestamps - -## 💡 Tips - -- **Regular updates**: Run the master script weekly to track progress -- **Compare over time**: Save dated copies to track improvement -- **Identify gaps**: Use red/orange cells to prioritize translation work -- **Celebrate success**: Green cells show completed work! - ---- - -*Reports auto-generated by Bitcoin Educational Content translation tools* diff --git a/scripts/reports/image_translation_overview.html b/scripts/reports/image_translation_overview.html deleted file mode 100644 index 723a695cd78..00000000000 --- a/scripts/reports/image_translation_overview.html +++ /dev/null @@ -1,1384 +0,0 @@ - - - - - - Image Translation Overview - - - -
    -
    - Back to Reports -

    🖼️ Image Translation Overview

    -
    -
    - Languages analyzed: en, fr, es, it, de, ru, zh-Hant
    - Total courses: 38 | - Total images: 15675 | - Translated: 9044 (57.7% overall) -
    - -
    -

    Legend

    -
    - - Complete (100%) -
    -
    - - Partial (50-99%) -
    -
    - - Low (1-49%) -
    -
    - - None (0%) -
    -
    - - N/A (No content) -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CourseENFRESITDERUZH-HANT
    biz101 - 31/31 - 100% - - 31/31 - 100% - - 31/31 - 100% - - 31/31 - 100% - - 31/31 - 100% - - 31/31 - 100% - - 31/31 - 100% -
    biz205 - 0/41 - 0% - - 0/41 - 0% - - 0/41 - 0% - - 0/41 - 0% - - 0/41 - 0% - - 0/41 - 0% - - 0/41 - 0% -
    btc101 - 87/87 - 100% - - 87/87 - 100% - - 87/87 - 100% - - 87/87 - 100% - - 87/87 - 100% - - 87/87 - 100% - - 87/87 - 100% -
    btc102 - 102/102 - 100% - - 102/102 - 100% - - 102/102 - 100% - - 102/102 - 100% - - 102/102 - 100% - - 102/102 - 100% - - 102/102 - 100% -
    btc202 - 0/108 - 0% - - 108/108 - 100% - - 0/108 - 0% - - 0/108 - 0% - - 0/108 - 0% - - 0/108 - 0% - - 0/108 - 0% -
    btc204 - 249/249 - 100% - - 249/249 - 100% - - 249/249 - 100% - - 249/249 - 100% - - 249/249 - 100% - - 249/249 - 100% - - 249/249 - 100% -
    btc303 - 21/23 - 91% - - 21/23 - 91% - - 21/23 - 91% - - 21/23 - 91% - - 21/23 - 91% - - 21/23 - 91% - - 21/23 - 91% -
    btc304 - 63/63 - 100% - - 63/63 - 100% - - 63/63 - 100% - - 63/63 - 100% - - 63/63 - 100% - - 63/63 - 100% - - 63/63 - 100% -
    csv402 - 108/108 - 100% - - 0/108 - 0% - - 0/108 - 0% - - 0/108 - 0% - - 0/108 - 0% - - 0/108 - 0% - - 0/108 - 0% -
    csv404 - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images -
    cyp201 - 71/71 - 100% - - 0/71 - 0% - - 0/71 - 0% - - 0/71 - 0% - - 0/71 - 0% - - 0/71 - 0% - - 0/71 - 0% -
    cyp302 - 19/19 - 100% - - 0/19 - 0% - - 0/19 - 0% - - 0/19 - 0% - - 0/19 - 0% - - 0/19 - 0% - - 0/19 - 0% -
    dev103 - 8/8 - 100% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% -
    dev303 - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images -
    eco104 - 17/17 - 100% - - 0/17 - 0% - - 0/10 - 0% - - 0/17 - 0% - - 0/10 - 0% - - 0/17 - 0% - - 0/17 - 0% -
    eco201 - 23/23 - 100% - - 23/23 - 100% - - 23/23 - 100% - - 23/23 - 100% - - 23/23 - 100% - - 23/23 - 100% - - 23/23 - 100% -
    eco203 - 125/125 - 100% - - 125/125 - 100% - - 125/125 - 100% - - 125/125 - 100% - - 125/125 - 100% - - 125/125 - 100% - - 125/125 - 100% -
    eco204 - 74/74 - 100% - - 74/74 - 100% - - 74/74 - 100% - - 74/74 - 100% - - 74/74 - 100% - - 74/74 - 100% - - 74/74 - 100% -
    ene101 - N/A - No content - - N/A - No images - - N/A - No content - - N/A - No content - - N/A - No content - - N/A - No content - - N/A - No content -
    his201 - 70/70 - 100% - - 70/70 - 100% - - 70/70 - 100% - - 70/70 - 100% - - 72/72 - 100% - - 70/70 - 100% - - 70/70 - 100% -
    his203 - N/A - No content - - 1/97 - 1% - - N/A - No content - - N/A - No content - - N/A - No content - - N/A - No content - - N/A - No content -
    his204 - 95/95 - 100% - - 95/95 - 100% - - 95/95 - 100% - - 95/95 - 100% - - 95/95 - 100% - - 95/95 - 100% - - 95/95 - 100% -
    his205 - 47/47 - 100% - - 47/47 - 100% - - 47/47 - 100% - - 47/47 - 100% - - 47/47 - 100% - - 47/47 - 100% - - 47/47 - 100% -
    lnp201 - 84/84 - 100% - - 0/84 - 0% - - 0/84 - 0% - - 0/84 - 0% - - 0/84 - 0% - - 0/84 - 0% - - 0/84 - 0% -
    lnp206 - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images -
    min201 - 79/79 - 100% - - 0/79 - 0% - - 0/79 - 0% - - 0/79 - 0% - - 0/79 - 0% - - 0/77 - 0% - - 0/79 - 0% -
    min306 - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images -
    net302 - 35/35 - 100% - - 35/35 - 100% - - 35/35 - 100% - - 35/35 - 100% - - 35/35 - 100% - - 35/35 - 100% - - 35/35 - 100% -
    phi101 - 102/102 - 100% - - 102/102 - 100% - - 102/102 - 100% - - 102/103 - 99% - - 102/102 - 100% - - 102/102 - 100% - - 102/102 - 100% -
    phi102 - N/A - No content - - 0/30 - 0% - - N/A - No content - - N/A - No content - - N/A - No content - - N/A - No content - - N/A - No content -
    phi305 - 0/62 - 0% - - 62/62 - 100% - - 0/62 - 0% - - 0/62 - 0% - - 0/62 - 0% - - 0/62 - 0% - - 0/62 - 0% -
    pos305 - 128/128 - 100% - - 0/128 - 0% - - 0/128 - 0% - - 0/128 - 0% - - 0/128 - 0% - - 0/128 - 0% - - 0/128 - 0% -
    pro101 - 61/62 - 98% - - 0/62 - 0% - - 0/62 - 0% - - 0/62 - 0% - - 0/62 - 0% - - 0/62 - 0% - - 0/62 - 0% -
    pro202 - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% - - 0/8 - 0% -
    scu101 - 21/21 - 100% - - 21/21 - 100% - - 21/21 - 100% - - 21/21 - 100% - - 21/21 - 100% - - 21/21 - 100% - - 21/21 - 100% -
    scu202 - 0/281 - 0% - - 281/281 - 100% - - 0/281 - 0% - - 0/281 - 0% - - 0/281 - 0% - - 0/281 - 0% - - 0/281 - 0% -
    sid202 - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images -
    sid302 - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images - - N/A - No images -
    - - -
    - - diff --git a/scripts/reports/index.html b/scripts/reports/index.html deleted file mode 100644 index 7eb97c303f2..00000000000 --- a/scripts/reports/index.html +++ /dev/null @@ -1,252 +0,0 @@ - - - - - - Translation Reports - Bitcoin Educational Content - - - - - - diff --git a/scripts/reports/md_translation_overview.html b/scripts/reports/md_translation_overview.html deleted file mode 100644 index 35835182c03..00000000000 --- a/scripts/reports/md_translation_overview.html +++ /dev/null @@ -1,4542 +0,0 @@ - - - - - - Markdown Translation Overview - - - -
    -
    - Back to Reports -

    📝 Markdown Translation Overview

    -
    -
    - Languages analyzed: 28 languages
    - Total courses: 39 | - Translations: 943/1092 (86.4% overall) -
    - -
    -

    Legend

    -
    - - ✓ Translated (file exists) -
    -
    - - ✗ Missing (no file) -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CourseBGCSDEENESETFAFIFRHIIDITJAKONB-NONLPLPTRNRUSISR-LATNSVSWTRVIZH-HANSZH-HANT
    biz101 - - 20,764w - - - 18,463w - - - 19,919w - - - 19,497w - - - 22,605w - - - 16,066w - - - 21,575w - - - 15,439w - - - 23,687w - - - 23,909w - - - 19,166w - - - 21,823w - - - 2,039w - - - 15,132w - - - 19,597w - - - 20,528w - - - 18,519w - - - 22,131w - - - 18,957w - - - 18,333w - - - - - 18,338w - - - 19,162w - - - 20,408w - - - 17,657w - - - 30,135w - - - 2,409w - - - 3,616w -
    biz205 - - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w - - - 15,467w -
    btc101 - - 18,120w - - - 15,637w - - - 18,050w - - - 18,279w - - - 19,432w - - - 14,079w - - - 21,104w - - - 13,300w - - - 20,067w - - - 20,692w - - - 16,678w - - - 18,409w - - - 3,921w - - - 12,755w - - - 17,730w - - - 19,123w - - - 15,876w - - - 19,165w - - - 15,721w - - - 15,626w - - - 24,770w - - - 16,083w - - - 17,438w - - - 17,222w - - - 14,787w - - - 26,026w - - - 1,949w - - - 2,154w -
    btc102 - - 30,275w - - - 26,867w - - - 29,684w - - - 28,959w - - - 32,153w - - - 24,314w - - - 32,955w - - - 22,929w - - - 33,436w - - - 36,147w - - - 28,387w - - - 31,230w - - - 2,823w - - - 22,208w - - - 30,005w - - - 31,238w - - - 26,753w - - - 31,802w - - - 27,818w - - - 26,967w - - - - - 27,045w - - - 29,604w - - - 29,070w - - - 25,080w - - - 43,503w - - - 3,137w - - - 5,020w -
    btc202 - - 26,639w - - - 23,226w - - - 26,621w - - - 26,705w - - - 28,314w - - - 20,880w - - - 29,021w - - - 19,515w - - - 28,883w - - - 31,424w - - - 24,790w - - - 27,210w - - - 2,627w - - - 18,985w - - - 25,782w - - - 27,804w - - - 23,411w - - - 28,061w - - - 24,190w - - - 23,547w - - - - - 23,753w - - - 25,734w - - - 25,411w - - - 21,875w - - - 36,333w - - - 4,122w - - - 5,366w -
    btc204 - - 52,367w - - - 45,584w - - - 52,445w - - - 52,777w - - - 56,134w - - - 40,895w - - - 55,984w - - - 38,832w - - - 57,631w - - - 63,610w - - - 48,463w - - - 53,383w - - - 5,060w - - - 37,725w - - - 51,593w - - - 55,178w - - - 46,008w - - - 55,101w - - - 46,831w - - - 45,847w - - - - - 46,761w - - - 51,449w - - - 49,998w - - - 41,986w - - - 71,670w - - - 8,371w - - - 11,687w -
    btc303 - - 27,387w - - - 23,703w - - - 27,056w - - - 26,787w - - - 28,932w - - - 21,447w - - - 29,071w - - - 20,346w - - - 30,803w - - - 31,745w - - - 25,127w - - - 28,074w - - - 2,088w - - - 19,472w - - - 26,443w - - - 27,807w - - - 23,879w - - - 28,556w - - - 24,068w - - - 23,942w - - - - - 24,144w - - - 25,953w - - - 24,683w - - - 22,036w - - - 37,073w - - - 4,371w - - - 4,704w -
    btc304 - - 17,079w - - - 14,907w - - - 16,870w - - - 16,237w - - - 17,797w - - - 13,535w - - - 18,287w - - - 12,617w - - - 21,975w - - - 19,721w - - - 15,915w - - - 17,358w - - - 1,793w - - - 12,483w - - - 17,089w - - - 17,549w - - - 14,850w - - - 17,687w - - - 15,414w - - - 14,967w - - - - - 15,156w - - - 16,829w - - - 15,878w - - - 13,947w - - - 23,739w - - - 2,829w - - - 2,957w -
    csv402 - - 35,887w - - - 31,802w - - - 36,040w - - - 36,822w - - - 38,675w - - - 28,867w - - - 38,676w - - - 27,493w - - - 39,168w - - - 42,848w - - - 33,625w - - - 37,351w - - - 6,573w - - - 26,969w - - - 34,748w - - - 37,920w - - - 32,001w - - - 38,573w - - - 32,396w - - - 31,965w - - - - - 32,326w - - - 34,823w - - - 34,375w - - - 30,080w - - - 48,971w - - - 8,637w - - - 13,064w -
    csv404 - - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w - - - 14,428w -
    cyp201 - - 24,126w - - - 21,208w - - - 24,130w - - - 25,302w - - - 26,290w - - - 18,895w - - - 25,749w - - - 18,157w - - - 26,995w - - - 28,939w - - - 22,436w - - - 24,731w - - - 4,541w - - - 18,015w - - - 23,647w - - - 25,886w - - - 21,678w - - - 25,267w - - - 22,195w - - - 21,426w - - - - - 22,060w - - - 23,943w - - - 24,276w - - - 20,424w - - - 31,479w - - - 4,691w - - - 7,748w -
    cyp302 - - 36,516w - - - 32,247w - - - 36,218w - - - 37,228w - - - 39,595w - - - 29,392w - - - 39,033w - - - 28,328w - - - 40,105w - - - 44,801w - - - 34,493w - - - 37,979w - - - 6,879w - - - 27,556w - - - 35,568w - - - 37,194w - - - 33,128w - - - 38,933w - - - 34,205w - - - 32,473w - - - - - 33,756w - - - 35,407w - - - 35,926w - - - 30,968w - - - 48,572w - - - 9,173w - - - 10,281w -
    dev103 - - 19,605w - - - 17,886w - - - 20,091w - - - 19,827w - - - 20,048w - - - 17,157w - - - 21,355w - - - 16,593w - - - 21,719w - - - 23,016w - - - 18,911w - - - 19,970w - - - 8,445w - - - 16,011w - - - 20,089w - - - 20,453w - - - 18,157w - - - 20,106w - - - 17,672w - - - 18,424w - - - - - 18,423w - - - 20,100w - - - 18,433w - - - 17,074w - - - 24,264w - - - 9,239w - - - 9,420w -
    dev303 - - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w - - - 21,407w -
    eco104 - - 36,267w - - - 31,051w - - - 35,019w - - - 35,328w - - - 38,624w - - - 27,408w - - - 38,751w - - - 26,170w - - - 40,230w - - - 42,729w - - - 32,828w - - - 37,063w - - - 1,479w - - - 25,110w - - - 35,073w - - - 37,495w - - - 32,007w - - - 37,136w - - - 32,423w - - - 31,445w - - - - - 32,300w - - - 35,341w - - - 35,051w - - - 29,581w - - - 53,130w - - - 1,468w - - - 3,914w -
    eco201 - - 12,627w - - - 10,741w - - - 12,321w - - - 12,702w - - - 14,486w - - - 9,293w - - - 13,319w - - - 9,038w - - - 14,187w - - - 14,883w - - - 11,702w - - - 13,213w - - - 467w - - - 8,782w - - - 12,128w - - - 13,374w - - - 11,116w - - - 13,350w - - - 11,695w - - - 11,005w - - - - - 11,163w - - - 12,257w - - - 12,512w - - - 10,341w - - - 19,562w - - - 476w - - - 816w -
    eco203 - - 17,105w - - - 14,601w - - - 17,958w - - - 17,905w - - - 18,844w - - - 13,791w - - - 17,956w - - - 13,336w - - - 19,141w - - - 19,887w - - - 15,834w - - - 17,477w - - - 875w - - - 12,258w - - - 17,339w - - - 18,757w - - - 14,988w - - - 18,176w - - - 15,398w - - - 15,009w - - - - - 15,440w - - - 17,294w - - - 16,315w - - - 13,733w - - - 24,988w - - - 1,184w - - - 1,602w -
    eco204 - - 15,836w - - - 13,903w - - - 15,635w - - - 15,987w - - - 17,470w - - - 11,754w - - - 16,780w - - - 11,801w - - - 17,218w - - - 18,391w - - - 14,587w - - - 16,352w - - - 851w - - - 11,179w - - - 15,565w - - - 16,757w - - - 13,828w - - - 16,829w - - - 15,258w - - - 13,674w - - - - - 14,093w - - - 15,233w - - - 16,517w - - - 12,944w - - - 23,263w - - - 1,696w - - - 1,970w -
    ene101 - - - - - - - - - - - - - - - - - - 955w - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    his201 - - 35,427w - - - 30,513w - - - 35,296w - - - 35,927w - - - 38,912w - - - 27,260w - - - 38,133w - - - 26,284w - - - 40,573w - - - 41,350w - - - 33,830w - - - 36,504w - - - 2,301w - - - 24,823w - - - 34,600w - - - 37,131w - - - 31,850w - - - 37,563w - - - 32,742w - - - 30,822w - - - - - 32,668w - - - 33,950w - - - 33,722w - - - 28,842w - - - 49,282w - - - 2,331w - - - 7,858w -
    his203 - - - - - - - - - - - - - - - - - - 56,345w - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    his204 - - 15,484w - - - 13,508w - - - 15,919w - - - 15,789w - - - 16,656w - - - 12,556w - - - 16,313w - - - 12,093w - - - 16,994w - - - 18,335w - - - 14,846w - - - 15,818w - - - 898w - - - 11,106w - - - 15,511w - - - 16,788w - - - 13,657w - - - 16,099w - - - 14,384w - - - 13,808w - - - - - 14,256w - - - 15,275w - - - 14,777w - - - 12,564w - - - 22,592w - - - 1,420w - - - 2,468w -
    his205 - - 41,531w - - - 36,694w - - - 42,583w - - - 42,860w - - - 45,438w - - - 32,619w - - - 44,355w - - - 31,396w - - - 46,342w - - - 49,298w - - - 39,146w - - - 42,717w - - - 1,738w - - - 29,114w - - - 41,360w - - - 44,502w - - - 36,538w - - - 44,196w - - - 39,377w - - - 37,123w - - - - - 38,096w - - - 40,824w - - - 41,431w - - - 34,406w - - - 61,609w - - - 2,915w - - - 3,411w -
    lnp201 - - 12,444w - - - 10,598w - - - 12,151w - - - 12,419w - - - 13,077w - - - 9,514w - - - 13,939w - - - 9,045w - - - 13,591w - - - 15,170w - - - 11,401w - - - 12,487w - - - 1,275w - - - 8,925w - - - 11,998w - - - 13,102w - - - 11,088w - - - 12,656w - - - 11,210w - - - 10,767w - - - - - 11,016w - - - 12,277w - - - 11,648w - - - 10,010w - - - 16,163w - - - 1,383w - - - 3,033w -
    lnp206 - - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w - - - 760w -
    lnp404 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    min201 - - 12,606w - - - 10,691w - - - 12,291w - - - 12,769w - - - 13,948w - - - 9,259w - - - 13,343w - - - 8,722w - - - 13,965w - - - 14,551w - - - 11,235w - - - 12,860w - - - 701w - - - 9,081w - - - 11,912w - - - 13,392w - - - 10,924w - - - 13,003w - - - 11,728w - - - 10,454w - - - - - 11,272w - - - 12,018w - - - 12,078w - - - 10,211w - - - 17,911w - - - 726w - - - 2,177w -
    min306 - - - - 17,885w - - - 17,885w - - - 18,009w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w - - - 17,885w -
    net302 - - 23,868w - - - 21,220w - - - 23,422w - - - 23,323w - - - 25,797w - - - 18,674w - - - 25,565w - - - 18,130w - - - 27,470w - - - 27,834w - - - 22,035w - - - 24,822w - - - 4,006w - - - 18,172w - - - 22,475w - - - 24,212w - - - 21,314w - - - 25,215w - - - 22,239w - - - 20,883w - - - - - 21,332w - - - 22,335w - - - 23,544w - - - 20,502w - - - 32,351w - - - 6,188w - - - 6,672w -
    phi101 - - 27,252w - - - 23,294w - - - 28,471w - - - 28,992w - - - 30,576w - - - 21,475w - - - 28,550w - - - 20,739w - - - 30,975w - - - 32,298w - - - 25,152w - - - 28,898w - - - 1,319w - - - 19,154w - - - 27,187w - - - 30,433w - - - 24,046w - - - 28,880w - - - 25,520w - - - 23,643w - - - - - 24,471w - - - 27,090w - - - 26,816w - - - 22,167w - - - 42,382w - - - 1,247w - - - 2,516w -
    phi102 - - - - - - - - - - - - - - - - - - 24,222w - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    phi305 - - 9,304w - - - 8,084w - - - 9,822w - - - 9,707w - - - 10,096w - - - 7,573w - - - 9,793w - - - 7,269w - - - 10,549w - - - 11,037w - - - 8,699w - - - 9,504w - - - 399w - - - 6,215w - - - 9,628w - - - 10,264w - - - 8,189w - - - 9,742w - - - 8,296w - - - 8,202w - - - - - 8,412w - - - 9,486w - - - 8,911w - - - 7,302w - - - 13,463w - - - 683w - - - 706w -
    pos305 - - 24,248w - - - 20,809w - - - 22,882w - - - 22,901w - - - 25,537w - - - 18,300w - - - 25,346w - - - 17,192w - - - 26,445w - - - 29,835w - - - 21,368w - - - 24,185w - - - 4,112w - - - 17,447w - - - 21,873w - - - 23,614w - - - 20,552w - - - 25,015w - - - 21,126w - - - 20,804w - - - - - 21,216w - - - 22,063w - - - 22,684w - - - 19,025w - - - 30,973w - - - 5,980w - - - 5,303w -
    pro101 - - - - 19,263w - - - 21,735w - - - 20,597w - - - 24,255w - - - 17,119w - - - 23,024w - - - 16,321w - - - 25,419w - - - 25,347w - - - 19,959w - - - 23,117w - - - 3,086w - - - 16,672w - - - 20,617w - - - 22,315w - - - 19,327w - - - 23,577w - - - 20,774w - - - 19,325w - - - - - 19,318w - - - 20,315w - - - 21,632w - - - 18,383w - - - 31,436w - - - 4,872w - - - 5,261w -
    pro202 - - - - 552w - - - 594w - - - 4,877w - - - 688w - - - 505w - - - 667w - - - 476w - - - 710w - - - 707w - - - 586w - - - 645w - - - 124w - - - 510w - - - 577w - - - 613w - - - 543w - - - 674w - - - 614w - - - 558w - - - - - 554w - - - 583w - - - 647w - - - 546w - - - 896w - - - 163w - - - 166w -
    scu101 - - 6,925w - - - 5,722w - - - 6,565w - - - 6,632w - - - 7,341w - - - 5,036w - - - 7,317w - - - 4,732w - - - 7,788w - - - 8,025w - - - 6,566w - - - 7,019w - - - 509w - - - 4,983w - - - 6,489w - - - 6,923w - - - 5,980w - - - 6,878w - - - 6,669w - - - 5,727w - - - - - 5,966w - - - 6,582w - - - 6,529w - - - 5,645w - - - 9,791w - - - 528w - - - 935w -
    scu202 - - 53,897w - - - 47,743w - - - 52,195w - - - 52,739w - - - 57,447w - - - 42,432w - - - 58,605w - - - 40,300w - - - 58,723w - - - 63,065w - - - 50,619w - - - 54,781w - - - 5,746w - - - 39,826w - - - 51,669w - - - 54,092w - - - 47,901w - - - 57,374w - - - 50,501w - - - 47,926w - - - - - 48,311w - - - 51,229w - - - 52,646w - - - 45,127w - - - 76,588w - - - 9,392w - - - 10,624w -
    sid202 - - 10,319w - - - 8,806w - - - 10,105w - - - 10,484w - - - 10,892w - - - 7,823w - - - 10,990w - - - 7,466w - - - 11,357w - - - 11,773w - - - 9,374w - - - 10,531w - - - 1,309w - - - 7,265w - - - 9,840w - - - 10,775w - - - 8,728w - - - 10,819w - - - 8,828w - - - 8,890w - - - - - 9,253w - - - 9,899w - - - 9,192w - - - 8,100w - - - 13,701w - - - 1,912w - - - 2,944w -
    sid302 - - 834w - - - 755w - - - 812w - - - 801w - - - 888w - - - 652w - - - 893w - - - 614w - - - 918w - - - 967w - - - 750w - - - 873w - - - 107w - - - 624w - - - 780w - - - 843w - - - 729w - - - 861w - - - 785w - - - 743w - - - - - 723w - - - 770w - - - 871w - - - 722w - - - 1,247w - - - 199w - - - 259w -
    - - -
    - - diff --git a/scripts/reports/video_deployment_overview.html b/scripts/reports/video_deployment_overview.html deleted file mode 100644 index 7177833666d..00000000000 --- a/scripts/reports/video_deployment_overview.html +++ /dev/null @@ -1,1210 +0,0 @@ - - - - - - Video Deployment Overview - - - -
    -
    - Back to Reports -

    🎥 Video Deployment Overview

    -
    -
    - Languages analyzed: en, fr, es, it, de, ru, zh-Hant
    - Total courses: 25 | - Total videos: 424 -
    - -
    -

    Legend

    -
    - - Complete (100%) -
    -
    - - Partial (50-99%) -
    -
    - - Low (1-49%) -
    -
    - - None (0%) -
    -

    -
    - Y - YouTube only -
    -
    - P - PeerTube only -
    -
    - B - Both providers -
    -

    -
    - Special course types:
    - 📝 No-video courses (0 videos = ✅ green): btc202, btc302, cyp302, dev103, eco104, net302, phi302, pos305, scu202
    - 🇬🇧 English-only courses (non-English 0 videos = ✅ green): btc401, csv402, csv404, dev203, min306, pro202, sid202, sid302 -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CourseTotal VideosENFRESITDERUZH-HANT
    biz20517 - 17/17 - 100% -
    Y:17
    -
    - 0/17 - 0% -
    -
    -
    - 0/17 - 0% -
    -
    -
    - 0/17 - 0% -
    -
    -
    - 0/17 - 0% -
    -
    -
    - 0/17 - 0% -
    -
    -
    - 0/17 - 0% -
    -
    -
    btc10123 - 0/23 - 0% -
    -
    -
    - 23/23 - 100% -
    Y:23
    -
    - 0/23 - 0% -
    -
    -
    - 0/23 - 0% -
    -
    -
    - 0/23 - 0% -
    -
    -
    - 0/23 - 0% -
    -
    -
    - 0/23 - 0% -
    -
    -
    btc10215 - 15/15 - 100% -
    P:15
    -
    - 0/15 - 0% -
    -
    -
    - 14/15 - 93% -
    P:14
    -
    - 14/15 - 93% -
    P:14
    -
    - 14/15 - 93% -
    P:14
    -
    - 0/15 - 0% -
    -
    -
    - 14/15 - 93% -
    P:14
    -
    btc20427 - 27/27 - 100% -
    P:27
    -
    - 27/27 - 100% -
    Y:1 B:26
    -
    - 27/27 - 100% -
    P:27
    -
    - 25/27 - 93% -
    P:25
    -
    - 16/27 - 59% -
    P:16
    -
    - 0/27 - 0% -
    -
    -
    - 0/27 - 0% -
    -
    -
    btc30412 - 0/12 - 0% -
    -
    -
    - 12/12 - 100% -
    Y:12
    -
    - 0/12 - 0% -
    -
    -
    - 0/12 - 0% -
    -
    -
    - 0/12 - 0% -
    -
    -
    - 0/12 - 0% -
    -
    -
    - 0/12 - 0% -
    -
    -
    csv40211 - 11/11 - 100% -
    B:11
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    csv40416 - 16/16 - 100% -
    P:16
    -
    - 0/16 - 0% -
    -
    -
    - 0/16 - 0% -
    -
    -
    - 0/16 - 0% -
    -
    -
    - 0/16 - 0% -
    -
    -
    - 0/16 - 0% -
    -
    -
    - 0/16 - 0% -
    -
    -
    cyp20119 - 19/19 - 100% -
    P:19
    -
    - 19/19 - 100% -
    P:1 B:18
    -
    - 19/19 - 100% -
    P:19
    -
    - 19/19 - 100% -
    P:19
    -
    - 19/19 - 100% -
    P:19
    -
    - 0/19 - 0% -
    -
    -
    - 0/19 - 0% -
    -
    -
    dev30315 - 15/15 - 100% -
    B:15
    -
    - 0/15 - 0% -
    -
    -
    - 0/15 - 0% -
    -
    -
    - 0/15 - 0% -
    -
    -
    - 0/15 - 0% -
    -
    -
    - 0/15 - 0% -
    -
    -
    - 0/15 - 0% -
    -
    -
    eco2016 - 0/6 - 0% -
    -
    -
    - 6/6 - 100% -
    Y:6
    -
    - 0/6 - 0% -
    -
    -
    - 0/6 - 0% -
    -
    -
    - 0/6 - 0% -
    -
    -
    - 0/6 - 0% -
    -
    -
    - 0/6 - 0% -
    -
    -
    eco20322 - 22/22 - 100% -
    P:3 B:19
    -
    - 22/22 - 100% -
    Y:1 B:21
    -
    - 22/22 - 100% -
    P:3 B:19
    -
    - 22/22 - 100% -
    P:22
    -
    - 22/22 - 100% -
    P:22
    -
    - 1/22 - 5% -
    P:1
    -
    - 1/22 - 5% -
    P:1
    -
    eco20410 - 0/10 - 0% -
    -
    -
    - 10/10 - 100% -
    Y:10
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    his20119 - 14/19 - 74% -
    P:14
    -
    - 19/19 - 100% -
    Y:5 P:14
    -
    - 14/19 - 74% -
    P:14
    -
    - 14/19 - 74% -
    P:14
    -
    - 14/19 - 74% -
    P:14
    -
    - 14/19 - 74% -
    P:14
    -
    - 0/19 - 0% -
    -
    -
    his20421 - 0/21 - 0% -
    -
    -
    - 21/21 - 100% -
    B:21
    -
    - 0/21 - 0% -
    -
    -
    - 0/21 - 0% -
    -
    -
    - 0/21 - 0% -
    -
    -
    - 0/21 - 0% -
    -
    -
    - 0/21 - 0% -
    -
    -
    his20544 - 0/44 - 0% -
    -
    -
    - 44/44 - 100% -
    P:1 B:43
    -
    - 0/44 - 0% -
    -
    -
    - 0/44 - 0% -
    -
    -
    - 0/44 - 0% -
    -
    -
    - 0/44 - 0% -
    -
    -
    - 0/44 - 0% -
    -
    -
    lnp20114 - 12/14 - 86% -
    P:12
    -
    - 13/14 - 93% -
    Y:1 B:12
    -
    - 12/14 - 86% -
    P:12
    -
    - 12/14 - 86% -
    P:12
    -
    - 12/14 - 86% -
    P:12
    -
    - 12/14 - 86% -
    P:12
    -
    - 0/14 - 0% -
    -
    -
    min20114 - 0/14 - 0% -
    -
    -
    - 14/14 - 100% -
    Y:14
    -
    - 0/14 - 0% -
    -
    -
    - 0/14 - 0% -
    -
    -
    - 0/14 - 0% -
    -
    -
    - 0/14 - 0% -
    -
    -
    - 0/14 - 0% -
    -
    -
    min30618 - 18/18 - 100% -
    P:18
    -
    - 0/18 - 0% -
    -
    -
    - 0/18 - 0% -
    -
    -
    - 0/18 - 0% -
    -
    -
    - 0/18 - 0% -
    -
    -
    - 0/18 - 0% -
    -
    -
    - 0/18 - 0% -
    -
    -
    phi10125 - 25/25 - 100% -
    P:25
    -
    - 25/25 - 100% -
    B:25
    -
    - 25/25 - 100% -
    P:25
    -
    - 0/25 - 0% -
    -
    -
    - 0/25 - 0% -
    -
    -
    - 0/25 - 0% -
    -
    -
    - 0/25 - 0% -
    -
    -
    phi30510 - 0/10 - 0% -
    -
    -
    - 10/10 - 100% -
    B:10
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    - 0/10 - 0% -
    -
    -
    pos3052 - 2/2 - 100% -
    Y:2
    -
    - 0/2 - 0% -
    -
    -
    - 0/2 - 0% -
    -
    -
    - 0/2 - 0% -
    -
    -
    - 0/2 - 0% -
    -
    -
    - 0/2 - 0% -
    -
    -
    - 0/2 - 0% -
    -
    -
    pro10130 - 30/30 - 100% -
    Y:30
    -
    - 0/30 - 0% -
    -
    -
    - 0/30 - 0% -
    -
    -
    - 0/30 - 0% -
    -
    -
    - 0/30 - 0% -
    -
    -
    - 0/30 - 0% -
    -
    -
    - 0/30 - 0% -
    -
    -
    scu10116 - 9/16 - 56% -
    P:9
    -
    - 16/16 - 100% -
    Y:16
    -
    - 9/16 - 56% -
    P:9
    -
    - 9/16 - 56% -
    P:9
    -
    - 0/16 - 0% -
    -
    -
    - 0/16 - 0% -
    -
    -
    - 0/16 - 0% -
    -
    -
    sid20211 - 11/11 - 100% -
    Y:11
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    - 0/11 - 0% -
    -
    -
    sid3027 - 7/7 - 100% -
    B:7
    -
    - 0/7 - 0% -
    -
    -
    - 0/7 - 0% -
    -
    -
    - 0/7 - 0% -
    -
    -
    - 0/7 - 0% -
    -
    -
    - 0/7 - 0% -
    -
    -
    - 0/7 - 0% -
    -
    -
    - - -
    - - diff --git a/scripts/translation_builders.py b/scripts/translation_builders.py deleted file mode 100644 index ac95217400d..00000000000 --- a/scripts/translation_builders.py +++ /dev/null @@ -1,105 +0,0 @@ -import os -import io -import shutil -import yaml -import re -import time -import openai -from openai.error import RateLimitError, Timeout, APIError -from dotenv import load_dotenv -from tqdm import tqdm - - -def translate_text(prompt, temperature=0.1, model_engine="gpt-3.5-turbo"): - """ - Translates the transcript text using OpenAI's GPT API. - - Args: - prompt (str, optional): The prompt to use for text manipulation. - - Returns: - str: The translated transcript in text format. - """ - # Use the OpenAI GPT API to translate the transcript - response = openai.ChatCompletion.create( - model = model_engine, - messages=[ - {"role": "user", "content": prompt} - ], - temperature=temperature - ) - - chatgpt_output = content = response['choices'][0]['message']['content'] - - return chatgpt_output - -def process_files(): - languages = ['french', 'german', 'spanish', 'portuguese', 'italian'] - language_codes = ['fr', 'de', 'es', 'pt', 'it'] - - # Load environment variables from .env file - load_dotenv() - - # Set OpenAI key from environment variable - openai.api_key = os.getenv("OPENAI_API_KEY") - - folder_count = sum([len(d) for r, d, files in os.walk('../resources/builders')]) - - with tqdm(total=folder_count, desc="Folders") as folder_progress: - for root, dirs, files in os.walk('../resources/builders'): - with tqdm(total=len(languages), desc="Languages in a folder", leave=False) as language_progress: - for language, lang_code in zip(languages, language_codes): - file_name = f'{lang_code}.yml' - if 'en.yml' in files: - with open(os.path.join(root, 'en.yml')) as file: - data = yaml.safe_load(file) - - description = data.get('description', '') - contributors = data.get('contributors', []) - - if file_name not in files: - tmp_file = io.StringIO() # create a temporary file in memory - - if description: - # Extract description and use manipulate_text function to translate it in the {language} - prompt = f"translate the following text into {language} :\n '{description}'" - while True: - try: - translation = translate_text(prompt) - break - except RateLimitError as e: - print("Rate limit error occurred. Retrying in 5 seconds...") - time.sleep(5) - except Timeout as e: - print("Timeout error occurred. Retrying in 5 seconds...") - time.sleep(5) - except APIError as e: - print("API error occurred. Retrying in 5 seconds...") - time.sleep(5) - - # Write the translated description to the temporary file - tmp_file.write('description: |\n') - tmp_file.write(" " + '\n '.join(translation.splitlines()) + '\n' ) - - # If contributors field is not present or is not a list, create an empty list - if not isinstance(contributors, list): - contributors = [] - - # Add 'another-satoshi' to the list of contributors - contributors.append('another-satoshi') - - # Write the contributors to the temporary file - tmp_file.write('contributors:\n') - for contributor in contributors: - tmp_file.write(' - ' + contributor + '\n') - - # Save the temporary file as {language_code}.yml - with open(os.path.join(root, file_name), 'w') as file: - file.write(tmp_file.getvalue()) - tmp_file.close() - language_progress.update() - folder_progress.update() - - -# Translate all builders description -process_files() diff --git a/scripts/tutorial-related/data-creator/README.md b/scripts/tutorial-related/data-creator/README.md deleted file mode 100644 index 62049a5ef67..00000000000 --- a/scripts/tutorial-related/data-creator/README.md +++ /dev/null @@ -1,63 +0,0 @@ -Python app designed to create resources locally on your clone of the Plan ₿ Network data repo. It automatically generates and correctly places all the files and necessary metadata, following the latest guidelines from the data team. - -Supports adding: -- A new tutorial; -- A new professor profile; -- A new project. - -App maintained by Loïc Morel. - -## Features - -- Allows easy customization of the metadata for the new tutorial. -- Automatically generates `tutorial.yml`, `professor.yml`, `project.yml` and `.md` files with a new UUID. -- Creates the correct folder structure automatically. -- Saves recurring inputs (local path, language, GitHub ID, professor ID) in a JSON file so you don’t need to re-enter them next time. -- Function to search for the project, prof and tags by name and automatically return the correct `UUID` or tag. -- A modern GUI built with CustomTkinter. - -I have integrated the `appdirs` library to manage user-specific configuration. In practice, the JSON settings file (which stores recurring inputs like your local path, language, GitHub ID, and professor ID) is saved in a directory dedicated to the user, outside of the repository clone (for example, under `%LOCALAPPDATA%` or `%APPDATA%` on Windows, and in `~/.config/` on Linux). This ensures that even if you execute the script directly from your repository clone, your personal configuration is not tracked by Git. And you will also be able to get my script updates directly by updating your clone. - -## Requirements - -- Python 3.8+ -- Install the dependencies : - -```bash -pip install -r requirements.txt -``` - -### Debian quick-start - -1. Ensure you have **Python 3.8 +** installed. -2. From the *data-creator* folder, run the installation script: - -```bash -chmod +x install.sh -./install.sh -``` - -This script will install the system package python3-tk (required by Tkinter/CustomTkinter), upgrade pip, install all Python dependencies from requirements.txt. - -Then launch the app: -```bash -chmod +x run.sh -./run.sh -``` - -## How to Use - -1. Go to the script's directory. -2. Run the script: - -```bash -python3 main.py -``` - -3. Fill in the required fields on the homepage, then click on a button corresponding to the resource you want to create. - -4. Fill in the specific fields requested for your resource, then click the "Create..." button to generate the necessary files and structure. - -For projects and professors, there’s nothing else to do. Everything is created by the app, so you can proceed with a PR. For tutorials, only the document templates are created. You will need to write the tutorial and place the images in the appropriate folder before making the PR. - -Upon closing, the software automatically saves your recurring inputs (local path, language, GitHub ID, professor ID) in a JSON file. \ No newline at end of file diff --git a/scripts/tutorial-related/data-creator/config.py b/scripts/tutorial-related/data-creator/config.py deleted file mode 100644 index 22a639c9a5a..00000000000 --- a/scripts/tutorial-related/data-creator/config.py +++ /dev/null @@ -1,34 +0,0 @@ -import os -import json -from appdirs import user_config_dir -from utils.path_utils import normalize_base_path - - -APP_NAME = "Tutorial Creator GUI" -APP_AUTHOR = "Plan B Network" -CONFIG_DIR = user_config_dir(APP_NAME, APP_AUTHOR) -os.makedirs(CONFIG_DIR, exist_ok=True) -SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json") - -def load_settings(): - """Load user settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): - with open(SETTINGS_FILE, "r", encoding="utf-8") as f: - settings = json.load(f) - else: - settings = {} - settings.setdefault("base_path", "") - settings.setdefault("language_option", 1) - settings.setdefault("language", "") - settings.setdefault("contributor_id", "") - settings.setdefault("professor_id", "") - settings.setdefault("theme", "Light") - settings.setdefault("window_width", 768) - settings.setdefault("window_height", 576) - settings["base_path"] = normalize_base_path(settings.get("base_path", "")) - return settings - -def save_settings(settings): - """Save user settings to JSON file.""" - with open(SETTINGS_FILE, "w", encoding="utf-8") as f: - json.dump(settings, f, ensure_ascii=False, indent=4) diff --git a/scripts/tutorial-related/data-creator/favicon.ico b/scripts/tutorial-related/data-creator/favicon.ico deleted file mode 100644 index a89b29a36f7..00000000000 Binary files a/scripts/tutorial-related/data-creator/favicon.ico and /dev/null differ diff --git a/scripts/tutorial-related/data-creator/gui/__init__.py b/scripts/tutorial-related/data-creator/gui/__init__.py deleted file mode 100644 index b53ec981687..00000000000 --- a/scripts/tutorial-related/data-creator/gui/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# gui/__init__.py -# contains the GUI pages. diff --git a/scripts/tutorial-related/data-creator/gui/footer.py b/scripts/tutorial-related/data-creator/gui/footer.py deleted file mode 100644 index 6623db49e3a..00000000000 --- a/scripts/tutorial-related/data-creator/gui/footer.py +++ /dev/null @@ -1,10 +0,0 @@ -# gui/footer.py -import customtkinter as ctk -from PIL import Image - -def create_footer(parent): - logo = Image.open("./pbn_logo.png") - logo_image = ctk.CTkImage(light_image=logo, size=(200, 36)) - footer = ctk.CTkLabel(parent, text="", image=logo_image, compound="left") - footer.grid(row=999, column=0, columnspan=parent.grid_size()[0], sticky="s", pady=10) - return footer diff --git a/scripts/tutorial-related/data-creator/gui/home.py b/scripts/tutorial-related/data-creator/gui/home.py deleted file mode 100644 index 805204c8e61..00000000000 --- a/scripts/tutorial-related/data-creator/gui/home.py +++ /dev/null @@ -1,399 +0,0 @@ -import customtkinter as ctk -from tkinter import filedialog, messagebox - -from gui.footer import create_footer -from gui.tutorial_page import TutorialPage -from gui.professor_page import ProfessorPage -from gui.project_page import ProjectPage - -from utils.constants import MAIN_LANGUAGE_OPTIONS, OTHER_LANGUAGE_OPTIONS -from utils.data_loader import load_all_professors - -from utils.path_utils import normalize_base_path -from utils.repo_validator import validate_repo_root - - -class HomePage(ctk.CTkFrame): - def __init__(self, parent, settings): - super().__init__(parent) - self.parent = parent - self.settings = settings - self.repo_ok = False - self.last_validation = {"ok": False, "path": "", "issues": ["No repository path selected."]} - - # Rows: 0..8 (we added a status row) - for i in range(9): - self.grid_rowconfigure(i, weight=1) - for j in range(3): - self.grid_columnconfigure(j, weight=1) - - # Row 0: Local repository path - self.base_path_var = ctk.StringVar(value=self.settings.get("base_path", "")) - - ctk.CTkLabel( - self, - text="Local repository path:", - font=("Arial", 14) - ).grid(row=0, column=0, padx=10, pady=5, sticky="w") - - self.base_path_entry = ctk.CTkEntry( - self, - textvariable=self.base_path_var, - width=320 - ) - self.base_path_entry.grid(row=0, column=1, padx=10, pady=5, sticky="ew") - - # Normalize on Enter / when leaving the field (no cursor jump while typing) - self.base_path_entry.bind("", lambda _e: self.commit_base_path()) - self.base_path_entry.bind("", lambda _e: self.commit_base_path()) - - ctk.CTkButton( - self, - text="Browse", - command=self.select_base_path, - width=120, - height=40, - font=("Arial", 14, "bold") - ).grid(row=0, column=2, padx=10, pady=5) - - # Row 1: Blocking repo status message (hidden when OK) - self.repo_status_frame = ctk.CTkFrame(self, corner_radius=10) - self.repo_status_frame.grid(row=1, column=0, columnspan=3, padx=10, pady=(0, 10), sticky="ew") - - self.repo_status_title = ctk.CTkLabel( - self.repo_status_frame, - text="Repository check failed", - font=("Arial", 14, "bold"), - anchor="w", - justify="left" - ) - self.repo_status_title.grid(row=0, column=0, padx=12, pady=(10, 2), sticky="ew") - - self.repo_status_details = ctk.CTkLabel( - self.repo_status_frame, - text="", - font=("Arial", 12), - anchor="w", - justify="left", - wraplength=800 - ) - self.repo_status_details.grid(row=1, column=0, padx=12, pady=(0, 10), sticky="ew") - - self.repo_status_frame.grid_columnconfigure(0, weight=1) - - # Row 2: Language option (Main vs Other) - self.language_option_var = ctk.IntVar(value=self.settings.get("language_option", 1)) - self.language_option_var.trace_add("write", lambda *args: self.update_language_options()) - - ctk.CTkLabel( - self, - text="Language Option:", - font=("Arial", 14) - ).grid(row=2, column=0, padx=10, pady=5, sticky="w") - - language_frame = ctk.CTkFrame(self, fg_color="transparent") - language_frame.grid(row=2, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - - ctk.CTkRadioButton( - language_frame, - text="Main Languages", - variable=self.language_option_var, - value=1, - font=("Arial", 12, "bold") - ).pack(side="left", padx=5) - - ctk.CTkRadioButton( - language_frame, - text="Other Languages", - variable=self.language_option_var, - value=2, - font=("Arial", 12, "bold") - ).pack(side="left", padx=5) - - # Row 3: Language selection dropdown - self.language_var = ctk.StringVar(value=self.settings.get("language", MAIN_LANGUAGE_OPTIONS[0])) - ctk.CTkLabel( - self, - text="Language Selection:", - font=("Arial", 14) - ).grid(row=3, column=0, padx=10, pady=5, sticky="w") - - self.language_menu = ctk.CTkOptionMenu( - self, - values=MAIN_LANGUAGE_OPTIONS, - variable=self.language_var, - width=320, - font=("Arial", 14, "bold") - ) - self.language_menu.grid(row=3, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - self.update_language_options() - - # Row 4: Contributor's GitHub ID - self.contributor_id_var = ctk.StringVar(value=self.settings.get("contributor_id", "")) - ctk.CTkLabel( - self, - text="Contributor's GitHub ID:", - font=("Arial", 14) - ).grid(row=4, column=0, padx=10, pady=5, sticky="w") - - self.contributor_entry = ctk.CTkEntry( - self, - textvariable=self.contributor_id_var, - width=320, - font=("Arial", 14, "bold") - ) - self.contributor_entry.grid(row=4, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - - # Row 5: Professor Name - self.professor_search_var = ctk.StringVar(value=self.settings.get("professor_name", "")) - - ctk.CTkLabel( - self, - text="Professor Name:", - font=("Arial", 14) - ).grid(row=5, column=0, padx=10, pady=5, sticky="w") - - self.professor_search_entry = ctk.CTkEntry( - self, - textvariable=self.professor_search_var, - width=100, - font=("Arial", 14, "bold"), - placeholder_text="Search by name" - ) - self.professor_search_entry.grid(row=5, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - self.professor_search_entry.bind("", self.update_professor_suggestions) - - ctk.CTkLabel( - self, - text="Prof. Suggestions:", - font=("Arial", 14) - ).grid(row=6, column=0, padx=10, pady=5, sticky="w") - - self.professor_suggestions_menu = ctk.CTkOptionMenu( - self, - values=[], - command=self.on_professor_selected, - width=180, - font=("Arial", 14, "bold") - ) - self.professor_suggestions_menu.grid(row=6, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - - # Row 7: Professor’s UUID - self.professor_id_var = ctk.StringVar(value=self.settings.get("professor_id", "")) - - ctk.CTkLabel( - self, - text="Professor's UUID:", - font=("Arial", 14) - ).grid(row=7, column=0, padx=10, pady=5, sticky="w") - - self.professor_id_entry = ctk.CTkEntry( - self, - textvariable=self.professor_id_var, - width=320, - font=("Arial", 14, "bold") - ) - self.professor_id_entry.grid(row=7, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - - # Row 8: Control buttons - button_frame = ctk.CTkFrame(self) - button_frame.grid(row=8, column=0, columnspan=3, padx=10, pady=20, sticky="nsew") - button_frame.grid_rowconfigure(0, weight=1) - for j in range(3): - button_frame.grid_columnconfigure(j, weight=1) - - self.tutorial_button = ctk.CTkButton( - button_frame, - text="New Tutorial", - command=self.open_tutorial_page, - width=220, - height=42, - font=("Arial", 16, "bold") - ) - self.tutorial_button.grid(row=0, column=0, padx=20, pady=20) - - self.professor_button = ctk.CTkButton( - button_frame, - text="New Professor", - command=self.open_professor_page, - width=220, - height=42, - font=("Arial", 16, "bold") - ) - self.professor_button.grid(row=0, column=1, padx=20, pady=20) - - self.project_button = ctk.CTkButton( - button_frame, - text="New Project", - command=self.open_project_page, - width=220, - height=42, - font=("Arial", 16, "bold") - ) - self.project_button.grid(row=0, column=2, padx=20, pady=20) - - create_footer(self) - - # Set this page as the active page - self.master.active_page = self - - # Live validation while user edits (non-destructive) - self.base_path_var.trace_add("write", lambda *_: self.refresh_repo_status(live=True)) - - # First normalization + validation - self.commit_base_path() - self.refresh_repo_status(live=False) - self.update_professor_suggestions() - - def update_language_options(self): - if self.language_option_var.get() == 1: - self.language_menu.configure(values=MAIN_LANGUAGE_OPTIONS) - if self.language_var.get() not in MAIN_LANGUAGE_OPTIONS: - self.language_var.set(MAIN_LANGUAGE_OPTIONS[0]) - else: - self.language_menu.configure(values=OTHER_LANGUAGE_OPTIONS) - if self.language_var.get() not in OTHER_LANGUAGE_OPTIONS: - self.language_var.set(OTHER_LANGUAGE_OPTIONS[0]) - - def select_base_path(self): - path = filedialog.askdirectory() - if path: - # Browse should work as before; we simply normalize afterward. - self.base_path_var.set(path) - self.commit_base_path() - - def commit_base_path(self): - """ - Normalize and persist base_path without breaking Browse. - Triggered on Enter / FocusOut, and after Browse. - """ - raw = self.base_path_var.get() - normalized = normalize_base_path(raw) - - if normalized != raw: - # Setting var will trigger refresh_repo_status() via trace_add - self.base_path_var.set(normalized) - - self.settings["base_path"] = normalized - - def refresh_repo_status(self, live: bool = False): - """ - Validate repository structure and update UI. - """ - status = validate_repo_root(self.base_path_var.get()) - self.last_validation = status - self.repo_ok = bool(status.get("ok")) - - if self.repo_ok: - # Hide blocking message - self.repo_status_frame.grid_remove() - else: - # Show blocking message - self.repo_status_frame.grid() - path = status.get("path", "") or self.base_path_var.get().strip() - issues = status.get("issues", []) - - details = ( - "The app could not detect a valid repository root.\n" - "Please select the cloned repository root folder (it must contain docs/, tutorials/, professors/ and resources/).\n\n" - f"Selected path: {path}\n\n" - "Detected issues:\n" - + "\n".join([f"- {i}" for i in issues]) - ) - self.repo_status_details.configure(text=details) - - # Disable / enable creation buttons - state = "normal" if self.repo_ok else "disabled" - self.tutorial_button.configure(state=state) - self.professor_button.configure(state=state) - self.project_button.configure(state=state) - - def update_settings(self): - """ - Updates the global settings with current page values. - Ensures base_path is normalized before saving. - """ - self.commit_base_path() - self.settings["base_path"] = self.base_path_var.get() - self.settings["language_option"] = self.language_option_var.get() - self.settings["language"] = self.language_var.get() - self.settings["contributor_id"] = self.contributor_id_var.get() - self.settings["professor_id"] = self.professor_id_var.get() - self.settings["professor_name"] = self.professor_search_var.get() - - def ensure_repo_ready_or_block(self) -> bool: - """ - Hard block navigation if repo is not valid. - """ - self.refresh_repo_status(live=False) - if self.repo_ok: - return True - - path = self.last_validation.get("path", "") or self.base_path_var.get().strip() - issues = self.last_validation.get("issues", []) - - msg = ( - "Cannot continue: invalid repository path.\n\n" - f"Selected path: {path}\n\n" - "Please select the repository root folder.\n\n" - "Detected issues:\n" + "\n".join([f"- {i}" for i in issues]) - ) - messagebox.showerror("Repository Error", msg) - return False - - def open_tutorial_page(self): - self.update_settings() - if not self.ensure_repo_ready_or_block(): - return - self.destroy() - tutorial_page = TutorialPage(self.parent, self.settings) - tutorial_page.pack(fill="both", expand=True) - self.master.active_page = tutorial_page - - def open_professor_page(self): - self.update_settings() - if not self.ensure_repo_ready_or_block(): - return - self.destroy() - professor_page = ProfessorPage(self.parent, self.settings) - professor_page.pack(fill="both", expand=True) - self.master.active_page = professor_page - - def open_project_page(self): - self.update_settings() - if not self.ensure_repo_ready_or_block(): - return - self.destroy() - project_page = ProjectPage(self.parent, self.settings) - project_page.pack(fill="both", expand=True) - self.master.active_page = project_page - - def update_professor_suggestions(self, event=None): - # If repo is invalid, keep UX explicit - if not self.repo_ok: - self.professor_suggestions_menu.configure(values=["Repository not ready"]) - self.professor_suggestions_menu.set("Repository not ready") - return - - search_text = self.professor_search_var.get().lower() - professors_mapping = load_all_professors(self.base_path_var.get()) - - if not search_text: - suggestions = list(professors_mapping.keys()) - else: - suggestions = [name for name in professors_mapping.keys() if search_text in name.lower()] - - if suggestions: - self.professor_suggestions_menu.configure(values=suggestions) - self.professor_suggestions_menu.set(suggestions[0]) - else: - self.professor_suggestions_menu.configure(values=["No match"]) - self.professor_suggestions_menu.set("No match") - - def on_professor_selected(self, selected_name): - if not self.repo_ok: - return - professors_mapping = load_all_professors(self.base_path_var.get()) - if selected_name in professors_mapping: - self.professor_id_var.set(professors_mapping[selected_name]) - self.professor_search_var.set(selected_name) diff --git a/scripts/tutorial-related/data-creator/gui/professor_page.py b/scripts/tutorial-related/data-creator/gui/professor_page.py deleted file mode 100644 index 92632d87980..00000000000 --- a/scripts/tutorial-related/data-creator/gui/professor_page.py +++ /dev/null @@ -1,301 +0,0 @@ - -import os -import re -import uuid -import random -import customtkinter as ctk -from tkinter import messagebox, filedialog -from PIL import Image -from gui.footer import create_footer -from utils.data_loader import load_allowed_tags -from utils.file_ops import ( - create_directory, - write_file, - process_profile_image, - create_professor_yaml, - create_language_yaml -) - -class ProfessorPage(ctk.CTkFrame): - def __init__(self, parent, settings): - super().__init__(parent) - self.parent = parent - self.settings = settings - self.base_path = self.settings.get("base_path", "") - - # Set this page as the active page in the master - self.master.active_page = self - - # Retrieve any previously saved professor data - professor_data = self.settings.get("professor_data", {}) - - for i in range(17): - self.grid_rowconfigure(i, weight=1) - for j in range(4): - self.grid_columnconfigure(j, weight=1) - - row = 0 - ctk.CTkLabel(self, text="New Professor Creation", font=("Arial", 20, "bold")).grid(row=row, column=0, columnspan=4, pady=10) - row += 1 - - # Folder name input - ctk.CTkLabel(self, text="Folder name:", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.folder_name_var = ctk.StringVar(value=professor_data.get("folder_name", "")) - ctk.CTkEntry(self, textvariable=self.folder_name_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=1, padx=10, pady=5, sticky="ew") - row += 1 - - # First and last name inputs - ctk.CTkLabel(self, text="First Name:", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.first_name_var = ctk.StringVar(value=professor_data.get("first_name", "")) - ctk.CTkEntry(self, textvariable=self.first_name_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=1, padx=10, pady=5, sticky="ew") - ctk.CTkLabel(self, text="Last Name (optional):", font=("Arial", 14)).grid(row=row, column=2, sticky="w", padx=10) - self.last_name_var = ctk.StringVar(value=professor_data.get("last_name", "")) - ctk.CTkEntry(self, textvariable=self.last_name_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=3, padx=10, pady=5, sticky="ew") - row += 1 - - # Website / Twitter - ctk.CTkLabel(self, text="Website (optional):", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.website_var = ctk.StringVar(value=professor_data.get("website", "")) - ctk.CTkEntry(self, textvariable=self.website_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=1, padx=10, pady=5, sticky="ew") - ctk.CTkLabel(self, text="Twitter / X (optional):", font=("Arial", 14)).grid(row=row, column=2, sticky="w", padx=10) - self.twitter_var = ctk.StringVar(value=professor_data.get("twitter", "")) - ctk.CTkEntry(self, textvariable=self.twitter_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=3, padx=10, pady=5, sticky="ew") - row += 1 - - # Nostr / LinkedIn - ctk.CTkLabel(self, text="Nostr (optional):", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.nostr_var = ctk.StringVar(value=professor_data.get("nostr", "")) - ctk.CTkEntry(self, textvariable=self.nostr_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=1, padx=10, pady=5, sticky="ew") - ctk.CTkLabel(self, text="LinkedIn (optional):", font=("Arial", 14)).grid(row=row, column=2, sticky="w", padx=10) - self.linkedin_var = ctk.StringVar(value=professor_data.get("linkedin", "")) - ctk.CTkEntry(self, textvariable=self.linkedin_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=3, padx=10, pady=5, sticky="ew") - row += 1 - - # GitHub - ctk.CTkLabel(self, text="GitHub (optional):", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.github_var = ctk.StringVar(value=professor_data.get("github", "")) - ctk.CTkEntry(self, textvariable=self.github_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=1, padx=10, pady=5, sticky="ew") - row += 1 - - # Optional lightning address input - ctk.CTkLabel(self, text="Lightning Address (optional):", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.lightning_var = ctk.StringVar(value=professor_data.get("lightning", "")) - ctk.CTkEntry(self, textvariable=self.lightning_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=1, padx=10, pady=5, sticky="ew") - row += 1 - - # Tags input and suggestions (minimum 2 required) - ctk.CTkLabel(self, text="Tags (min. 2):", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - tag_frame = ctk.CTkFrame(self, width=300) - tag_frame.grid(row=row, column=1, columnspan=3, padx=10, pady=5, sticky="ew") - num_tags = 3 - gap_width = 5 - tag_field_width = int((300 - (num_tags - 1) * gap_width) / num_tags * 1.8) - self.tag1_var = ctk.StringVar(value=professor_data.get("tag1", "")) - self.tag2_var = ctk.StringVar(value=professor_data.get("tag2", "")) - self.tag3_var = ctk.StringVar(value=professor_data.get("tag3", "")) - self.tag1_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag1_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag1_entry.grid(row=0, column=0, padx=(0, gap_width), sticky="ew") - self.tag1_entry.bind("", self.update_tag1_suggestions) - self.tag2_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag2_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag2_entry.grid(row=0, column=1, padx=(0, gap_width), sticky="ew") - self.tag2_entry.bind("", self.update_tag2_suggestions) - self.tag3_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag3_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag3_entry.grid(row=0, column=2, sticky="ew") - self.tag3_entry.bind("", self.update_tag3_suggestions) - row += 1 - - ctk.CTkLabel(self, text="Tag Suggestions:", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - tag_suggestion_frame = ctk.CTkFrame(self, width=300) - tag_suggestion_frame.grid(row=row, column=1, columnspan=3, padx=10, pady=5, sticky="ew") - self.tag1_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag1_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag1_suggestions_menu.grid(row=0, column=0, padx=(0, gap_width), sticky="ew") - self.tag2_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag2_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag2_suggestions_menu.grid(row=0, column=1, padx=(0, gap_width), sticky="ew") - self.tag3_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag3_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag3_suggestions_menu.grid(row=0, column=2, sticky="ew") - self.update_tag1_suggestions() - self.update_tag2_suggestions() - self.update_tag3_suggestions() - row += 1 - - # Profile image input - ctk.CTkLabel(self, text="Profile Image:", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.image_path_var = ctk.StringVar(value=professor_data.get("image_path", "")) - ctk.CTkEntry(self, textvariable=self.image_path_var, width=200, font=("Arial", 14, "bold")).grid(row=row, column=1, padx=10, pady=5, sticky="ew") - ctk.CTkButton(self, text="Select Image", command=self.select_image, width=120, font=("Arial", 14, "bold")).grid(row=row, column=2, padx=10, pady=5) - row += 1 - - # Biography and Short bio inputs - ctk.CTkLabel(self, text="Biography:", font=("Arial", 14)).grid(row=row, column=0, sticky="nw", padx=10) - self.bio_textbox = ctk.CTkTextbox(self, width=400, height=100) - self.bio_textbox.grid(row=row, column=1, columnspan=3, padx=10, pady=5, sticky="ew") - if "bio" in professor_data: - self.bio_textbox.insert("1.0", professor_data.get("bio")) - row += 1 - - ctk.CTkLabel(self, text="Short bio:", font=("Arial", 14)).grid(row=row, column=0, sticky="w", padx=10) - self.short_bio_var = ctk.StringVar(value=professor_data.get("short_bio", "")) - ctk.CTkEntry(self, textvariable=self.short_bio_var, width=400, font=("Arial", 14, "bold")).grid(row=row, column=1, columnspan=3, padx=10, pady=5, sticky="ew") - row += 1 - - button_frame = ctk.CTkFrame(self, fg_color="transparent") - button_frame.grid(row=row, column=0, columnspan=4, pady=20, sticky="ew") - ctk.CTkButton(button_frame, text="Create Professor", command=self.create_professor, font=("Arial", 14, "bold")).pack(side="left", padx=10, expand=True) - ctk.CTkButton(button_frame, text="Back", command=self.go_back, font=("Arial", 14, "bold")).pack(side="left", padx=10, expand=True) - - create_footer(self) - - - def update_tag1_suggestions(self, event=None): - allowed = load_allowed_tags(self.base_path) - text = self.tag1_var.get().lower() - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag1_suggestions_menu.configure(values=suggestions) - self.tag1_suggestions_menu.set(suggestions[0]) - else: - self.tag1_suggestions_menu.configure(values=["No match"]) - self.tag1_suggestions_menu.set("No match") - - def update_tag2_suggestions(self, event=None): - allowed = load_allowed_tags(self.base_path) - text = self.tag2_var.get().lower() - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag2_suggestions_menu.configure(values=suggestions) - self.tag2_suggestions_menu.set(suggestions[0]) - else: - self.tag2_suggestions_menu.configure(values=["No match"]) - self.tag2_suggestions_menu.set("No match") - - def update_tag3_suggestions(self, event=None): - allowed = load_allowed_tags(self.base_path) - text = self.tag3_var.get().lower() - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag3_suggestions_menu.configure(values=suggestions) - self.tag3_suggestions_menu.set(suggestions[0]) - else: - self.tag3_suggestions_menu.configure(values=["No match"]) - self.tag3_suggestions_menu.set("No match") - - def on_tag1_selected(self, selected_tag): - self.tag1_var.set(selected_tag) - - def on_tag2_selected(self, selected_tag): - self.tag2_var.set(selected_tag) - - def on_tag3_selected(self, selected_tag): - self.tag3_var.set(selected_tag) - - def update_local_state(self): - self.settings["professor_data"] = { - "folder_name": self.folder_name_var.get(), - "first_name": self.first_name_var.get(), - "last_name": self.last_name_var.get(), - "website": self.website_var.get(), - "twitter": self.twitter_var.get(), - "nostr": self.nostr_var.get(), - "linkedin": self.linkedin_var.get(), - "github": self.github_var.get(), - "lightning": self.lightning_var.get(), - "tag1": self.tag1_var.get(), - "tag2": self.tag2_var.get(), - "tag3": self.tag3_var.get(), - "image_path": self.image_path_var.get(), - "bio": self.bio_textbox.get("1.0", "end").strip(), - "short_bio": self.short_bio_var.get() - } - - def select_image(self): - file_path = filedialog.askopenfilename( - title="Select Profile Image", - filetypes=[("Image Files", "*.png;*.jpg;*.jpeg;*.webp")] - ) - if file_path: - self.image_path_var.set(file_path) - - def create_professor(self): - folder_name = self.folder_name_var.get().strip() - if not folder_name or not re.match(r'^[a-z0-9\-]+$', folder_name): - messagebox.showerror("Error", "Invalid folder name. Use only lowercase letters, digits, and dashes.") - return - - if not self.first_name_var.get().strip(): - messagebox.showerror("Error", "Please enter a first name or a pseudo.") - return - - tags = [t.strip() for t in [self.tag1_var.get(), self.tag2_var.get(), self.tag3_var.get()] if t.strip()] - if len(tags) < 2: - messagebox.showerror("Error", "Please enter at least two tags.") - return - allowed_tags = load_allowed_tags(self.base_path) - for tag in tags: - if tag not in allowed_tags: - messagebox.showerror("Error", f"Tag '{tag}' is not valid. Please select a valid tag from the suggestions.") - return - - if not self.image_path_var.get().strip(): - messagebox.showerror("Error", "Please select a profile image.") - return - - if not self.bio_textbox.get("1.0", "end").strip(): - messagebox.showerror("Error", "Please enter a biography for the professor.") - return - - professors_dir = os.path.join(self.base_path, "professors") - create_directory(professors_dir) - new_folder = os.path.join(professors_dir, folder_name) - if os.path.exists(new_folder): - counter = 2 - while os.path.exists(f"{new_folder}-{counter}"): - counter += 1 - folder_name = f"{folder_name}-{counter}" - new_folder = os.path.join(professors_dir, folder_name) - - try: - create_directory(new_folder) - assets_dir = os.path.join(new_folder, "assets") - create_directory(assets_dir) - - image_path = self.image_path_var.get().strip() - if image_path and os.path.exists(image_path): - dest_image = os.path.join(assets_dir, "profile.webp") - process_profile_image(image_path, dest_image) - - first_name = self.first_name_var.get().strip() - last_name = self.last_name_var.get().strip() - full_name = first_name if not last_name else f"{first_name} {last_name}" - - yaml_content = create_professor_yaml( - full_name, - website=self.website_var.get().strip(), - twitter=self.twitter_var.get().strip(), - lightning=self.lightning_var.get().strip(), - tags=tags, - nostr=self.nostr_var.get().strip(), - linkedin=self.linkedin_var.get().strip(), - github=self.github_var.get().strip() - ) - yaml_file_path = os.path.join(new_folder, "professor.yml") - write_file(yaml_file_path, yaml_content) - - language = self.settings.get("language", "en").split(" ")[0] - lang_yaml_content = create_language_yaml( - language, - self.bio_textbox.get("1.0", "end").strip(), - self.short_bio_var.get().strip() - ) - lang_yaml_path = os.path.join(new_folder, f"{language}.yml") - write_file(lang_yaml_path, lang_yaml_content) - - messagebox.showinfo("Success", f"Professor created successfully in:\n{new_folder}") - except Exception as e: - messagebox.showerror("Error", f"An error occurred: {e}") - - def go_back(self): - self.update_local_state() - from gui.home import HomePage - self.destroy() - home_page = HomePage(self.parent, self.settings) - home_page.pack(fill="both", expand=True) diff --git a/scripts/tutorial-related/data-creator/gui/project_page.py b/scripts/tutorial-related/data-creator/gui/project_page.py deleted file mode 100644 index bbe2322281c..00000000000 --- a/scripts/tutorial-related/data-creator/gui/project_page.py +++ /dev/null @@ -1,363 +0,0 @@ - -import os -import re -import uuid -import datetime -import customtkinter as ctk -from tkinter import messagebox, filedialog -from gui.footer import create_footer -from utils.data_loader import load_allowed_tags, load_all_builders -from utils.file_ops import ( - create_directory, - write_file, - process_profile_image, - create_project_yaml, - create_project_language_yaml -) -from utils.constants import PROJECT_CATEGORIES, LICENSE_OPTIONS, DEFAULT_LICENSE - -class ProjectPage(ctk.CTkFrame): - def __init__(self, parent, settings): - super().__init__(parent) - self.parent = parent - self.settings = settings - self.base_path = settings.get("base_path", "") - - project_data = self.settings.get("project_data", {}) - - self.license_var = ctk.StringVar(value=project_data.get("license", DEFAULT_LICENSE)) - if self.license_var.get() not in LICENSE_OPTIONS: - self.license_var.set(DEFAULT_LICENSE) - - self.master.active_page = self - - # Configure grid layout - for i in range(16): - self.grid_rowconfigure(i, weight=1) - for j in range(4): - self.grid_columnconfigure(j, weight=1) - - row = 0 - ctk.CTkLabel(self, text="New Project Creation", font=("Arial", 20, "bold")).grid(row=row, column=0, columnspan=4, pady=10) - row += 1 - - # Folder name input - ctk.CTkLabel(self, text="Folder name:", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.folder_name_var = ctk.StringVar(value=project_data.get("folder_name", "")) - ctk.CTkEntry(self, textvariable=self.folder_name_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=1, padx=10, pady=5, sticky="ew" - ) - row += 1 - - # Project name input (required) - ctk.CTkLabel(self, text="Project Name:", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.project_name_var = ctk.StringVar(value=project_data.get("project_name", "")) - ctk.CTkEntry(self, textvariable=self.project_name_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=1, padx=10, pady=5, sticky="ew" - ) - row += 1 - - # Website / Twitter - ctk.CTkLabel(self, text="Website (optional):", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.website_var = ctk.StringVar(value=project_data.get("website", "")) - ctk.CTkEntry(self, textvariable=self.website_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=1, padx=10, pady=5, sticky="ew" - ) - ctk.CTkLabel(self, text="Twitter / X (optional):", font=("Arial", 14)).grid( - row=row, column=2, sticky="w", padx=10 - ) - self.twitter_var = ctk.StringVar(value=project_data.get("twitter", "")) - ctk.CTkEntry(self, textvariable=self.twitter_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=3, padx=10, pady=5, sticky="ew" - ) - row += 1 - - # Nostr / LinkedIn - ctk.CTkLabel(self, text="Nostr (optional):", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.nostr_var = ctk.StringVar(value=project_data.get("nostr", "")) - ctk.CTkEntry(self, textvariable=self.nostr_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=1, padx=10, pady=5, sticky="ew" - ) - ctk.CTkLabel(self, text="LinkedIn (optional):", font=("Arial", 14)).grid( - row=row, column=2, sticky="w", padx=10 - ) - self.linkedin_var = ctk.StringVar(value=project_data.get("linkedin", "")) - ctk.CTkEntry(self, textvariable=self.linkedin_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=3, padx=10, pady=5, sticky="ew" - ) - row += 1 - - # GitHub - ctk.CTkLabel(self, text="GitHub (optional):", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.github_var = ctk.StringVar(value=project_data.get("github", "")) - ctk.CTkEntry(self, textvariable=self.github_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=1, padx=10, pady=5, sticky="ew" - ) - row += 1 - - # Category dropdown - ctk.CTkLabel(self, text="Category:", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.category_var = ctk.StringVar(value=project_data.get("category", PROJECT_CATEGORIES[0])) - self.category_menu = ctk.CTkOptionMenu(self, values=PROJECT_CATEGORIES, variable=self.category_var, width=200, font=("Arial", 14, "bold")) - self.category_menu.grid(row=row, column=1, padx=10, pady=5, sticky="ew") - row += 1 - - # Licence dropdown (small) - ctk.CTkLabel(self, text="Licence:", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.license_menu = ctk.CTkOptionMenu( - self, values=LICENSE_OPTIONS, variable=self.license_var, - width=180, font=("Arial", 14, "bold") - ) - self.license_menu.grid(row=row, column=1, padx=10, pady=5, sticky="w") - row += 1 - - # Tags input and suggestions (minimum 2 tags required) - ctk.CTkLabel(self, text="Tags (min. 2):", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - tag_frame = ctk.CTkFrame(self, width=300) - tag_frame.grid(row=row, column=1, columnspan=3, padx=10, pady=5, sticky="ew") - num_tags = 3 - gap_width = 5 - tag_field_width = int((300 - (num_tags - 1) * gap_width) / num_tags * 1.8) - self.tag1_var = ctk.StringVar(value=project_data.get("tag1", "")) - self.tag2_var = ctk.StringVar(value=project_data.get("tag2", "")) - self.tag3_var = ctk.StringVar(value=project_data.get("tag3", "")) - self.tag1_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag1_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag1_entry.grid(row=0, column=0, padx=(0, gap_width), sticky="ew") - self.tag1_entry.bind("", self.update_tag1_suggestions) - self.tag2_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag2_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag2_entry.grid(row=0, column=1, padx=(0, gap_width), sticky="ew") - self.tag2_entry.bind("", self.update_tag2_suggestions) - self.tag3_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag3_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag3_entry.grid(row=0, column=2, sticky="ew") - self.tag3_entry.bind("", self.update_tag3_suggestions) - row += 1 - - ctk.CTkLabel(self, text="Tag Suggestions:", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - tag_suggestion_frame = ctk.CTkFrame(self, width=300) - tag_suggestion_frame.grid(row=row, column=1, columnspan=3, padx=10, pady=5, sticky="ew") - self.tag1_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag1_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag1_suggestions_menu.grid(row=0, column=0, padx=(0, gap_width), sticky="ew") - self.tag2_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag2_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag2_suggestions_menu.grid(row=0, column=1, padx=(0, gap_width), sticky="ew") - self.tag3_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag3_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag3_suggestions_menu.grid(row=0, column=2, sticky="ew") - self.update_tag1_suggestions() - self.update_tag2_suggestions() - self.update_tag3_suggestions() - row += 1 - - # Logo image input - ctk.CTkLabel(self, text="Logo Image:", font=("Arial", 14)).grid( - row=row, column=0, sticky="w", padx=10 - ) - self.image_path_var = ctk.StringVar(value=project_data.get("image_path", "")) - ctk.CTkEntry(self, textvariable=self.image_path_var, width=200, font=("Arial", 14, "bold")).grid( - row=row, column=1, padx=10, pady=5, sticky="ew" - ) - ctk.CTkButton(self, text="Select Image", command=self.select_image, width=120, font=("Arial", 14, "bold")).grid( - row=row, column=2, padx=10, pady=5 - ) - row += 1 - - # Description input (multiline) - ctk.CTkLabel(self, text="Description:", font=("Arial", 14)).grid( - row=row, column=0, sticky="nw", padx=10 - ) - self.description_textbox = ctk.CTkTextbox(self, width=400, height=100) - self.description_textbox.grid(row=row, column=1, columnspan=3, padx=10, pady=5, sticky="ew") - if "description" in project_data: - self.description_textbox.insert("1.0", project_data.get("description")) - row += 1 - - # Buttons for creating project and going back - button_frame = ctk.CTkFrame(self, fg_color="transparent") - button_frame.grid(row=row, column=0, columnspan=4, pady=20, sticky="ew") - ctk.CTkButton(button_frame, text="Create Project", command=self.create_project, font=("Arial", 14, "bold")).pack(side="left", padx=10, expand=True) - ctk.CTkButton(button_frame, text="Back", command=self.go_back, font=("Arial", 14, "bold")).pack(side="left", padx=10, expand=True) - - create_footer(self) - - def update_tag1_suggestions(self, event=None): - allowed = load_allowed_tags(self.base_path) - text = self.tag1_var.get().lower() - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag1_suggestions_menu.configure(values=suggestions) - self.tag1_suggestions_menu.set(suggestions[0]) - else: - self.tag1_suggestions_menu.configure(values=["No match"]) - self.tag1_suggestions_menu.set("No match") - - def update_tag2_suggestions(self, event=None): - allowed = load_allowed_tags(self.base_path) - text = self.tag2_var.get().lower() - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag2_suggestions_menu.configure(values=suggestions) - self.tag2_suggestions_menu.set(suggestions[0]) - else: - self.tag2_suggestions_menu.configure(values=["No match"]) - self.tag2_suggestions_menu.set("No match") - - def update_tag3_suggestions(self, event=None): - allowed = load_allowed_tags(self.base_path) - text = self.tag3_var.get().lower() - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag3_suggestions_menu.configure(values=suggestions) - self.tag3_suggestions_menu.set(suggestions[0]) - else: - self.tag3_suggestions_menu.configure(values=["No match"]) - self.tag3_suggestions_menu.set("No match") - - def on_tag1_selected(self, selected_tag): - self.tag1_var.set(selected_tag) - - def on_tag2_selected(self, selected_tag): - self.tag2_var.set(selected_tag) - - def on_tag3_selected(self, selected_tag): - self.tag3_var.set(selected_tag) - - def select_image(self): - file_path = filedialog.askopenfilename( - title="Select Logo Image", - filetypes=[("Image Files", "*.png;*.jpg;*.jpeg;*.webp")] - ) - if file_path: - self.image_path_var.set(file_path) - - def create_project(self): - folder_name = self.folder_name_var.get().strip() - if not folder_name or not re.match(r'^[a-z0-9\-]+$', folder_name): - messagebox.showerror("Error", "Invalid folder name. Use only lowercase letters, digits, and dashes.") - return - - project_name = self.project_name_var.get().strip() - if not project_name: - messagebox.showerror("Error", "Please enter the project name.") - return - - projects_dir = os.path.join(self.base_path, "resources", "projects") - create_directory(projects_dir) - new_folder = os.path.join(projects_dir, folder_name) - if os.path.exists(new_folder): - counter = 2 - while os.path.exists(f"{new_folder}-{counter}"): - counter += 1 - folder_name = f"{folder_name}-{counter}" - new_folder = os.path.join(projects_dir, folder_name) - - tags = [t.strip() for t in [self.tag1_var.get(), self.tag2_var.get(), self.tag3_var.get()] if t.strip()] - if len(tags) < 2: - messagebox.showerror("Error", "Please enter at least two tags for the project.") - return - allowed_tags = load_allowed_tags(self.base_path) - for tag in tags: - if tag not in allowed_tags: - messagebox.showerror("Error", f"Tag '{tag}' is not valid. Please select a valid tag from the suggestions.") - return - - if not self.image_path_var.get().strip(): - messagebox.showerror("Error", "Please select a logo image.") - return - - description = self.description_textbox.get("1.0", "end").strip() - if not description: - messagebox.showerror("Error", "Please enter a project description.") - return - - try: - create_directory(new_folder) - assets_dir = os.path.join(new_folder, "assets") - create_directory(assets_dir) - logo_source = self.image_path_var.get().strip() - if logo_source and os.path.exists(logo_source): - dest_logo = os.path.join(assets_dir, "logo.webp") - process_profile_image(logo_source, dest_logo) - - project_uuid = str(uuid.uuid4()) - - website = self.website_var.get().strip() - twitter = self.twitter_var.get().strip() - nostr = self.nostr_var.get().strip() - linkedin = self.linkedin_var.get().strip() - github = self.github_var.get().strip() - category = self.category_var.get().strip() - language_code = self.settings.get("language", "en").split(" ")[0] - current_date = datetime.date.today().strftime("%Y-%m-%d") - global_contributor = self.settings.get("contributor_id", "").strip() - - project_yaml_content = create_project_yaml( - project_uuid, - project_name, - website, - twitter, - category, - tags, - language_code, - current_date, - global_contributor, - license=self.license_var.get(), - nostr=nostr, - linkedin=linkedin, - github=github - ) - project_yaml_path = os.path.join(new_folder, "project.yml") - write_file(project_yaml_path, project_yaml_content) - - professor_global = self.settings.get("professor_id", "").strip() - lang_yaml_content = create_project_language_yaml( - language_code, - description, - professor_global - ) - lang_yaml_path = os.path.join(new_folder, f"{language_code}.yml") - write_file(lang_yaml_path, lang_yaml_content) - - messagebox.showinfo("Success", f"Project created successfully in:\n{new_folder}") - except Exception as e: - messagebox.showerror("Error", f"An error occurred: {e}") - - def update_local_state(self): - self.settings["project_data"] = { - "folder_name": self.folder_name_var.get(), - "project_name": self.project_name_var.get(), - "website": self.website_var.get(), - "twitter": self.twitter_var.get(), - "nostr": self.nostr_var.get(), - "linkedin": self.linkedin_var.get(), - "github": self.github_var.get(), - "category": self.category_var.get(), - "license": self.license_var.get(), - "tag1": self.tag1_var.get(), - "tag2": self.tag2_var.get(), - "tag3": self.tag3_var.get(), - "image_path": self.image_path_var.get(), - "description": self.description_textbox.get("1.0", "end").strip() - } - - def go_back(self): - self.update_local_state() - from gui.home import HomePage - self.destroy() - home_page = HomePage(self.parent, self.settings) - home_page.pack(fill="both", expand=True) diff --git a/scripts/tutorial-related/data-creator/gui/tutorial_page.py b/scripts/tutorial-related/data-creator/gui/tutorial_page.py deleted file mode 100644 index b47c6365c28..00000000000 --- a/scripts/tutorial-related/data-creator/gui/tutorial_page.py +++ /dev/null @@ -1,357 +0,0 @@ -import customtkinter as ctk -from tkinter import messagebox, filedialog -from utils.constants import SECTIONS, LEVELS, LICENSE_OPTIONS, DEFAULT_LICENSE -from utils.data_loader import load_allowed_tags, load_all_builders -from utils.file_ops import create_tutorial_files -from gui.footer import create_footer - -class TutorialPage(ctk.CTkFrame): - def __init__(self, parent, settings): - super().__init__(parent) - self.parent = parent - self.settings = settings - self.base_path = self.settings.get("base_path", "") - - # Set this page as the active page - self.master.active_page = self - - # Retrieve saved tutorial data if available - tutorial_data = self.settings.get("tutorial_data", {}) - - # Initialize variables with saved values if present - self.section_var = ctk.StringVar(value=tutorial_data.get("section", "")) - self.category_var = ctk.StringVar(value=tutorial_data.get("subcategory", "")) - self.level_var = ctk.StringVar(value=tutorial_data.get("difficulty", "")) - self.tutorial_name_var = ctk.StringVar(value=tutorial_data.get("tutorial_name", "")) - self.builder_search_var = ctk.StringVar(value=tutorial_data.get("builder_search", "")) - self.project_id_var = ctk.StringVar(value=tutorial_data.get("project_id", "")) - self.credit_link_var = ctk.StringVar(value=tutorial_data.get("credit_link", "")) - self.tag1_var = ctk.StringVar(value=tutorial_data.get("tag1", "")) - self.tag2_var = ctk.StringVar(value=tutorial_data.get("tag2", "")) - self.tag3_var = ctk.StringVar(value=tutorial_data.get("tag3", "")) - self.license_var = ctk.StringVar(value=tutorial_data.get("license", DEFAULT_LICENSE)) - - # Set up grid layout - total_rows = 14 - for i in range(total_rows): - self.grid_rowconfigure(i, weight=1) - for j in range(3): - self.grid_columnconfigure(j, weight=1) - - row = 0 - ctk.CTkLabel(self, text="New Tutorial Creation", font=("Arial", 20, "bold")).grid(row=row, column=0, columnspan=4, pady=10) - row += 1 - - # Category and subcategory selection - ctk.CTkLabel(self, text="Category:").grid(row=row, column=0, padx=10, pady=5, sticky="w") - self.section_menu = ctk.CTkOptionMenu( - self, - values=list(SECTIONS.keys()), - variable=self.section_var, - command=self.update_categories, - width=300, - font=("Arial", 14, "bold") - ) - self.section_menu.grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - row += 1 - - ctk.CTkLabel(self, text="Subcategory:").grid(row=row, column=0, padx=10, pady=5, sticky="w") - self.category_menu = ctk.CTkOptionMenu(self, values=[], variable=self.category_var, width=300, font=("Arial", 14, "bold")) - self.category_menu.grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - row += 1 - - # Difficulty level selection - ctk.CTkLabel(self, text="Difficulty level:").grid(row=row, column=0, padx=10, pady=5, sticky="w") - self.level_menu = ctk.CTkOptionMenu(self, values=LEVELS, variable=self.level_var, width=300, font=("Arial", 14, "bold")) - self.level_menu.grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - row += 1 - - # Tutorial folder name entry - ctk.CTkLabel(self, text="Folder name:").grid(row=row, column=0, padx=10, pady=5, sticky="w") - ctk.CTkEntry(self, textvariable=self.tutorial_name_var, width=300, font=("Arial", 14, "bold")).grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - row += 1 - - # Project search entry - ctk.CTkLabel(self, text="Project Name (optional):").grid(row=row, column=0, padx=10, pady=5, sticky="w") - self.builder_search_entry = ctk.CTkEntry(self, textvariable=self.builder_search_var, width=300, placeholder_text="Find the project ID", font=("Arial", 14, "bold")) - self.builder_search_entry.grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - self.builder_search_entry.bind("", self.update_builder_suggestions) - row += 1 - - # Project suggestions dropdown - ctk.CTkLabel(self, text="Project Suggestions (optional):").grid(row=row, column=0, padx=10, pady=5, sticky="w") - self.builder_suggestions_menu = ctk.CTkOptionMenu(self, values=[], command=self.on_builder_selected, width=300, font=("Arial", 14, "bold")) - self.builder_suggestions_menu.grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - self.update_builder_suggestions() - row += 1 - - # Project ID entry - ctk.CTkLabel(self, text="Project ID (optional):").grid(row=row, column=0, padx=10, pady=5, sticky="w") - ctk.CTkEntry(self, textvariable=self.project_id_var, width=300, font=("Arial", 14, "bold")).grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - row += 1 - - # Credit Link entry (optional) - ctk.CTkLabel(self, text="Credit Link (optional):").grid(row=row, column=0, padx=10, pady=5, sticky="w") - ctk.CTkEntry(self, textvariable=self.credit_link_var, width=300, font=("Arial", 14, "bold"), placeholder_text="https://...").grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - row += 1 - - # License dropdown - ctk.CTkLabel(self, text="License:").grid(row=row, column=0, padx=10, pady=5, sticky="w") - self.license_menu = ctk.CTkOptionMenu( - self, values=LICENSE_OPTIONS, variable=self.license_var, - width=180, font=("Arial", 14, "bold") - ) - self.license_menu.grid(row=row, column=1, padx=10, pady=5, sticky="w") - row += 1 - - # Tags entry and suggestions with fixed width - ctk.CTkLabel(self, text="Tags (2 or 3):").grid(row=row, column=0, padx=10, pady=5, sticky="w") - tag_frame = ctk.CTkFrame(self, width=300) - tag_frame.grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - - num_tags = 3 - gap_width = 5 - tag_field_width = int((300 - (num_tags - 1) * gap_width) / num_tags * 1.8) - - self.tag1_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag1_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag1_entry.grid(row=0, column=0, padx=(0, gap_width), sticky="ew") - self.tag1_entry.bind("", self.update_tag1_suggestions) - - self.tag2_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag2_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag2_entry.grid(row=0, column=1, padx=(0, gap_width), sticky="ew") - self.tag2_entry.bind("", self.update_tag2_suggestions) - - self.tag3_entry = ctk.CTkEntry(tag_frame, textvariable=self.tag3_var, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag3_entry.grid(row=0, column=2, sticky="ew") - self.tag3_entry.bind("", self.update_tag3_suggestions) - row += 1 - - ctk.CTkLabel(self, text="Tag Suggestions:").grid(row=row, column=0, padx=10, pady=5, sticky="w") - tag_suggestion_frame = ctk.CTkFrame(self, width=300) - tag_suggestion_frame.grid(row=row, column=1, columnspan=2, padx=10, pady=5, sticky="ew") - - self.tag1_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag1_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag1_suggestions_menu.grid(row=0, column=0, padx=(0, gap_width), sticky="ew") - - self.tag2_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag2_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag2_suggestions_menu.grid(row=0, column=1, padx=(0, gap_width), sticky="ew") - - self.tag3_suggestions_menu = ctk.CTkOptionMenu(tag_suggestion_frame, values=[], command=self.on_tag3_selected, width=tag_field_width, font=("Arial", 14, "bold")) - self.tag3_suggestions_menu.grid(row=0, column=2, sticky="ew") - - self.update_tag1_suggestions() - self.update_tag2_suggestions() - self.update_tag3_suggestions() - row += 1 - - # Buttons for Create, Clear, and Back actions - button_frame = ctk.CTkFrame(self, fg_color="transparent") - button_frame.grid(row=row, column=0, columnspan=3, pady=20, sticky="ew") - - ctk.CTkButton(button_frame, text="Create Tutorial", command=self.create_tutorial, font=("Arial", 14, "bold")).pack(side="left", padx=10, expand=True) - ctk.CTkButton(button_frame, text="Clear", command=self.clear_fields, font=("Arial", 14, "bold")).pack(side="left", padx=10, expand=True) - ctk.CTkButton(button_frame, text="Back", command=self.go_back, font=("Arial", 14, "bold")).pack(side="left", padx=10, expand=True) - - create_footer(self) - - def update_categories(self, *args): - section = self.section_menu.get() - categories = SECTIONS.get(section, []) - self.category_menu.configure(values=categories) - if categories: - self.category_menu.set(categories[0]) - else: - self.category_menu.set("") - - def update_builder_suggestions(self, event=None): - search_text = self.builder_search_var.get().lower() - builders_mapping = load_all_builders(self.base_path) - suggestions = [name for name in builders_mapping.keys() if search_text in name.lower()] - if suggestions: - self.builder_suggestions_menu.configure(values=suggestions) - self.builder_suggestions_menu.set(suggestions[0]) - else: - self.builder_suggestions_menu.configure(values=["No match"]) - self.builder_suggestions_menu.set("No match") - - def on_builder_selected(self, selected_name): - builders_mapping = load_all_builders(self.base_path) - if selected_name in builders_mapping: - self.project_id_var.set(builders_mapping[selected_name]) - self.builder_search_var.set(selected_name) - - def update_tag1_suggestions(self, event=None): - text = self.tag1_var.get().lower() - allowed = load_allowed_tags(self.base_path) - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag1_suggestions_menu.configure(values=suggestions) - self.tag1_suggestions_menu.set(suggestions[0]) - else: - self.tag1_suggestions_menu.configure(values=["No match"]) - self.tag1_suggestions_menu.set("No match") - - def update_tag2_suggestions(self, event=None): - text = self.tag2_var.get().lower() - allowed = load_allowed_tags(self.base_path) - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag2_suggestions_menu.configure(values=suggestions) - self.tag2_suggestions_menu.set(suggestions[0]) - else: - self.tag2_suggestions_menu.configure(values=["No match"]) - self.tag2_suggestions_menu.set("No match") - - def update_tag3_suggestions(self, event=None): - text = self.tag3_var.get().lower() - allowed = load_allowed_tags(self.base_path) - suggestions = [t for t in allowed if text in t.lower()] - if suggestions: - self.tag3_suggestions_menu.configure(values=suggestions) - self.tag3_suggestions_menu.set(suggestions[0]) - else: - self.tag3_suggestions_menu.configure(values=["No match"]) - self.tag3_suggestions_menu.set("No match") - - def on_tag1_selected(self, selected_tag): - self.tag1_var.set(selected_tag) - - def on_tag2_selected(self, selected_tag): - self.tag2_var.set(selected_tag) - - def on_tag3_selected(self, selected_tag): - self.tag3_var.set(selected_tag) - - def update_local_state(self): - """Save current tutorial data for later reuse.""" - self.settings["tutorial_data"] = { - "section": self.section_menu.get(), - "subcategory": self.category_menu.get(), - "difficulty": self.level_menu.get(), - "tutorial_name": self.tutorial_name_var.get(), - "builder_search": self.builder_search_var.get(), - "project_id": self.project_id_var.get(), - "credit_link": self.credit_link_var.get(), - "license": self.license_var.get(), - "tag1": self.tag1_var.get(), - "tag2": self.tag2_var.get(), - "tag3": self.tag3_var.get() - } - - def select_image(self): - file_path = filedialog.askopenfilename( - title="Select Logo Image", - filetypes=[("Image Files", "*.png;*.jpg;*.jpeg;*.webp")] - ) - if file_path: - self.image_path_var.set(file_path) - - def create_tutorial(self): - if not self.base_path: - messagebox.showerror("Error", "Please select the local base path for the repository.") - return - - if not self.section_menu.get(): - messagebox.showerror("Error", "Please select the tutorial section.") - return - - if not self.tutorial_name_var.get(): - messagebox.showerror("Error", "Please enter the folder name for the tutorial.") - return - - project_id = self.project_id_var.get().strip() - if not project_id: - proceed = messagebox.askyesno( - "No Project Associated", - "You did not associate this tutorial with any project.\n\n" - "Are you sure you want to continue?" - ) - if not proceed: - return - - # Validate tutorial difficulty selection - if not self.level_menu.get(): - messagebox.showerror("Error", "Please select the tutorial's difficulty level.") - return - - tags = [t.strip() for t in [self.tag1_var.get(), self.tag2_var.get(), self.tag3_var.get()] if t.strip()] - if len(tags) < 2: - messagebox.showerror("Error", "Please enter at least two tags for the tutorial.") - return - - if len(set(tags)) != len(tags): - messagebox.showerror("Error", "Duplicate tags detected. Please ensure all tags are unique.") - return - - allowed_tags = load_allowed_tags(self.base_path) - for tag in tags: - if tag not in allowed_tags: - messagebox.showerror("Error", f"Tag '{tag}' is not valid. Please select a valid tag from the suggestions.") - return - - if not self.category_menu.get(): - messagebox.showerror("Error", "Please select the subcategory.") - return - - # Retrieve global settings - language = self.settings.get("language", "") - if not language: - messagebox.showerror("Error", "Global language setting is missing.") - return - - language_code = language.split(" ")[0] - contributor_id = self.settings.get("contributor_id", "").strip() - professor_id = self.settings.get("professor_id", "").strip() - - if not contributor_id: - messagebox.showerror("Error", "Global contributor's GitHub ID is missing.") - return - - if not professor_id: - messagebox.showerror("Error", "Global PBN professor's ID is missing.") - return - - credit_link = self.credit_link_var.get().strip() - - try: - tutorial_path = create_tutorial_files( - base=self.base_path, - section_name=self.section_menu.get(), - tutorial_name=self.tutorial_name_var.get(), - language_code=language_code, - project_id=project_id, - tags=tags, - category_value=self.category_menu.get(), - level_value=self.level_menu.get(), - professor_id=professor_id, - contributor_id=contributor_id, - credit_link=credit_link, - license=self.license_var.get() - ) - messagebox.showinfo("Success", f"Tutorial successfully created in the folder:\n{tutorial_path}") - except Exception as e: - messagebox.showerror("Error", f"An error occurred: {e}") - - def clear_fields(self): - answer = messagebox.askyesno("Confirmation", "Are you sure you want to erase all data?") - if answer: - self.section_menu.set("") - self.category_menu.set("") - self.level_menu.set("") - self.tutorial_name_var.set("") - self.builder_search_var.set("") - self.builder_suggestions_menu.set("") - self.project_id_var.set("") - self.credit_link_var.set("") - self.license_var.set(DEFAULT_LICENSE) - self.tag1_var.set("") - self.tag2_var.set("") - self.tag3_var.set("") - - def go_back(self): - # Save state before returning to home page - self.update_local_state() - from gui.home import HomePage - self.destroy() - home_page = HomePage(self.parent, self.settings) - home_page.pack(fill="both", expand=True) \ No newline at end of file diff --git a/scripts/tutorial-related/data-creator/install.sh b/scripts/tutorial-related/data-creator/install.sh deleted file mode 100644 index e121d7a7d98..00000000000 --- a/scripts/tutorial-related/data-creator/install.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - - -if ! command -v apt-get >/dev/null 2>&1; then - echo "❌ Unsupported distribution (requires apt-get)" >&2 - exit 1 -fi - - -if [[ $EUID -ne 0 ]]; then - echo "🔐 Elevating privileges…" - exec sudo -E "$0" "$@" -fi - - -apt-get update -qq -apt-get install -y python3-tk python3-venv - - -: "${SUDO_USER:=$(logname)}" -APP_DIR="$(cd "$(dirname "$0")" && pwd)" -VENV="/home/$SUDO_USER/.planb-creator-venv" - -export APP_DIR VENV - - -sudo -u "$SUDO_USER" -E bash - <=0.7.1 -packaging>=23.2 -Pillow>=10.2.0 diff --git a/scripts/tutorial-related/data-creator/run.sh b/scripts/tutorial-related/data-creator/run.sh deleted file mode 100644 index 8c9bcdb18d0..00000000000 --- a/scripts/tutorial-related/data-creator/run.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash -source ~/.planb-creator-venv/bin/activate -python3 "$(dirname "$0")/main.py" diff --git a/scripts/tutorial-related/data-creator/theme.json b/scripts/tutorial-related/data-creator/theme.json deleted file mode 100644 index 8fe029aaf30..00000000000 --- a/scripts/tutorial-related/data-creator/theme.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "CTk": { - "fg_color": ["#000000", "#000000"] - }, - "CTkToplevel": { - "fg_color": ["#000000", "#000000"] - }, - "CTkFrame": { - "corner_radius": 6, - "border_width": 0, - "fg_color": ["#000000", "#000000"], - "top_fg_color": ["#000000", "#000000"], - "border_color": ["#000000", "#000000"] - }, - "CTkButton": { - "corner_radius": 6, - "border_width": 0, - "fg_color": ["#381A05", "#381A05"], - "hover_color": ["#ff5c00", "#ff5c00"], - "border_color": ["#000000", "#000000"], - "text_color": ["#ffffff", "#ffffff"], - "text_color_disabled": ["#808080", "#808080"] - }, - "CTkLabel": { - "corner_radius": 0, - "fg_color": "transparent", - "text_color": ["#808080", "#808080"], - "text_color_disabled": ["#808080", "#808080"] - }, - "CTkEntry": { - "corner_radius": 6, - "border_width": 1, - "fg_color": ["#222222", "#222222"], - "border_color": ["#444444", "#444444"], - "text_color": ["#ffffff", "#ffffff"], - "placeholder_text_color": ["#888888", "#888888"] - }, - "CTkOptionMenu": { - "corner_radius": 6, - "fg_color": ["#222222", "#222222"], - "button_color": ["#381A05", "#381A05"], - "button_hover_color": ["#e35200", "#e35200"], - "text_color": ["#ffffff", "#ffffff"], - "text_color_disabled": ["#808080", "#808080"] - }, - "CTkComboBox": { - "corner_radius": 6, - "border_width": 1, - "fg_color": ["#222222", "#222222"], - "border_color": ["#444444", "#444444"], - "button_color": ["#ff5c00", "#ff5c00"], - "button_hover_color": ["#e35200", "#e35200"], - "text_color": ["#ffffff", "#ffffff"], - "text_color_disabled": ["#808080", "#808080"] - }, - "CTkSlider": { - "corner_radius": 1000, - "button_corner_radius": 1000, - "border_width": 4, - "fg_color": ["#333333", "#333333"], - "progress_color": ["#ff5c00", "#ff5c00"], - "button_color": ["#222222", "#222222"], - "button_hover_color": ["#e35200", "#e35200"] - }, - "CTkSwitch": { - "corner_radius": 1000, - "border_width": 3, - "button_length": 0, - "progress_color": ["#ff5c00", "#ff5c00"], - "button_color": ["#222222", "#222222"], - "fg_color": ["#444444", "#444444"], - "button_hover_color": ["#e35200", "#e35200"], - "text_color": ["#ffffff", "#ffffff"], - "text_color_disabled": ["#808080", "#808080"] - }, - "CTkCheckBox": { - "corner_radius": 6, - "border_width": 3, - "fg_color": ["#444444", "#444444"], - "border_color": ["#444444", "#444444"], - "hover_color": ["#ff5c00", "#ff5c00"], - "checkmark_color": ["#ffffff", "#ffffff"], - "text_color": ["#ffffff", "#ffffff"], - "text_color_disabled": ["#808080", "#808080"] - }, - "CTkRadioButton": { - "corner_radius": 1000, - "border_width_checked": 6, - "border_width_unchecked": 3, - "fg_color": ["#222222", "#222222"], - "border_color": ["#444444", "#444444"], - "hover_color": ["#ff5C00", "#ff5C00"], - "selected_color": ["#381A05", "#381A05"], - "text_color": ["#ffffff", "#ffffff"], - "text_color_disabled": ["#808080", "#808080"] - }, - "CTkProgressBar": { - "corner_radius": 1000, - "fg_color": ["#333333", "#333333"], - "progress_color": ["#ff5c00", "#ff5c00"], - "border_color": ["#000000", "#000000"] - }, - "CTkScrollbar": { - "corner_radius": 1000, - "border_spacing": 4, - "fg_color": "transparent", - "button_color": ["#444444", "#444444"], - "button_hover_color": ["#ff5c00", "#ff5c00"] - }, - "CTkTextbox": { - "corner_radius": 6, - "border_width": 0, - "fg_color": ["#222222", "#222222"], - "border_color": ["#444444", "#444444"], - "text_color": ["#ffffff", "#ffffff"], - "scrollbar_button_color": ["#444444", "#444444"], - "scrollbar_button_hover_color": ["#ff5c00", "#ff5c00"] - }, - "CTkSegmentedButton": { - "corner_radius": 6, - "border_width": 2, - "fg_color": ["#222222", "#222222"], - "selected_color": ["#ff5c00", "#ff5c00"], - "selected_hover_color": ["#e35200", "#e35200"], - "unselected_color": ["#444444", "#444444"], - "unselected_hover_color": ["#ff5c00", "#ff5c00"], - "text_color": ["#ffffff", "#ffffff"], - "text_color_disabled": ["#808080", "#808080"] - }, - "DropdownMenu": { - "fg_color": ["#222222", "#222222"], - "hover_color": ["#e35200", "#e35200"], - "text_color": ["#ffffff", "#ffffff"] - }, - "CTkScrollableFrame": { - "label_fg_color": ["#000000", "#000000"] - }, - "CTkFont": { - "macOS": { - "family": "SF Display", - "size": 13, - "weight": "normal" - }, - "Windows": { - "family": "Roboto", - "size": 13, - "weight": "normal" - }, - "Linux": { - "family": "Roboto", - "size": 13, - "weight": "normal" - } - } -} diff --git a/scripts/tutorial-related/data-creator/utils/__init__.py b/scripts/tutorial-related/data-creator/utils/__init__.py deleted file mode 100644 index e4687c399b2..00000000000 --- a/scripts/tutorial-related/data-creator/utils/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# utils/__init__.py -# contains utility modules for constants, data loading, and file operations. diff --git a/scripts/tutorial-related/data-creator/utils/constants.py b/scripts/tutorial-related/data-creator/utils/constants.py deleted file mode 100644 index c12605a2524..00000000000 --- a/scripts/tutorial-related/data-creator/utils/constants.py +++ /dev/null @@ -1,207 +0,0 @@ -# utils/constants.py -# Global constants for the application - -# Sections and subcategories for tutorials -SECTIONS = { - "wallet": ["desktop", "hardware", "mobile", "backup"], - "node": ["bitcoin", "lightning-network", "others"], - "mining": ["hardware", "pool"], - "exchange": ["centralized", "peer-to-peer"], - "business": ["point-of-sale", "others"], - "privacy": ["on-chain", "explorer"], - "computer-security": ["authentication", "communication", "data", "operating system"], - "contribution": ["content", "resource", "others"] -} - -# Difficulty levels -LEVELS = ["beginner", "intermediate", "advanced", "expert"] - -# Project categories (for projects) -PROJECT_CATEGORIES = [ - "communities", "conference", "education", "exchange", "infrastructure", - "investment", "manufacturer", "merchant", "mining", "news", - "node", "privacy", "service", "wallet" -] - -# License options -LICENSE_OPTIONS = ["CC-BY-SA-V4", "MIT"] -DEFAULT_LICENSE = LICENSE_OPTIONS[0] - -# Language options for main languages -MAIN_LANGUAGE_CODES = { - "en": "English", - "es": "Español", - "fr": "Français", - "de": "Deutsch", - "it": "Italiano" -} -MAIN_LANGUAGE_OPTIONS = [f"{code} ({MAIN_LANGUAGE_CODES[code]})" for code in MAIN_LANGUAGE_CODES] - -# Other languages -OTHER_LANGUAGE_CODES = { - "af": "Afrikaans", - "am": "አማርኛ", - "ar": "العربية", - "ar-EG": "العربية (مصر)", - "ar-SA": "العربية (السعودية)", - "as": "অসমীয়া", - "az": "Azərbaycanca", - "be": "Беларуская", - "bg": "Български", - "bn": "বাংলা", - "bn-BD": "বাংলা (বাংলাদেশ)", - "bn-IN": "বাংলা (ভারত)", - "bo": "བོད་སྐད་", - "bs": "Bosanski", - "ca": "Català", - "ceb": "Cebuano", - "co": "Corsu", - "cs": "Čeština", - "cs-CZ": "Čeština (Česko)", - "cy": "Cymraeg", - "da": "Dansk", - "de": "Deutsch", - "de-DE": "Deutsch (Deutschland)", - "dv": "ދިވެހި", - "dz": "རྫོང་ཁ", - "ee": "Eʋegbe", - "el": "Ελληνικά", - "el-GR": "Ελληνικά (Ελλάδα)", - "eo": "Esperanto", - "et": "Eesti", - "et-EE": "Eesti (Eesti)", - "eu": "Euskara", - "fa": "فارسی", - "fa-IR": "فارسی (ایران)", - "fi": "Suomi", - "fi-FI": "Suomi (Suomi)", - "fil": "Filipino", - "fj": "Na Vosa Vakaviti", - "fo": "Føroyskt", - "fr-CA": "Français (Canada)", - "fr-FR": "Français (France)", - "fy": "Frysk", - "ga": "Gaeilge", - "gd": "Gàidhlig", - "gl": "Galego", - "gn": "Avañe'ẽ", - "gu": "ગુજરાતી", - "ha": "Hausa", - "he": "עברית", - "he-IL": "עברית (ישראל)", - "hi": "हिन्दी", - "hi-IN": "हिन्दी (भारत)", - "hmn": "Hmoob", - "hr": "Hrvatski", - "ht": "Kreyòl Ayisyen", - "hu": "Magyar", - "hy": "Հայերեն", - "id": "Bahasa Indonesia", - "id-ID": "Bahasa Indonesia (Indonesia)", - "ig": "Igbo", - "is": "Íslenska", - "it-IT": "Italiano (Italia)", - "ja": "日本語", - "ja-JP": "日本語 (日本)", - "jv": "Basa Jawa", - "ka": "ქართული", - "kk": "Қазақша", - "kl": "Kalaallisut", - "km": "ខ្មែរ", - "kn": "ಕನ್ನಡ", - "ko": "한국어", - "ko-KR": "한국어 (대한민국)", - "ku": "Kurdî", - "ky": "Кыргызча", - "la": "Latina", - "lb": "Lëtzebuergesch", - "lo": "ລາວ", - "lt": "Lietuvių", - "lv": "Latviešu", - "mg": "Malagasy", - "mi": "Māori", - "mk": "Македонски", - "ml": "മലയാളം", - "mn": "Монгол", - "mr": "मराठी", - "ms": "Bahasa Melayu", - "ms-MY": "Bahasa Melayu (Malaysia)", - "mt": "Malti", - "my": "မြန်မာစာ", - "nb": "Norsk Bokmål", - "nb-NO": "Norsk Bokmål (Norge)", - "ne": "नेपाली", - "nl": "Nederlands", - "nl-NL": "Nederlands (Nederland)", - "nn": "Norsk Nynorsk", - "nn-NO": "Norsk Nynorsk (Noreg)", - "no": "Norsk", - "no-NO": "Norsk (Norge)", - "ny": "Chichewa", - "oc": "Occitan", - "om": "Afaan Oromoo", - "or": "ଓଡ଼ିଆ", - "pa": "ਪੰਜਾਬੀ", - "pl": "Polski", - "pl-PL": "Polski (Polska)", - "ps": "پښتو", - "pt": "Português", - "pt-BR": "Português (Brasil)", - "pt-PT": "Português (Portugal)", - "ro": "Română", - "ru": "Русский", - "ru-RU": "Русский (Россия)", - "rw": "Kinyarwanda", - "sd": "سنڌي", - "si": "සිංහල", - "sk": "Slovenčina", - "sl": "Slovenščina", - "sm": "Gagana Samoa", - "sn": "ChiShona", - "so": "Soomaali", - "sq": "Shqip", - "sr": "Српски", - "sr-Cyrl": "Српски (Ћирилица)", - "sr-Latn": "Srpski (Latinica)", - "ss": "SiSwati", - "st": "Sesotho", - "su": "Basa Sunda", - "sv": "Svenska", - "sv-SE": "Svenska (Sverige)", - "sw": "Kiswahili", - "sw-KE": "Kiswahili (Kenya)", - "sw-TZ": "Kiswahili (Tanzania)", - "ta": "தமிழ்", - "ta-IN": "தமிழ் (இந்தியா)", - "ta-LK": "தமிழ் (இலங்கை)", - "te": "తెలుగు", - "tg": "Тоҷикӣ", - "th": "ไทย", - "ti": "ትግርኛ", - "tk": "Türkmençe", - "tl": "Tagalog", - "tn": "Setswana", - "tr": "Türkçe", - "tr-TR": "Türkçe (Türkiye)", - "ts": "Xitsonga", - "uk": "Українська", - "uk-UA": "Українська (Україна)", - "ur": "اردو", - "uz": "Oʻzbekcha", - "vi": "Tiếng Việt", - "vi-VN": "Tiếng Việt (Việt Nam)", - "xh": "isiXhosa", - "yi": "ייִדיש", - "yo": "Yorùbá", - "zh": "中文", - "zh-CN": "简体中文(中国)", - "zh-HK": "繁體中文(香港)", - "zh-Hans": "简体中文", - "zh-Hant": "繁體中文", - "zh-SG": "简体中文(新加坡)", - "zh-TW": "繁體中文(台灣)", - "zu": "isiZulu" -} - -SORTED_OTHER_LANGUAGES = sorted(OTHER_LANGUAGE_CODES) -OTHER_LANGUAGE_OPTIONS = [f"{code} ({OTHER_LANGUAGE_CODES[code]})" for code in SORTED_OTHER_LANGUAGES] diff --git a/scripts/tutorial-related/data-creator/utils/data_loader.py b/scripts/tutorial-related/data-creator/utils/data_loader.py deleted file mode 100644 index ad9b00241b3..00000000000 --- a/scripts/tutorial-related/data-creator/utils/data_loader.py +++ /dev/null @@ -1,76 +0,0 @@ -import os -import re - -def load_allowed_tags(base_path): - """ - Load allowed tags from the markdown file located at 'docs/50-planb-tags.md' - relative to the repository root. - """ - tags = [] - if base_path: - file_path = os.path.join(base_path, "docs", "50-planb-tags.md") - else: - file_path = os.path.join("docs", "50-planb-tags.md") - if os.path.exists(file_path): - with open(file_path, "r", encoding="utf-8") as f: - for line in f: - m = re.match(r"^\s*\d+\.\s+(.+?):", line) - if m: - tags.append(m.group(1).strip()) - return tags - -def load_all_builders(base_path): - """ - Load all builders (projects) from the 'resources/projects' directory relative to the repository root. - Returns a dictionary mapping builder names to their IDs. - """ - builders = {} - if not base_path: - return builders - builders_dir = os.path.join(base_path, "resources", "projects") - if not os.path.exists(builders_dir): - return builders - for d in os.listdir(builders_dir): - sub_dir = os.path.join(builders_dir, d) - if os.path.isdir(sub_dir): - builder_file = os.path.join(sub_dir, "project.yml") - if os.path.exists(builder_file): - with open(builder_file, "r", encoding="utf-8") as bf: - lines = bf.readlines() - b_id = None - b_name = None - for line in lines: - if line.startswith("id:"): - b_id = line.split(":", 1)[1].strip() - elif line.startswith("name:"): - b_name = line.split(":", 1)[1].strip() - if b_id and b_name: - builders[b_name] = b_id - return builders - -def load_all_professors(base_path): - - professors = {} - if not base_path: - return professors - professors_dir = os.path.join(base_path, "professors") - if not os.path.exists(professors_dir): - return professors - - for folder in os.listdir(professors_dir): - folder_path = os.path.join(professors_dir, folder) - if os.path.isdir(folder_path): - yaml_path = os.path.join(folder_path, "professor.yml") - if os.path.exists(yaml_path): - with open(yaml_path, "r", encoding="utf-8") as f: - content = f.readlines() - p_id = None - p_name = None - for line in content: - if line.startswith("id:"): - p_id = line.split(":", 1)[1].strip() - elif line.startswith("name:"): - p_name = line.split(":", 1)[1].strip() - if p_id and p_name: - professors[p_name] = p_id - return professors diff --git a/scripts/tutorial-related/data-creator/utils/file_ops.py b/scripts/tutorial-related/data-creator/utils/file_ops.py deleted file mode 100644 index 0f1d6176e56..00000000000 --- a/scripts/tutorial-related/data-creator/utils/file_ops.py +++ /dev/null @@ -1,216 +0,0 @@ - -import os -import shutil -import uuid -from PIL import Image -import datetime - -def create_directory(path): - os.makedirs(path, exist_ok=True) - -def write_file(file_path, content): - with open(file_path, "w", encoding="utf-8") as f: - f.write(content) - -def process_profile_image(source_path, dest_path): - """ - Convert and copy the profile image to WEBP. - """ - if source_path and os.path.exists(source_path): - _, ext = os.path.splitext(source_path) - ext = ext.lower() - if ext in [".png", ".jpg", ".jpeg"]: - img = Image.open(source_path) - img.save(dest_path, "WEBP") - else: - shutil.copy(source_path, dest_path) - -def create_tutorial_files(base, section_name, tutorial_name, language_code, project_id, tags, category_value, level_value, professor_id, contributor_id, credit_link=None, license="CC-BY-SA-V4"): - """ - Create files required for a new tutorial. - """ - tutorial_path = os.path.join(base, "tutorials", section_name, tutorial_name) - create_directory(tutorial_path) - - assets_path = os.path.join(tutorial_path, "assets") - create_directory(assets_path) - - assets_lang_path = os.path.join(assets_path, language_code) - create_directory(assets_lang_path) - - md_filename = f"{language_code}.md" - md_content = """--- -name: -description: ---- - -![cover](assets/cover.webp) -""" - write_file(os.path.join(tutorial_path, md_filename), md_content) - - uuid_value = str(uuid.uuid4()) - current_date = datetime.date.today().strftime("%Y-%m-%d") - - lines = [ - f"id: {uuid_value}", - "", - f"last_update_date: {current_date}", - ] - - - # Add credit_link if we have it - if credit_link: - lines.extend([ - "", - f"credit_link: {credit_link}", - ]) - - if project_id: - lines.extend([ - "", - f"project_id: {project_id}", - "", - ]) - else: - lines.append("") - - lines.append("tags:") - - - for tag in tags: - lines.append(f" - {tag}") - - lines.extend([ - "", - f"category: {category_value}", - "", - f"level: {level_value}", - "", - f"professor_id: {professor_id}", - "", - f"license: {license}", - "", - "# Proofreading metadata", - "", - f"original_language: {language_code}", - "proofreading:", - f" - language: {language_code}", - f" last_contribution_date: {current_date}", - " urgency: 1", - " contributor_names:", - f" - {contributor_id}", - " reward: 0" - ]) - - yaml_content = "\n".join(lines) - write_file(os.path.join(tutorial_path, "tutorial.yml"), yaml_content) - - return tutorial_path - -def create_professor_yaml(full_name, website=None, twitter=None, lightning=None, tags=None, nostr=None, linkedin=None, github=None): - prof_uuid = str(uuid.uuid4()) - lines = [ - f"id: {prof_uuid}", - f"name: {full_name}", - "", - "" - ] - - # Links block (optional) - if website or twitter or nostr or linkedin or github: - lines.append("links:") - if website: - lines.append(f" website: {website}") - if twitter: - lines.append(f" twitter: {twitter}") - if nostr: - lines.append(f" nostr: {nostr}") - if linkedin: - lines.append(f" linkedin: {linkedin}") - if github: - lines.append(f" github: {github}") - lines.append("") - - # Tips block (optional) - if lightning: - lines.append("tips:") - lines.append(f" lightning_address: {lightning}") - lines.append("") - - # Tags block (optional) - if tags: - lines.append("tags:") - for t in tags: - lines.append(f" - {t}") - lines.append("") - - return "\n".join(lines) - -def create_language_yaml(language_code, bio, short_bio): - """ - Generate YAML content for language-specific project information. - """ - lines = [ - "bio: |", - f" {bio}", - "", - "short_bio:" + (f" {short_bio}" if short_bio else "") - ] - return "\n".join(lines) - -def create_project_yaml(project_uuid, project_name, website, twitter, category, tags, language_code, current_date, global_contributor, license="CC-BY-SA-V4", nostr=None, linkedin=None, github=None): - """ - Generate the content for project.yml. - 'global_contributor' is the GitHub Contributor's ID from HOME. - """ - lines = [ - f"id: {project_uuid}", - "", - f"name: {project_name}", - "", - ] - - if website or twitter or nostr or linkedin or github: - lines.append("links:") - if website: - lines.append(f" website: {website}") - if twitter: - lines.append(f" twitter: {twitter}") - if nostr: - lines.append(f" nostr: {nostr}") - if linkedin: - lines.append(f" linkedin: {linkedin}") - if github: - lines.append(f" github: {github}") - lines.append("") - - lines.append(f"category: {category}") - lines.append("") - lines.append("contributor_names:") - lines.append(f" - {global_contributor}") - lines.append("") - lines.append("tags:") - for t in tags: - lines.append(f" - {t}") - lines.append("") - lines.append(f"license: {license}") - lines.append("") - lines.append("# Proofreading metadata") - lines.append(f"original_language: {language_code}") - lines.append("proofreading:") - lines.append(f" - language: {language_code}") - lines.append(f" last_contribution_date: {current_date}") - lines.append(" urgency: 1") - lines.append(" contributor_names:") - lines.append(f" - {global_contributor}") - lines.append(" reward: 0") - lines.append("") - - return "\n".join(lines) - -def create_project_language_yaml(language_code, description, professor_global): - lines = [ - f"description: |", - f" {description}", - ] - return "\n".join(lines) diff --git a/scripts/tutorial-related/data-creator/utils/path_utils.py b/scripts/tutorial-related/data-creator/utils/path_utils.py deleted file mode 100644 index 687bb316793..00000000000 --- a/scripts/tutorial-related/data-creator/utils/path_utils.py +++ /dev/null @@ -1,23 +0,0 @@ -import os - -def normalize_base_path(raw) -> str: - if raw is None: - return "" - - s = str(raw).strip() - if not s: - return "" - - if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'): - s = s[1:-1].strip() - - s = os.path.expanduser(os.path.expandvars(s)) - - s = os.path.normpath(s) - - try: - s = os.path.abspath(s) - except Exception: - pass - - return s diff --git a/scripts/tutorial-related/data-creator/utils/repo_validator.py b/scripts/tutorial-related/data-creator/utils/repo_validator.py deleted file mode 100644 index 23fd6c89b7a..00000000000 --- a/scripts/tutorial-related/data-creator/utils/repo_validator.py +++ /dev/null @@ -1,52 +0,0 @@ -import os -from utils.path_utils import normalize_base_path - -REQUIRED_ITEMS = [ - ("docs/50-planb-tags.md", "file", "Required tag file"), - ("resources/projects", "dir", "Projects directory"), - ("professors", "dir", "Professors directory"), - ("tutorials", "dir", "Tutorials directory"), -] - -def validate_repo_root(base_path: str) -> dict: - """ - Validate that base_path is the root of the cloned repository - and that the app can access required folders/files. - """ - normalized = normalize_base_path(base_path) - issues = [] - - if not normalized: - issues.append("No repository path selected.") - return {"ok": False, "path": normalized, "issues": issues} - - if not os.path.exists(normalized): - issues.append("The selected path does not exist.") - return {"ok": False, "path": normalized, "issues": issues} - - if not os.path.isdir(normalized): - issues.append("The selected path is not a directory.") - return {"ok": False, "path": normalized, "issues": issues} - - # Check required items - for rel, kind, _label in REQUIRED_ITEMS: - parts = rel.split("/") - target = os.path.join(normalized, *parts) - - if kind == "file": - if not os.path.isfile(target): - issues.append(f"Missing required file: {rel}") - elif not os.access(target, os.R_OK): - issues.append(f"Cannot read required file: {rel}") - - elif kind == "dir": - if not os.path.isdir(target): - issues.append(f"Missing required directory: {rel}/") - else: - if not os.access(target, os.R_OK | os.X_OK): - issues.append(f"Cannot access required directory: {rel}/") - # The app must write here (create files/folders) - if not os.access(target, os.W_OK): - issues.append(f"No write permission in: {rel}/") - - return {"ok": len(issues) == 0, "path": normalized, "issues": issues} diff --git a/scripts/uuids-check/check_uuids.py b/scripts/uuids-check/check_uuids.py deleted file mode 100644 index ff337f6e4db..00000000000 --- a/scripts/uuids-check/check_uuids.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -UUID v4 Checker and Replacer for YAML files. - -This script: -1. Scans all YAML files in the repository for UUIDs -2. Identifies UUIDs that are NOT valid UUIDv4 -3. Generates new UUIDv4 replacements -4. Finds all references in .md and .yml files -5. Replaces the old UUID with the new one everywhere - -Usage: - python check_uuids.py [--dry-run] [--verbose] - -Options: - --dry-run Show what would be changed without making changes - --verbose Show detailed output -""" - -import os -import re -import sys -import uuid -from pathlib import Path -from dataclasses import dataclass -from typing import Generator - - -# UUID regex pattern (matches any UUID format) -UUID_PATTERN = re.compile( - r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', - re.IGNORECASE -) - -# UUIDv4 pattern: version digit is 4, variant is 8, 9, a, or b -UUIDV4_PATTERN = re.compile( - r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', - re.IGNORECASE -) - - -@dataclass -class UUIDLocation: - """Represents a UUID found in a file.""" - file_path: Path - line_number: int - uuid_value: str - line_content: str - - -@dataclass -class UUIDReplacement: - """Represents a UUID that needs replacement.""" - old_uuid: str - new_uuid: str - locations: list[UUIDLocation] - - -def is_uuid_v4(uuid_str: str) -> bool: - """Check if a UUID string is a valid UUIDv4.""" - return bool(UUIDV4_PATTERN.match(uuid_str.lower())) - - -def generate_uuid_v4() -> str: - """Generate a new UUIDv4.""" - return str(uuid.uuid4()) - - -def find_yaml_files(repo_path: Path) -> Generator[Path, None, None]: - """Find all YAML files in the repository.""" - for pattern in ['**/*.yml', '**/*.yaml']: - yield from repo_path.glob(pattern) - - -def find_all_files(repo_path: Path) -> Generator[Path, None, None]: - """Find all YAML and Markdown files in the repository.""" - for pattern in ['**/*.yml', '**/*.yaml', '**/*.md']: - yield from repo_path.glob(pattern) - - -def extract_uuids_from_file(file_path: Path) -> list[UUIDLocation]: - """Extract all UUIDs from a file with their locations.""" - locations = [] - try: - with open(file_path, 'r', encoding='utf-8') as f: - for line_num, line in enumerate(f, 1): - for match in UUID_PATTERN.finditer(line): - locations.append(UUIDLocation( - file_path=file_path, - line_number=line_num, - uuid_value=match.group().lower(), - line_content=line.rstrip() - )) - except (UnicodeDecodeError, PermissionError) as e: - print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr) - return locations - - -def find_uuid_references(uuid_str: str, repo_path: Path) -> list[UUIDLocation]: - """Find all references to a UUID in YAML and Markdown files.""" - references = [] - uuid_lower = uuid_str.lower() - - for file_path in find_all_files(repo_path): - try: - with open(file_path, 'r', encoding='utf-8') as f: - for line_num, line in enumerate(f, 1): - if uuid_lower in line.lower(): - references.append(UUIDLocation( - file_path=file_path, - line_number=line_num, - uuid_value=uuid_str, - line_content=line.rstrip() - )) - except (UnicodeDecodeError, PermissionError): - continue - - return references - - -def replace_uuid_in_file(file_path: Path, old_uuid: str, new_uuid: str) -> bool: - """Replace all occurrences of old_uuid with new_uuid in a file.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Case-insensitive replacement - pattern = re.compile(re.escape(old_uuid), re.IGNORECASE) - new_content = pattern.sub(new_uuid, content) - - if content != new_content: - with open(file_path, 'w', encoding='utf-8') as f: - f.write(new_content) - return True - except (UnicodeDecodeError, PermissionError) as e: - print(f"Error: Could not process {file_path}: {e}", file=sys.stderr) - - return False - - -def main(): - # Parse arguments - dry_run = '--dry-run' in sys.argv - verbose = '--verbose' in sys.argv - - # Determine repository path (two levels up from script location) - script_dir = Path(__file__).parent.resolve() - repo_path = script_dir.parent.parent - - print(f"Scanning repository: {repo_path}") - print(f"Mode: {'DRY RUN' if dry_run else 'LIVE'}") - print("-" * 60) - - # Step 1: Find all UUIDs in YAML files - all_uuids: dict[str, list[UUIDLocation]] = {} - - for yaml_file in find_yaml_files(repo_path): - locations = extract_uuids_from_file(yaml_file) - for loc in locations: - uuid_key = loc.uuid_value - if uuid_key not in all_uuids: - all_uuids[uuid_key] = [] - all_uuids[uuid_key].append(loc) - - print(f"Found {len(all_uuids)} unique UUIDs in YAML files") - - # Step 2: Identify non-UUIDv4 - non_v4_uuids = { - uuid_str: locations - for uuid_str, locations in all_uuids.items() - if not is_uuid_v4(uuid_str) - } - - print(f"Found {len(non_v4_uuids)} non-UUIDv4 UUIDs that need replacement") - print("-" * 60) - - if not non_v4_uuids: - print("All UUIDs are valid UUIDv4. No changes needed.") - return 0 - - # Step 3: Process each non-v4 UUID - replacements: list[UUIDReplacement] = [] - - for old_uuid, yaml_locations in non_v4_uuids.items(): - # Find all references in repo (yml and md files) - all_references = find_uuid_references(old_uuid, repo_path) - - # Generate new UUIDv4 - new_uuid = generate_uuid_v4() - - replacement = UUIDReplacement( - old_uuid=old_uuid, - new_uuid=new_uuid, - locations=all_references - ) - replacements.append(replacement) - - # Print info - print(f"\n{'='*60}") - print(f"OLD UUID (non-v4): {old_uuid}") - print(f"NEW UUID (v4): {new_uuid}") - print(f"Found in {len(all_references)} location(s):") - - for loc in all_references: - rel_path = loc.file_path.relative_to(repo_path) - print(f" - {rel_path}:{loc.line_number}") - if verbose: - print(f" {loc.line_content[:80]}...") - - print(f"\n{'='*60}") - print(f"SUMMARY: {len(replacements)} UUIDs to replace") - print(f"Total file locations affected: {sum(len(r.locations) for r in replacements)}") - - if dry_run: - print("\n[DRY RUN] No changes made. Run without --dry-run to apply changes.") - return 0 - - # Step 4: Apply replacements - print("\nApplying replacements...") - - files_modified = set() - for replacement in replacements: - # Get unique files that need modification - files_to_modify = {loc.file_path for loc in replacement.locations} - - for file_path in files_to_modify: - if replace_uuid_in_file(file_path, replacement.old_uuid, replacement.new_uuid): - files_modified.add(file_path) - print(f" Updated: {file_path.relative_to(repo_path)}") - - print(f"\nDone! Modified {len(files_modified)} file(s).") - - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/scripts/validate.py b/scripts/validate.py deleted file mode 100644 index 94391cfbe2c..00000000000 --- a/scripts/validate.py +++ /dev/null @@ -1,329 +0,0 @@ -import os -import re -import ruamel.yaml -from typing import List, Dict, Union -from ruamel.yaml.comments import CommentedMap -from ruamel.yaml.scalarstring import PreservedScalarString -from unidecode import unidecode -import frontmatter - -YAML = ruamel.yaml.YAML() -YAML.indent(mapping=2, sequence=4, offset=2) -YAML.width = 100000 - -INVALID_CHARS_PATTERN = re.compile(r"[^\w\d_.-]", re.UNICODE) - - -class ValidationError(Exception): - pass - - -class ValidationMixin: - data: Dict[str, Union[str, int, List[str], Dict[str, str]]] - - REQUIRED_FIELDS = [] - OPTIONAL_FIELDS = [] - - def __init__(self): - self.errors: list[str] = [] - self.warnings: list[str] = [] - - def validate_required_fields(self): - missing_fields = set(self.REQUIRED_FIELDS) - set(self.data.keys()) - if missing_fields: - self.errors.append(f"Missing required fields: {', '.join(missing_fields)}") - - def validate_optional_fields(self): - if self.OPTIONAL_FIELDS: - missing_fields = set(self.OPTIONAL_FIELDS) - set(self.data.keys()) - if missing_fields: - self.warnings.append( - f"Missing optional fields: {', '.join(missing_fields)}" - ) - - def validate_unrecognized_fields(self): - allowed_fields = set(self.REQUIRED_FIELDS + self.OPTIONAL_FIELDS) - unrecognized_fields = set(self.data.keys()) - allowed_fields - if unrecognized_fields: - self.warnings.append( - f"Unrecognized fields: {', '.join(unrecognized_fields)}" - ) - - def fix_order(self): - sorted_fields = [] - unrecognized_fields = [] - - for field in self.REQUIRED_FIELDS: - if field in self.data: - sorted_fields.append(field) - - for field in self.data.keys(): - if field not in self.REQUIRED_FIELDS and field in self.OPTIONAL_FIELDS: - sorted_fields.append(field) - else: - unrecognized_fields.append(field) - - self.data = CommentedMap( - sorted( - self.data.items(), - key=lambda pair: ( - sorted_fields.index(pair[0]) - if pair[0] in sorted_fields - else len(sorted_fields) + self.OPTIONAL_FIELDS.index(pair[0]) - if pair[0] in self.OPTIONAL_FIELDS - else len(sorted_fields) - + len(self.OPTIONAL_FIELDS) - + unrecognized_fields.index(pair[0]), - ), - ) - ) - - def print_report(self, header: str): - if self.errors or self.warnings: - print(header) - - if self.errors: - print(f" Errors:") - for error in self.errors: - print(f" - {error}") - - if self.warnings: - print(f" Warnings:") - for warning in self.warnings: - print(f" - {warning}") - - if self.errors or self.warnings: - print("") - - -class Course(ValidationMixin): - REQUIRED_FIELDS = ["level", "hours", "teacher", "contributors"] - REQUIRED_TRANSLATABLE_FIELDS = ["name", "goal"] - LEVELS = ["beginner", "intermediate", "advanced", "expert"] - - def __init__(self, course_dir: str): - super().__init__() - self.course_dir = course_dir - self.course_yml = os.path.join(course_dir, "course.yml") - self.languages = [] - - self.load_course() - - def load_course(self): - self.data = self.load_course_yml() - self.load_languages() - - def load_course_yml(self) -> CommentedMap: - with open(self.course_yml, encoding="utf-8") as f: - return YAML.load(f) - - def load_languages(self): - for file in os.listdir(self.course_dir): - if file.endswith(".md"): - lang = file[:-3] - self.languages.append(lang) - - def save(self): - with open(self.course_yml, "w", encoding="utf-8") as f: - YAML.dump(self.data, f) - - def validate(self): - self.validate_required_fields() - self.validate_optional_fields() - self.validate_unrecognized_fields() - - if self.data["level"] not in self.LEVELS: - self.errors.append(f"Invalid level: {self.data['level']}") - - if "hours" in self.data and not isinstance(self.data["hours"], int): - self.errors.append(f"'hours' should be an integer: {self.data['hours']}") - - if "teacher" in self.data and not isinstance(self.data["teacher"], str): - self.errors.append(f"'teacher' should be a string: {self.data['teacher']}") - - if "contributors" in self.data and ( - not isinstance(self.data["contributors"], list) - or not all(isinstance(c, str) for c in self.data["contributors"]) - ): - self.errors.append( - f"'contributors' should be a list of strings: {self.data['contributors']}" - ) - - def validate_md_files(self): - for lang in self.languages: - lang_file = os.path.join(self.course_dir, f"{lang}.md") - if not os.path.isfile(lang_file): - self.errors.append(f"Language file '{lang}.md' not found") - continue - - with open(lang_file, "r", encoding="utf-8") as f: - lines = f.readlines() - - post = frontmatter.loads("\n".join(lines)) - if not set(self.REQUIRED_TRANSLATABLE_FIELDS) <= set(post.keys()): - missing_fields = set(self.REQUIRED_TRANSLATABLE_FIELDS) - set( - post.keys() - ) - self.errors.append( - f"Missing required fields in {lang}: {', '.join(missing_fields)}" - ) - - start_line = None - end_line = None - - for i, line in enumerate(lines[1:]): - if line.strip() == "---" and start_line is None: - start_line = i - if line.strip() == "+++" and end_line is None: - end_line = i - break - - start_line = start_line + 1 if start_line is not None else None - end_line = end_line + 1 if end_line is not None else None - - if start_line is None or end_line is None: - self.errors.append(f"Missing excerpt in {lang}.md") - else: - excerpt_lines = lines[start_line + 1 : end_line] - excerpt = "".join(excerpt_lines).strip() - - if not excerpt: - self.warnings.append(f"Empty excerpt in {lang}.md") - - -class Podcast(ValidationMixin): - REQUIRED_FIELDS = ["name", "host", "language"] - OPTIONAL_FIELDS = [ - "links", - "description", - "tags", - "contributors", - ] - - def __init__(self, podcast_dir: str): - super().__init__() - self.podcast_dir = podcast_dir - self.podcast_yml = os.path.join(podcast_dir, "podcast.yml") - - self.load_podcast() - - def load_podcast(self): - self.data = self.load_podcast_yml() - - def load_podcast_yml(self) -> CommentedMap: - with open(self.podcast_yml, encoding="utf-8") as f: - return YAML.load(f) - - def save(self): - with open(self.podcast_yml, "w", encoding="utf-8") as f: - YAML.dump(self.data, f) - - def validate(self): - self.validate_required_fields() - self.validate_unrecognized_fields() - - if "links" not in self.data: - self.warnings.append(f"No links found") - - if "description" not in self.data: - self.warnings.append(f"No description") - - if "links" in self.data: - self.validate_links() - - def validate_links(self): - links = self.data["links"] - - if not isinstance(links, dict): - self.errors.append("'links' should be an object") - - for link_name, link_url in links.items(): - if not isinstance(link_url, str): - self.errors.append(f"'{link_name}' link should be a string: {link_url}") - - -def replace_in_files(files: List[str], old: str, new: str): - for file_path in files: - with open(file_path, "r", encoding="utf-8") as file: - file_data = file.read() - - file_data = file_data.replace(old, new) - - with open(file_path, "w", encoding="utf-8") as file: - file.write(file_data) - - -def find_and_fix_courses(directory: str): - course_dirs = [ - os.path.join(directory, d) - for d in os.listdir(directory) - if os.path.isdir(os.path.join(directory, d)) - ] - - print("🔎 Checking courses") - - for course_dir in course_dirs: - course = Course(course_dir) - course.validate() - course.validate_md_files() - course.fix_order() - course.save() - course.print_report(f"Course {course_dir.split('/')[-1]}") - - print("✅ Finished checking courses\n") - - -def find_and_fix_podcasts(directory: str): - podcast_dirs = [ - os.path.join(directory, d) - for d in os.listdir(directory) - if os.path.isdir(os.path.join(directory, d)) - ] - - print("🔎 Checking podcasts") - - for podcast_dir in podcast_dirs: - podcast = Podcast(podcast_dir) - podcast.validate() - podcast.fix_order() - podcast.save() - podcast.print_report(f"Podcast {podcast_dir.split('/')[-1]}") - - print("✅ Finished checking podcasts\n") - - -def find_and_fix_assets(directory: str): - print("🔎 Checking file names") - - for root, _, files in os.walk(directory): - for file in files: - if file.startswith(".") or "/." in os.path.join(root, file): - continue - - old_file_path = os.path.join(root, file) - new_file_name = re.sub( - "_+|-+", "_", INVALID_CHARS_PATTERN.sub("_", unidecode(file).lower()) - ) - - new_file_path = os.path.join(root, new_file_name) - - if old_file_path != new_file_path: - os.rename(old_file_path, new_file_path) - print(f"Renamed file {old_file_path} to {new_file_path}") - - files = [ - os.path.join(r, f) - for r, _, fs in os.walk(directory) - for f in fs - if f.endswith(".md") or f.endswith(".yml") - ] - replace_in_files(files, file, new_file_name) - - print("✅ Finished checking file names") - - -if __name__ == "__main__": - directory = "." - find_and_fix_courses(os.path.join(directory, "courses")) - find_and_fix_podcasts(os.path.join(directory, "resources/podcasts")) - find_and_fix_assets(directory) diff --git a/scripts/validate_all.py b/scripts/validate_all.py deleted file mode 100755 index bfadbfc03a3..00000000000 --- a/scripts/validate_all.py +++ /dev/null @@ -1,824 +0,0 @@ -#!/usr/bin/env python3 -""" -PlanB Network - Validate All Content - -Simple script to validate all courses and tutorials and generate an HTML report. - -Usage: - python validate_all.py # Validate all, generate HTML report - python validate_all.py --courses-only # Only validate courses - python validate_all.py --tutorials-only # Only validate tutorials - python validate_all.py --no-report # Validate without generating HTML -""" - -import argparse -import json -import shutil -import subprocess -import sys -from datetime import datetime -from pathlib import Path - - -class Colors: - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - CYAN = "\033[96m" - BOLD = "\033[1m" - DIM = "\033[2m" - END = "\033[0m" - - -class ProgressBar: - """Simple progress bar with single-line updates.""" - - def __init__(self, total: int, prefix: str = "", width: int = 30): - self.total = total - self.prefix = prefix - self.width = width - self.current = 0 - self.passed = 0 - self.failed = 0 - self.warnings = 0 - self.is_tty = sys.stdout.isatty() - - def update(self, name: str, errors: int = 0, warnings: int = 0): - """Update progress bar with new item result.""" - self.current += 1 - if errors > 0: - self.failed += 1 - elif warnings > 0: - self.warnings += 1 - else: - self.passed += 1 - - if self.is_tty: - self._draw(name) - - def _draw(self, name: str): - """Draw the progress bar on a single line (TTY only).""" - pct = self.current / self.total - filled = int(self.width * pct) - bar = "█" * filled + "░" * (self.width - filled) - - # Truncate name to fit - max_name = 25 - if len(name) > max_name: - name = name[:max_name-2] + ".." - - line = f"{self.prefix} │{bar}│ {self.current:3d}/{self.total} {name:<{max_name}}" - - sys.stdout.write(f"\r\033[K{line}") - sys.stdout.flush() - - def finish(self): - """Complete the progress bar and show summary.""" - bar = "█" * self.width - - parts = [] - if self.passed > 0: - parts.append(f"{Colors.GREEN}{self.passed} ✓{Colors.END}") - if self.failed > 0: - parts.append(f"{Colors.RED}{self.failed} ✗{Colors.END}") - if self.warnings > 0: - parts.append(f"{Colors.YELLOW}{self.warnings} ⚠{Colors.END}") - - summary = " ".join(parts) - - if self.is_tty: - line = f"{self.prefix} │{Colors.GREEN}{bar}{Colors.END}│ {self.total:3d}/{self.total} Done: {summary}" - sys.stdout.write(f"\r\033[K{line}\n") - else: - # Non-TTY: just print final summary - print(f"{self.prefix} {self.total}/{self.total} - {summary}") - - sys.stdout.flush() - - -def find_repo_root() -> Path: - """Find the repository root by looking for .git directory.""" - current = Path(__file__).resolve().parent - for _ in range(10): - if (current / ".git").exists(): - return current - if current.parent == current: - break - current = current.parent - # Fallback: assume script is in repo_root/scripts/ - return Path(__file__).resolve().parent.parent - - -def find_courses(repo_root: Path) -> list[Path]: - """Find all course folders.""" - courses_dir = repo_root / "courses" - if not courses_dir.exists(): - return [] - - courses = [] - for item in sorted(courses_dir.iterdir()): - if item.is_dir() and not item.name.startswith('.'): - # Check if it has course.yml (it's a valid course) - if (item / "course.yml").exists(): - courses.append(item) - return courses - - -def find_tutorials(repo_root: Path) -> list[Path]: - """Find all tutorial folders (nested under category).""" - tutorials_dir = repo_root / "tutorials" - if not tutorials_dir.exists(): - return [] - - tutorials = [] - for category in sorted(tutorials_dir.iterdir()): - if category.is_dir() and not category.name.startswith('.'): - for tutorial in sorted(category.iterdir()): - if tutorial.is_dir() and not tutorial.name.startswith('.'): - # Check if it has tutorial.yml (it's a valid tutorial) - if (tutorial / "tutorial.yml").exists(): - tutorials.append(tutorial) - return tutorials - - -def validate_folder(folder: Path, validator_script: Path) -> dict: - """Run validator on a folder and return JSON result.""" - try: - result = subprocess.run( - [sys.executable, str(validator_script), str(folder), "--json"], - capture_output=True, - text=True, - timeout=60 - ) - - # Try to parse JSON from stdout - if result.stdout.strip(): - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - # stdout has non-JSON content (likely error message) - error_msg = result.stdout.strip() + "\n" + result.stderr.strip() - return { - "folder": str(folder), - "results": [{"file": str(folder), "errors": [error_msg.strip()], "warnings": []}], - "total_errors": 1, - "total_warnings": 0, - } - else: - error_msg = result.stderr.strip() if result.stderr else "No output from validator" - return { - "folder": str(folder), - "results": [{"file": str(folder), "errors": [error_msg], "warnings": []}], - "total_errors": 1 if result.returncode != 0 else 0, - "total_warnings": 0, - } - except subprocess.TimeoutExpired: - return { - "folder": str(folder), - "results": [{"file": str(folder), "errors": ["Validation timed out"], "warnings": []}], - "total_errors": 1, - "total_warnings": 0, - } - except Exception as e: - return { - "folder": str(folder), - "results": [{"file": str(folder), "errors": [str(e)], "warnings": []}], - "total_errors": 1, - "total_warnings": 0, - } - - -def generate_html_report(courses: list[dict], tutorials: list[dict], output_path: Path, repo_root: Path): - """Generate an HTML report from validation results.""" - - # Calculate statistics - course_total = len(courses) - courses_with_errors = sum(1 for r in courses if r.get('total_errors', 0) > 0) - course_errors = sum(r.get('total_errors', 0) for r in courses) - course_warnings = sum(r.get('total_warnings', 0) for r in courses) - - tutorial_total = len(tutorials) - tutorials_with_errors = sum(1 for r in tutorials if r.get('total_errors', 0) > 0) - tutorial_errors = sum(r.get('total_errors', 0) for r in tutorials) - tutorial_warnings = sum(r.get('total_warnings', 0) for r in tutorials) - - total_items = course_total + tutorial_total - total_errors = course_errors + tutorial_errors - total_warnings = course_warnings + tutorial_warnings - - # Group errors by type - error_types = {} - for result in courses + tutorials: - content_type = 'courses' if 'courses' in result.get('folder', '') else 'tutorials' - for file_result in result.get('results', []): - for error in file_result.get('errors', []): - # Extract error type (first part before specific details) - error_key = error.split(']')[0] + ']' if ']' in error else error[:60] - if error_key not in error_types: - error_types[error_key] = {'courses': [], 'tutorials': []} - - entry = { - 'name': result.get('folder', 'unknown'), - 'file': file_result.get('file', 'unknown'), - 'full_error': error - } - error_types[error_key][content_type].append(entry) - - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - - # Build HTML - html = f''' - - - - - Schema Validation Report - PlanB Network - - - -
    -
    -

    Schema Validation Report

    -

    Generated: {timestamp}

    -
    - -
    -

    Overall Summary

    -
    -
    -
    {total_items}
    -
    Total Content
    -
    -
    -
    {total_items - courses_with_errors - tutorials_with_errors}
    -
    Passing
    -
    -
    -
    {courses_with_errors + tutorials_with_errors}
    -
    With Errors
    -
    -
    -
    {total_errors}
    -
    Total Errors
    -
    -
    -
    {total_warnings}
    -
    Total Warnings
    -
    -
    - -
    -
    -
    {course_total}
    -
    Courses
    -
    -
    -
    -
    -
    -
    {course_total - courses_with_errors}
    -
    Courses Passing
    -
    -
    -
    {courses_with_errors}
    -
    Courses Failed
    -
    -
    - -
    -
    -
    {tutorial_total}
    -
    Tutorials
    -
    -
    -
    -
    -
    -
    {tutorial_total - tutorials_with_errors}
    -
    Tutorials Passing
    -
    -
    -
    {tutorials_with_errors}
    -
    Tutorials Failed
    -
    -
    -
    -''' - - # Error summary section - if error_types: - html += ''' -
    -

    Error Summary

    -''' - sorted_errors = sorted(error_types.items(), key=lambda x: -(len(x[1]['courses']) + len(x[1]['tutorials']))) - - for error_type, occurrences in sorted_errors: - course_count = len(occurrences['courses']) - tutorial_count = len(occurrences['tutorials']) - error_id = abs(hash(error_type)) % 100000 - - html += f''' -
    -
    - {error_type[:80]} -
    -''' - if course_count > 0: - html += f' {course_count} courses\n' - if tutorial_count > 0: - html += f' {tutorial_count} tutorials\n' - html += f'''
    -
    -
    -''' - all_occurrences = occurrences['courses'] + occurrences['tutorials'] - visible_count = 4 - for occ in all_occurrences[:visible_count]: - file_path = occ['file'].replace(str(repo_root) + '/', '') - html += f'''
    -
    {file_path}
    -
    {occ['full_error'][:300]}
    -
    -''' - if len(all_occurrences) > visible_count: - remaining = len(all_occurrences) - visible_count - html += f'''
    - ... and {remaining} more occurrence(s) - click to expand -
    -
    -''' - for occ in all_occurrences[visible_count:]: - file_path = occ['file'].replace(str(repo_root) + '/', '') - html += f'''
    -
    {file_path}
    -
    {occ['full_error'][:300]}
    -
    -''' - html += '''
    -''' - html += '''
    -
    -''' - html += '''
    -''' - - # Content details with tabs - html += f''' -
    -

    Content Details

    -
    -
    Courses ({course_total})
    -
    Tutorials ({tutorial_total})
    -
    - -
    -
    -''' - - # Courses - sorted_courses = sorted(courses, key=lambda r: (-r.get('total_errors', 0), r.get('folder', ''))) - for result in sorted_courses: - name = Path(result.get('folder', 'unknown')).name - errors = result.get('total_errors', 0) - warnings = result.get('total_warnings', 0) - files = len(result.get('results', [])) - - if errors > 0: - card_class = 'has-errors' - badge = 'FAILED' - elif warnings > 0: - card_class = 'has-warnings' - badge = 'WARNINGS' - else: - card_class = 'valid' - badge = 'OK' - - html += f'''
    -
    {name} {badge}
    -
    - {files} files -''' - if errors > 0: - html += f' {errors} errors\n' - if warnings > 0: - html += f' {warnings} warnings\n' - html += '''
    -
    -''' - - html += '''
    -
    - -
    -
    -''' - - # Tutorials - sorted_tutorials = sorted(tutorials, key=lambda r: (-r.get('total_errors', 0), r.get('folder', ''))) - for result in sorted_tutorials: - folder = Path(result.get('folder', 'unknown')) - name = f"{folder.parent.name}/{folder.name}" - errors = result.get('total_errors', 0) - warnings = result.get('total_warnings', 0) - files = len(result.get('results', [])) - - if errors > 0: - card_class = 'has-errors' - badge = 'FAILED' - elif warnings > 0: - card_class = 'has-warnings' - badge = 'WARNINGS' - else: - card_class = 'valid' - badge = 'OK' - - html += f'''
    -
    {name} {badge}
    -
    - {files} files -''' - if errors > 0: - html += f' {errors} errors\n' - if warnings > 0: - html += f' {warnings} warnings\n' - html += '''
    -
    -''' - - html += '''
    -
    -
    - - -
    - - - - -''' - - output_path.write_text(html, encoding='utf-8') - - -def main(): - parser = argparse.ArgumentParser( - description="Validate all PlanB Network courses and tutorials" - ) - parser.add_argument('--courses-only', action='store_true', help='Only validate courses') - parser.add_argument('--tutorials-only', action='store_true', help='Only validate tutorials') - parser.add_argument('--no-report', action='store_true', help='Skip HTML report generation') - parser.add_argument('--output', '-o', default=None, help='Output HTML file path') - - args = parser.parse_args() - - # Find paths - repo_root = find_repo_root() - template_repo = repo_root / "docs" / "PBN-template-repo" - validator_script = template_repo / "scripts" / "schema_validator.py" - - if not validator_script.exists(): - print(f"{Colors.RED}ERROR: schema_validator.py not found at {validator_script}{Colors.END}") - print(f"Make sure you're running from the bitcoin-educational-content repository.") - sys.exit(1) - - # Check required dependencies - missing_deps = [] - for mod in ['yaml', 'jsonschema', 'frontmatter']: - try: - __import__(mod) - except ImportError: - missing_deps.append(mod) - - if missing_deps: - print(f"{Colors.RED}ERROR: Missing required Python packages:{Colors.END}") - print(f" pip install pyyaml jsonschema python-frontmatter") - sys.exit(1) - - # Default output path - in scripts/reports/ - if args.output: - output_path = Path(args.output) - else: - reports_dir = repo_root / "scripts" / "reports" - reports_dir.mkdir(exist_ok=True) - output_path = reports_dir / "validation_report.html" - - print(f"{Colors.BOLD}PlanB Network Schema Validator{Colors.END}") - print(f"Repository: {repo_root}") - print(f"{'='*60}\n") - - courses = [] - tutorials = [] - course_results = [] - tutorial_results = [] - - # Find content - if not args.tutorials_only: - courses = find_courses(repo_root) - print(f"{Colors.CYAN}Found {len(courses)} courses{Colors.END}") - - if not args.courses_only: - tutorials = find_tutorials(repo_root) - print(f"{Colors.CYAN}Found {len(tutorials)} tutorials{Colors.END}") - - print() - - # Validate courses - if courses: - progress = ProgressBar(len(courses), prefix="Courses ") - for course in courses: - result = validate_folder(course, validator_script) - result['folder'] = str(course) - course_results.append(result) - progress.update( - course.name, - errors=result.get('total_errors', 0), - warnings=result.get('total_warnings', 0) - ) - progress.finish() - - # Validate tutorials - if tutorials: - progress = ProgressBar(len(tutorials), prefix="Tutorials") - for tutorial in tutorials: - name = f"{tutorial.parent.name}/{tutorial.name}" - result = validate_folder(tutorial, validator_script) - result['folder'] = str(tutorial) - tutorial_results.append(result) - progress.update( - name, - errors=result.get('total_errors', 0), - warnings=result.get('total_warnings', 0) - ) - progress.finish() - - # Summary - course_errors = sum(r.get('total_errors', 0) for r in course_results) - course_warnings = sum(r.get('total_warnings', 0) for r in course_results) - tutorial_errors = sum(r.get('total_errors', 0) for r in tutorial_results) - tutorial_warnings = sum(r.get('total_warnings', 0) for r in tutorial_results) - - print(f"{'='*60}") - print(f"{Colors.BOLD}Summary{Colors.END}") - print(f" Courses: {len(course_results)} validated, {course_errors} errors, {course_warnings} warnings") - print(f" Tutorials: {len(tutorial_results)} validated, {tutorial_errors} errors, {tutorial_warnings} warnings") - print(f"{'='*60}\n") - - # Generate HTML report - if not args.no_report: - print(f"Generating HTML report...", end=" ", flush=True) - generate_html_report(course_results, tutorial_results, output_path, repo_root) - print(f"{Colors.GREEN}Done!{Colors.END}") - print(f"Report: {output_path}") - - # Exit code - total_errors = course_errors + tutorial_errors - sys.exit(0 if total_errors == 0 else 1) - - -if __name__ == "__main__": - main() diff --git a/scripts/validation-format/README.md b/scripts/validation-format/README.md deleted file mode 100644 index a28bde722fe..00000000000 --- a/scripts/validation-format/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# PlanB Network Content Validator - -A modular, centralized validation system for all content types in the repository. - -## Quick Start - -```bash -# Activate the virtual environment (required) -source .venv/bin/activate - -# Validate a single content folder -python scripts/validation-format/validate.py courses/btc101 -python scripts/validation-format/validate.py tutorials/wallet/sparrow-wallet -python scripts/validation-format/validate.py resources/papers/bitcoin-a-peer-to-peer-electronic-cash-system - -# Validate all content folders -python scripts/validation-format/validate_all.py - -# Validate specific content types -python scripts/validation-format/validate_all.py --courses-only -python scripts/validation-format/validate_all.py --tutorials-only -python scripts/validation-format/validate_all.py --resources-only -python scripts/validation-format/validate_all.py --type resources/papers -``` - -## Requirements - -```bash -pip install jsonschema pyyaml python-frontmatter -``` - -## Scripts - -### validate.py - -Validates a single content folder against its JSON schema. - -```bash -# Basic usage -python scripts/validation-format/validate.py - -# JSON output for CI/CD -python scripts/validation-format/validate.py courses/btc101 --json -``` - -**Exit codes:** -- `0` - Validation passed -- `1` - Validation failed (errors found) -- `2` - Validation passed with warnings - -### validate_all.py - -Validates all content folders in the repository. - -```bash -# Validate everything -python scripts/validation-format/validate_all.py - -# Filter by content type -python scripts/validation-format/validate_all.py --courses-only -python scripts/validation-format/validate_all.py --tutorials-only -python scripts/validation-format/validate_all.py --professors-only -python scripts/validation-format/validate_all.py --events-only -python scripts/validation-format/validate_all.py --resources-only - -# Validate a specific resource type -python scripts/validation-format/validate_all.py --type resources/papers -python scripts/validation-format/validate_all.py --type resources/books - -# Output options -python scripts/validation-format/validate_all.py --json # JSON output -python scripts/validation-format/validate_all.py --summary-only # Hide individual errors -python scripts/validation-format/validate_all.py --verbose # Show all results -``` - -## Supported Content Types - -| Content Type | Metadata File | Schema File | -|--------------|---------------|-------------| -| courses | `course.yml` | `course-scheme.json` | -| tutorials | `tutorial.yml` | `tutorial-scheme.json` | -| professors | `professor.yml` | `professor-scheme.json` | -| events | `event.yml` | `event-scheme.json` | -| resources/bet | `bet.yml` | `bet-scheme.json` | -| resources/books | `book.yml` | `book-scheme.json` | -| resources/channels | `channel.yml` | `channel-scheme.json` | -| resources/conferences | `conference.yml` | `conference-scheme.json` | -| resources/glossary | `word.yml` | `word-scheme.json` | -| resources/movies | `movie.yml` | `movie-scheme.json` | -| resources/newsletters | `newsletter.yml` | `newsletter-scheme.json` | -| resources/papers | `paper.yml` | `paper-scheme.json` | -| resources/podcasts | `podcast.yml` | `podcast-scheme.json` | -| resources/projects | `project.yml` | `project-scheme.json` | - -## Directory Structure - -``` -scripts/validation-format/ -├── README.md # This file -├── TODO.md # Implementation progress -├── validate.py # Single folder validator -├── validate_all.py # Bulk validator -└── schemas/ # JSON Schema definitions - ├── course-scheme.json - ├── course-content-scheme.json - ├── tutorial-scheme.json - ├── tutorial-content-scheme.json - ├── professor-scheme.json - ├── professor-content-scheme.json - ├── event-scheme.json - ├── paper-scheme.json - └── ... (other schemas) -``` - -## CI/CD Integration - -Both scripts support JSON output and exit codes suitable for CI/CD pipelines: - -```bash -# Run validation and check exit code -python scripts/validation-format/validate_all.py --json > results.json -if [ $? -eq 0 ]; then - echo "All validations passed" -elif [ $? -eq 1 ]; then - echo "Validation errors found" -else - echo "Validation passed with warnings" -fi -``` diff --git a/scripts/validation-format/TODO.md b/scripts/validation-format/TODO.md deleted file mode 100644 index 7d73cb524c6..00000000000 --- a/scripts/validation-format/TODO.md +++ /dev/null @@ -1,207 +0,0 @@ -# Validation Format - Implementation Plan - -## Overview - -Create a modular, centralized validation system for all content types in the repository. - -**Goal**: A single script that takes a content folder path (relative to repo root), auto-detects the content type, and validates it against the corresponding schema. - -**Location**: `scripts/validation-format/` - ---- - -## Phase 1: Setup & Reorganization ✓ - -### 1.1 Create folder structure -- [x] Create `scripts/validation-format/` directory -- [x] Create `scripts/validation-format/schemas/` directory for all JSON schemas - -### 1.2 Move and consolidate schemas -- [x] Move course schemas from `docs/PBN-template-repo/courses/` - - `course-scheme.json` - - `course-content-scheme.json` -- [x] Move tutorial schemas from `docs/PBN-template-repo/tutorials/` - - `tutorial-scheme.json` - - `tutorial-content-scheme.json` -- [x] Move professor schemas from `docs/PBN-template-repo/professors/` - - `professor-scheme.json` - - `professor-content-scheme.json` -- [x] Move event schemas from `docs/PBN-template-repo/events/` - - `event-scheme.json` -- [x] Move resource schemas from `docs/PBN-template-repo/resources/` - - `bet-scheme.json`, `bet-content-scheme.json` - - `book-scheme.json`, `book-content-scheme.json` - - `channel-scheme.json` - - `conference-scheme.json` - - `movie-scheme.json` - - `newsletter-scheme.json` - - `podcast-scheme.json` - - `project-scheme.json` (non-community-builder-scheme.json) - - `word-scheme.json` (glossary) -- [x] Move quiz schemas - - `quizz-question-scheme.json` - - `quizz-translation-scheme.json` - -### 1.3 Move validator script -- [x] Copy `docs/PBN-template-repo/scripts/schema_validator.py` to `scripts/validation-format/validate.py` -- [x] Update schema path resolution to use new `schemas/` directory -- [ ] Remove old script from `docs/PBN-template-repo/scripts/` (deferred - keep for backward compatibility) - ---- - -## Phase 2: Create Paper Schema ✓ - -### 2.1 Analyze paper structure -- [x] Document all fields found in existing `paper.yml` files: - - `title` (required, string) - - `original_language` (required, string, ISO 639-1) - - `authors` (required, array of strings) - - `abstract` (required, string, multiline) - - `publication_date` (optional, string, YYYY-MM-DD) - - `paper_type` (required, enum: whitepaper, conference, academic, pre-print, review, PhD Thesis, book) - - `source` (optional, string) - - `type` (optional, enum: SCI, SCIE, SSCI) - - `category` (optional, string) - - `topics` (required, array of strings) - - `pdf_url` (required, string, URL format) - - `id` (required, string, UUID format) - -### 2.2 Create paper schema -- [x] Create `scripts/validation-format/schemas/paper-scheme.json` -- [x] Follow same JSON Schema format as other schemas (draft-07) -- [x] Define required vs optional fields -- [x] Add proper validation patterns (UUID, date, URL) - ---- - -## Phase 3: Update Validator Script ✓ - -### 3.1 Add paper support -- [x] Add `resources/papers` to CONTENT_TYPES mapping -- [x] Map to `paper.yml` and `paper-scheme.json` - -### 3.2 Fix schema path resolution -- [x] Update all schema path lookups to use `scripts/validation-format/schemas/` -- [x] Ensure relative path resolution works from any working directory - -### 3.3 Verify all content types work -- [x] Test courses validation (btc101: 0 errors) -- [x] Test tutorials validation (sparrow-wallet: runs correctly) -- [x] Test professors validation (runs correctly) -- [x] Test events validation (runs correctly) -- [x] Test resources/bet validation (runs correctly) -- [x] Test resources/books validation (runs correctly) -- [x] Test resources/channels validation (0 errors) -- [x] Test resources/conferences validation (runs correctly) -- [x] Test resources/glossary validation (runs correctly) -- [x] Test resources/movies validation (runs correctly) -- [x] Test resources/newsletters validation (runs correctly) -- [x] Test resources/papers validation (0 errors) -- [x] Test resources/podcasts validation (runs correctly) -- [x] Test resources/projects validation (runs correctly) - -Note: Some content types show validation errors due to schema drift (schemas need updating to match current content structure). The validator itself works correctly for all types. - ---- - -## Phase 4: Enhance Output Options ✓ - -### 4.1 Add JSON output flag -- [x] Add `--json` / `-j` flag for JSON output -- [x] Structure JSON output with: - - `folder`: content path validated - - `results`: array with file, valid, errors, warnings - - `total_errors`: total error count - - `total_warnings`: total warning count - -### 4.2 Add exit codes -- [x] Exit code 0: validation passed -- [x] Exit code 1: validation failed (errors) -- [x] Exit code 2: validation passed with warnings only - -### 4.3 Keep terminal output as default -- [x] Colored output for human readability (ANSI codes) -- [x] Summary statistics at the end - ---- - -## Phase 5: Create Validate-All Wrapper ✓ - -### 5.1 Create bulk validation script -- [x] Create `scripts/validation-format/validate_all.py` -- [x] Iterate over all content folders -- [x] Call `validate.py` for each -- [x] Aggregate results -- [x] Support filtering by content type (`--courses-only`, `--tutorials-only`, `--resources-only`, `--type`) - -### 5.2 Generate reports -- [x] Summary report in terminal (with progress bar) -- [x] JSON output (`--json`) -- [ ] Optional HTML report (`--html-report`) - deferred - ---- - -## Phase 6: Cleanup - -### 6.1 Remove old files -- [ ] Remove `docs/PBN-template-repo/scripts/schema_validator.py` - deferred (template may be standalone) -- [ ] Remove old `scripts/validate_all.py` (if exists) - deferred (has HTML report feature) -- [ ] Remove schemas from `docs/PBN-template-repo/` subdirectories - deferred (template is self-contained) - -### 6.2 Update documentation -- [x] Update any references to old script locations (none found) -- [x] Add usage documentation to `scripts/validation-format/README.md` - ---- - -## Content Types Reference - -| Content Type | Metadata File | Schema File | Content Files | -|--------------|---------------|-------------|---------------| -| courses | `course.yml` | `course-scheme.json` | `{lang}.md` | -| tutorials | `tutorial.yml` | `tutorial-scheme.json` | `{lang}.md` | -| professors | `professor.yml` | `professor-scheme.json` | `{lang}.md` | -| events | `event.yml` | `event-scheme.json` | - | -| resources/bet | `bet.yml` | `bet-scheme.json` | `{lang}.yml` | -| resources/books | `book.yml` | `book-scheme.json` | `{lang}.yml` | -| resources/channels | `channel.yml` | `channel-scheme.json` | - | -| resources/conferences | `conference.yml` | `conference-scheme.json` | - | -| resources/glossary | `word.yml` | `word-scheme.json` | - | -| resources/movies | `movie.yml` | `movie-scheme.json` | - | -| resources/newsletters | `newsletter.yml` | `newsletter-scheme.json` | - | -| resources/papers | `paper.yml` | `paper-scheme.json` | - | -| resources/podcasts | `podcast.yml` | `podcast-scheme.json` | - | -| resources/projects | `project.yml` | `project-scheme.json` | `{lang}.yml` | - ---- - -## Usage (Target) - -```bash -# Validate a single content folder -python scripts/validation-format/validate.py courses/btc101 -python scripts/validation-format/validate.py tutorials/wallet/bitbox02 -python scripts/validation-format/validate.py resources/papers/bitcoin-a-peer-to-peer-electronic-cash-system - -# JSON output for CI/CD -python scripts/validation-format/validate.py courses/btc101 --json - -# Validate all content -python scripts/validation-format/validate_all.py - -# Validate only specific types -python scripts/validation-format/validate_all.py --courses-only -python scripts/validation-format/validate_all.py --resources-only -``` - ---- - -## Progress Tracking - -**Status Legend**: -- [ ] Not started -- [x] Completed - -**Current Phase**: Phase 6.2 Complete (documentation) - -**Last Updated**: 2026-01-16 - Phase 1-5 completed, Phase 6.2 documentation complete (schemas centralized, paper schema added, validator with JSON output and exit codes, bulk validation wrapper, README.md) diff --git a/scripts/validation-format/schemas/channel-scheme.json b/scripts/validation-format/schemas/channel-scheme.json deleted file mode 100644 index 8f3d6acb893..00000000000 --- a/scripts/validation-format/schemas/channel-scheme.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "channel-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "UUID v4 identifier for the channel." - }, - "name": { - "type": "string", - "description": "Complete name of the channel." - }, - "language": { - "type": "string", - "description": "Main language of the channel using ISO 639-1 language code (e.g., en, es, zh-Hans)." - }, - "links": { - "type": "object", - "properties": { - "channel": { - "type": "string", - "format": "uri", - "description": "URL of the channel's main page." - }, - "trailer": { - "type": "string", - "format": "uri", - "description": "URL of the pinned video or most viewed video on the channel." - } - }, - "required": ["channel"], - "additionalProperties": false - }, - "description": { - "type": "string", - "description": "Description of the channel content. Multiline allowed." - }, - "contributor_names": { - "type": "array", - "description": "List of contributor GitHub usernames who helped format, review, and translate the content.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "contributors": { - "type": "array", - "description": "List of contributor UUIDs (alternative to contributor_names for main repo).", - "items": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "minItems": 1 - }, - "tags": { - "type": "array", - "description": "List of associated tags from the predefined 52 PlanB Network tags.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "license": { - "type": "string", - "description": "License for the content.", - "enum": ["CC-BY-SA-V4", "MIT"] - }, - "project_id": { - "type": "string", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", - "description": "UUID v4 linking this channel to a PlanB Network project." - } - }, - "assets_rules": { - "description": "Required image assets for channels.", - "files": [ - { - "path": "assets/thumbnail.webp", - "required": true, - "format": "WebP", - "dimensions": { - "width": 1280, - "height": 720, - "aspect_ratio": "16:9" - }, - "orientation": "horizontal", - "description": "Channel thumbnail image." - } - ] - }, - "required": ["id", "name", "language", "links", "description", "tags"], - "oneOf": [ - { - "required": ["contributor_names"] - }, - { - "required": ["contributors"] - } - ], - "additionalProperties": false -} diff --git a/scripts/validation-format/schemas/conference-scheme.json b/scripts/validation-format/schemas/conference-scheme.json deleted file mode 100644 index d1e4705eb58..00000000000 --- a/scripts/validation-format/schemas/conference-scheme.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "conference-characteristics", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Complete name of the conference including the year to avoid duplication." - }, - "year": { - "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}$", - "description": "The year and month when the conference was held, format: YYYY-MM." - }, - "builder": { - "type": "string", - "description": "The name of the builder associated with the conference. This will be changed to a UUID in the future." - }, - "location": { - "type": "string", - "description": "The location of the conference, format: Town, Country." - }, - "language": { - "type": "array", - "description": "Defines the languages spoken during the conference, using ISO 639-1 language codes.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "links": { - "type": "object", - "properties": { - "website": { - "type": "string", - "description": "URL of the conference's main website." - }, - "twitter": { - "type": "string", - "description": "URL of the conference's Twitter profile." - } - }, - "required": ["website"], - "additionalProperties": false - }, - "tags": { - "type": "array", - "description": "List of associated tags with the conference. Specific list of tags to be defined.", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "assets_rules": { - "description": "Required image assets for conference content.", - "files": [ - { - "path": "assets/thumbnail.webp", - "required": true, - "format": "WebP", - "dimensions": { - "width": 1280, - "height": 720, - "aspect_ratio": "16:9", - "orientation": "horizontal" - }, - "description": "Conference banner or promotional image" - } - ] - }, - "required": ["name", "year", "builder", "location", "language", "links", "tags"], - "additionalProperties": false -} diff --git a/scripts/validation-format/schemas/movie-scheme.json b/scripts/validation-format/schemas/movie-scheme.json deleted file mode 100644 index 07d05ba6704..00000000000 --- a/scripts/validation-format/schemas/movie-scheme.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "movie-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Unique identifier for the movie, UUID v4 format." - }, - "title": { - "type": "string", - "description": "Complete title of the movie or documentary." - }, - "author": { - "type": "string", - "description": "Full name of the director or creator(s)." - }, - "publication_year": { - "type": "integer", - "minimum": 1900, - "maximum": 2100, - "description": "The year the movie was published or released." - }, - "duration": { - "type": "integer", - "minimum": 1, - "description": "Duration of the movie in minutes." - }, - "language": { - "type": "string", - "description": "Original audio language of the content, using ISO 639-1 language codes (e.g., en, es, fr)." - }, - "links": { - "type": "object", - "properties": { - "platform": { - "type": "string", - "format": "uri", - "description": "URL to the main viewing platform (YouTube, Prime Video, etc.)." - }, - "trailer": { - "type": "string", - "format": "uri", - "description": "URL to the movie trailer." - } - }, - "required": ["platform"], - "additionalProperties": false - }, - "description": { - "type": "string", - "description": "Multiline description of the movie content." - }, - "contributor_names": { - "type": "array", - "description": "List of contributors identified by their GitHub usernames.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "tags": { - "type": "array", - "description": "List of associated tags for the movie. Tags must be from the predefined list of 51 items.", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "license": { - "type": "string", - "enum": ["CC-BY-SA-V4", "MIT"], - "description": "License for the content. CC-BY-SA-V4 is the default with share-alike clause. MIT allows almost unrestricted use." - } - }, - "assets_rules": { - "description": "Required image assets for movies.", - "files": [ - { - "path": "assets/thumbnail.webp", - "required": true, - "format": "WebP", - "dimensions": { - "width": 1280, - "height": 720, - "aspect_ratio": "16:9" - }, - "orientation": "horizontal", - "description": "Movie thumbnail image. Should be a movie poster or representative frame." - } - ] - }, - "required": ["id", "title", "author", "publication_year", "duration", "language", "links", "description", "contributor_names", "tags"], - "additionalProperties": false -} diff --git a/scripts/validation-format/schemas/professor-content-scheme.json b/scripts/validation-format/schemas/professor-content-scheme.json deleted file mode 100644 index 45f26d730a6..00000000000 --- a/scripts/validation-format/schemas/professor-content-scheme.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "professor-content-characteristics", - "type": "object", - "properties": { - "bio": { - "type": "string", - "description": "Full biography of the professor. Multiline text allowed." - }, - "short_bio": { - "type": "string", - "description": "A short one-line biography or tagline for the professor." - } - }, - "required": ["bio", "short_bio"], - "additionalProperties": false -} diff --git a/scripts/validation-format/schemas/quizz-question-scheme.json b/scripts/validation-format/schemas/quizz-question-scheme.json deleted file mode 100644 index 085664dd574..00000000000 --- a/scripts/validation-format/schemas/quizz-question-scheme.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "quiz-question", - "type": "object", - "properties": { - "chapterId": { - "type": "string", - "description": "Defines the chapter this question is related to, using the chapterId." - }, - "difficulty": { - "type": "string", - "description": "Difficulty level of the question.", - "enum": ["easy", "intermediate", "hard", "expert"] - }, - "author": { - "type": "string", - "description": "The contribution-id of the author of the question." - }, - "tags": { - "type": "array", - "description": "List of tags associated with the question.", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "required": ["chapterId", "difficulty", "author", "tags"], - "additionalProperties": false -} - diff --git a/scripts/validation-format/schemas/quizz-translation-scheme.json b/scripts/validation-format/schemas/quizz-translation-scheme.json deleted file mode 100644 index 6def9dd8e2b..00000000000 --- a/scripts/validation-format/schemas/quizz-translation-scheme.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "quiz-question-content", - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "The complete question text." - }, - "answer": { - "type": "string", - "description": "The correct answer to the question." - }, - "wrong_answers": { - "type": "array", - "description": "A list of three incorrect answers to the question.", - "items": { - "type": "string" - }, - "minItems": 3, - "maxItems": 3 - }, - "explanation": { - "type": "string", - "description": "Additional information explaining why the correct answer is true and why the other answers are incorrect." - }, - "reviewed": { - "type": "boolean", - "description": "Indicates whether the question has been reviewed by at least two different persons." - } - }, - "required": ["question", "answer", "wrong_answers", "explanation", "reviewed"], - "additionalProperties": false -} - diff --git a/scripts/validation-format/schemas/word-content-scheme.json b/scripts/validation-format/schemas/word-content-scheme.json deleted file mode 100644 index bc9cd85a071..00000000000 --- a/scripts/validation-format/schemas/word-content-scheme.json +++ /dev/null @@ -1,246 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "glossary-word-content", - "description": "Schema for validating glossary word markdown files (e.g., en.md, fr.md). These files contain YAML front-matter with the term field and markdown content defining the glossary term.", - "type": "object", - "properties": { - "frontmatter": { - "type": "object", - "description": "YAML front-matter block enclosed by --- delimiters at the start of the file", - "properties": { - "term": { - "type": "string", - "description": "The glossary term in UPPERCASE. May include parenthetical additions for abbreviations, opcodes, or clarifications (e.g., 'SATOSHI (SAT)', 'OP_ROLL (0X7A)', 'HOT WALLET (SOFTWARE)'). Numbers should be written out (e.g., '51 PERCENT ATTACK' not '51%').", - "pattern": "^[A-Z0-9][A-Z0-9 _+-]*(?:\\s*\\([A-Z0-9 ]+\\))?$", - "examples": [ - "WALLET", - "SATOSHI (SAT)", - "51 PERCENT ATTACK", - "OP_ROLL (0X7A)", - "SHA256", - "C++ (PLUS PLUS)", - "API" - ] - } - }, - "required": ["term"], - "additionalProperties": false - }, - "content_rules": { - "type": "object", - "description": "Documentation of markdown content structural requirements. This property describes validation rules that should be enforced by content validators.", - "properties": { - "definition_paragraph": { - "type": "object", - "description": "The primary definition paragraph immediately following the YAML front-matter", - "properties": { - "required": { - "type": "boolean", - "const": true, - "description": "A definition paragraph is required immediately after the front-matter" - }, - "position": { - "type": "string", - "const": "immediately_after_frontmatter", - "description": "Must appear directly after the closing --- of the front-matter" - }, - "guidelines": { - "type": "array", - "description": "Content guidelines for the definition paragraph", - "items": { - "type": "string" - }, - "default": [ - "Defines what the term is in clear, technical language", - "For acronyms, start with: Acronym for \"[Full Name]\".", - "Should be comprehensive but concise", - "Use backticks for technical terms: `scriptPubKey`, `SHA256`" - ] - } - }, - "additionalProperties": false - }, - "additional_paragraphs": { - "type": "object", - "description": "Optional extended explanation paragraphs", - "properties": { - "required": { - "type": "boolean", - "const": false, - "description": "Additional paragraphs are optional" - }, - "guidelines": { - "type": "array", - "description": "Content guidelines for additional paragraphs", - "items": { - "type": "string" - }, - "default": [ - "Provide deeper technical explanation if needed", - "Include examples, use cases, or historical context", - "May include bulleted or numbered lists" - ] - } - }, - "additionalProperties": false - }, - "code_formatting": { - "type": "object", - "description": "Rules for inline code and code blocks", - "properties": { - "inline_code": { - "type": "string", - "const": "backticks", - "description": "Use single backticks for technical terms: `scriptPubKey`, `SHA256`, `HASH160`" - }, - "examples": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "`scriptPubKey`", - "`SHA256`", - "`RIPEMD160`", - "`OP_CHECKSIG`" - ] - } - }, - "additionalProperties": false - }, - "math_formulas": { - "type": "object", - "description": "Rules for mathematical formulas using LaTeX notation", - "properties": { - "inline_format": { - "type": "string", - "const": "$formula$", - "description": "Single dollar signs for inline math" - }, - "block_format": { - "type": "string", - "const": "$$formula$$", - "description": "Double dollar signs for block/display math" - }, - "examples": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "$y^2 = x^3 + 7$", - "$H(m_1) = H(m_2)$", - "$$s_A = n_A + t + H(N_A + T \\parallel P_A \\parallel m_A) \\cdot p_A$$" - ] - } - }, - "additionalProperties": false - }, - "images": { - "type": "object", - "description": "Rules for including images in glossary definitions", - "properties": { - "required": { - "type": "boolean", - "const": false, - "description": "Images are optional" - }, - "format": { - "type": "string", - "const": "![](relative/path/to/image.webp)", - "description": "Standard markdown image syntax with empty alt text" - }, - "path_pattern": { - "type": "string", - "const": "../../dictionnaire/assets/XX.webp", - "description": "Relative path from word directory to assets folder" - }, - "preferred_format": { - "type": "string", - "const": "webp", - "description": "Preferred image format is WebP" - } - }, - "additionalProperties": false - }, - "alternative_names_blockquote": { - "type": "object", - "description": "Optional blockquote at the END of the file containing alternative names, translations, or etymology", - "properties": { - "required": { - "type": "boolean", - "const": false, - "description": "Alternative names blockquote is optional" - }, - "position": { - "type": "string", - "const": "end_of_file", - "description": "MUST appear at the very end of the file if present" - }, - "format": { - "type": "string", - "const": "> *text in italics*", - "description": "Blockquote with arrow symbol and italicized text" - }, - "pattern": { - "type": "string", - "const": "^> ► \\*.*\\*$", - "description": "Regex pattern: starts with '> ► *' and ends with '*'" - }, - "content_types": { - "type": "array", - "description": "Types of content that appear in the alternative names blockquote", - "items": { - "type": "string" - }, - "default": [ - "Alternative English names for the term", - "French translation or equivalent term", - "Etymology or origin notes", - "Related terminology clarifications" - ] - }, - "examples": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "> ► *This attack is also known as the \"Goldfinger attack\".*", - "> ► *In French, this is translated as \"interface de programmation d'applications\" or simply \"interface de programmation.\"*", - "> ► *In English, it is referred to as a \"hash function\".*" - ] - } - }, - "additionalProperties": false - }, - "links": { - "type": "object", - "description": "Rules for hyperlinks in content", - "properties": { - "format": { - "type": "string", - "const": "[text](url)", - "description": "Standard markdown link syntax" - }, - "guidelines": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "Use descriptive link text, not raw URLs", - "Prefer linking to primary sources (BIPs, papers, official repos)", - "External links should point to stable, reliable resources" - ] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "required": ["frontmatter"], - "additionalProperties": false -} diff --git a/scripts/validation-format/schemas/word-scheme.json b/scripts/validation-format/schemas/word-scheme.json deleted file mode 100644 index 74387a6a755..00000000000 --- a/scripts/validation-format/schemas/word-scheme.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "word-characteristics", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the glossary word using UUID v4 format", - "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" - }, - "en_word": { - "type": "string", - "description": "The word in English, typically in uppercase. This is the basis for identifying the word in the glossary" - }, - "related_words": { - "type": ["array", "null"], - "description": "List of related words in the glossary, using their en_word names", - "items": { - "type": "string" - } - }, - "tags": { - "type": "array", - "description": "List of tags associated with the word, chosen from the predefined PlanB tags list", - "items": { - "type": "string" - } - }, - "original_language": { - "type": "string", - "description": "ISO 639-1 language code indicating the original language of the word definition", - "pattern": "^[a-z]{2}(-[A-Z]{2}|-[A-Za-z]{4})?$" - }, - "license": { - "type": "string", - "description": "License under which the content is distributed", - "enum": ["CC-BY-SA-V4", "MIT"] - }, - "proofreading": { - "type": "array", - "description": "Proofreading metadata for each language translation", - "items": { - "type": "object", - "properties": { - "language": { - "type": "string", - "description": "ISO 639-1 language code for this proofreading entry", - "pattern": "^[a-z]{2}(-[A-Z]{2}|-[A-Za-z]{4})?$" - }, - "last_contribution_date": { - "description": "Date of the last contribution in YYYY-MM-DD format, or null if not yet contributed" - }, - "urgency": { - "type": "integer", - "description": "Level of urgency for proofreading, 1 being low urgency", - "minimum": 1 - }, - "contributor_names": { - "type": ["array", "null"], - "description": "List of contributor GitHub usernames who have proofread this language", - "items": { - "type": "string" - } - }, - "reward": { - "type": "number", - "description": "Amount of sats available as reward for proofreading", - "minimum": 0 - } - }, - "required": ["language", "urgency", "reward"], - "additionalProperties": false - } - } - }, - "required": ["id", "en_word", "original_language", "proofreading"], - "additionalProperties": false -} diff --git a/scripts/validation-format/validate.py b/scripts/validation-format/validate.py deleted file mode 100644 index 48ab86a661d..00000000000 --- a/scripts/validation-format/validate.py +++ /dev/null @@ -1,904 +0,0 @@ -#!/usr/bin/env python3 -""" -PlanB Network Content Schema Validator - -Validates content folders against JSON schema definitions. -Checks both YAML metadata files and markdown content files. - -The validator auto-detects the repository root and uses centralized schemas -from scripts/validation-format/schemas/. - -Usage: - python validate.py - python validate.py courses/btc101 - python validate.py tutorials/wallet/sparrow-wallet - python validate.py professors/pbn-author -""" - -import argparse -import json -import os -import re -import sys -from dataclasses import dataclass, field -from datetime import date, datetime -from pathlib import Path -from typing import Any, Optional - -try: - import jsonschema - from jsonschema import Draft7Validator, ValidationError as JsonSchemaError -except ImportError: - print("Error: jsonschema package required. Install with: pip install jsonschema") - sys.exit(1) - -try: - import yaml -except ImportError: - print("Error: PyYAML package required. Install with: pip install pyyaml") - sys.exit(1) - -try: - import frontmatter -except ImportError: - print("Error: python-frontmatter package required. Install with: pip install python-frontmatter") - sys.exit(1) - - -# ANSI color codes for terminal output -class Colors: - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - CYAN = "\033[96m" - BOLD = "\033[1m" - END = "\033[0m" - - -@dataclass -class ValidationResult: - """Holds validation results for a single file or content check.""" - file_path: str - errors: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - - @property - def is_valid(self) -> bool: - return len(self.errors) == 0 - - def add_error(self, msg: str): - self.errors.append(msg) - - def add_warning(self, msg: str): - self.warnings.append(msg) - - -@dataclass -class ContentTypeConfig: - """Configuration for a content type.""" - name: str - yaml_file: str # e.g., "tutorial.yml", "professor.yml" - schema_file: str # e.g., "tutorial-scheme.json" - content_schema_file: Optional[str] = None # e.g., "tutorial-content-scheme.json" - has_markdown_content: bool = True - content_uses_frontmatter: bool = True # True for .md files with YAML frontmatter - content_uses_yml: bool = False # True for .yml content files (e.g., books, bet) - has_quizzes: bool = False # True for content types with quizz subfolder - - -# Content type configurations -CONTENT_TYPES: dict[str, ContentTypeConfig] = { - "tutorials": ContentTypeConfig( - name="Tutorial", - yaml_file="tutorial.yml", - schema_file="tutorial-scheme.json", - content_schema_file="tutorial-content-scheme.json", - ), - "courses": ContentTypeConfig( - name="Course", - yaml_file="course.yml", - schema_file="course-scheme.json", - content_schema_file="course-content-scheme.json", - has_quizzes=True, - ), - "professors": ContentTypeConfig( - name="Professor", - yaml_file="professor.yml", - schema_file="professor-scheme.json", - content_schema_file="professor-content-scheme.json", - ), - "events": ContentTypeConfig( - name="Event", - yaml_file="event.yml", - schema_file="event-scheme.json", - has_markdown_content=False, - ), - "resources/bet": ContentTypeConfig( - name="BET", - yaml_file="bet.yml", - schema_file="bet-scheme.json", - content_schema_file="bet-content-scheme.json", - content_uses_yml=True, - ), - "resources/books": ContentTypeConfig( - name="Book", - yaml_file="book.yml", - schema_file="book-scheme.json", - content_schema_file="book-content-scheme.json", - content_uses_yml=True, - ), - "resources/channels": ContentTypeConfig( - name="Channel", - yaml_file="channel.yml", - schema_file="channel-scheme.json", - has_markdown_content=False, - ), - "resources/conferences": ContentTypeConfig( - name="Conference", - yaml_file="conference.yml", - schema_file="conference-scheme.json", - has_markdown_content=False, - ), - "resources/glossary": ContentTypeConfig( - name="Glossary Word", - yaml_file="word.yml", - schema_file="word-scheme.json", - content_schema_file="word-content-scheme.json", - ), - "resources/movies": ContentTypeConfig( - name="Movie", - yaml_file="movie.yml", - schema_file="movie-scheme.json", - has_markdown_content=False, - ), - "resources/newsletters": ContentTypeConfig( - name="Newsletter", - yaml_file="newsletter.yml", - schema_file="newsletter-scheme.json", - has_markdown_content=False, - ), - "resources/podcasts": ContentTypeConfig( - name="Podcast", - yaml_file="podcast.yml", - schema_file="podcast-scheme.json", - has_markdown_content=False, - ), - "resources/projects": ContentTypeConfig( - name="Project", - yaml_file="project.yml", - schema_file="project-scheme.json", - content_schema_file="project-content-scheme.json", - content_uses_yml=True, - ), - "resources/papers": ContentTypeConfig( - name="Paper", - yaml_file="paper.yml", - schema_file="paper-scheme.json", - has_markdown_content=False, - ), -} - - -class SchemaValidator: - """Validates content folders against JSON schemas.""" - - def __init__(self, base_path: str): - self.base_path = Path(base_path).resolve() - # Schema path is now centralized in scripts/validation-format/schemas/ - self.schema_path = self.base_path / "scripts" / "validation-format" / "schemas" - self.results: list[ValidationResult] = [] - - def _find_repo_root(self, folder_path: Path) -> Path: - """Find the repository root by looking for .git directory (primary) or known directories (fallback).""" - current = folder_path - # First pass: look for .git (most reliable) - for _ in range(15): # Max 15 levels up - if (current / ".git").exists(): - return current - if current.parent == current: - break - current = current.parent - - # Second pass: fallback to known directory structure - current = folder_path - for _ in range(15): - if all((current / d).exists() for d in ["courses", "tutorials", "professors"]): - # Make sure we're not in a template/docs subfolder - if "docs" not in current.parts[-3:] and "PBN-template-repo" not in current.parts: - return current - if current.parent == current: - break - current = current.parent - return folder_path - - def detect_content_type(self, folder_path: Path) -> Optional[ContentTypeConfig]: - """Detect content type from folder path.""" - # First, find the repo root relative to the folder - repo_root = self._find_repo_root(folder_path) - - try: - rel_path = str(folder_path.relative_to(repo_root)) - except ValueError: - # If folder is not relative to found repo root, try using base_path - try: - rel_path = str(folder_path.relative_to(self.base_path)) - except ValueError: - # Last resort: use folder name parts to detect type - rel_path = str(folder_path) - - # Check for nested content types (tutorials have category subfolder) - parts = rel_path.split(os.sep) - - # Try matching from most specific to least specific - for content_path, config in CONTENT_TYPES.items(): - content_parts = content_path.split("/") - if parts[:len(content_parts)] == content_parts: - return config - - return None - - def find_schema_file(self, config: ContentTypeConfig) -> Optional[Path]: - """Find the schema file for the content type from centralized location.""" - schema_file = self.schema_path / config.schema_file - if schema_file.exists(): - return schema_file - return None - - def find_content_schema_file(self, config: ContentTypeConfig) -> Optional[Path]: - """Find the content schema file for the content type from centralized location.""" - if not config.content_schema_file: - return None - - schema_file = self.schema_path / config.content_schema_file - if schema_file.exists(): - return schema_file - return None - - def load_json_schema(self, schema_path: Path) -> dict: - """Load a JSON schema file.""" - with open(schema_path, 'r', encoding='utf-8') as f: - return json.load(f) - - def load_yaml_file(self, yaml_path: Path) -> dict: - """Load a YAML file.""" - with open(yaml_path, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) or {} - # Convert datetime objects to strings for JSON schema validation - return self._convert_dates_to_strings(data) - - def _convert_dates_to_strings(self, obj: Any) -> Any: - """Recursively convert datetime.date and datetime.datetime objects to ISO format strings.""" - if isinstance(obj, datetime): - return obj.strftime("%Y-%m-%d %H:%M:%S") - elif isinstance(obj, date): - return obj.strftime("%Y-%m-%d") - elif isinstance(obj, dict): - return {k: self._convert_dates_to_strings(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [self._convert_dates_to_strings(item) for item in obj] - return obj - - def _find_and_remove_null_values(self, obj: Any, path: str = "") -> tuple[Any, list[str]]: - """ - Recursively find null values and remove them from the data structure. - Returns the cleaned object and a list of paths where null values were found. - """ - null_paths = [] - - if obj is None: - return None, [path] if path else [] - elif isinstance(obj, dict): - cleaned = {} - for k, v in obj.items(): - current_path = f"{path} -> {k}" if path else k - if v is None: - null_paths.append(current_path) - # Don't include null values in cleaned dict - else: - cleaned_v, sub_nulls = self._find_and_remove_null_values(v, current_path) - null_paths.extend(sub_nulls) - if cleaned_v is not None: - cleaned[k] = cleaned_v - return cleaned, null_paths - elif isinstance(obj, list): - cleaned = [] - for i, item in enumerate(obj): - current_path = f"{path}[{i}]" - if item is None: - null_paths.append(current_path) - else: - cleaned_item, sub_nulls = self._find_and_remove_null_values(item, current_path) - null_paths.extend(sub_nulls) - if cleaned_item is not None: - cleaned.append(cleaned_item) - return cleaned, null_paths - return obj, null_paths - - def validate_yaml_against_schema(self, yaml_data: dict, schema: dict, file_path: str) -> ValidationResult: - """Validate YAML data against a JSON schema.""" - result = ValidationResult(file_path=file_path) - - # Find and remove null values, reporting them as warnings - cleaned_data, null_paths = self._find_and_remove_null_values(yaml_data) - for null_path in null_paths: - result.add_warning(f"[{null_path}] Empty/null value") - - validator = Draft7Validator(schema) - errors = list(validator.iter_errors(cleaned_data)) - - for error in errors: - path = " -> ".join(str(p) for p in error.absolute_path) if error.absolute_path else "root" - result.add_error(f"[{path}] {error.message}") - - return result - - def validate_markdown_content(self, md_path: Path, content_schema: dict) -> ValidationResult: - """Validate markdown content file against content schema.""" - result = ValidationResult(file_path=str(md_path)) - - try: - post = frontmatter.load(md_path) - except Exception as e: - result.add_error(f"Failed to parse markdown file: {e}") - return result - - # Validate frontmatter against schema properties - schema_props = content_schema.get("properties", {}) - required_fields = content_schema.get("required", []) - - # Check required frontmatter fields - for field in required_fields: - if field not in post.metadata: - result.add_error(f"Missing required frontmatter field: '{field}'") - - # Validate frontmatter field types and constraints - for field, value in post.metadata.items(): - if field in schema_props: - field_schema = schema_props[field] - self._validate_field(result, field, value, field_schema) - - # Validate content rules if present - content_rules = content_schema.get("content_rules", {}) - if content_rules: - self._validate_content_rules(result, post.content, content_rules, md_path) - - return result - - def _validate_field(self, result: ValidationResult, field: str, value: Any, schema: dict): - """Validate a single field against its schema.""" - expected_type = schema.get("type") - - type_mapping = { - "string": str, - "integer": int, - "number": (int, float), - "boolean": bool, - "array": list, - "object": dict, - } - - if expected_type and expected_type in type_mapping: - expected = type_mapping[expected_type] - if not isinstance(value, expected): - result.add_error(f"Field '{field}' should be {expected_type}, got {type(value).__name__}") - - # Check minLength/maxLength for strings - if expected_type == "string" and isinstance(value, str): - min_len = schema.get("minLength") - max_len = schema.get("maxLength") - if min_len and len(value) < min_len: - result.add_error(f"Field '{field}' is too short (min {min_len} chars)") - if max_len and len(value) > max_len: - result.add_error(f"Field '{field}' is too long (max {max_len} chars)") - - # Check enum values - if "enum" in schema and value not in schema["enum"]: - result.add_error(f"Field '{field}' has invalid value '{value}'. Allowed: {schema['enum']}") - - def _validate_content_rules(self, result: ValidationResult, content: str, rules: dict, md_path: Path): - """Validate markdown content against content rules.""" - lines = content.split('\n') - - # Check cover image - cover_rule = rules.get("cover_image", {}) - if cover_rule.get("required", False): - # Cover must appear within the first 10 lines after frontmatter - # Pattern: ![any-alt-text](cover.webp) or ![any-alt-text](assets/cover.webp) - cover_pattern = re.compile(r'!\[.*?\]\((?:assets/)?cover\.webp\)') - first_10_lines = '\n'.join(lines[:10]) - if not cover_pattern.search(first_10_lines): - result.add_error("Cover image ![...](cover.webp) or ![...](assets/cover.webp) must appear within the first 10 lines after YAML front-matter") - - # Check headings - heading_rules = rules.get("headings", {}) - h1_rule = heading_rules.get("h1", {}) - if h1_rule.get("allowed") is False: - # Remove code blocks before checking for H1 headings - # Remove fenced code blocks (``` or ~~~) - content_no_code = re.sub(r'```[\s\S]*?```', '', content) - content_no_code = re.sub(r'~~~[\s\S]*?~~~', '', content_no_code) - # Remove indented code blocks (4 spaces or 1 tab at line start) - content_no_code = re.sub(r'^(?: |\t).*$', '', content_no_code, flags=re.MULTILINE) - - h1_matches = re.findall(r'^# [^#]', content_no_code, re.MULTILINE) - if h1_matches: - result.add_error("H1 headings (# Title) are not allowed - title comes from YAML 'name' field") - - # Check list markers - list_rules = rules.get("lists", {}) - unordered_rule = list_rules.get("unordered", {}) - if unordered_rule.get("marker") == "-": - # Check for asterisk list markers - asterisk_lists = re.findall(r'^\* ', content, re.MULTILINE) - if asterisk_lists: - result.add_warning(f"Found {len(asterisk_lists)} asterisk (*) list markers. Use dash (-) instead.") - - # Check images format - image_rules = rules.get("images", {}) - if image_rules.get("format") == "WebP only (.webp extension)": - non_webp = re.findall(r'!\[.*?\]\([^)]+\.(png|jpg|jpeg|gif)\)', content, re.IGNORECASE) - if non_webp: - result.add_error(f"Non-WebP images found. All images must be .webp format.") - - # Check for alt text on images - alt_text_rule = image_rules.get("alt_text", {}) - if alt_text_rule.get("required"): - empty_alt = re.findall(r'!\[\]\(', content) - if empty_alt: - result.add_warning(f"Found {len(empty_alt)} images without alt text") - - # Check assets folder exists - assets_path = md_path.parent / "assets" - if cover_rule.get("required") and not assets_path.exists(): - result.add_error("Assets folder is missing") - - # Check cover.webp exists - if cover_rule.get("required") and assets_path.exists(): - cover_path = assets_path / "cover.webp" - if not cover_path.exists(): - result.add_error("Cover image 'assets/cover.webp' is missing") - - def validate_yml_content(self, yml_path: Path, content_schema: dict) -> ValidationResult: - """Validate YML content files (used by books, bet, etc.).""" - result = ValidationResult(file_path=str(yml_path)) - - try: - data = self.load_yaml_file(yml_path) - except Exception as e: - result.add_error(f"Failed to parse YAML file: {e}") - return result - - # Use JSON schema validation - schema_props = content_schema.get("properties", {}) - required_fields = content_schema.get("required", []) - - for field in required_fields: - if field not in data: - result.add_error(f"Missing required field: '{field}'") - - for field, value in data.items(): - if field in schema_props: - self._validate_field(result, field, value, schema_props[field]) - - return result - - def validate_quiz_folder(self, quiz_folder: Path) -> list[ValidationResult]: - """Validate a single quiz folder (e.g., quizz/000/).""" - results = [] - - # Check for question.yml (required) - question_file = quiz_folder / "question.yml" - if not question_file.exists(): - result = ValidationResult(file_path=str(question_file)) - result.add_error("Missing required file: question.yml") - results.append(result) - return results - - # Validate question.yml structure - result = ValidationResult(file_path=str(question_file)) - try: - data = self.load_yaml_file(question_file) - - # Required fields in question.yml - required_fields = ["id", "chapterId", "difficulty", "duration", "author"] - for field in required_fields: - if field not in data: - result.add_error(f"Missing required field: '{field}'") - - # Validate UUID format for id and chapterId - uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - if "id" in data and not re.match(uuid_pattern, str(data.get("id", "")), re.IGNORECASE): - result.add_error(f"Field 'id' is not a valid UUID: {data.get('id')}") - if "chapterId" in data and not re.match(uuid_pattern, str(data.get("chapterId", "")), re.IGNORECASE): - result.add_error(f"Field 'chapterId' is not a valid UUID: {data.get('chapterId')}") - - # Validate difficulty enum - valid_difficulties = ["easy", "intermediate", "hard"] - if "difficulty" in data and data["difficulty"] not in valid_difficulties: - result.add_error(f"Field 'difficulty' has invalid value '{data['difficulty']}'. Allowed: {valid_difficulties}") - - # Validate duration is a positive number - if "duration" in data: - if not isinstance(data["duration"], (int, float)) or data["duration"] <= 0: - result.add_error(f"Field 'duration' must be a positive number, got: {data['duration']}") - - except Exception as e: - result.add_error(f"Failed to parse question.yml: {e}") - - results.append(result) - - # Check for at least one translation file - translation_files = [f for f in quiz_folder.glob("*.yml") if f.name != "question.yml"] - if not translation_files: - result = ValidationResult(file_path=str(quiz_folder)) - result.add_error("No translation files found (e.g., en.yml, fr.yml)") - results.append(result) - else: - # Validate each translation file structure - for trans_file in translation_files: - trans_result = self._validate_quiz_translation(trans_file) - if trans_result.errors or trans_result.warnings: - results.append(trans_result) - - return results - - def _validate_quiz_translation(self, trans_file: Path) -> ValidationResult: - """Validate a quiz translation file (e.g., en.yml, fr.yml).""" - result = ValidationResult(file_path=str(trans_file)) - - try: - data = self.load_yaml_file(trans_file) - - # Required fields in translation files - required_fields = ["question", "answer", "wrong_answers"] - for field in required_fields: - if field not in data: - result.add_error(f"Missing required field: '{field}'") - - # Validate wrong_answers is a list with at least 1 item - if "wrong_answers" in data: - if not isinstance(data["wrong_answers"], list): - result.add_error("Field 'wrong_answers' must be a list") - elif len(data["wrong_answers"]) < 1: - result.add_error("Field 'wrong_answers' must have at least 1 item") - - except Exception as e: - result.add_error(f"Failed to parse translation file: {e}") - - return result - - def validate_quizzes(self, folder: Path) -> list[ValidationResult]: - """Validate all quizzes in a content folder's quizz/ subdirectory.""" - results = [] - quizz_folder = folder / "quizz" - - if not quizz_folder.exists(): - # Quiz folder is optional - just return empty results - return results - - if not quizz_folder.is_dir(): - result = ValidationResult(file_path=str(quizz_folder)) - result.add_error("'quizz' exists but is not a directory") - return [result] - - # Get all quiz subfolders (numbered folders like 000, 001, etc.) - # Skip hidden folders (starting with '.') - quiz_subfolders = sorted([d for d in quizz_folder.iterdir() if d.is_dir() and not d.name.startswith('.')]) - - if not quiz_subfolders: - result = ValidationResult(file_path=str(quizz_folder)) - result.add_warning("Quiz folder exists but contains no quiz subfolders") - return [result] - - # Validate each quiz subfolder - for quiz_folder in quiz_subfolders: - quiz_results = self.validate_quiz_folder(quiz_folder) - results.extend(quiz_results) - - # Create summary result for quiz validation - summary_result = ValidationResult(file_path=str(quizz_folder)) - total_quizzes = len(quiz_subfolders) - quizzes_with_errors = sum(1 for r in results if r.errors) - - if quizzes_with_errors == 0: - # All quizzes valid - don't add to results (will show in summary) - pass - else: - summary_result.add_warning(f"Validated {total_quizzes} quizzes, {quizzes_with_errors} with errors") - results.insert(0, summary_result) - - return results - - def _validate_event_semantics(self, yaml_data: dict, file_path: str) -> ValidationResult: - """Validate semantic rules for events that go beyond schema type checking.""" - result = ValidationResult(file_path=file_path) - - booking_enabled = yaml_data.get("book_online") is True or yaml_data.get("book_in_person") is True - - if booking_enabled and "available_seats" not in yaml_data: - result.add_warning("Booking enabled but available_seats not set. Internal events should specify capacity.") - - if booking_enabled and "project_id" not in yaml_data: - result.add_warning("Booking enabled but no project_id. If this is an external event, set book_online/book_in_person to false.") - - if not booking_enabled and yaml_data.get("price_dollars", 0) > 0: - result.add_warning("Price set but booking is disabled. External events should not have a price.") - - return result - - def validate_folder(self, folder_path: str) -> list[ValidationResult]: - """Validate a content folder.""" - folder = Path(folder_path) - - if not folder.is_absolute(): - # Try resolving from current directory first - resolved_from_cwd = (Path.cwd() / folder).resolve() - - if resolved_from_cwd.exists(): - folder = resolved_from_cwd - else: - # Try resolving from repo root (for paths like "courses/btc101") - resolved_from_repo = (self.base_path / folder).resolve() - if resolved_from_repo.exists(): - folder = resolved_from_repo - else: - # Last try: strip leading "../" and try from repo root - # This handles cases like "../courses/btc101" when user is deep in the repo - clean_path = str(folder) - while clean_path.startswith("../") or clean_path.startswith("..\\"): - clean_path = clean_path[3:] - resolved_clean = (self.base_path / clean_path).resolve() - if resolved_clean.exists(): - folder = resolved_clean - else: - folder = resolved_from_cwd # Use original for error message - else: - folder = folder.resolve() - - if not folder.exists(): - result = ValidationResult(file_path=str(folder)) - result.add_error(f"Folder does not exist. Try using absolute path or run from repo root.") - return [result] - - if not folder.is_dir(): - result = ValidationResult(file_path=str(folder)) - result.add_error("Path is not a directory") - return [result] - - # Detect content type - config = self.detect_content_type(folder) - if not config: - result = ValidationResult(file_path=str(folder)) - result.add_error(f"Could not detect content type for folder. Supported types: {list(CONTENT_TYPES.keys())}") - return [result] - - results = [] - - # Find and validate main YAML file - yaml_path = folder / config.yaml_file - if not yaml_path.exists(): - result = ValidationResult(file_path=str(yaml_path)) - result.add_warning(f"Missing metadata file: {config.yaml_file} (empty folder?)") - results.append(result) - return results - else: - schema_path = self.find_schema_file(config) - if schema_path: - schema = self.load_json_schema(schema_path) - yaml_data = self.load_yaml_file(yaml_path) - result = self.validate_yaml_against_schema(yaml_data, schema, str(yaml_path)) - results.append(result) - - # Run semantic validation for events - if config.name == "Event": - semantic_result = self._validate_event_semantics(yaml_data, str(yaml_path)) - if semantic_result.warnings or semantic_result.errors: - results.append(semantic_result) - else: - result = ValidationResult(file_path=str(yaml_path)) - result.add_warning(f"Schema file '{config.schema_file}' not found - skipping validation") - results.append(result) - - # Validate content files if applicable - if config.has_markdown_content: - content_schema_path = self.find_content_schema_file(config) - content_schema = self.load_json_schema(content_schema_path) if content_schema_path else None - - if config.content_uses_yml: - # Validate .yml content files (books, bet, projects) - for yml_file in folder.glob("*.yml"): - if yml_file.name != config.yaml_file: # Skip main metadata file - if content_schema: - result = self.validate_yml_content(yml_file, content_schema) - else: - result = ValidationResult(file_path=str(yml_file)) - result.add_warning("Content schema not found - skipping validation") - results.append(result) - else: - # Validate .md content files - # Skip presentation.md as it has no frontmatter requirements - for md_file in folder.glob("*.md"): - if md_file.name == "presentation.md": - continue # Skip presentation files - they don't have frontmatter - if content_schema: - result = self.validate_markdown_content(md_file, content_schema) - else: - result = ValidationResult(file_path=str(md_file)) - result.add_warning("Content schema not found - skipping validation") - results.append(result) - - # Validate quizzes if applicable - if config.has_quizzes: - quiz_results = self.validate_quizzes(folder) - results.extend(quiz_results) - - return results - - def print_results(self, results: list[ValidationResult]): - """Print validation results in a formatted way.""" - total_errors = sum(len(r.errors) for r in results) - total_warnings = sum(len(r.warnings) for r in results) - - print(f"\n{Colors.BOLD}{'='*60}{Colors.END}") - print(f"{Colors.BOLD}Validation Results{Colors.END}") - print(f"{'='*60}\n") - - for result in results: - rel_path = result.file_path - # Try to make path relative to repo root for cleaner output - file_path = Path(result.file_path) - repo_root = self._find_repo_root(file_path.parent if file_path.is_file() else file_path) - try: - rel_path = str(file_path.relative_to(repo_root)) - except ValueError: - try: - rel_path = str(file_path.relative_to(self.base_path)) - except ValueError: - pass - - if result.errors or result.warnings: - status = f"{Colors.RED}FAILED{Colors.END}" if result.errors else f"{Colors.YELLOW}WARNINGS{Colors.END}" - print(f"{Colors.CYAN}{rel_path}{Colors.END} - {status}") - - for error in result.errors: - print(f" {Colors.RED}ERROR:{Colors.END} {error}") - - for warning in result.warnings: - print(f" {Colors.YELLOW}WARNING:{Colors.END} {warning}") - - print() - else: - print(f"{Colors.CYAN}{rel_path}{Colors.END} - {Colors.GREEN}PASSED{Colors.END}") - - print(f"\n{'='*60}") - if total_errors == 0: - print(f"{Colors.GREEN}{Colors.BOLD}All validations passed!{Colors.END}") - else: - print(f"{Colors.RED}{Colors.BOLD}Validation failed:{Colors.END} {total_errors} error(s), {total_warnings} warning(s)") - print(f"{'='*60}\n") - - return total_errors == 0 - - -def main(): - parser = argparse.ArgumentParser( - description="Validate PlanB Network content against JSON schemas", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python validate.py tutorials/wallet/sparrow-wallet - python validate.py courses/btc101 - python validate.py professors/pbn-author - python validate.py resources/bet/a-game-on-bitcoin - python validate.py events/awesome-workshop-YYYY - -Supported content types: - - tutorials// - - courses/ - - professors/ - - events/ - - resources/bet/ - - resources/books/ - - resources/channels/ - - resources/conferences/ - - resources/glossary/ - - resources/movies/ - - resources/newsletters/ - - resources/podcasts/ - - resources/projects/ - """ - ) - - parser.add_argument( - "folder", - help="Path to the content folder to validate (relative to repo root or absolute)" - ) - - parser.add_argument( - "--base-path", - default=None, - help="Base path of the repository (auto-detected if not specified)" - ) - - parser.add_argument( - "--json", "-j", - action="store_true", - help="Output results as JSON" - ) - - args = parser.parse_args() - - # Resolve the folder path first to help with repo root detection - folder_path = Path(args.folder) - if not folder_path.is_absolute(): - folder_path = Path.cwd() / folder_path - folder_path = folder_path.resolve() - - # Auto-detect repo root from folder path or base-path - def find_repo_root(start_path: Path) -> Path: - """Find repo root by looking for .git directory (primary) or known directories (fallback).""" - current = start_path - # First pass: look for .git (most reliable) - for _ in range(15): # Max 15 levels up - if (current / ".git").exists(): - return current - if current.parent == current: - break - current = current.parent - - # Second pass: fallback to known directory structure (but require .git to not be deeper) - current = start_path - for _ in range(15): - # Check for known directories AND verify this isn't inside a docs/template folder - if all((current / d).exists() for d in ["courses", "tutorials", "professors"]): - # Make sure we're not in a template/docs subfolder - if "docs" not in current.parts[-3:] and "PBN-template-repo" not in current.parts: - return current - if current.parent == current: - break - current = current.parent - return start_path - - if args.base_path: - base_path = Path(args.base_path).resolve() - else: - base_path = find_repo_root(folder_path) - - validator = SchemaValidator(str(base_path)) - results = validator.validate_folder(args.folder) - - if args.json: - output = { - "folder": args.folder, - "results": [ - { - "file": r.file_path, - "valid": r.is_valid, - "errors": r.errors, - "warnings": r.warnings - } - for r in results - ], - "total_errors": sum(len(r.errors) for r in results), - "total_warnings": sum(len(r.warnings) for r in results), - } - print(json.dumps(output, indent=2)) - total_errors = output["total_errors"] - total_warnings = output["total_warnings"] - else: - validator.print_results(results) - total_errors = sum(len(r.errors) for r in results) - total_warnings = sum(len(r.warnings) for r in results) - - # Exit codes: 0 = passed, 1 = errors, 2 = warnings only - if total_errors > 0: - sys.exit(1) - elif total_warnings > 0: - sys.exit(2) - else: - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/scripts/validation-format/validate_all.py b/scripts/validation-format/validate_all.py deleted file mode 100644 index 7976bf8e0fb..00000000000 --- a/scripts/validation-format/validate_all.py +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env python3 -""" -PlanB Network Bulk Content Validator - -Validates all content folders in the repository against their respective schemas. -Uses validate.py to validate each folder individually. - -Usage: - python validate_all.py # Validate all content - python validate_all.py --courses-only # Validate only courses - python validate_all.py --tutorials-only # Validate only tutorials - python validate_all.py --resources-only # Validate only resources - python validate_all.py --json # Output as JSON -""" - -import argparse -import json -import os -import subprocess -import sys -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - - -# ANSI color codes for terminal output -class Colors: - RED = "\033[91m" - GREEN = "\033[92m" - YELLOW = "\033[93m" - BLUE = "\033[94m" - CYAN = "\033[96m" - MAGENTA = "\033[95m" - BOLD = "\033[1m" - END = "\033[0m" - - -@dataclass -class ContentFolderResult: - """Holds validation results for a single content folder.""" - folder: str - content_type: str - total_errors: int = 0 - total_warnings: int = 0 - file_results: list = field(default_factory=list) - - @property - def is_valid(self) -> bool: - return self.total_errors == 0 - - -# Content types and their root directories -CONTENT_TYPE_ROOTS = { - "courses": "courses", - "tutorials": "tutorials", - "professors": "professors", - "events": "events", - "resources/bet": "resources/bet", - "resources/books": "resources/books", - "resources/channels": "resources/channels", - "resources/conferences": "resources/conferences", - "resources/glossary": "resources/glossary", - "resources/movies": "resources/movies", - "resources/newsletters": "resources/newsletters", - "resources/papers": "resources/papers", - "resources/podcasts": "resources/podcasts", - "resources/projects": "resources/projects", -} - - -def find_repo_root() -> Path: - """Find the repository root by looking for .git directory.""" - current = Path(__file__).resolve().parent - for _ in range(10): - if (current / ".git").exists(): - return current - if current.parent == current: - break - current = current.parent - - # Fallback: assume we're in scripts/validation-format/ - return Path(__file__).resolve().parent.parent.parent - - -def get_content_folders(repo_root: Path, content_type: str) -> list[Path]: - """Get all content folders for a given content type.""" - root_path = repo_root / content_type - - if not root_path.exists(): - return [] - - folders = [] - - # Special handling for tutorials (has category subfolders) - if content_type == "tutorials": - for category in root_path.iterdir(): - if category.is_dir() and not category.name.startswith('.'): - for tutorial in category.iterdir(): - if tutorial.is_dir() and not tutorial.name.startswith('.'): - # Check if it has a tutorial.yml file - if (tutorial / "tutorial.yml").exists(): - folders.append(tutorial) - else: - # Direct content folders (courses, professors, etc.) - for item in root_path.iterdir(): - if item.is_dir() and not item.name.startswith('.'): - folders.append(item) - - return sorted(folders) - - -def validate_folder(repo_root: Path, folder: Path, json_output: bool = True) -> dict: - """Run validate.py on a single folder and return the result.""" - validate_script = repo_root / "scripts" / "validation-format" / "validate.py" - - # Calculate relative path from repo root - try: - rel_path = folder.relative_to(repo_root) - except ValueError: - rel_path = folder - - cmd = [sys.executable, str(validate_script), str(rel_path), "--json"] - - try: - result = subprocess.run( - cmd, - cwd=str(repo_root), - capture_output=True, - text=True, - timeout=60 - ) - - # Parse JSON output - if result.stdout: - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - return { - "folder": str(rel_path), - "results": [], - "total_errors": 1, - "total_warnings": 0, - "error": f"Failed to parse validator output: {result.stdout[:200]}" - } - else: - return { - "folder": str(rel_path), - "results": [], - "total_errors": 1, - "total_warnings": 0, - "error": f"No output from validator. stderr: {result.stderr[:200] if result.stderr else 'none'}" - } - except subprocess.TimeoutExpired: - return { - "folder": str(rel_path), - "results": [], - "total_errors": 1, - "total_warnings": 0, - "error": "Validation timed out" - } - except Exception as e: - return { - "folder": str(rel_path), - "results": [], - "total_errors": 1, - "total_warnings": 0, - "error": str(e) - } - - -def print_progress(current: int, total: int, folder: str, result: dict, width: int = 50): - """Print progress bar and current folder status.""" - percent = current / total - filled = int(width * percent) - bar = "█" * filled + "░" * (width - filled) - - status = "" - if result["total_errors"] > 0: - status = f"{Colors.RED}✗{Colors.END}" - elif result["total_warnings"] > 0: - status = f"{Colors.YELLOW}!{Colors.END}" - else: - status = f"{Colors.GREEN}✓{Colors.END}" - - # Clear line and print progress - print(f"\r{Colors.CYAN}[{bar}]{Colors.END} {current}/{total} {status} {folder[:60]:<60}", end="", flush=True) - - -def main(): - parser = argparse.ArgumentParser( - description="Validate all PlanB Network content against JSON schemas", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python validate_all.py # Validate everything - python validate_all.py --courses-only # Validate only courses - python validate_all.py --tutorials-only # Validate only tutorials - python validate_all.py --resources-only # Validate all resource types - python validate_all.py --json # Output as JSON - python validate_all.py --summary-only # Show only summary, not individual errors - """ - ) - - # Filter options - parser.add_argument( - "--courses-only", - action="store_true", - help="Validate only courses" - ) - parser.add_argument( - "--tutorials-only", - action="store_true", - help="Validate only tutorials" - ) - parser.add_argument( - "--professors-only", - action="store_true", - help="Validate only professors" - ) - parser.add_argument( - "--events-only", - action="store_true", - help="Validate only events" - ) - parser.add_argument( - "--resources-only", - action="store_true", - help="Validate only resources (all resource types)" - ) - parser.add_argument( - "--type", - type=str, - help="Validate specific content type (e.g., 'resources/papers', 'tutorials')" - ) - - # Output options - parser.add_argument( - "--json", "-j", - action="store_true", - help="Output results as JSON" - ) - parser.add_argument( - "--summary-only", "-s", - action="store_true", - help="Show only summary statistics, not individual errors" - ) - parser.add_argument( - "--verbose", "-v", - action="store_true", - help="Show all results including passed validations" - ) - - args = parser.parse_args() - - repo_root = find_repo_root() - - # Determine which content types to validate - content_types_to_validate = [] - - if args.type: - if args.type in CONTENT_TYPE_ROOTS: - content_types_to_validate = [args.type] - else: - print(f"{Colors.RED}Error:{Colors.END} Unknown content type '{args.type}'") - print(f"Available types: {', '.join(CONTENT_TYPE_ROOTS.keys())}") - sys.exit(1) - elif args.courses_only: - content_types_to_validate = ["courses"] - elif args.tutorials_only: - content_types_to_validate = ["tutorials"] - elif args.professors_only: - content_types_to_validate = ["professors"] - elif args.events_only: - content_types_to_validate = ["events"] - elif args.resources_only: - content_types_to_validate = [t for t in CONTENT_TYPE_ROOTS if t.startswith("resources/")] - else: - content_types_to_validate = list(CONTENT_TYPE_ROOTS.keys()) - - # Collect all folders to validate - all_folders = [] - for content_type in content_types_to_validate: - folders = get_content_folders(repo_root, content_type) - for folder in folders: - all_folders.append((content_type, folder)) - - if not all_folders: - if args.json: - print(json.dumps({"error": "No content folders found to validate", "results": []})) - else: - print(f"{Colors.YELLOW}No content folders found to validate.{Colors.END}") - sys.exit(0) - - # Validate all folders - all_results = [] - total_errors = 0 - total_warnings = 0 - passed_count = 0 - failed_count = 0 - warning_count = 0 - - if not args.json: - print(f"\n{Colors.BOLD}Validating {len(all_folders)} content folders...{Colors.END}\n") - - for i, (content_type, folder) in enumerate(all_folders, 1): - result = validate_folder(repo_root, folder) - result["content_type"] = content_type - all_results.append(result) - - total_errors += result.get("total_errors", 0) - total_warnings += result.get("total_warnings", 0) - - if result.get("total_errors", 0) > 0: - failed_count += 1 - elif result.get("total_warnings", 0) > 0: - warning_count += 1 - else: - passed_count += 1 - - if not args.json: - print_progress(i, len(all_folders), result["folder"], result) - - if not args.json: - print() # New line after progress bar - - # Output results - if args.json: - output = { - "summary": { - "total_folders": len(all_folders), - "passed": passed_count, - "failed": failed_count, - "warnings_only": warning_count, - "total_errors": total_errors, - "total_warnings": total_warnings, - }, - "results": all_results - } - print(json.dumps(output, indent=2)) - else: - # Print detailed results (errors and warnings) - if not args.summary_only: - print(f"\n{Colors.BOLD}{'='*70}{Colors.END}") - print(f"{Colors.BOLD}Validation Details{Colors.END}") - print(f"{'='*70}\n") - - # Group results by content type - by_type: dict[str, list] = {} - for result in all_results: - ct = result.get("content_type", "unknown") - if ct not in by_type: - by_type[ct] = [] - by_type[ct].append(result) - - for content_type in sorted(by_type.keys()): - results = by_type[content_type] - - # Check if there are any errors or warnings in this type - has_issues = any( - r.get("total_errors", 0) > 0 or r.get("total_warnings", 0) > 0 - for r in results - ) - - if has_issues or args.verbose: - print(f"\n{Colors.MAGENTA}{Colors.BOLD}[{content_type}]{Colors.END}") - - for result in results: - errors = result.get("total_errors", 0) - warnings = result.get("total_warnings", 0) - - if errors > 0: - print(f" {Colors.RED}✗{Colors.END} {result['folder']} - {errors} error(s), {warnings} warning(s)") - if not args.summary_only: - for file_result in result.get("results", []): - for error in file_result.get("errors", []): - print(f" {Colors.RED}ERROR:{Colors.END} {error}") - for warning in file_result.get("warnings", []): - print(f" {Colors.YELLOW}WARNING:{Colors.END} {warning}") - elif warnings > 0: - print(f" {Colors.YELLOW}!{Colors.END} {result['folder']} - {warnings} warning(s)") - if not args.summary_only: - for file_result in result.get("results", []): - for warning in file_result.get("warnings", []): - print(f" {Colors.YELLOW}WARNING:{Colors.END} {warning}") - elif args.verbose: - print(f" {Colors.GREEN}✓{Colors.END} {result['folder']}") - - # Print summary - print(f"\n{Colors.BOLD}{'='*70}{Colors.END}") - print(f"{Colors.BOLD}Summary{Colors.END}") - print(f"{'='*70}\n") - - print(f" Total folders validated: {len(all_folders)}") - print(f" {Colors.GREEN}Passed:{Colors.END} {passed_count}") - print(f" {Colors.YELLOW}Warnings only:{Colors.END} {warning_count}") - print(f" {Colors.RED}Failed:{Colors.END} {failed_count}") - print() - print(f" Total errors: {total_errors}") - print(f" Total warnings: {total_warnings}") - - print(f"\n{'='*70}") - if total_errors == 0: - if total_warnings == 0: - print(f"{Colors.GREEN}{Colors.BOLD}All validations passed!{Colors.END}") - else: - print(f"{Colors.YELLOW}{Colors.BOLD}Validation passed with {total_warnings} warning(s){Colors.END}") - else: - print(f"{Colors.RED}{Colors.BOLD}Validation failed: {total_errors} error(s) in {failed_count} folder(s){Colors.END}") - print(f"{'='*70}\n") - - # Exit codes: 0 = passed, 1 = errors, 2 = warnings only - if total_errors > 0: - sys.exit(1) - elif total_warnings > 0: - sys.exit(2) - else: - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/scripts/video_deployment_overview/README.md b/scripts/video_deployment_overview/README.md deleted file mode 100644 index 9387582fc65..00000000000 --- a/scripts/video_deployment_overview/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Video Deployment Overview Generator - -This tool analyzes video deployment across all courses and generates an HTML report showing coverage by language and provider. - -## Features - -- Analyzes all courses in the `courses/` directory -- Tracks video deployment for 7 languages: **en, fr, es, it, de, ru, zh-Hant** -- Shows provider coverage: **YouTube**, **PeerTube**, or **Both** -- Color-coded visualization: - - 🟢 Green = 100% coverage - - 🟡 Yellow = 50-99% coverage - - 🟠 Orange = 1-49% coverage - - 🔴 Red = 0% coverage - -## Requirements - -```bash -pip install pyyaml -``` - -## Usage - -```bash -cd scripts/video_deployment_overview -python generate_report.py -``` - -Or from the project root: - -```bash -python scripts/video_deployment_overview/generate_report.py -``` - -## Output - -The script generates `video_deployment_overview.html` in the central `scripts/reports/` folder. Open it in your browser to view the interactive report. - -## How it Works - -1. Scans all course directories in `courses/` -2. Parses each `course.yml` file -3. Extracts video IDs and provider URLs for each language -4. Aggregates coverage statistics -5. Generates color-coded HTML table with provider badges - -## Report Structure - -| Course | Total Videos | EN | FR | ES | IT | DE | RU | ZH-HANT | -|--------|-------------|----|----|----|----|----|----|---------| -| btc101 | 23 | ✅ | ✅ | 🟡 | 🟡 | 🔴 | 🔴 | 🔴 | - -Each cell shows: -- Coverage fraction (e.g., 23/23) -- Coverage percentage (e.g., 100%) -- Provider badges: - - **Y:X** = X videos on YouTube only - - **P:X** = X videos on PeerTube only - - **B:X** = X videos on both providers diff --git a/scripts/video_deployment_overview/generate_report.py b/scripts/video_deployment_overview/generate_report.py deleted file mode 100755 index b2dbc752168..00000000000 --- a/scripts/video_deployment_overview/generate_report.py +++ /dev/null @@ -1,475 +0,0 @@ -#!/usr/bin/env python3 -""" -Video Deployment Overview Generator - -Analyzes video deployment across all courses and languages. -Generates an HTML report showing video coverage by provider (YouTube/PeerTube). - -Usage: - python generate_report.py - -The HTML report will be saved in the same directory as this script. -""" - -import yaml -import os -from pathlib import Path -from datetime import datetime - -# Languages to check -LANGUAGES = ['en', 'fr', 'es', 'it', 'de', 'ru', 'zh-Hant'] - -# Courses that should have NO VIDEOS (0 is green for all languages) -NO_VIDEO_COURSES = { - 'btc202', 'btc302', 'cyp302', 'dev103', 'eco104', - 'net302', 'phi302', 'pos305', 'scu202' -} - -# Courses that should have ENGLISH ONLY VIDEOS (0 is green for non-English) -ENGLISH_ONLY_VIDEO_COURSES = { - 'btc401', 'csv404', 'csv402', 'dev203', 'min306', - 'pro202', 'sid202', 'sid302' -} - -def get_courses_directory(): - """Get the courses directory path relative to this script.""" - script_dir = Path(__file__).parent.resolve() - courses_dir = script_dir / '../../courses' - return courses_dir.resolve() - -def parse_course_yml(course_path): - """Parse course.yml and extract video deployment information.""" - yml_file = course_path / 'course.yml' - - if not yml_file.exists(): - return None - - try: - with open(yml_file, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - - if not data or 'videos' not in data: - return None - - course_id = course_path.name - videos = data.get('videos', []) - - # Track deployment per language - deployment = { - 'course_id': course_id, - 'total_videos': len(videos), - 'languages': {} - } - - for lang in LANGUAGES: - deployment['languages'][lang] = { - 'youtube': 0, - 'peertube': 0, - 'both': 0, - 'total_videos': len(videos) - } - - # Analyze each video - for video in videos: - video_id = video.get('id', 'unknown') - - # Check YouTube - youtube_data = video.get('youtube', []) - peertube_data = video.get('peertube', []) - - for lang in LANGUAGES: - has_youtube = False - has_peertube = False - - # Check if language exists in YouTube - if youtube_data: - for entry in youtube_data: - if isinstance(entry, dict) and lang in entry: - has_youtube = True - break - - # Check if language exists in PeerTube - if peertube_data: - for entry in peertube_data: - if isinstance(entry, dict) and lang in entry: - has_peertube = True - break - - # Count coverage - if has_youtube and has_peertube: - deployment['languages'][lang]['both'] += 1 - elif has_youtube: - deployment['languages'][lang]['youtube'] += 1 - elif has_peertube: - deployment['languages'][lang]['peertube'] += 1 - - return deployment - - except Exception as e: - print(f"⚠️ Error parsing {yml_file}: {e}") - return None - -def generate_html_report(all_deployments, output_file): - """Generate HTML report with color-coded table.""" - - # Calculate summary statistics - total_courses = len(all_deployments) - total_videos = sum(d['total_videos'] for d in all_deployments) - - html = f""" - - - - - Video Deployment Overview - - - -
    -
    - Back to Reports -

    🎥 Video Deployment Overview

    -
    -
    - Languages analyzed: {", ".join(LANGUAGES)}
    - Total courses: {total_courses} | - Total videos: {total_videos} -
    - -
    -

    Legend

    -
    - - Complete (100%) -
    -
    - - Partial (50-99%) -
    -
    - - Low (1-49%) -
    -
    - - None (0%) -
    -

    -
    - Y - YouTube only -
    -
    - P - PeerTube only -
    -
    - B - Both providers -
    -

    -
    - Special course types:
    - 📝 No-video courses (0 videos = ✅ green): {", ".join(sorted(NO_VIDEO_COURSES))}
    - 🇬🇧 English-only courses (non-English 0 videos = ✅ green): {", ".join(sorted(ENGLISH_ONLY_VIDEO_COURSES))} -
    -
    - - - - - - -""" - - for lang in LANGUAGES: - html += f" \n" - - html += """ - - -""" - - # Sort courses by ID - sorted_deployments = sorted(all_deployments, key=lambda x: x['course_id']) - - for deployment in sorted_deployments: - course_id = deployment['course_id'] - total_videos = deployment['total_videos'] - - html += f" \n" - html += f" \n" - html += f" \n" - - for lang in LANGUAGES: - lang_data = deployment['languages'][lang] - youtube_count = lang_data['youtube'] - peertube_count = lang_data['peertube'] - both_count = lang_data['both'] - total_covered = youtube_count + peertube_count + both_count - - # Calculate coverage percentage - if total_videos > 0: - coverage_pct = (total_covered / total_videos) * 100 - else: - coverage_pct = 0 - - # Determine color class based on course type - is_no_video_course = course_id in NO_VIDEO_COURSES - is_english_only_course = course_id in ENGLISH_ONLY_VIDEO_COURSES - is_non_english = lang != 'en' - - if is_no_video_course and total_covered == 0: - # No video courses: 0 videos = green for all languages - color_class = 'coverage-complete' - elif is_english_only_course and is_non_english and total_covered == 0: - # English-only courses: 0 videos = green for non-English - color_class = 'coverage-complete' - elif coverage_pct == 100: - color_class = 'coverage-complete' - elif coverage_pct >= 50: - color_class = 'coverage-partial' - elif coverage_pct > 0: - color_class = 'coverage-low' - else: - color_class = 'coverage-none' - - # Build provider info - provider_info = [] - if youtube_count > 0: - provider_info.append(f'Y:{youtube_count}') - if peertube_count > 0: - provider_info.append(f'P:{peertube_count}') - if both_count > 0: - provider_info.append(f'B:{both_count}') - - provider_html = ' '.join(provider_info) if provider_info else '-' - - html += f" \n" - - html += " \n" - - html += f""" -
    CourseTotal Videos{lang.upper()}
    {course_id}{total_videos}\n" - html += f" {total_covered}/{total_videos}\n" - html += f" {coverage_pct:.0f}%\n" - html += f"
    {provider_html}
    \n" - html += f"
    - - -
    - - -""" - - with open(output_file, 'w', encoding='utf-8') as f: - f.write(html) - - print(f"✅ HTML report generated: {output_file}") - -def main(): - """Main execution function.""" - print("🚀 Video Deployment Overview Generator") - print("=" * 50) - - # Get script directory and reports folder for output - script_dir = Path(__file__).parent.resolve() - reports_dir = script_dir.parent / 'reports' - reports_dir.mkdir(exist_ok=True) - output_file = reports_dir / 'video_deployment_overview.html' - - # Get courses directory - courses_dir = get_courses_directory() - - if not courses_dir.exists(): - print(f"❌ Error: Courses directory not found at {courses_dir}") - return 1 - - print(f"📂 Courses directory: {courses_dir}") - print(f"📄 Output file: {output_file}") - print() - - # Find all course directories - course_dirs = [d for d in courses_dir.iterdir() - if d.is_dir() and not d.name.startswith('.')] - - print(f"📂 Found {len(course_dirs)} course directories") - print() - - all_deployments = [] - - for course_dir in sorted(course_dirs): - print(f"🔍 Analyzing {course_dir.name}...") - deployment = parse_course_yml(course_dir) - if deployment: - all_deployments.append(deployment) - - print() - print(f"✅ Successfully analyzed {len(all_deployments)} courses") - print() - - # Generate HTML report - generate_html_report(all_deployments, output_file) - - print() - print(f"🎉 Done! Open the following file in your browser:") - print(f" {output_file}") - - return 0 - -if __name__ == '__main__': - exit(main()) diff --git a/scripts/webp-batch-conversion.py b/scripts/webp-batch-conversion.py deleted file mode 100644 index 6e469858f48..00000000000 --- a/scripts/webp-batch-conversion.py +++ /dev/null @@ -1,83 +0,0 @@ -from PIL import Image -import os -import re -import sys - -def convert_to_webp_and_remove_metadata(root_dir): - total_reduction_percentage = 0 - num_converted_files = 0 - - # Debugging: Print the absolute path to verify correct directory - print("Root directory absolute path:", os.path.abspath(root_dir)) - - total_files = sum([len(files) for r, d, files in os.walk(root_dir) if any(f.lower().endswith(('.png', '.jpg', '.jpeg')) for f in files)]) - print(f"Total image files found: {total_files}") - - for root, dirs, files in os.walk(root_dir): - # Debugging: Print each directory being walked - print("Walking through:", root) - if not files: - print("No files in this directory.") - for file in files: - if file.lower().endswith(('.png', '.jpg', '.jpeg')): - img_path = os.path.join(root, file) - print("Processing file:", img_path) # Debugging: Show which file is being processed - - original_size = os.path.getsize(img_path) # Get original file size - img = Image.open(img_path) - webp_path = f"{os.path.splitext(img_path)[0]}.webp" - - # Save as WebP without metadata - img.save(webp_path, "WEBP", quality=90, optimize=True, method=6, save_all=True, include_metadata=False) - converted_size = os.path.getsize(webp_path) # Get converted file size - - reduction_percentage = (original_size - converted_size) / original_size * 100 - total_reduction_percentage += reduction_percentage - num_converted_files += 1 - - # Remove the original file - os.remove(img_path) - - # Print update progress - sys.stdout.write(f"\rConverted {num_converted_files}/{total_files} files. Last: {file} -> {os.path.basename(webp_path)} | Reduction: {reduction_percentage:.2f}%") - sys.stdout.flush() - - if num_converted_files > 0: - average_reduction = total_reduction_percentage / num_converted_files - print(f"\nAverage size reduction: {average_reduction:.2f}%") - else: - print("\nNo files were converted.") - -def remove_metadata_from_existing_webp(root_dir): - for root, dirs, files in os.walk(root_dir): - for file in files: - if file.lower().endswith('.webp'): - webp_path = os.path.join(root, file) - img = Image.open(webp_path) - - # Save WebP without metadata - img.save(webp_path, "WEBP", quality=90, optimize=True, method=6, save_all=True, include_metadata=False) - print(f"Metadata removed from: {webp_path}") - -def update_md_links(root_dir): - for root, dirs, files in os.walk(root_dir): - for file in files: - if file.lower().endswith('.md'): - md_path = os.path.join(root, file) - with open(md_path, 'r', encoding='utf-8') as md_file: - content = md_file.read() - - # Replace image extensions except for 'thumbnail.png' - updated_content = re.sub(r'\.(png|jpg|jpeg)(?!\.webp)', '.webp', content, flags=re.IGNORECASE) - - with open(md_path, 'w', encoding='utf-8') as md_file: - md_file.write(updated_content) - - print(f"Updated image links in {md_path}") - -if __name__ == "__main__": - root_dir = './../resources/conference/lecture-pierre-rochard/assets/' - convert_to_webp_and_remove_metadata(root_dir) # Converts non-WebP files and strips metadata - remove_metadata_from_existing_webp(root_dir) # Ensures existing WebP files have no metadata - update_md_links(root_dir) # Updates markdown links -