Blog

Arbeiten mit Nuclino Teil 3

11.12.2020 // Renée Bäcker

In den vergangenen beiden Artikeln über unser Nuclino-Backup habe ich erst vorgestellt, wie wir die Backups erstellen und anschließend wie wir das Programm schneller gemacht haben.

Jetzt wo wir das Backup haben, kommen natürlich noch weitere Ideen. Wie können wir die Daten nutzen, so dass wir Nuclino als eine große Datenquelle nutzen und dann alle möglichen Produkte daraus ziehen: Dokumentation für Kunden, Schulungsunterlagen als PDF, Konzepte für Projekte, Blogposts etc.

Die Texte in Nuclino sind alle mit Markdown verfasst. Die Blogposts sind seit jeher als Markdown gespeichert und PDFs kann man bequem mit pandoc aus Markdown-Dateien generieren.

Was wir als erstes brauchen ist ein Mapping, damit wir bei den Brains – Sammlungen von Dokumenten in einem Workspace – anstelle der UUIDs mit konkreten Namen arbeiten können.

Außerdem benötigen wir ein Mapping der UUIDs zu Dateinamen und Titeln. Mit Hilfe dieses Mappings Übersetzen wir die Links mit der Nuclino-Domain in Links mit einer beliebigen neuen Domain setzen die Titel der Dateien als Überschrift in die Dokumente ein.

Wir wollen also so etwas:

{
  "brains" : {
    "0184e034-3156-11eb-bf6d-2bbce7395d9b":"Kunden",
    "0f0b2e2a-3156-11eb-a397-97d9e025800c":"FeatureAddons",
    "14c45b66-3156-11eb-bf04-835bcdf7bdc7":"PerlAcademy",
    ...
  },
  "documents" : {
    "19c8e5e6-3156-11eb-ba6d-8beba50b7276": {
      "file":"index.html",
      "title":"index.html"
    },
    "27fc869a-3156-11eb-98f5-8f9deed3e298": {
      "file":"GraphQL vs. REST 0051eb4b.md",
      "title":"GraphQL vs. REST"
    },
    ...
  }
}

Wer sich mit Nuclino auseinandersetzt, wird schnell feststellen, dass da nur ganz wenige normale HTTP-Anfragen abgesetzt werden. Der Informationsaustausch zwischen Browser und Server erfolgt über Websockets.

Also ist wieder eine Analyse der Kommunikation notwendig. Da helfen wieder die Entwicklertools des Browsers mit der Netzwerkanalyse. Auch wenn man in Nuclino arbeitet, kommen keine weiteren Requests in der Übersicht hinzu.

Dann kommt man schnell zu dem einen Websocket...

b0b6b008-d207-4ff0-88be-dfe2c4f18518.png

Diese Kommunikation muss mit Mojolicious nachgebaut werden. Das schöne an Mojolicious ist, dass es schon alles mitbringt – so auch die Unterstützung für Websockets.

my $ua = Mojo::UserAgent->new(
    cookie_jar         => Mojo::UserAgent::CookieJar->new,
    max_connections    => 200,
    inactivity_timeout => 20,
    request_timeout    => 20,
);

my ($info, @brains) = _get_brains( $ua );
my @promises        = _create_mapping( $ua, $info, \@brains, $home_dir );

Mit der Funktion _get_brains werden die Zip-Dateien der Brains geholt und in $info stehen der Benutzername und weitere Infos. Auch bei _create_mapping arbeiten wir wieder mit Promises. Ein Promise repräsentiert das Ergebnis einer asynchronen Operation. Je nachdem, ob die Operation erfolgreich oder fehlerhaft beendet wurde, wird das Promise entsprechend gekennzeichnet.

In _create_mapping bauen wir die Websocket-Verbindung mit Nuclino auf.

sub _create_mapping {
    my ( $ua, $info, $brains, $home_dir ) = @_;

    my $mapping = {};

    my $promise = Mojo::Promise->new;

    $ua->websocket(
        'wss://api.nuclino.com/syncing' => {
            Via => '1.1 vegur',
            Origin => 'https://app.nuclino.com',
        } => ['v1.proto'] => sub {
        
        # ... hier der Code fuer den Informationsaustausch
    });
}

Ist die Verbindung aufgebaut, müssen wir festlegen, was mit eingehenden Nachrichten passieren soll. Und wenn die Verbindung geschlossen wird, müssen die zusammengetragenen Informationen als JSON-Datei gespeichert wird.

Betrachten wir also nur die Subroutine, die bei websocket als Callback übergeben wird:

            my ($ua, $tx) = @_;

            return if !$tx->isa('Mojo::Transaction::WebSocket');

            $tx->on( message => sub {
                my ($tx, $msg) = @_;
                _handle_message( $tx, $msg, $mapping );
            });

            $tx->on( finish => sub {
                my ($tx) = @_;

                # generate JSON file for mapping
                $home_dir->child('backups', 'mapping.json')->spurt(
                    encode_json $mapping
                );

                $promise->resolve(1);
            });

Trifft eine Nachricht vom Server ein, wird ein message-Event gefeuert. Wir reagieren mit $tx->on( message => sub {...} ); darauf. Was in der Funktion _handle_message passiert, schauen wir uns später an.

Mit $tx->on( finish => sub {...} ); reagieren wir auf das Ende der Verbindung. Da wird einfach nur die JSON-Datei geschrieben und mit $promise->resolve(1) wird gesagt, dass das Promise erfolgreich abgearbeitet wurde.

Im weiteren Verlauf des Callbacks setzen wir die Eingangs-Requests ab, die weiter oben beschrieben sind:

            $tx->send( encode_json +{
                ns => 'sd',
                data => {
                    a => 's',
                    c => 'ot_config',
                    d => 'a16b46be-31a7-11eb-ad70-e338b15b7ae1',
                }
            });
            
            # hier noch die Requests fuer die Kommandos
            # ot_user, ot_user_private, ot_team

            Mojo::IOLoop->timer( 3 => sub {
                for my $brain ( @{$brains} ) {
                    $tx->send( encode_json +{
                        ns => 'sd',
                        data => {
                            a => 's',
                            c => 'ot_brain',
                            d => $brain,
                        }
                    });
                }
            });

Die UUID im ersten Request ist immer die gleiche. Das ist vermutlich die UUID unseres Unternehmensaccounts.

Danach folgen noch drei weitere Requests. Diese können direkt nacheinander abgesetzt werden.

Abschließend folgt noch für jeden brain ein Request. Hier hat es in den Tests nie geklappt, die direkt nach den zuvor genannten Requests abzusetzen. Die genannten vier Requests müssen erst abgearbeitet sein, bevor es weiter geht.

Mit den hier gezeigten Requests werden die Infos zu einem brain geholt. Die Daten kommen als JSON an und sehen folgendermaßen aus:

{
  "ns":"sd",
  "data":{
    "data":{
      "v":2,
      "data":{
        "kind":"PUBLIC",
        "name":"Werkzeuge",
        "teamId":"1f2966e2-321d-11eb-8f00-5b043dffbd53",
        "members":[],
        "createdAt":"2019-07-17T15:40:28.897Z",
        "creatorId":"2a9e9ad8-321d-11eb-a81f-631faf46d6c4",
        "mainCellId":"320c3df2-321d-11eb-9604-fbfbed008d34",
        "defaultView":"TREE",
        "trashCellId":"377bf386-321d-11eb-b969-3fea1f109107",
        "archiveCellId":"3de4e624-321d-11eb-a864-67366681e78f",
        "formerMembers":[],
        "pinnedCellIds":[],
        "defaultMemberRole":"MEMBER"
      },
      "type":"https://nuclino.com/ot-types/json01"
    },
    "a":"s",
    "c":"ot_brain",
    "d":"7091aff1-1513-4e68-ae9f-d6f8936fcf14"
  }
}

Die Funktion _handle_message nimmt die Antworten des Servers auseinander und sammelt die Daten für das Mapping. Die Antworten des Servers für ot_brain enthalten Angaben über die Zellen – also die Seiten des Brains. Für jede dieser Seiten werden wieder Requests abgesetzt.

sub _handle_message {
    my ($tx, $msg, $mapping) = @_;

    my $data;
    eval {
        $data = decode_json( encode_utf8 $msg );
    };

    return if !$data;

    my $command = $data->{data}->{c} || '';

    return if !( first { $command eq $_ }qw(ot_cell ot_brain) );
    
    # ziehe die Informationen aus den JSON-Antworten
    
    for my $cell ( @cell_ids ) {
        $tx->send( encode_json +{
            ns => 'sd',
            data => {
                a => 's',
                c => 'ot_cell',
                d => $cell,
            }
        });
    }

}

Die Ergebnisse der weiter oben gezeigten Kommandos wie ot_user,* ot_user_private*, *ot_config und ot_team* interessieren uns nicht, da dort keine Informationen über die Dateien zu finden sind. Aus diesem Grund werden die hier nicht behandelt.

In den Antworten für das ot_brain stehen dann Informationen, mit denen wir weiterarbeiten können. In mainCellId steht die UUID des Hauptdokuments. Für dieses Dokument holen wir mit ot_cell die näheren Informationen*.*

{
  "ns":"sd",
  "data":{
    "data":{
      "v":18,
      "data":{
        "kind":"MAIN",
        "title":"Main",
        "brainId":"a29de3ff-18a5-4d51-ab9b-a6c3b5d82b6c",
        "sharing":{},
        "childIds":[
          "bb10e948-ba91-4097-83a1-60c9ea6ec17b",
          "4d44737d-e113-4786-bb98-06d8755d2bb7",
          "..."
        ],
        "createdAt":"2019-07-17T14:47:35.743Z",
        "creatorId":"34e53829-cafb-4859-9e7b-0aae34295d04",
        "memberIds":[],
        "updatedAt":"2019-07-17T14:47:35.743Z",
        "activities":[],
        "contentMeta":{},
      },
      "type":"https://nuclino.com/ot-types/json01"
    },
    "a":"s",
    "c":"ot_cell",
    "d":"d56942a8-aa6e-4197-93fd-1f88967dedc6"
  }
} 

Die wichtigen Informationen sind der Typ des Dokuments – MAIN ist das Startdokument eines brains, PARENT ein Knoten und LEAF ein Dokument mit Text –, der Titel und die Kinddokumente, für die dann ebenfalls die Informationen geholt werden.

Was haben wir gelernt? Wir haben die Websocket-Kommunikation zwischen Nuclino und dem Browser analysiert, und wir haben gesehen, dass Mojolicious bei der Umsetzung extrem hilfreich ist, weil die Unterstützung für Websockets direkt mit eingebaut ist.


Permalink: