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 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.
|
||||
|
||||
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
|
||||
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.
|
||||
*/
|
||||
|
||||
import { codeAtEnd } from './character_codes.js';
|
||||
import { randomId } from './crypto.js';
|
||||
import { textBounds } from './page_model.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.spellcheck = false;
|
||||
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.field.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -231,6 +238,30 @@ export class TextEditor {
|
||||
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. */
|
||||
changed() {
|
||||
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
|
||||
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.
|
||||
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 |
|
||||
|----------------------------|-------------------------------------------------|
|
||||
@@ -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 |
|
||||
|
||||
The client's `config.json` on the site says
|
||||
`{"relay": "wss://kokkoja.andykopra.com/relay", "storage": "browser",
|
||||
"site": "https://kokkoja.andykopra.com/"}`: the notebooks of anyone using
|
||||
`{"relay": "wss://app.kokkoja.com/relay", "storage": "browser",
|
||||
"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 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.
|
||||
|
||||
## Setting up or updating
|
||||
@@ -38,14 +39,16 @@ denied (publickey)".
|
||||
|
||||
Steps a person does, once:
|
||||
|
||||
1. DNS: an A record `kokkoja.andykopra.com` pointing at the VPS
|
||||
(82.221.141.89), in cPanel's Zone Editor, since andykopra.com is on
|
||||
the orangewebsite.com nameservers. Check with
|
||||
`host kokkoja.andykopra.com buffy.ns.orangewebsite.com`.
|
||||
1. DNS: an A record `app.kokkoja.com` pointing at the VPS
|
||||
(82.221.141.89), wherever kokkoja.com's DNS is managed (its registrar,
|
||||
or the orangewebsite.com nameservers if delegated there). The older
|
||||
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:
|
||||
|
||||
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
|
||||
HTTP block into a redirect. Afterwards `setup_vps.sh` leaves the
|
||||
@@ -57,7 +60,7 @@ Steps a person does, once:
|
||||
|
||||
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;
|
||||
`journalctl -u kokkoja-relay -n 20` shows it listening.
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net}
|
||||
site=https://kokkoja.andykopra.com/
|
||||
relay=wss://kokkoja.andykopra.com/relay
|
||||
site=https://app.kokkoja.com/
|
||||
relay=wss://app.kokkoja.com/relay
|
||||
|
||||
staging=$(mktemp -d)
|
||||
trap 'rm -rf "$staging"' EXIT
|
||||
|
||||
+21
-5
@@ -1,13 +1,14 @@
|
||||
# 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.
|
||||
#
|
||||
# This is the plain HTTP form. certbot --nginx -d kokkoja.andykopra.com
|
||||
# adds the TLS listener and turns this block into a redirect.
|
||||
# This is the plain HTTP form. certbot --nginx -d app.kokkoja.com -d
|
||||
# kokkoja.andykopra.com adds the TLS listeners and turns the HTTP blocks
|
||||
# into redirects.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name kokkoja.andykopra.com;
|
||||
server_name app.kokkoja.com;
|
||||
|
||||
root /var/www/kokkoja;
|
||||
index index.html;
|
||||
@@ -33,7 +34,22 @@ server {
|
||||
# browsers ask whether they have changed rather than keeping old ones.
|
||||
add_header Cache-Control "no-cache" always;
|
||||
# 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 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
|
||||
# machine (see deploy/README.md):
|
||||
# certbot --nginx -d kokkoja.andykopra.com
|
||||
# certbot --nginx -d app.kokkoja.com -d kokkoja.andykopra.com
|
||||
|
||||
set -euo pipefail
|
||||
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.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):
|
||||
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
||||
and the text reappeared on the new page."""
|
||||
|
||||
@@ -64,7 +64,7 @@ class QrCodeTest(unittest.TestCase):
|
||||
self.assertEqual(matrix, expected)
|
||||
|
||||
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")
|
||||
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
|
||||
("Übung 1: äöü", "M")])
|
||||
|
||||
Reference in new issue
Block a user