📜 Suivi automatique de l'historique des modifications des donnĂ©es dans Grist

:dart: Le problÚme résolu

Vous avez besoin de savoir qui a modifié quoi et quand sur vos fiches Grist ? Cette solution crée un journal de bord automatique qui enregistre chaque modification avec :

  • :date: La date et l’heure
  • :bust_in_silhouette: L’utilisateur qui a fait le changement
  • :arrows_counterclockwise: L’ancienne et la nouvelle valeur

Exemple de rendu :

👉 Modification du 23 Nov 2025 14:30 (Marie DUPONT)
  - Statut : En attente → ValidĂ©
  - PrioritĂ© : Normale → Haute

📅 Créé le 20 Nov 2025 09:15 (par Jean MARTIN)
- Nom Du Projet : Mon super projet
- Direction : DSI
- Statut : En attente

:building_construction: Architecture de la solution

La solution repose sur 2 champs qui travaillent ensemble :

Champ Type RĂŽle
HISTORIQUE_BRUT Texte (formule d’initialisation) Stocke l’historique + un snapshot encodĂ© en base64
HISTORIQUE Texte (formule) Affiche l’historique nettoyĂ© (sans le snapshot technique)

:question: Pourquoi 2 champs et pas 1 seul ?

Pour dĂ©tecter les modifications, on doit comparer l’état actuel avec l’état prĂ©cĂ©dent. Mais Grist ne garde pas l’historique des valeurs !

Notre solution : Ă  chaque sauvegarde, on stocke une « photo Â» (snapshot) de toutes les valeurs, encodĂ©e en base64. Ce snapshot ressemble Ă  ça :

[SNAP:eyJOT01fRFVfUFJPSkVUIjoiTW9uIHByb2pldCIsIlNUQVRVVCI6IkVuIGNvdXJzIn0=]

C’est illisible pour un humain, mais indispensable pour la comparaison !

Le problùme : si on n’avait qu’un seul champ, l’utilisateur verrait ce charabia base64 dans l’historique.

La solution :

  • HISTORIQUE_BRUT stocke tout (historique lisible + snapshot technique)
  • HISTORIQUE filtre l’affichage pour masquer le snapshot et ne montrer que la partie lisible

→ L’utilisateur ne voit que HISTORIQUE, propre et lisible ! :dart:

:bulb: Le principe technique

  1. À chaque sauvegarde, on crĂ©e un snapshot (photo) de toutes les valeurs du record
  2. On encode ce snapshot en base64 et on le stocke dans le champ
  3. À la prochaine modification, on compare le nouveau snapshot avec l’ancien
  4. On génÚre les lignes de différences détectées
  5. L’historique s’empile : le plus rĂ©cent en haut

:memo: Mise en place étape par étape

Étape 1 : CrĂ©er le champ HISTORIQUE_BRUT

  1. Ajoutez une nouvelle colonne nommée HISTORIQUE_BRUT
  2. Type : Texte
  3. :warning: Important : Utilisez une formule d’initialisation (pas une formule normale)
    • Cliquez sur « DĂ©finir la formule d’initialisation Â» dans les options de colonne
    • Cochez « Appliquer sur les nouvelles lignes Â»
    • Cochez « RĂ©appliquer en cas de modification Â» → « N’importe quel champ (except formulas) Â»
  4. Collez la formule complĂšte ci-dessous

Étape 2 : CrĂ©er le champ HISTORIQUE

  1. Ajoutez une nouvelle colonne nommée HISTORIQUE
  2. Type : Texte avec formule (formule normale cette fois)
  3. Collez la formule de filtrage ci-dessous

Étape 3 : Configurer l’affichage

  • Affichez le champ HISTORIQUE dans vos vues (pas HISTORIQUE_BRUT)
  • Masquez HISTORIQUE_BRUT si vous le souhaitez (il doit exister mais peut ĂȘtre cachĂ©)

:clipboard: Formule HISTORIQUE_BRUT (formule d’initialisation)

# =============================================================================
# 📜 SYSTÈME DE SUIVI D'HISTORIQUE DES MODIFICATIONS
# =============================================================================
# Cette formule crée un journal automatique de toutes les modifications
# Elle utilise un systÚme de "snapshots" encodés en base64 pour comparer
# les valeurs entre chaque sauvegarde
#
# 💡 Fonctionne comme une "formule d'initialisation" (DĂ©finir la formule d'initialisation)
#    avec "Appliquer sur les nouvelles lignes" ET "Réappliquer en cas de modification" cochés
# =============================================================================


# =============================================================================
# ⚙ CONFIGURATION - ZONE MODIFIABLE
# =============================================================================
# Personnalisez ici les colonnes Ă  exclure du suivi

# Liste des colonnes Ă  ignorer (noms exacts)
COLONNES_EXCLUES = {
    'id',                # Identifiant technique Grist
    'manualSort',        # Ordre de tri manuel
    'HISTORIQUE_BRUT',   # Ce champ lui-mĂȘme (Ă©vite la boucle infinie)
    'HISTORIQUE',        # Le champ d'affichage filtré
    'Formula'            # Colonnes de type formule
}

# Patterns à exclure (tout champ contenant ces textes sera ignoré)
PATTERNS_EXCLUS = [
    'gristHelper_',      # Colonnes techniques Grist
    'gristhelper_',      # Variante minuscule
    '#Summary#',         # Colonnes de résumé
    '#Lookup#',          # Colonnes de lookup
    '_summary_',         # Autre pattern de résumé
    '#lookup#'           # Autre pattern de lookup
]

# Configuration des colonnes d'affichage pour les références
# Format: 'NomTable': 'NomColonneAAfficher'
# Exemple: 'Clients': 'NOM_CLIENT' affichera le nom au lieu de l'ID
SHOW_COLUMNS_PAR_TABLE = {
    # Décommentez et adaptez selon vos tables :
    # 'MaTable': 'MA_COLONNE_DISPLAY',
}


# =============================================================================
# 🔧 FONCTIONS UTILITAIRES - NE PAS MODIFIER
# =============================================================================

def exclure(nom_col):
    """
    VĂ©rifie si une colonne doit ĂȘtre exclue du suivi
    Retourne True si la colonne doit ĂȘtre ignorĂ©e
    """
    # Exclure si dans la liste noire ou commence par #
    if nom_col in COLONNES_EXCLUES or nom_col.startswith('#'):
        return True
    # Exclure si contient un des patterns exclus
    return any(p.lower() in nom_col.lower() for p in PATTERNS_EXCLUS)


def formater_date(date_obj):
    """
    Formate une date en français lisible
    Exemple: 23 Nov 2024
    """
    if not date_obj:
        return ''
    try:
        # Dictionnaire des mois en français abrégé
        mois = {
            1:'Jan', 2:'Fev', 3:'Mar', 4:'Avr', 5:'Mai', 6:'Juin',
            7:'Juil', 8:'Aout', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'
        }
        jour = str(date_obj.day).zfill(2)
        mois_str = mois.get(date_obj.month, str(date_obj.month))
        annee = str(date_obj.year)
        return f"{jour} {mois_str} {annee}"
    except:
        return str(date_obj)


def extraire_display_reference(ref_obj):
    """
    Extrait la valeur d'affichage d'un objet Reference
    GÚre les colonnes de référence vers d'autres tables
    """
    if not ref_obj or not hasattr(ref_obj, '_table'):
        return None
    
    try:
        # Récupérer le nom de la table référencée
        table_name = ref_obj._table.table_id if hasattr(ref_obj._table, 'table_id') else None
        
        # Si configuration personnalisée existe pour cette table
        if table_name and table_name in SHOW_COLUMNS_PAR_TABLE:
            col_name = SHOW_COLUMNS_PAR_TABLE[table_name]
            if hasattr(ref_obj, col_name):
                val = getattr(ref_obj, col_name, None)
                if val is not None and not hasattr(val, '_table'):
                    s = str(val).strip()
                    if s and s != 'None':
                        return s
        
        # Sinon utiliser gristHelper_Display (colonne d'affichage par défaut)
        if hasattr(ref_obj, 'gristHelper_Display'):
            display = getattr(ref_obj, 'gristHelper_Display', None)
            if display is not None and not hasattr(display, '_table'):
                s = str(display).strip()
                if s and s != 'None':
                    return s
        
        # Fallback : chercher la premiĂšre colonne texte non vide
        attrs = sorted([a for a in dir(ref_obj) if not a.startswith('_') and not a.startswith('#')])
        colonnes_metier = [a for a in attrs if a not in ['id', 'manualSort', 'isCompleted', 'isDuplicated'] and not a.startswith('gristHelper')]
        
        for attr in colonnes_metier:
            try:
                val = getattr(ref_obj, attr, None)
                if val is None or hasattr(val, '_table') or isinstance(val, (list, tuple)):
                    continue
                if isinstance(val, str):
                    s = val.strip()
                    if s and s != 'None':
                        return s
            except:
                continue
        
        return str(ref_obj).strip()
        
    except:
        try:
            return str(ref_obj).strip()
        except:
            return ''


def formater_valeur(nom_col, val):
    """
    Formate une valeur selon son type pour un affichage lisible
    GÚre : dates, booléens, listes, références, texte simple
    """
    if val is None:
        return ''
    
    # Traitement des dates
    try:
        if hasattr(val, 'day') and hasattr(val, 'month'):
            return formater_date(val)
    except:
        pass
    
    # Traitement des booléens
    if isinstance(val, bool):
        return 'Oui' if val else 'Non'
    
    # Traitement des listes (Reference Lists)
    if isinstance(val, (list, tuple)):
        if not val:
            return ''
        elements = []
        for item in val:
            display = extraire_display_reference(item)
            if display:
                elements.append(display)
            else:
                s = str(item).strip()
                if s and s != 'None' and not s.startswith('Record('):
                    elements.append(s)
        return ', '.join(elements) if elements else ''
    
    # Traitement des références simples
    if hasattr(val, '_table'):
        display = extraire_display_reference(val)
        if display:
            return display
        return ''
    
    # Traitement des valeurs simples (texte, nombres)
    try:
        s = str(val).strip()
        if not s or s == 'None':
            return ''
        if s.startswith('Record(') or s.startswith('[Record('):
            return ''
        if s == '[]':
            return ''
        return s
    except:
        return ''


def obtenir_colonnes():
    """
    RécupÚre la liste de toutes les colonnes de la table actuelle
    Exclut automatiquement les colonnes techniques
    """
    cols = set()
    try:
        for attr in dir(type(rec)):
            if not attr.startswith('_') and not exclure(attr):
                attr_obj = getattr(type(rec), attr, None)
                if isinstance(attr_obj, property) or hasattr(attr_obj, '__get__'):
                    cols.add(attr)
    except:
        pass
    return sorted(list(cols))


def libelle(nom_col):
    """
    Convertit un nom de colonne technique en libellé lisible
    Exemple: 'NOM_DU_PROJET' → 'Nom Du Projet'
    """
    mots = nom_col.replace('_', ' ').split()
    return ' '.join(m if (m.isupper() and len(m) <= 5) else m.capitalize() for m in mots)


def timestamp():
    """
    GénÚre un timestamp formaté avec date et heure
    Exemple: 23 Nov 2024 14:30
    """
    t = NOW()
    mois = {
        1:'Jan', 2:'Fev', 3:'Mar', 4:'Avr', 5:'Mai', 6:'Juin',
        7:'Juil', 8:'Aout', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'
    }
    jour = str(t.day).zfill(2)
    mois_str = mois[t.month]
    annee = str(t.year)
    heure = str(t.hour).zfill(2)
    minute = str(t.minute).zfill(2)
    return f"{jour} {mois_str} {annee} {heure}:{minute}"


def nom_user():
    """
    RécupÚre le nom de l'utilisateur connecté
    """
    try:
        return user.Name
    except:
        return "Inconnu"


# =============================================================================
# 📾 GESTION DES SNAPSHOTS - NE PAS MODIFIER
# =============================================================================

def creer_snap():
    """
    Crée un snapshot (photo) de toutes les valeurs actuelles du record
    Retourne un dictionnaire {colonne: valeur_formatée}
    """
    snap = {}
    for col in obtenir_colonnes():
        try:
            val = getattr(rec, col, None)
            snap[col] = formater_valeur(col, val)
        except:
            snap[col] = ''
    return snap


def encoder_snap(snap):
    """
    Encode un snapshot en base64 pour stockage
    Format: [SNAP:données_encodées]
    """
    import base64, json
    data = json.dumps(snap, ensure_ascii=False)
    encoded = base64.b64encode(data.encode('utf-8')).decode('ascii')
    return f"[SNAP:{encoded}]"


def decoder_snap(texte):
    """
    Décode le premier snapshot trouvé dans le texte
    Retourne le dictionnaire des valeurs précédentes
    """
    if not texte or '[SNAP:' not in texte:
        return {}
    try:
        import base64, json
        debut = texte.find('[SNAP:')
        if debut == -1:
            return {}
        fin = texte.find(']', debut)
        if fin == -1:
            return {}
        encoded = texte[debut+6:fin]
        data = base64.b64decode(encoded.encode('ascii')).decode('utf-8')
        return json.loads(data)
    except:
        return {}


# =============================================================================
# 🎯 LOGIQUE PRINCIPALE - NE PAS MODIFIER
# =============================================================================

if not value:
    # =========================================================================
    # CAS 1 : CRÉATION D'UN NOUVEAU RECORD
    # =========================================================================
    # Le champ est vide → c'est une nouvelle fiche
    # On enregistre l'état initial avec toutes les valeurs renseignées
    
    snap = creer_snap()
    ts = timestamp()
    usr = nom_user()
    
    # Construire le message de création
    lignes = [f"📅 Créé le {ts} (par {usr})"]
    
    # Lister toutes les valeurs non vides
    for col in sorted(snap.keys()):
        val = snap[col]
        if val:
            lignes.append(f"- {libelle(col)} : {val}")
    
    # Ajouter le snapshot encodé (sera filtré à l'affichage)
    lignes.append("")
    lignes.append(encoder_snap(snap))
    
    return '\n'.join(lignes)

else:
    # =========================================================================
    # CAS 2 : MODIFICATION D'UN RECORD EXISTANT
    # =========================================================================
    # Le champ contient dĂ©jĂ  de l'historique → on compare avec le snapshot prĂ©cĂ©dent
    
    snap_actuel = creer_snap()
    snap_prec = decoder_snap(value)
    
    # Détecter les colonnes ajoutées ou supprimées (évolution du schéma)
    colonnes_actuelles = set(snap_actuel.keys())
    colonnes_precedentes = set(snap_prec.keys())
    
    colonnes_ajoutees = colonnes_actuelles - colonnes_precedentes
    colonnes_supprimees = colonnes_precedentes - colonnes_actuelles
    
    # Liste pour stocker tous les changements détectés
    changements = []
    
    # Traiter les colonnes supprimées
    for col in sorted(colonnes_supprimees):
        lib = libelle(col)
        val_old = snap_prec.get(col, '')
        if val_old:
            changements.append(f"  - đŸ—‘ïž {lib} : colonne supprimĂ©e (ancienne valeur : {val_old})")
        else:
            changements.append(f"  - đŸ—‘ïž {lib} : colonne supprimĂ©e")
    
    # Traiter les colonnes ajoutées
    for col in sorted(colonnes_ajoutees):
        val_new = snap_actuel[col]
        if val_new:
            lib = libelle(col)
            changements.append(f"  - ➕ {lib} : nouvelle colonne ajoutĂ©e ({val_new})")
    
    # Comparer les valeurs des colonnes existantes
    for col in sorted(colonnes_actuelles & colonnes_precedentes):
        val_new = snap_actuel[col]
        val_old = snap_prec.get(col, '')
        
        if val_new != val_old:
            lib = libelle(col)
            if val_old and val_new:
                # Modification : ancienne → nouvelle
                changements.append(f"  - {lib} : {val_old} → {val_new}")
            elif val_new:
                # Ajout de valeur (était vide)
                changements.append(f"  - {lib} : {val_new}")
            elif val_old:
                # Suppression de valeur
                changements.append(f"  - {lib} : (supprimé)")
    
    # Si aucun changement détecté, ne rien faire
    if not changements:
        return value
    
    # Construire le nouveau bloc d'historique
    ts = timestamp()
    usr = nom_user()
    
    nouvelle = [
        f"👉 Modification du {ts} ({usr})",
        '\n'.join(changements),
        "",
        encoder_snap(snap_actuel),
        ""
    ]
    
    # Ajouter en tĂȘte de l'historique existant
    return '\n'.join(nouvelle) + '\n' + value

:clipboard: Formule HISTORIQUE (formule normale)

Cette formule filtre l’affichage pour masquer les snapshots techniques :

# =============================================================================
# 📜 FILTRE D'AFFICHAGE DE L'HISTORIQUE
# =============================================================================
# Cette formule nettoie l'historique brut pour l'affichage :
# - Supprime les lignes de snapshot encodées en base64
# - Nettoie les lignes vides multiples
# =============================================================================

# Si pas d'historique, retourner vide
if not $HISTORIQUE_BRUT:
    return ''

texte = $HISTORIQUE_BRUT
resultat = []
lignes = texte.split('\n')

for ligne in lignes:
    # Ignorer les lignes de snapshot (données techniques encodées)
    if ligne.strip().startswith('[SNAP:'):
        continue
    
    resultat.append(ligne)

# Nettoyer les lignes vides multiples consécutives
output = []
ligne_vide_precedente = False

for ligne in resultat:
    if ligne.strip() == '':
        # N'ajouter qu'une seule ligne vide Ă  la suite
        if not ligne_vide_precedente:
            output.append(ligne)
            ligne_vide_precedente = True
    else:
        output.append(ligne)
        ligne_vide_precedente = False

return '\n'.join(output)

:art: Personnalisation

Exclure des colonnes du suivi

Si vous ne voulez pas suivre les modifications de certains champs, modifiez COLONNES_EXCLUES dans la formule HISTORIQUE_BRUT et ajoutez le nom des champs que vous ne voulez pas suivre :

COLONNES_EXCLUES = {
    'id', 'manualSort', 'HISTORIQUE_BRUT', 'HISTORIQUE',
    'MA_COLONNE_A_IGNORER',  # ← Ajoutez ici
    'AUTRE_COLONNE'
}

Configurer l’affichage des rĂ©fĂ©rences

Si vous avez des colonnes de type Reference, configurez l’affichage :

SHOW_COLUMNS_PAR_TABLE = {
    'Clients': 'NOM_CLIENT',       # Affiche le nom au lieu de l'ID
    'Utilisateurs': 'EMAIL',       # Affiche l'email
}

Changer les emojis

Vous pouvez personnaliser les emojis dans les lignes :

  • 📅 : CrĂ©ation
  • 👉 : Modification
  • đŸ—‘ïž : Colonne supprimĂ©e
  • ➕ : Colonne ajoutĂ©e

:warning: Points d’attention

  1. Formule d’initialisation : HISTORIQUE_BRUT doit ĂȘtre une formule d’initialisation, pas une formule normale. Sinon elle se recalculera en boucle !

  2. Performance : Sur des tables avec beaucoup de colonnes, le snapshot peut devenir volumineux. Excluez les colonnes non pertinentes.

  3. Records existants : L’historique ne dĂ©marre qu’à partir du moment oĂč vous ajoutez ces champs. Les modifications antĂ©rieures ne sont pas trackĂ©es.

  4. Formules exclues : Les colonnes de type formule sont automatiquement exclues (elles se recalculent, pas besoin de les tracker).

8 « J'aime »

Wow, sacré beau travail, avec un code propre et super bien commenté, merci !

Pourrait-on imaginer une alternative qui sauvegarde chaque modification dans une table de log (avec par exemple les colonnes « date Â», « table Â», « id_ligne Â», « utilisateur·ice Â», « modification Â»), en utilisant la fonction lookupOrAddDerived ?

:eyes: Who Did What and When? - Change History Tracking in Grist

The Problem

Need to know who changed what and when on your Grist records? This solution creates an automatic changelog that records every modification with:

  • The date and time
  • The user who made the change
  • The old AND new values

Example output:

👉 Modified on 23 Nov 2024 14:30 (Marie DUPONT)
  - Status : Pending → Approved
  - Priority : Normal → High

📅 Created on 20 Nov 2024 09:15 (by Jean MARTIN)
- Project Name : My awesome project
- Department : IT
- Status : Pending

Architecture

The solution uses 2 columns working together:

Column Type Purpose
HISTORY_RAW Text (trigger formula) Stores the history + a base64-encoded snapshot
HISTORY Text (formula) Displays the cleaned history (without the technical snapshot)

Why 2 columns instead of 1?

To detect changes, we need to compare the current state with the previous state. But Grist doesn’t keep a history of values!

Our solution: on every save, we store a « photo Â» (snapshot) of all values, encoded in base64. This snapshot looks like this:

[SNAP:eyJQUk9KRUNUX05BTUUiOiJNeSBwcm9qZWN0IiwiU1RBVFVTIjoiSW4gcHJvZ3Jlc3MifQ==]

It’s unreadable for humans, but essential for comparison!

The problem: with only one column, users would see this base64 gibberish in the history.

The solution:

  • HISTORY_RAW stores everything (readable history + technical snapshot)
  • HISTORY filters the display to hide the snapshot and show only the readable part

The user only sees HISTORY, clean and readable!

How it works

  1. On each save, we create a snapshot of all record values
  2. We encode this snapshot in base64 and store it in the column
  3. On the next modification, we compare the new snapshot with the previous one
  4. We generate the lines of detected differences
  5. History stacks up: most recent on top

Step-by-Step Setup

Step 1: Create the HISTORY_RAW column

  1. Add a new column named HISTORY_RAW
  2. Type: Text
  3. Important: Use a trigger formula (not a regular formula)
    • Click on « Set trigger formula Â» in the column options
    • Check « Apply to new records Â»
    • Check « Apply on record changes Â» → « Any field (except formulas) Â»
  4. Paste the complete formula below

Step 2: Create the HISTORY column

  1. Add a new column named HISTORY
  2. Type: Text with formula (regular formula this time)
  3. Paste the filter formula below

Step 3: Configure the display

  • Display the HISTORY column in your views (not HISTORY_RAW)
  • Hide HISTORY_RAW if you want (it must exist but can be hidden)

HISTORY_RAW Formula (trigger formula)

# =============================================================================
# CHANGE HISTORY TRACKING SYSTEM
# =============================================================================
# This formula creates an automatic log of all modifications
# It uses a "snapshot" system encoded in base64 to compare
# values between each save
#
# Works as a "trigger formula" (Set trigger formula)
# with "Apply to new records" AND "Apply on record changes" checked
# =============================================================================


# =============================================================================
# CONFIGURATION - EDITABLE ZONE
# =============================================================================
# Customize here the columns to exclude from tracking

# List of columns to ignore (exact names)
# IMPORTANT: The name of the column containing this formula MUST be here!
EXCLUDED_COLUMNS = {
    'id',              # Grist technical identifier
    'manualSort',      # Manual sort order
    'HISTORY_RAW',     # This column itself (avoids infinite loop)
    'HISTORY',         # The filtered display column
    'Formula'          # Formula-type columns
}

# Patterns to exclude (any column containing these texts will be ignored)
EXCLUDED_PATTERNS = [
    'gristHelper_',    # Grist technical columns
    'gristhelper_',    # Lowercase variant
    '#Summary#',       # Summary columns
    '#Lookup#',        # Lookup columns
    '_summary_',       # Another summary pattern
    '#lookup#'         # Another lookup pattern
]

# Display column configuration for references
# Format: 'TableName': 'DisplayColumnName'
# Example: 'Clients': 'CLIENT_NAME' will display the name instead of the ID
SHOW_COLUMNS_BY_TABLE = {
    # Uncomment and adapt according to your tables:
    # 'MyTable': 'MY_DISPLAY_COLUMN',
}


# =============================================================================
# UTILITY FUNCTIONS - DO NOT MODIFY
# =============================================================================

def should_exclude(col_name):
    """
    Checks if a column should be excluded from tracking
    Returns True if the column should be ignored
    """
    # Exclude if in blacklist or starts with #
    if col_name in EXCLUDED_COLUMNS or col_name.startswith('#'):
        return True
    # Exclude if contains one of the excluded patterns
    return any(p.lower() in col_name.lower() for p in EXCLUDED_PATTERNS)


def format_date(date_obj):
    """
    Formats a date in readable format
    Example: 23 Nov 2024
    """
    if not date_obj:
        return ''
    try:
        # Month dictionary
        months = {
            1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun',
            7:'Jul', 8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'
        }
        day = str(date_obj.day).zfill(2)
        month_str = months.get(date_obj.month, str(date_obj.month))
        year = str(date_obj.year)
        return f"{day} {month_str} {year}"
    except:
        return str(date_obj)


def extract_reference_display(ref_obj):
    """
    Extracts the display value from a Reference object
    Handles reference columns to other tables
    """
    if not ref_obj or not hasattr(ref_obj, '_table'):
        return None
    
    try:
        # Get the referenced table name
        table_name = ref_obj._table.table_id if hasattr(ref_obj._table, 'table_id') else None
        
        # If custom configuration exists for this table
        if table_name and table_name in SHOW_COLUMNS_BY_TABLE:
            col_name = SHOW_COLUMNS_BY_TABLE[table_name]
            if hasattr(ref_obj, col_name):
                val = getattr(ref_obj, col_name, None)
                if val is not None and not hasattr(val, '_table'):
                    s = str(val).strip()
                    if s and s != 'None':
                        return s
        
        # Otherwise use gristHelper_Display (default display column)
        if hasattr(ref_obj, 'gristHelper_Display'):
            display = getattr(ref_obj, 'gristHelper_Display', None)
            if display is not None and not hasattr(display, '_table'):
                s = str(display).strip()
                if s and s != 'None':
                    return s
        
        # Fallback: look for the first non-empty text column
        attrs = sorted([a for a in dir(ref_obj) if not a.startswith('_') and not a.startswith('#')])
        business_cols = [a for a in attrs if a not in ['id', 'manualSort', 'isCompleted', 'isDuplicated'] and not a.startswith('gristHelper')]
        
        for attr in business_cols:
            try:
                val = getattr(ref_obj, attr, None)
                if val is None or hasattr(val, '_table') or isinstance(val, (list, tuple)):
                    continue
                if isinstance(val, str):
                    s = val.strip()
                    if s and s != 'None':
                        return s
            except:
                continue
        
        return str(ref_obj).strip()
        
    except:
        try:
            return str(ref_obj).strip()
        except:
            return ''


def format_value(col_name, val):
    """
    Formats a value according to its type for readable display
    Handles: dates, booleans, lists, references, simple text
    """
    if val is None:
        return ''
    
    # Date handling
    try:
        if hasattr(val, 'day') and hasattr(val, 'month'):
            return format_date(val)
    except:
        pass
    
    # Boolean handling
    if isinstance(val, bool):
        return 'Yes' if val else 'No'
    
    # List handling (Reference Lists)
    if isinstance(val, (list, tuple)):
        if not val:
            return ''
        elements = []
        for item in val:
            display = extract_reference_display(item)
            if display:
                elements.append(display)
            else:
                s = str(item).strip()
                if s and s != 'None' and not s.startswith('Record('):
                    elements.append(s)
        return ', '.join(elements) if elements else ''
    
    # Simple reference handling
    if hasattr(val, '_table'):
        display = extract_reference_display(val)
        if display:
            return display
        return ''
    
    # Simple value handling (text, numbers)
    try:
        s = str(val).strip()
        if not s or s == 'None':
            return ''
        if s.startswith('Record(') or s.startswith('[Record('):
            return ''
        if s == '[]':
            return ''
        return s
    except:
        return ''


def get_columns():
    """
    Gets the list of all columns in the current table
    Automatically excludes technical columns
    """
    cols = set()
    try:
        for attr in dir(type(rec)):
            if not attr.startswith('_') and not should_exclude(attr):
                attr_obj = getattr(type(rec), attr, None)
                if isinstance(attr_obj, property) or hasattr(attr_obj, '__get__'):
                    cols.add(attr)
    except:
        pass
    return sorted(list(cols))


def to_label(col_name):
    """
    Converts a technical column name to a readable label
    Example: 'PROJECT_NAME' → 'Project Name'
    """
    words = col_name.replace('_', ' ').split()
    return ' '.join(w if (w.isupper() and len(w) <= 5) else w.capitalize() for w in words)


def timestamp():
    """
    Generates a formatted timestamp with date and time
    Example: 23 Nov 2024 14:30
    """
    t = NOW()
    months = {
        1:'Jan', 2:'Feb', 3:'Mar', 4:'Apr', 5:'May', 6:'Jun',
        7:'Jul', 8:'Aug', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'
    }
    day = str(t.day).zfill(2)
    month_str = months[t.month]
    year = str(t.year)
    hour = str(t.hour).zfill(2)
    minute = str(t.minute).zfill(2)
    return f"{day} {month_str} {year} {hour}:{minute}"


def get_username():
    """
    Gets the logged-in user's name
    """
    try:
        return user.Name
    except:
        return "Unknown"


# =============================================================================
# SNAPSHOT MANAGEMENT - DO NOT MODIFY
# =============================================================================

def create_snapshot():
    """
    Creates a snapshot of all current record values
    Returns a dictionary {column: formatted_value}
    """
    snap = {}
    for col in get_columns():
        try:
            val = getattr(rec, col, None)
            snap[col] = format_value(col, val)
        except:
            snap[col] = ''
    return snap


def encode_snapshot(snap):
    """
    Encodes a snapshot in base64 for storage
    Format: [SNAP:encoded_data]
    """
    import base64, json
    data = json.dumps(snap, ensure_ascii=False)
    encoded = base64.b64encode(data.encode('utf-8')).decode('ascii')
    return f"[SNAP:{encoded}]"


def decode_snapshot(text):
    """
    Decodes the first snapshot found in the text
    Returns the dictionary of previous values
    """
    if not text or '[SNAP:' not in text:
        return {}
    try:
        import base64, json
        start = text.find('[SNAP:')
        if start == -1:
            return {}
        end = text.find(']', start)
        if end == -1:
            return {}
        encoded = text[start+6:end]
        data = base64.b64decode(encoded.encode('ascii')).decode('utf-8')
        return json.loads(data)
    except:
        return {}


# =============================================================================
# MAIN LOGIC - DO NOT MODIFY
# =============================================================================

if not value:
    # =========================================================================
    # CASE 1: NEW RECORD CREATION
    # =========================================================================
    # The field is empty → it's a new record
    # We record the initial state with all filled values
    
    snap = create_snapshot()
    ts = timestamp()
    usr = get_username()
    
    # Build the creation message
    lines = [f"📅 Created on {ts} (by {usr})"]
    
    # List all non-empty values
    for col in sorted(snap.keys()):
        val = snap[col]
        if val:
            lines.append(f"- {to_label(col)} : {val}")
    
    # Add the encoded snapshot (will be filtered on display)
    lines.append("")
    lines.append(encode_snapshot(snap))
    
    return '\n'.join(lines)

else:
    # =========================================================================
    # CASE 2: EXISTING RECORD MODIFICATION
    # =========================================================================
    # The field already contains history → we compare with the previous snapshot
    
    current_snap = create_snapshot()
    prev_snap = decode_snapshot(value)
    
    # Detect added or removed columns (schema evolution)
    current_cols = set(current_snap.keys())
    prev_cols = set(prev_snap.keys())
    
    added_cols = current_cols - prev_cols
    removed_cols = prev_cols - current_cols
    
    # List to store all detected changes
    changes = []
    
    # Handle removed columns
    for col in sorted(removed_cols):
        label = to_label(col)
        old_val = prev_snap.get(col, '')
        if old_val:
            changes.append(f"  - đŸ—‘ïž {label} : column removed (old value: {old_val})")
        else:
            changes.append(f"  - đŸ—‘ïž {label} : column removed")
    
    # Handle added columns
    for col in sorted(added_cols):
        new_val = current_snap[col]
        if new_val:
            label = to_label(col)
            changes.append(f"  - ➕ {label} : new column added ({new_val})")
    
    # Compare values of existing columns
    for col in sorted(current_cols & prev_cols):
        new_val = current_snap[col]
        old_val = prev_snap.get(col, '')
        
        if new_val != old_val:
            label = to_label(col)
            if old_val and new_val:
                # Modification: old → new
                changes.append(f"  - {label} : {old_val} → {new_val}")
            elif new_val:
                # Value added (was empty)
                changes.append(f"  - {label} : {new_val}")
            elif old_val:
                # Value removed
                changes.append(f"  - {label} : (removed)")
    
    # If no changes detected, do nothing
    if not changes:
        return value
    
    # Build the new history block
    ts = timestamp()
    usr = get_username()
    
    new_entry = [
        f"👉 Modified on {ts} ({usr})",
        '\n'.join(changes),
        "",
        encode_snapshot(current_snap),
        ""
    ]
    
    # Add at the top of existing history
    return '\n'.join(new_entry) + '\n' + value

HISTORY Formula (regular formula)

This formula filters the display to hide the technical snapshots:

# =============================================================================
# HISTORY DISPLAY FILTER
# =============================================================================
# This formula cleans the raw history for display:
# - Removes base64-encoded snapshot lines
# - Cleans up multiple empty lines
# =============================================================================

# If no history, return empty
if not $HISTORY_RAW:
    return ''

text = $HISTORY_RAW
result = []
lines = text.split('\n')

for line in lines:
    # Ignore snapshot lines (encoded technical data)
    if line.strip().startswith('[SNAP:'):
        continue
    
    result.append(line)

# Clean up multiple consecutive empty lines
output = []
prev_line_empty = False

for line in result:
    if line.strip() == '':
        # Only add one empty line in a row
        if not prev_line_empty:
            output.append(line)
            prev_line_empty = True
    else:
        output.append(line)
        prev_line_empty = False

return '\n'.join(output)

Customization

Exclude columns from tracking

Edit EXCLUDED_COLUMNS in the HISTORY_RAW formula:

EXCLUDED_COLUMNS = {
    'id', 'manualSort', 'HISTORY_RAW', 'HISTORY',
    'MY_COLUMN_TO_IGNORE',  # ← Add here
    'ANOTHER_COLUMN'
}

Configure reference display

If you have Reference-type columns, configure the display:

SHOW_COLUMNS_BY_TABLE = {
    'Clients': 'CLIENT_NAME',    # Displays the name instead of the ID
    'Users': 'EMAIL',            # Displays the email
}

Change the emojis

You can customize the emojis in the output:

  • 📅 : Creation
  • 👉 : Modification
  • đŸ—‘ïž : Column removed
  • ➕ : Column added

Important Notes

  1. Trigger formula: HISTORY_RAW must be a trigger formula, not a regular formula. Otherwise it will recalculate in an infinite loop!

  2. Existing records: History only starts from the moment you add these columns. Previous modifications are not tracked.

  3. Formula columns: Formula-type columns are automatically excluded (they recalculate themselves, no need to track them).

Known Limitations

  1. Performance: On tables with many columns, the snapshot can become large. If you have 50+ columns, consider excluding irrelevant ones to keep things fast.

  2. Edge cases: Columns with very unusual names or special characters might behave unexpectedly. We haven’t tested every possible scenario.

  3. No retroactive history: This only tracks changes from the moment you set it up. It can’t recover past modifications.

  4. Storage: The base64 snapshots add data to your document. On very active tables with thousands of records and frequent changes, this could add up over time.

1 « J'aime »