Application de prise de notes en Markdown
Construisez une application de prise de notes en terminal qui stocke les notes au format JSON, supporte la recherche plein texte, l’organisation par tags, et l’export en fichiers Markdown propres. Ce projet renforce la manipulation de dictionnaires, les entrées/sorties de fichiers, le traitement de chaînes, et la construction d’un CLI orienté utilisateur à partir de zéro.
🎯 Ce que vous allez faire
- Concevoir un modèle de données pour les notes en utilisant des dictionnaires et la sérialisation JSON.
- Implémenter une recherche plein texte sur plusieurs champs.
- Construire un système de tags pour une organisation et un filtrage flexibles.
- Créer un CLI à menu avec sortie colorée et validation des entrées.
- Exporter des données structurées en fichiers Markdown pour le partage.
Où exécuter ceci
En local avec uv est le chemin principal recommandé, ce projet n’a besoin d’aucun package externe, donc la configuration est minimaliste. Les étapes 1 à 7 ci-dessous supposent ce chemin.
GitHub Codespaces fonctionne bien aussi : ouvrez tout le dépôt du cours dans un Codespace gratuit, et les mêmes commandes uv fonctionnent depuis un onglet de navigateur.
Google Colab et les notebooks Kaggle ne conviennent pas à ce projet, c’est une application CLI interactive qui nécessite un terminal persistant. Considérez-les comme inadaptés ici.
- Exécutez-le dans le navigateur. Un compagnon notebook interactif est prêt, ouvrez-le dans Colab, Kaggle ou Binder et suivez les étapes dans l’ordre.
Setup
uv init note-taking-app
cd note-taking-app
Aucun package externe n’est requis, l’application utilise uniquement la bibliothèque standard Python (json, os, datetime, pathlib).
Étape 1, Mettre en place la structure du projet
Chaque note a besoin d’une forme cohérente pour que le reste de l’application puisse compter sur les mêmes champs. Nous stockerons les notes comme une liste de dictionnaires dans un fichier JSON. Chaque note aura un champ id, title, content, tags, created_at, et updated_at.
Créez un fichier appelé notes.py et définissez le modèle de données et la couche de stockage :
import json
import os
from datetime import datetime, timezone
from pathlib import Path
DATA_DIR = Path("data")
NOTES_FILE = DATA_DIR / "notes.json"
def ensure_data_dir():
"""Create the data directory if it doesn't exist."""
DATA_DIR.mkdir(exist_ok=True)
def load_notes() -> list[dict]:
"""Load all notes from the JSON file."""
ensure_data_dir()
if not NOTES_FILE.exists():
return []
with open(NOTES_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def save_notes(notes: list[dict]) -> None:
"""Save all notes to the JSON file."""
ensure_data_dir()
with open(NOTES_FILE, "w", encoding="utf-8") as f:
json.dump(notes, f, indent=2, ensure_ascii=False)
def generate_id(notes: list[dict]) -> int:
"""Return the next available note ID."""
if not notes:
return 1
return max(note["id"] for note in notes) + 1
# Quick test
ensure_data_dir()
notes = load_notes()
print(f"Data directory: {DATA_DIR.resolve()}")
print(f"Notes loaded: {len(notes)}")
print(f"Notes file: {NOTES_FILE}")
🎯 Résultat attendu :
Data directory: /home/user/note-taking-app/data
Notes loaded: 0
Notes file: data/notes.json
🩹 Si ça ne marche pas :
- Si vous voyez un
FileNotFoundError, vérifiez queDATA_DIR.mkdir(exist_ok=True)est appelé avant d’accéder au fichier. - Si le chemin semble incorrect, assurez-vous d’exécuter le script depuis la racine du projet.
✅ Liste de vérification
- ✅ Le répertoire
data/est créé automatiquement lors de l’exécution du script. - ✅
load_notes()retourne une liste vide quand aucun fichier n’existe encore. - ✅
save_notes()écrit un fichier JSON valide.
🤔 Question(s) socratique(s)
- Pourquoi stocker les notes en JSON plutôt qu’en texte brut ? Que perdriez-vous si chaque note était un fichier
.txtséparé ?
Étape 2, Créer des notes
Maintenant que nous pouvons charger et sauvegarder, construisons la fonction qui crée une nouvelle note. Elle prend un titre, un contenu, et des tags optionnels, assigne un ID et des horodatages, et l’ajoute à la liste.
Ajoutez ceci à notes.py :
def create_note(title: str, content: str, tags: list[str] | None = None) -> dict:
"""Create a new note and save it."""
notes = load_notes()
now = datetime.now(timezone.utc).isoformat()
note = {
"id": generate_id(notes),
"title": title.strip(),
"content": content.strip(),
"tags": [tag.strip().lower() for tag in (tags or [])],
"created_at": now,
"updated_at": now,
}
notes.append(note)
save_notes(notes)
return note
# Test it out
note1 = create_note(
"Python List Comprehensions",
"List comprehensions provide a concise way to create lists.\nExample: [x**2 for x in range(10)]",
tags=["python", "basics"]
)
note2 = create_note(
"Git Rebase vs Merge",
"Rebase rewrites commit history to create a linear timeline.\nMerge preserves the full branch history with a merge commit.",
tags=["git", "workflow"]
)
note3 = create_note(
"Python Virtual Environments",
"Use venv to create isolated Python environments.\nCommands: python -m venv .venv && source .venv/bin/activate",
tags=["python", "tools"]
)
print(f"Created note {note1['id']}: {note1['title']}")
print(f"Created note {note2['id']}: {note2['title']}")
print(f"Created note {note3['id']}: {note3['title']}")
print(f"\nTotal notes: {len(load_notes())}")
🎯 Résultat attendu :
Created note 1: Python List Comprehensions
Created note 2: Git Rebase vs Merge
Created note 3: Python Virtual Environments
Total notes: 3
Après l’exécution, inspectez data/notes.json, vous verrez les trois notes stockées avec des IDs, des tags, et des horodatages.
🩹 Si ça ne marche pas :
- Si les IDs ne sont pas séquentiels, vérifiez que
load_notes()lit la liste courante avant de générer le prochain ID. - Les tags devraient être en minuscules, si vous voyez de la casse mixte, la compréhension de liste dans
create_notene s’exécute pas.
✅ Liste de vérification
- ✅ Chaque note reçoit un ID unique et croissant.
- ✅ Les tags sont normalisés en minuscules et dépouillés des espaces.
- ✅
created_atetupdated_atsont définis sur des horodatages UTC au format ISO. - ✅ Les notes persistent dans
data/notes.jsonaprès l’arrêt du script.
🤔 Question(s) socratique(s)
- Que se passe-t-il si deux utilisateurs créent des notes en même temps ? Comment pourriez-vous rendre les IDs plus robustes ?
Étape 3, Lister et rechercher des notes
Une application de prise de notes est inutile si vous ne trouvez rien. Nous implémenterons deux choses : lister toutes les notes dans un format lisible, et une recherche plein texte qui correspond à la fois aux titres et au contenu.
Ajoutez ces fonctions à notes.py :
def list_notes(sort_by: str = "updated_at") -> None:
"""Print all notes in a readable format."""
notes = load_notes()
if not notes:
print(" No notes yet. Create one with option 1!")
return
notes.sort(key=lambda n: n[sort_by], reverse=True)
print(f"\n {'ID':<4} {'Title':<35} {'Tags':<20} {'Updated':<12}")
print(f" {'-'*4} {'-'*35} {'-'*20} {'-'*12}")
for note in notes:
tags_str = ", ".join(note["tags"]) if note["tags"] else "—"
updated = note["updated_at"][:10]
print(f" {note['id']:<4} {note['title'][:34]:<35} {tags_str[:19]:<20} {updated:<12}")
print(f"\n {len(notes)} note(s) total")
def search_notes(query: str) -> list[dict]:
"""Search notes by matching query against title and content (case-insensitive)."""
notes = load_notes()
query_lower = query.lower()
results = [
note for note in notes
if query_lower in note["title"].lower()
or query_lower in note["content"].lower()
]
return results
def display_search_results(query: str) -> None:
"""Search and display matching notes."""
results = search_notes(query)
if not results:
print(f' No notes matching "{query}"')
return
print(f'\n Found {len(results)} note(s) matching "{query}":')
for note in results:
tags_str = ", ".join(note["tags"]) if note["tags"] else "—"
print(f"\n [{note['id']}] {note['title']}")
print(f" Tags: {tags_str}")
preview = note["content"][:80].replace("\n", " ")
if len(note["content"]) > 80:
preview += "..."
print(f" {preview}")
# Test listing
list_notes()
# Test search
print("\n--- Search: 'python' ---")
display_search_results("python")
print("\n--- Search: 'git' ---")
display_search_results("git")
print("\n--- Search: 'docker' ---")
display_search_results("docker")
🎯 Résultat attendu :
ID Title Tags Updated
---- ----------------------------------- -------------------- ------------
3 Python Virtual Environments python, tools 2026-09-06
2 Git Rebase vs Merge git, workflow 2026-09-06
1 Python List Comprehensions python, basics 2026-09-06
3 note(s) total
--- Search: 'python' ---
Found 2 note(s) matching "python":
[1] Python List Comprehensions
Tags: python, basics
List comprehensions provide a concise way to create lists. Example: [x**2...
[3] Python Virtual Environments
Tags: python, tools
Use venv to create isolated Python environments. Commands: python -m v...
--- Search: 'git' ---
Found 1 note(s) matching "git":
[2] Git Rebase vs Merge
Tags: git, workflow
Rebase rewrites commit history to create a linear timeline. Merge pre...
--- Search: 'docker' ---
No notes matching "docker"
🩹 Si ça ne marche pas :
- Si la recherche ne retourne rien pour “python”, vérifiez que
query_lowerest comparé ànote["title"].lassement, la sensibilité à la casse est le coupable habituel. - Si les colonnes du tableau sont mal alignées, assurez-vous que les spécificateurs de largeur de f-string (
:<4,:<35, etc.) correspondent aux largeurs des en-têtes.
✅ Liste de vérification
- ✅
list_notes()affiche toutes les notes triées par mise à jour la plus récente. - ✅
search_notes()retourne les notes correspondant à la requête dans le titre ou le contenu. - ✅ La recherche insensible à la casse fonctionne pour les correspondances partielles.
- ✅ Les résultats de recherche affichent un aperçu du contenu tronqué à 80 caractères.
🤔 Question(s) socratique(s)
- Comment étendriez-vous la recherche pour correspondre aussi aux tags ? Et pour rechercher les notes créées cette semaine ?
Étape 4, Organiser avec des tags
Les tags vous permettent de regrouper des catégories sans catégories rigides. Nous ajouterons des fonctions pour ajouter et supprimer des tags à des notes existantes, et pour filtrer la liste des notes par un tag spécifique.
Ajoutez ces fonctions à notes.py :
def add_tag_to_note(note_id: int, tag: str) -> bool:
"""Add a tag to a note. Returns True if successful."""
notes = load_notes()
for note in notes:
if note["id"] == note_id:
tag_clean = tag.strip().lower()
if tag_clean not in note["tags"]:
note["tags"].append(tag_clean)
note["updated_at"] = datetime.now(timezone.utc).isoformat()
save_notes(notes)
print(f' Added tag "{tag_clean}" to note {note_id}')
else:
print(f' Note {note_id} already has tag "{tag_clean}"')
return True
print(f" Note {note_id} not found")
return False
def remove_tag_from_note(note_id: int, tag: str) -> bool:
"""Remove a tag from a note. Returns True if successful."""
notes = load_notes()
for note in notes:
if note["id"] == note_id:
tag_clean = tag.strip().lower()
if tag_clean in note["tags"]:
note["tags"].remove(tag_clean)
note["updated_at"] = datetime.now(timezone.utc).isoformat()
save_notes(notes)
print(f' Removed tag "{tag_clean}" from note {note_id}')
else:
print(f' Note {note_id} does not have tag "{tag_clean}"')
return True
print(f" Note {note_id} not found")
return False
def get_all_tags() -> dict[str, int]:
"""Return a dictionary of all tags and how many notes use each."""
notes = load_notes()
tag_counts: dict[str, int] = {}
for note in notes:
for tag in note["tags"]:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
return dict(sorted(tag_counts.items(), key=lambda x: x[1], reverse=True))
def filter_by_tag(tag: str) -> list[dict]:
"""Return all notes that have the given tag."""
notes = load_notes()
tag_clean = tag.strip().lower()
return [note for note in notes if tag_clean in note["tags"]]
# Test tag operations
add_tag_to_note(1, "reference")
add_tag_to_note(1, "reference") # duplicate — should warn
print("\nAll tags:")
for tag, count in get_all_tags().items():
print(f" {tag}: {count} note(s)")
print("\nNotes tagged 'python':")
for note in filter_by_tag("python"):
print(f" [{note['id']}] {note['title']}")
remove_tag_from_note(1, "basics")
print("\nTags on note 1 after removal:")
note = [n for n in load_notes() if n["id"] == 1][0]
print(f" {note['title']}: {note['tags']}")
🎯 Résultat attendu :
Added tag "reference" to note 1
Note 1 already has tag "reference"
All tags:
python: 2 note(s)
reference: 1 note(s)
basics: 1 note(s)
tools: 1 note(s)
git: 1 note(s)
workflow: 1 note(s)
Notes tagged 'python':
[1] Python List Comprehensions
[3] Python Virtual Environments
Removed tag "basics" from note 1
Tags on note 1 after removal:
Python List Comprehensions: ['python', 'reference']
🩹 Si ça ne marche pas :
- Si des tags en double apparaissent, vérifiez que la garde
if tag_clean not in note["tags"]est en place avant l’ajout. - Si
get_all_tags()affiche des compteurs inattendus, vérifiez quefilter_by_tagutilise la même normalisation.lower()queadd_tag_to_note.
✅ Liste de vérification
- ✅ L’ajout d’un tag en double affiche un avertissement au lieu de le dupliquer.
- ✅ La suppression d’un tag met à jour
updated_atet persiste le changement. - ✅
get_all_tags()retourne un dictionnaire trié de compteurs d’utilisation des tags. - ✅
filter_by_tag()retourne uniquement les notes contenant le tag exact.
🤔 Question(s) socratique(s)
- Comment implémenteriez-vous des tags imbriqués (par ex.
python/djangoetpython/flasksous un tag parentpython) ?
Étape 5, Modifier et supprimer des notes
Les utilisateurs ont besoin de corriger des erreurs et de supprimer des notes obsolètes. Nous ajouterons des fonctions pour mettre à jour des champs spécifiques d’une note existante et pour supprimer des notes par ID.
Ajoutez ces fonctions à notes.py :
def get_note_by_id(note_id: int) -> dict | None:
"""Return a single note by its ID, or None if not found."""
notes = load_notes()
for note in notes:
if note["id"] == note_id:
return note
return None
def edit_note(note_id: int, title: str | None = None, content: str | None = None) -> bool:
"""Update the title and/or content of an existing note."""
notes = load_notes()
for note in notes:
if note["id"] == note_id:
if title is not None:
note["title"] = title.strip()
if content is not None:
note["content"] = content.strip()
note["updated_at"] = datetime.now(timezone.utc).isoformat()
save_notes(notes)
print(f" Updated note {note_id}: {note['title']}")
return True
print(f" Note {note_id} not found")
return False
def delete_note(note_id: int) -> bool:
"""Delete a note by ID. Returns True if deleted."""
notes = load_notes()
original_count = len(notes)
notes = [note for note in notes if note["id"] != note_id]
if len(notes) < original_count:
save_notes(notes)
print(f" Deleted note {note_id}")
return True
print(f" Note {note_id} not found")
return False
# Test edit
print("Before edit:")
note = get_note_by_id(2)
print(f" [{note['id']}] {note['title']}")
print(f" {note['content'][:60]}...")
edit_note(2, title="Git: Rebase vs Merge", content="Rebase rewrites commit history for a linear timeline.\nMerge preserves full branch history with a merge commit.\n\nWhen to rebase: local cleanup before sharing.\nWhen to merge: shared branches where history matters.")
print("\nAfter edit:")
note = get_note_by_id(2)
print(f" [{note['id']}] {note['title']}")
print(f" {note['content'][:80]}...")
# Test delete
print("\nDeleting note 3...")
delete_note(3)
list_notes()
🎯 Résultat attendu :
Before edit:
[2] Git Rebase vs Merge
Rebase rewrites commit history to create a linear timeline. Merge pre...
Updated note 2: Git: Rebase vs Merge
After edit:
[2] Git: Rebase vs Merge
Rebase rewrites commit history for a linear timeline. Merge preserves full branch hist...
Deleted note 3
ID Title Tags Updated
---- ----------------------------------- -------------------- ------------
2 Git: Rebase vs Merge git, workflow 2026-09-06
1 Python List Comprehensions python, reference 2026-09-06
2 note(s) total
🩹 Si ça ne marche pas :
- Si
edit_notene semble pas sauvegarder, vérifiez que vous passeztitle=etcontent=comme arguments de mot-clé, la fonction utiliseNonecomme sentinelle pour ignorer les champs inchangés. - Si
delete_notedit « not found » mais la note existe, vérifiez que l’ID est un entier, pas une chaîne.
✅ Liste de vérification
- ✅
edit_note()met à jour uniquement les champs que vous passez, laissant les autres inchangés. - ✅
edit_note()met à jour l’horodatageupdated_at. - ✅
delete_note()supprime la note du fichier JSON et confirme la suppression. - ✅
get_note_by_id()retourneNonepour les IDs inexistants.
🤔 Question(s) socratique(s)
- Comment pourriez-vous implémenter un « annuler » pour la suppression ? Quelles données devriez-vous conserver ?
Étape 6, Exporter en Markdown
Les fichiers Markdown sont faciles à partager, à prévisualiser sur GitHub, ou à importer dans d’autres outils. Nous convertirons les notes en fichiers .md propres, un fichier par note, ou un seul document combiné.
Ajoutez ces fonctions à notes.py :
EXPORT_DIR = Path("exports")
def export_note_to_markdown(note: dict, output_dir: Path | None = None) -> Path:
"""Export a single note to a Markdown file."""
output_dir = output_dir or EXPORT_DIR
output_dir.mkdir(exist_ok=True)
safe_title = note["title"].replace(" ", "-").replace("/", "-").lower()
filename = f"{note['id']:03d}-{safe_title}.md"
filepath = output_dir / filename
tags_line = ", ".join(f"`{tag}`" for tag in note["tags"]) if note["tags"] else "None"
created = note["created_at"][:10]
updated = note["updated_at"][:10]
md_content = f"""# {note['title']}
> **Tags:** {tags_line}
> **Created:** {created} | **Updated:** {updated}
---
{note['content']}
"""
with open(filepath, "w", encoding="utf-8") as f:
f.write(md_content)
return filepath
def export_all_notes(combined: bool = False) -> list[Path]:
"""Export all notes to Markdown. If combined, write a single file."""
notes = load_notes()
if not notes:
print(" No notes to export")
return []
EXPORT_DIR.mkdir(exist_ok=True)
paths = []
if combined:
filepath = EXPORT_DIR / "all-notes.md"
with open(filepath, "w", encoding="utf-8") as f:
f.write("# All Notes\n\n")
f.write(f"*Exported on {datetime.now(timezone.utc).strftime('%Y-%m-%d')}*\n\n")
f.write("---\n\n")
for note in notes:
tags_line = ", ".join(f"`{tag}`" for tag in note["tags"]) if note["tags"] else "None"
f.write(f"## {note['title']}\n\n")
f.write(f"**Tags:** {tags_line} | **Created:** {note['created_at'][:10]}\n\n")
f.write(f"{note['content']}\n\n---\n\n")
paths.append(filepath)
print(f" Exported combined file: {filepath}")
else:
for note in notes:
path = export_note_to_markdown(note)
paths.append(path)
print(f" Exported: {path}")
print(f"\n {len(paths)} file(s) exported to {EXPORT_DIR}/")
return paths
# Test individual export
note = get_note_by_id(1)
path = export_note_to_markdown(note)
print(f"Exported to: {path}")
# Print the generated Markdown
with open(path, "r") as f:
print(f"\n--- Content of {path.name} ---")
print(f.read())
# Test combined export
print("--- Exporting all notes as one file ---")
export_all_notes(combined=True)
🎯 Résultat attendu :
Exported to: exports/001-python-list-comprehensions.md
--- Content of 001-python-list-comprehensions.md ---
# Python List Comprehensions
> **Tags:** `python`, `reference`
> **Created:** 2026-09-06 | **Updated:** 2026-09-06
---
List comprehensions provide a concise way to create lists.
Example: [x**2 for x in range(10)]
--- Exporting all notes as one file ---
Exported combined file: exports/all-notes.md
1 file(s) exported to exports/
🩹 Si ça ne marche pas :
- Si le fichier exporté est vide, vérifiez que
note["content"]est une chaîne, une valeurNoneproduirait silencieusement aucune sortie. - Si
safe_titlecontient des caractères étranges, ajoutez plus de remplacements :note["title"].replace(":", "").replace("'", "").
✅ Liste de vérification
- ✅ Chaque fichier
.mdexporté a un en-tête, un bloc de métadonnées, et le contenu de la note. - ✅ Les noms de fichiers sont sûrs pour tous les systèmes d’exploitation (pas de caractères spéciaux).
- ✅ L’export combiné produit un seul fichier
all-notes.mdavec toutes les notes séparées par des lignes horizontales. - ✅ Le Markdown exporté s’affiche correctement dans n’importe quel afficheur Markdown.
🤔 Question(s) socratique(s)
- Comment ajoutereriez-vous une table des matières à l’export combiné avec des liens vers chaque en-tête de note ?
Étape 7, Peaufiner le CLI
La dernière étape réunit le tout avec une interface à menu. Nous ajouterons une sortie colorée en utilisant des codes ANSI, de la validation des entrées, et une gestion propre des erreurs pour que l’application soit polie.
Remplacez le bas de notes.py (ou ajoutez à un nouveau main.py et importez depuis notes.py) avec :
# ── Colors (ANSI escape codes) ──────────────────────────────────
BOLD = "\033[1m"
GREEN = "\033[32m"
CYAN = "\033[36m"
YELLOW = "\033[33m"
RED = "\033[31m"
RESET = "\033[0m"
def colored(text: str, color: str) -> str:
return f"{color}{text}{RESET}"
def print_header():
print(f"\n{colored('=' * 50, CYAN)}")
print(colored(" 📝 Markdown Note-Taking App", BOLD))
print(colored('=' * 50, CYAN))
def print_menu():
print(f"""
{colored('1.', GREEN)} Create a new note
{colored('2.', GREEN)} List all notes
{colored('3.', GREEN)} Search notes
{colored('4.', GREEN)} Add tag to a note
{colored('5.', GREEN)} Remove tag from a note
{colored('6.', GREEN)} Filter notes by tag
{colored('7.', GREEN)} Edit a note
{colored('8.', GREEN)} Delete a note
{colored('9.', GREEN)} Export all notes to Markdown
{colored('0.', RED)} Exit
""")
def get_input(prompt: str) -> str:
"""Get input with colored prompt."""
return input(colored(f" {prompt}: ", CYAN)).strip()
def get_int(prompt: str) -> int | None:
"""Get an integer input, returning None on failure."""
try:
return int(get_input(prompt))
except ValueError:
print(colored(" Please enter a valid number.", RED))
return None
def handle_create():
title = get_input("Title")
if not title:
print(colored(" Title cannot be empty.", RED))
return
print(" Content (press Enter twice when done):")
lines = []
while True:
line = input(" > ")
if line == "" and lines and lines[-1] == "":
break
lines.append(line)
content = "\n".join(lines).strip()
tags_input = get_input("Tags (comma-separated, or leave empty)")
tags = [t.strip() for t in tags_input.split(",") if t.strip()] if tags_input else []
note = create_note(title, content, tags)
print(colored(f" ✓ Created note {note['id']}: {note['title']}", GREEN))
def handle_search():
query = get_input("Search query")
if query:
display_search_results(query)
def handle_list():
list_notes()
def handle_add_tag():
note_id = get_int("Note ID")
if note_id is None:
return
tag = get_input("Tag to add")
if tag:
add_tag_to_note(note_id, tag)
def handle_remove_tag():
note_id = get_int("Note ID")
if note_id is None:
return
tag = get_input("Tag to remove")
if tag:
remove_tag_from_note(note_id, tag)
def handle_filter_tag():
tag = get_input("Tag to filter by")
if tag:
results = filter_by_tag(tag)
if results:
print(f"\n Notes tagged '{tag}':")
for note in results:
print(f" [{note['id']}] {note['title']}")
else:
print(f" No notes with tag '{tag}'")
def handle_edit():
note_id = get_int("Note ID to edit")
if note_id is None:
return
note = get_note_by_id(note_id)
if not note:
print(colored(f" Note {note_id} not found.", RED))
return
print(f" Current title: {note['title']}")
new_title = get_input("New title (leave blank to keep)")
print(f" Current content preview: {note['content'][:50]}...")
new_content = get_input("New content (leave blank to keep)")
edit_note(
note_id,
title=new_title if new_title else None,
content=new_content if new_content else None,
)
def handle_delete():
note_id = get_int("Note ID to delete")
if note_id is None:
return
note = get_note_by_id(note_id)
if not note:
print(colored(f" Note {note_id} not found.", RED))
return
confirm = get_input(f'Delete "{note["title"]}"? (yes/no)')
if confirm.lower() == "yes":
delete_note(note_id)
print(colored(" ✓ Deleted.", GREEN))
else:
print(" Cancelled.")
def handle_export():
export_all_notes(combined=True)
HANDLERS = {
1: handle_create,
2: handle_list,
3: handle_search,
4: handle_add_tag,
5: handle_remove_tag,
6: handle_filter_tag,
7: handle_edit,
8: handle_delete,
9: handle_export,
}
def main():
"""Run the note-taking app."""
ensure_data_dir()
print_header()
while True:
print_menu()
choice = get_int("Choose an option")
if choice == 0:
print(colored("\n Goodbye! 👋\n", YELLOW))
break
if choice is None or choice not in HANDLERS:
print(colored(" Invalid option. Try again.", RED))
continue
try:
HANDLERS[choice]()
except KeyboardInterrupt:
print(colored("\n\n Interrupted. Goodbye!", YELLOW))
break
except Exception as e:
print(colored(f" Error: {e}", RED))
if __name__ == "__main__":
main()
🎯 Résultat attendu (session interactive) :
==================================================
📝 Markdown Note-Taking App
==================================================
1. Create a new note
2. List all notes
3. Search notes
4. Add tag to a note
5. Remove tag from a note
6. Filter notes by tag
7. Edit a note
8. Delete a note
9. Export all notes to Markdown
0. Exit
Choose an option: 2
ID Title Tags Updated
---- ----------------------------------- -------------------- ------------
2 Git: Rebase vs Merge git, workflow 2026-09-06
1 Python List Comprehensions python, reference 2026-09-06
2 note(s) total
Choose an option: 0
Goodbye! 👋
🩹 Si ça ne marche pas :
- Si les couleurs n’apparaissent pas, votre terminal ne supporte peut-être pas les codes ANSI, essayez un terminal différent ou vérifiez que
$TERMest défini surxterm-256colorou similaire. - Si la boucle d’entrée se bloque, vérifiez que
handle_createsort correctement de la boucle de saisie du contenu sur deux lignes vides consécutives. - Si
Ctrl+Cne quitte pas proprement, le blocexcept KeyboardInterruptdevrait le capturer.
✅ Liste de vérification
- ✅ Le menu s’affiche avec des options numérotées et du texte coloré.
- ✅ Une entrée invalide affiche une erreur et réaffiche le menu.
- ✅ Chaque option du menu appelle la bonne fonction de gestionnaire.
- ✅ Ctrl+C quitte l’application gracieusement sans traceback.
- ✅ L’application boucle jusqu’à ce que l’utilisateur choisisse l’option 0.
🤔 Question(s) socratique(s)
- Comment ajoutereriez-vous des arguments de ligne de commande pour que les utilisateurs puissent exécuter
python notes.py search "python"sans entrer dans le menu interactif ?
🧩 Défis
Défi 1, Épinglage des notes
Ajoutez un champ booléen pinned à chaque note. Lors du listage, les notes épinglées apparaissent toujours en haut quel que soit l’ordre de tri.
Défi 2, Export complet avec table des matières
Étendez l’export Markdown combiné pour inclure une table des matières en haut, avec des liens vers chaque en-tête de note en utilisant la syntaxe d’ancre Markdown (par ex. [Python Basics](#python-basics)).
Défi 3, Recherche par plage de dates
Ajoutez des filtres --from et --to à la fonction de recherche pour que les utilisateurs puissent trouver les notes créées ou mises à jour dans une plage de dates spécifique. Analysez les dates avec datetime.fromisoformat().
Objectifs avancés
- Ajouter des catégories de notes (dossiers) en plus des tags.
- Implémenter une recherche floue en utilisant
difflib.SequenceMatcher. - Construire une interface web simple avec
flaskpour visualiser et modifier des notes dans un navigateur. - Ajouter un prévisualiseur Markdown dans le terminal en utilisant
rich.
Ce que vous avez appris
- Concevoir un modèle de données avec des dictionnaires et du JSON pour un stockage persistant.
- Implémenter une recherche plein texte sur plusieurs champs avec correspondance insensible à la casse.
- Construire un système de tags flexible avec des opérations d’ajout, de suppression, de filtrage, et de comptage.
- Créer, modifier, et supprimer des enregistrements structurés avec une validation appropriée.
- Exporter des données en fichiers Markdown avec génération de noms de fichiers sûrs.
- Construire un CLI poli avec une boucle de menu, une sortie colorée, de la validation des entrées, et de la gestion des erreurs.
Terminez chaque étape puis marquez ce projet comme terminé pour gagner ses points.