Character codes in typed text; the site and relay at app.kokkoja.com
With the Text tool, Klammertext's codes give letters a keyboard lacks: ^o" is ö, ^s^ ß, ^a1 ā, ^130ED^ a code point of 4 to 6 digits, ^9053 53EF^ a sequence, ^^ a caret, replaced as each code is completed. The public site and relay moved to app.kokkoja.com; the old name redirects. The Markdown documentation is withdrawn from the release while it is converted to Klammertext; the README's links to it are stale until then. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
34ca33d365
commit
89b4a41b2a
15 files changed
+364
-1273
No files matched your search
@@ -28,12 +28,12 @@ may be used.
|
|||||||
|
|
||||||
A guest needs nothing but a web browser: open the invitation link, enter
|
A guest needs nothing but a web browser: open the invitation link, enter
|
||||||
a name, join. A host can start a session from the same web site,
|
a name, join. A host can start a session from the same web site,
|
||||||
`https://kokkoja.andykopra.com/`, with the notebooks kept in the
|
`https://app.kokkoja.com/`, with the notebooks kept in the
|
||||||
browser.
|
browser.
|
||||||
|
|
||||||
A host who wants notebooks as files needs Python 3 and this directory:
|
A host who wants notebooks as files needs Python 3 and this directory:
|
||||||
|
|
||||||
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
|
python3 kokkoja.py --relay wss://app.kokkoja.com/relay
|
||||||
|
|
||||||
opens Kokkoja in the browser; *Start a session* gives an invitation to
|
opens Kokkoja in the browser; *Start a session* gives an invitation to
|
||||||
send to the guests. Notebooks are directories in `Kokkoja` in the
|
send to the guests. Notebooks are directories in `Kokkoja` in the
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// @ts-check
|
||||||
|
/**
|
||||||
|
* Klammertext's character codes, for typing letters a keyboard lacks:
|
||||||
|
* `^o"` is ö, `^s^` is ß, `^a1` is ā, `^00E9^` is é, `^130ED^` an
|
||||||
|
* Egyptian hieroglyph, `^9053 53EF^` two characters from one pair of
|
||||||
|
* carets, and `^^` is a caret. The text field replaces a code as soon
|
||||||
|
* as the keystroke that completes it is typed (text_editor.js), like a
|
||||||
|
* compose key.
|
||||||
|
*
|
||||||
|
* The tables and the rules are those of Klammertext's K/mac/character.h,
|
||||||
|
* character.cpp and katom_process.cpp (the live tree; K/src is
|
||||||
|
* deprecated), copied 2026-10-13; `kdesc -c` lists the tables. Left
|
||||||
|
* out, since a text block is not a document: the `^'...'^` literal span
|
||||||
|
* (a code is replaced only at the caret here, so nothing needs
|
||||||
|
* protecting), and the one-hex-digit form `^a^`, which makes a control
|
||||||
|
* character. A single character standing for itself inside a sequence
|
||||||
|
* (`^9053 a^` is 道a) applies only when at least one member is a code
|
||||||
|
* point (Andy, 2026-10-13), so that a typed `x^2 + y^2` stays. An
|
||||||
|
* invalid code point, which is an error in a document, stays as typed.
|
||||||
|
* The result is normalized to its precomposed form, as Klammertext's
|
||||||
|
* `compose` does, so that ö is one character in the page file and to
|
||||||
|
* Backspace.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The diacritic each mark letter stands for, as a combining character. */
|
||||||
|
export const DIACRITICS = {
|
||||||
|
'`': '̀', // grave accent
|
||||||
|
"'": '́', // acute accent
|
||||||
|
'h': '̂', // circumflex (hat)
|
||||||
|
'~': '̃', // tilde
|
||||||
|
'-': '̄', // macron
|
||||||
|
'"': '̈', // dieresis
|
||||||
|
'b': '̆', // breve
|
||||||
|
'd': '̇', // dot above
|
||||||
|
'r': '̊', // ring above
|
||||||
|
'w': '̌', // wedge (caron)
|
||||||
|
'c': '̧', // cedilla
|
||||||
|
'a': '̋', // double acute accent
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Letters outside the Roman alphabet and ligatures, `^code^`. */
|
||||||
|
export const EXTENDED_LATIN = {
|
||||||
|
's': 'ß', // Eszett
|
||||||
|
'i': 'ı', // dotless i
|
||||||
|
'I': 'İ', // capital dotted i
|
||||||
|
't': 'þ', // thorn
|
||||||
|
'T': 'Þ', // thorn capital
|
||||||
|
'e': 'ð', // eth
|
||||||
|
'E': 'Ð', // eth capital
|
||||||
|
'o': 'ø', // o stroke
|
||||||
|
'O': 'Ø', // o stroke capital
|
||||||
|
'd': 'đ', // d stroke
|
||||||
|
'D': 'Đ', // d stroke capital
|
||||||
|
'ae': 'æ', // ae ligature
|
||||||
|
'AE': 'Æ', // ae ligature capital
|
||||||
|
'oe': 'œ', // oe ligature
|
||||||
|
'OE': 'Œ', // oe ligature capital
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mandarin pinyin tones on a vowel, `^a1` to `^a4`. */
|
||||||
|
export const PINYIN_TONES = {
|
||||||
|
'1': '̄', // high
|
||||||
|
'2': '́', // rising
|
||||||
|
'3': '̌', // falling-rising
|
||||||
|
'4': '̀', // falling
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A code point in hex: 4 to 6 digits (2 and 3 would collide with the mnemonics). */
|
||||||
|
const HEX = '[0-9A-Fa-f]{4,6}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the number is a code point a character can be made of
|
||||||
|
* (Klammertext's `valid_codepoint`): in range, not a surrogate, not a
|
||||||
|
* noncharacter.
|
||||||
|
* @param {number} cp
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function validCodePoint(cp) {
|
||||||
|
if (cp < 0 || cp > 0x10FFFF) return false;
|
||||||
|
if (0xD800 <= cp && cp <= 0xDFFF) return false;
|
||||||
|
if (0xFDD0 <= cp && cp <= 0xFDEF) return false;
|
||||||
|
if ((cp & 0xFFFE) === 0xFFFE) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a sequence's members stand for: a hex member its code point, any
|
||||||
|
* other, a single character, itself; or null when no member is a code
|
||||||
|
* point (the run is not a sequence) or one is not a valid code point.
|
||||||
|
* @param {string[]} members
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function charactersOf(members) {
|
||||||
|
const hexRe = new RegExp(`^${HEX}$`, 'u');
|
||||||
|
if (!members.some((m) => hexRe.test(m))) return null;
|
||||||
|
let result = '';
|
||||||
|
for (const member of members) {
|
||||||
|
if (hexRe.test(member)) {
|
||||||
|
const cp = parseInt(member, 16);
|
||||||
|
if (!validCodePoint(cp)) return null;
|
||||||
|
result += String.fromCodePoint(cp);
|
||||||
|
} else {
|
||||||
|
result += member;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code that the text ends with, if it ends with a complete one.
|
||||||
|
* Checked in Klammertext's order: an extended Latin letter, a code point
|
||||||
|
* or a sequence, the caret itself, a diacritic, a pinyin tone.
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {{length: number, character: string} | null}
|
||||||
|
* How many characters of the text the code takes, and what it means.
|
||||||
|
*/
|
||||||
|
export function codeAtEnd(text) {
|
||||||
|
const extended = /\^(ae|AE|oe|OE|[siItTeEoOdD])\^$/u.exec(text);
|
||||||
|
if (extended) {
|
||||||
|
const name = /** @type {keyof typeof EXTENDED_LATIN} */ (extended[1]);
|
||||||
|
return { length: name.length + 2, character: EXTENDED_LATIN[name] };
|
||||||
|
}
|
||||||
|
// One pair of carets around members separated by spaces, on one line,
|
||||||
|
// no space inside the carets: code points of 4 to 6 hex digits and,
|
||||||
|
// once one of those is present, single characters standing for
|
||||||
|
// themselves (a caret cannot be one: it would close the sequence).
|
||||||
|
const member = `(?:${HEX}|[^\\s^])`;
|
||||||
|
const sequence = new RegExp(`\\^(${member}(?: +${member})*)\\^$`, 'u').exec(text);
|
||||||
|
if (sequence) {
|
||||||
|
const character = charactersOf(sequence[1].split(/ +/));
|
||||||
|
if (character !== null) return { length: sequence[0].length, character };
|
||||||
|
}
|
||||||
|
if (text.endsWith('^^')) return { length: 2, character: '^' };
|
||||||
|
// The base is a letter of any alphabet: not a space, a digit or ASCII
|
||||||
|
// punctuation (Klammertext's rule).
|
||||||
|
const diacritic = /\^([^\s0-9!-/:-@[-`{-~])([-'`h~"cbrdwa])$/u.exec(text);
|
||||||
|
if (diacritic) {
|
||||||
|
const mark = /** @type {keyof typeof DIACRITICS} */ (diacritic[2]);
|
||||||
|
return { length: diacritic[0].length, character: (diacritic[1] + DIACRITICS[mark]).normalize('NFC') };
|
||||||
|
}
|
||||||
|
const tone = /\^([aeiou])([1-4])$/u.exec(text);
|
||||||
|
if (tone) {
|
||||||
|
const number = /** @type {keyof typeof PINYIN_TONES} */ (tone[2]);
|
||||||
|
return { length: 3, character: (tone[1] + PINYIN_TONES[number]).normalize('NFC') };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
* shows it; everyone else sees it drawn, updated at every keystroke.
|
* shows it; everyone else sees it drawn, updated at every keystroke.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { codeAtEnd } from './character_codes.js';
|
||||||
import { randomId } from './crypto.js';
|
import { randomId } from './crypto.js';
|
||||||
import { textBounds } from './page_model.js';
|
import { textBounds } from './page_model.js';
|
||||||
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
|
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
|
||||||
@@ -51,7 +52,13 @@ export class TextEditor {
|
|||||||
this.field.className = 'text_field';
|
this.field.className = 'text_field';
|
||||||
this.field.spellcheck = false;
|
this.field.spellcheck = false;
|
||||||
this.field.hidden = true;
|
this.field.hidden = true;
|
||||||
this.field.addEventListener('input', () => this.changed());
|
/** True while a character code is being replaced in the field. */
|
||||||
|
this.replacing = false;
|
||||||
|
this.field.addEventListener('input', () => {
|
||||||
|
if (this.replacing) return;
|
||||||
|
this.replaceCode();
|
||||||
|
this.changed();
|
||||||
|
});
|
||||||
this.watchForHold();
|
this.watchForHold();
|
||||||
this.field.addEventListener('keydown', (event) => {
|
this.field.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
@@ -231,6 +238,30 @@ export class TextEditor {
|
|||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text before the caret ends with one of Klammertext's character
|
||||||
|
* codes (character_codes.js): replace it by the character it stands
|
||||||
|
* for. Done through the browser's own editing command so that its
|
||||||
|
* undo history survives (assigning the field's value would discard
|
||||||
|
* it); the command fires an input event of its own, ignored here.
|
||||||
|
*/
|
||||||
|
replaceCode() {
|
||||||
|
const field = this.field;
|
||||||
|
const caret = field.selectionStart;
|
||||||
|
if (caret !== field.selectionEnd) return;
|
||||||
|
const code = codeAtEnd(field.value.slice(0, caret));
|
||||||
|
if (!code) return;
|
||||||
|
field.setSelectionRange(caret - code.length, caret);
|
||||||
|
this.replacing = true;
|
||||||
|
try {
|
||||||
|
if (!document.execCommand('insertText', false, code.character)) {
|
||||||
|
field.setRangeText(code.character, caret - code.length, caret, 'end');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.replacing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The text, position or width changed: lay it out and tell the page. */
|
/** The text, position or width changed: lay it out and tell the page. */
|
||||||
changed() {
|
changed() {
|
||||||
const block = this.block;
|
const block = this.block;
|
||||||
|
|||||||
+13
-10
@@ -4,7 +4,8 @@ A Kokkoja *server* is one machine that does two things for a group of
|
|||||||
users: it runs the relay, which passes the participants' encrypted
|
users: it runs the relay, which passes the participants' encrypted
|
||||||
messages, and it serves the client as a web site, so that a guest can
|
messages, and it serves the client as a web site, so that a guest can
|
||||||
join by opening a link with nothing installed. Neither stores anything.
|
join by opening a link with nothing installed. Neither stores anything.
|
||||||
The first one is Andy's VPS at `kokkoja.andykopra.com`.
|
The first one is Andy's VPS, reached at `app.kokkoja.com` (the older
|
||||||
|
name `kokkoja.andykopra.com` still works, redirected there).
|
||||||
|
|
||||||
| On the server | What it is |
|
| On the server | What it is |
|
||||||
|----------------------------|-------------------------------------------------|
|
|----------------------------|-------------------------------------------------|
|
||||||
@@ -15,10 +16,10 @@ The first one is Andy's VPS at `kokkoja.andykopra.com`.
|
|||||||
| nginx site `kokkoja` | `https://<site>/` serves the client; `wss://<site>/relay` reaches the relay |
|
| nginx site `kokkoja` | `https://<site>/` serves the client; `wss://<site>/relay` reaches the relay |
|
||||||
|
|
||||||
The client's `config.json` on the site says
|
The client's `config.json` on the site says
|
||||||
`{"relay": "wss://kokkoja.andykopra.com/relay", "storage": "browser",
|
`{"relay": "wss://app.kokkoja.com/relay", "storage": "browser",
|
||||||
"site": "https://kokkoja.andykopra.com/"}`: the notebooks of anyone using
|
"site": "https://app.kokkoja.com/"}`: the notebooks of anyone using
|
||||||
the site are kept in their browser. A host with the local program uses
|
the site are kept in their browser. A host with the local program uses
|
||||||
the server with `python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay`;
|
the server with `python3 kokkoja.py --relay wss://app.kokkoja.com/relay`;
|
||||||
the invitation is then a link to the site.
|
the invitation is then a link to the site.
|
||||||
|
|
||||||
## Setting up or updating
|
## Setting up or updating
|
||||||
@@ -38,14 +39,16 @@ denied (publickey)".
|
|||||||
|
|
||||||
Steps a person does, once:
|
Steps a person does, once:
|
||||||
|
|
||||||
1. DNS: an A record `kokkoja.andykopra.com` pointing at the VPS
|
1. DNS: an A record `app.kokkoja.com` pointing at the VPS
|
||||||
(82.221.141.89), in cPanel's Zone Editor, since andykopra.com is on
|
(82.221.141.89), wherever kokkoja.com's DNS is managed (its registrar,
|
||||||
the orangewebsite.com nameservers. Check with
|
or the orangewebsite.com nameservers if delegated there). The older
|
||||||
`host kokkoja.andykopra.com buffy.ns.orangewebsite.com`.
|
name `kokkoja.andykopra.com` keeps its A record in cPanel's Zone
|
||||||
|
Editor and is redirected to app.kokkoja.com by nginx. Check with
|
||||||
|
`host app.kokkoja.com`.
|
||||||
|
|
||||||
2. TLS, after the name resolves and `deploy.sh` has run once:
|
2. TLS, after the name resolves and `deploy.sh` has run once:
|
||||||
|
|
||||||
ssh root@vps313847.iceservers.net certbot --nginx -d kokkoja.andykopra.com
|
ssh root@vps313847.iceservers.net certbot --nginx -d app.kokkoja.com -d kokkoja.andykopra.com
|
||||||
|
|
||||||
certbot adds the TLS listener to the nginx site and turns the plain
|
certbot adds the TLS listener to the nginx site and turns the plain
|
||||||
HTTP block into a redirect. Afterwards `setup_vps.sh` leaves the
|
HTTP block into a redirect. Afterwards `setup_vps.sh` leaves the
|
||||||
@@ -57,7 +60,7 @@ Steps a person does, once:
|
|||||||
|
|
||||||
vps-sentinel --accept
|
vps-sentinel --accept
|
||||||
|
|
||||||
Checks: `https://kokkoja.andykopra.com/config.json` shows the
|
Checks: `https://app.kokkoja.com/config.json` shows the
|
||||||
configuration above; `systemctl status kokkoja-relay` is active;
|
configuration above; `systemctl status kokkoja-relay` is active;
|
||||||
`journalctl -u kokkoja-relay -n 20` shows it listening.
|
`journalctl -u kokkoja-relay -n 20` shows it listening.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net}
|
server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net}
|
||||||
site=https://kokkoja.andykopra.com/
|
site=https://app.kokkoja.com/
|
||||||
relay=wss://kokkoja.andykopra.com/relay
|
relay=wss://app.kokkoja.com/relay
|
||||||
|
|
||||||
staging=$(mktemp -d)
|
staging=$(mktemp -d)
|
||||||
trap 'rm -rf "$staging"' EXIT
|
trap 'rm -rf "$staging"' EXIT
|
||||||
|
|||||||
+21
-5
@@ -1,13 +1,14 @@
|
|||||||
# nginx server block for a Kokkoja web site: the client's files at
|
# nginx server block for a Kokkoja web site: the client's files at
|
||||||
# https://kokkoja.andykopra.com/ and the relay at wss://kokkoja.andykopra.com/relay.
|
# https://app.kokkoja.com/ and the relay at wss://app.kokkoja.com/relay.
|
||||||
# Installed by deploy/setup_vps.sh as /etc/nginx/sites-available/kokkoja.
|
# Installed by deploy/setup_vps.sh as /etc/nginx/sites-available/kokkoja.
|
||||||
#
|
#
|
||||||
# This is the plain HTTP form. certbot --nginx -d kokkoja.andykopra.com
|
# This is the plain HTTP form. certbot --nginx -d app.kokkoja.com -d
|
||||||
# adds the TLS listener and turns this block into a redirect.
|
# kokkoja.andykopra.com adds the TLS listeners and turns the HTTP blocks
|
||||||
|
# into redirects.
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name kokkoja.andykopra.com;
|
server_name app.kokkoja.com;
|
||||||
|
|
||||||
root /var/www/kokkoja;
|
root /var/www/kokkoja;
|
||||||
index index.html;
|
index index.html;
|
||||||
@@ -33,7 +34,22 @@ server {
|
|||||||
# browsers ask whether they have changed rather than keeping old ones.
|
# browsers ask whether they have changed rather than keeping old ones.
|
||||||
add_header Cache-Control "no-cache" always;
|
add_header Cache-Control "no-cache" always;
|
||||||
# The client loads nothing from anywhere else, and may not be framed.
|
# The client loads nothing from anywhere else, and may not be framed.
|
||||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://kokkoja.andykopra.com; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" always;
|
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://app.kokkoja.com; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The old name, kept so that bookmarks and links to the SITE still reach
|
||||||
|
# it: everything is redirected to app.kokkoja.com. The browser carries a
|
||||||
|
# link's #join fragment across the redirect, so the join page opens; but
|
||||||
|
# an invitation made before the move names the old relay inside that
|
||||||
|
# fragment, and a WebSocket does not follow the 301 (and the new CSP
|
||||||
|
# forbids the old host), so a pre-move invitation opens but cannot
|
||||||
|
# connect. Invitations made from app.kokkoja.com name the new relay and
|
||||||
|
# are fine; live invitations are per-session, so none is affected in
|
||||||
|
# practice.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name kokkoja.andykopra.com;
|
||||||
|
return 301 https://app.kokkoja.com$request_uri;
|
||||||
|
}
|
||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
#
|
#
|
||||||
# TLS is the one step left to a person, once the name resolves to this
|
# TLS is the one step left to a person, once the name resolves to this
|
||||||
# machine (see deploy/README.md):
|
# machine (see deploy/README.md):
|
||||||
# certbot --nginx -d kokkoja.andykopra.com
|
# certbot --nginx -d app.kokkoja.com -d kokkoja.andykopra.com
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
incoming=/opt/kokkoja/incoming
|
incoming=/opt/kokkoja/incoming
|
||||||
|
|||||||
-336
@@ -1,336 +0,0 @@
|
|||||||
# A guest's Kokkoja
|
|
||||||
|
|
||||||
This part describes Kokkoja as a guest sees it: someone invited to a
|
|
||||||
session by its host. In a lesson, the guest is the student. The host's
|
|
||||||
screen is the same with a few more buttons, described in
|
|
||||||
[A host's Kokkoja](host.md).
|
|
||||||
|
|
||||||
## The invitation
|
|
||||||
|
|
||||||
The host sends you an invitation by whatever means you both use: email, a
|
|
||||||
chat message, a text message. It is a link of this shape:
|
|
||||||
|
|
||||||
https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23uQ5c__CGB4FvN64Qiv3jGA.aS_1O51Umk1YHXwgqBZANw
|
|
||||||
|
|
||||||
The part after `join=` is the invitation itself, which the host's Kokkoja
|
|
||||||
may also give you on its own, without the link around it. Both forms
|
|
||||||
work wherever an invitation is asked for. A host in the same room may
|
|
||||||
instead show you the link as a QR code on their screen: point your
|
|
||||||
phone's or tablet's camera at it and open the link it offers. The invitation carries the key
|
|
||||||
that protects the session, so pass it on only to people who belong in the
|
|
||||||
session, and treat it as you would a meeting link. An invitation is good
|
|
||||||
for one session: the host's next session has a new one.
|
|
||||||
|
|
||||||
## Joining
|
|
||||||
|
|
||||||
Open the link in a web browser. Firefox, Brave and Chrome on a
|
|
||||||
computer, and Safari on an iPad or an iPhone, are known to work; other
|
|
||||||
browsers built on the same engines very probably do. You see
|
|
||||||
Kokkoja's start screen, headed "Join a session", with the invitation
|
|
||||||
already filled in. Enter the name the others should see and press
|
|
||||||
**Join**. That is all: nothing is installed, and there is no account to
|
|
||||||
make. (Opened without an invitation, the same screen also offers to
|
|
||||||
start a session of your own; an invitation is a request to join one
|
|
||||||
session, so with one it offers joining alone.)
|
|
||||||
|
|
||||||
Your name is remembered by the browser for next time. So is a second
|
|
||||||
choice, **Notebook for the pages of this session**, which you can leave
|
|
||||||
as it is for now; what it does is explained under "Your notebook" below.
|
|
||||||
|
|
||||||
If you have the Kokkoja program installed on your computer (see
|
|
||||||
[A host's Kokkoja](host.md) for what that is and why you might want it),
|
|
||||||
the start screen also offers to open the invitation in it instead. If
|
|
||||||
you have not, ignore that line.
|
|
||||||
|
|
||||||
If the host has not started yet, or has left, you are still in the
|
|
||||||
session. The page is shared among whoever is present, and the host's
|
|
||||||
place in it waits for the host to return.
|
|
||||||
|
|
||||||
## The screen
|
|
||||||
|
|
||||||
Across the top is the toolbar, one row holding everything in three
|
|
||||||
groups: on the left the tools, their settings and **Undo**; in the
|
|
||||||
middle the page and the view; on the right the host's page buttons,
|
|
||||||
**Notebooks**, your notebook, and **Session**, which opens a window with
|
|
||||||
the participants and the invitation. Below it the page has the whole width of the window. Every button
|
|
||||||
explains itself: rest the pointer on it for a second and its
|
|
||||||
explanation appears beneath it.
|
|
||||||
|
|
||||||
### The page
|
|
||||||
|
|
||||||
The page is A4 in size, lying on its side, shown so that it fits the
|
|
||||||
window. Write
|
|
||||||
anywhere on it. When your writing comes near an edge, the page grows in
|
|
||||||
that direction, in small steps, so there is always room, and the view
|
|
||||||
keeps its scale and extends beyond the window; scroll to reach the rest.
|
|
||||||
An image the host puts on the page lies at the top, or below the images
|
|
||||||
already there.
|
|
||||||
|
|
||||||
The page as a whole is shared: everyone sees the same marks in the same
|
|
||||||
places. Your view of it is your own: how far it is zoomed and where it
|
|
||||||
is scrolled to concern nobody else.
|
|
||||||
|
|
||||||
### Pen
|
|
||||||
|
|
||||||
**Pen** is chosen when you arrive. Draw with a pen on a tablet, a mouse,
|
|
||||||
or a finger on a touch screen. With a pen, pressing harder makes the
|
|
||||||
line wider. The colours are to the right of the tools: black, red,
|
|
||||||
green, blue and white, and **+** opens a palette of more, in which the
|
|
||||||
colours already in the row leave their places empty. A colour chosen
|
|
||||||
from the palette joins the row and stays there in this browser. White
|
|
||||||
is for painting over. The four lines to the right of the colours are
|
|
||||||
the line widths: 0.4, 0.7, 1.2 and 2.4 mm at ordinary pressure.
|
|
||||||
|
|
||||||
If a pen is in use, a hand resting on a touch screen is ignored. A
|
|
||||||
click on the page that only brings the browser window to the front, when
|
|
||||||
you come back to it from another window, makes no mark.
|
|
||||||
|
|
||||||
### Text
|
|
||||||
|
|
||||||
**Text** types on the page. Tap where the text should begin and type; a
|
|
||||||
text field appears there and the text is laid out on the page as you
|
|
||||||
type. Enter begins a new line. Press Escape, or tap somewhere else, to
|
|
||||||
finish; a block with nothing in it disappears. You need not finish to
|
|
||||||
move a block: rest the pen on it while typing, and it follows the pen,
|
|
||||||
field and all; lift, and go on typing.
|
|
||||||
|
|
||||||
A block made by a tap does not wrap: its lines are as long as you type
|
|
||||||
them, and the page grows to hold them; a red corner mark of 5 mm at its
|
|
||||||
top left shows where it begins. To make a *text rectangle*
|
|
||||||
instead, drag it open where you want it: a red box, white inside, in
|
|
||||||
which the text wraps at the box's width, and which grows downward by
|
|
||||||
lines when you type more than it holds. The box is part of the page,
|
|
||||||
seen by everyone, and where it lies over an image it covers the image
|
|
||||||
and nothing else; a teacher uses one to say "write your answer here".
|
|
||||||
|
|
||||||
Tap your own text to change it, anywhere in a box, and the cursor is at
|
|
||||||
the end of the text. To move a block of yours, rest the pen
|
|
||||||
on it, without moving, for a little over half a second: a green outline
|
|
||||||
appears, and it follows the pen. To resize a text rectangle, rest the
|
|
||||||
pen near one of its corners and drag the corner; the text wraps anew at
|
|
||||||
the new width. You cannot change anyone else's text.
|
|
||||||
|
|
||||||
With the Text tool, the row of colours stays and the line widths give way
|
|
||||||
to the font, one of **Sans**, **Serif** and **Mono**, and the size in
|
|
||||||
points, 14 unless you change it. Both apply to the block you are typing
|
|
||||||
and to the next ones.
|
|
||||||
|
|
||||||
### Eraser
|
|
||||||
|
|
||||||
**Eraser** removes your own writing. Move it across a stroke and the
|
|
||||||
whole stroke goes; across a text block and the block goes. It removes
|
|
||||||
nothing of anyone else's, and nobody else's eraser can touch yours. An
|
|
||||||
erased stroke is gone from the page for everyone, until you undo the
|
|
||||||
erasure.
|
|
||||||
|
|
||||||
### Undo
|
|
||||||
|
|
||||||
**Undo**, the last button of the drawing tools, takes back your latest mark: a
|
|
||||||
stroke, what one movement of the eraser removed, a text block as you
|
|
||||||
last typed or moved it, a Hide rectangle made, moved or removed. Press
|
|
||||||
it again for the mark before that, and so on back to the beginning of
|
|
||||||
the page. Ctrl+Z (Command+Z on a Mac) does the same, and so do two
|
|
||||||
quick taps of the Eraser on empty paper, away from anything the eraser
|
|
||||||
would remove; a tap that erases something is never half of such a
|
|
||||||
double tap. Undo acts only on your own marks, since nobody can change
|
|
||||||
anyone else's, and only on marks made on this page since you opened it:
|
|
||||||
after a reload of the tab, or on a page loaded from a notebook, there is
|
|
||||||
nothing to undo although the marks are there. The button is grey when
|
|
||||||
there is nothing to undo. An image cannot be undone; put it back with
|
|
||||||
**Image** if a tap of the eraser removed it by mistake.
|
|
||||||
|
|
||||||
### The view
|
|
||||||
|
|
||||||
A page is shown whole when you first see it. Afterwards the view keeps
|
|
||||||
its scale: when the page grows, or the host adds an image, the page
|
|
||||||
extends beyond the window and you scroll to the rest. Three buttons and
|
|
||||||
two gestures change the view, and none of them changes the page:
|
|
||||||
|
|
||||||
* **Fit** shows the whole page in the window.
|
|
||||||
* **A4** shows the page at the scale a new page has, a full A4 page
|
|
||||||
filling the window, so that a page shrunk to a small exercise is seen
|
|
||||||
at its own size rather than enlarged to fill the window.
|
|
||||||
* When the host presses **Fit** or **A4**, every participant's view
|
|
||||||
follows, each in their own window. Your own Fit and A4 change only
|
|
||||||
your screen.
|
|
||||||
* **Expand**, which only the host and co-hosts have, makes the page
|
|
||||||
itself fill the window, for everyone, at the present zoom, in both
|
|
||||||
directions. The page grows equally on every side, so nothing on it
|
|
||||||
moves and nothing is rescaled; there is simply more room around it.
|
|
||||||
* **Shrink**, also for the host and co-hosts, makes the page, for everyone, just large enough for what is on it,
|
|
||||||
with the usual margin around. Nothing on it moves either.
|
|
||||||
* **Extend** is a box, ticked to begin with. Ticked, the page grows
|
|
||||||
when your writing comes near an edge, so there is always room.
|
|
||||||
Unticked, your writing stops short of the edge; a stroke that leaves
|
|
||||||
the page resumes when the pen comes back. Images always get the room
|
|
||||||
they need.
|
|
||||||
* Hold **Ctrl** (Command on a Mac) and turn the scroll wheel to zoom in
|
|
||||||
and out about the pointer.
|
|
||||||
* Hold **Ctrl** (Command on a Mac) and draw a stroke with the pen: up
|
|
||||||
zooms in, down zooms out, by twice for every 100 pixels of movement,
|
|
||||||
about the point where the pen touched down. Nothing is drawn while
|
|
||||||
the key is held.
|
|
||||||
|
|
||||||
If you reload the browser tab, it comes back to the page as you last saw
|
|
||||||
it: same zoom, same place.
|
|
||||||
|
|
||||||
### Session
|
|
||||||
|
|
||||||
**Session** in the toolbar opens a window with the people and the
|
|
||||||
invitation. **Participants** lists everyone in the session, with *you*
|
|
||||||
and *host* marked, and *away* beside anyone who has left or lost their
|
|
||||||
connection; an absent participant's writing stays on the page. The box
|
|
||||||
in front of each name shows or hides that participant's writing on your
|
|
||||||
screen alone. Hiding your teacher's corrections to try an exercise
|
|
||||||
again, or hiding your own answers to see the exercise as it was,
|
|
||||||
changes nothing for anyone else.
|
|
||||||
|
|
||||||
**Invitation** shows the invitation of this session, with a button to
|
|
||||||
copy it and, below, as a QR code for a camera, in case someone else
|
|
||||||
should be invited. **Log** is a record of what happened in this
|
|
||||||
session, pages and connection and every press and release of the pen,
|
|
||||||
with a button to copy it: if something goes wrong, copy it and send it
|
|
||||||
to whoever looks after Kokkoja. Above it the window says which page
|
|
||||||
you are on, counted from the start of the session, how you stand with
|
|
||||||
the relay, and when the page was last saved; below it, where your
|
|
||||||
notebooks are kept.
|
|
||||||
|
|
||||||
### Notices
|
|
||||||
|
|
||||||
While all is well Kokkoja says nothing. When there is something to
|
|
||||||
know, a line appears between the toolbar and the page: in red while
|
|
||||||
a problem lasts, in grey for a few seconds after something was done.
|
|
||||||
*Not connected. Reconnecting…* means the relay cannot be reached;
|
|
||||||
Kokkoja reconnects by itself, and what you wrote meanwhile is sent when
|
|
||||||
it returns. *The page could not be saved* means the notebook is out of
|
|
||||||
reach; the page is still in memory and the saving is tried again.
|
|
||||||
*Waiting for the host's page* means the host has not started a page
|
|
||||||
yet, or your connection is down. The grey notices say that a page was
|
|
||||||
kept or exported, and where.
|
|
||||||
|
|
||||||
For finding faults, the browser's console (F12) keeps a short log of the
|
|
||||||
session's events: pages, images, the connection, and what your pen or
|
|
||||||
mouse delivers.
|
|
||||||
|
|
||||||
## Your notebook
|
|
||||||
|
|
||||||
Every page of the session is saved for you, as it changes, without your
|
|
||||||
doing anything. Where it is saved depends on how you joined.
|
|
||||||
|
|
||||||
**Notebooks** in the toolbar opens a chooser that shows your notebooks
|
|
||||||
and small pictures of the pages kept in them, wherever they are kept.
|
|
||||||
Rest the pointer on a page for a second and its facts appear: the first
|
|
||||||
text on it, when it was last changed, who wrote on it, what is on it
|
|
||||||
and the size of the page; on a notebook, how many pages it keeps and
|
|
||||||
from when, when it was last written in, and where it is.
|
|
||||||
There you can delete pages and notebooks you no longer want: mark them
|
|
||||||
with the × at their left, then press **Delete** twice. In the browser
|
|
||||||
a deleted page is gone for good; with the Kokkoja program it goes to the
|
|
||||||
Trash. Starting a page for everyone from a kept page is the host's
|
|
||||||
business, and a co-host's.
|
|
||||||
|
|
||||||
### In the browser
|
|
||||||
|
|
||||||
When you joined by opening the link, your notebook is kept inside the
|
|
||||||
browser, on the computer you are using. It stays there between sessions,
|
|
||||||
but it belongs to that browser: another browser, or another computer,
|
|
||||||
has its own. The page you are on is saved there continuously. When the
|
|
||||||
session moves to another page, the page you are leaving is kept under a
|
|
||||||
dated name, such as `2026_10_06_01.svg`, the date being the day it was
|
|
||||||
last changed and the number counting the pages kept that day.
|
|
||||||
|
|
||||||
Two buttons bring pages out of the browser:
|
|
||||||
|
|
||||||
* **Keep** keeps a dated copy of the page as it is now, and
|
|
||||||
downloads it, so you find it among your browser's downloads. Pressing
|
|
||||||
it again before anything has changed makes no second copy and no
|
|
||||||
second download. Any mark counts as a change, even a dot from a
|
|
||||||
stray touch of the pen.
|
|
||||||
* **Export** downloads a picture of the page as you see it, as a PNG
|
|
||||||
file; writing you have hidden is left out. Its name is the date and
|
|
||||||
the page number.
|
|
||||||
|
|
||||||
A page file is an SVG file. Any web browser displays it, and vector
|
|
||||||
drawing programs such as Inkscape open it. Kokkoja itself can load one
|
|
||||||
again, writing and all, when the host or a co-host chooses it (see "When the host gives you more" below, and the host's
|
|
||||||
part).
|
|
||||||
|
|
||||||
The notebook list on the start screen names the notebook within the
|
|
||||||
browser's storage. Giving each course or each colleague a notebook of
|
|
||||||
their own keeps their pages apart; until you make one, everything goes
|
|
||||||
into the notebook *General*. The last entry of the list, **New
|
|
||||||
notebook…**, asks for a name and makes a notebook of that name; it
|
|
||||||
exists from the moment a page is written into it. **Current notebook**
|
|
||||||
in the toolbar is the same list, and lets you change notebook, or make
|
|
||||||
one, during the session.
|
|
||||||
|
|
||||||
### On your computer
|
|
||||||
|
|
||||||
If you would rather have your pages as files in a directory on your own
|
|
||||||
computer, where you can open, copy and back them up as you do any file,
|
|
||||||
install the Kokkoja program. It is small and needs only Python, which
|
|
||||||
Linux and macOS have or can quickly get; [A host's Kokkoja](host.md)
|
|
||||||
tells how. Then join from the program's own
|
|
||||||
start screen, pasting the invitation, or follow the start screen's offer
|
|
||||||
to open the invitation in the installed program.
|
|
||||||
|
|
||||||
With the program, a notebook is a directory in `Kokkoja` in your
|
|
||||||
Documents directory. The page you are on is `Current.svg` there, and
|
|
||||||
kept pages lie beside it under their dated names. **Keep** makes
|
|
||||||
the dated copy; **Export** opens Kokkoja's own file chooser, in which you
|
|
||||||
pick a directory and a name for the PNG file.
|
|
||||||
|
|
||||||
The program also keeps your identity, so that you are the same
|
|
||||||
participant in every session and from any browser on that computer, and
|
|
||||||
writing from an earlier lesson is still yours when its page is loaded
|
|
||||||
again. In the browser, your identity belongs to that browser.
|
|
||||||
|
|
||||||
## Leaving and returning
|
|
||||||
|
|
||||||
Close the tab and you have left. Nothing needs saving. If you come back
|
|
||||||
to the same session by opening the invitation again, your writing is
|
|
||||||
still on the page, from the others' copies and from your notebook, and
|
|
||||||
it is still yours to erase or change. A tab that was in a session and is
|
|
||||||
reloaded goes straight back in.
|
|
||||||
|
|
||||||
## When the host makes you a co-host
|
|
||||||
|
|
||||||
Normally only the host puts images on the page, starts new pages, or
|
|
||||||
loads pages from a notebook. The host can make you a *co-host*, which
|
|
||||||
gives you the same powers, for instance in a discussion among
|
|
||||||
colleagues where everyone brings material. The Session window then
|
|
||||||
says *co-host* beside your name, and three more buttons appear at the
|
|
||||||
right of your toolbar:
|
|
||||||
|
|
||||||
* **New** starts a new blank page for everyone. The page being left is
|
|
||||||
kept in each person's notebook.
|
|
||||||
* **Image** puts an image file on the page for everyone, below any
|
|
||||||
images already there. Your browser's file chooser asks for the file
|
|
||||||
(with the Kokkoja program, Kokkoja's own chooser does, beginning where
|
|
||||||
the notebooks are). A PNG or JPEG file keeps its exact pixels unless
|
|
||||||
it is very large; other formats and very large images are converted
|
|
||||||
and reduced before they are shared. An image is placed at its natural
|
|
||||||
size, centred, 5 mm below the image above or at the top of the page;
|
|
||||||
one wider than the page widens it. To put an image on a page of its
|
|
||||||
own, press **New** first.
|
|
||||||
* **Notebooks**, which everyone has, now also starts a new page for
|
|
||||||
everyone from a page kept in one of your notebooks, with everything
|
|
||||||
that was written on it: choose the page in the chooser. The kept page
|
|
||||||
itself is not changed: the session works on a copy.
|
|
||||||
* **Hide**, among the tools, hides part of the page for everyone: drag
|
|
||||||
open a rectangle and whatever lies under it, writing included, is
|
|
||||||
covered in white, marked by blue corners so that nobody writes there
|
|
||||||
unknowingly. Rest the pen on it to move it, near a corner to resize
|
|
||||||
it; a tap of the eraser removes it and uncovers what was there. A Hide
|
|
||||||
stays even for someone who hides your writing on their own screen.
|
|
||||||
|
|
||||||
As a co-host you can also move and scale images, as the host can: rest the pen on an image for a little over half a second
|
|
||||||
and it follows the pen; rest it near a corner and the corner follows,
|
|
||||||
the shape kept. Writing stays where it is, so images are arranged
|
|
||||||
first and written on afterwards. A new image always arrives below the
|
|
||||||
others, centred, at its own size, to be arranged by hand. A tap of the
|
|
||||||
eraser on an image removes it; writing on it stays. An image you added
|
|
||||||
yourself you may always remove, even after you have stopped being a
|
|
||||||
co-host.
|
|
||||||
|
|
||||||
You stay a co-host until the host decides otherwise or the session ends.
|
|
||||||
-341
@@ -1,341 +0,0 @@
|
|||||||
# A host's Kokkoja
|
|
||||||
|
|
||||||
The host starts a session, invites the guests and drives the session:
|
|
||||||
puts images on the page, starts new pages, loads pages from a notebook,
|
|
||||||
and decides which guests may do the same. In a lesson, the teacher is
|
|
||||||
the host. Everything in [A guest's Kokkoja](guest.md) applies to the host
|
|
||||||
too; this part describes what is added.
|
|
||||||
|
|
||||||
## Hosting from the web site, or with the Kokkoja program
|
|
||||||
|
|
||||||
A host, like a guest, can use Kokkoja with nothing installed: open
|
|
||||||
`https://kokkoja.andykopra.com/` in a browser, enter a name, press
|
|
||||||
**Start a session**, and the invitation is ready in the Session window.
|
|
||||||
Your notebooks are then kept inside that browser, as a guest's are (see
|
|
||||||
"Your notebook" in the guest's part): they belong to that browser on
|
|
||||||
that computer, and a page leaves it by **Keep** or **Export**, which
|
|
||||||
download it. "Image" asks for the file with the browser's own chooser.
|
|
||||||
|
|
||||||
If you would rather have your notebooks as ordinary directories of
|
|
||||||
files, where you can open, copy, back up and send them, run the Kokkoja
|
|
||||||
program on your own computer. It is a small Python program with two
|
|
||||||
jobs: it shows Kokkoja in your web browser, and it reads and writes
|
|
||||||
your notebooks as directories and files. It listens only to your own
|
|
||||||
computer and talks to nothing else but the relay you name. Your
|
|
||||||
identity, too, then belongs to your computer rather than to one
|
|
||||||
browser, so your writing from an earlier lesson is yours from any
|
|
||||||
browser there. A teacher who keeps notebooks of exercises and a
|
|
||||||
notebook per student will want the program; for a first session the
|
|
||||||
web site is enough.
|
|
||||||
|
|
||||||
## Installing the Kokkoja program
|
|
||||||
|
|
||||||
You need:
|
|
||||||
|
|
||||||
* **Linux or macOS.** Windows has not been tried yet.
|
|
||||||
* **Python 3.** Linux has it. On macOS, open Terminal and type
|
|
||||||
`python3 --version`; if Python is not installed, macOS offers to
|
|
||||||
install the command line developer tools, which include it. Any Python
|
|
||||||
from 3.9 on will do.
|
|
||||||
* **A web browser.** Firefox, Brave or Chrome, or Safari on a Mac;
|
|
||||||
other browsers built on the same engines very probably work.
|
|
||||||
* **A pen tablet**, if you want to write by hand. A mouse works for
|
|
||||||
pointing and for rough marks.
|
|
||||||
|
|
||||||
Get Kokkoja from its repository: download
|
|
||||||
|
|
||||||
https://git.andykopra.com/ack/kokkoja-rel/archive/main.zip
|
|
||||||
|
|
||||||
and unpack it, or, if you have git,
|
|
||||||
|
|
||||||
git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja
|
|
||||||
|
|
||||||
Nothing else is installed and nothing is changed on your system. To
|
|
||||||
update later, download again or run `git pull` in that directory.
|
|
||||||
Kokkoja is free software under the GNU General Public License; the
|
|
||||||
`LICENSE.md` file in the directory is the license. To
|
|
||||||
remove Kokkoja, delete the directory; your notebooks are elsewhere and
|
|
||||||
stay.
|
|
||||||
|
|
||||||
### On a Mac
|
|
||||||
|
|
||||||
The same steps, with four things to know:
|
|
||||||
|
|
||||||
* Terminal is in Applications → Utilities. The first `python3` or
|
|
||||||
`git` command may make macOS offer to install the command line
|
|
||||||
developer tools; accept, and run the command again when it has
|
|
||||||
finished.
|
|
||||||
* The first time the program writes a notebook, macOS asks whether
|
|
||||||
Terminal may reach your Documents folder. Allow it, once; the
|
|
||||||
notebooks are there. The answer can be changed later under System
|
|
||||||
Settings → Privacy & Security → Files and Folders.
|
|
||||||
* The program opens Kokkoja in your default browser, Safari included.
|
|
||||||
To use another, start the program with `--no-browser` and open the
|
|
||||||
address it prints in the browser you prefer.
|
|
||||||
* Wherever this documentation says Ctrl for zooming, use Command on a
|
|
||||||
Mac. Both work, so a hand used to Ctrl is not punished.
|
|
||||||
|
|
||||||
## Starting Kokkoja
|
|
||||||
|
|
||||||
Open a terminal in the Kokkoja directory and run
|
|
||||||
|
|
||||||
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
|
|
||||||
|
|
||||||
The program prints an address beginning `http://localhost:8760/` and
|
|
||||||
opens your browser there. Leave the terminal open: the program must run
|
|
||||||
as long as you are in a session. Ctrl-C stops it.
|
|
||||||
|
|
||||||
The address the program prints contains a secret token. Your browser
|
|
||||||
remembers it, so afterwards `http://localhost:8760/` alone brings Kokkoja
|
|
||||||
back; but if the browser ever says that the notebooks are locked, open
|
|
||||||
the full address the program printed. The token is what stops any other
|
|
||||||
web page you might have open from reading your notebooks.
|
|
||||||
|
|
||||||
The relay address names the *Kokkoja server* you use. A server does two
|
|
||||||
things: it passes the participants' messages, encrypted so that it
|
|
||||||
cannot read them, and it serves the web site that your guests join from.
|
|
||||||
The address above is Andy's server; whoever runs a server for you gives
|
|
||||||
you its address. The program remembers it, so from the second time on
|
|
||||||
|
|
||||||
python3 kokkoja.py
|
|
||||||
|
|
||||||
is enough, and the program says which relay it is using. Give the
|
|
||||||
option again only to change servers. Started for the first time with no
|
|
||||||
relay, it stops and asks for one.
|
|
||||||
|
|
||||||
Other options, all optional:
|
|
||||||
|
|
||||||
* `--notebooks DIRECTORY` keeps the notebooks somewhere other than
|
|
||||||
`Kokkoja` in your Documents directory.
|
|
||||||
* `--port NUMBER` uses another port than 8760, if that one is taken.
|
|
||||||
* `--no-browser` does not open a browser; open the printed address
|
|
||||||
yourself.
|
|
||||||
* `--site ADDRESS` names the web site that invitations link to. You
|
|
||||||
need it only if your server's web site is not at the relay's host.
|
|
||||||
* `--trash DIRECTORY` is where deleted notebooks and pages are moved,
|
|
||||||
instead of your system's Trash; `--trash none` deletes them outright.
|
|
||||||
|
|
||||||
## Starting a session
|
|
||||||
|
|
||||||
On the start screen, enter your name and the notebook for this session,
|
|
||||||
and press **Start a session**. You are on page 1, blank, and
|
|
||||||
**Session** in the toolbar shows the invitation.
|
|
||||||
|
|
||||||
### The notebook
|
|
||||||
|
|
||||||
A notebook is a directory under `Kokkoja` in your Documents directory.
|
|
||||||
The start screen lists the notebooks you have; until you make one, there
|
|
||||||
is *General*, the notebook for whatever has no notebook of its own. The
|
|
||||||
last entry of the list, **New notebook…**, asks for a name and makes a
|
|
||||||
notebook of that name, which exists from the moment a page is written
|
|
||||||
into it. For teaching, a notebook per student works well: everything a
|
|
||||||
student is given, with everything they and you wrote on it, gathers in
|
|
||||||
that student's notebook, and the next lesson with that student starts
|
|
||||||
by choosing it again. Your library of exercises can live in notebooks
|
|
||||||
of its own, such as *Exercises A1*, filled by keeping pages in a session
|
|
||||||
of your own with nobody else present.
|
|
||||||
|
|
||||||
The notebook can be changed during a session, in the notebook list in
|
|
||||||
the toolbar. The page you are on then continues in the notebook chosen.
|
|
||||||
|
|
||||||
### The invitation
|
|
||||||
|
|
||||||
**Session** in the toolbar opens a window; **Invitation** there shows a
|
|
||||||
link. **Copy** copies it; send it to
|
|
||||||
your guests by email, in a chat message, or however you reach them. A
|
|
||||||
guest opens it, enters a name and is in. Below the link the same
|
|
||||||
invitation stands as a QR code: a guest with a phone or a tablet points
|
|
||||||
its camera at your screen, and the link opens there. A guest with the Kokkoja
|
|
||||||
program installed may paste it into their own program instead. The link
|
|
||||||
carries the secret that protects the session, so send it only to those
|
|
||||||
who should be there; anyone with the link can join and read the page.
|
|
||||||
Each session you start has a new invitation.
|
|
||||||
|
|
||||||
An invitation is good as long as you keep the session open. A guest can
|
|
||||||
join at any time, see everything written so far, and leave and return.
|
|
||||||
|
|
||||||
## Driving the session
|
|
||||||
|
|
||||||
Three buttons at the right of the toolbar are the host's.
|
|
||||||
|
|
||||||
**New** starts a new blank page for everyone. Pages are numbered from 1
|
|
||||||
through the session. The page being left is kept in every participant's
|
|
||||||
notebook, by itself, under a dated name; nobody has to remember to save.
|
|
||||||
|
|
||||||
**Image** puts an image file on the page for everyone. Kokkoja's own
|
|
||||||
file chooser opens, beginning where your notebooks are and remembering
|
|
||||||
where you last chose from; a directory path can also be typed into it.
|
|
||||||
It shows small pictures of the image files it finds. Choose one and it
|
|
||||||
is placed on the page in one way always: 5 mm below the lowest image
|
|
||||||
there, or at the top of the page if there is none, centred across the
|
|
||||||
page's width, at its natural size, in front of the images already
|
|
||||||
there. Natural size is the size the file records: a scan arrives at
|
|
||||||
the size of the paper it came from. A photo from a phone or camera
|
|
||||||
records no useful size and arrives at a tenth of a millimetre per
|
|
||||||
pixel. An image wider than the page widens it on both sides, and one
|
|
||||||
taller than the page lengthens it downward, so the whole image has room.
|
|
||||||
From there you move and scale it by hand, as described next;
|
|
||||||
there is no other setting to choose. PNG and JPEG files are shared as
|
|
||||||
they are unless very large; other formats, and images over 2400 pixels
|
|
||||||
on a side or 1.5 MB, are converted to JPEG and reduced first, which
|
|
||||||
does not change the size they arrive at. A page holds up to 12 images.
|
|
||||||
|
|
||||||
To move an image, rest the pen on it, without moving, for a little over
|
|
||||||
half a second: a green outline appears, and the image follows the pen
|
|
||||||
until you lift it. It comes to the front of the other images, which a
|
|
||||||
hold without moving also does. To scale an image, rest the pen near one
|
|
||||||
of its corners: a green square marks the corner, and dragging it scales
|
|
||||||
the image along its diagonal, the opposite corner staying put and the
|
|
||||||
shape kept. This works with every tool: a dot the pen may have drawn
|
|
||||||
while resting is removed, and the eraser erases nothing while it rests.
|
|
||||||
Writing is not attached to images: an image moved from under writing
|
|
||||||
leaves the writing where it was, so compose the images first and write
|
|
||||||
afterwards. Only you and co-hosts can move images.
|
|
||||||
|
|
||||||
For a page that begins with an image, press **New** and then **Image**.
|
|
||||||
For a series of exercises, keep pressing **Image** on the same page: the
|
|
||||||
page grows downward and everyone scrolls through them. A new image does
|
|
||||||
not change anyone's zoom; it appears below, to be scrolled to.
|
|
||||||
|
|
||||||
A tap of the eraser on an image removes it, whoever added it and
|
|
||||||
whenever. Writing on it stays where it was, so remove an image before
|
|
||||||
anyone writes on it unless the writing is meant to stay. The page keeps
|
|
||||||
the size the image gave it; **Shrink** trims it. A guest who added an
|
|
||||||
image may remove it, even after they have stopped being a co-host.
|
|
||||||
|
|
||||||
**Fit** and **A4**, pressed by you, act on every participant's screen,
|
|
||||||
each in their own window: the way to bring everyone back to the whole
|
|
||||||
page. A guest's Fit and A4 change only that guest's screen.
|
|
||||||
|
|
||||||
**Hide**, the tool beside the Eraser, covers part of the page for
|
|
||||||
everyone: drag open a rectangle, and whatever lies under it, writing of
|
|
||||||
anyone included, is covered in white, marked by blue corners that tell
|
|
||||||
the students something is there. Use it to cover a solution until the
|
|
||||||
student has tried, or a part of the exercise for later. Rest the pen on
|
|
||||||
it to move it, near a corner to resize it; a tap of the eraser removes
|
|
||||||
it and shows what was covered, and **Undo** takes back any of these as
|
|
||||||
it does your writing. Nothing under a Hide is lost, and a
|
|
||||||
student cannot uncover it by hiding your writing on their screen: a Hide
|
|
||||||
belongs to the page, not to your writing. A Hide is white in an
|
|
||||||
exported picture and in the page file as other programs see it.
|
|
||||||
|
|
||||||
With the Text tool, a rectangle dragged open is a *text rectangle*: a
|
|
||||||
red box with a white ground, which covers the image beneath it and no
|
|
||||||
writing, in which text wraps and which grows downward as it fills. It
|
|
||||||
is the "write your answer here" box of a worksheet: draw it on the scan
|
|
||||||
where the answer belongs, and the student types into their own box or
|
|
||||||
beside yours. Only its owner can type in a box, as with any text.
|
|
||||||
|
|
||||||
**Expand**, among the view buttons, makes the page fill your window for
|
|
||||||
everyone, at your present zoom, in both directions: the quickest way to
|
|
||||||
more room for writing around an exercise. The page grows equally on
|
|
||||||
every side, so nothing on it moves. The page keeps the new size
|
|
||||||
in everyone's notebook. **Shrink** is its opposite: the page becomes
|
|
||||||
just large enough for everything on it, images and writing, with the
|
|
||||||
margin of 5 mm around, which trims a page down to its exercise before you keep
|
|
||||||
or export it. A blank page cannot shrink.
|
|
||||||
|
|
||||||
**Notebooks** starts a new page for everyone from a page kept in one of
|
|
||||||
your notebooks, with everything that was written on it. A chooser shows
|
|
||||||
your notebooks on the left and small pictures of the chosen notebook's
|
|
||||||
kept pages on the right. The loaded page is a copy: the kept page is
|
|
||||||
not changed by anything that happens next. Each layer on it still
|
|
||||||
belongs to the participant who wrote it, so a student's answers from an
|
|
||||||
earlier lesson are still theirs, and your corrections still yours.
|
|
||||||
|
|
||||||
This is also how you return to an earlier page of the session: the page
|
|
||||||
was kept when you left it, and **Notebooks** brings it back as a new page.
|
|
||||||
A lesson might go: **New**, **Image** for the exercise, work, **New**
|
|
||||||
for a point of grammar, work, **Notebooks** to return to the exercise.
|
|
||||||
|
|
||||||
The same chooser deletes. Every page card and every notebook in the
|
|
||||||
list carries a small × at its left; a tap marks it with a tick, a second tap unmarks it,
|
|
||||||
and a marked notebook goes with every page in it. As soon as something
|
|
||||||
is marked, a **Delete** button in the chooser's header says how much:
|
|
||||||
"Delete 3 pages?", "Delete 1 notebook and 3 pages?". The first press
|
|
||||||
says what will happen, "Press again to move 3 pages to the Trash";
|
|
||||||
the second does it. With the Kokkoja program, deleted notebooks and
|
|
||||||
pages are moved to your system's Trash, where the
|
|
||||||
file manager can put them back; the program's `--trash` option
|
|
||||||
names another directory, or `none` to delete outright. In the browser
|
|
||||||
there is no Trash, and the button says "for good". The notebook
|
|
||||||
you are writing in cannot be deleted while you are: choose another
|
|
||||||
notebook in the toolbar first.
|
|
||||||
|
|
||||||
## Co-hosts
|
|
||||||
|
|
||||||
In the **Participants** list of the Session window, each guest's name
|
|
||||||
has a box **Co-host** beside it. Ticking it makes that guest a
|
|
||||||
co-host: they may then start new pages, put images on the page and
|
|
||||||
arrange them, load pages from their notebook, expand, shrink and hide,
|
|
||||||
exactly as you can, and the buttons for it appear on their toolbar.
|
|
||||||
Unticking it takes it back; otherwise it lasts until the session
|
|
||||||
ends. What stays yours alone: making co-hosts, and bringing everyone's
|
|
||||||
view to the page with Fit or A4. Guests, co-hosts or not, can never
|
|
||||||
erase or change anyone's writing but their own; that needs no setting.
|
|
||||||
|
|
||||||
## Hiding writing
|
|
||||||
|
|
||||||
The box in front of each participant's name shows or hides that
|
|
||||||
participant's writing on your screen only. Hide a student's answers to
|
|
||||||
see the exercise clean, hide your own corrections to see what the
|
|
||||||
student sees, show them again; the others notice nothing. **Export**
|
|
||||||
leaves hidden writing out of the picture it makes, which is how to make a
|
|
||||||
picture of the exercise with the corrections alone.
|
|
||||||
|
|
||||||
## Keeping and exporting
|
|
||||||
|
|
||||||
The page you are on is written continuously to `Current.svg` in the
|
|
||||||
current notebook, and copied to a dated file whenever the session moves
|
|
||||||
on. **Keep** makes such a copy now, named for the day and a serial
|
|
||||||
number, such as `2026_10_06_02.svg`; pressing it again before anything
|
|
||||||
has changed makes no second copy, and neither does moving on from a
|
|
||||||
page that is already kept, such as one loaded from the notebook and left
|
|
||||||
as it was. Any mark counts as a change, even a
|
|
||||||
dot from a stray touch of the pen. Use it to mark a state worth keeping
|
|
||||||
on its own, or to add a finished page to a notebook of exercises.
|
|
||||||
|
|
||||||
**Export** saves a picture of the page as you see it, as a PNG file at
|
|
||||||
200 dots per inch, in a directory and under a name you choose in
|
|
||||||
Kokkoja's file chooser. Replacing an existing file takes a second press
|
|
||||||
of the button, so a slip does not overwrite anything.
|
|
||||||
|
|
||||||
The notebooks are ordinary directories, and a page is an ordinary SVG
|
|
||||||
file. Open one in a browser or in Inkscape, attach it to an email, keep
|
|
||||||
it with your other documents. Only Kokkoja can load it as a live page
|
|
||||||
again, with each person's writing still theirs.
|
|
||||||
|
|
||||||
## Ending a session
|
|
||||||
|
|
||||||
Close the browser tab, or stop the program with Ctrl-C in its terminal.
|
|
||||||
There is nothing to save: the page is already in your notebook, and in
|
|
||||||
each guest's. Guests who are still connected can go on writing on the
|
|
||||||
page among themselves, but no new page can be started without you or a
|
|
||||||
co-host, and a guest who arrives after everyone
|
|
||||||
has left finds an empty session. The next lesson is a new session with a
|
|
||||||
new invitation; load the pages you need from the student's notebook.
|
|
||||||
|
|
||||||
## When something goes wrong
|
|
||||||
|
|
||||||
* **The browser says the notebooks are locked.** Open Kokkoja from the
|
|
||||||
full address the program printed when it started, the one with the
|
|
||||||
token.
|
|
||||||
* **"Not connected" above the page.** Kokkoja reconnects by itself as
|
|
||||||
soon as the relay can be reached, and sends what was written meanwhile.
|
|
||||||
If it does not come back, check your connection; the page is safe in
|
|
||||||
your notebook either way.
|
|
||||||
* **A guest sees "Waiting for the host's page".** You have not started
|
|
||||||
a page yet, or your connection is down. The first page is started when
|
|
||||||
you start the session, so this usually means the latter.
|
|
||||||
* **Pressure does not work.** The browser's console (F12) logs what
|
|
||||||
the browser receives from your pen at your first stroke. If it says
|
|
||||||
*mouse*, the browser is not seeing the pen as a pen; try the other
|
|
||||||
browser.
|
|
||||||
* **"Address already in use" when starting the program.** A copy of
|
|
||||||
the program is already running, usually in another terminal window.
|
|
||||||
Only one can run at a time; use it, or stop it there with Ctrl-C and
|
|
||||||
start again.
|
|
||||||
* **Guests cannot connect.** Look at the line `Relay: ...` the program
|
|
||||||
printed when it started: it names the relay the session goes through.
|
|
||||||
If it is not your server's address, stop the program and start it
|
|
||||||
again with `--relay` and the right address.
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
# How Kokkoja works
|
|
||||||
|
|
||||||
This part is for the curious, for anyone who wants to run a Kokkoja
|
|
||||||
server, and for anyone who wants to read or change the code. It
|
|
||||||
describes the parts, what travels between them, what is protected and
|
|
||||||
what is not, and how pages are stored. The code is the final word; it
|
|
||||||
is short and commented.
|
|
||||||
|
|
||||||
## The parts
|
|
||||||
|
|
||||||
Kokkoja is three programs and a few static files.
|
|
||||||
|
|
||||||
* **The client** (`client/`) runs in the web browser and does everything
|
|
||||||
the user sees: the pen, the page, the text, the encryption, the
|
|
||||||
saving. It is plain JavaScript modules, run by the browser as written,
|
|
||||||
with no build step and no third-party packages. Its types are written
|
|
||||||
in JSDoc comments and checked by the TypeScript compiler during
|
|
||||||
development.
|
|
||||||
* **The local program** (`kokkoja.py`) runs on the user's computer. It
|
|
||||||
serves the client to the browser at `http://localhost:8760/`, keeps the
|
|
||||||
user's identity, and reads and writes the notebook directories, which
|
|
||||||
a web page cannot do on its own. It uses nothing but Python's standard
|
|
||||||
library.
|
|
||||||
* **The relay** (`relay/kokkoja_relay.py`) runs on a server that every
|
|
||||||
participant can reach. It passes messages between the members of a
|
|
||||||
session and nothing more: it cannot read them and it stores nothing.
|
|
||||||
It is Python with one library, `websockets`.
|
|
||||||
* **The web site** is the `client/` directory served over HTTPS, with a
|
|
||||||
`config.json` beside it, so that a guest can join, and a host can
|
|
||||||
start a session, without the local program. `deploy/` has the server
|
|
||||||
setup.
|
|
||||||
|
|
||||||
Every participant's computer holds the whole page. Only changes travel:
|
|
||||||
a stroke as it is drawn, a text block as it is typed, an erasure, a new
|
|
||||||
page, an image once. There is no central copy.
|
|
||||||
|
|
||||||
## Why the relay exists
|
|
||||||
|
|
||||||
Two computers behind home routers generally cannot connect to each other
|
|
||||||
directly, so something in between has to pass the messages. The relay is
|
|
||||||
the smallest such thing: a WebSocket server that passes messages among
|
|
||||||
the members of a session. What a user calls a session, the relay calls a
|
|
||||||
*room*: the word appears in the relay's code and log and in the address a
|
|
||||||
client connects to, `wss://<server>/relay/room/<room id>`, and nowhere
|
|
||||||
else. The relay gives each connection a number, tells the others that a
|
|
||||||
peer has arrived, and from then on sends every message it receives from
|
|
||||||
one member to all the others, stamped with the sender's number. When
|
|
||||||
the last member leaves, nothing of the session remains at the relay. It
|
|
||||||
holds at most 32 members per session and 500 sessions, and refuses
|
|
||||||
messages over 1 MB; clients split anything larger.
|
|
||||||
|
|
||||||
## Sessions and encryption
|
|
||||||
|
|
||||||
The invitation is
|
|
||||||
|
|
||||||
<relay address>#<secret>.<host id>
|
|
||||||
|
|
||||||
The *secret* is 16 random bytes, made by the host's browser. From it
|
|
||||||
every client derives two values with HKDF-SHA-256: the *room id*, which
|
|
||||||
names the session at the relay, and the *message key*, a 256-bit AES-GCM
|
|
||||||
key with which every message is encrypted before it is sent and
|
|
||||||
decrypted on arrival. The relay sees the room id, the members' network
|
|
||||||
addresses, and the size and timing of encrypted messages. It cannot
|
|
||||||
compute the key from the room id, so it cannot read anything. Neither
|
|
||||||
can anyone watching the network between the participants and the relay,
|
|
||||||
which is TLS-protected in any case.
|
|
||||||
|
|
||||||
A link invitation wraps the same text:
|
|
||||||
|
|
||||||
https://<site>/#join=<invitation>
|
|
||||||
|
|
||||||
Browsers never send the part of an address after `#` to the site, so the
|
|
||||||
secret stays between the host and the guests. The Session window also
|
|
||||||
shows the link as a QR code, so that a phone or a tablet in the room can
|
|
||||||
join by camera; the encoder is Kokkoja's own (`qr_code.js`: byte mode,
|
|
||||||
error correction level M, any version), checked by the tests against
|
|
||||||
Nayuki's reference encoder.
|
|
||||||
|
|
||||||
The client writes its singular events (pages, images, the connection,
|
|
||||||
the co-host right, the first input report) to the browser's console
|
|
||||||
and keeps them, with a trace of every press, release and cancellation
|
|
||||||
of the pointer (an eraser's release says how many strokes and text
|
|
||||||
blocks it removed), every change of tool and the touch events, in
|
|
||||||
memory (`log.js`, the last 400 entries). The Session window shows the record with a Copy button,
|
|
||||||
so that a guest on a tablet, where there is no console, can send it
|
|
||||||
when the pen misbehaves. The record stays in the browser tab; nothing
|
|
||||||
of it is sent anywhere by Kokkoja.
|
|
||||||
|
|
||||||
## Identity and layers
|
|
||||||
|
|
||||||
Each participant has a signing key pair (ECDSA on P-256), made in the
|
|
||||||
browser the first time and then kept, by the local program in its
|
|
||||||
configuration directory or by the browser in its own storage. The
|
|
||||||
*participant id* is the first 16 bytes of the SHA-256 hash of the public
|
|
||||||
key. When a client connects to a session it announces itself with a signed
|
|
||||||
`hello` carrying its name and public key; the signature covers the room
|
|
||||||
id and the peer number the relay stamped on the message, so nobody can
|
|
||||||
replay it elsewhere or claim another peer's connection. Until a peer has
|
|
||||||
sent a valid `hello`, everything else from it is ignored.
|
|
||||||
|
|
||||||
A page is a stack of layers, one per participant, keyed by participant
|
|
||||||
id. A client accepts a change to a layer only from the participant whose
|
|
||||||
id it is, so the rule "nobody can change anyone else's writing" is not a
|
|
||||||
permission that could be misconfigured but a consequence of how the data
|
|
||||||
is organised. Because the id is derived from the key, and the key is
|
|
||||||
kept, a person is the same participant in every session from the same
|
|
||||||
computer, and their writing on a page loaded weeks later is still theirs.
|
|
||||||
|
|
||||||
The host is whoever holds the key whose id is in the invitation. Pages,
|
|
||||||
images and the list of co-hosts are accepted only
|
|
||||||
from the host (or from an allowed guest, for pages and images).
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
Page coordinates are in units of 0.1 mm. A blank page is A4
|
|
||||||
in landscape, 2970 by 2100 units. "Expand" makes the page fill the
|
|
||||||
window in both directions, and
|
|
||||||
"Shrink" makes it the bounding box of everything on the page plus the
|
|
||||||
greater of the two margins, exactly, on every side; both are changes to the page like adding
|
|
||||||
an image, carried in the page descriptor (its `left` and `top` edges and
|
|
||||||
its size) with the images' revision raised. Writing within 5 mm of an edge grows the page by
|
|
||||||
5 mm on that side, counted from the page's edge. An image is placed at its natural size, taken from
|
|
||||||
the resolution recorded in the file, or 0.1 mm per pixel when there is
|
|
||||||
none (the nominal 72 and 96 dpi that cameras and screenshots write count
|
|
||||||
as none), centred across the page, 5 mm from the top or from the image
|
|
||||||
above, the page widened equally on both sides if the image is wider
|
|
||||||
than it and lengthened downward if the image is taller; only one wider
|
|
||||||
than a page can be, 2 m, is scaled down. A page holds at most 12 images and 24 MB of image data.
|
|
||||||
An image file over 2400 pixels on a side or 1.5 MB, or not PNG or JPEG,
|
|
||||||
is redrawn at most 2400 pixels and stored as JPEG before it is shared;
|
|
||||||
smaller PNG and JPEG files are shared byte for byte.
|
|
||||||
|
|
||||||
A text block is free text, or a *text rectangle* with a width and a
|
|
||||||
height, drawn with a red frame on a white ground; the ground is drawn
|
|
||||||
beneath every layer, so that it hides an image and never anyone's
|
|
||||||
writing, and the frame within the owner's layer. A *Hide* rectangle is
|
|
||||||
a page object like an image, in the page descriptor and the page file,
|
|
||||||
drawn white above every layer with blue corners on screen; made, moved
|
|
||||||
and removed by the host and co-hosts. Both are moved by a hold of
|
|
||||||
the pen and resized from their corners, like images but with their
|
|
||||||
shape free; an image keeps its shape.
|
|
||||||
|
|
||||||
A stroke is a list of points with pressure, and the line width is a
|
|
||||||
function of pressure: nominal at a pressure of 0.4, about 2.6 times that
|
|
||||||
at 0.75. Points are sent while the stroke is drawn, so the others see it
|
|
||||||
grow. A text block is sent whole at every keystroke, with its line
|
|
||||||
breaks already laid out by the writer's browser, so that everyone shows
|
|
||||||
the same lines.
|
|
||||||
|
|
||||||
Each page has a number, counted from 1 in the session, and a random id.
|
|
||||||
A client that joins late asks the longest-connected member for the
|
|
||||||
layers of absent participants, and receives each present participant's
|
|
||||||
layer from that participant. A client that reconnects after a dropped
|
|
||||||
connection sends its whole layer again, so that nothing drawn meanwhile
|
|
||||||
is lost.
|
|
||||||
|
|
||||||
Undo is personal, since nobody can change another's layer, and is a
|
|
||||||
stack of one's own gestures kept in the browser tab for the page being
|
|
||||||
written: a stroke; the strokes and text blocks one movement of the
|
|
||||||
eraser removed, kept whole; a text block as it was before an editing or
|
|
||||||
a move; a Hide rectangle as it was before it was made, moved or removed.
|
|
||||||
Undoing sends the ordinary messages, except that erased strokes come
|
|
||||||
back by a `stroke_restore` message carrying them whole. Images are not
|
|
||||||
in the stack, because their data is not kept. The stack is cleared when
|
|
||||||
the page changes and is not in the page file, so a reloaded tab starts
|
|
||||||
with nothing to undo.
|
|
||||||
|
|
||||||
## The page file
|
|
||||||
|
|
||||||
A notebook is a directory. The page being written is `Current.svg` in
|
|
||||||
it, rewritten a moment after every change (at most every 400 ms while
|
|
||||||
writing continues, at least every 2 s). A *kept* page is a copy of
|
|
||||||
`Current.svg` named `YYYY_MM_DD_NN.svg`, the day being when the page was
|
|
||||||
last changed and NN a serial number within that day. A copy is made when
|
|
||||||
the user asks (**Keep**), and by the program whenever `Current.svg`
|
|
||||||
is about to be given to another page: when the session moves to a new
|
|
||||||
page, when the notebook is changed, and when a session begins and finds
|
|
||||||
a page of another session in the notebook. Nothing is copied if the
|
|
||||||
page is blank or the same page is already kept in that notebook; a
|
|
||||||
page loaded from a notebook and left unchanged is therefore not kept
|
|
||||||
again, although the file names it as a new page. A kept page or a
|
|
||||||
whole notebook can be deleted from the Notebooks chooser, whose
|
|
||||||
tooltips describe each page (from the page file itself, which the
|
|
||||||
chooser fetches for the picture) and each notebook (from the listing,
|
|
||||||
which names the pages and says when each was last written); the local
|
|
||||||
program moves it to the system's Trash (the freedesktop Trash on
|
|
||||||
Linux, with the info file the file manager wants for putting it back;
|
|
||||||
`~/.Trash` on macOS) unless `--trash` says otherwise, and the
|
|
||||||
browser's notebook, having none, deletes for good.
|
|
||||||
|
|
||||||
The file is an SVG document that any viewer displays: the images as
|
|
||||||
embedded `<image>` elements, each layer as a group with the participant's
|
|
||||||
name and id, each stroke as a path and each text block as text.
|
|
||||||
Beside the shapes, the same elements carry Kokkoja's own data in
|
|
||||||
attributes, the exact points and pressures, the text and its breaks, so
|
|
||||||
that Kokkoja reads the page back without loss, with each layer still
|
|
||||||
belonging to its participant. The session id and host id are recorded
|
|
||||||
too, which is how a reloaded tab knows that `Current.svg` is its own page
|
|
||||||
to continue from.
|
|
||||||
|
|
||||||
With the local program, the notebooks are in `Kokkoja` in the Documents
|
|
||||||
directory (`--notebooks` chooses another place), the identity and the
|
|
||||||
browser's token in the configuration directory (`~/.config/kokkoja` on
|
|
||||||
Linux, `~/Library/Application Support/kokkoja` on macOS). In the
|
|
||||||
browser, the notebooks are in the site's IndexedDB under the same names
|
|
||||||
and rules, and the identity in its localStorage; a kept page and an
|
|
||||||
exported picture are offered as downloads, since that is the only way a
|
|
||||||
web page can hand a file to its user.
|
|
||||||
|
|
||||||
## The local program and its token
|
|
||||||
|
|
||||||
The local program listens on 127.0.0.1 only. Any web page open in the
|
|
||||||
browser could nevertheless send requests to `localhost:8760`, so every
|
|
||||||
request that touches files must carry a secret token, which the program
|
|
||||||
makes once, keeps in its configuration directory, and passes to the
|
|
||||||
browser in the address it opens (`#token=...`, which the browser keeps
|
|
||||||
in its local storage and never sends to any server). Requests without
|
|
||||||
the token are refused. The program also checks that requests come from
|
|
||||||
its own origin. Outside the notebooks it reads only image files and
|
|
||||||
writes only PNG files, each where the file chooser was pointed.
|
|
||||||
|
|
||||||
## The web site, and whom you trust
|
|
||||||
|
|
||||||
A client served by a web site is a web application like any other: the
|
|
||||||
site operator's code runs in your browser. The end-to-end encryption
|
|
||||||
protects the session from the relay and the network, but a dishonest
|
|
||||||
site could serve a client that gives the key away. Participants who run
|
|
||||||
the local program load the client from their own disk and are not
|
|
||||||
exposed to that; they trust the code they downloaded once. The site
|
|
||||||
sends a Content Security Policy that lets the client load nothing from
|
|
||||||
anywhere else, which protects against the ordinary web attacks but not
|
|
||||||
against the operator. For a lesson this is the same trust one places in
|
|
||||||
any video call service, and considerably less than in one that sees the
|
|
||||||
content; it is stated here so that nobody is surprised.
|
|
||||||
|
|
||||||
## Running a server
|
|
||||||
|
|
||||||
A server is a machine with a public name, TLS and nginx (or any reverse
|
|
||||||
proxy that passes WebSockets). `deploy/README.md` describes the one
|
|
||||||
Kokkoja comes with: the relay as a systemd service bound to localhost,
|
|
||||||
nginx terminating TLS, serving the client's files at `https://<name>/`
|
|
||||||
and forwarding `wss://<name>/relay/` to the relay, and a `config.json`
|
|
||||||
telling the client which relay to use and that notebooks are kept in the
|
|
||||||
browser. A host uses the server with
|
|
||||||
|
|
||||||
python3 kokkoja.py --relay wss://<name>/relay
|
|
||||||
|
|
||||||
and invitations become links to `https://<name>/`. One server serves
|
|
||||||
any number of hosts and sessions at once; the relay's work per session
|
|
||||||
is passing a few kilobytes per second. Since the relay cannot read what
|
|
||||||
it passes, a server run by someone else is a matter of availability, not
|
|
||||||
of privacy; the web site is the part that requires trust, as described
|
|
||||||
above. A teacher who wants no dependence on anyone's server needs a
|
|
||||||
machine of their own that is reachable from the Internet, which a
|
|
||||||
computer behind a home router is not without arrangement.
|
|
||||||
|
|
||||||
## What is not protected yet
|
|
||||||
|
|
||||||
* Only `hello` is signed. A participant working together with a
|
|
||||||
dishonest relay could forge another participant's strokes. Signing
|
|
||||||
every message, or page descriptors with the host's key, would close
|
|
||||||
this.
|
|
||||||
* A participant can announce a page with a higher number than the
|
|
||||||
host's, and the others move to it.
|
|
||||||
* The relay sees who talks to whom, when, and how much.
|
|
||||||
|
|
||||||
## Pen input
|
|
||||||
|
|
||||||
The client uses the browser's pointer events, which deliver every sample
|
|
||||||
a pen produces, with pressure, in Firefox and in Chromium-based browsers
|
|
||||||
on Linux and macOS. The browser's console shows what arrives. When a
|
|
||||||
pen has been used, touches are ignored, so a hand resting on a touch
|
|
||||||
screen does not draw; this rule has not yet been tested with a device
|
|
||||||
that has both a pen and a touch sensor. Kokkoja opens none of the
|
|
||||||
browser's system dialogs (the colour palette and, with the local
|
|
||||||
program, the file chooser are its own), because one such dialog crashed
|
|
||||||
Firefox on Linux.
|
|
||||||
|
|
||||||
## Adjustable choices
|
|
||||||
|
|
||||||
Some choices of the interface are named constants at the top of the
|
|
||||||
file that uses them, so that they can be changed in one place, or offered
|
|
||||||
as preferences later: the line widths and the five colours (`main.js`),
|
|
||||||
the zoom step of the wheel and the zoom per 100 pixels of a pen drag
|
|
||||||
(`renderer.js`), the dead zone of that drag (`pen_input.js`), the margin
|
|
||||||
by which the page grows (`page_model.js`), the tooltip delay (1 s,
|
|
||||||
`DELAY_MS` in `tooltip.js`) and how long after one tooltip the next
|
|
||||||
button's comes at once (1 s, `WARM_MS` there), the time within which a press after the window comes to the front is taken for the click that raised it and draws nothing (250 ms, `FOCUS_SETTLE_MS` in `pen_input.js`), the hold that moves an image (600 ms, with 6 pixels of
|
|
||||||
slack, `pen_input.js`), the reach of an image's corner (24 pixels,
|
|
||||||
`renderer.js`), the smallest an image can be scaled to (1 cm,
|
|
||||||
`page_model.js`), how often a dragged image's place is sent (40 ms,
|
|
||||||
`session.js`) and the points of a stroke (25 ms, `session.js`), the
|
|
||||||
interval of the eraser's double tap (400 ms, `DOUBLE_TAP_MS` in
|
|
||||||
`main.js`) and how many gestures can be undone (100, `UNDO_LIMIT` in
|
|
||||||
`session.js`), the colour and width of a text rectangle's frame
|
|
||||||
(`page_file.js`) and whether the frame is shown whole or as corners on
|
|
||||||
screen (`TEXT_FRAME_STYLE` in `renderer.js`; the file and the export
|
|
||||||
always hold the whole frame).
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
The code is Python and JavaScript only. The tests run with
|
|
||||||
|
|
||||||
.venv/bin/python -m unittest discover tests -v
|
|
||||||
|
|
||||||
where `tests/test_end_to_end.py` drives a headless Firefox through
|
|
||||||
geckodriver with several participants, with and without the local
|
|
||||||
program, and `npm run check` type-checks the client (the TypeScript
|
|
||||||
compiler is the only development dependency). The relay's `--host` and
|
|
||||||
`--port` and the local program's options are listed by `--help`.
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Kokkoja: an introduction
|
|
||||||
|
|
||||||
Kokkoja lets people at different computers write together on a shared
|
|
||||||
*page*. Everyone in a session sees the same page. Anyone can write on it
|
|
||||||
by hand or type on it, and what one person writes appears on the others'
|
|
||||||
screens as it is written. A page can begin blank, or it can begin with an
|
|
||||||
image, such as a scanned document or a photograph, which everyone then writes
|
|
||||||
on. Every page is saved automatically to a *notebook* on each person's
|
|
||||||
own computer, so that after the session each participant keeps a complete
|
|
||||||
copy of everything that was written.
|
|
||||||
|
|
||||||
Kokkoja runs in a web browser, beside whatever video call you are already
|
|
||||||
using. It replaces the screen sharing and annotation of a video call
|
|
||||||
application with something made for writing together, and it leaves the
|
|
||||||
voice and video to the call.
|
|
||||||
|
|
||||||
## What Kokkoja does
|
|
||||||
|
|
||||||
**A shared page, not a shared screen.** In a video call one person's
|
|
||||||
screen is transmitted as a picture, and annotations float on top of that
|
|
||||||
picture. In Kokkoja every participant's computer holds the page itself:
|
|
||||||
the image, if there is one, and every mark made on it. Only the changes
|
|
||||||
travel over the network. The marks belong to the page, are saved with it,
|
|
||||||
and can be shown or hidden later.
|
|
||||||
|
|
||||||
**Handwriting and typing.** Kokkoja is made for a pen on a drawing
|
|
||||||
tablet, with pressure giving the line its thickness, but a mouse or a
|
|
||||||
finger will do. Typed text is placed anywhere on the page and wraps at a
|
|
||||||
width you choose. Both come in any colour.
|
|
||||||
|
|
||||||
**Everyone writes on their own layer.** Each participant's writing is a
|
|
||||||
layer of its own. You can erase your own writing and nothing else; nobody
|
|
||||||
else can erase yours. Any layer can be hidden on your own screen without
|
|
||||||
affecting anyone else's view.
|
|
||||||
|
|
||||||
**A page that grows.** A page is A4 in size, lying on its side. When writing approaches an
|
|
||||||
edge, the page grows in that direction, so you are never short of room.
|
|
||||||
An image is placed at its natural size; one wider than the page is
|
|
||||||
scaled down to fit, and the page grows to hold an image taller than it
|
|
||||||
or further images below.
|
|
||||||
|
|
||||||
**Saved without a save command.** The page you are on is written to your
|
|
||||||
notebook continuously, as it changes. Nothing is lost if a connection
|
|
||||||
drops or a computer crashes. When the session moves to another page, the
|
|
||||||
page being left is kept as a dated file. You can also keep a dated copy
|
|
||||||
of a page at any moment, and export a picture of it as a PNG file.
|
|
||||||
|
|
||||||
**Notebooks are yours.** A notebook is a directory on your own computer
|
|
||||||
and a page is a single file in it, an ordinary SVG file that any
|
|
||||||
image viewer or vector drawing program can open. Nothing is stored on any
|
|
||||||
server, and there are no accounts: you choose the name the others will
|
|
||||||
see, and that is all.
|
|
||||||
|
|
||||||
**Private.** Everything that travels between participants is encrypted
|
|
||||||
on their own computers. The relay server that passes the messages cannot
|
|
||||||
read them. The invitation to a session carries the key, so only those
|
|
||||||
who were invited can read the page.
|
|
||||||
|
|
||||||
**Nothing to install.** A guest joins by opening a link in a web
|
|
||||||
browser, and a host can start a session from the web site in the same
|
|
||||||
way. Someone who wants a notebook of real files on their own computer,
|
|
||||||
a teacher with a notebook per student for instance, installs the small
|
|
||||||
Kokkoja program instead.
|
|
||||||
|
|
||||||
**Free software.** Kokkoja is free and open-source. Anyone can use it
|
|
||||||
without paying, and nobody depends on a company's service. It runs in
|
|
||||||
Firefox, in browsers built on the Chromium engine, such as Brave or
|
|
||||||
Chrome, and in Safari, on Linux and macOS; a guest can also join from
|
|
||||||
Safari on an iPad or an iPhone, writing with a pencil or a finger.
|
|
||||||
|
|
||||||
## Who uses it
|
|
||||||
|
|
||||||
Kokkoja is a general tool for working on a page together at a distance:
|
|
||||||
going through a document with a colleague, sketching a diagram while
|
|
||||||
talking it over, correcting a draft, teaching anything that is done on
|
|
||||||
paper. Wherever two people would otherwise lean over one sheet of
|
|
||||||
paper, Kokkoja puts that page between their computers.
|
|
||||||
|
|
||||||
Its ideal use, and the one it was made for first, is teaching a foreign
|
|
||||||
language over a video call. The teacher puts a scanned exercise on the
|
|
||||||
page. The student fills it in by hand or by typing, during the lesson,
|
|
||||||
while the teacher watches, corrects mistakes as they are made, and adds
|
|
||||||
notes in the margin. Each correction is in the teacher's own hand and
|
|
||||||
colour, on the teacher's own layer, where the student cannot change it.
|
|
||||||
When the lesson is over, the student's notebook holds every exercise of
|
|
||||||
the lesson, with the student's answers and the teacher's corrections
|
|
||||||
together on one page, and the teacher's notebook holds the same pages as
|
|
||||||
a record of what the student was given. The teacher's notebooks of
|
|
||||||
exercises become a library: loading a page from one of them puts a fresh
|
|
||||||
copy in front of a new student, and the original is never changed.
|
|
||||||
|
|
||||||
## Hosts and guests
|
|
||||||
|
|
||||||
A session has one *host*, the person who starts it, and any number of
|
|
||||||
*guests*. In a lesson the teacher is the host and the students are the
|
|
||||||
guests. The host sends the guests an invitation, by email, in a chat
|
|
||||||
message, or by any other means. The host *drives*: only the host puts
|
|
||||||
images on the page, starts new pages or loads pages from a notebook, and
|
|
||||||
everyone follows. The host can make any guest a *co-host*, with the
|
|
||||||
same powers, for a discussion among colleagues.
|
|
||||||
|
|
||||||
## What runs where
|
|
||||||
|
|
||||||
Three kinds of machine take part, and it helps to have the picture in
|
|
||||||
mind before reading on.
|
|
||||||
|
|
||||||
```
|
|
||||||
Host's computer Server Guests
|
|
||||||
--------------- ------ ------
|
|
||||||
Kokkoja program kokkoja.andykopra.com Guest A: a browser
|
|
||||||
serves the client -+ | opens the link
|
|
||||||
to the browser at | +----+-----+ the server serves
|
|
||||||
localhost:8760 | | relay | <-------------> the client to it;
|
|
||||||
keeps the notebook | | (passes | notebook in the
|
|
||||||
as files | | messages,| browser, downloads
|
|
||||||
| | | reads |
|
|
||||||
browser <-----------+ | nothing) | <-------------> Guest B: has the
|
|
||||||
| +----------+ Kokkoja program too;
|
|
||||||
+---- encrypted ----------^ | joins from its own
|
|
||||||
messages | start screen; note-
|
|
||||||
web site: book as files
|
|
||||||
the client's files
|
|
||||||
```
|
|
||||||
|
|
||||||
* **The host** in the picture runs the small Kokkoja program on their
|
|
||||||
own computer. It shows Kokkoja in the host's browser, at the address
|
|
||||||
`http://localhost:8760/`, and it keeps the host's notebooks as
|
|
||||||
directories of files. It must be running for as long as the host is
|
|
||||||
in a session; the browser alone cannot reach the notebooks. A host
|
|
||||||
can also do without it and start the session from the web site, like
|
|
||||||
guest A, with the notebooks in the browser.
|
|
||||||
|
|
||||||
* **The server** does two things for everyone. Its *relay* passes the
|
|
||||||
participants' messages, which are encrypted before they leave each
|
|
||||||
computer, so the relay reads nothing. Its *web site* serves the
|
|
||||||
Kokkoja client to guests who join by link. The host's program is
|
|
||||||
told the server's address once, when it is started.
|
|
||||||
|
|
||||||
* **A guest** needs only a browser: opening the invitation link fetches
|
|
||||||
the client from the server's web site, and the guest's notebook lives
|
|
||||||
in that browser, with kept pages and exports offered as downloads. A
|
|
||||||
guest who wants a notebook of real files runs the Kokkoja program as
|
|
||||||
the host does, and joins from the program's own start screen.
|
|
||||||
|
|
||||||
Every participant's computer holds the whole page. Only changes travel,
|
|
||||||
through the relay, to everyone else. Nothing is stored on the server.
|
|
||||||
|
|
||||||
The rest of this documentation describes Kokkoja from the guest's point of
|
|
||||||
view first, since the host sees the same screen and has a few more
|
|
||||||
buttons, then from the host's, and finally explains how it works
|
|
||||||
underneath, for those who want to run their own server or read the code.
|
|
||||||
|
|
||||||
* [Kokkoja in five minutes](quickstart.md), the shortest path for
|
|
||||||
someone at home in a terminal
|
|
||||||
* [A guest's Kokkoja](guest.md)
|
|
||||||
* [A host's Kokkoja](host.md)
|
|
||||||
* [How Kokkoja works](implementation.md)
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# Kokkoja in five minutes
|
|
||||||
|
|
||||||
The shortest path from nothing to a shared page, for someone who is at
|
|
||||||
home in a terminal. Everything here is said at more length in [A host's
|
|
||||||
Kokkoja](host.md), [A guest's Kokkoja](guest.md) and [How Kokkoja
|
|
||||||
works](implementation.md).
|
|
||||||
|
|
||||||
## What you are installing
|
|
||||||
|
|
||||||
Kokkoja has three parts. The **client** is plain JavaScript that runs
|
|
||||||
in a browser. The **local program**, `kokkoja.py`, serves the client to
|
|
||||||
your own browser and keeps your notebooks as directories of SVG files;
|
|
||||||
without it the notebooks live in the browser's storage. The **relay**
|
|
||||||
passes the participants' messages, encrypted end to end so that it
|
|
||||||
cannot read them, and serves the client as a web site, from which anyone
|
|
||||||
can host or join with nothing installed; Andy's relay at
|
|
||||||
`kokkoja.andykopra.com` is there for you, and you can run your own.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
* Linux or macOS. Windows is untried.
|
|
||||||
* Python 3. The local program uses the standard library only; nothing
|
|
||||||
is installed with pip.
|
|
||||||
* Firefox, Brave, Chrome or Safari.
|
|
||||||
* A pen tablet, or the pencil of a tablet, for handwriting. A mouse
|
|
||||||
does for pointing and rough marks.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja
|
|
||||||
cd kokkoja
|
|
||||||
|
|
||||||
or download and unpack
|
|
||||||
`https://git.andykopra.com/ack/kokkoja-rel/archive/main.zip`. Nothing
|
|
||||||
else touches your system. Updating is `git pull`; removal is deleting
|
|
||||||
the directory.
|
|
||||||
|
|
||||||
## Host a session
|
|
||||||
|
|
||||||
With nothing installed: open `https://kokkoja.andykopra.com/`, enter a
|
|
||||||
name, press **Start a session**; the notebooks are then in the browser.
|
|
||||||
With the program, for notebooks as files:
|
|
||||||
|
|
||||||
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
|
|
||||||
|
|
||||||
The program prints `http://localhost:8760/#token=…` and opens your
|
|
||||||
browser there. Enter a name, choose a notebook, press **Start a
|
|
||||||
session**. The relay is remembered, so from now on `python3 kokkoja.py`
|
|
||||||
suffices. Keep the terminal open while the session lasts; Ctrl-C ends
|
|
||||||
the program.
|
|
||||||
|
|
||||||
**Session** in the toolbar shows the invitation: a link such as
|
|
||||||
|
|
||||||
https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23<secret>.<host id>
|
|
||||||
|
|
||||||
Send it by any means, or let the other person scan the QR code under it.
|
|
||||||
The part after `#` is never sent to the web site; it carries the
|
|
||||||
session's secret, so treat the link as you would a meeting link.
|
|
||||||
|
|
||||||
## Join a session
|
|
||||||
|
|
||||||
Open the invitation link in a browser. Enter a name and press **Join**.
|
|
||||||
That is all; the page is in front of you. A guest with the local
|
|
||||||
program installed can instead paste the link into their own program's
|
|
||||||
start screen, and then their notebooks are files on their computer
|
|
||||||
rather than in the browser's storage.
|
|
||||||
|
|
||||||
## The screen in one paragraph
|
|
||||||
|
|
||||||
The toolbar holds the pen, text, eraser and hide tools, colours and
|
|
||||||
line widths, the view commands (Fit, A4, Expand, Shrink, Extend), the
|
|
||||||
host's page buttons (New, Image, Notebooks), your notebook, Keep, Export
|
|
||||||
and Session. Rest the pointer on any button for a second for its
|
|
||||||
explanation. Each participant writes on a layer of their own that
|
|
||||||
nobody else can change. The page is saved continuously to the current
|
|
||||||
notebook, `Current.svg`; Keep makes a dated copy. Rest the pen on an
|
|
||||||
image to move it, near a corner to scale it. Ctrl (Command on a Mac)
|
|
||||||
with the wheel, or with a pen drag, zooms.
|
|
||||||
|
|
||||||
## Where things are
|
|
||||||
|
|
||||||
* Notebooks: `~/Documents/Kokkoja/<notebook>/`, one SVG file per page,
|
|
||||||
which any browser displays.
|
|
||||||
* The program's settings, including your identity key and the remembered
|
|
||||||
relay: `~/.config/kokkoja/` on Linux, `~/Library/Application
|
|
||||||
Support/kokkoja/` on macOS.
|
|
||||||
* Options: `--notebooks DIR`, `--port N` (default 8760), `--no-browser`,
|
|
||||||
`--site URL`, `--config DIR`.
|
|
||||||
|
|
||||||
## Your own relay
|
|
||||||
|
|
||||||
python3 -m venv .venv && .venv/bin/pip install -r relay/requirements.txt
|
|
||||||
.venv/bin/python relay/kokkoja_relay.py --host 0.0.0.0 --port 8770
|
|
||||||
|
|
||||||
Then hosts start with `--relay ws://<host>:8770`. For a public server
|
|
||||||
with TLS, the web site and a systemd service, `deploy/` has the nginx
|
|
||||||
and systemd files and `deploy/README.md` the steps. The relay stores
|
|
||||||
nothing and sees only ciphertext.
|
|
||||||
|
|
||||||
## Tests and types
|
|
||||||
|
|
||||||
.venv/bin/pip install -r tests/requirements.txt
|
|
||||||
.venv/bin/python -m unittest discover tests
|
|
||||||
npm install && npm run check
|
|
||||||
|
|
||||||
The end-to-end test needs Firefox and geckodriver; the type check is
|
|
||||||
TypeScript over JSDoc comments and is the only use of npm.
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""The client's reading of Klammertext's character codes (character_codes.js)
|
||||||
|
against the tables `kdesc -c` prints and the rules of the live Klammertext
|
||||||
|
tree, K/mac (character.cpp, katom_process.cpp; K/src is deprecated), as
|
||||||
|
`ktext -s` showed them on 2026-10-13. Needs node (the type checker's).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
CLIENT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "js")
|
||||||
|
|
||||||
|
HARNESS = """
|
||||||
|
import { codeAtEnd } from %s;
|
||||||
|
const texts = JSON.parse(await new Promise((resolve) => {
|
||||||
|
let data = '';
|
||||||
|
process.stdin.on('data', (chunk) => { data += chunk; });
|
||||||
|
process.stdin.on('end', () => resolve(data));
|
||||||
|
}));
|
||||||
|
console.log(JSON.stringify(texts.map((text) => codeAtEnd(text))));
|
||||||
|
"""
|
||||||
|
|
||||||
|
# `kdesc -c`, 2026-10-13: Displayed, Written.
|
||||||
|
DIACRITICS = [("é", "^e'"), ("à", "^a`"), ("ô", "^oh"), ("ñ", "^n~"), ("ü", '^u"'), ("ç", "^cc"),
|
||||||
|
("ō", "^o-"), ("ğ", "^gb"), ("å", "^ar"), ("ė", "^ed"), ("š", "^sw"), ("ő", "^oa")]
|
||||||
|
EXTENDED_LATIN = [("ß", "^s^"), ("ı", "^i^"), ("İ", "^I^"), ("þ", "^t^"), ("Þ", "^T^"), ("ð", "^e^"),
|
||||||
|
("Ð", "^E^"), ("ø", "^o^"), ("Ø", "^O^"), ("đ", "^d^"), ("Đ", "^D^"), ("æ", "^ae^"),
|
||||||
|
("Æ", "^AE^"), ("œ", "^oe^"), ("Œ", "^OE^")]
|
||||||
|
PINYIN = [("ā", "^a1"), ("á", "^a2"), ("ǎ", "^a3"), ("à", "^a4")]
|
||||||
|
|
||||||
|
|
||||||
|
def codes_at_end(texts):
|
||||||
|
harness = HARNESS % json.dumps(os.path.join(CLIENT, "character_codes.js"))
|
||||||
|
result = subprocess.run(["node", "--input-type=module", "-e", harness],
|
||||||
|
input=json.dumps(texts), capture_output=True, text=True, check=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterCodesTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def check(self, cases):
|
||||||
|
"""[(text, expected)]: expected is the character, or None."""
|
||||||
|
results = codes_at_end([text for text, _ in cases])
|
||||||
|
for (text, expected), result in zip(cases, results):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
if expected is None:
|
||||||
|
self.assertIsNone(result)
|
||||||
|
else:
|
||||||
|
self.assertEqual(result, {"length": len(text) - len("Vor "), "character": expected})
|
||||||
|
|
||||||
|
def test_the_tables(self):
|
||||||
|
cases = [("Vor " + written, shown) for shown, written in DIACRITICS + EXTENDED_LATIN + PINYIN]
|
||||||
|
self.check(cases)
|
||||||
|
# Precomposed, one code point each, as the tables display them.
|
||||||
|
for shown, _ in DIACRITICS + EXTENDED_LATIN + PINYIN:
|
||||||
|
self.assertEqual(len(shown), 1)
|
||||||
|
|
||||||
|
def test_other_bases_and_capitals(self):
|
||||||
|
"""The base of a diacritic is a letter of any alphabet, not a
|
||||||
|
digit, a space or ASCII punctuation."""
|
||||||
|
self.check([("Vor ^A'", "Á"), ("Vor ^U\"", "Ü"), ("Vor ^Ss", None), ("Vor ^Sw", "Š"),
|
||||||
|
("Vor ^c'", "ć"), ("Vor ^Ccedilla", None), ("Vor ^e1", "ē"), ("Vor ^u3", "ǔ"),
|
||||||
|
("Vor ^x1", None), ("Vor ^q~", "q̃"), ("Vor ^1~", None), ("Vor ^.~", None),
|
||||||
|
("Vor ^ö-", "\u022B"), ("Vor ^α'", "ά")])
|
||||||
|
|
||||||
|
def test_code_points_and_the_caret(self):
|
||||||
|
self.check([("Vor ^00E9^", "é"), ("Vor ^00e9^", "é"), ("Vor ^20AC^", "€"), ("Vor ^^", "^"),
|
||||||
|
("Vor ^00AE^", "®"), # not ^E^ at its end
|
||||||
|
("Vor ^130ED^", "𓃭"), ("Vor ^1F600^", "😀"), ("Vor ^10FFFD^", "\U0010FFFD"),
|
||||||
|
# 1 to 3 digits are not a code point: a control
|
||||||
|
# character, or a collision with the mnemonics.
|
||||||
|
("Vor ^a^", None), ("Vor ^7^", None), ("Vor ^ae^", "æ"), ("Vor ^0E9^", None),
|
||||||
|
# Not a character: beyond the range, a surrogate, a
|
||||||
|
# noncharacter. Stays as typed (an error in a document).
|
||||||
|
("Vor ^110000^", None), ("Vor ^D800^", None), ("Vor ^FFFE^", None), ("Vor ^FDD0^", None)])
|
||||||
|
|
||||||
|
def test_sequences(self):
|
||||||
|
"""Code points separated by spaces between one pair of carets."""
|
||||||
|
self.check([("Vor ^9053 53EF 9053 975E 5A38 9053^", "道可道非娸道"), ("Vor ^9053 53EF^", "道可"),
|
||||||
|
("Vor ^0041 1F600^", "A😀"),
|
||||||
|
("Vor ^ 9053^", None), ("Vor ^9053 ^", None), # no space inside the carets
|
||||||
|
("Vor ^9053 D800^", None), # one bad member spoils it
|
||||||
|
# A single character stands for itself, as in
|
||||||
|
# Klammertext, once a code point is among the members
|
||||||
|
# (Andy, 2026-10-13); without one, x^2 + y^2 stays.
|
||||||
|
("Vor ^9053 a 53EF^", "道a可"), ("Vor ^a 1F600^", "a😀"), ("Vor ^9053 @^", "道@"),
|
||||||
|
("Vor ^2 + y^", None), ("Vor ^a b^", None), ("Vor ^ab 9053^", None),
|
||||||
|
("Vor ^a^", None)])
|
||||||
|
|
||||||
|
def test_an_unfinished_or_no_code(self):
|
||||||
|
"""A keystroke at a time: nothing until the code is complete, and
|
||||||
|
a caret with something else after it stays."""
|
||||||
|
self.check([("Vor ^", None), ("Vor ^o", None), ("Vor ^ae", None), ("Vor ^00E", None),
|
||||||
|
("Vor ^00E9", None), ("Vor ^ a", None), ("Vor ^o\"a", None), ("Vor 2^3", None),
|
||||||
|
("Vor ^@'", None), ("Vor ", None), ("", None)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1346,6 +1346,51 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
firefox.switch_to(host_tab)
|
firefox.switch_to(host_tab)
|
||||||
firefox.go("about:blank")
|
firefox.go("about:blank")
|
||||||
|
|
||||||
|
def test_character_codes_in_text(self):
|
||||||
|
"""Andy, 2026-10-13: Klammertext's character codes in a text
|
||||||
|
block, replaced as each is completed; the field's own undo
|
||||||
|
survives a replacement."""
|
||||||
|
firefox = self.firefox
|
||||||
|
host_tab = firefox.current_tab()
|
||||||
|
firefox.go(self.address("Late"))
|
||||||
|
self.enter_name("Typist", "start_session")
|
||||||
|
invitation = firefox.run("return document.getElementById('invitation').value;")
|
||||||
|
guest_tab = firefox.new_tab()
|
||||||
|
firefox.go(self.address("Student", join=invitation))
|
||||||
|
self.enter_name("Onlooker", "join_session")
|
||||||
|
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the onlooker")
|
||||||
|
firefox.switch_to(host_tab)
|
||||||
|
field_text = lambda: firefox.run("return document.querySelector('.text_field').value;")
|
||||||
|
|
||||||
|
firefox.run("document.querySelector('[data-tool=text]').click();")
|
||||||
|
firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3)
|
||||||
|
firefox.type_into(".text_field", 'Gr^u"^s^e, ^a` 2^^3, ^OE^uvre, ^00E9^, m^a1, ^130ED^ ^9053 53EF^')
|
||||||
|
self.assertEqual(field_text(), "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
|
||||||
|
self.wait_until(lambda s: s["texts"]["Typist"] and s["texts"]["Typist"][0]["text"] == "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可",
|
||||||
|
"the text with its codes replaced")
|
||||||
|
# The caret counts UTF-16 units, so the hieroglyph counts twice.
|
||||||
|
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"),
|
||||||
|
len("Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可".encode("utf-16-le")) // 2)
|
||||||
|
|
||||||
|
# In the middle of the text too, and only at the caret: the ^
|
||||||
|
# left earlier stays.
|
||||||
|
firefox.run("const f = document.querySelector('.text_field'); f.setSelectionRange(2, 2);")
|
||||||
|
firefox.type_into(".text_field", "^o-")
|
||||||
|
self.assertEqual(field_text(), "Grōüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
|
||||||
|
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), 3)
|
||||||
|
|
||||||
|
# The browser's own undo in the field is not lost by a replacement:
|
||||||
|
# one step gives back the code as typed.
|
||||||
|
firefox.chord("\ue009", "z")
|
||||||
|
self.assertEqual(field_text(), "Gr^o-üße, à 2^3, Œuvre, é, mā, 𓃭 道可")
|
||||||
|
|
||||||
|
firefox.run("document.querySelector('[data-tool=pen]').click();")
|
||||||
|
shown = self.wait_until(lambda s: s["texts"]["Typist"] and "üße" in s["texts"]["Typist"][0]["text"],
|
||||||
|
"the finished block")["texts"]["Typist"][0]["text"]
|
||||||
|
firefox.switch_to(guest_tab)
|
||||||
|
self.wait_until(lambda s: s["texts"].get("Typist") and s["texts"]["Typist"][0]["text"] == shown,
|
||||||
|
"the onlooker to see the same text")
|
||||||
|
|
||||||
def test_text_being_typed_stays_on_the_page_left(self):
|
def test_text_being_typed_stays_on_the_page_left(self):
|
||||||
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
||||||
and the text reappeared on the new page."""
|
and the text reappeared on the new page."""
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class QrCodeTest(unittest.TestCase):
|
|||||||
self.assertEqual(matrix, expected)
|
self.assertEqual(matrix, expected)
|
||||||
|
|
||||||
def test_invitation_link(self):
|
def test_invitation_link(self):
|
||||||
link = ("https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23"
|
link = ("https://app.kokkoja.com/#join=wss://app.kokkoja.com/relay%23"
|
||||||
"AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
"AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
||||||
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
|
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
|
||||||
("Übung 1: äöü", "M")])
|
("Übung 1: äöü", "M")])
|
||||||
|
|||||||
Reference in new issue
Block a user