Realizzare un archivio stile X-Files in C: terminale, SQLite e gestione dei documenti

Uno dei progetti personali che mi ha divertito maggiormente negli ultimi tempi è stato lo sviluppo di una piccola applicazione scritta interamente in C e ispirata all’atmosfera di X-Files: un archivio riservato accessibile da terminale, con autenticazione, documenti classificati e persistenza dei dati tramite SQLite.

L’obiettivo non era semplicemente realizzare un programma CRUD, ma costruire un’applicazione che avesse una propria identità visiva pur rimanendo completamente all’interno di una console. Il progetto è stato anche un buon esercizio per lavorare con aspetti del C che normalmente vengono nascosti dai framework e dai linguaggi di più alto livello: gestione diretta del terminale, input da tastiera, segnali del sistema operativo, memoria, file e accesso a un database.

In questo articolo vediamo alcuni dei passaggi più significativi che hanno caratterizzato la realizzazione del progetto.

1. Progettare il flusso dell’applicazione

Prima ancora di scrivere il codice è stato necessario definire il flusso principale dell’applicazione. Il programma presenta inizialmente una schermata di benvenuto, successivamente richiede l’autenticazione dell’utente e permette di selezionare la sorgente dell’archivio.

Una volta entrati nel sistema viene mostrata l’interfaccia principale dalla quale è possibile consultare, creare, modificare, cercare ed eliminare documenti.

Una versione semplificata del ciclo principale può essere rappresentata così:

int main(void)
{
    initialize_terminal();

    while (1) {

        show_welcome_screen();

        if (!login()) {
            continue;
        }

        int source = select_database_source();

        if (source == SOURCE_EXIT) {
            break;
        }

        open_archive(source);
        run_application();
        close_archive();
    }

    restore_terminal();

    return 0;
}

Questa struttura permette di mantenere separate le varie responsabilità del programma. Il main() si limita a coordinare il flusso generale, mentre le singole funzionalità vengono delegate a funzioni specifiche.

2. Trasformare il terminale in un’interfaccia

Una delle parti più interessanti del progetto è stata la costruzione dell’interfaccia. Nonostante si tratti di un’applicazione console, l’obiettivo era ottenere qualcosa di più vicino a un vecchio terminale governativo che a un normale programma da riga di comando.

Per ottenere questo risultato si possono utilizzare le sequenze ANSI, che permettono di controllare il cursore, cancellare lo schermo e modificare alcuni attributi del testo.

Per esempio, per cancellare completamente il terminale e riportare il cursore nell’angolo superiore sinistro:

printf("\033[2J");
printf("\033[H");

Le due operazioni possono essere combinate:

printf("\033[2J\033[H");

È inoltre possibile definire alcuni colori:

#define COLOR_GREEN "\033[32m"
#define COLOR_RED   "\033[31m"
#define COLOR_WHITE "\033[37m"
#define COLOR_RESET "\033[0m"

printf(COLOR_GREEN "SYSTEM ONLINE" COLOR_RESET "\n");

Con queste semplici istruzioni è già possibile creare messaggi di sistema, segnalazioni di errore e intestazioni con un aspetto molto più caratteristico.

3. Leggere dinamicamente le dimensioni del terminale

Un’interfaccia costruita manualmente presenta però un problema importante: la finestra del terminale può essere ridimensionata in qualsiasi momento.

Per evitare che bordi, separatori e contenuti vengano visualizzati in maniera errata, il programma deve conoscere il numero di righe e colonne disponibili.

Su sistemi Unix-like possiamo utilizzare ioctl():

#include <sys/ioctl.h>
#include <unistd.h>

void get_terminal_size(int *rows, int *cols)
{
    struct winsize size;

    ioctl(STDOUT_FILENO, TIOCGWINSZ, &size);

    *rows = size.ws_row;
    *cols = size.ws_col;
}

Queste informazioni possono essere utilizzate per calcolare dinamicamente la lunghezza delle linee, centrare un titolo oppure stabilire quanti documenti possono essere visualizzati contemporaneamente.

4. Creare un header persistente

Nel progetto ho voluto mantenere un’intestazione sempre visibile nella parte superiore dello schermo. L’header contiene informazioni come l’identità dell’agente e la sorgente dati attualmente utilizzata.

Una versione semplificata potrebbe essere la seguente:

void draw_header(const char *agent, const char *database)
{
    int rows;
    int cols;

    get_terminal_size(&rows, &cols);

    printf("\033[H");

    printf("+");

    for (int i = 0; i < cols - 2; i++) {
        printf("-");
    }

    printf("+\n");

    printf(" NATO SPECIAL AGENT: %s\n", agent);
    printf(" DATABASE: %s\n", database);

    for (int i = 0; i < cols; i++) {
        printf("-");
    }

    printf("\n");
}

Il punto interessante è che la larghezza dell’header non è fissa, ma viene calcolata in base alle dimensioni reali della finestra.

5. Leggere la tastiera senza premere Invio

Un programma interattivo da terminale diventa molto più interessante quando può reagire immediatamente alla pressione dei tasti.

Normalmente il terminale lavora in modalità canonica: ciò significa che ciò che viene digitato viene consegnato al programma soltanto dopo la pressione del tasto Invio.

Utilizzando termios è possibile modificare temporaneamente questo comportamento:

#include <termios.h>
#include <unistd.h>

int read_key(void)
{
    struct termios old_state;
    struct termios new_state;

    tcgetattr(STDIN_FILENO, &old_state);

    new_state = old_state;
    new_state.c_lflag &= ~(ICANON | ECHO);

    tcsetattr(STDIN_FILENO, TCSANOW, &new_state);

    int ch = getchar();

    tcsetattr(STDIN_FILENO, TCSANOW, &old_state);

    return ch;
}

In questo modo il programma riceve immediatamente il carattere digitato dall’utente e può reagire senza aspettare Invio.

6. Implementare Ctrl+S nell’editor

Tra le funzionalità che volevo inserire c’era anche un piccolo editor integrato per creare e modificare i documenti dell’archivio.

Per rendere l’utilizzo più naturale ho scelto di utilizzare Ctrl+S come comando di salvataggio, esattamente come accade nei normali editor di testo.

Il carattere generato dalla combinazione Ctrl+S corrisponde al valore ASCII 19:

#define CTRL_S 19

int key = read_key();

if (key == CTRL_S) {
    save_document();
}

Questo piccolo dettaglio cambia molto l’esperienza di utilizzo, perché l’applicazione comincia a comportarsi più come un vero programma interattivo che come una semplice sequenza di richieste tramite scanf().

7. Utilizzare SQLite come archivio locale

Per memorizzare i documenti ho scelto SQLite. La scelta è particolarmente adatta a questo tipo di progetto perché SQLite non richiede l’installazione di un database server.

L’intero archivio viene memorizzato all’interno di un singolo file, nel nostro caso xfiles.db.

L’apertura del database richiede poche righe:

#include <sqlite3.h>

sqlite3 *db;

int result = sqlite3_open("xfiles.db", &db);

if (result != SQLITE_OK) {

    fprintf(
        stderr,
        "Database error: %s\n",
        sqlite3_errmsg(db)
    );

    return 1;
}

Un aspetto molto comodo è che, se il file xfiles.db non esiste, SQLite può crearlo automaticamente. Questo permette di distribuire l’applicazione senza dover preparare manualmente il database.

8. Creare la tabella dei documenti

La struttura iniziale del database può essere estremamente semplice:

CREATE TABLE IF NOT EXISTS documents (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Ogni documento possiede quindi un identificativo univoco, un titolo, il contenuto e la data di creazione.

La tabella può essere inizializzata direttamente dal programma C:

const char *sql =
    "CREATE TABLE IF NOT EXISTS documents ("
    "id INTEGER PRIMARY KEY AUTOINCREMENT,"
    "title TEXT NOT NULL,"
    "content TEXT NOT NULL,"
    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
    ");";

char *error = NULL;

if (sqlite3_exec(db, sql, NULL, NULL, &error) != SQLITE_OK) {

    fprintf(stderr, "%s\n", error);

    sqlite3_free(error);
}

Questo significa che l’applicazione è in grado di preparare autonomamente la propria struttura dati durante il primo avvio.

9. Inserire nuovi documenti in modo sicuro

Per inserire i documenti nel database è preferibile utilizzare i prepared statement invece di costruire manualmente le query SQL concatenando stringhe.

const char *sql =
    "INSERT INTO documents(title, content) "
    "VALUES (?, ?);";

sqlite3_stmt *stmt;

sqlite3_prepare_v2(
    db,
    sql,
    -1,
    &stmt,
    NULL
);

sqlite3_bind_text(
    stmt,
    1,
    title,
    -1,
    SQLITE_TRANSIENT
);

sqlite3_bind_text(
    stmt,
    2,
    content,
    -1,
    SQLITE_TRANSIENT
);

sqlite3_step(stmt);

sqlite3_finalize(stmt);

I valori vengono associati ai placeholder della query separatamente dal comando SQL. Oltre a rendere il codice più ordinato, questo approccio evita diversi problemi legati all’escaping delle stringhe.

10. Costruire l’indice dei documenti

Una delle schermate principali del programma è l’indice dell’archivio, nel quale vengono mostrati l’identificativo e il titolo dei documenti presenti nel database.

La query necessaria è molto semplice:

SELECT id, title
FROM documents
ORDER BY id DESC;

Dal codice C possiamo leggere i risultati riga per riga:

sqlite3_stmt *stmt;

sqlite3_prepare_v2(
    db,
    "SELECT id, title FROM documents ORDER BY id DESC;",
    -1,
    &stmt,
    NULL
);

while (sqlite3_step(stmt) == SQLITE_ROW) {

    int id =
        sqlite3_column_int(stmt, 0);

    const unsigned char *title =
        sqlite3_column_text(stmt, 1);

    printf(
        "%04d  %s\n",
        id,
        title
    );
}

sqlite3_finalize(stmt);

Nel progetto l’elenco viene anche paginato, perché il numero di documenti visualizzabili deve dipendere dall’altezza disponibile nel terminale.

11. Implementare la ricerca

Un archivio diventa rapidamente poco utile se non permette di cercare al suo interno. Per questo è stata aggiunta una funzione di ricerca sia nel titolo sia nel contenuto dei documenti.

Una query possibile è:

SELECT id, title
FROM documents
WHERE title LIKE ?
   OR content LIKE ?
ORDER BY id DESC;

Il testo inserito dall’utente può essere trasformato in un pattern SQL:

char pattern[256];

snprintf(
    pattern,
    sizeof(pattern),
    "%%%s%%",
    search_text
);

Se l’utente cerca la parola alien, il pattern utilizzato da SQLite diventerà %alien%, permettendo di trovare la parola anche all’interno di una frase più lunga.

12. Aggiornare un documento esistente

La modifica di un documento utilizza una logica molto simile all’inserimento. La differenza principale consiste nell’identificazione del record tramite il suo ID.

UPDATE documents
SET title = ?,
    content = ?
WHERE id = ?;

Anche in questo caso è utile utilizzare un prepared statement:

sqlite3_bind_text(
    stmt,
    1,
    title,
    -1,
    SQLITE_TRANSIENT
);

sqlite3_bind_text(
    stmt,
    2,
    content,
    -1,
    SQLITE_TRANSIENT
);

sqlite3_bind_int(
    stmt,
    3,
    document_id
);

13. Eliminare un documento

L’eliminazione è probabilmente l’operazione CRUD più semplice:

DELETE FROM documents
WHERE id = ?;

Dal punto di vista dell’interfaccia, però, è importante chiedere una conferma prima di eseguire definitivamente l’operazione.

Questo evidenzia un aspetto importante dello sviluppo: spesso la parte più interessante di un’applicazione non è la query SQL in sé, ma il modo in cui viene costruito il flusso di interazione con l’utente.

14. Gestire il resize della finestra

Durante lo sviluppo è emerso anche il problema della gestione del ridimensionamento della finestra.

Sui sistemi Unix viene generato il segnale SIGWINCH quando le dimensioni del terminale cambiano.

È quindi possibile intercettarlo:

#include <signal.h>

volatile sig_atomic_t resized = 0;

void handle_resize(int signal)
{
    resized = 1;
}

int main(void)
{
    signal(SIGWINCH, handle_resize);

    /* ... */

    return 0;
}

Il signal handler non ridisegna direttamente tutta l’interfaccia, ma si limita a modificare una variabile. Sarà poi il normale ciclo dell’applicazione a rilevare il cambiamento e aggiornare lo schermo.

Questo approccio è più sicuro perché all’interno di un signal handler è opportuno eseguire il minor numero possibile di operazioni.

15. Logout senza terminare il programma

Un’altra scelta progettuale è stata quella di distinguere nettamente il comando di logout dall’uscita dal programma.

Con il logout la sessione corrente termina, il database viene chiuso e l’utente viene riportato alla schermata iniziale.

Il processo rimane quindi attivo:

while (application_running) {

    if (!login()) {
        continue;
    }

    open_archive();

    int action = run_application();

    close_archive();

    if (action == ACTION_EXIT) {
        application_running = 0;
    }
}

Questa distinzione rende il programma più simile a un vero sistema multi-sessione.

16. Il risultato finale

Alla fine il progetto è diventato molto più di un semplice esercizio con SQLite. È un piccolo esempio di come sia possibile costruire un’applicazione interattiva utilizzando quasi esclusivamente strumenti messi a disposizione direttamente dal linguaggio C e dal sistema operativo.

Il programma permette di autenticarsi, selezionare l’archivio, consultare una lista paginata di documenti, aprirli, crearli, modificarli, salvarli tramite Ctrl+S, eliminarli e cercarli. Tutto questo viene presentato attraverso un’interfaccia testuale ispirata ai vecchi terminali investigativi e ai database classificati delle serie televisive degli anni Novanta.

Cosa mi ha lasciato questo progetto

Per chi proviene da linguaggi come Java, lavorare nuovamente con il C è particolarmente interessante. Molti comportamenti che normalmente vengono gestiti automaticamente devono essere affrontati direttamente: l’input della tastiera, i buffer, i segnali, il terminale, la memoria e il collegamento con librerie esterne.

Ed è proprio questo uno degli aspetti che trovo più affascinanti del linguaggio C: dietro a poche righe apparentemente semplici c’è spesso un rapporto molto diretto con il sistema operativo e con il funzionamento reale della macchina.

Il progetto X-Files nasce principalmente come esperimento personale e come omaggio a un certo immaginario informatico, ma è diventato anche un ottimo laboratorio per riscoprire concetti fondamentali della programmazione di sistema.

A volte non serve costruire un progetto enorme per imparare qualcosa di nuovo. Può bastare un terminale, un database SQLite, qualche centinaio di righe in C e l’idea di stare consultando un archivio che probabilmente non dovrebbe essere accessibile.