Lingara Lingara Dokumentasi Panduan API Pustaka Aplikasi Buat Aplikasi web
Bahasa: Bahasa Indonesia

Panduan cepat

Halaman ini diterjemahkan dari bahasa Inggris. Jika keduanya berbeda, halaman bahasa Inggris yang benar. Baca halaman bahasa Inggris

Halaman ini membawa sebuah aplikasi dari nol hingga menampilkan kartu di akun Anda sendiri. Contoh di bawah mengikuti langkah yang sama di setiap bahasa.

Buat klien OAuth

Aplikasi adalah klien OAuth yang memiliki manifest, jadi mulailah dengan klien. Buat klien di halaman Klien OAuth pada konsol dan berikan kemampuan API yang akan dibutuhkan aplikasi Anda, seperti membaca rencana pelajaran. Client ID dan rahasianya memungkinkan server Anda memanggil Lingara API sebagai aplikasi Anda. Simpan keduanya di environment server Anda.

Buat dan unggah manifest

Manifest menjelaskan aplikasi Anda kepada Lingara:

  • nama dan deskripsinya, dalam bahasa utama, dengan terjemahan opsional;
  • render_url, alamat HTTPS tujuan Lingara mengirimkan permintaan;
  • slot tempat kartunya dapat muncul;
  • konteks yang dimintanya untuk dilihat, yang disetujui pelajar saat memasang (lihat Konteks dan persetujuan);
  • kemampuan API yang dibutuhkannya, yang harus sudah dimiliki klien OAuth-nya;
  • apakah aplikasi dapat menawarkan catatan ke tutor (lihat Catatan untuk tutor).

Kit membangun manifest sebagai JSON dan memeriksanya terhadap aturan yang diterapkan saat unggah, sehingga kesalahan muncul lebih dulu di mesin Anda sendiri. Di halaman Aplikasi pada konsol, pilih klien Anda, jadikan sebagai aplikasi, lalu unggah filenya.

Buat rahasia penandatanganan

Di halaman yang sama, buat rahasia penandatanganan. Rahasia ini hanya ditampilkan sekali. Berikan ke server Anda melalui environment-nya, dan jangan pernah meng-commit-nya ke source control. Lingara menandatangani setiap permintaan ke aplikasi Anda dengan rahasia itu, dan kit menolak permintaan yang tanda tangannya tidak cocok. Satu aplikasi dapat memiliki dua rahasia sekaligus, sehingga Anda dapat mengganti salah satunya tanpa downtime: selama dua rahasia aktif, setiap permintaan ditandatangani dengan keduanya.

Tulis fungsi render

Fungsi render Anda menerima permintaan dan mengembalikan kartu. Permintaan itu menyebutkan slot, bahasa pelajar, konteks yang mereka setujui untuk dibagikan, dan subject, nilai stabil yang mengidentifikasi pelajar bagi aplikasi Anda. Simpan apa pun yang Anda simpan per pelajar di bawah subject. Tombol pada kartu Anda mengirimkan aksi, dan server Anda menjawabnya dengan kartu baru. Halaman kartu mencantumkan semua yang dapat dimuat sebuah kartu.

Hubungkan adapter

Adapter dari kit membaca permintaan mentah, memeriksa tanda tangannya, memanggil fungsi Anda, dan menulis balasannya. Sajikan adapter itu di alamat yang disebutkan oleh render_url di manifest Anda. Lingara hanya memanggil alamat HTTPS di internet publik.

Pasang di akun Anda sendiri

Di halaman Aplikasi pada konsol, pilih untuk memasang aplikasi di akun Anda, pilih apa yang dapat dilihatnya, lalu konfirmasi. Buka layar rencana pelajaran di layar lebar, atau halaman beranda di web, dan kartu Anda akan muncul.

Klien OAuth aplikasi Anda juga menerima event, seperti rencana pelajaran yang sudah siap. Panduan webhook dan event di referensi API menjelaskannya.

Bahasa contoh kode

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
Bahasa contoh kode

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.
Bahasa contoh kode

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()]);
        },
    ],
);
Bahasa contoh kode

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();

Lihat juga