Script de recherche interactive de posts Hugo

Le script suivant fournit une fonctionnalité de recherche interactive pour les posts Hugo. Il vous permet de rechercher des posts par titre et fournit une interface interactive basée sur ncurses pour naviguer dans les résultats de recherche.

Lors de l’appui sur Enter, le script copie un lien Markdown dans le presse-papiers, qui peut être collé dans votre post Hugo.

Hugo Post Search.avif

hugo_post_search.py
#!/usr/bin/env python3
"""
Outil de recherche de posts de blog Hugo

Un outil basé sur ncurses pour rechercher dans les posts de blog Hugo, générer des URL,
et copier des liens Markdown dans le presse-papiers.
"""

import os
import sys
import json
import re
import curses
import argparse
import hashlib
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple, Optional
import subprocess

try:
    import pyperclip
except ImportError:
    pyperclip = None


class HugoPost:
    """Représente un post de blog Hugo avec ses métadonnées."""

    def __init__(self, filepath: Path):
        self.filepath = filepath
        self.title = ""
        self.slug = ""
        self.date = None
        self.url = ""
        self.mtime = 0

    def parse_frontmatter(self) -> bool:
        """Analyser le frontmatter YAML du post."""
        try:
            with open(self.filepath, 'r', encoding='utf-8') as f:
                content = f.read()

            # Extraire le frontmatter
            if not content.startswith('---'):
                return False

            end_marker = content.find('---', 3)
            if end_marker == -1:
                return False

            frontmatter = content[3:end_marker].strip()

            # Analyser le titre
            title_match = re.search(r'^title:\s*["\']?(.*?)["\']?\s*$', frontmatter, re.MULTILINE)
            if title_match:
                self.title = title_match.group(1).strip()

            # Analyser le slug
            slug_match = re.search(r'^slug:\s*["\']?(.*?)["\']?\s*$', frontmatter, re.MULTILINE)
            if slug_match:
                self.slug = slug_match.group(1).strip()

            # Analyser la date
            date_match = re.search(r'^date:\s*["\']?(.*?)["\']?\s*$', frontmatter, re.MULTILINE)
            if date_match:
                date_str = date_match.group(1).strip()
                try:
                    # Essayer différents formats de date
                    for fmt in ['%Y-%m-%d', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%SZ']:
                        try:
                            self.date = datetime.strptime(date_str.split('T')[0] if 'T' in date_str else date_str, '%Y-%m-%d')
                            break
                        except ValueError:
                            continue
                except:
                    pass

            # Obtenir l'heure de modification du fichier
            self.mtime = os.path.getmtime(self.filepath)

            # Générer l'URL
            if self.date and self.slug:
                self.url = f"/{self.date.year:04d}/{self.date.month:02d}/{self.date.day:02d}/{self.slug}/"

            return bool(self.title and self.slug and self.date)

        except Exception as e:
            return False


class PostCache:
    """Gère le cache des posts Hugo."""

    def __init__(self, posts_dir: Path, cache_file: Path):
        self.posts_dir = posts_dir
        self.cache_file = cache_file
        self.posts: List[HugoPost] = []

    def get_directory_hash(self) -> str:
        """Obtenir un hash de l'état du répertoire des posts."""
        hash_obj = hashlib.md5()

        if not self.posts_dir.exists():
            return hash_obj.hexdigest()

        for md_file in sorted(self.posts_dir.glob('**/*.md')):
            try:
                stat = md_file.stat()
                hash_obj.update(f"{md_file}:{stat.st_mtime}:{stat.st_size}".encode())
            except:
                continue

        return hash_obj.hexdigest()

    def load_cache(self) -> bool:
        """Charger les posts depuis le cache s'il est valide."""
        if not self.cache_file.exists():
            return False

        try:
            with open(self.cache_file, 'r', encoding='utf-8') as f:
                cache_data = json.load(f)

            # Vérifier si le cache est encore valide
            if cache_data.get('directory_hash') != self.get_directory_hash():
                return False

            # Charger les posts depuis le cache
            self.posts = []
            for post_data in cache_data.get('posts', []):
                post = HugoPost(Path(post_data['filepath']))
                post.title = post_data['title']
                post.slug = post_data['slug']
                if post_data['date']:
                    post.date = datetime.fromisoformat(post_data['date'])
                post.url = post_data['url']
                post.mtime = post_data['mtime']
                self.posts.append(post)

            return True

        except Exception as e:
            return False

    def save_cache(self):
        """Sauvegarder les posts dans le cache."""
        posts_data = []

        for post in self.posts:
            post_data = {
                'filepath': str(post.filepath),
                'title': post.title,
                'slug': post.slug,
                'date': post.date.isoformat() if post.date else None,
                'url': post.url,
                'mtime': post.mtime
            }
            posts_data.append(post_data)

        cache_data = {
            'directory_hash': self.get_directory_hash(),
            'posts': posts_data
        }

        # Assurer que le répertoire de cache existe
        self.cache_file.parent.mkdir(parents=True, exist_ok=True)

        with open(self.cache_file, 'w', encoding='utf-8') as f:
            json.dump(cache_data, f, indent=2)

    def scan_posts(self):
        """Scanner le répertoire des posts et construire le cache."""
        self.posts = []

        if not self.posts_dir.exists():
            return

        for md_file in self.posts_dir.glob('**/*.md'):
            post = HugoPost(md_file)
            if post.parse_frontmatter():
                self.posts.append(post)

        # Trier par date (plus récent en premier)
        self.posts.sort(key=lambda p: p.date or datetime.min, reverse=True)

    def get_posts(self) -> List[HugoPost]:
        """Obtenir tous les posts, chargeant depuis le cache ou scannant si nécessaire."""
        if not self.load_cache():
            self.scan_posts()
            self.save_cache()

        return self.posts


class PostSearchUI:
    """Interface de recherche basée sur ncurses pour les posts Hugo."""

    def __init__(self, posts: List[HugoPost]):
        self.posts = posts
        self.filtered_posts = posts[:]
        self.search_term = ""
        self.selected_index = 0
        self.scroll_offset = 0

    def filter_posts(self, search_term: str):
        """Filtrer les posts selon le terme de recherche."""
        if not search_term.strip():
            self.filtered_posts = self.posts[:]
        else:
            term_lower = search_term.lower()
            self.filtered_posts = [
                post for post in self.posts
                if term_lower in post.title.lower() or term_lower in post.slug.lower()
            ]

        # Réinitialiser la sélection
        self.selected_index = 0
        self.scroll_offset = 0

    def copy_to_clipboard(self, text: str) -> bool:
        """Copier le texte dans le presse-papiers."""
        if pyperclip:
            try:
                pyperclip.copy(text)
                return True
            except:
                pass

        # Solution de secours : essayer les commandes système
        try:
            # Essayer xclip (Linux)
            subprocess.run(['xclip', '-selection', 'clipboard'],
                         input=text.encode(), check=True)
            return True
        except:
            try:
                # Essayer pbcopy (macOS)
                subprocess.run(['pbcopy'], input=text.encode(), check=True)
                return True
            except:
                pass

        return False

    def run(self, stdscr):
        """Exécuter l'interface ncurses."""
        # Initialiser les couleurs
        curses.curs_set(1)  # Afficher le curseur
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)  # Sélectionné
        curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_BLACK)   # Recherche

        while True:
            height, width = stdscr.getmaxyx()
            stdscr.clear()

            # Dessiner la ligne de recherche
            search_line = f"Search: {self.search_term}"
            stdscr.addstr(0, 0, search_line[:width-1], curses.color_pair(2))

            # Dessiner la liste des posts
            visible_height = height - 2
            start_idx = self.scroll_offset
            end_idx = min(start_idx + visible_height, len(self.filtered_posts))

            for i in range(start_idx, end_idx):
                row = i - start_idx + 1
                if row >= height - 1:
                    break

                post = self.filtered_posts[i]
                title = post.title[:width-1]

                if i == self.selected_index:
                    stdscr.addstr(row, 0, title, curses.color_pair(1))
                else:
                    stdscr.addstr(row, 0, title)

            # Afficher la ligne de statut
            if self.filtered_posts:
                status = f"{len(self.filtered_posts)} posts found"
                if len(self.filtered_posts) > visible_height:
                    status += f" (showing {start_idx+1}-{end_idx})"
            else:
                status = "No posts found"

            if height > 1:
                stdscr.addstr(height-1, 0, status[:width-1])

            # Positionner le curseur dans le champ de recherche
            cursor_pos = min(len(search_line), width-1)
            stdscr.move(0, cursor_pos)

            stdscr.refresh()

            # Gérer les entrées
            key = stdscr.getch()

            if key == 27:  # Échap
                return None
            elif key in (10, 13):  # Entrée
                if self.filtered_posts and 0 <= self.selected_index < len(self.filtered_posts):
                    selected_post = self.filtered_posts[self.selected_index]
                    markdown_link = f"[{selected_post.title}]({selected_post.url})"
                    return markdown_link
            elif key == curses.KEY_UP:
                if self.selected_index > 0:
                    self.selected_index -= 1
                    if self.selected_index < self.scroll_offset:
                        self.scroll_offset = self.selected_index
            elif key == curses.KEY_DOWN:
                if self.selected_index < len(self.filtered_posts) - 1:
                    self.selected_index += 1
                    if self.selected_index >= self.scroll_offset + visible_height:
                        self.scroll_offset = self.selected_index - visible_height + 1
            elif key == curses.KEY_BACKSPACE or key == 127:
                if self.search_term:
                    self.search_term = self.search_term[:-1]
                    self.filter_posts(self.search_term)
            elif 32 <= key <= 126:  # Caractères imprimables
                self.search_term += chr(key)
                self.filter_posts(self.search_term)


def main():
    parser = argparse.ArgumentParser(description='Hugo Blog Post Search Tool')
    parser.add_argument('--posts-dir', type=Path, default=Path('content/post'),
                       help='Path to Hugo posts directory (default: content/post)')
    parser.add_argument('--cache-file', type=Path, default=Path('.hugo_post_cache.json'),
                       help='Path to cache file (default: .hugo_post_cache.json)')

    args = parser.parse_args()

    # Initialiser le cache
    cache = PostCache(args.posts_dir, args.cache_file)

    try:
        posts = cache.get_posts()
    except Exception as e:
        print(f"Error loading posts: {e}", file=sys.stderr)
        return 1

    if not posts:
        print("No valid Hugo posts found.", file=sys.stderr)
        return 1

    # Initialiser et exécuter l'interface
    ui = PostSearchUI(posts)

    try:
        result = curses.wrapper(ui.run)

        if result:
            print(result)
            if ui.copy_to_clipboard(result):
                print("Copied to clipboard!", file=sys.stderr)
            else:
                print("Could not copy to clipboard. Install pyperclip or xclip/pbcopy.", file=sys.stderr)

        return 0

    except KeyboardInterrupt:
        return 1
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1


if __name__ == '__main__':
    sys.exit(main())

Check out similar posts by category: Hugo, Python