pendify 0.1.5__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pendify-0.1.5/src/pendify.egg-info → pendify-0.1.6}/PKG-INFO +3 -3
- {pendify-0.1.5 → pendify-0.1.6}/README.md +2 -2
- {pendify-0.1.5 → pendify-0.1.6}/pyproject.toml +1 -1
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/__main__.py +93 -5
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/page.py +60 -11
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/qr.py +129 -37
- pendify-0.1.6/src/pendify/runfile.py +501 -0
- {pendify-0.1.5 → pendify-0.1.6/src/pendify.egg-info}/PKG-INFO +3 -3
- pendify-0.1.5/src/pendify/runfile.py +0 -246
- {pendify-0.1.5 → pendify-0.1.6}/LICENSE +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/MANIFEST.in +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/setup.cfg +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/__init__.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/alert.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/autostart.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/client.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/codes.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/config.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/icon.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/pairing.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/pendify.ico +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/plate_almena.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/tray.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/update.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/watcher.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify/worker.py +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify.egg-info/SOURCES.txt +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify.egg-info/dependency_links.txt +0 -0
- {pendify-0.1.5 → pendify-0.1.6}/src/pendify.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pendify
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Pairs this PC with a phone account by a QR on a loopback page and sends it two kinds of alerts.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ElkinDev/PendiFy
|
|
@@ -28,7 +28,7 @@ Pulsa Windows + R, pega esta línea completa y pulsa Enter. También sirve en Po
|
|
|
28
28
|
|
|
29
29
|
powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
|
|
30
30
|
|
|
31
|
-
La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine.
|
|
31
|
+
La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine. Si PendiFy ya está abierto, la línea instala la versión nueva y la copia nueva reemplaza a la anterior por sí sola.
|
|
32
32
|
|
|
33
33
|
La ventana queda abierta al terminar para que leas el resultado; ciérrala cuando acabe.
|
|
34
34
|
|
|
@@ -93,7 +93,7 @@ Press Windows + R, paste this whole line and press Enter. It also works in Power
|
|
|
93
93
|
|
|
94
94
|
powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
|
|
95
95
|
|
|
96
|
-
The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done.
|
|
96
|
+
The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done. If PendiFy is already open, the line installs the new version and the new copy replaces the older one by itself.
|
|
97
97
|
|
|
98
98
|
The window stays open at the end so you can read the result; close it when it is done.
|
|
99
99
|
|
|
@@ -12,7 +12,7 @@ Pulsa Windows + R, pega esta línea completa y pulsa Enter. También sirve en Po
|
|
|
12
12
|
|
|
13
13
|
powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
|
|
14
14
|
|
|
15
|
-
La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine.
|
|
15
|
+
La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine. Si PendiFy ya está abierto, la línea instala la versión nueva y la copia nueva reemplaza a la anterior por sí sola.
|
|
16
16
|
|
|
17
17
|
La ventana queda abierta al terminar para que leas el resultado; ciérrala cuando acabe.
|
|
18
18
|
|
|
@@ -77,7 +77,7 @@ Press Windows + R, paste this whole line and press Enter. It also works in Power
|
|
|
77
77
|
|
|
78
78
|
powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
|
|
79
79
|
|
|
80
|
-
The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done.
|
|
80
|
+
The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done. If PendiFy is already open, the line installs the new version and the new copy replaces the older one by itself.
|
|
81
81
|
|
|
82
82
|
The window stays open at the end so you can read the result; close it when it is done.
|
|
83
83
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pendify"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "Pairs this PC with a phone account by a QR on a loopback page and sends it two kinds of alerts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
The page and the watcher stop together on Ctrl+C, on Ctrl+Break, on the page's quit button and on the quit of
|
|
11
11
|
the icon by the clock, which remove the run file and exit 0. One instance per config folder: a second start
|
|
12
12
|
opens the running page and exits 0, and one made while that program is still closing after its quit waits for
|
|
13
|
-
it to end and starts. A
|
|
13
|
+
it to end and starts. A start of a newer version over a running older copy replaces it: the older copy is asked to
|
|
14
|
+
stop through its page's /replace with the run file's secret, or ended by its pid when it has no secret or its page
|
|
15
|
+
does not answer, and this start then claims the run file as a first one does. A restart the page asks for
|
|
16
|
+
(«Reiniciar ahora», once a newer version is installed, or the
|
|
14
17
|
icon's «Reiniciar para actualizar») starts a new copy once the run file is released: without --quiet, so its page
|
|
15
18
|
opens, and with the --dry, --data-dir, --worker and --client-lockfile of this one.
|
|
16
19
|
Where no console is attached (a pythonw start), the line a start ends on is also shown in a
|
|
@@ -18,12 +21,15 @@ message box. `--data-dir`, `--worker` and `--client-lockfile` are the
|
|
|
18
21
|
test overrides, loopback only, so a test run reads neither the profile, the Worker nor the client's files.
|
|
19
22
|
"""
|
|
20
23
|
import argparse
|
|
24
|
+
import http.client
|
|
21
25
|
import os
|
|
22
26
|
import signal
|
|
23
27
|
import subprocess
|
|
24
28
|
import sys
|
|
25
29
|
import threading
|
|
26
30
|
import time
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
27
33
|
import webbrowser
|
|
28
34
|
from pathlib import Path
|
|
29
35
|
|
|
@@ -46,6 +52,16 @@ PAGE_NOT_KNOWN_LINE = ("already running: the page of the program that runs is no
|
|
|
46
52
|
CLAIM_FAILED_LINE = "cannot start: the run file cannot be replaced: {path}"
|
|
47
53
|
FOLDER_FAILED_LINE = "cannot start: the config folder cannot be written: {path}"
|
|
48
54
|
RESTART_FAILED_LINE = "cannot restart: the new copy did not start; start the program again"
|
|
55
|
+
# The line a start that replaced an older copy shows, in the page's language: the older copy's version, «desconocida»
|
|
56
|
+
# or "unknown" for a record with none, then this one's.
|
|
57
|
+
REPLACED_LINES = {"es": "Se reemplazó la copia anterior ({old}) por esta ({new}).",
|
|
58
|
+
"en": "The older copy ({old}) was replaced by this one ({new})."}
|
|
59
|
+
UNKNOWN_VERSION = {"es": "desconocida", "en": "unknown"}
|
|
60
|
+
# The line a start prints when the older copy it would replace cannot be ended, in the page's language, with the
|
|
61
|
+
# system's reason.
|
|
62
|
+
CLOSE_REFUSED_LINES = {"es": "No se pudo cerrar la copia anterior: {reason}",
|
|
63
|
+
"en": "The older copy could not be closed: {reason}"}
|
|
64
|
+
REPLACE_TIMEOUT_SECONDS = 5.0 # the replace post to the older copy's page
|
|
49
65
|
_MB_ICONINFORMATION = 0x40
|
|
50
66
|
_MB_SETFOREGROUND = 0x10000
|
|
51
67
|
# The restart's new copy: no console, its own process group, out of this one's.
|
|
@@ -181,6 +197,65 @@ def _restart(args, spawn, show, opens_page):
|
|
|
181
197
|
return 0
|
|
182
198
|
|
|
183
199
|
|
|
200
|
+
def _replaces(holder):
|
|
201
|
+
"""True when this copy is newer than `holder`, a live holder that is not closing: X.Y.Z compared as tuples of
|
|
202
|
+
ints, and a holder with no version, as an older copy writes its record, older than any. A copy whose own version
|
|
203
|
+
is not X.Y.Z replaces nothing."""
|
|
204
|
+
if update._parts(update.RUNNING_VERSION) is None:
|
|
205
|
+
return False
|
|
206
|
+
return holder["version"] is None or update.newer(update.RUNNING_VERSION, holder["version"])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ask_replace(holder):
|
|
210
|
+
"""True when the holder's page answered 202 to POST /replace with the run file's secret; False for any other
|
|
211
|
+
answer, none, or no port."""
|
|
212
|
+
if holder["port"] is None:
|
|
213
|
+
return False
|
|
214
|
+
request = urllib.request.Request(f"http://{page.ADDRESS}:{holder['port']}/replace", method="POST",
|
|
215
|
+
data=urllib.parse.urlencode({"secret": holder["secret"]}).encode())
|
|
216
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # a loopback address never goes through one
|
|
217
|
+
try:
|
|
218
|
+
with opener.open(request, timeout=REPLACE_TIMEOUT_SECONDS) as answer:
|
|
219
|
+
return answer.status == 202
|
|
220
|
+
except (OSError, http.client.HTTPException): # a refusal (urllib.error.HTTPError), no answer in time, a broken one
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _kept_language(store):
|
|
225
|
+
"""The page's language before the page is made, with no browser to ask: the kept choice, else Spanish, as the
|
|
226
|
+
icon's words. A config file that cannot be read gives Spanish: the choice only words one line."""
|
|
227
|
+
try:
|
|
228
|
+
return store.read_lang() or page.language(None)
|
|
229
|
+
except (OSError, ValueError):
|
|
230
|
+
return page.language(None)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _replace(run, holder, terminate, store):
|
|
234
|
+
"""The older `holder` dealt with, then the run file claimed again: (the claim's answer, `holder` when this start
|
|
235
|
+
replaced it, else None). It is asked through its page's /replace; with no secret in its record or no 202 from its
|
|
236
|
+
page, `terminate` is asked to end it by its pid, which it does only for the process its record names
|
|
237
|
+
(runfile.terminate):
|
|
238
|
+
- a 202, or ended: once the run file no longer names it as a live process within CLOSING_WAIT_SECONDS, its file
|
|
239
|
+
gone or left stale by its death, the file is claimed; a holder still alive then is handed back as it was read;
|
|
240
|
+
- not-ours: the pid names another process now, so the file is stale: evicted while it still holds that record,
|
|
241
|
+
then claimed as a first start claims it;
|
|
242
|
+
- refused: the reason printed on one line and the file waited for as long, since the pid may be dying under
|
|
243
|
+
another start's terminate, then claimed again: a holder that still stands is read afresh, never the snapshot."""
|
|
244
|
+
if holder["secret"] is None or not _ask_replace(holder):
|
|
245
|
+
answer = terminate(holder["pid"], holder["started"], run.path)
|
|
246
|
+
if answer == runfile.NOT_OURS:
|
|
247
|
+
run.evict(holder)
|
|
248
|
+
return run.claim(), holder
|
|
249
|
+
if answer == runfile.REFUSED:
|
|
250
|
+
reason = answer.error.strerror or answer.error
|
|
251
|
+
print(CLOSE_REFUSED_LINES[_kept_language(store)].format(reason=reason), flush=True)
|
|
252
|
+
run.wait_released(holder, CLOSING_WAIT_SECONDS)
|
|
253
|
+
return run.claim(), None
|
|
254
|
+
if run.wait_released(holder, CLOSING_WAIT_SECONDS):
|
|
255
|
+
return run.claim(), holder
|
|
256
|
+
return holder, None
|
|
257
|
+
|
|
258
|
+
|
|
184
259
|
def _ready_version(updater):
|
|
185
260
|
"""The version an install made ready, for the icon's item; None in any other state."""
|
|
186
261
|
seen = updater.snapshot()
|
|
@@ -188,14 +263,19 @@ def _ready_version(updater):
|
|
|
188
263
|
|
|
189
264
|
|
|
190
265
|
def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart, tray, updater,
|
|
191
|
-
spawn):
|
|
266
|
+
spawn, terminate):
|
|
192
267
|
# A quiet start is the system's, at logon: it never opens the browser, and a start that ends well shows no box.
|
|
193
268
|
calm = (lambda line: None) if args.quiet else show
|
|
194
269
|
run = runfile.RunFile(store.path.parent, clock=clock, sleep=sleep)
|
|
270
|
+
replaced = None # the record of the older copy this start replaced
|
|
195
271
|
try:
|
|
196
272
|
holder = run.claim()
|
|
197
273
|
if holder is not None and holder["closing"] and run.wait_released(holder, CLOSING_WAIT_SECONDS):
|
|
198
274
|
holder = run.claim() # the closing copy let its file go: this start claims it as a first one does
|
|
275
|
+
# A running older copy is replaced (_replace); a holder that still stands after it keeps the road below,
|
|
276
|
+
# its page opened.
|
|
277
|
+
if holder is not None and not holder["closing"] and _replaces(holder):
|
|
278
|
+
holder, replaced = _replace(run, holder, terminate, store)
|
|
199
279
|
except runfile.FolderNotWritable as refused:
|
|
200
280
|
return _ends(FOLDER_FAILED_LINE.format(path=refused.filename), 1, show)
|
|
201
281
|
except OSError:
|
|
@@ -231,6 +311,7 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
|
|
|
231
311
|
|
|
232
312
|
pairing_page = page.PairingPage(state, watch=watch.snapshot, on_quit=quit_page, on_pause=watch.pause,
|
|
233
313
|
on_resume=watch.resume, autostart=autostart, events=watch.events)
|
|
314
|
+
pairing_page.run = run # its /replace answers the secret the publish below writes
|
|
234
315
|
url = pairing_page.start()
|
|
235
316
|
# The update's rounds on their own thread (update.py), built once the page serves; its restart is the
|
|
236
317
|
# page's quit, after the flag read below once the run file is free; pip's output goes to the config folder.
|
|
@@ -249,6 +330,12 @@ def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, s
|
|
|
249
330
|
if mode != update.OFF:
|
|
250
331
|
updating.start()
|
|
251
332
|
run.publish(pairing_page.port)
|
|
333
|
+
if replaced is not None: # in the page's language, as the icon's words; a box never holds the start
|
|
334
|
+
lang = pairing_page.language_of(None)
|
|
335
|
+
line = REPLACED_LINES[lang].format(old=replaced["version"] or UNKNOWN_VERSION[lang],
|
|
336
|
+
new=update.RUNNING_VERSION)
|
|
337
|
+
print(line, flush=True)
|
|
338
|
+
threading.Thread(target=calm, args=(line,), daemon=True).start()
|
|
252
339
|
print(f"page: {url}", flush=True)
|
|
253
340
|
if not args.quiet and not opener(url): # a start by a person opens the page, linked or not
|
|
254
341
|
print("open the address above in a browser", flush=True)
|
|
@@ -292,13 +379,14 @@ def _break_as_interrupt():
|
|
|
292
379
|
|
|
293
380
|
def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT_SECONDS, delay=None,
|
|
294
381
|
beep=alert.beep, box=_message_box, autostart=None, console=_console_attached, clock=time.monotonic,
|
|
295
|
-
sleep=time.sleep, tray=Tray, updater=update.Updater, spawn=subprocess.Popen):
|
|
382
|
+
sleep=time.sleep, tray=Tray, updater=update.Updater, spawn=subprocess.Popen, terminate=runfile.terminate):
|
|
296
383
|
"""`beep`, `box` and `autostart` are the seams of the sound, of the message box and of the start with Windows
|
|
297
384
|
(the per-user Run value when None): a test run passes silent ones and a fake. `console` says whether a printed
|
|
298
385
|
line reaches anybody; `clock` and `sleep` time the run file's re-reads. `tray` makes the icon by the clock, the
|
|
299
386
|
seam a test run fills with a fake, so no test shows a real icon. `updater` makes the update and `spawn` starts
|
|
300
387
|
the restart's new copy, the seams a test run fills with fakes, so no test reaches PyPI, runs pip or starts a
|
|
301
|
-
copy.
|
|
388
|
+
copy. `terminate` ends an older copy by its pid when it cannot be asked to stop, the seam every test run fakes, so
|
|
389
|
+
no test ends a process."""
|
|
302
390
|
args = _arguments(sys.argv[1:] if argv is None else argv)
|
|
303
391
|
store = config.ConfigStore(getattr(args, "data_dir", None) or config.default_base_dir())
|
|
304
392
|
base = getattr(args, "worker", worker.BASE_URL)
|
|
@@ -314,7 +402,7 @@ def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT
|
|
|
314
402
|
try:
|
|
315
403
|
return _serve(args, store, base, timeout, opener, stop or threading.Event(),
|
|
316
404
|
delay or watcher.accept_delay, beep, show, clock, sleep,
|
|
317
|
-
autostart if autostart is not None else Autostart(), tray, updater, spawn)
|
|
405
|
+
autostart if autostart is not None else Autostart(), tray, updater, spawn, terminate)
|
|
318
406
|
finally:
|
|
319
407
|
if previous is not None:
|
|
320
408
|
signal.signal(signal.SIGBREAK, previous)
|
|
@@ -8,6 +8,7 @@ nothing from any origin; no request line and no body is ever logged.
|
|
|
8
8
|
import hmac
|
|
9
9
|
import html
|
|
10
10
|
import json
|
|
11
|
+
import random
|
|
11
12
|
import secrets
|
|
12
13
|
import sys
|
|
13
14
|
import threading
|
|
@@ -49,9 +50,9 @@ WORDS = {
|
|
|
49
50
|
"intro": "Este programa envía sus avisos a tu cuenta de Pendi. Para eso, este PC tiene que quedar enlazado "
|
|
50
51
|
"con tu cuenta.",
|
|
51
52
|
"code_label": "Clave de este PC:",
|
|
52
|
-
"qr_for": "
|
|
53
|
-
"
|
|
54
|
-
"
|
|
53
|
+
"qr_for": "ESCANEA ESTE CÓDIGO QR con tu teléfono para recibir notificaciones cuando empiece la partida y "
|
|
54
|
+
"se acepte la cola.",
|
|
55
|
+
"qr_press": "Pulsa Mostrar el código",
|
|
55
56
|
"show_code": "Mostrar el código",
|
|
56
57
|
"hide_code": "Ocultar el código",
|
|
57
58
|
"mask": "Clave oculta",
|
|
@@ -141,9 +142,9 @@ WORDS = {
|
|
|
141
142
|
"intro": "This program sends its alerts to your Pendi account. For that, this PC has to be linked to your "
|
|
142
143
|
"account.",
|
|
143
144
|
"code_label": "This PC's key:",
|
|
144
|
-
"qr_for": "
|
|
145
|
-
"
|
|
146
|
-
"
|
|
145
|
+
"qr_for": "SCAN THIS QR CODE with your phone to get notifications when the match starts and the queue is "
|
|
146
|
+
"accepted.",
|
|
147
|
+
"qr_press": "Press Show the code",
|
|
147
148
|
"show_code": "Show the code",
|
|
148
149
|
"hide_code": "Hide the code",
|
|
149
150
|
"mask": "Key hidden",
|
|
@@ -332,6 +333,9 @@ _STYLE = (":root{color-scheme:light dark;" + _LIGHT + "}\n"
|
|
|
332
333
|
"button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}\n"
|
|
333
334
|
"form[action='/check'] button,form[action='/relink'] button{width:100%;background:var(--brand);"
|
|
334
335
|
"color:var(--on-brand)}\n"
|
|
336
|
+
# The form under the QR and the linked page's relink form keep the card «Actividad»'s rhythm (OR-106): from
|
|
337
|
+
# 880 px the key's card leaves for its column, and form{margin:0} alone left each flush under «Este PC».
|
|
338
|
+
"form[action='/check'],form[action='/relink']{margin-top:16px}\n"
|
|
335
339
|
"form[action='/forget'] button{background:transparent;border-color:var(--line);color:var(--danger)}\n"
|
|
336
340
|
"form[action='/quit'] button{padding-inline:16px;background:transparent;color:var(--ink2)}\n"
|
|
337
341
|
".more{display:grid;gap:16px;margin-top:32px}\n"
|
|
@@ -424,6 +428,18 @@ _STYLE += ("@media (min-width:880px){.page{max-width:max(1040px,85vw)}\n"
|
|
|
424
428
|
'.key-card:has(.key[data-shown="false"]) .modules{visibility:hidden}\n'
|
|
425
429
|
'.key-card:has(.key[data-shown="true"]) .qr-mask{visibility:hidden}\n'
|
|
426
430
|
".qr-for{margin:0}\n"
|
|
431
|
+
# The label with impact (OR-105, the pfmask mockup): the #state linked block's recipe, stretched to the
|
|
432
|
+
# card, the owner's leading capitals as its lead line and the rest under it. The masks over the QR (OR-104,
|
|
433
|
+
# qr.MASKS): the board's two tokens, and the instruction as plain text in the outline token over a halo in
|
|
434
|
+
# the masks' own ground, qr.GROUND, with no plate.
|
|
435
|
+
".key-card .qr-for{justify-self:stretch;padding:16px 20px;border-radius:12px;background:var(--tonal);"
|
|
436
|
+
"color:var(--on-tonal);font-size:16px;line-height:24px;font-weight:500}\n"
|
|
437
|
+
".key-card .qr-for b{display:block;margin:0 0 2px;font-size:20px;line-height:28px;font-weight:600;"
|
|
438
|
+
"letter-spacing:.02em}\n"
|
|
439
|
+
".qm-1{fill:var(--brand)}.qm-2{fill:var(--tonal)}\n"
|
|
440
|
+
f".qm-say{{fill:var(--px-line);stroke:{qr.GROUND};stroke-width:10px;stroke-linejoin:round;"
|
|
441
|
+
"paint-order:stroke;font:600 28px system-ui,sans-serif;text-anchor:middle;dominant-baseline:central}"
|
|
442
|
+
".qm-board{font-size:32px}\n"
|
|
427
443
|
".mask{display:flex;align-items:flex-end;gap:10px}\n"
|
|
428
444
|
".mask .grp{display:flex;align-items:flex-end;gap:2px}\n"
|
|
429
445
|
".mask svg{display:block;flex:none}\n"
|
|
@@ -670,6 +686,19 @@ _NOSCRIPT_STYLE = ('.key[data-shown="false"] .code,.key-card:has(.key[data-shown
|
|
|
670
686
|
'{visibility:visible}.mask,.qr-mask,.reveal{display:none}')
|
|
671
687
|
|
|
672
688
|
|
|
689
|
+
def _lead(sentence):
|
|
690
|
+
"""The QR label's leading capitals and the rest (OR-105): the words before the first one that holds a lower-case
|
|
691
|
+
letter, and the words from it. The tables keep the owner's sentence as one string; the page draws the capitals
|
|
692
|
+
as the label's lead line."""
|
|
693
|
+
words = sentence.split(" ")
|
|
694
|
+
for i, word in enumerate(words):
|
|
695
|
+
if any(letter.islower() for letter in word):
|
|
696
|
+
if i == 0:
|
|
697
|
+
break
|
|
698
|
+
return " ".join(words[:i]), " ".join(words[i:])
|
|
699
|
+
raise ValueError(f"the label holds no leading capitals followed by lower-case words: {sentence!r}")
|
|
700
|
+
|
|
701
|
+
|
|
673
702
|
def _mask(words):
|
|
674
703
|
"""The mask over the key, three groups of the four heads, named for a screen reader. `words` are escaped."""
|
|
675
704
|
return f'<span class="mask" role="img" aria-label="{words["mask"]}">{_MASK_GROUP * 3}</span>'
|
|
@@ -765,7 +794,7 @@ def _switch(words, token, lang):
|
|
|
765
794
|
|
|
766
795
|
class PairingPage:
|
|
767
796
|
def __init__(self, state, watch=None, on_quit=None, on_pause=None, on_resume=None, autostart=None, events=None,
|
|
768
|
-
updater=None):
|
|
797
|
+
updater=None, mask=None):
|
|
769
798
|
"""`watch` answers the watcher's snapshot; without it the page shows no watcher line. `on_quit` stops
|
|
770
799
|
the program; without it the page shows no quit button and /quit is no route. `on_pause` and `on_resume`
|
|
771
800
|
pause and resume the watcher; without them /pause and /resume are no routes. `autostart` is the start
|
|
@@ -775,7 +804,10 @@ class PairingPage:
|
|
|
775
804
|
events, oldest first; with it /state carries the log's lines and the page draws them in the card «Actividad»,
|
|
776
805
|
without it no log key and no card. `updater` is the update (update.py), set by main once the page serves;
|
|
777
806
|
with it /state carries its state, the page draws its line under the state line, and /update and /restart
|
|
778
|
-
are routes; without it none of them.
|
|
807
|
+
are routes; without it none of them. `mask` is the QR's mask, one of qr.MASKS; without it the page picks one
|
|
808
|
+
here, once, at random (the standard random module: a cosmetic pick), so each start, a fresh install's first
|
|
809
|
+
included, shows the bust or the board, and every render of this page draws the same one (owner 2026-10-02
|
|
810
|
+
12:4x)."""
|
|
779
811
|
self.state = state
|
|
780
812
|
self.watch = watch
|
|
781
813
|
self.on_quit = on_quit
|
|
@@ -783,6 +815,8 @@ class PairingPage:
|
|
|
783
815
|
self.autostart = autostart if autostart is not None and autostart.available else None
|
|
784
816
|
self.events = events
|
|
785
817
|
self.updater = updater
|
|
818
|
+
self.mask = mask if mask is not None else random.choice(qr.MASKS)
|
|
819
|
+
self.run = None # the RunFile main hands the page: /replace answers its secret once published
|
|
786
820
|
self.token = secrets.token_urlsafe(32)
|
|
787
821
|
self.server = None
|
|
788
822
|
self.port = None
|
|
@@ -937,16 +971,18 @@ class PairingPage:
|
|
|
937
971
|
link.append(log_card)
|
|
938
972
|
if secret is not None:
|
|
939
973
|
modules = qr.encode(qr.pairing_address(secret).encode("ascii")).modules
|
|
940
|
-
drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="qr-for", mask=
|
|
974
|
+
drawn = qr.scene_svg(modules, plate_almena.PLATE_DATA_URI, labelledby="qr-for", mask=self.mask,
|
|
975
|
+
mask_words=words["qr_press"])
|
|
941
976
|
if drawn is None: # the scene is drawn for version 3 only
|
|
942
|
-
drawn = qr.svg(modules, labelledby="qr-for", mask=words["
|
|
977
|
+
drawn = qr.svg(modules, labelledby="qr-for", mask=self.mask, mask_words=words["qr_press"])
|
|
978
|
+
lead, rest = _lead(words["qr_for"])
|
|
943
979
|
link += [# The code is hidden until asked (owner 2026-10-02): the QR's modules under their pixelated
|
|
944
980
|
# mask and the key's text under its heads until its person presses the reveal, a real button
|
|
945
981
|
# whose label says what a press does, with the label of what the QR is for over it, the QR's
|
|
946
982
|
# one sentence and its name; _SCRIPT hides both again 60 s after a show. The drawings stay.
|
|
947
983
|
f'<figure class="key-card">{drawn}<p class="key" data-shown="false"><span class="key-label">'
|
|
948
984
|
f'{words["code_label"]}</span> <span class="key-val">{_mask(words)}<span class="code" id="code">'
|
|
949
|
-
f'{codes.display(secret)}</span></span></p><p class="qr-for" id="qr-for">{
|
|
985
|
+
f'{codes.display(secret)}</span></span></p><p class="qr-for" id="qr-for"><b>{lead}</b> {rest}</p>'
|
|
950
986
|
f'<button type="button" class="reveal" '
|
|
951
987
|
f'aria-controls="code" data-show="{words["show_code"]}" data-hide="{words["hide_code"]}">'
|
|
952
988
|
f'{words["show_code"]}</button>{_PARTY}</figure>',
|
|
@@ -1188,6 +1224,8 @@ def _handler(page):
|
|
|
1188
1224
|
routes += ("/resume",) if page.on_resume is not None else ()
|
|
1189
1225
|
routes += ("/autostart",) if page.autostart is not None else ()
|
|
1190
1226
|
routes += ("/update", "/restart") if page.updater is not None else ()
|
|
1227
|
+
replace_secret = page.run.secret if page.run is not None else None
|
|
1228
|
+
routes += ("/replace",) if page.on_quit is not None and replace_secret is not None else ()
|
|
1191
1229
|
if path not in routes:
|
|
1192
1230
|
return self._refuse(404)
|
|
1193
1231
|
if length is None: # chunked or absent is 411, anything but plain digits 400
|
|
@@ -1200,6 +1238,17 @@ def _handler(page):
|
|
|
1200
1238
|
except ValueError: # more fields than any form of the page has
|
|
1201
1239
|
return self._refuse(400)
|
|
1202
1240
|
form = {key: values[0] for key, values in fields.items()}
|
|
1241
|
+
if path == "/replace":
|
|
1242
|
+
# A newer copy started over this one (__main__.py): it has no page token, so the run file's secret is
|
|
1243
|
+
# its check; then the stop of «Salir», answered first as /quit is.
|
|
1244
|
+
if not hmac.compare_digest(form.get("secret", "").encode(), replace_secret.encode()):
|
|
1245
|
+
return self._refuse(403)
|
|
1246
|
+
try:
|
|
1247
|
+
closing = "Cerrando" if page.language_of(self.headers.get("Accept-Language")) == "es" else "Closing"
|
|
1248
|
+
self._send(202, "text/plain; charset=utf-8", closing)
|
|
1249
|
+
finally:
|
|
1250
|
+
page.on_quit()
|
|
1251
|
+
return
|
|
1203
1252
|
if not hmac.compare_digest(form.get("token", "").encode(), page.token.encode()):
|
|
1204
1253
|
return self._refuse(403)
|
|
1205
1254
|
if path == "/quit":
|
|
@@ -223,22 +223,82 @@ def penalty(modules):
|
|
|
223
223
|
+ 10 * (abs(dark * 2 - total) * 10 // total))
|
|
224
224
|
|
|
225
225
|
|
|
226
|
-
# The
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
# the
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
226
|
+
# The two masks over the pairing QR's modules (owner 2026-10-02 12:4x, ledger OR-104, lane pfmaskimpl), one picked at
|
|
227
|
+
# random by the page once per start: the bust, a helmeted bust in left profile of 32 by 32 blocks (the pfmask design
|
|
228
|
+
# lane's bust.py, options A and C of mockups/pendify-qr-mask-2026-10-02.html), and the board, 8 by 8 flat cells in
|
|
229
|
+
# the page's --brand and --tonal (option B). Each lies on one GROUND path over the whole symbol, finders and timing
|
|
230
|
+
# included, so no finder, module or grid shows through, and each carries the instruction as plain text (the owner's
|
|
231
|
+
# correction: both with option C's words). Neither is drawn from any module of any key.
|
|
232
|
+
MASKS = ("bust", "board")
|
|
233
|
+
# The bust's blocks as bust.py prints them: '.' empty, 'o' the outline in the theme's --px-line (class "pl", as the
|
|
234
|
+
# party's sprites), the rest the party's colours, page._FIGURE_COLOURS: v d l the helmet, y a the plume, h s the plates.
|
|
235
|
+
_BUST = ("................................",
|
|
236
|
+
"................................",
|
|
237
|
+
"................................",
|
|
238
|
+
"..............ooooo.............",
|
|
239
|
+
".............oyyyyyoy...........",
|
|
240
|
+
"............oyaaaaayyy..........",
|
|
241
|
+
"............oaayyyyaayy.........",
|
|
242
|
+
"............ooyyyyyyaayy........",
|
|
243
|
+
".........ooodvoooyyyyaao........",
|
|
244
|
+
".......oodvvvlvo.ooyyyaao.......",
|
|
245
|
+
"......odvvvvddlvo..ooyyayo......",
|
|
246
|
+
".....oddvddddddlvo...ooaoo......",
|
|
247
|
+
".....ooooodddddvvo.....o........",
|
|
248
|
+
".....odddddddddvvo..............",
|
|
249
|
+
".....ooooooodddddvo.............",
|
|
250
|
+
".....odvvvvvoooddvvo............",
|
|
251
|
+
"....odddddddvo.ooddvooooooo.....",
|
|
252
|
+
"....oddddddddvo..oddvvoshhhoo...",
|
|
253
|
+
"....odddddddldvo.ooooossssshho..",
|
|
254
|
+
".....oddddddddvooshhhhsssssss...",
|
|
255
|
+
"......oddddddddossssssssssss....",
|
|
256
|
+
".....oooodooooossssoooooooo.....",
|
|
257
|
+
"......hhhoshhhhsssosssssss......",
|
|
258
|
+
".......sshsssssssssssssss.......",
|
|
259
|
+
"........ssssssssssssssss........",
|
|
260
|
+
".........ssssssssssssss.........",
|
|
261
|
+
"..........oooooooooooo..........",
|
|
262
|
+
"...........ssssssssss...........",
|
|
263
|
+
"............ssssssss............",
|
|
264
|
+
".............ssssss.............",
|
|
265
|
+
"..............ssss..............",
|
|
266
|
+
"...............ss...............")
|
|
267
|
+
_BUST_COLOURS = {"v": "#6D28D9", "d": "#40277C", "l": "#C3B1F7", "y": "#FBBE3C", "a": "#A5510B", "h": "#ECE8F6",
|
|
268
|
+
"s": "#A79FC2"}
|
|
269
|
+
_BUST_CELL = 10.5 # one block in plate units: 5.25 px in the page's 288 px box, 8.75 px at 480
|
|
270
|
+
_BUST_SAY_ROW = 23.6 # the bust's row the instruction sits on: its lower band, the chest's upper plate
|
|
271
|
+
_BOARD = 8 # the board's cells a side
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _bust_paths():
|
|
275
|
+
"""The bust as the page's _paths() draws a pixel drawing: one path per colour, one closed run per row."""
|
|
276
|
+
runs = {}
|
|
277
|
+
for y, row in enumerate(_BUST):
|
|
278
|
+
x = 0
|
|
279
|
+
while x < len(row):
|
|
280
|
+
if row[x] == ".":
|
|
281
|
+
x += 1
|
|
282
|
+
continue
|
|
283
|
+
start = x
|
|
284
|
+
while x < len(row) and row[x] == row[start]:
|
|
285
|
+
x += 1
|
|
286
|
+
runs.setdefault(row[start], []).append(f"M{start} {y}h{x - start}v1h-{x - start}z")
|
|
287
|
+
return "".join(f'<path class="pl" d="{"".join(d)}"/>' if key == "o" else
|
|
288
|
+
f'<path fill="{_BUST_COLOURS[key]}" d="{"".join(d)}"/>' for key, d in runs.items())
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _mask_named(mask, words):
|
|
292
|
+
"""A mask is one of MASKS and is drawn with its words; anything else is refused, never drawn as another."""
|
|
293
|
+
if mask not in MASKS:
|
|
294
|
+
raise ValueError(f"no such mask: {mask!r}; the masks are {MASKS}")
|
|
295
|
+
if words is None:
|
|
296
|
+
raise ValueError(f"the mask {mask!r} is drawn with its words")
|
|
237
297
|
|
|
238
298
|
|
|
239
|
-
def
|
|
240
|
-
"""The
|
|
241
|
-
return
|
|
299
|
+
def _say(words, x, y, board):
|
|
300
|
+
"""The instruction on a mask: plain svg text in the page's .qm-say rule (no plate, no box, no corner)."""
|
|
301
|
+
return f'<text class="qm-say{" qm-board" if board else ""}" x="{x:.1f}" y="{y:.1f}">{words}</text>'
|
|
242
302
|
|
|
243
303
|
|
|
244
304
|
def _finder(x, y):
|
|
@@ -248,17 +308,31 @@ def _finder(x, y):
|
|
|
248
308
|
f'<rect x="{x + 2}" y="{y + 2}" width="3" height="3" rx="1" fill="{_QR_INK}"/>')
|
|
249
309
|
|
|
250
310
|
|
|
251
|
-
def _flat_mask(n, q,
|
|
252
|
-
"""svg()'s mask
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
311
|
+
def _flat_mask(n, q, mask, words):
|
|
312
|
+
"""svg()'s mask, the scene's laid on the flat symbol: one GROUND path over the n by n symbol, finders included,
|
|
313
|
+
then the bust's 32 blocks fitted to it or the board's 8 by 8 cells, and the words placed as the scene places
|
|
314
|
+
them, in the scene's own units scaled to this box, so the page's one .qm-say rule draws both."""
|
|
315
|
+
side = n + 2 * q
|
|
316
|
+
ground = f'<path fill="{GROUND}" d="M{q} {q}h{n}v{n}h-{n}z"/>'
|
|
317
|
+
if mask == "bust":
|
|
318
|
+
art = (f'<g transform="translate({q} {q}) scale({n / len(_BUST):.6g})" shape-rendering="crispEdges">'
|
|
319
|
+
f"{_bust_paths()}</g>")
|
|
320
|
+
row = _BUST_SAY_ROW * n / len(_BUST)
|
|
321
|
+
else:
|
|
322
|
+
step = n / _BOARD
|
|
323
|
+
cells = {1: "", 2: ""}
|
|
324
|
+
for r in range(_BOARD):
|
|
325
|
+
for c in range(_BOARD):
|
|
326
|
+
cells[1 + (r + c) % 2] += f"M{q + c * step:.6g} {q + r * step:.6g}h{step:.6g}v{step:.6g}h-{step:.6g}z"
|
|
327
|
+
art = f'<path class="qm-1" d="{cells[1]}"/><path class="qm-2" d="{cells[2]}"/>'
|
|
328
|
+
row = n / 2
|
|
329
|
+
unit = VIEW / side
|
|
330
|
+
say = _say(words, (q + n / 2) * unit, (q + row) * unit, mask == "board")
|
|
331
|
+
return (f'<g class="qr-mask" role="img" aria-label="{words}">{ground}{art}'
|
|
332
|
+
f'<g transform="scale({side / VIEW:.6g})">{say}</g></g>')
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def svg(modules, labelledby=None, mask=None, mask_words=None):
|
|
262
336
|
"""An inline SVG of the symbol on a white tile that keeps a four-module quiet zone, one unit per module.
|
|
263
337
|
|
|
264
338
|
The grid is the encoder's; only how a module is painted changes. A dark data module is a 0.88 dot with
|
|
@@ -268,9 +342,12 @@ def svg(modules, labelledby=None, mask=None):
|
|
|
268
342
|
corner centre inside the arc only while its radius is under 0.5*sqrt(2)/(sqrt(2)-1), about 1.707.
|
|
269
343
|
|
|
270
344
|
Every module, finders and alignment patterns included, is drawn in one group, class "modules", over the tile.
|
|
271
|
-
Given `mask`,
|
|
272
|
-
|
|
345
|
+
Given `mask`, one of MASKS, and `mask_words`, the escaped instruction drawn on it and its name for a screen
|
|
346
|
+
reader, a second group, class "qr-mask", draws that mask over the symbol (_flat_mask); the page's rules show one
|
|
347
|
+
of the two.
|
|
273
348
|
"""
|
|
349
|
+
if mask is not None:
|
|
350
|
+
_mask_named(mask, mask_words)
|
|
274
351
|
n, q = len(modules), QUIET_ZONE
|
|
275
352
|
side = n + 2 * q
|
|
276
353
|
finders = ((0, 0), (0, n - 7), (n - 7, 0))
|
|
@@ -300,7 +377,7 @@ def svg(modules, labelledby=None, mask=None):
|
|
|
300
377
|
f'<rect x="{x + 2}" y="{y + 2}" width="1" height="1" rx=".3" fill="{_QR_INK}"/>')
|
|
301
378
|
out.append("</g>")
|
|
302
379
|
if mask is not None:
|
|
303
|
-
out.append(_flat_mask(n, q, mask))
|
|
380
|
+
out.append(_flat_mask(n, q, mask, mask_words))
|
|
304
381
|
out.append("</svg>")
|
|
305
382
|
return "".join(out)
|
|
306
383
|
|
|
@@ -401,7 +478,7 @@ def _runs(cells):
|
|
|
401
478
|
return out
|
|
402
479
|
|
|
403
480
|
|
|
404
|
-
def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None):
|
|
481
|
+
def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None, mask_words=None):
|
|
405
482
|
"""The pairing QR as the scene: the plate image under a group of polygons. None when the symbol is not version 3,
|
|
406
483
|
and the caller draws svg() instead.
|
|
407
484
|
|
|
@@ -413,8 +490,11 @@ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None):
|
|
|
413
490
|
near kerb clips it all.
|
|
414
491
|
|
|
415
492
|
All that a key changes is one group, class "modules"; the plate is drawn before it and outside it. Given `mask`,
|
|
416
|
-
the escaped
|
|
417
|
-
|
|
493
|
+
one of MASKS, and `mask_words`, the escaped instruction and the mask's name for a screen reader, a second group,
|
|
494
|
+
class "qr-mask", lays that mask flat on the symbol under the same kerb, over one GROUND path that covers the whole
|
|
495
|
+
symbol, the plate's finders and timing included, with the words over it."""
|
|
496
|
+
if mask is not None:
|
|
497
|
+
_mask_named(mask, mask_words)
|
|
418
498
|
n = len(modules)
|
|
419
499
|
if n != MODULES:
|
|
420
500
|
return None
|
|
@@ -460,12 +540,24 @@ def scene_svg(modules, plate_data_uri, labelledby=None, uid="q", mask=None):
|
|
|
460
540
|
|
|
461
541
|
veil = ""
|
|
462
542
|
if mask is not None:
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
543
|
+
cx, cy = _pt(s0 + n / 2, s0 + n / 2, 0.0) # the symbol's centre
|
|
544
|
+
if mask == "bust":
|
|
545
|
+
gx, gy = cx - len(_BUST) / 2 * _BUST_CELL, cy - len(_BUST) / 2 * _BUST_CELL
|
|
546
|
+
art = (f'<g transform="translate({gx:.2f} {gy:.2f}) scale({_BUST_CELL})" shape-rendering="crispEdges">'
|
|
547
|
+
f"{_bust_paths()}</g>")
|
|
548
|
+
say = _say(mask_words, cx, gy + _BUST_SAY_ROW * _BUST_CELL, False)
|
|
549
|
+
else:
|
|
550
|
+
step = n / _BOARD
|
|
551
|
+
cells = {1: "", 2: ""}
|
|
552
|
+
for r in range(_BOARD):
|
|
553
|
+
for c in range(_BOARD):
|
|
554
|
+
cells[1 + (r + c) % 2] += area(c * step, r * step, (c + 1) * step, (r + 1) * step, 0.0)
|
|
555
|
+
art = f'<path class="qm-1" d="{cells[1]}"/><path class="qm-2" d="{cells[2]}"/>'
|
|
556
|
+
say = _say(mask_words, cx, cy, True)
|
|
557
|
+
# the drawing lies on the field under the kerb's clip, the ground path first; the words are laid over the
|
|
558
|
+
# scene, never clipped
|
|
559
|
+
veil = (f'<g class="qr-mask" role="img" aria-label="{mask_words}"><g clip-path="url(#{uid}k)">'
|
|
560
|
+
f'<path fill="{GROUND}" d="{area(0, 0, n, n, 0.0)}"/>{art}</g>{say}</g>')
|
|
469
561
|
dot = _poly([((du - dv) * AX, (du + dv) * AY) for du, dv in _OCTAGON]) # one dot, centred on 0 0
|
|
470
562
|
dots = {True: [], False: []}
|
|
471
563
|
for r in range(n):
|