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": "Alternative format using markdown image syntax for YouTube videos",
- "example": ""
- }
- ]
- },
- "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": [
- "",
- ""
- ]
- }
- },
- "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\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)
-
-
-
-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/`.
-
-
-
-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 :
-
-
-
-## 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)
-
-
-
-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/`.
-
-
-
-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:
-
-
-
-## 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)
-
-
-
-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/`.
-
-
-
-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 :
-
-
-
-## 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"
-
-
-
-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"
-
-
-
-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"
-
-
-
-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"
-
-
-
-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
-
-
-
\ 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
-
-
-
\ 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": "",
- "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'''
-
-
- {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
----
-
-
-
-**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)
-
-
-
-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.
-
-
-
-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": "",
- "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": [
- "",
- ""
- ]
- },
- "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
-
-
-
-
""")
+
+ for lang in languages:
+ html_parts.append(f"
{lang}
\n")
+
+ html_parts.append("""
+
+ """)
+
+ for item in sorted_items:
+ html_parts.append(f"""
+
+
{item["id"]}
""")
+
+ 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"""
+
+ ✓
+ {words:,}w
+
""")
+ else:
+ html_parts.append("""
+
+ ✗
+
""")
+
+ html_parts.append("\n
")
+
+ html_parts.append("""
+
+
""")
+
+ 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
+
+
+
+
+
+ """]
+
+ 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"""
+
+
{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):,}
+
""")
+
+ html_parts.append("""
+
+
+
+
Course Details
+
+
+
+
Course
+
Lang
+
Parts
+
Chapters
+
Words
+
Quizzes
+
Languages
+
+
+ """)
+
+ for c in courses:
+ html_parts.append(f"""
+
+
{c["id"]}
+
{c["language"]}
+
{c["parts"]}
+
{c["chapters"]}
+
{c["total_words"]:,}
+
{c["quizzes"]}
+
{len(c["languages"])}
+
""")
+
+ html_parts.append(f"""
+
+
+
+
+
+
+
+""")
+
+ 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  or  "
+ "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": "",
+ "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
+---
+
+
+
+## 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\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_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"
+ "\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'
+ "\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 "" 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 "" 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 "" 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 "" 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}>.+{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}{balise_id}>\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)>(.+)\1>$', 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'
{cell}
\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'
{cell}
\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'
{cell}
\n'
- html += '
\n'
- html += '\n'
-
- html += '
'
- 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'{tag}>')
-
- 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[-1]}>')
- 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'{tag}>')
- new_lines.append(line)
-
- # Close any remaining open lists
- while list_stack:
- tag = list_stack.pop()
- new_lines.append(f'{tag}>')
-
- 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''
- break
-
- # If image not found, return original markdown
- print(f" ⚠️ Image not found: {img_path}")
- return f''
-
- # 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
-
-
-
-
-
- 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)
-
-
-
-
-
-
-
Course
-"""
-
- for lang in languages:
- html += f"
{lang.upper()}
\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"
{course_id}
\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"
- html += f" ✓\n"
- html += f" {words:,}w\n"
- html += f"
\n"
- else:
- html += f"
\n"
- html += f" ✗\n"
- html += f"
\n"
-
- 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("🚀 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
-
-
-
-
-
-
- These reports provide comprehensive insights into the translation progress
- across all educational courses. Select a report below to view detailed
- statistics and status information.
-