merge main into packet/vertical
This commit is contained in:
commit
1a7724b3c1
714 changed files with 13009 additions and 5267 deletions
92
README.md
92
README.md
|
|
@ -124,6 +124,56 @@ and graduates to a named tab when it's ready.
|
|||
|
||||
---
|
||||
|
||||
## Who it's for
|
||||
|
||||
### Vibe coders — who dx-maps was built for
|
||||
|
||||
If you build with AI agents, this repo is made for you:
|
||||
|
||||
- **One repo, many plugins.** Each plugin is one folder in `plugin-designs/` with its own `src/`,
|
||||
and they all share one library, so your agents can build many plugins side by side.
|
||||
- **See every design live.** `bun run dev` opens the Plugin Studio: every plugin in one place,
|
||||
updating the moment you save.
|
||||
- **Speak instead of typing.** Rudder, an offline voice-to-text tool, sits as a corner widget in the
|
||||
Studio: look at a plugin, say what you see, and hand the text to your agent. Nothing leaves your
|
||||
computer (Linux today).
|
||||
- **One plugin, several outputs.** The same plugin ships as a web embed or a mobile app, and a plugin
|
||||
can target the desktop: Rudder is the desktop example, running in the Studio today with its
|
||||
desktop packaging next.
|
||||
- **Build together.** [Run it in the cloud](#run-it-in-the-cloud) gives a whole team one shared dev
|
||||
server.
|
||||
|
||||
### The people doing the mission
|
||||
|
||||
**Each map answers a real task for someone doing the mission.** Open it in the Research Gallery, or
|
||||
embed just the one you need.
|
||||
|
||||
| You are… | Open | It answers |
|
||||
|---|---|---|
|
||||
| a **translator** (or an AI translation pipeline) | Gospel Resources (map, list, data tables) | which people groups have no Scripture, film or audio in their language, and which bridge language already reaches them |
|
||||
| **media to movements** | Cities with Internet · Gospel × Internet | where unreached people groups live with internet access, country by country and down to province where data exists |
|
||||
| a **catalyst** or mobilizer | Big Picture · Simple Research · Regions | filter the 2,107 groups by region, religion, language and engagement; **Export CSV** or **Export this selection**; open a group's **Full Profile** |
|
||||
| **praying** | Prayer Progress · UPG-100 List | where prayer is covered and where it is still needed; print a **Prayer List**, **Poster Map** or **Card Deck** for a list of 100 |
|
||||
| a **teacher** or presenter | the Research Gallery | every map in one app, in 11 languages, ready to show |
|
||||
|
||||
<details>
|
||||
<summary><b>For AI agents</b></summary>
|
||||
|
||||
- **Every map, and how to embed it:** `public/dx-maps/manifest.json` lists each plugin with its
|
||||
`embed.iframe` and `embed.script` snippet (with its integrity hash).
|
||||
- **The data behind the maps:** `public/dx-maps/assets/tables/*.json` (gospel access, internet
|
||||
access, bridge languages, resource links…), `assets/people-groups/*.json` (points and slim
|
||||
records) and `assets/people-groups/lists/` (the membership of each list).
|
||||
- **Live people-group data (public, no key):** `https://pray.doxa.life/api/people-groups/list`
|
||||
and `…/detail/{slug}`, the same endpoints the published maps call.
|
||||
- **Switches on any map page:** `?lang=` (11 languages), `?provider=maplibre|mapbox`,
|
||||
`?list=` (a project list such as `700`), `?tk=` (a Mapbox token). Embeds take the same keys in
|
||||
`profile-config`. Contract: [documentation/reference-universal-props.md](./documentation/reference-universal-props.md).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## The 5 plugin capabilities
|
||||
|
||||
A capability is **how much a plugin nests**, from one element (1) to an app of apps (5). Every
|
||||
|
|
@ -438,6 +488,38 @@ bun run tauri:build # builds the app — needs Rust, the Tauri CLI and webvie
|
|||
|
||||
---
|
||||
|
||||
## Security controls
|
||||
|
||||
**Bundle integrity.** Every bundle ships with a SHA-384 hash in `public/dx-maps/manifest.json`
|
||||
(`bundles[].integrity`), and every copy-paste script snippet (`profiles[].embed.script`) already
|
||||
uses it:
|
||||
|
||||
```html
|
||||
<script src="https://YOUR_HOST/dx-maps/doxa-simple-map/doxa-simple-map.js"
|
||||
integrity="sha384-…" crossorigin="anonymous"></script>
|
||||
```
|
||||
|
||||
If the file on the host is changed by anyone, even by one byte, the browser refuses to run it.
|
||||
`crossorigin="anonymous"` is required for the check to work across sites, so the host must send
|
||||
`Access-Control-Allow-Origin: *`. Copy the snippet again after each release, because the hash changes
|
||||
with the bundle. `bun run check:sri` keeps the manifest and the built pages in step.
|
||||
|
||||
<details>
|
||||
<summary><b>The backend in the cloud container (v1, experimental)</b></summary>
|
||||
|
||||
- **Locked by default.** A new PocketBase collection starts with admin-only rules; you open it
|
||||
deliberately, collection by collection.
|
||||
- **Dashboards hidden.** The production instance exposes only its `/api/`; its admin dashboard is
|
||||
blocked at the proxy. The development dashboard is open only to the admin group.
|
||||
- **No server-side code.** PocketBase's JavaScript hooks are switched off in both instances.
|
||||
- **Not built yet:** per-plugin locks between plugin authors (who may edit which plugin's backend)
|
||||
and isolated edge functions. Until they exist, everyone with dev access is trusted with every
|
||||
plugin's backend.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Languages
|
||||
|
||||
**Every map speaks 11 languages** — add `?lang=<code>` to a map's page address, or, when a map is embedded in
|
||||
|
|
@ -671,3 +753,13 @@ doxa-maps/
|
|||
> **Maps should be open source, run everywhere, and multiply.** One build makes each map a
|
||||
> self-contained file for your site, your partners' sites and a phone. The Plugin Studio is where
|
||||
> you compose them into something bigger.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
dx-maps stands on the work of open-source communities: [Vue](https://vuejs.org), [Vite](https://vite.dev),
|
||||
[MapLibre GL JS](https://maplibre.org), [Pinia](https://pinia.vuejs.org), [Vue I18n](https://vue-i18n.intlify.dev),
|
||||
[Capacitor](https://capacitorjs.com), [Tauri](https://tauri.app), [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy),
|
||||
[NeMo-Speech.cpp](https://github.com/NVIDIA/NeMo-Speech.cpp) with NVIDIA's open [Nemotron speech model](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b), and the map data of
|
||||
[OpenStreetMap](https://www.openstreetmap.org/copyright) contributors served by [OpenFreeMap](https://openfreemap.org). Thank you.
|
||||
|
|
|
|||
13
backend/pocketbase/README.md
Normal file
13
backend/pocketbase/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# PocketBase — the backend for plugins that need one
|
||||
|
||||
The cloud container ([deployment/cloud](../../deployment/cloud/README.md)) runs two PocketBase
|
||||
instances: **dev** (behind the team sign-in, for building) and **prod** (its `/api/` public, for
|
||||
published plugins). Both apply the migrations in [`pb_migrations/`](./pb_migrations/) on start, so
|
||||
**the schema lives in git** and publishing never copies data from dev to prod.
|
||||
|
||||
A plugin reads its backend address at runtime (`profile-config` `apiBase`, or
|
||||
`window.MAP_API_BASE` set by the published build's `runtime-config.js`), so moving the backend never
|
||||
needs a rebuild.
|
||||
|
||||
v1 limits: collection rules are the only access control (lock collections by default); no per-plugin
|
||||
isolation between plugin authors yet.
|
||||
0
backend/pocketbase/pb_migrations/.gitkeep
Normal file
0
backend/pocketbase/pb_migrations/.gitkeep
Normal file
|
|
@ -63,7 +63,8 @@ func envOr(k, d string) string {
|
|||
|
||||
func loadConfig() config {
|
||||
home, _ := os.UserHomeDir()
|
||||
base := filepath.Join(home, ".local", "share", "rudder")
|
||||
// RUDDER_HOME is where setup-runtime.sh installed the runtime and model (a server keeps it on a volume).
|
||||
base := envOr("RUDDER_HOME", filepath.Join(home, ".local", "share", "rudder"))
|
||||
return config{
|
||||
Port: envOr("RUDDER_PORT", "5193"),
|
||||
NemoPort: envOr("RUDDER_NEMO_PORT", "5194"),
|
||||
|
|
@ -181,9 +182,11 @@ func (e *engine) status() event {
|
|||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return event{
|
||||
"engine": e.state, "error": e.stateErr, "recording": e.recording, "recorder": e.recorder,
|
||||
// A browser session belongs to one reader; everyone else sees only that the engine is in use.
|
||||
"engine": e.state, "error": e.stateErr, "recording": e.recording && !browserAudio(), "inUse": e.recording, "recorder": e.recorder,
|
||||
"language": e.language, "defaultLanguage": e.cfg.Language, "device": e.device,
|
||||
"runtime": "NVIDIA NeMo-Speech.cpp (ggml, CPU)", "model": filepath.Base(e.cfg.Model),
|
||||
"audio": map[bool]string{true: "browser", false: "local"}[browserAudio()],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,7 +287,25 @@ func recorderCmds(device string) [][]string {
|
|||
return cmds
|
||||
}
|
||||
|
||||
func (e *engine) start(lang, device string) error {
|
||||
// browserAudio: RUDDER_AUDIO=browser — the engine runs on a server (the cloud dev server), where there is
|
||||
// no microphone of the reader's to record. The page captures its own mic and streams 16 kHz s16le PCM to
|
||||
// /api/rudder/stream; that stream feeds the same pump and speech session as a local recorder.
|
||||
func browserAudio() bool { return os.Getenv("RUDDER_AUDIO") == "browser" }
|
||||
|
||||
func (e *engine) start(lang, device string) error { return e.startWith(lang, device, nil, nil) }
|
||||
|
||||
// startWith starts a session. src, when set, is the audio source (a browser stream); nil spawns a local recorder.
|
||||
func (e *engine) startWith(lang, device string, src *recorderProc, sink func(event)) error {
|
||||
browser := src != nil
|
||||
// A session's events go to ITS reader (a browser session's own socket), captured here, so a session
|
||||
// still draining its last words after Stop never writes into the next reader's session.
|
||||
emit := func(ev event) {
|
||||
if sink != nil {
|
||||
sink(ev)
|
||||
return
|
||||
}
|
||||
e.hub.send(ev)
|
||||
}
|
||||
e.mu.Lock()
|
||||
if e.state != "ready" {
|
||||
e.mu.Unlock()
|
||||
|
|
@ -292,6 +313,9 @@ func (e *engine) start(lang, device string) error {
|
|||
}
|
||||
if e.recording {
|
||||
e.mu.Unlock()
|
||||
if browser {
|
||||
return errors.New("busy — someone else is recording on this server; try again when they stop")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lang == "" {
|
||||
|
|
@ -316,22 +340,25 @@ func (e *engine) start(lang, device string) error {
|
|||
// mic link died 3-5 s into EVERY session that had speech (kernel: corrupted SCO packets); after
|
||||
// switching to CVSD it ran 9.5 minutes without a drop. CVSD is 8 kHz, and Nemotron's transcript of an
|
||||
// 8 kHz copy of the test clip differed by one word. RUDDER_KEEP_MSBC=1 opts out.
|
||||
if card := btCard(device); card != "" && os.Getenv("RUDDER_KEEP_MSBC") == "" {
|
||||
if card := btCard(device); !browser && card != "" && os.Getenv("RUDDER_KEEP_MSBC") == "" {
|
||||
if p := currentProfile(card); p == "headset-head-unit-msbc" {
|
||||
e.logs.add("bluetooth headset on mSBC — switching to CVSD before recording (stable voice link)")
|
||||
e.hub.send(event{"type": "recovering", "message": "Switching your Bluetooth headset to its stable voice mode…"})
|
||||
emit(event{"type": "recovering", "message": "Switching your Bluetooth headset to its stable voice mode…"})
|
||||
if err := switchProfile(card, e.logs); err != nil {
|
||||
e.logs.add("pre-switch to CVSD failed: %v", err)
|
||||
} else {
|
||||
e.hub.send(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."})
|
||||
emit(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
first, err := spawnRecorder(device, e.logs)
|
||||
if err != nil {
|
||||
ws.Close()
|
||||
return err
|
||||
first := src
|
||||
if first == nil {
|
||||
first, err = spawnRecorder(device, e.logs)
|
||||
if err != nil {
|
||||
ws.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
recName := first.name
|
||||
var recMu sync.Mutex
|
||||
|
|
@ -349,21 +376,39 @@ func (e *engine) start(lang, device string) error {
|
|||
cur.kill()
|
||||
recMu.Unlock()
|
||||
writeMu.Lock()
|
||||
// The streaming model holds back the last fraction of a second (its right context) until more audio
|
||||
// or a pause arrives, and a commit alone does not flush it (rega: a Stop mid-sentence lost "what
|
||||
// your country…"). 1.2 s of silence first lets it finish the words already spoken.
|
||||
silence := make([]byte, 3200)
|
||||
for i := 0; i < 12; i++ {
|
||||
_ = ws.WriteMessage(websocket.BinaryMessage, silence)
|
||||
}
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte(`{"type":"input_audio_buffer.commit"}`))
|
||||
writeMu.Unlock()
|
||||
select { // let the final words arrive
|
||||
// Let the final words arrive. A shared server's CPU can run behind real time (measured: a loaded
|
||||
// 8-core box finished an 11 s clip ~15 s after it ended), so a browser session waits longer.
|
||||
wait := 4 * time.Second
|
||||
if browser {
|
||||
wait = 30 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(4 * time.Second):
|
||||
case <-time.After(wait):
|
||||
}
|
||||
ws.Close()
|
||||
emit(event{"type": "recording", "recording": false})
|
||||
}
|
||||
e.mu.Unlock()
|
||||
e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device)))
|
||||
go watchKernelBluetooth(ctx, e.logs)
|
||||
e.hub.send(event{"type": "recording", "recording": true, "recorder": recName, "language": lang, "device": device})
|
||||
if browser {
|
||||
e.logs.add("recording started: recorder=browser language=%s", lang)
|
||||
} else {
|
||||
e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device)))
|
||||
go watchKernelBluetooth(ctx, e.logs)
|
||||
}
|
||||
emit(event{"type": "recording", "recording": true, "recorder": recName, "language": lang, "device": device})
|
||||
for _, d := range e.devs.current().Devices {
|
||||
if (d.ID == device || (device == "" && d.Default)) && d.Muted {
|
||||
e.hub.send(event{"type": "warning", "message": "Your microphone \"" + d.Label + "\" is muted in the system sound settings — unmute it or nothing will be heard."})
|
||||
emit(event{"type": "warning", "message": "Your microphone \"" + d.Label + "\" is muted in the system sound settings — unmute it or nothing will be heard."})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -400,6 +445,12 @@ func (e *engine) start(lang, device string) error {
|
|||
}
|
||||
continue
|
||||
}
|
||||
if browser {
|
||||
e.logs.add("browser audio stream ended: %v", err)
|
||||
emit(event{"type": "error", "message": "The browser stopped sending microphone audio (the page was closed or lost its connection)."})
|
||||
go e.stop()
|
||||
return
|
||||
}
|
||||
// The recorder died on its own (bad device, sound server unreachable, device unplugged).
|
||||
// End the session so the UI stops showing "Listening", and say WHY in plain words.
|
||||
_ = r.cmd.Wait()
|
||||
|
|
@ -415,7 +466,7 @@ func (e *engine) start(lang, device string) error {
|
|||
if why != "" {
|
||||
msg += " (" + r.name + ": " + why + ")"
|
||||
}
|
||||
e.hub.send(event{"type": "error", "message": msg})
|
||||
emit(event{"type": "error", "message": msg})
|
||||
go e.stop()
|
||||
return
|
||||
}
|
||||
|
|
@ -434,7 +485,7 @@ func (e *engine) start(lang, device string) error {
|
|||
// transcribed, then literal zeros while parec stayed "healthy" — the old Rudder's BUG 1,
|
||||
// the headset losing the mic side of the link. Re-opening the source restarts the audio
|
||||
// stream; the speech session and the transcript carry on untouched.
|
||||
if peak == 0 {
|
||||
if peak == 0 && !browser {
|
||||
if silentSince.IsZero() {
|
||||
silentSince = time.Now()
|
||||
} else if !gaveUp && !resetting.Load() && time.Since(silentSince) > 1500*time.Millisecond {
|
||||
|
|
@ -443,7 +494,7 @@ func (e *engine) start(lang, device string) error {
|
|||
case recoveries == 1:
|
||||
// Step 1: reopen the recorder (cheap; fixes a stalled stream).
|
||||
e.logs.add("mic link dead (exact zeros 1.5s) — step 1: reopening the microphone")
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone went silent — reconnecting it…"})
|
||||
emit(event{"type": "recovering", "attempt": recoveries, "message": "Microphone went silent — reconnecting it…"})
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
old := cur
|
||||
|
|
@ -458,12 +509,12 @@ func (e *engine) start(lang, device string) error {
|
|||
// "detect first", so it can't decide a dead link is fine.
|
||||
card := btCard(device)
|
||||
e.logs.add("mic still dead — step 2: forcing a new voice link: %s → headset-head-unit-cvsd", card)
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone still silent — switching your Bluetooth headset to its more stable voice mode…"})
|
||||
emit(event{"type": "recovering", "attempt": recoveries, "message": "Microphone still silent — switching your Bluetooth headset to its more stable voice mode…"})
|
||||
resetting.Store(true)
|
||||
go func() {
|
||||
defer resetting.Store(false)
|
||||
if err := switchProfile(card, e.logs); err != nil {
|
||||
e.hub.send(event{"type": "warning", "message": "Couldn't switch the headset's voice mode: " + err.Error()})
|
||||
emit(event{"type": "warning", "message": "Couldn't switch the headset's voice mode: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
|
|
@ -478,7 +529,7 @@ func (e *engine) start(lang, device string) error {
|
|||
case recoveries == 3 && btResetPath() != "":
|
||||
// Step 3: full headset reset (disconnect → restart PipeWire → reconnect).
|
||||
e.logs.add("mic still dead — step 3: full headset reset via RUDDER_BT_RESET")
|
||||
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Still silent — fully resetting your Bluetooth headset (about 15 seconds)…"})
|
||||
emit(event{"type": "recovering", "attempt": recoveries, "message": "Still silent — fully resetting your Bluetooth headset (about 15 seconds)…"})
|
||||
resetting.Store(true)
|
||||
go func() {
|
||||
defer resetting.Store(false)
|
||||
|
|
@ -487,7 +538,7 @@ func (e *engine) start(lang, device string) error {
|
|||
return
|
||||
}
|
||||
if !ok {
|
||||
e.hub.send(event{"type": "warning", "message": "The headset reset didn't bring the mic back — is the headset on and in range?"})
|
||||
emit(event{"type": "warning", "message": "The headset reset didn't bring the mic back — is the headset on and in range?"})
|
||||
}
|
||||
if next, err := spawnRecorder(device, e.logs); err == nil {
|
||||
recMu.Lock()
|
||||
|
|
@ -505,7 +556,7 @@ func (e *engine) start(lang, device string) error {
|
|||
if btResetPath() == "" {
|
||||
msg += " (No headset-reset command is configured — see RUDDER_BT_RESET.)"
|
||||
}
|
||||
e.hub.send(event{"type": "warning", "message": msg})
|
||||
emit(event{"type": "warning", "message": msg})
|
||||
}
|
||||
silentSince = time.Now()
|
||||
}
|
||||
|
|
@ -514,7 +565,7 @@ func (e *engine) start(lang, device string) error {
|
|||
recovering.Store(false)
|
||||
healthySince = time.Now()
|
||||
e.logs.add("mic recovered after %d reconnect(s)", recoveries)
|
||||
e.hub.send(event{"type": "recovered", "message": "Microphone is back — keep talking."})
|
||||
emit(event{"type": "recovered", "message": "Microphone is back — keep talking."})
|
||||
}
|
||||
if recoveries > 0 && !healthySince.IsZero() && time.Since(healthySince) > 30*time.Second {
|
||||
recoveries, gaveUp, healthySince = 0, false, time.Time{}
|
||||
|
|
@ -530,14 +581,14 @@ func (e *engine) start(lang, device string) error {
|
|||
}
|
||||
if time.Since(lastLevel) > 150*time.Millisecond {
|
||||
lastLevel = time.Now()
|
||||
e.hub.send(event{"type": "level", "peak": float64(peak) / 32768})
|
||||
emit(event{"type": "level", "peak": float64(peak) / 32768})
|
||||
}
|
||||
writeMu.Lock()
|
||||
err := ws.WriteMessage(websocket.BinaryMessage, frame)
|
||||
writeMu.Unlock()
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
e.hub.send(event{"type": "error", "message": "speech server connection lost: " + err.Error()})
|
||||
emit(event{"type": "error", "message": "speech server connection lost: " + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -547,6 +598,7 @@ func (e *engine) start(lang, device string) error {
|
|||
// Result reader: map the server's events onto the plugin's small vocabulary.
|
||||
go func() {
|
||||
defer close(done)
|
||||
committed := false
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
|
|
@ -560,17 +612,28 @@ func (e *engine) start(lang, device string) error {
|
|||
text := firstString(m, "transcript", "delta", "text")
|
||||
switch {
|
||||
case strings.HasSuffix(t, "input_audio_transcription.delta"):
|
||||
if committed {
|
||||
_ = ws.SetReadDeadline(time.Now().Add(6 * time.Second))
|
||||
}
|
||||
// Deltas APPEND to the utterance in progress; the server sends many empty ones.
|
||||
if text != "" {
|
||||
e.hub.send(event{"type": "partial", "text": text})
|
||||
emit(event{"type": "partial", "text": text})
|
||||
}
|
||||
case strings.HasSuffix(t, "input_audio_transcription.completed"):
|
||||
e.hub.send(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)})
|
||||
emit(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)})
|
||||
if committed {
|
||||
return // the last utterance after Stop is in
|
||||
}
|
||||
case t == "error":
|
||||
e.logs.add("speech server error: %s", string(msg))
|
||||
e.hub.send(event{"type": "error", "message": string(msg)})
|
||||
emit(event{"type": "error", "message": string(msg)})
|
||||
case t == "input_audio_buffer.committed":
|
||||
return
|
||||
// The commit acknowledges Stop; the words for the audio before it can still follow (rega: a
|
||||
// Stop mid-word lost "what your country…"). Keep reading until the final text or 6 s of quiet (a loaded server can take seconds per backlog chunk).
|
||||
committed = true
|
||||
e.logs.add("speech server committed the stream; reading its last words")
|
||||
_ = ws.SetReadDeadline(time.Now().Add(6 * time.Second))
|
||||
continue
|
||||
default:
|
||||
e.logs.add("speech event: %s", t)
|
||||
}
|
||||
|
|
@ -585,9 +648,13 @@ type recorderProc struct {
|
|||
out io.ReadCloser
|
||||
name string
|
||||
errBuf *bytes.Buffer
|
||||
closer io.Closer // a browser source: closing it ends the stream
|
||||
}
|
||||
|
||||
func (r *recorderProc) kill() {
|
||||
if r != nil && r.closer != nil {
|
||||
_ = r.closer.Close()
|
||||
}
|
||||
if r != nil && r.cmd != nil && r.cmd.Process != nil {
|
||||
_ = r.cmd.Process.Kill()
|
||||
go func() { _ = r.cmd.Wait() }()
|
||||
|
|
@ -640,9 +707,8 @@ func (e *engine) stop() {
|
|||
if !was || stop == nil {
|
||||
return
|
||||
}
|
||||
stop()
|
||||
e.logs.add("recording stopped")
|
||||
e.hub.send(event{"type": "recording", "recording": false})
|
||||
e.logs.add("recording stopping")
|
||||
stop() // sends "recording false" to the session's own reader once its last words are in
|
||||
}
|
||||
|
||||
func (e *engine) shutdown() {
|
||||
|
|
@ -670,6 +736,18 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
|||
// - CORS: only the desktop webview's origin (tauri://localhost, or http://tauri.localhost on Windows)
|
||||
// may read responses cross-origin. The Plugin Studio needs none: it reaches /api/rudder through the
|
||||
// dev server's same-origin proxy. RUDDER_ALLOW_ORIGIN adds one more exact origin (tests).
|
||||
//
|
||||
// allowedHost: RUDDER_ALLOW_HOST (comma-separated) names the public host of a server deployment, whose own
|
||||
// sign-in (oauth2-proxy) sits in front of the dev server that proxies /api/rudder here. Unset = loopback only.
|
||||
func allowedHost(host string) bool {
|
||||
for _, h := range strings.Split(os.Getenv("RUDDER_ALLOW_HOST"), ",") {
|
||||
if h = strings.TrimSpace(h); h != "" && h == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func guard(next http.Handler) http.Handler {
|
||||
extra := os.Getenv("RUDDER_ALLOW_ORIGIN")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -677,7 +755,7 @@ func guard(next http.Handler) http.Handler {
|
|||
if i := strings.LastIndex(host, ":"); i >= 0 && !strings.HasSuffix(host, "]") {
|
||||
host = host[:i]
|
||||
}
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "[::1]" {
|
||||
if host != "127.0.0.1" && host != "localhost" && host != "[::1]" && !allowedHost(host) {
|
||||
http.Error(w, "forbidden host", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
|
@ -721,6 +799,10 @@ func main() {
|
|||
Device string `json:"device"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if browserAudio() {
|
||||
writeJSON(w, 409, event{"error": "this engine takes the browser's microphone: stream to /api/rudder/stream"})
|
||||
return
|
||||
}
|
||||
if err := e.start(body.Language, body.Device); err != nil {
|
||||
e.logs.add("start failed: %v", err)
|
||||
writeJSON(w, 409, event{"error": err.Error()})
|
||||
|
|
@ -728,6 +810,53 @@ func main() {
|
|||
}
|
||||
writeJSON(w, 200, e.status())
|
||||
})
|
||||
// /api/rudder/stream (RUDDER_AUDIO=browser): a WebSocket of binary 16 kHz mono s16le PCM frames from the
|
||||
// page's microphone. Opening it starts a session, closing it stops one; the text comes back on /events.
|
||||
// The upgrader's default origin check (Origin must match Host) keeps other sites out.
|
||||
upgrader := websocket.Upgrader{ReadBufferSize: 8192}
|
||||
mux.HandleFunc("/api/rudder/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !browserAudio() {
|
||||
writeJSON(w, 409, event{"error": "this engine records a local microphone (RUDDER_AUDIO is not browser)"})
|
||||
return
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
pr, pw := io.Pipe()
|
||||
src := &recorderProc{out: pr, name: "browser", errBuf: &bytes.Buffer{}, closer: pr}
|
||||
var wmu sync.Mutex
|
||||
sink := func(ev event) {
|
||||
b, _ := json.Marshal(ev)
|
||||
wmu.Lock()
|
||||
_ = conn.WriteMessage(websocket.TextMessage, b)
|
||||
wmu.Unlock()
|
||||
}
|
||||
if err := e.startWith(r.URL.Query().Get("language"), "", src, sink); err != nil {
|
||||
e.logs.add("stream start failed: %v", err)
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(4409, err.Error()))
|
||||
return
|
||||
}
|
||||
for {
|
||||
t, b, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if t != websocket.BinaryMessage {
|
||||
// {"type":"stop"}: end the session but keep the socket open, so the last words (sent after the
|
||||
// speech server's commit) still reach this reader; the page closes the socket after "recording false".
|
||||
if bytes.Contains(b, []byte(`"stop"`)) {
|
||||
go e.stop()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := pw.Write(b); err != nil {
|
||||
break // the session stopped (its pipe was closed)
|
||||
}
|
||||
}
|
||||
e.stop()
|
||||
})
|
||||
mux.HandleFunc("/api/rudder/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||
e.stop()
|
||||
writeJSON(w, 200, e.status())
|
||||
|
|
|
|||
1
data/tables/card-00.json
Normal file
1
data/tables/card-00.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-01.json
Normal file
1
data/tables/card-01.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-02.json
Normal file
1
data/tables/card-02.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-03.json
Normal file
1
data/tables/card-03.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"ani":{"row":["Ani",23.755117,103.52517,"CHN","yix",5100,1,"ani"],"c":{"l":"Axi","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/yix","n":"Axi"}},"country":"People's Republic of China","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/ani?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/yix":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/4983?language=Axi)","at":"2026-09-24T18:32:59.196Z"}},"sun":null},"bhatu-andamani":{"row":["Bhatu (Andamani)",11.623377,92.726483,"IND","hca",400,1,"bhatu-andamani"],"c":{"l":"Andaman Hindi Creole","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/hca","n":"Andaman Hindi Creole"}},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/bhatu-andamani?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/hca":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/19048?language=Andaman%20Hindi%20Creole)","at":"2026-09-24T19:37:20.449Z"}},"sun":null},"kalmyk-oirats":{"row":["Kalmyk-Oirats",48.584897,95.431934,"MNG","xal",189000,2,"kalmyk-oirats"],"c":{"l":"Kalmyk-Oirat","f":"https://jesusfilm.org/watch/jesus/kalmyk-oirat","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/XALIBT","n":"Institute for Bible Translation, Moscow","ln":"Kalmyk-Oirat","m":"audio, text, video","vsc":"36 parts, Эклц to АПОКАЛИПСИС"},"sx":{"u":"https://live.bible.is/bible/XALIBT","n":"Institute for Bible Translation, Moscow","src":"Faith Comes By Hearing","parts":36}},"country":"Mongolia","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/kalmyk-oirats?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/kalmyk-oirat":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/kalmyk-oirat.html)","at":"2026-09-24T12:25:45.542Z"},"https://live.bible.is/bible/XALIBT":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:44.855Z"}},"sun":null},"kasseng":{"row":["Kasseng",15.25616,107.35139,"LAO","tdf",1200,1,"kasseng"],"c":{"l":"Talieng","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/tdf","n":"Talieng"}},"country":"Lao People's Democratic Republic","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/kasseng?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/tdf":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/17109?language=Talieng)","at":"2026-09-24T20:15:38.099Z"}},"sun":null}}
|
||||
1
data/tables/card-04.json
Normal file
1
data/tables/card-04.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-05.json
Normal file
1
data/tables/card-05.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-06.json
Normal file
1
data/tables/card-06.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-07.json
Normal file
1
data/tables/card-07.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-08.json
Normal file
1
data/tables/card-08.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-09.json
Normal file
1
data/tables/card-09.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-0a.json
Normal file
1
data/tables/card-0a.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-0b.json
Normal file
1
data/tables/card-0b.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-0c.json
Normal file
1
data/tables/card-0c.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-0d.json
Normal file
1
data/tables/card-0d.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-0e.json
Normal file
1
data/tables/card-0e.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-0f.json
Normal file
1
data/tables/card-0f.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-10.json
Normal file
1
data/tables/card-10.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-11.json
Normal file
1
data/tables/card-11.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-12.json
Normal file
1
data/tables/card-12.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-13.json
Normal file
1
data/tables/card-13.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-14.json
Normal file
1
data/tables/card-14.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-15.json
Normal file
1
data/tables/card-15.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-16.json
Normal file
1
data/tables/card-16.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-17.json
Normal file
1
data/tables/card-17.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-18.json
Normal file
1
data/tables/card-18.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-19.json
Normal file
1
data/tables/card-19.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-1a.json
Normal file
1
data/tables/card-1a.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-1b.json
Normal file
1
data/tables/card-1b.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-1c.json
Normal file
1
data/tables/card-1c.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-1d.json
Normal file
1
data/tables/card-1d.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-1e.json
Normal file
1
data/tables/card-1e.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-1f.json
Normal file
1
data/tables/card-1f.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-20.json
Normal file
1
data/tables/card-20.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"deaf-hong-kong":{"row":["Deaf of Hong Kong",22.27819894,114.16930265,"HKG","hks",7900,0,"deaf-hong-kong"],"c":{"l":"Hong Kong Sign Language","ck":"arclight (14 sign languages, all captured), fcbh (none)","un":"deaf_bible_society, door_international — both key-gated","sg":1},"country":"Hong Kong","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/deaf-hong-kong?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{},"sun":{"u":"https://sun.bible/sun/NewTestament","t":"SUN New Testament"}},"kono":{"row":["Kono",7.7533,-8.8226,"GIN","knu",121000,1,"kono"],"c":{"l":"Kono (Guinea)","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/knu","n":"Kono"}},"country":"Guinea","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/kono?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/knu":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/2530?language=Kono)","at":"2026-09-24T19:37:21.234Z"}},"sun":null},"male":{"row":["Male",5.8713,36.8208,"ETH","mdy",158000,2,"male"],"c":{"l":"Male","s":"https://ebible.org/mdybse/","sv":"Maale NT","sf":0,"ss":"New Testament","f":"https://jesusfilm.org/watch/jesus/male","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/MDYBSE","n":"2007 Bible Society of Ethiopia","ln":"Maale","m":"audio, text, video","vsc":"66 parts, ማዢንቲ ማፃኣፔ to ዮሃኒሴም ፆኦሲ ዻዌ ባኮ"},"cr":{"o":"Bible Society of Ethiopia","y":"2007"}},"country":"Ethiopia","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/male?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://ebible.org/mdybse/":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:25:41.839Z"},"https://jesusfilm.org/watch/jesus/male":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/male.html)","at":"2026-09-24T12:25:42.498Z"},"https://live.bible.is/bible/MDYBSE":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:42.572Z"}},"sun":null},"sandhai-muslims":{"row":["Sandhai Muslims",21.523339,70.45605,"IND","kfr",1500,1,"sandhai-muslims"],"c":{"d":[{"s":"Open Bible Stories","u":"https://git.door43.org/OBS-TLF/kfr_obs"}],"l":"Kacchi","f":"https://jesusfilm.org/watch/jesus/kachchhi","o":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)"},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/sandhai-muslims?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/kachchhi":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/kachchhi.html)","at":"2026-09-24T12:25:49.907Z"},"https://git.door43.org/OBS-TLF/kfr_obs":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:25:49.526Z"}},"sun":null}}
|
||||
1
data/tables/card-21.json
Normal file
1
data/tables/card-21.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-22.json
Normal file
1
data/tables/card-22.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-23.json
Normal file
1
data/tables/card-23.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-24.json
Normal file
1
data/tables/card-24.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-25.json
Normal file
1
data/tables/card-25.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-26.json
Normal file
1
data/tables/card-26.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-27.json
Normal file
1
data/tables/card-27.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-28.json
Normal file
1
data/tables/card-28.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-29.json
Normal file
1
data/tables/card-29.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-2a.json
Normal file
1
data/tables/card-2a.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-2b.json
Normal file
1
data/tables/card-2b.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-2c.json
Normal file
1
data/tables/card-2c.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-2d.json
Normal file
1
data/tables/card-2d.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-2e.json
Normal file
1
data/tables/card-2e.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-2f.json
Normal file
1
data/tables/card-2f.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-30.json
Normal file
1
data/tables/card-30.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-31.json
Normal file
1
data/tables/card-31.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-32.json
Normal file
1
data/tables/card-32.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-33.json
Normal file
1
data/tables/card-33.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-34.json
Normal file
1
data/tables/card-34.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"lati-vietnam":{"row":["Lati",22.80149,104.85041,"VNM","lbt",13000,0,"lati-vietnam"],"c":{"l":"Lachi","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion"},"country":"Vietnam","bridge":null,"bridge_candidate":{"l":"Vietnamese","c":"vie","pct":86,"official":true,"src":"cldr"},"pray_url":"https://pray.doxa.life/lati-vietnam?source=doxalife","_cardRes":{"bridge":null,"candidate":{"iso":"vie","language":"Vietnamese","credit":null,"scripture_url":"https://ebible.org/vie1934/","scripture_title":"Vietnamese Bible 1923","film_url":"https://jesusfilm.org/watch/jesus/vietnamese","film_title":"","zume_url":"https://zume.training/vi"},"alt":null},"verified":{},"sun":null},"nyong":{"row":["Nyong",18.4174,98.9743,"THA","yno",14000,1,"nyong"],"c":{"l":"Yong","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/yno","n":"Yong"}},"country":"Thailand","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/nyong?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/yno":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/18600?language=Yong)","at":"2026-09-24T22:03:36.784Z"}},"sun":null},"saba":{"row":["Saba",11.30721,18.49058,"TCD","saa",3300,1,"saba"],"c":{"l":"Saba","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion","ga":{"u":"https://fivefish.org/saa","n":"Saba"}},"country":"Chad","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/saba?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://fivefish.org/saa":{"state":"verified","detail":"HTTP 200 (via https://fivefish.org/3108?language=Saba)","at":"2026-09-24T23:45:40.178Z"}},"sun":null},"tswa":{"row":["Tswa",-21.0315,31.4419,"ZWE","tsc",196000,1,"tswa"],"c":{"l":"Tswa","f":"https://jesusfilm.org/watch/jesus/tswa","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/TSCBSM","n":"1972 Bible Society of Mozambique","ln":"Xitshwa","m":"audio, video","vsc":"27 parts, S Mateus to Apocalipse"}},"country":"Zimbabwe","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/tswa?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/tswa":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/tswa.html)","at":"2026-09-24T12:25:44.210Z"},"https://live.bible.is/bible/TSCBSM":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:43.468Z"}},"sun":null}}
|
||||
1
data/tables/card-35.json
Normal file
1
data/tables/card-35.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-36.json
Normal file
1
data/tables/card-36.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-37.json
Normal file
1
data/tables/card-37.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-38.json
Normal file
1
data/tables/card-38.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-39.json
Normal file
1
data/tables/card-39.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-3a.json
Normal file
1
data/tables/card-3a.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-3b.json
Normal file
1
data/tables/card-3b.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-3c.json
Normal file
1
data/tables/card-3c.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-3d.json
Normal file
1
data/tables/card-3d.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-3e.json
Normal file
1
data/tables/card-3e.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-3f.json
Normal file
1
data/tables/card-3f.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-40.json
Normal file
1
data/tables/card-40.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-41.json
Normal file
1
data/tables/card-41.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-42.json
Normal file
1
data/tables/card-42.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-43.json
Normal file
1
data/tables/card-43.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-44.json
Normal file
1
data/tables/card-44.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-45.json
Normal file
1
data/tables/card-45.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-46.json
Normal file
1
data/tables/card-46.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-47.json
Normal file
1
data/tables/card-47.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-48.json
Normal file
1
data/tables/card-48.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"bashkardi":{"row":["Bashkardi",25.7053,58.8715,"IRN","bsg",7900,0,"bashkardi"],"c":{"l":"Bashkardi","ck":"arclight, door43, ebible, fcbh, grn, scriptureearth","un":"api_bible, dbl, progress_bible, youversion"},"country":"Islamic Republic of Iran","bridge":{"l":"Persian, Iranian","c":"pes","st":"moderate","n":5,"s":true,"f":true},"bridge_candidate":null,"pray_url":"https://pray.doxa.life/bashkardi?source=doxalife","_cardRes":{"bridge":{"iso":"pes","language":"Persian, Iranian","credit":{"o":"Biblica, Inc","y":"2022","u":"https://www.biblica.com/","g":"biblica"},"scripture_url":"https://ebible.org/pesopcb/","scripture_title":"Open Persian Contemporary Bible","film_url":"https://jesusfilm.org/watch/jesus/farsi-western","film_title":"","zume_url":"https://zume.training/fa"},"candidate":null,"alt":null},"verified":{},"sun":null},"bororo":{"row":["Bororo",-16.833333,-56.133333,"BRA","bor",2400,2,"bororo"],"c":{"l":"Borôro","s":"https://ebible.org/bor/","sv":"Borôro: Pao Kurireu Bataru Kurireu; O Novo Testamento (New Testament)","sf":0,"ss":"New Testament","f":"https://jesusfilm.org/watch/jesus/bororo","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/BORWYI","n":"Bororo - Wycliffe Bible Translators, Inc.","ln":"Bororo","m":"audio, text, video","vsc":"27 parts, Mateus to Boe Oto Padure Kejeboe Reo"},"se":{"u":"https://scriptureearth.org/data/bor/","k":["PDF","sab"]},"cr":{"o":"Wycliffe Bible Translators, Inc","y":"2020","u":"https://www.wycliffe.org/","g":"wycliffe"}},"country":"Brazil","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/bororo?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://ebible.org/bor/":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:24:57.729Z"},"https://jesusfilm.org/watch/jesus/bororo":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/bororo.html)","at":"2026-09-24T12:24:58.501Z"},"https://live.bible.is/bible/BORWYI":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:24:57.619Z"},"https://scriptureearth.org/data/bor/":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T17:38:04.642Z"}},"sun":null},"damami":{"row":["Damami",26.451475,74.63615,"IND","mtr",5900,2,"damami"],"c":{"d":[{"s":"Open Bible Stories","u":"https://git.door43.org/OBS-TLF/mtr_obs"}],"l":"Mewari","f":"https://jesusfilm.org/watch/jesus/mewari","a":1,"o":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/MTRNLC","n":"New Life Computer Institute","ln":"Mewari","m":"audio, text, video","vsc":"27 parts, मत्ती to परकासितवाक्य"},"sx":{"u":"https://live.bible.is/bible/MTRNLC","n":"New Life Computer Institute","src":"Faith Comes By Hearing","parts":27}},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/damami?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/mewari":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/mewari.html)","at":"2026-09-24T12:25:49.581Z"},"https://live.bible.is/bible/MTRNLC":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:48.240Z"},"https://git.door43.org/OBS-TLF/mtr_obs":{"state":"verified","detail":"HTTP 200","at":"2026-09-24T12:25:48.373Z"}},"sun":null},"pangwal-buddhists":{"row":["Pangwal Buddhists",33.15328,76.49206,"IND","pgg",2100,1,"pangwal-buddhists"],"c":{"l":"Pangwali","f":"https://jesusfilm.org/watch/jesus/pangwali","a":1,"ck":"arclight, door43, ebible, fcbh, scriptureearth","un":"api_bible, dbl, progress_bible, youversion, grn (not yet swept)","au":{"u":"https://live.bible.is/bible/PGGWIN","n":"Pangwali - Wycliffe India Version","ln":"Pangwali","m":"audio, video","vsc":"27 parts, Matthew to Revelation"}},"country":"India","bridge":null,"bridge_candidate":null,"pray_url":"https://pray.doxa.life/pangwal-buddhists?source=doxalife","_cardRes":{"bridge":null,"candidate":null,"alt":null},"verified":{"https://jesusfilm.org/watch/jesus/pangwali":{"state":"verified","detail":"HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/pangwali.html)","at":"2026-09-24T12:25:19.162Z"},"https://live.bible.is/bible/PGGWIN":{"state":"verified","detail":"fileset confirmed by live.bible.is /api/bibles (the page itself answers 200 for any id)","at":"2026-09-24T12:25:18.519Z"}},"sun":null}}
|
||||
1
data/tables/card-49.json
Normal file
1
data/tables/card-49.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-4a.json
Normal file
1
data/tables/card-4a.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-4b.json
Normal file
1
data/tables/card-4b.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-4c.json
Normal file
1
data/tables/card-4c.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-4d.json
Normal file
1
data/tables/card-4d.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-4e.json
Normal file
1
data/tables/card-4e.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-4f.json
Normal file
1
data/tables/card-4f.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-50.json
Normal file
1
data/tables/card-50.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-51.json
Normal file
1
data/tables/card-51.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-52.json
Normal file
1
data/tables/card-52.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-53.json
Normal file
1
data/tables/card-53.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-54.json
Normal file
1
data/tables/card-54.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-55.json
Normal file
1
data/tables/card-55.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-56.json
Normal file
1
data/tables/card-56.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-57.json
Normal file
1
data/tables/card-57.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-58.json
Normal file
1
data/tables/card-58.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-59.json
Normal file
1
data/tables/card-59.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-5a.json
Normal file
1
data/tables/card-5a.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-5b.json
Normal file
1
data/tables/card-5b.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-5c.json
Normal file
1
data/tables/card-5c.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-5d.json
Normal file
1
data/tables/card-5d.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-5e.json
Normal file
1
data/tables/card-5e.json
Normal file
File diff suppressed because one or more lines are too long
1
data/tables/card-5f.json
Normal file
1
data/tables/card-5f.json
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue