# Subtitle Editor — Build Specification

`https://contexts.live/org/{slug}/tools/editor/`

---

## 1. Overview

A single-page web application for creating, editing, and exporting subtitle/caption data as structured JSON. It replaces a Google Sheets workflow and produces JSON files consumed by the contexts.live subtitle streaming platform.

Files can be saved directly to the server via a Node.js HTTPS endpoint, or exported as browser downloads in JSON (v1 or v2), TSV, or VTT format. A server-side file picker allows loading previously saved files and browsing version history.

The editor supports two JSON formats:
- **v1** — legacy format with separate `cues` array and top-level metadata keys
- **v2** — new format with `meta.cx: "2.0"`, `cueIn`/`cueOut` on lines, sources as fields

---

## 2. Technology

| | |
|---|---|
| **Delivery** | Three files: `editor.html`, `editor.js`, `editor.css` — no build step, no npm, no framework |
| **Auth** | nginx auth oracle (cx-auth) — client or admin role required |
| **Styling** | `editor.css`: IM Fell Great Primer serif, red (#CC4B4B) buttons, snow background |
| **JS** | Vanilla ES2020, strict mode; JSZip (CDN, PPTX import only); PDF.js 3.11.174 (CDN, PDF import only) |
| **Fonts** | Google Fonts CDN |
| **Save** | POST to cx-server HTTPS on port 8008 (`/{slug}/save` endpoint) — always saves v2 JSON |
| **Load** | GET from cx-server (`/{slug}/list`, `/{slug}/load`), or local file picker, or paste panel |
| **Export** | JSON v2, TSV, VTT: browser download (v1 export removed) |

---

## 3. Data Model

### 3.1 Source formats

**TSV/CSV** — first row is column headers. Rows fall into meta and line sections.

**JSON v1** — legacy format: `title`, `composer` etc. as top-level objects with `{type, text{}}` structure; `cues` array parallel to `lines`; colon-separated column names (`ad:audio:en`).

**JSON v2** — new format: flat `meta` object with `cx: "2.0"`; `cueIn`/`cueOut` on lines (legacy `timecodeIn`/`timecodeOut` normalised to these on import); sources (`st`, `ad`, `sl` etc.) as fields on lines; dot-separated column names (`st.text.en`).

**VTT / SRT / PPTX** — auto-detected on import.

### 3.2 Column kinds

| Kind | How identified | JSON behaviour |
|---|---|---|
| `fixed` | The `type` column only | Not stored in `data{}`, drives section/export logic |
| `lang` | 2-letter ISO codes, `ens`, `ipa`; or triple columns with a `text` segment | Values go into `text{}` at v1 export; triple lang cols exported via unflatten |
| `plain` | Any other named column | Stored and exported by column name |
| `computed` | Name matches `fn(source)` or `fn(source,target)` pattern | Pure function cols excluded from export; translation cols saved normally |

**Triple columns** — column names using `.` or `:` denote nested JSON structure up to 3 levels deep (e.g. `st.text.en`, `ad.audio.en`). Both separators accepted on import; normalised to `.` internally. On v2 JSON export, re-nested via `unflattenObj()`. On v1 export, re-nested with `:` separator via `unflattenObjColon()`.

A triple column is `kind: 'lang'` if any segment of its name is `text`; otherwise `kind: 'plain'`.

### 3.3 META_TYPES

`cx`, `title`, `id`, `language`, `composer`, `writer`, `date`, `venue`, `subtitle`, `description`, `performers`, `version`, `scorePages`, `scoreFormat`, `scorePrefix`, `catalogue`, `source`, `site`, `url`, `index`, `style`, `region`, `note`

Unknown meta types (e.g. custom fields from v2 `meta` object) are dynamically added to `META_TYPES` on import. They generate a validation warning.

`cx` controls the export format — `"2.0"` triggers v2 export from **Save to server**.

### 3.4 CONTENT_TYPES

`text`, `audible`

### 3.5 LINE_TYPES_ORDER

`text`, `audible`, `character`, `actSettingTitle`, `scene`, `stage`, `clear`, `pack`, `unit`, `note`

`pack` is the v2 type (replacing `unit`). Both are supported.

### 3.6 JSON v1 output structure

```
{
  title: { type, text: { fr, en, ens }, ... },
  ...
  lines: [ { type, text:{}, ad:{ audio:{en}, text:{en} }, ... }, ... ],
  cues: [ "26.713", "35.298", ..., "#####" ],
  metadata: { id, url, site, language, ... }
}
```

### 3.7 JSON v2 output structure

```
{
  meta: {
    cx: "2.0",
    id: "show2",
    title: { en: "Bluebeard's Castle" },
    language: "en",
    ...
  },
  lines: [
    {
      type: "text",
      cueIn: 12.0,
      cueOut: 16.5,
      ordinal: "1",
      st: { text: { en: "Bluebeard, this is your castle?" } },
      ad: { text: { en: "Judith looks up." }, audio: { en: "0003.mp3" } }
    },
    ...
  ]
}
```

Triple columns are re-nested using dot separator. Colon columns in v1 export use colon separator.

### 3.8 Format detection and save routing

- `importJSON()` checks `obj.meta?.cx` — if `"2.0"`, calls `importJSONv2()`; otherwise v1 import
- `importJSONv2()` builds meta rows from `obj.meta`, line rows from `obj.lines`
- `saveToServer()` always exports v2 JSON — `exportJSONv2()`. Server is cx-server on port 8008, saves to `org/{slug}/packs/`
- Config loaded from `../../config.json` relative to the editor (i.e. `org/{slug}/config.json`)

### 3.9 Character row folding (v1)

Character rows are folded into the immediately following non-character row as a nested `character` object. Option to also emit as standalone lines.

### 3.10 Cues column (v1)

Content rows contribute one string entry to the top-level `cues` array. Blank cues become `"-1"`. `"#####"` terminal sentinel when array is present.

### 3.11 Versioning

`name.json` is always current. `name.-1.json` is one save ago, `name.-2.json` two saves ago.

### 3.12 VTT-specific data

On import: inline markup preserved; multiple lines joined with `<br>`; `NOTE` blocks → `note` line rows; `STYLE`/`REGION` blocks → meta rows; per-cue settings → `cueSettings` column.

---

## 4. Server Integration

### 4.1 Endpoints

| Endpoint | Method | Description |
|---|---|---|
| `/{slug}/save` | POST | Write JSON to `packs/` folder, versioning any existing file. Checks `_mtime` for conflicts; returns `{conflict:true}` if file is newer. Skips check if `_force:true`. Strips `_mtime` and `_force` before writing. |
| `/{slug}/list` | GET | Return metadata for all current JSON files, including `editors` array per file (active presence entries) |
| `/{slug}/load?pack=x` or `?file=x.json` | GET | Return raw file contents |
| `/{slug}/presence` | POST | Heartbeat from an open editor window. Body: `{file}`. Records identity (IP) with timestamp. TTL 50 seconds. |
| `/{slug}/score?pack=x` | POST | Receive raw PDF body, split to PNGs via `pdftoppm`, save to `org/{slug}/media/{pack}/score.image/page-000001.png` etc. Returns `{ok, pages}`. |

### 4.2 Server

`serverBase()` builds the server URL from `config.json` → `port` field. If no port is configured, returns null and save/load operations fail with an error. cx-server runs on port 8008; all packs saved to `org/{slug}/packs/`.

### 4.3 File picker

`openServerPicker()` fetches `/{slug}/list` and renders a modal. Version buttons load `name.-1.json` etc.

Each file entry includes an `editors` array — active presence entries. If non-empty, the picker renders a red pill: **⚠ Also open by: {identity} (opened Xm ago)**. Identity is the client IP.

### 4.4 Presence tracking

When a file is loaded from the server (`loadFromServer()`), the editor starts a heartbeat: a `POST /{slug}/presence` request with `{file: filename}` fires immediately and then every 30 seconds. The server records `{identity, since, lastSeen}` per slug/file/identity. Entries older than 50 seconds are pruned on each `/list` call. The heartbeat stops on `beforeunload`.

Identity is the client IP (`X-Forwarded-For` or `socket.remoteAddress`).

### 4.5 Conflict detection on save

`saveToServer()` sends `_mtime` (the file's mtime at load time) in the save payload. The server checks whether the file's current mtime is newer; if so, returns `{ok: false, conflict: true}`. The editor shows a confirm dialog; if confirmed, resends with `_force: true`, which bypasses the check. Both `_mtime` and `_force` are stripped from the payload before writing.

---

## 5. Editor UI

### 5.1 Toolbar

Groups: File (New, Paste, Open server, Open local) · Save · Download (JSON v2, TSV, VTT) · Edit (Undo, Find & Replace, Validate, Columns, Rows, Options, Help)

### 5.2 Table structure

One `<table>` with meta rows above the `—— Lines ——` divider row, line rows below. Chunked rendering: 80 rows per `requestAnimationFrame`. `content-visibility: auto` on each `<tr>`.

### 5.3 Column kinds and colours

| Type | Header | Cell tint |
|---|---|---|
| Language | Amber | Light amber |
| Timing (`cue`, `time`, `sec` in name) | Mauve | Light mauve |
| Computed | Teal | Light teal |
| Plain | Grey | — |

### 5.4 Find & Replace

Three modes: Replace (find + replace text), Transform (apply function to matched cells), Quick transforms (one-click). When find field is empty, defaults to Transform mode; switches to Replace mode when user types a find term. Scope = current selection or all when find field empty.

### 5.5 Validation

🔴 Fatal · 🟡 Warning · 🔵 Note

Unknown meta types generate a warning (not fatal) — they are preserved from import.

---

## 6. Key constants and functions

| Name | Purpose |
|---|---|
| `sanitiseId(name)` | Replaces non-alphanumeric/hyphen/underscore chars with `-`; collapses runs; trims ends |
| `maybeAddIdRow(rows, filename)` | Prepends a meta id row derived from filename if none exists |
| `importPDF(file)` | Counts pages via PDF.js, creates score pack rows, stores File in `state.pendingPdf` |
| `META_TYPES` | Set — dynamically extended on v2 import |
| `LINE_TYPES_ORDER` | Array — includes both `pack` and `unit` |
| `CONTENT_TYPES` | Set — `text`, `audible` |
| `isTripleCol(name)` | Accepts `.` or `:` separator |
| `normaliseTripleCol(name)` | Normalises `:` → `.` on import |
| `flattenObj(obj, prefix, depth)` | Flattens to dot-separated keys |
| `unflattenObj(flat)` | Re-nests dot-separated keys |
| `unflattenObjColon(flat)` | Re-nests as colon-separated (v1 export) |
| `importJSON(text)` | Detects v1/v2, routes to correct importer |
| `importJSONv2(obj)` | v2 import: meta object + sources-as-fields |
| `exportJSONv2()` | v2 export — used for server save and download |
| `saveToServer(force)` | Calls `exportJSONv2()`, sends `_mtime`, handles conflict response |
| `serverBase()` | Builds server URL from config.port (default 8008) |
| `startHeartbeat(filename)` | Fires immediate presence POST then every 30s |
| `stopHeartbeat()` | Clears heartbeat interval (called on beforeunload) |

---

## 7. Key design decisions

| Decision | Choice | Rationale |
|---|---|---|
| Framework | None — vanilla ES2020 | No build step, no dependencies |
| Format | v2 only for server save; v1 accepted on import | Backward compatibility for opening old packs |
| Separator | `.` internal; `:` accepted on import (v1 files) | New format uses dots; old files use colons |
| Server routing | cx-server port 8008, `/{slug}/save` | Single server, all packs in `org/{slug}/packs/` |
| PDF score import | PDF.js counts pages locally; PDF uploaded to `/{slug}/score` on save; server splits via `pdftoppm` | No client-side rendering; server does the work |
| Id sanitisation | `sanitiseId()` on all filename-derived ids | Safe directory names; no special chars in pack id |
| Unknown meta types | Dynamically added, validation warning | Preserves custom fields; alerts user |
| Triple col detection | `.` or `:` separator | Accepts both formats on import |
| Cell editing | Render on demand | Reduces DOM for large files |
| Undo | Full state snapshot | Simple, correct, no edge cases |
