#!/bin/python3 import os import re import shutil from datetime import datetime import pypandoc import subprocess import tempfile from md2gemini import md2gemini import locale from website_config import * # this path should be customized org_roam_dir: str = '/home/tykayn/Nextcloud/textes/orgmode/org-roam/' # Trouver l'identifiant OrgROAM pattern_roam_id_search = r':ID:(?:\s+)?([a-zA-Z0-9-]+)' # Expression régulière pour extraire la date et le slug du nom de fichier org regex = r"^([a-zA-Z0-9_-]+)\_\_(-[a-zA-Z0-9_-]+)\.org$" # Recherche de date de création du fichier org-roam dans un article gemini regex_orgroam = regex # show_logs=True show_logs = global_config["show_logs"] def mylog(*content): """Fonction qui imprime tous les arguments passés selon le niveau de debug souhaité.""" if show_logs: for c in content: print(' ',c) def trouver_nom_article(fichier_org, blog_name, format="html"): # mylog('fichier_org, ', fichier_org) with open(fichier_org, 'r') as file: lignes = file.readlines() nom_article = '' # mylog('trouver_nom_article format', format) # Expressions régulières pour trouver les titres de niveau 1 et 2 if format == 'html': titre_niveau_1 = r'(.*?)' titre_niveau_2 = r'^\(.+)\<\/h2\>$' else: titre_niveau_1 = r'^\*+ (.+)$' titre_niveau_2 = r'^\*\*+ (.+)$' # Itérer sur les lignes du fichier for ligne in lignes: # Rechercher un titre de niveau 1 titre_niveau_1_match = re.match(titre_niveau_1, ligne) if titre_niveau_1_match: titre_niveau_1_texte = titre_niveau_1_match.group(1) if titre_niveau_1_texte.lower() != "article" and titre_niveau_1_texte.lower() != "liens": nom_article = titre_niveau_1_texte break else: # Si le premier titre de niveau 1 est "Article", rechercher le premier titre de niveau 2 titre_niveau_2_match = re.match(titre_niveau_2, ligne) if titre_niveau_2_match: nom_article = titre_niveau_2_match.group(1) break # mylog(f"Nom de l'article : {nom_article}") return nom_article.replace(blog_name + '_', '').replace('_', ' ') def find_org_roam_id(content): match = re.search(pattern_roam_id_search, content) if match: return match.group(1) return None def get_blog_template_conf(blogname) -> dict: """ Retourne la configuration du blog spécifié. :param blogname: Nom du blog (str). :return: Configuration du blog (dict). """ if blogname not in configs_sites: return default_config else: return configs_sites[blogname] def find_year_and_slug_on_filename(filename): # print(f"Traitement du fichier: {filename}") # Debug try: # Supposons que le format attendu est "YYYYMMDDHHMMSS-slug.org" date_str = filename[:14] # Prend les 14 premiers caractères pour la date annee = date_str[:4] # Prend les 4 premiers caractères pour l'année # Gestion plus robuste du slug if '-' in filename: slug = filename.split('-', 1)[1].replace('.org', '') else: slug = filename.replace('.org', '') return date_str, annee, slug except Exception as e: print(f"Format de fichier non standard: {filename}") return None, None, filename.replace('.org', '') def enlever_premier_tiret_ou_underscore(chaîne): if chaîne.startswith('-') or chaîne.startswith('_'): chaîne = chaîne[1:] return chaîne # création des dossiers intermédiaires s'il y en a # déplace le fichier dans le dossier spécifié def create_path_folders_and_move_file(path, file): os.makedirs(os.path.dirname(path), exist_ok=True) shutil.move(file, path) def get_files_list_of_folder(folder_path): # Vérifie si le dossier existe if not os.path.exists(folder_path): print(f" ------------ build_indexes: Erreur : Le dossier '{folder_path}' n'existe pas.") return mylog('----------- get_files_list_of_folder: folder_path : ', folder_path) # Liste les fichiers articles, trie par nom décroissant try: fichiers_md = sorted( [f.replace('.' + global_config['source_files_extension'], '.gmi') for f in os.listdir(folder_path) if f.endswith(global_config['source_files_extension'])], reverse=True) # print('fichiers trouvés:', len(fichiers_md)) return fichiers_md except OSError as e: print(f" ------------ build_indexes: Erreur lors de la lecture du dossier : {e}") return def get_id_of_roam_note_content(content): match = re.search(pattern_roam_id_search, content) if match: return match.group(1) return None def find_first_level1_title(content): pattern = r'^\*\s(.+)$' match = re.search(pattern, content, re.MULTILINE) if match: if match.group(1) != 'Article': return match.group(1) else: pattern = r'^\*\*\s(.+)$' match = re.search(pattern, content, re.MULTILINE) if match: return match.group(1) return None def find_extract_in_content_org(org_content): # Supprimer les lignes qui commencent par #+ org_content = re.sub(r'^\s*#\+.*\n', '', org_content, flags=re.MULTILINE) # Supprimer les sections de logbook org_content = re.sub(r'^\*\* Logbook\n.*?(?=\*\* |\Z)', '', org_content, flags=re.DOTALL | re.MULTILINE) # Supprimer les propriétés org_content = re.sub(r'^:PROPERTIES:\n.*?:END:\n', '', org_content, flags=re.DOTALL | re.MULTILINE) # Supprimer les lignes vides supplémentaires org_content = re.sub(r'\n\s*\n+', '\n', org_content) # Supprimer les espaces en début et fin de chaque ligne org_content = '\n'.join(line.strip() for line in org_content.splitlines()) # Supprimer les espaces en début et fin du contenu final return org_content.strip() def extract_body_content(html_content): pattern = r'(.*?)' match = re.search(pattern, html_content, re.DOTALL) if match: return match.group(1) else: print('---- extract_body_content : no body found in this html') return html_content def add_tags_from_content(tags=None, file_content="", words_to_check=None): """ Ajoute des tags à l'ensemble `tags` si les mots correspondants sont trouvés dans le contenu du fichier. :param tags: Ensemble de tags (set). Si None, un nouvel ensemble est créé (type set, optionnel). :param file_content: Contenu du fichier (str). :param words_to_check: Liste de mots à repérer (list). Si None, une liste vide est utilisée (type list, optionnel). :return: Ensemble de tags mis à jour (set). """ # Initialiser l'ensemble tags s'il est None if tags is None: tags = set() # Initialiser la liste words_to_check s'il est None if words_to_check is None: words_to_check = [] # Convertir le contenu du fichier en minuscules pour une recherche insensible à la casse file_content_lower = file_content.lower() # Parcourir chaque mot à vérifier for word in words_to_check: # Vérifier si le mot est présent dans le contenu du fichier # Chercher une correspondance sans mettre en lowercase si le tag est en majuscule, c'est sans doute un acronyme/sigle. if word.isupper(): if word in file_content: tags.add(word) else: if word.lower() in file_content_lower: tags.add(word) return tags # Variable globale pour stocker les fichiers sans tags untagged_files = [] def save_untagged_files(output_file="sources/site_web/build/articles_without_tags.json"): """ Sauvegarde la liste des fichiers sans tags dans un fichier JSON. :param output_file: Chemin du fichier JSON de sortie """ import json import os # Créer le dossier de sortie si nécessaire os.makedirs(os.path.dirname(output_file), exist_ok=True) print('save_untagged_files', len(untagged_files)) # Sauvegarder la liste dans le fichier JSON with open(output_file, 'w', encoding='utf-8') as f: json.dump(untagged_files, f, ensure_ascii=False, indent=4) def extract_tags_from_file(file_path, excluded_tags, auto_detected_tags_list=global_config['auto_tag_terms']): tags = set() with open(file_path, 'r', encoding='utf-8') as file_content: tag_found = False for line in file_content: if global_config['automatic_tagging_enabled'] and len(auto_detected_tags_list) > 0: tags = add_tags_from_content(tags, line, auto_detected_tags_list) # Check for orgmode tags :tag1:tag2: if global_config.get('automatic_tagging_org_files', True): if ':' in line: for word in line.split(): if len(word) > 1 and word.startswith(':') and word.endswith(':'): tag = word[1:-1] if tag not in excluded_tags: tags.add(tag) tag_found = True # Check for #+tags: tag1,tag2 if line.startswith('#+tags:'): for tag in line[len('#+tags:'):].split(','): tag = tag.strip() if tag and tag not in excluded_tags: tags.add(tag) tag_found = True if not tag_found: untagged_files.append(file_path) # print('no tag in the article', file_path) return tags def remove_properties_section(text): pattern = r"

Article

.+?" replacement = "" return re.sub(pattern, replacement, text, flags=re.DOTALL) def remove_article_head_properties_orgmode(text): pattern = r":PROPERTIES:.+?:END:" replacement = "" return re.sub(pattern, replacement, text, flags=re.DOTALL) def remove_hint_html(text): pattern = r"

ceciestduhtml

" replacement = "" return re.sub(pattern, replacement, text, flags=re.DOTALL) def slugify_title(title): """ Convertit un titre en slug URL-friendly en conservant les accents. - Convertit en minuscules - Conserve les accents francophones - Remplace les caractères spéciaux par des tirets - Supprime les tirets multiples Args: title (str): Le titre à convertir Returns: str: Le slug généré """ # Conversion en minuscules title = title.lower() # Liste des caractères autorisés (inclut les accents francophones) # On garde a-z, 0-9, les accents français, les tirets allowed_chars = r'[^a-zàâäéèêëîïôöùûüçñ0-9-]' # Remplacer les caractères non autorisés par des tirets title = re.sub(allowed_chars, '-', title) # Supprimer les tirets en début et fin title = title.strip('-') # Remplacer les tirets multiples par un seul title = re.sub(r'-+', '-', title) return title def find_slug_in_file_basename(file_basename) -> str: """ Extrait l'année et le slug du nom de fichier selon le format spécifié. :param file_basename: Nom de fichier (str). :return: Tuple contenant l'année et le slug (année, slug) ou None si non trouvé. """ pattern = regex_orgroam match = re.match(pattern, file_basename) if match: year = match.group(1) slug = match.group(2) # prendre la partie finale du nom du fichier splitted = slug.split('_') # print('len(splitted)', len(splitted), splitted) if len(splitted) > 1: slug = splitted[len(splitted)-1] slug=enlever_premier_tiret_ou_underscore(slug) slug = f"{year}/{slug}" return slug return None def get_stats_on_all_websites(): """ Retourne des statistiques sur tous les sites web dans le dossier sources/. Pour chaque site, compte le nombre d'articles .org et trouve l'article le plus récent. :return: Dictionnaire avec les stats par site """ stats = {} base_dir = "sources" # Parcourir tous les dossiers de sites dans sources/ for site in os.listdir(base_dir): site_path = os.path.join(base_dir, site) if not os.path.isdir(site_path): continue # Initialiser les stats pour ce site stats[site] = { 'nb_articles': 0, 'nb_mots': 0, 'dernier_article': None, 'date_dernier_article': None } # Chercher les articles .org dans lang_fr et lang_en for lang in ['lang_fr', 'lang_en']: lang_path = os.path.join(site_path, lang) if not os.path.exists(lang_path): continue # Lister tous les fichiers .org org_files = [f for f in os.listdir(lang_path) if f.endswith('.org')] stats[site]['nb_articles'] += len(org_files) # Calculer le nombre total de mots pour ce dossier de langue total_mots = 0 for org_file in org_files: file_path = os.path.join(lang_path, org_file) try: with open(file_path, 'r', encoding='utf-8') as f: contenu = f.read() # Compter les mots en divisant par les espaces total_mots += len(contenu.split()) except Exception as e: print(f"Erreur lors de la lecture de {file_path}: {e}") # Ajouter ou incrémenter le compteur de mots dans les stats stats[site]['nb_mots'] += total_mots # Trouver le fichier le plus récent for org_file in org_files: file_path = os.path.join(lang_path, org_file) mod_time = os.path.getmtime(file_path) if (stats[site]['date_dernier_article'] is None or mod_time > stats[site]['date_dernier_article']): stats[site]['date_dernier_article'] = mod_time stats[site]['dernier_article'] = file_path # Convertir le timestamp en date lisible if stats[site]['date_dernier_article']: stats[site]['date_dernier_article'] = datetime.fromtimestamp( stats[site]['date_dernier_article'] ).strftime('%Y-%m-%d %H:%M:%S') return stats def convert_org_to_html(org_file, output_html_file): """ Convertit un fichier Org en HTML en utilisant pypandoc. :param org_file: Chemin du fichier Org à convertir. :param output_html_file: Chemin du fichier HTML de sortie. """ try: pypandoc.convert_file(org_file, 'html', outputfile=output_html_file) print(f"Conversion réussie : {org_file} -> {output_html_file}") except Exception as e: print(f"Erreur lors de la conversion de {org_file} : {e}") def get_first_picture_url(content): # Utiliser une expression régulière pour # trouver la première URL d'image dans le contenu pattern = r'\[\[(.*?)\]\]' match = re.search(pattern, content) if match: return match.group(1) else: return None def org_to_gmi(org_text: str) -> str: """ Convertit un texte au format Org en un fichier au format GMI (Gemini) en utilisant pypandoc. Args: - org_text (str): Le texte au format Org à convertir. """ # Nettoyer le contenu org en retirant les lignes commençant par #+post cleaned_content = '\n'.join(line for line in org_text.splitlines() if not line.strip().startswith('#+')) org_content = cleaned_content try: converted_text = pypandoc.convert_text(org_content, 'markdown', format='org') # Remplacer les apostrophes échappées converted_text = converted_text.replace('\\\'', '\'') # Convertir les liens simples converted_text = re.sub(r'<(https?://[^>]+)>', r'\n=> \1\n\n', converted_text) # Nettoyer les espaces multiples et retours à la ligne dans les liens simples converted_text = re.sub(r'(\=\> [^\n]+?)[ \n]{2,}', r'\1 ', converted_text) # Nettoyer les espaces multiples et retours à la ligne dans les liens avec description converted_text = re.sub(r'(\=\> [^\n]+?)[ \n]{2,}([^\n]+)', r'\1 \2', converted_text) # Convertir les liens avec description [texte](url) converted_text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\n=> \2 \1\n\n', converted_text) # Ajouter un saut de ligne devant les lignes commençant par # converted_text = re.sub(r'(?m)^#', r'\n#', converted_text) # Remplacer les sauts de ligne multiples par un maximum de 2 sauts de ligne converted_text = re.sub(r'\n{3,}', '\n\n', converted_text) output = f""" ------------------- {converted_text} ------------------- """ except RuntimeError as e: print(f"Erreur de conversion : {e}") return return output def count_files_in_directories(directories): total_count = 0 # Exclure le dossier ".." du comptage for directory in directories: if directory == "..": continue for root, dirs, files in os.walk(directory): print('files', files) total_count += sum(1 for f in files if f.endswith(('.org', '.md', '.gmi'))) continue return total_count def format_date_str(date_str): """ Formate une chaîne de date dans différents formats possibles """ try: # Définir la locale en français try: locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8') except: try: locale.setlocale(locale.LC_TIME, 'fra') except: print("Impossible de définir la locale en français") # Format YYYYMMDDHHMMSS (14 caractères) if len(date_str) == 14: if 'T' not in date_str: return datetime.strptime(date_str, '%Y%m%d%H%M%S').strftime('%d %B %Y à %H:%M:%S') else: # Si contient T, traiter comme YYYYMMDDTHHMM + 1 chiffre base_date = date_str[:12] return datetime.strptime(base_date, '%Y%m%dT%H%M').strftime('%d %B %Y à %H:%M') # Format YYYYMMDDTHHMMSS (15 caractères avec T) elif len(date_str) == 15 and 'T' in date_str: return datetime.strptime(date_str, '%Y%m%dT%H%M%S').strftime('%d %B %Y à %H:%M:%S') # Format YYYYMMDDTHHMM + chiffre optionnel (13 caractères avec T) elif len(date_str) == 13 and 'T' in date_str: # Toujours prendre les 12 premiers caractères (YYYYMMDDTHHMM) base_date = date_str[:12] return datetime.strptime(base_date, '%Y%m%dT%H%M').strftime('%d %B %Y à %H:%M') # Format YYYY-MM-DD elif len(date_str) == 10 and '-' in date_str: return datetime.strptime(date_str, '%Y-%m-%d').strftime('%d %B %Y') else: print(f"Format de date non reconnu: {date_str}") # Essayer d'extraire au moins la date de base try: base_date = date_str[:8] # Prendre juste YYYYMMDD return datetime.strptime(base_date, '%Y%m%d').strftime('%d %B %Y') except ValueError: return date_str except ValueError as e: print(f"Erreur lors du formatage de la date {date_str}: {str(e)}") # En cas d'erreur, essayer de parser juste la partie date try: base_date = date_str[:8] # Prendre juste YYYYMMDD return datetime.strptime(base_date, '%Y%m%d').strftime('%d %B %Y') except ValueError: return date_str def convert_org_to_gemini(org_content): """ Convertit un contenu org en gemini en utilisant pandoc et md2gemini Args: org_content (str): Contenu au format org Returns: str: Contenu converti en format gemini """ # Nettoyer le contenu org en retirant les lignes commençant par #+post cleaned_content = '\n'.join( line for line in org_content.splitlines() if not line.strip().startswith('#+') ) org_content = cleaned_content try: # Créer un fichier temporaire avec le contenu org with tempfile.NamedTemporaryFile(mode='w', suffix='.org', encoding='utf-8') as temp_org: temp_org.write(org_content) temp_org.flush() # Première étape : conversion org vers markdown avec pandoc pandoc_cmd = [ 'pandoc', '-f', 'org', '-t', 'markdown', temp_org.name ] markdown_content = subprocess.check_output( pandoc_cmd, text=True, stderr=subprocess.PIPE ) # Deuxième étape : conversion markdown vers gemini avec md2gemini gemini_content = md2gemini( markdown_content, frontmatter=True, links='inline', ) return gemini_content.strip() except subprocess.CalledProcessError as e: print(f"Erreur lors de la conversion avec pandoc: {e.stderr}") raise except Exception as e: print(f"Erreur lors de la conversion: {str(e)}") raise def save_gemini_file(blog_name, article, articles_info, template_content): """ Sauvegarde le contenu gemini d'un article Args: blog_name (str): Nom du blog article (dict): Dictionnaire contenant les informations de l'article articles_info (dict): Dictionnaire contenant tous les articles template_content (str): Contenu du template """ # Créer le dossier de destination s'il n'existe pas os.makedirs(f"gemini-capsules/{blog_name}/{article['annee']}", exist_ok=True) # Construire les liens précédent/suivant previous_article_link = "" next_article_link = "" tags = "" if article['tags']: tags = "Tags: " + ', '.join(article['tags']) if article['previous'] and articles_info and article['previous'] in articles_info: prev = articles_info[article['previous']] print('prev', prev['title']) slug = slugify_title(prev['title']) previous_article_link = f"=> {prev['annee']}/{slug}.gmi {prev['date_formattee']} - {prev['title']}" if article['next'] and articles_info and article['next'] in articles_info: next_art = articles_info[article['next']] print('next', next_art['title']) slug = slugify_title(next_art['title']) next_article_link = f"=> {next_art['annee']}/{slug}.gmi {next_art['date_formattee']} - {next_art['title']}" # Construire le contenu gemini complet gemini_content = f"""# {article['title']} Date: {article['date_formattee']} {article['gemini_content']} ----------------------------------------------- {tags} ----------------------------------------------- {template_content['AUTHOR']} {template_content['SOUTIEN']} ----------------------------------------------- Navigation: """ if previous_article_link: gemini_content += f"\nArticle précédent:\n{previous_article_link}" if next_article_link: gemini_content += f"\nArticle suivant:\n{next_article_link}" gemini_content += f""" ----------------------------------------------- => index.gmi Index des articles """ # Sauvegarder le fichier slug = slugify_title(article['title']) os.makedirs(f"gemini-capsules/{blog_name}/{article['annee']}", exist_ok=True) with open(f"gemini-capsules/{blog_name}/{article['annee']}/{slug}.gmi", "w", encoding="utf-8") as f: f.write(gemini_content)