docs: one manual, the pages a device has and a printable copy of all of them
Documentation becomes part of the release rather than a job afterwards.
docs/manual is now the only place teacher instructions are written, and
tools/build-manual produces both versions from it:
classroom-manual.html installed on every machine, showing only the
features THAT device has
classroom-manual.pdf the universal printed manual, every feature,
with a conditional sentence above each
optional page so it stays true in a room that
does not have them all
They cannot drift apart because neither is written by hand, and
build-release.sh builds and checks them on every release: a page that
declares a feature with no printed-if fails the build, because that is
exactly how the printed manual comes to state an optional feature as
though every room had it. So does a page naming a feature device.conf
does not have, and an optional feature with no page at all.
WHAT REACHES THE CLASSROOM ACCOUNT. Only a list of setting NAMES, and
only ones the package already defines as optional applications.
classroom-desktop-layout — already the one place that decides what a
device offers — writes them to /run/ekklesia-classroom/features.json,
and classroom-manual filters that against FEATURES before using it. No
values from device.conf, no broker password, no addresses. With no
manifest at all every page is shown, which is the printed manual and is
never wrong, only less specific.
Help is a Quick Launch button beside Parent Call, deliberately not in
the row with the applications: putting it there would move buttons
teachers reach for without looking. Every room gets it, because a
teacher who needs the manual is by definition one who does not know
where to look.
The PDF is written directly rather than converted. A manual is
headings, paragraphs, bullets, steps and one table; writing that with
the standard fonts is a couple of hundred lines and no dependency, so a
release can be built on any machine with Python. Pictures are embedded
in the digital manual as data URIs so it stays one file that works
wherever it is copied and with the network unplugged; the printed
manual names them instead, which is the one thing this builder does not
do and is noted where somebody would look for it.
Two defects found building it, both of which hung or corrupted the
output rather than failing: a paragraph opening with a bold word
matched the bullet guard but not the bullet rule, so nothing consumed
it and the build spun; and a list item wrapped across two source lines
ended the list, so a three-step procedure printed as three steps all
numbered 1.
Also adds the 0.2.6 physical checklist to docs/testing.md and the
release validator's checks for the Wi-Fi regression, the conversion
pipeline on a real Debian, the fonts, the manual and the dock order.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
55f4c183c7
commit
f2942a2946
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Open this computer's own manual.
|
||||
|
||||
classroom-manual open it
|
||||
classroom-manual --features say what this device would show
|
||||
|
||||
One manual is installed on every machine, carrying every page. What makes
|
||||
it THIS room's manual is that the pages for features this device does not
|
||||
have are removed before a teacher ever sees them: a Bible study laptop
|
||||
opens it and reads about the Browser, Presentations and the volume, with
|
||||
no mention of Wonder Ink or Parent Call to wonder about.
|
||||
|
||||
WHAT REACHES THE CLASSROOM ACCOUNT, AND WHAT DOES NOT.
|
||||
|
||||
Only a list of setting NAMES — ["WONDERINK_ENABLED", ...] — and only
|
||||
those the package already defines as optional applications. No values
|
||||
from device.conf, no broker address, no broker password, no update URL,
|
||||
no Wi-Fi anything, no other key an administrator has put in that file.
|
||||
|
||||
That list is prepared by classroom-desktop-layout, which is already the
|
||||
one place that decides what this device offers, and left in the runtime
|
||||
directory where it is readable and carries nothing worth protecting. If
|
||||
it is not there, every page is shown — which is exactly the printed
|
||||
manual, and is never wrong, only less specific.
|
||||
|
||||
Shown through Chrome in application mode: no address bar, no tabs, one
|
||||
window that closes. Chrome is on every classroom machine already, the
|
||||
manual is a local file, and none of it needs the network.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
from classroom_config import FEATURES, feature, read_device_config # noqa: E402
|
||||
|
||||
MANUAL = "/usr/local/share/ekklesia-classroom/manual/classroom-manual.html"
|
||||
|
||||
# Written by classroom-desktop-layout at boot. See the module docstring
|
||||
# for what is and is not in it.
|
||||
MANIFEST = "/run/ekklesia-classroom/features.json"
|
||||
|
||||
|
||||
def say(message):
|
||||
print(f"classroom-manual: {message}")
|
||||
|
||||
|
||||
def enabled_features():
|
||||
"""The optional applications this device offers, by setting name."""
|
||||
try:
|
||||
with open(MANIFEST, "r", encoding="utf-8") as handle:
|
||||
listed = json.load(handle)
|
||||
|
||||
if isinstance(listed, list):
|
||||
# Filtered against what this package knows, so a stale or
|
||||
# hand-edited manifest can only ever name a real feature.
|
||||
return [name for name in listed if name in FEATURES]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# No manifest. Fall back to asking device.conf directly for the
|
||||
# feature keys and nothing else — the same question, one step less
|
||||
# removed. A root-run --features does this; a classroom session
|
||||
# normally has the manifest.
|
||||
config = read_device_config()
|
||||
|
||||
return [name for name in sorted(FEATURES) if feature(config, name)]
|
||||
|
||||
|
||||
def page(features):
|
||||
"""The installed manual, with this device's features declared in it."""
|
||||
with open(MANUAL, "r", encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
|
||||
declaration = (
|
||||
"<script>window.CLASSROOM_FEATURES = "
|
||||
+ json.dumps(sorted(features))
|
||||
+ ";</script>"
|
||||
)
|
||||
|
||||
# Before the viewer script, which reads it. Nothing else in the file
|
||||
# is touched, so what a teacher reads is the file the package
|
||||
# installed.
|
||||
return text.replace("<body>", "<body>" + declaration, 1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Open this computer's manual.")
|
||||
parser.add_argument("--features", action="store_true",
|
||||
help="say what this device would show; opens nothing")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
features = enabled_features()
|
||||
|
||||
if arguments.features:
|
||||
for name in sorted(FEATURES):
|
||||
print(f" {name:<24} {'shown' if name in features else 'hidden'}")
|
||||
|
||||
return 0
|
||||
|
||||
if not os.path.exists(MANUAL):
|
||||
say(f"the manual is not installed at {MANUAL}")
|
||||
return 1
|
||||
|
||||
# Composed into the teacher's own cache rather than beside the
|
||||
# installed file, which is root's and read-only.
|
||||
cache = os.path.join(
|
||||
os.environ.get("XDG_CACHE_HOME")
|
||||
or os.path.expanduser("~/.cache"),
|
||||
"ekklesia-classroom")
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
|
||||
try:
|
||||
composed = os.path.join(cache, "manual.html")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", dir=cache, delete=False,
|
||||
suffix=".html") as handle:
|
||||
handle.write(page(features))
|
||||
temporary = handle.name
|
||||
|
||||
os.replace(temporary, composed)
|
||||
except OSError as error:
|
||||
say(f"could not prepare the manual: {error}")
|
||||
return 1
|
||||
|
||||
scale = "1"
|
||||
|
||||
try:
|
||||
scale = subprocess.run(
|
||||
["/usr/local/bin/classroom-ui-scale"], capture_output=True,
|
||||
text=True, timeout=10, check=False).stdout.strip() or "1"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# Application mode: the manual in a window of its own, with no
|
||||
# address bar for a teacher to end up somewhere else in. The
|
||||
# room's ordinary browser profile is deliberately not used, so
|
||||
# opening Help cannot disturb a lesson that is already open in
|
||||
# Chrome.
|
||||
os.execvp("google-chrome", [
|
||||
"google-chrome",
|
||||
"--ozone-platform=wayland",
|
||||
f"--force-device-scale-factor={scale}",
|
||||
f"--user-data-dir={os.path.join(cache, 'manual-profile')}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--password-store=basic",
|
||||
"--hide-crash-restore-bubble",
|
||||
f"--app=file://{composed}",
|
||||
])
|
||||
except OSError as error:
|
||||
say(f"could not open the browser: {error}")
|
||||
say(f"the manual itself is at {composed}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user