Lingara Lingara Dokumentation Ratgeber API Bibliotheken Apps Erstellen Web-App
Sprache: Deutsch

Schnellstart

Diese Seite ist aus dem Englischen übersetzt. Wenn die beiden voneinander abweichen, ist die englische Seite maßgeblich. Englische Seite lesen

Diese Seite bringt eine App von null bis zu einer Karte auf Ihrem eigenen Konto. Die Beispiele unten folgen in jeder Sprache denselben Schritten.

Einen OAuth-Client erstellen

Eine App ist ein OAuth-Client mit einem Manifest, also beginnen Sie mit einem Client. Erstellen Sie einen auf der Seite „OAuth-Clients“ der Konsole und geben Sie ihm die API-Berechtigungen, die Ihre App brauchen wird, etwa das Lesen von Lektionsplänen. Mit seiner Client-ID und seinem Secret kann Ihr Server die Lingara-API als Ihre App aufrufen. Bewahren Sie beides in der Umgebung Ihres Servers auf.

Das Manifest erstellen und hochladen

Das Manifest beschreibt Ihre App gegenüber Lingara:

  • ihren Namen und ihre Beschreibung, in einer Hauptsprache, mit optionalen Übersetzungen;
  • render_url, die HTTPS-Adresse, an die Lingara Anfragen sendet;
  • die Plätze, in denen ihre Karte erscheinen darf;
  • den Kontext, den sie sehen möchte und dem der Lernende bei der Installation zustimmt (siehe Kontext und Einwilligung);
  • die API-Berechtigungen, die sie braucht und die ihr OAuth-Client bereits haben muss;
  • ob sie dem Tutor eine Notiz mitgeben darf (siehe Die Tutor-Notiz).

Das Kit erstellt das Manifest als JSON und prüft es gegen die Regeln, die ein Upload anwendet, sodass ein Fehler zuerst auf Ihrem eigenen Rechner sichtbar wird. Wählen Sie auf der Seite „Apps“ der Konsole Ihren Client, machen Sie daraus eine App und laden Sie die Datei hoch.

Ein Signatur-Secret erstellen

Erstellen Sie auf derselben Seite ein Signatur-Secret. Es wird nur einmal angezeigt. Geben Sie es Ihrem Server über seine Umgebung und committen Sie es nie in die Versionsverwaltung. Lingara signiert damit jede Anfrage an Ihre App, und das Kit lehnt eine Anfrage ab, deren Signatur nicht passt. Eine App kann zwei Secrets gleichzeitig haben, sodass Sie eines ohne Ausfallzeit ersetzen können: Solange zwei aktiv sind, wird jede Anfrage mit beiden signiert.

Eine Render-Funktion schreiben

Ihre Render-Funktion erhält eine Anfrage und gibt eine Karte zurück. Die Anfrage nennt den Platz, die Sprache des Lernenden, den Kontext, dessen Weitergabe er zugestimmt hat, und subject, einen stabilen Wert, der den Lernenden gegenüber Ihrer App identifiziert. Legen Sie alles, was Sie pro Lernendem speichern, unter subject ab. Ein Button auf Ihrer Karte sendet eine Aktion, und Ihr Server beantwortet sie mit einer neuen Karte. Die Seite Karten listet alles auf, was eine Karte enthalten kann.

Den Adapter einbinden

Der Adapter des Kits liest die rohe Anfrage, prüft ihre Signatur, ruft Ihre Funktion auf und schreibt die Antwort. Stellen Sie ihn unter der Adresse bereit, die die render_url Ihres Manifests nennt. Lingara ruft nur HTTPS-Adressen im öffentlichen Internet auf.

Auf dem eigenen Konto installieren

Wählen Sie auf der Seite „Apps“ der Konsole, die App auf Ihrem Konto zu installieren, legen Sie fest, was sie sehen darf, und bestätigen Sie. Öffnen Sie den Lektionsplan-Bildschirm auf einem breiten Bildschirm oder die Startseite im Web, und Ihre Karte erscheint.

Der OAuth-Client Ihrer App empfängt außerdem Events, etwa wenn ein Lektionsplan fertig wird. Der Leitfaden zu Webhooks und Events in der API-Referenz beschreibt sie.

Sprache des Codebeispiels

TypeScript

npm install @lingara/apps@0.1.0-alpha.6

Rust

cargo add lingara-apps@0.1.0-alpha.6 --features axum
cargo add axum@0.8
cargo add tokio --features macros,net,rt-multi-thread

Go

go get github.com/Spinning-Cat-Studios/lingara_app_framework_sdk/go@v0.1.0-alpha.6

Java

<dependency>
  <groupId>com.getlingara</groupId>
  <artifactId>lingara-apps-java</artifactId>
  <version>0.1.0-alpha.6</version>
</dependency>

Kotlin

implementation("com.getlingara:lingara-apps-kotlin:0.1.0-alpha.6")

Ruby

gem install lingara-apps -v 0.1.0-alpha.6

PHP

composer require spinningcatstudios/lingara-apps:0.1.0-alpha.6 spinningcatstudios/lingara:@alpha
Sprache des Codebeispiels

TypeScript

import { manifest } from "@lingara/apps";

const json = manifest()
  .defaultLocale("en")
  .name({ en: "Daily five", "zh-Hans": "每日五词" })
  .description("Five words to review, picked from your plan.")
  .renderUrl("https://apps.example.com/lingara/render")
  .slots("home.side", "plans.empty_detail")
  .context("languages", "plan_summary")
  // `.scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
  .tutorNote()
  .toJson(); // Throws ManifestError naming the rule an upload would refuse.

writeFileSync("manifest.json", json); // Upload it in the console.

Rust

let manifest = manifest()
    .default_locale("en")
    .name("Daily five")
    .description("Five words to review, picked from your plan.")
    .render_url("https://apps.example.com/lingara/render")
    .slots([AppSlotName::HomeSide, AppSlotName::PlansEmptyDetail])
    .context([ContextSliceKind::Languages, ContextSliceKind::PlanSummary])
    // `.scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
    .tutor_note(true)
    .build()?;
std::fs::write("manifest.json", manifest.to_json())?;

Go

manifest, err := lingaraapps.NewManifest().
	DefaultLocale("en").
	Names(map[string]string{"en": "Daily five", "zh-Hans": "每日五词"}).
	Description("Five words to review, picked from your plan.").
	RenderURL("https://apps.example.com/lingara").
	Slots(lingaraapps.AppSlotNameHomeSide, lingaraapps.AppSlotNamePlansEmptyDetail).
	Context(lingaraapps.ContextSliceKindLanguages, lingaraapps.ContextSliceKindPlanSummary).
	// `.Scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
	TutorNote(true).
	Build()
if err != nil {
	return err // a *lingaraapps.ManifestError names the broken rule
}
data, err := manifest.ToJSON()
if err != nil {
	return err
}
// Upload manifest.json in the console.
err = os.WriteFile("manifest.json", data, 0o644)

Java

import com.getlingara.apps.Manifest;
import com.getlingara.apps.model.AppSlotName;
import com.getlingara.apps.model.ContextSliceKind;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

String json =
    Manifest.manifest()
        .defaultLocale("en")
        .name("Daily five")
        .description("Five words to review, picked from your plan.")
        .renderUrl("https://apps.example.com/lingara/render")
        .slots(AppSlotName.HOME_SIDE)
        .context(ContextSliceKind.LANGUAGES, ContextSliceKind.PLAN_SUMMARY)
        // `.scopes(…)` lists the API scopes your client uses, for the learner's consent
        // page. This app uses none.
        .tutorNote(true)
        .build() // refuses what the upload would, naming the rule
        .toJson();
// Upload manifest.json in the console, beside the app's icon.
Files.writeString(Path.of("manifest.json"), json);

Kotlin

import com.getlingara.apps.kotlin.manifest
import com.getlingara.apps.kotlin.model.AppSlotName
import com.getlingara.apps.kotlin.model.ContextSliceKind
import java.io.File

val json =
    manifest {
        defaultLocale = "en"
        name("Daily five")
        description("Five words to review, picked from your plan.")
        renderUrl = "https://apps.example.com/lingara/render"
        slots(AppSlotName.HOME_PERIOD_SIDE)
        context(ContextSliceKind.LANGUAGES, ContextSliceKind.PLAN_SUMMARY)
        // `scopes(…)` lists the API scopes your client uses, for the learner's consent page.
        // This app uses none.
        tutorNote = true
    }.toJson() // refuses what the upload would, naming the rule
// Upload manifest.json in the console, beside the app's icon.
File("manifest.json").writeText(json)

Ruby

manifest = Lingara::Apps.manifest
  .default_locale("en")
  .name("Daily five") # A bare String is the default locale's; or {"en" => …, "zh-Hans" => …}.
  .description("Five words to review, picked from your plan.")
  .render_url("https://apps.example.com/lingara")
  .slots("home.side", "plans.empty_detail")
  .context("languages", "plan_summary")
  # `.scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
  .tutor_note
# Raises Lingara::Apps::ManifestError naming the rule the upload would refuse.
File.write("manifest.json", manifest.to_json)

PHP

use Lingara\Apps\Generated\AppSlotName;
use Lingara\Apps\Manifest;

$json = Manifest::create()
    ->defaultLocale('en')
    ->name(['en' => 'Daily five', 'zh-Hans' => '每日五词'])
    ->description('Five words to review, picked from your plan.')
    ->renderUrl('https://apps.example.com/lingara/render')
    ->slots(AppSlotName::HOME_SIDE, AppSlotName::PLANS_EMPTY_DETAIL)
    ->context('languages', 'plan_summary')
    // `->scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
    ->tutorNote()
    ->toJson(); // Throws ManifestException naming the rule an upload would refuse.

file_put_contents('manifest.json', $json); // Upload it in the console.
Sprache des Codebeispiels

TypeScript

const app = lingaraApp({
  // lgr_whsec_…, from the app's settings; pass two during a rotation.
  secret: process.env.LINGARA_APP_SECRET!,
  // Hold per-learner state on `subject`, the same value your events carry.
  render: (request) => (request.slot === "home.side" ? withNote(request) : todayCard(streaks.get(request.subject) ?? 0)),
  actions: {
    // The button's `action`; may arrive twice, so make it safe to repeat.
    done: (request) => {
      streaks.set(request.subject, (streaks.get(request.subject) ?? 0) + 1);
      return todayCard(streaks.get(request.subject)!);
    },
  },
});

Rust

// The app's signing secret (lgr_whsec_…), from the environment.
let seen = Seen::default();
let (render_seen, action_seen) = (Arc::clone(&seen), Arc::clone(&seen));
let app = App::new([std::env::var("LINGARA_APP_SECRET")?], move |request: AppRenderRequest| {
    render(Arc::clone(&render_seen), request)
})?
// The button's `action` comes back as `action_id`. An action can arrive
// twice, so keep it safe to repeat.
.action("next", move |request: AppActionRequest| {
    let seen = Arc::clone(&action_seen);
    async move {
        *seen.lock().map_err(|_| "poisoned")?.entry(request.subject).or_default() += 1;
        today_card("Next word", "zh")
    }
});

Go

// Per-learner state is keyed on Subject, the lgr_sub_… value the app's
// events carry too.
var mu sync.Mutex
streaks := map[string]int{}

render := func(ctx context.Context, req lingaraapps.AppRenderRequest) (lingaraapps.Replier, error) {
	mu.Lock()
	days := streaks[req.Subject]
	mu.Unlock()
	return lingaraapps.NewCard().
		Heading("Daily five", 1).
		Progress(float64(days%5)/5, "This week").
		Button("Done today", "done").
		Build()
}
// An action may arrive twice: make it safe to run twice.
done := func(ctx context.Context, req lingaraapps.AppActionRequest) (lingaraapps.Replier, error) {
	mu.Lock()
	streaks[req.Subject]++
	mu.Unlock()
	return lingaraapps.NewCard().Heading("Nice work", 1).Text("See you tomorrow.").Build()
}
app := lingaraapps.App{
	Render:  render,
	Actions: map[string]lingaraapps.ActionFunc{"done": done},
}

Java

import com.getlingara.apps.Card;
import com.getlingara.apps.LingaraApp;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

// Per-learner state is keyed on subject, the value the app's events carry too.
Map<String, Integer> seen = new ConcurrentHashMap<>();
LingaraApp app =
    LingaraApp.builder()
        // The app's signing secret (lgr_whsec_…); pass two during a rotation.
        .secrets(System.getenv("LINGARA_APP_SECRET"))
        .render(
            request -> {
              int count = seen.getOrDefault(request.getSubject(), 0);
              return Card.card()
                  .heading("Today's five", 1)
                  .text("Reviewed " + count + " so far.")
                  .button("Next", "next")
                  .build();
            })
        // The button's action comes back as the request's action_id. It may arrive
        // twice, so make it safe to repeat.
        .action(
            "next",
            request -> {
              int count = seen.merge(request.getSubject(), 1, Integer::sum);
              return Card.card().progress(Math.min(1.0, count / 5.0), count + " of 5").build();
            })
        .build();

Kotlin

import com.getlingara.apps.kotlin.LingaraApp
import com.getlingara.apps.kotlin.card
import com.getlingara.apps.kotlin.lingaraApp
import java.util.concurrent.ConcurrentHashMap

// Per-learner state is keyed on subject, the value the app's events carry too.
val seen = ConcurrentHashMap<String, Int>()
val app =
    lingaraApp {
        // The app's signing secret (lgr_whsec_…); pass two during a rotation.
        secrets(System.getenv("LINGARA_APP_SECRET"))
        render { request ->
            val count = seen.getOrDefault(request.subject, 0)
            card {
                heading("Today's five", 1)
                text("Reviewed $count so far.")
                button("Next", "next")
            }
        }
        // The button's action comes back as the request's action_id. It may arrive twice,
        // so make it safe to repeat.
        action("next") { request ->
            val count = seen.merge(request.subject, 1, Int::plus) ?: 1
            card { progress(minOf(1.0, count / 5.0), "$count of 5") }
        }
    }

Ruby

# lgr_whsec_…, from the app's settings; pass an Array of two during a rotation.
app = Lingara::Apps::App.new(secret: ENV.fetch("LINGARA_APP_SECRET"))
# Hold per-learner state on `subject`, the same value your events carry.
app.render do |request|
  (request.slot == "home.side") ? with_note(request) : today_card(STREAKS[request.subject])
end
# The button's `action`; it may arrive twice, so make it safe to repeat.
app.action("done") do |request|
  STREAKS[request.subject] += 1
  today_card(STREAKS[request.subject])
end

PHP

use Lingara\Apps\App;
use Lingara\Apps\Generated\AppActionRequest;
use Lingara\Apps\Generated\AppRenderRequest;

$app = new App(
    // lgr_whsec_…, from the app's settings; pass a list of two during a rotation.
    secret: (string) getenv('LINGARA_APP_SECRET'),
    // Hold per-learner state on `subject`, the same value your events carry.
    render: static fn(AppRenderRequest $request) => $request->getSlot() === AppSlotName::HOME_SIDE
        ? withNote($request)
        : todayCard($streaks[$request->getSubject()] ?? 0),
    actions: [
        // The button's `action`; may arrive twice, so make it safe to repeat.
        'done' => static function (AppActionRequest $request) use (&$streaks): CardModel {
            $streaks[$request->getSubject()] = ($streaks[$request->getSubject()] ?? 0) + 1;
            return todayCard($streaks[$request->getSubject()]);
        },
    ],
);
Sprache des Codebeispiels

TypeScript

// A node:http request listener: it reads the raw body, verifies, dispatches and replies.
createServer(nodeHandler(app)).listen(8787);

Rust

// Nest the adapter at the path your manifest's render_url names.
let routes = axum::Router::new().nest("/lingara/render", lingara_apps::axum::router(app));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, routes).await?;

Go

// The app's signing secret, lgr_whsec_…, or both during a rotation.
handler, err := lingaraapps.NewHandler(app, os.Getenv("LINGARA_APP_SECRET"))
if err != nil {
	return err // a malformed secret fails here, at startup
}
mux := http.NewServeMux()
mux.Handle("/lingara", handler)
err = http.ListenAndServe(":8080", mux)

Java

import com.getlingara.apps.JdkHttpHandler;
import com.getlingara.apps.LingaraApp;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;

// The JDK's own server: the handler reads the raw body, verifies it, then dispatches.
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/lingara/render", JdkHttpHandler.of(app));
server.start();

Kotlin

import com.getlingara.apps.kotlin.LingaraApp
import com.getlingara.apps.kotlin.ktor.lingaraApp
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.route
import io.ktor.server.routing.routing

// Route.lingaraApp reads the raw body itself, so content negotiation never parses it.
embeddedServer(CIO, port = 8080) {
    routing {
        route("/lingara/render") { lingaraApp(app) }
    }
}.start(wait = true)

Ruby

# config.ru: any Rack server (Puma, Falcon, rackup) runs it. It reads the
# raw body itself, so mount it where nothing has parsed the JSON first.
run Lingara::Apps::RackApp.new(app)

PHP

use Lingara\Apps\Psr15Handler;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;

// A PSR-15 handler: mount it on your framework's route, or answer from a
// front controller as here. It reads the raw body, verifies, dispatches and replies.
$factory = new Psr17Factory();
$handler = new Psr15Handler($app, $factory, $factory);
$response = $handler->handle((new ServerRequestCreator($factory, $factory, $factory, $factory))->fromGlobals());
http_response_code($response->getStatusCode());
foreach ($response->getHeaders() as $name => $values) {
    header("{$name}: " . implode(', ', $values));
}
echo $response->getBody();

Siehe auch