feat: Presentations, for a teacher who downloaded tonight's session

PRESENTATIONS_ENABLED, off by default, independent of everything else. A
teacher downloads a session in ordinary Chrome and it is on the
Presentations screen a few seconds later, ready for the television. No
file manager, no import button, no Impress.

WHY NOTHING LAUNCHES A DOCUMENT APPLICATION. The brief was to use
existing presentation software, so the existing software was tried
first, on Debian 13, before any of this was designed. `soffice --show`
does start a real fullscreen slideshow — and needs XDG_SESSION_TYPE plus
a VCL plugin or it dies with "Failed to open display", shows a splash,
opened a "Tip of the Day: 1/225" dialog over the slides, and leaves the
Impress editor when the show ends. `okular --presentation` opens THREE
windows: the presentation, the ordinary document window behind it, and
an information dialog. zathura is not a presenter. pdfpc is the closest
and is another dependency with a presenter-console model. Every one of
them leaves an application on screen that can open any file on the
machine, and none of them knows there is a library to go back to.

So LibreOffice converts once, headless, at import, with no window on any
screen; poppler renders the pages; the classroom shows the pictures.
Measured: 0.34s for a ten-slide PPTX, 0.03s for its ten thumbnails,
0.10s for one slide at 1920px. That is not a new renderer — it is the
same engine Impress would have used live — and it means Escape is ours,
presenting is instant, and a PDF exported from Google Slides skips the
conversion entirely and keeps the appearance Google gave it.

THE THREE THINGS KEPT APART. The library thumbnail, the starting slide
and the retention clock are independent, because the first slide of a
Bible study session is a generic opening shared by every session and the
one that names it is usually the second. Quick Look chooses the picture;
it does not change where the slideshow starts and it does not count as
having presented anything. mark_presented() is called when a slide is
actually drawn, not when Present is pressed: an intention is not a
presentation, and a viewer that failed to start must not buy another
thirty days.

IMPORTING. The teacher's download is copied, never moved, renamed or
deleted. A file with a .crdownload beside it is still arriving. Wonder
Ink's own folder is not looked at. Two downloads of the same name are
two presentations, the second titled (2) — found in the lab, where a PDF
and a PPTX of one session both came out with the same name because the
import being named was excluded from the taken-titles set BY TITLE, and
a set holds one copy of a repeated string.

Metadata is written saying "importing" before any work, so a reboot
mid-conversion leaves a directory the next startup sweeps and imports
again. A file that cannot be converted gets a card saying so, offering
only Remove, with the teacher's download untouched.

Retention: 180 days never presented, 30 days from the last presentation,
every presentation starting the thirty over. The cleanup never touches a
presentation being shown, never leaves the library directory, and never
looks at Downloads.

The dock order is unchanged: Presentations is appended after the disc
player, so Wonder Ink, the browser and the player keep the places
teachers have been reaching for. There is a test for exactly that.

classroom-wonderink now asks feature() rather than carrying its own copy
of the default, so the panel and the command cannot disagree.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-22 20:21:19 -05:00
co-authored by Claude Opus 5
parent 30d2d99230
commit 55f4c183c7
23 changed files with 2979 additions and 111 deletions
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" width="22" height="22">
<!-- The comment lives inside <svg>, not above it. gdk-pixbuf
recognises an SVG by sniffing the first bytes of the file, and a
comment before the opening tag pushes it out of that window:
every GTK program then reports "couldn't recognize the image file
format" while Qt renders it happily. Measured on both of the
classroom's other icons, and not learned again here. -->
<!-- Presentations.
Breeze's own view-presentation is a bar chart on a board, which in
a church reads as a business meeting rather than as tonight's
Bible study. x-office-presentation is a projector screen and comes
closest, but it is a filetype mark: it says "a file of this kind"
where the panel needs "the place your slides are".
So: a screen on a stand with a slide showing on it — a title line
and two lines of text, which is the shape of every one of these
sessions. Same 22-unit grid, same single-path style and the same
ColorScheme-Text class as Parent Call, so it recolours with the
session and needs no second file for the light theme. -->
<defs>
<style type="text/css">.ColorScheme-Text { color: #fcfcfc; }</style>
</defs>
<g class="ColorScheme-Text" style="fill:currentColor;fill-opacity:1;stroke:none">
<!-- The screen: an outline, so the slide inside it reads as content
rather than the whole mark being one solid block at 22px. -->
<path d="M2 3h18a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v8h16V5z"/>
<!-- The slide on it: a title, and two lines under it. -->
<path d="M5 7h7v1.5H5zm0 3h12v1H5zm0 2.2h9v1H5z" style="fill-opacity:0.85"/>
<!-- The stand. A presentation is a thing set up in front of a room,
and the two feet are what say so at this size. -->
<path d="M10.25 16h1.5v3.2l2.9 1.45-.67 1.34-2.98-1.49-2.98 1.49-.67-1.34 2.9-1.45z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,28 @@
[Desktop Entry]
Type=Application
Name=Presentations
GenericName=Slides
Comment=Show a presentation that was downloaded in the browser
# Drawn for this: a screen with a slide on it and a corner turned, which
# is a presentation rather than a document or a photograph. Breeze's
# view-presentation is a chart on a board, which in a church reads as a
# business meeting. Uses ColorScheme-Text like the classroom's other two
# marks, so it recolours with the session and needs no light-theme twin.
Icon=ekklesia-presentations
Exec=/usr/local/bin/classroom-presentations
Terminal=false
Categories=Office;Presentation;
StartupNotify=true
# How the panel matches the window on screen to this launcher. GTK sets
# the Wayland app id from GLib.set_prgname, which classroom-presentations
# sets to exactly this. A mismatch here is not an error anywhere — it is
# silently two buttons on the panel instead of one.
StartupWMClass=ekklesia-presentations
# One window. Pressing the panel button again brings the library forward
# rather than opening a second copy of it.
X-GNOME-SingleWindow=true
# Deliberately visible to the applications index, like the disc player and
# Parent Call: the panel resolves its launcher through that index, and an
# entry hidden from it gives the bar a blank page instead of the mark.
# There is no application menu in this classroom for it to clutter.
NoDisplay=false
@@ -12,19 +12,22 @@
// WHICH APPLICATIONS THIS DEVICE OFFERS. // WHICH APPLICATIONS THIS DEVICE OFFERS.
// //
// classroom-desktop-layout rewrites the three lines between the markers // classroom-desktop-layout rewrites the lines between the markers to
// to match device.conf and this machine's hardware, and nothing else in // match device.conf and this machine's hardware, and nothing else in
// this file. Each application is its own line because each is its own // this file. Each application is its own line because each is its own
// setting: a laptop taking children's church needs Wonder Ink and a tower // setting: a laptop taking children's church needs Wonder Ink and a tower
// used for Bible study does not, so the shape of the machine is never // used for Bible study does not, so the shape of the machine is never
// allowed to decide what it is for. // allowed to decide what it is for.
// //
// All true here is the shipped default, which is what an existing // The first three are true here, which is what an existing classroom must
// classroom must come up as. // come up as. PRESENTATIONS is false, which is what an existing classroom
// must come up as too: no room has ever had it, and an upgrade may not
// put a button on a bar nobody asked for.
/* CLASSROOM-FEATURES-BEGIN */ /* CLASSROOM-FEATURES-BEGIN */
var WONDERINK = true; var WONDERINK = true;
var DVD = true; var DVD = true;
var PARENT_CALL = true; var PARENT_CALL = true;
var PRESENTATIONS = false;
/* CLASSROOM-FEATURES-END */ /* CLASSROOM-FEATURES-END */
var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"; var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
@@ -84,6 +87,27 @@ if (PARENT_CALL) {
parentCall.writeConfig("maxSectionCount", 1); parentCall.writeConfig("maxSectionCount", 1);
} }
// HELP, beside Parent Call and not in the row with the applications.
//
// Every room gets it: the manual is the one thing that is never optional,
// because a teacher who needs it is by definition a teacher who does not
// know where to look. It shows only the pages for the features THIS
// device has — see classroom-manual — so the button means the same thing
// in every room while what it opens does not.
//
// On the left, with Parent Call, for the same reason Parent Call is
// there: it is not one of the room's applications, and putting it in the
// middle row would move buttons teachers reach for without looking.
var help = panel.addWidget("org.kde.plasma.quicklaunch");
help.currentConfigGroup = ["General"];
help.writeConfig(
"launcherUrls",
"file:///usr/share/applications/ekklesia-manual.desktop"
);
help.writeConfig("showLauncherNames", false);
help.writeConfig("enablePopup", false);
help.writeConfig("maxSectionCount", 1);
var leftGap = panel.addWidget("org.kde.plasma.panelspacer"); var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
leftGap.currentConfigGroup = ["General"]; leftGap.currentConfigGroup = ["General"];
leftGap.writeConfig("expanding", true); leftGap.writeConfig("expanding", true);
@@ -114,6 +138,17 @@ if (DVD) {
launchers.push("applications:ekklesia-dvd.desktop"); launchers.push("applications:ekklesia-dvd.desktop");
} }
// LAST, AND THAT IS THE WHOLE REASON IT IS HERE.
//
// Every existing room has Wonder Ink, the browser and the disc player in
// that order, and teachers have been pressing them in that order for
// months. Appending keeps all three exactly where they are on every
// machine that upgrades; putting Presentations anywhere else in the row
// would move a button somebody reaches for without looking.
if (PRESENTATIONS) {
launchers.push("applications:ekklesia-presentations.desktop");
}
tasks.writeConfig("launchers", launchers.join(",")); tasks.writeConfig("launchers", launchers.join(","));
// THE ORDER STAYS PUT WHEN AN APPLICATION OPENS. // THE ORDER STAYS PUT WHEN AN APPLICATION OPENS.
+220
View File
@@ -0,0 +1,220 @@
# Presentations
Optional per device, off by default. `PRESENTATIONS_ENABLED=true` in
`/etc/ekklesia-classroom/device.conf` puts a **Presentations** button on
the bar and starts the importer; nothing else in the room changes.
It depends on nothing else. A machine may have Presentations with no
Wonder Ink, no drive, no Parent Call, on Wi-Fi or a cable, as a laptop or
a tower. The Bible study laptop is the first installation.
## What was measured before any of this was designed
The brief was to use existing presentation software. So the existing
presentation software was tried first, on Debian 13 in the Plasma lab,
against a ten-slide deck in both PPTX and PDF. **The critical
requirement is that a teacher who ends or cancels a slideshow lands back
in the library and never in an editor.**
| Tried | What it actually did |
| --- | --- |
| `soffice --show deck.pptx` | Starts a real fullscreen slideshow — `app_id=libreoffice-impress`, `"Presenting: session7"`, `fullscreen=1`. But it needs `XDG_SESSION_TYPE=wayland` **and** `libreoffice-gtk3`/`-qt6`, or it dies with *"Failed to open display"*; it shows the LibreOffice splash on the way up; and a **"Tip of the Day: 1/225"** dialog opened on top of the slideshow on first run. Ending the show leaves the Impress editor. |
| `okular --presentation deck.pdf` | Opens **three** windows: the presentation, the ordinary Okular document window behind it, and an **"Information — Okular"** dialog. The full viewer UI — File, Open, Settings — sits behind the slides. |
| `zathura deck.pdf` | Opens windowed, not fullscreen. A document reader, not a presenter. |
| `pdfpc deck.pdf` | Single fullscreen window, the closest of the four. With `--single-screen` it came up windowed, and in the lab it did not respond to Right, Escape or `q`. A presenter-console tool with notes and a timer, and another dependency. |
None of them returns a teacher to a library, because none of them knows
there is one. Every one of them leaves an application on the screen that
can open any file on the machine.
### What was chosen instead
**LibreOffice converts once, headless, at import. Poppler renders the
pages. The classroom shows the pictures.**
```
Session 7.pptx --soffice --headless --convert-to pdf--> slides.pdf
slides.pdf --pdftoppm -png -scale-to-x 1920--------> page-001.png ...
```
Measured on the same deck:
| Step | Time |
| --- | --- |
| PPTX → PDF, ten slides | 0.34 s |
| All ten pages → PNG | 0.03 s |
| One page at 1920px | 0.10 s |
Fonts embed correctly (`BAAAAA+LiberationSans`, subset, embedded) and the
text survives, which is where a title comes from when the filename is a
hash.
This is not a new renderer. LibreOffice does exactly the rendering
Impress would have done live — it is the same engine — and poppler does
what every PDF viewer does. The classroom's own code displays a PNG.
What it buys:
* **A teacher can never reach an editor**, because no editor ever runs
interactively. `soffice` runs once, with no window, at import.
* **Escape is ours.** The presenter is a window of the library's own
process, so ending a slideshow is the library raising itself, not a
second program being asked to exit.
* **Presenting is instant.** The pictures already exist; there is no
LibreOffice to start in front of a room.
* **PDF is first class.** A session exported from Google Slides as PDF
skips the conversion entirely and keeps exactly the appearance Google
gave it. PPT and PPTX join that path rather than having one of their
own.
* **Offline by construction.** Nothing after the download touches the
network.
`libreoffice-gtk3` is deliberately *not* a dependency: nothing
interactive runs, so no UI integration package is needed.
## Where things are
```
~/Downloads/ the teacher's own download. NEVER touched.
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
~/.local/share/ekklesia-classroom/presentations/
imported.json which downloads have been dealt with
<id>/
presentation.json title, pages, thumbnail slide, timers
Session 7 - Romans.pptx the managed copy, original name kept
slides.pdf what everything is rendered from
pages/page-001.png ... one per slide, 1920px wide
thumbnail.png the card picture, from the chosen slide
```
## Importing
`ekklesia-classroom-presentations.service` (user) runs
`classroom-presentation-import`, which looks at `~/Downloads` every four
seconds. Polling, not inotify: a teacher waits the same few seconds
either way, and this cannot miss an event across a restart.
A file is imported when **all** of these hold:
* its extension is `.pdf`, `.pptx` or `.ppt`;
* it is not inside the `Wonder Ink` directory;
* there is no `.crdownload`, `.part`, `.partial`, `.download` or `.tmp`
sibling — Chrome writes `Session 7.pptx.crdownload` and renames it when
the transfer finishes, so that sibling is the authoritative "still
arriving";
* it has not changed for six seconds, which catches a copy from a USB
stick that has no `.crdownload` to go by;
* it is not empty; and
* its fingerprint — path, size, modification time — is not already in
`imported.json`.
Two downloads of the same name are two presentations. The second gets
`(2)` after its title; neither overwrites the other.
**Crash safety.** The metadata is written saying `importing` *before* any
work, and the fingerprint is recorded at the same moment so a file that
crashes the converter cannot be retried forever. On startup the importer
sweeps every directory still saying `importing`, deletes it and forgets
its fingerprint, so an import interrupted by a reboot simply happens
again. A presentation becomes visible to a teacher only once every file
it needs is on disk.
A file that cannot be converted gets a card saying so, offering only
**Remove**. The teacher's download is untouched, which is what the card
says.
## The three things kept apart
This is the part most worth not breaking.
| | Changed by | Never changed by |
| --- | --- | --- |
| **Library thumbnail** | *Use this slide as the picture* in Quick Look | presenting, importing |
| **Starting slide** | nothing — always slide 1 | choosing a thumbnail |
| **Retention** | a slide actually reaching the screen | Quick Look, opening the library, choosing a thumbnail, preview generation, a viewer that failed to start |
The first slide of a Bible study session is a generic opening shared by
every session; the one that names the session is usually the second. So
the thumbnail defaults to slide 1 and Quick Look exists to change it, and
changing it must not be mistaken for having presented anything.
`Presenter.show_page` calls `mark_presented()` at the moment the first
slide is drawn, and not before. Pressing **Present** is an intention; a
picture on the screen is a presentation.
## Retention
| State | Kept |
| --- | --- |
| Never presented | 180 days from import |
| Presented | 30 days from the **last** presentation |
Every presentation restarts the thirty days. No teacher-facing setting.
The cleanup runs hourly in the importer and:
* never removes a presentation whose id is in
`$XDG_RUNTIME_DIR/ekklesia-classroom-presenting` — the lock the
presenter holds while a slide is on the screen. In the runtime
directory so a crash cannot make a presentation permanently
un-removable;
* only ever removes a directory under the library root;
* never touches `~/Downloads`, and therefore never a Wonder Ink
worksheet;
* tolerates a directory that is already half gone.
## The presenter
A fullscreen `Gtk.Window` in the library's own process showing one
pre-rendered page at a time, letterboxed — never cropped, never
stretched.
| Key | Does |
| --- | --- |
| Space, →, ↓, Page Down, Return, `n` | Next slide |
| ←, ↑, Page Up, Backspace, `p` | Previous slide |
| Home / End | First / last slide |
| Escape, `q`, F5 | Finish, back to the library |
Page Up and Page Down are what the clicker in the cupboard sends.
Going past the last slide finishes, rather than showing a black "click to
exit" slide.
**Fullscreen is re-asserted, not assumed.** Measured in the lab: focusing
another window on the same workspace dropped the slideshow to half the
screen with the library beside it. `window-state-event` puts it back for
as long as the presentation is running, and stops the moment Escape is
pressed so it can never fight the teacher's way out.
## Fonts
The pastor's material comes from Google Slides. These are all in Debian
main and install without a question:
| Package | Covers |
| --- | --- |
| `fonts-liberation2` | Arial, Times New Roman, Courier New — metric compatible |
| `fonts-crosextra-carlito` | Calibri |
| `fonts-crosextra-caladea` | Cambria |
| `fonts-croscore` | the same three under their Chrome OS names |
| `fonts-liberation-sans-narrow` | Arial Narrow |
| `fonts-roboto`, `fonts-open-sans`, `fonts-lato`, `fonts-montserrat` | the web fonts Google Slides offers |
| `fonts-urw-base35` | the PostScript standard 35 |
`ttf-mscorefonts-installer` is deliberately **absent**. It is in contrib,
it downloads from a third party at install time, and it shows an EULA
prompt — which an unattended classroom upgrade cannot answer.
## Checking a machine
```
classroom-presentation-import --status what the library holds
classroom-presentation-import --once one pass, then stop
classroom-presentation-import --cleanup apply retention now
classroom-manual --features which manual pages this device shows
```
`--status` prints titles, slide counts, the chosen thumbnail slide, both
timers and the expiry date. It is the fastest way to answer "why has this
gone" and "why has this not appeared".
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+35
View File
@@ -101,6 +101,19 @@ DVD_ENABLED=true
# in the corner is then one more thing to explain. # in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true PARENT_CALL_ENABLED=true
# Whether this device offers Presentations.
#
# A teacher downloads a session in Chrome and it appears here, ready to
# show on the television: no file manager, no Impress, nothing to
# explain. Off by default, because every existing classroom upgrading
# into this release has never had it and a new button nobody asked for
# is a new thing to support.
#
# It depends on nothing else. A machine may have Presentations with no
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
# laptop or a tower.
PRESENTATIONS_ENABLED=false
# Which network this device is meant to be on. # Which network this device is meant to be on.
# #
# wired an Ethernet cable is the intended connection. # wired an Ethernet cable is the intended connection.
@@ -212,6 +225,22 @@ DVD_ENABLED=true
# A room full of children needs it. A Bible study does not, and the button # A room full of children needs it. A Bible study does not, and the button
# in the corner is then one more thing to explain. # in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true PARENT_CALL_ENABLED=true
""",
),
(
"PRESENTATIONS_ENABLED",
"""# Whether this device offers Presentations.
#
# A teacher downloads a session in Chrome and it appears here, ready to
# show on the television: no file manager, no Impress, nothing to
# explain. Off by default, because every existing classroom upgrading
# into this release has never had it and a new button nobody asked for
# is a new thing to support.
#
# It depends on nothing else. A machine may have Presentations with no
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
# laptop or a tower.
PRESENTATIONS_ENABLED=false
""", """,
), ),
( (
@@ -325,6 +354,12 @@ FEATURES = {
"WONDERINK_ENABLED": True, "WONDERINK_ENABLED": True,
"DVD_ENABLED": True, "DVD_ENABLED": True,
"PARENT_CALL_ENABLED": True, "PARENT_CALL_ENABLED": True,
# The one that defaults to OFF, and the only one that can. Every
# existing classroom has Wonder Ink, a drive and Parent Call, so a
# room upgrading into this release must come up with all three. None
# of them has ever had Presentations, so a room upgrading into this
# release must come up without it.
"PRESENTATIONS_ENABLED": False,
} }
+807
View File
@@ -0,0 +1,807 @@
"""The classroom's presentation library.
What a teacher sees is in classroom-presentations. This is everything
underneath it — where the files live, what is known about each one, and
the two things that have to be right whoever is asking: when a download
has really finished, and when a presentation has really been presented.
WHY THERE IS A MANAGED COPY AT ALL.
A teacher downloads a session through ordinary Chrome, into ordinary
~/Downloads. That file stays exactly where Chrome put it and is never
touched again — not renamed, not moved, not deleted, not by the library
and not by the cleanup. The library keeps its OWN copy, and everything
after the import happens to that copy. So a teacher who tidies their
downloads cannot break a presentation, and a cleanup that removes a
presentation cannot take a teacher's file with it.
WHY EVERY FORMAT BECOMES A PDF.
Measured in the lab, on Debian 13, before any of this was designed —
docs/presentations.md has the whole table. The short of it:
soffice --show starts a real fullscreen slideshow, and leaves
the Impress editor behind when it ends. It also
wants a first-run dialog dismissed and shows a
splash. A teacher who presses Escape must land
in the library, never in an application that can
open any file on the machine.
okular --presentation opens THREE windows: the
presentation, the ordinary Okular document
window behind it, and an information dialog.
So LibreOffice converts to PDF once, at import, with no window on the
screen at all, and poppler renders that PDF to page images. The classroom
presents the images. Nothing here renders a slide: LibreOffice and
poppler do all of it, which is the same engine Impress would have used
live. This is not a slide renderer — it is a picture viewer over pictures
somebody else drew.
That also makes a PDF export a first-class citizen rather than a special
case: a PDF is already the thing everything else is converted into, so a
session exported from Google Slides as PDF skips a conversion and keeps
the appearance Google gave it.
"""
import json
import os
import re
import shutil
import time
import unicodedata
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
# What a teacher may put in the library.
#
# Three, and deliberately no more. Every one of them is something the
# pastor's material is actually exported as, and each has a conversion
# into PDF that Debian packages.
SUPPORTED = {
".pdf": "pdf",
".pptx": "pptx",
".ppt": "ppt",
}
# A download that is still arriving.
#
# Chrome writes "Session 7.pptx.crdownload" and renames it to
# "Session 7.pptx" when the transfer completes, so the presence of the
# sibling is the authoritative answer to "has this finished". The others
# are what a different browser or a copy in progress leaves.
PARTIAL_SUFFIXES = (".crdownload", ".part", ".partial", ".download", ".tmp")
# How long a file has to stop changing before it counts as finished.
#
# Belt and braces with the .crdownload check above. A file copied onto
# the machine from a USB stick has no .crdownload beside it and is still
# half written, and a teacher watching a presentation appear mid-copy
# would get a library entry that cannot be opened.
SETTLE_SECONDS = 6
# Retention, in days. Neither is a teacher-facing setting in 0.2.6.
NEVER_PRESENTED_DAYS = 180
AFTER_PRESENTING_DAYS = 30
# How wide a rendered slide is, in pixels.
#
# The classroom televisions are 1080p and 4K. 1920 is the 1080p set
# exactly and a 4K set scaled up by two, which on static Bible study
# slides is indistinguishable at classroom distance and is a quarter of
# the pixels to render and store.
SLIDE_WIDTH = 1920
# The library card. Small on purpose: a wall of these has to stay quick.
THUMBNAIL_WIDTH = 480
def data_home():
return Path(os.environ.get("XDG_DATA_HOME")
or Path.home() / ".local" / "share")
def runtime_home():
return Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp")
def library_root():
return data_home() / "ekklesia-classroom" / "presentations"
def downloads_root():
return Path(os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads")
# Wonder Ink's own downloads, which are not presentations and must never
# be imported, moved or removed by any of this. classroom-wonderink puts
# a lesson's worksheets here; see the DOWNLOADS comment in that file for
# why it is a directory of its own.
WONDERINK_DOWNLOADS = "Wonder Ink"
def presenting_lock():
"""The file that says a presentation is on the screen right now.
In the runtime directory, so it cannot outlive a reboot: a lock left
behind by a crash would otherwise make a presentation permanently
un-removable.
"""
return runtime_home() / "ekklesia-classroom-presenting"
def now():
return datetime.now(timezone.utc)
def stamp(moment=None):
return (moment or now()).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_stamp(text):
if not text:
return None
try:
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=timezone.utc)
except (TypeError, ValueError):
return None
def write_json(path, data):
"""Write a JSON file so that a crash cannot leave half of one.
Everything the library knows is written this way. A presentation whose
metadata is truncated is a card a teacher cannot open and cannot
remove, which is worse than one that was never imported.
"""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".new")
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def read_json(path, default=None):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return default
# ------------------------------------------------------------- titles
# Things a download picks up on its way to the machine, none of which
# mean anything to a teacher.
_NOISE = re.compile(
r"""(
\s*\(\d+\)$ # Chrome's " (1)" for a second copy
| ^\d{8,}[-_\s]+ # a leading timestamp
| [-_\s]*\b[0-9a-f]{8,}\b # a hash or a UUID fragment
| \b(final|v\d+|copy|export|download)\b
)""",
re.IGNORECASE | re.VERBOSE,
)
def readable_title(filename, fallback_text=""):
"""A title a teacher would recognise, from a filename they did not choose.
Google Slides exports as "Session 7 - Romans.pptx" and a browser may
make that "Session_7_-_Romans%20(1).pptx". Neither a UUID nor a
40-character hash is a title, and a teacher scanning a wall of cards
for tonight's session should not have to read one.
"""
stem = Path(filename).stem
# Percent-escapes a download can carry, decoded only when they are
# obviously escapes rather than a literal per cent in a name.
if "%20" in stem or "%28" in stem:
for escape, character in (("%20", " "), ("%28", "("), ("%29", ")"),
("%26", "&"), ("%27", "'")):
stem = stem.replace(escape, character)
stem = unicodedata.normalize("NFC", stem)
stem = stem.replace("_", " ").replace("+", " ")
# A dash between words is a dash; a dash padding one is a space.
stem = re.sub(r"\s*-\s*", " - ", stem)
cleaned = _NOISE.sub(" ", stem)
cleaned = re.sub(r"\s+", " ", cleaned).strip(" -–—")
if len(cleaned) >= 3 and not re.fullmatch(r"[\W\d_]+", cleaned):
# Long enough to be a name and not purely punctuation and digits.
return cleaned[:80]
# The filename told us nothing. The first readable line of the
# document itself is a better answer than "a4f39c2e".
for line in (fallback_text or "").splitlines():
line = re.sub(r"\s+", " ", line).strip()
if len(line) >= 3:
return line[:80]
return "Presentation"
def unique_title(title, taken):
"""A title that is not already on another card.
Two files of the same name are two presentations, never one
overwriting the other, so the second has to be tellable from the
first on a wall of cards.
"""
if title not in taken:
return title
for number in range(2, 100):
candidate = f"{title} ({number})"
if candidate not in taken:
return candidate
return f"{title} ({uuid.uuid4().hex[:6]})"
# -------------------------------------------------------- the library
class Presentation:
"""One entry in the library, and the directory holding it."""
def __init__(self, directory, data):
self.directory = Path(directory)
self.data = data
# -- identity -------------------------------------------------
@property
def id(self):
return self.data.get("id") or self.directory.name
@property
def title(self):
return self.data.get("title") or "Presentation"
@property
def pages(self):
try:
return max(0, int(self.data.get("pages") or 0))
except (TypeError, ValueError):
return 0
@property
def state(self):
return self.data.get("state") or "importing"
@property
def ready(self):
return self.state == "ready" and self.slides.exists()
@property
def failed(self):
return self.state == "failed"
@property
def error(self):
return self.data.get("error") or ""
# -- files ----------------------------------------------------
@property
def slides(self):
"""The PDF everything is presented and rendered from."""
return self.directory / "slides.pdf"
@property
def pages_directory(self):
return self.directory / "pages"
def page_image(self, number):
return self.pages_directory / f"page-{number:03d}.png"
@property
def thumbnail(self):
return self.directory / "thumbnail.png"
@property
def managed_copy(self):
name = self.data.get("managed_copy")
return self.directory / name if name else None
# -- what a teacher chose -------------------------------------
@property
def thumbnail_page(self):
"""Which slide is the card's picture.
Independent of where a presentation starts, and independent of
whether it has ever been presented. Changing it is not a
presentation and does not touch a retention timer — the three are
kept apart on purpose, because the first slide of a Bible study
session is a generic opening shared by every session and the one
that names the session is usually the second.
"""
try:
chosen = int(self.data.get("thumbnail_page") or 1)
except (TypeError, ValueError):
return 1
return min(max(chosen, 1), self.pages or 1)
# -- retention ------------------------------------------------
@property
def imported_at(self):
return parse_stamp(self.data.get("imported_at"))
@property
def last_presented_at(self):
return parse_stamp(self.data.get("last_presented_at"))
@property
def times_presented(self):
try:
return max(0, int(self.data.get("times_presented") or 0))
except (TypeError, ValueError):
return 0
def expires_at(self):
"""When the cleanup may remove this, or None if it cannot say.
Two clocks, and only ever one of them running. A presentation
that has never been shown is kept for six months from the day it
arrived. One that has been shown is kept for a month from the
last time it was actually shown, and every showing starts that
month again.
"""
presented = self.last_presented_at
if presented is not None:
return presented + timedelta(days=AFTER_PRESENTING_DAYS)
imported = self.imported_at
if imported is None:
return None
return imported + timedelta(days=NEVER_PRESENTED_DAYS)
def expired(self, moment=None):
due = self.expires_at()
return due is not None and (moment or now()) >= due
# -- writing --------------------------------------------------
@property
def metadata_path(self):
return self.directory / "presentation.json"
def save(self):
write_json(self.metadata_path, self.data)
def set_thumbnail_page(self, number):
"""Choose the slide the library card shows.
Deliberately writes nothing else. This is not a presentation, it
is not an import, and it must not move a retention timer.
"""
number = min(max(int(number), 1), self.pages or 1)
self.data["thumbnail_page"] = number
self.save()
source = self.page_image(number)
if source.exists():
render_thumbnail(source, self.thumbnail)
return number
def mark_presented(self, moment=None):
"""Record that this was really shown to a room.
Called only after the presenter has a slide on the screen. Quick
Look, opening the library, generating previews and choosing a
thumbnail all deliberately do not call it, and neither does a
viewer that failed to start — a presentation that did not happen
must not buy another thirty days.
"""
self.data["last_presented_at"] = stamp(moment)
self.data["times_presented"] = self.times_presented + 1
self.save()
def load(directory):
directory = Path(directory)
data = read_json(directory / "presentation.json")
if not isinstance(data, dict):
return None
return Presentation(directory, data)
def library(root=None):
"""Every presentation, newest import first.
A directory that is half written — an import interrupted by a reboot,
say — has no readable metadata and is simply not in the list. The
importer clears those up; the library never shows a card it cannot
open.
"""
root = Path(root) if root is not None else library_root()
if not root.is_dir():
return []
found = []
for entry in sorted(root.iterdir()):
if not entry.is_dir():
continue
presentation = load(entry)
if presentation is not None:
found.append(presentation)
found.sort(key=lambda p: (p.data.get("imported_at") or ""), reverse=True)
return found
def titles_in_use(root=None, exclude=None):
"""Every title already on a card, optionally ignoring one presentation.
Excluded BY ID rather than by title, which is not a detail. The import
being named is already in the library — it is written before the
conversion starts so an interruption is recoverable — so its own
provisional title has to come out of the set. Taking it out by title
removed the other presentation's identical title along with it, a set
holding one copy of a repeated string, and two files whose names
differ only in their extension both came out called "Session 7 -
Romans". Found in the lab, importing a PDF and a PPTX of the same
session, which is exactly what tomorrow's test does.
"""
return {
presentation.title
for presentation in library(root)
if exclude is None or presentation.id != exclude
}
# ------------------------------------------------------------ imports
def is_partial(path):
"""Whether this file is still arriving."""
path = Path(path)
if path.name.startswith("."):
return True
if path.suffix.lower() in PARTIAL_SUFFIXES:
return True
# Chrome's in-progress file sits beside the finished name.
for suffix in PARTIAL_SUFFIXES:
if path.with_name(path.name + suffix).exists():
return True
return False
def settled(path, seconds=SETTLE_SECONDS):
"""Whether the file has stopped being written to."""
try:
stat = Path(path).stat()
except OSError:
return False
return (time.time() - stat.st_mtime) >= seconds and stat.st_size > 0
def importable(path):
"""Whether this is a finished file the library should take."""
path = Path(path)
if path.suffix.lower() not in SUPPORTED:
return False
if not path.is_file():
return False
return not is_partial(path) and settled(path)
def within_wonderink(path, downloads=None):
"""Whether this file belongs to Wonder Ink's own download workflow.
Wonder Ink puts a lesson's worksheets in a directory of its own
inside Downloads. They are worksheets, not presentations: they are
not imported, and nothing in the library ever removes one.
"""
downloads = Path(downloads) if downloads is not None else downloads_root()
try:
relative = Path(path).resolve().relative_to(downloads.resolve())
except (OSError, ValueError):
return False
return WONDERINK_DOWNLOADS in relative.parts
def candidates(downloads=None):
"""Finished presentation files sitting in the teacher's Downloads.
The top level only. Nothing recurses into Wonder Ink's directory,
and nothing looks anywhere else on the machine: a library that
imported from the whole home directory would be a file manager.
"""
downloads = Path(downloads) if downloads is not None else downloads_root()
if not downloads.is_dir():
return []
found = []
try:
entries = sorted(downloads.iterdir())
except OSError:
return []
for entry in entries:
if entry.is_dir():
continue
if within_wonderink(entry, downloads):
continue
if importable(entry):
found.append(entry)
return found
def fingerprint(path):
"""What makes this exact download this exact download.
Path, size and modification time. Enough that re-downloading the same
session tomorrow is a new presentation, and that a reboot does not
import everything in Downloads all over again.
"""
try:
stat = Path(path).stat()
except OSError:
return None
return f"{Path(path).resolve()}|{stat.st_size}|{int(stat.st_mtime)}"
def ledger_path(root=None):
root = Path(root) if root is not None else library_root()
return root / "imported.json"
def ledger(root=None):
data = read_json(ledger_path(root), default={})
return data if isinstance(data, dict) else {}
def remember(mark, value, root=None):
"""Record that this download has been dealt with.
Kept even after a teacher removes the presentation, so that Remove
means removed rather than removed-until-the-next-scan. The original
download is still in Downloads — untouched, as always — and without
this the importer would put it straight back.
"""
data = ledger(root)
data[mark] = value
write_json(ledger_path(root), data)
# ----------------------------------------------------- rendering
# Everything below shells out. Nothing here draws a slide: LibreOffice
# converts a deck to PDF exactly as Impress would have rendered it live,
# and poppler turns PDF pages into pictures. The classroom's own code
# only ever displays a picture one of them produced.
SOFFICE = "soffice"
PDFTOPPM = "pdftoppm"
PDFINFO = "pdfinfo"
PDFTOTEXT = "pdftotext"
# A large deck on a slow machine. Generous, because the alternative to
# waiting is a half-converted file presented to a room.
CONVERT_TIMEOUT = 300
RENDER_TIMEOUT = 600
def _run(command, timeout, **kwargs):
import subprocess
try:
return subprocess.run(
command, capture_output=True, text=True, timeout=timeout,
check=False, **kwargs)
except (OSError, subprocess.SubprocessError) as error:
class Failed:
returncode = 127
stdout = ""
stderr = str(error)
return Failed()
def convert_to_pdf(source, destination):
"""Turn a PPT or PPTX into the PDF everything else works from.
Headless, in a profile of its own, with no window on any screen. A
teacher never sees LibreOffice: not its splash, not its first-run
dialog, and above all not its editor. It runs once, at import, and
then never again for that presentation.
A separate -env:UserInstallation is not optional. LibreOffice takes
an exclusive lock on its user profile, so a conversion running while
anything else on the machine uses LibreOffice would fail, and the
failure would arrive as a presentation that never finished importing.
"""
source = Path(source)
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
workspace = destination.parent / "convert"
profile = destination.parent / "loprofile"
if workspace.exists():
shutil.rmtree(workspace, ignore_errors=True)
workspace.mkdir(parents=True, exist_ok=True)
profile.mkdir(parents=True, exist_ok=True)
result = _run(
[
SOFFICE,
f"-env:UserInstallation=file://{profile}",
"--headless", "--norestore", "--nologo", "--nolockcheck",
"--nodefault", "--nofirststartwizard",
"--convert-to", "pdf:impress_pdf_Export",
"--outdir", str(workspace),
str(source),
],
CONVERT_TIMEOUT,
)
produced = sorted(workspace.glob("*.pdf"))
if not produced:
shutil.rmtree(profile, ignore_errors=True)
shutil.rmtree(workspace, ignore_errors=True)
return False, (result.stderr or result.stdout or
"the file could not be opened").strip()
os.replace(produced[0], destination)
shutil.rmtree(profile, ignore_errors=True)
shutil.rmtree(workspace, ignore_errors=True)
return True, ""
def page_count(pdf):
"""How many slides, according to poppler."""
result = _run([PDFINFO, str(pdf)], 60)
for line in (result.stdout or "").splitlines():
if line.startswith("Pages:"):
try:
return int(line.split(":", 1)[1].strip())
except ValueError:
return 0
return 0
def first_text(pdf, pages=2):
"""A little of the document's own words, for a title of last resort."""
result = _run([PDFTOTEXT, "-f", "1", "-l", str(pages), str(pdf), "-"], 60)
return result.stdout or ""
def render_pages(pdf, directory, width=SLIDE_WIDTH, progress=None):
"""Every slide as a picture, at the size a television shows it.
One pdftoppm run for the whole document rather than one per page:
measured in the lab at 0.03s for ten pages, against a process launch
each time. `progress` is called with the number rendered so far, so a
library window can show a large import filling in rather than freeze.
"""
pdf = Path(pdf)
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
import subprocess
command = [
PDFTOPPM, "-png",
"-scale-to-x", str(width), "-scale-to-y", "-1",
str(pdf), str(directory / "page"),
]
try:
process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True)
except OSError as error:
return 0, str(error)
deadline = time.monotonic() + RENDER_TIMEOUT
while process.poll() is None:
if time.monotonic() > deadline:
process.kill()
return 0, "rendering took too long"
if progress is not None:
progress(len(list(directory.glob("page-*.png"))))
time.sleep(0.4)
if process.returncode != 0:
return 0, (process.stderr.read() if process.stderr else
"the slides could not be rendered")
# pdftoppm numbers by the page count's width: page-1 for a nine-page
# document, page-01 for a ninety-page one. The library asks for
# page-001, so they are renamed into one shape and nothing that reads
# them has to know how many there were.
rendered = sorted(directory.glob("page-*.png"))
for number, path in enumerate(rendered, start=1):
wanted = directory / f"page-{number:03d}.png"
if path != wanted:
os.replace(path, wanted)
return len(rendered), ""
def render_thumbnail(source, destination, width=THUMBNAIL_WIDTH):
"""The small picture on a library card, from an already rendered page."""
source = Path(source)
destination = Path(destination)
if not source.exists():
return False
try:
import gi
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import GdkPixbuf
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(source), width, -1, True)
temporary = destination.with_name(destination.name + ".new")
pixbuf.savev(str(temporary), "png", [], [])
os.replace(temporary, destination)
return True
except Exception: # noqa: BLE001 - a card without a picture is fine
# A thumbnail is a convenience. A library that refused to show a
# presentation because it could not shrink a picture would be
# worse than one showing a card with no picture on it.
return False
+98 -3
View File
@@ -1,9 +1,11 @@
"""The classroom stylesheet, resolved at the room's UI scale. """The classroom's look: its measurements and its palette.
The dock and the DVD control bar share this so they cannot drift apart: The DVD control bar, Parent Call and the Presentations library all draw
one template, one set of measurements, one look. themselves, and they share this so they cannot drift apart: one set of
measurements, one palette, one look.
""" """
import os
from pathlib import Path from pathlib import Path
from classroom_config import scaled from classroom_config import scaled
@@ -34,3 +36,96 @@ def render(scale, template=None):
css = css.replace(f"@{token}@", str(scaled(value, scale))) css = css.replace(f"@{token}@", str(scaled(value, scale)))
return css return css
# ---------------------------------------------------------- the palette
def load_colours():
"""The session's own palette, read from the colour scheme in use.
There is one set of design tokens in this classroom, and they live in
a Plasma colour scheme. Reading them here rather than repeating them
means this card follows classroom-theme light and dark without
knowing that either exists.
"""
palette = {
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
"line": "#25333a",
}
scheme = "EkklesiaDark"
for path in (
os.path.expanduser("~/.config/kdeglobals"),
"/etc/ekklesia-classroom/xdg/kdeglobals",
"/etc/xdg/kdeglobals",
):
try:
with open(path, "r", encoding="utf-8") as handle:
section = None
for line in handle:
line = line.strip()
if line.startswith("["):
section = line
elif section and section.startswith("[General]") and \
line.startswith("ColorScheme="):
scheme = line.split("=", 1)[1].strip()
break
except OSError:
continue
else:
if scheme:
break
def rgb(value):
parts = [part.strip() for part in value.split(",")]
if len(parts) != 3:
return None
try:
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
except ValueError:
return None
try:
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
encoding="utf-8") as handle:
group = None
for line in handle:
line = line.strip()
if line.startswith("["):
group = line
continue
if "=" not in line or group is None:
continue
key, value = (part.strip() for part in line.split("=", 1))
colour = rgb(value)
if colour is None:
continue
if group == "[Colors:Window]":
if key == "BackgroundNormal":
palette["window"] = colour
elif key == "ForegroundNormal":
palette["text"] = colour
elif key == "ForegroundInactive":
palette["dim"] = colour
elif key == "ForegroundNegative":
palette["negative"] = colour
elif key == "ForegroundNeutral":
palette["neutral"] = colour
elif key == "ForegroundPositive":
palette["positive"] = colour
elif key == "BackgroundAlternate":
palette["line"] = colour
elif group == "[Colors:View]" and key == "BackgroundNormal":
palette["view"] = colour
elif group == "[Colors:Selection]":
if key == "BackgroundNormal":
palette["accent"] = colour
elif key == "ForegroundNormal":
palette["on_accent"] = colour
except OSError:
pass
return palette
+23 -1
View File
@@ -4,7 +4,7 @@ Section: misc
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Maintainer: Ekklesia Maintainer: Ekklesia
Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6 Depends: pkexec, polkit-kde-agent-1, plasma-desktop, plasma-workspace, kwin-wayland, plasma-nm, plasma-pa, plasma-widgets-addons, konsole, sddm, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, pulseaudio-utils, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt, python3-pyqt6.qtwebengine, python3-pyqt6, libreoffice-impress, libreoffice-core, poppler-utils, fonts-liberation2, fonts-liberation-sans-narrow, fonts-croscore, fonts-crosextra-carlito, fonts-crosextra-caladea, fonts-urw-base35, fonts-roboto, fonts-open-sans, fonts-lato, fonts-montserrat, fonts-noto-extra
Recommends: vlc Recommends: vlc
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
Description: Ekklesia managed classroom environment Description: Ekklesia managed classroom environment
@@ -36,6 +36,28 @@ Description: Ekklesia managed classroom environment
of this build. Chrome stays exactly where it was, for the ordinary of this build. Chrome stays exactly where it was, for the ordinary
browser. browser.
. .
libreoffice-impress and poppler-utils are Presentations, and neither is
ever shown to a teacher. LibreOffice runs once per import, headless,
with no window on any screen, to turn a PPT or PPTX into a PDF; poppler
turns that PDF into one picture per slide; the classroom's own library
shows the pictures. Measured before it was chosen: `soffice --show`
does start a real slideshow and then leaves the Impress editor on
screen when it ends, and `okular --presentation` opens its own document
window and an information dialog behind the slides. Neither is
something a teacher may be left looking at, and converting once at
import costs 0.34s for a ten-slide deck and nothing at all afterwards.
libreoffice-gtk3 is deliberately NOT here: nothing interactive runs.
.
The fonts are what a Google Slides export asks for. The metric
compatible families cover the Microsoft names — Liberation for Arial,
Times New Roman and Courier New, Carlito for Calibri, Caladea for
Cambria, Croscore for the same three under their Chrome OS names — and
Roboto, Open Sans, Lato and Montserrat are the web fonts Google Slides
itself offers. Every one is in Debian main and installs without a
question. ttf-mscorefonts-installer is deliberately absent: it is in
contrib, it downloads from a third party at install time and it shows
an EULA prompt, and an unattended classroom upgrade cannot answer one.
.
The Conflicts line is deliberate. A welcome tour, a software centre and The Conflicts line is deliberate. A welcome tour, a software centre and
a phone pairing daemon are all things plasma-desktop recommends and a a phone pairing daemon are all things plasma-desktop recommends and a
room a teacher walks into should not have. room a teacher walks into should not have.
+7
View File
@@ -62,6 +62,13 @@ systemctl enable sddm.service >/dev/null 2>&1 || true
systemctl --global enable ekklesia-classroom-audio.service >/dev/null 2>&1 || true systemctl --global enable ekklesia-classroom-audio.service >/dev/null 2>&1 || true
systemctl --global enable ekklesia-classroom-desktop.service >/dev/null 2>&1 || true systemctl --global enable ekklesia-classroom-desktop.service >/dev/null 2>&1 || true
# The presentation watcher. Enabled on every machine and started by every
# session, because whether it does anything is device.conf's answer, not
# the installer's: with PRESENTATIONS_ENABLED=false — which is the
# default, and therefore every existing classroom — it exits immediately
# and the room never runs an importer it has no use for.
systemctl --global enable ekklesia-classroom-presentations.service >/dev/null 2>&1 || true
# The panel's disc player is decided before the display manager starts, not # The panel's disc player is decided before the display manager starts, not
# at the same time as it. Enabled rather than started: this install has # at the same time as it. Enabled rather than started: this install has
# just run the same command directly, below. # just run the same command directly, below.
+52 -2
View File
@@ -3,7 +3,7 @@
"""Match the classroom's panel to the machine it is on. """Match the classroom's panel to the machine it is on.
Which applications a device offers is decided here, once, and written into Which applications a device offers is decided here, once, and written into
the three feature lines of the installed layout. The panel is then built the feature lines of the installed layout. The panel is then built
from that layout at the next login. from that layout at the next login.
Each application is its own question, and the shape of the machine is Each application is its own question, and the shape of the machine is
@@ -15,6 +15,11 @@ never one of them:
Parent Call PARENT_CALL_ENABLED. Same rule. Parent Call PARENT_CALL_ENABLED. Same rule.
Presentations PRESENTATIONS_ENABLED. Same rule again, and it asks
the machine nothing at all: a presentation needs a
screen, which every device has. Alone among these it
defaults to OFF, because no existing room has it.
the disc DVD_ENABLED *and* a drive being present. The setting can the disc DVD_ENABLED *and* a drive being present. The setting can
player take the player away from a machine that has a drive; player take the player away from a machine that has a drive;
nothing can put it on a machine that has none, because a nothing can put it on a machine that has none, because a
@@ -39,6 +44,7 @@ again at the next login.
""" """
import glob import glob
import json
import os import os
import re import re
import sys import sys
@@ -61,10 +67,25 @@ FEATURE_LINES = (
("WONDERINK", "WONDERINK_ENABLED"), ("WONDERINK", "WONDERINK_ENABLED"),
("DVD", "DVD_ENABLED"), ("DVD", "DVD_ENABLED"),
("PARENT_CALL", "PARENT_CALL_ENABLED"), ("PARENT_CALL", "PARENT_CALL_ENABLED"),
("PRESENTATIONS", "PRESENTATIONS_ENABLED"),
) )
MARKER = "/* CLASSROOM-FEATURES-BEGIN */" MARKER = "/* CLASSROOM-FEATURES-BEGIN */"
# WHAT THE MANUAL IS ALLOWED TO KNOW ABOUT THIS DEVICE.
#
# This command already decides what the device offers, so it is also the
# place to write that answer down where the classroom account can read
# it. A list of setting NAMES, and nothing else: no values, no broker
# password, no Wi-Fi, no update address, none of the rest of device.conf
# — which the manual has no use for and the room should not be handed.
#
# In /run rather than /etc because it is derived, not configured: it is
# rewritten from device.conf at every boot by the same service that
# builds the bar, so it cannot go stale behind a setting somebody
# changed.
MANIFEST = "/run/ekklesia-classroom/features.json"
# The kernel names optical drives sr0, sr1, ... regardless of bus. Both # The kernel names optical drives sr0, sr1, ... regardless of bus. Both
# views are checked: /dev is what anything opening the drive will use, and # views are checked: /dev is what anything opening the drive will use, and
# /sys/block is the kernel's own list, which cannot be left behind by a # /sys/block is the kernel's own list, which cannot be left behind by a
@@ -121,6 +142,32 @@ def rewrite(text, variable, answer):
) )
def publish(answers, path=MANIFEST):
"""Leave the enabled features where classroom-manual can read them.
Best effort on purpose. A room whose manual shows a page for
something it has not got is a small confusion; a panel that failed to
build because a directory was read-only is a room with no way of
getting anywhere.
"""
enabled = sorted(
setting for variable, setting in FEATURE_LINES if answers.get(variable)
)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
temporary = f"{path}.new"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(enabled, handle)
os.replace(temporary, path)
os.chmod(path, 0o644)
say(f"the manual will show {', '.join(enabled) or 'no optional pages'}")
except OSError as error:
say(f"could not write {path}: {error}")
def main(): def main():
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT
@@ -137,8 +184,11 @@ def main():
return 0 return 0
after = before after = before
answers = wanted(read_device_config())
for variable, answer in wanted(read_device_config()).items(): publish(answers)
for variable, answer in answers.items():
after = rewrite(after, variable, answer) after = rewrite(after, variable, answer)
if after == before: if after == before:
+4 -89
View File
@@ -105,95 +105,10 @@ OVERALL_TIMEOUT = 210.0
CLOSE_AFTER_SUCCESS = 4.0 CLOSE_AFTER_SUCCESS = 4.0
def load_colours(): # The palette lives in classroom_style, so this card, the Presentations
"""The session's own palette, read from the colour scheme in use. # library and anything else the classroom draws cannot drift apart over
# what "the accent colour" means.
There is one set of design tokens in this classroom, and they live in from classroom_style import load_colours # noqa: E402,F401
a Plasma colour scheme. Reading them here rather than repeating them
means this card follows classroom-theme light and dark without
knowing that either exists.
"""
palette = {
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
"negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97",
"line": "#25333a",
}
scheme = "EkklesiaDark"
for path in (
os.path.expanduser("~/.config/kdeglobals"),
"/etc/ekklesia-classroom/xdg/kdeglobals",
"/etc/xdg/kdeglobals",
):
try:
with open(path, "r", encoding="utf-8") as handle:
section = None
for line in handle:
line = line.strip()
if line.startswith("["):
section = line
elif section and section.startswith("[General]") and \
line.startswith("ColorScheme="):
scheme = line.split("=", 1)[1].strip()
break
except OSError:
continue
else:
if scheme:
break
def rgb(value):
parts = [part.strip() for part in value.split(",")]
if len(parts) != 3:
return None
try:
return "#%02x%02x%02x" % tuple(int(part) for part in parts)
except ValueError:
return None
try:
with open(f"/usr/share/color-schemes/{scheme}.colors", "r",
encoding="utf-8") as handle:
group = None
for line in handle:
line = line.strip()
if line.startswith("["):
group = line
continue
if "=" not in line or group is None:
continue
key, value = (part.strip() for part in line.split("=", 1))
colour = rgb(value)
if colour is None:
continue
if group == "[Colors:Window]":
if key == "BackgroundNormal":
palette["window"] = colour
elif key == "ForegroundNormal":
palette["text"] = colour
elif key == "ForegroundInactive":
palette["dim"] = colour
elif key == "ForegroundNegative":
palette["negative"] = colour
elif key == "ForegroundNeutral":
palette["neutral"] = colour
elif key == "ForegroundPositive":
palette["positive"] = colour
elif key == "BackgroundAlternate":
palette["line"] = colour
elif group == "[Colors:View]" and key == "BackgroundNormal":
palette["view"] = colour
elif group == "[Colors:Selection]":
if key == "BackgroundNormal":
palette["accent"] = colour
elif key == "ForegroundNormal":
palette["on_accent"] = colour
except OSError:
pass
return palette
STYLE = """ STYLE = """
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Watch the teacher's downloads, and keep the library tidy.
A teacher downloads a session in ordinary Chrome and then looks for it in
Presentations. Nothing in between is their job: no file manager, no
import button, no "where did you save it". This is the in between.
classroom-presentation-import run as a service, forever
classroom-presentation-import --once one pass, for a technician
classroom-presentation-import --status what the library holds
WHAT IT WILL NOT DO.
It never touches the teacher's download. The file Chrome wrote stays
where Chrome wrote it, with the name Chrome gave it, for as long as
the teacher leaves it there. Everything the library does is done to
its own copy.
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
directory of its own inside Downloads and those are worksheets, not
sessions; the cleanup will not remove one either.
It never imports a file that is still arriving. Chrome writes
`Session 7.pptx.crdownload` and renames it when the transfer is
done, so a finished name with that sibling beside it is a download in
progress, not a presentation.
It never overwrites a presentation with a later one of the same
name. Two downloads called "Bible Study.pdf" are two presentations.
"""
import argparse
import os
import shutil
import sys
import time
import uuid
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
import classroom_presentations as library # noqa: E402
from classroom_config import feature, read_device_config # noqa: E402
# How often the downloads directory is looked at.
#
# Polling rather than inotify, deliberately. A teacher waits a few
# seconds either way, this cannot leak a watch descriptor or miss an
# event across a restart, and it is the kind of code somebody can still
# read in two years.
SCAN_SECONDS = 4
# How often expired presentations are cleared out.
CLEANUP_SECONDS = 3600
def say(message):
print(f"classroom-presentation-import: {message}", flush=True)
def new_directory(root):
root.mkdir(parents=True, exist_ok=True)
directory = root / uuid.uuid4().hex
directory.mkdir()
return directory
def import_one(source, root=None):
"""Take one finished download into the library.
Ordered so that an interruption is always recoverable. The metadata
is written first, saying "importing"; the sweep at startup throws
away anything still saying that and lets it be imported again. A
presentation only becomes visible to a teacher once every file it
needs is on disk.
"""
root = root if root is not None else library.library_root()
mark = library.fingerprint(source)
if mark is None:
return None
directory = new_directory(root)
managed = directory / source.name
data = {
"id": directory.name,
"state": "importing",
"title": library.readable_title(source.name),
"original_name": source.name,
"source_path": str(source),
"managed_copy": source.name,
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
"imported_at": library.stamp(),
"last_presented_at": None,
"times_presented": 0,
"thumbnail_page": 1,
"pages": 0,
"error": None,
}
presentation = library.Presentation(directory, data)
presentation.save()
# Recorded before the work, so that a file which crashes the
# conversion every time cannot be retried in a loop forever. The
# startup sweep is what un-records an import interrupted part way.
library.remember(mark, directory.name, root)
say(f"importing {source.name}")
try:
# THE TEACHER'S OWN DOWNLOAD IS COPIED, NEVER MOVED.
shutil.copy2(source, managed)
if data["format"] == "pdf":
# Already the thing everything else is converted into. A
# session exported from Google Slides as a PDF keeps exactly
# the appearance Google gave it, with no conversion at all.
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
source.name, library.first_text(presentation.slides)),
library.titles_in_use(root, exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
except Exception as error: # noqa: BLE001
# A card that says it could not be opened, rather than a card
# that opens onto nothing. The teacher's download is untouched
# either way, which is what the message says.
data["state"] = "failed"
data["error"] = str(error)[:300]
presentation.save()
say(f"could not import {source.name}: {error}")
return presentation
def sweep(root=None):
"""Throw away imports that were interrupted, so they can happen again.
A reboot in the middle of a conversion leaves a directory that says
"importing" and a ledger entry claiming the download is dealt with.
Both go, and the next scan imports it properly.
"""
root = root if root is not None else library.library_root()
if not root.is_dir():
return 0
ledger = library.ledger(root)
stale = 0
for presentation in library.library(root):
if presentation.state != "importing":
continue
say(f"clearing an import that did not finish: {presentation.title!r}")
for mark, value in list(ledger.items()):
if value == presentation.id:
ledger.pop(mark, None)
shutil.rmtree(presentation.directory, ignore_errors=True)
stale += 1
if stale:
library.write_json(library.ledger_path(root), ledger)
return stale
def scan(root=None, downloads=None):
"""One look at the downloads directory."""
root = root if root is not None else library.library_root()
known = library.ledger(root)
imported = 0
for candidate in library.candidates(downloads):
mark = library.fingerprint(candidate)
if mark is None or mark in known:
continue
if import_one(candidate, root) is not None:
imported += 1
known = library.ledger(root)
return imported
def cleanup(root=None, moment=None):
"""Remove presentations whose time is up.
Only ever the library's own directory, and never the presentation on
the screen. The teacher's download is not part of this and is not
looked at.
"""
root = root if root is not None else library.library_root()
lock = library.presenting_lock()
showing = ""
try:
showing = lock.read_text().strip()
except OSError:
showing = ""
removed = 0
for presentation in library.library(root):
if not presentation.expired(moment):
continue
if presentation.id == showing:
# A retention timer must never interrupt a room. It will
# still be expired in an hour.
say(f"{presentation.title!r} is expired but is being presented")
continue
if presentation.last_presented_at is None:
why = f"never presented, imported {presentation.data['imported_at']}"
else:
why = f"last presented {presentation.data['last_presented_at']}"
say(f"removing {presentation.title!r} — {why}")
shutil.rmtree(presentation.directory, ignore_errors=True)
removed += 1
return removed
def status(root=None):
presentations = library.library(root)
if not presentations:
print("the library is empty")
return 0
for presentation in presentations:
expires = presentation.expires_at()
print(f"{presentation.title}")
print(f" state {presentation.state}"
f"{' — ' + presentation.error if presentation.error else ''}")
print(f" slides {presentation.pages}"
f" (card shows slide {presentation.thumbnail_page})")
print(f" imported {presentation.data.get('imported_at')}")
presented = presentation.data.get("last_presented_at") or "never"
print(f" presented {presented} "
f"({presentation.times_presented} times)")
print(f" expires {library.stamp(expires) if expires else 'unknown'}")
return 0
def main():
parser = argparse.ArgumentParser(
description="Bring finished downloads into the Presentations library.")
parser.add_argument("--once", action="store_true",
help="one pass, then stop")
parser.add_argument("--status", action="store_true",
help="what the library holds; changes nothing")
parser.add_argument("--cleanup", action="store_true",
help="apply the retention rules now")
arguments = parser.parse_args()
if arguments.status:
return status()
# A device that does not offer Presentations does not run an importer
# for them. Said out loud for the technician who started it by hand.
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
say("PRESENTATIONS_ENABLED is false on this device; not starting")
return 0
if arguments.cleanup:
removed = cleanup()
say(f"removed {removed} expired presentation(s)")
return 0
sweep()
if arguments.once:
imported = scan()
cleanup()
say(f"imported {imported} presentation(s)")
return 0
say(f"watching {library.downloads_root()}")
last_cleanup = 0.0
while True:
try:
scan()
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
cleanup()
last_cleanup = time.monotonic()
except Exception as error: # noqa: BLE001
# One bad file must not stop the watcher. A teacher whose
# next download never appears has no way to find out why.
say(f"scan failed: {error}")
time.sleep(SCAN_SECONDS)
if __name__ == "__main__":
sys.exit(main())
+765
View File
@@ -0,0 +1,765 @@
#!/usr/bin/env python3
"""The Presentations library, as a teacher sees it.
Three buttons on a card — Present, Quick Look, Remove — and nothing else.
No storage locations, no file names, no import controls, no settings, no
retention. A teacher who has downloaded a session in Chrome opens this,
recognises tonight's by its picture, and presses Present.
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
The disc player is a separate process, because a disc plays until
somebody stops it and the panel is how you get back to it. A
presentation is the opposite: it is a thing a teacher steps into and
must step out of, back to the library, reliably, with one key they
already know.
Making the presenter a fullscreen window of this same application is the
whole of that guarantee. Escape is handled here, the fullscreen window is
taken down here, and the library is already on the screen underneath it.
There is no second process to fail to start, no window to be left behind,
and no path by which a teacher ends up looking at something that is not
the library.
That is also why nothing here launches Impress, Okular or any other
document application. Every one of them that was measured leaves its own
editor or viewer on the screen when a slideshow ends — see
docs/presentations.md for what each one actually did. The slides are
pictures by the time they reach this window, rendered at import by
LibreOffice and poppler, and a picture cannot open a file dialog.
"""
import os
import shutil
import sys
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
import classroom_presentations as library # noqa: E402
from classroom_config import feature, read_device_config # noqa: E402
from classroom_style import load_colours # noqa: E402
APP_ID = "ekklesia-presentations"
# How often the library notices a newly imported presentation.
#
# The importer is a separate service, so a session downloaded while this
# window is open appears without the teacher doing anything. Two seconds
# is well under the time it takes to walk back to the machine.
REFRESH_SECONDS = 2
CARD_WIDTH = 320
STYLE = """
window {{ background-color: {window}; }}
.heading {{ color: {text}; font-size: 30px; font-weight: 700; }}
.subtle {{ color: {dim}; font-size: 16px; }}
.card {{
background-color: {view};
border: 1px solid {line};
border-radius: 18px;
padding: 14px;
}}
.card-title {{ color: {text}; font-size: 19px; font-weight: 600; }}
.card-note {{ color: {dim}; font-size: 14px; }}
.thumb {{
background-color: {window};
border: 1px solid {line};
border-radius: 10px;
}}
button {{
color: {text};
background-image: none;
background-color: rgba(255,255,255,0.06);
border: 1px solid {line};
border-radius: 12px;
padding: 10px 14px;
font-size: 16px;
}}
button:hover {{ background-color: rgba(255,255,255,0.12); }}
button.primary {{
color: {on_accent};
background-color: {accent};
border-color: {accent};
font-weight: 700;
}}
button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
.empty {{ color: {dim}; font-size: 20px; }}
.slide-counter {{
color: {text};
background-color: rgba(0,0,0,0.65);
border-radius: 10px;
padding: 6px 12px;
font-size: 16px;
}}
"""
def scaled_pixbuf(path, width):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), width, -1, True)
except (GLib.Error, OSError):
return None
class Presenter(Gtk.Window):
"""The slideshow. A fullscreen window showing one picture at a time.
Everything a presentation remote and a keyboard can reasonably send
is accepted, because a teacher in front of a room does not get to
look up which key this one wants. The clicker in the cupboard sends
Page Up and Page Down; the keyboard sends arrows and space.
Escape is the way out and it is the only way out, which is what makes
this safe to put in front of a teacher: there is nothing else to
press and nowhere else to end up.
"""
def __init__(self, presentation, start_page=1, on_finish=None):
super().__init__(title=presentation.title)
self.presentation = presentation
self.on_finish = on_finish
self.page = min(max(start_page, 1), presentation.pages or 1)
self.counted = False
self.finished = False
self.set_app_paintable(True)
self.fullscreen()
# A slideshow is the only thing on the screen while it is running.
self.set_keep_above(True)
black = Gdk.RGBA()
black.parse("#000000")
self.override_background_color(Gtk.StateFlags.NORMAL, black)
self.image = Gtk.Image()
self.add(self.image)
self.connect("key-press-event", self._key)
self.connect("delete-event", lambda *_: self.finish())
self.connect("size-allocate", self._resized)
self.connect("window-state-event", self._state)
self._drawn_at = (0, 0, 0)
def _state(self, _widget, event):
"""Put the slideshow back on the whole screen if it is taken off it.
A compositor may unfullscreen a window without being asked:
measured in the lab, where focusing another window on the same
workspace dropped the slideshow to half the screen with the
library beside it, which is precisely the "teacher is left
looking at something that is not the presentation" this whole
design is built to prevent. KWin has its own occasions —
a notification, a second output appearing.
So fullscreen is re-asserted rather than assumed, for as long as
the presentation is running. It stops the moment Escape is
pressed, so this can never fight the teacher's way out.
"""
if self.finished:
return False
if not (event.new_window_state & Gdk.WindowState.FULLSCREEN):
self.fullscreen()
return False
# -- showing a slide ------------------------------------------
def _resized(self, _widget, _allocation):
self.show_page()
def show_page(self):
path = self.presentation.page_image(self.page)
allocation = self.get_allocation()
want = (self.page, allocation.width, allocation.height)
if want == self._drawn_at:
return
if not path.exists():
return
pixbuf = self._fitted(path, allocation.width, allocation.height)
if pixbuf is None:
return
self.image.set_from_pixbuf(pixbuf)
self._drawn_at = want
# A SLIDE IS ON THE SCREEN, SO THIS IS A PRESENTATION.
#
# Counted here and nowhere earlier. Pressing Present is an
# intention; a viewer that failed to start, a file that could not
# be read and a window that never drew are all not presentations,
# and none of them may buy another thirty days of retention.
if not self.counted:
self.counted = True
self.presentation.mark_presented()
def _fitted(self, path, width, height):
if width < 2 or height < 2:
return None
try:
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
except (GLib.Error, OSError):
return None
# Letterboxed, never cropped and never stretched. A slide with a
# verse on it cannot lose its edges to fit a television.
ratio = min(width / original.get_width(),
height / original.get_height())
return original.scale_simple(
max(1, int(original.get_width() * ratio)),
max(1, int(original.get_height() * ratio)),
GdkPixbuf.InterpType.BILINEAR,
)
# -- moving about ---------------------------------------------
def go(self, delta):
target = self.page + delta
if target < 1:
return
if target > self.presentation.pages:
# PAST THE LAST SLIDE IS THE END, AND THE END IS THE LIBRARY.
#
# Not a black "click to exit" slide, and certainly not an
# editor. A teacher who clicks once more than there are
# slides is finished, and being finished means being back
# where they started.
self.finish()
return
self.page = target
self.show_page()
def _key(self, _widget, event):
key = event.keyval
forward = (
Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_space, Gdk.KEY_Page_Down,
Gdk.KEY_KP_Page_Down, Gdk.KEY_Return, Gdk.KEY_KP_Enter,
Gdk.KEY_KP_Right, Gdk.KEY_KP_Down, Gdk.KEY_n,
)
backward = (
Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up,
Gdk.KEY_BackSpace, Gdk.KEY_KP_Left, Gdk.KEY_KP_Up, Gdk.KEY_p,
)
if key in forward:
self.go(1)
elif key in backward:
self.go(-1)
elif key in (Gdk.KEY_Home, Gdk.KEY_KP_Home):
self.page = 1
self.show_page()
elif key in (Gdk.KEY_End, Gdk.KEY_KP_End):
self.page = self.presentation.pages
self.show_page()
elif key in (Gdk.KEY_Escape, Gdk.KEY_q, Gdk.KEY_F5):
self.finish()
return True
def finish(self):
if self.finished:
return True
self.finished = True
try:
library.presenting_lock().unlink()
except OSError:
pass
if self.on_finish is not None:
self.on_finish()
self.destroy()
return True
class QuickLook(Gtk.Window):
"""Look through the slides before showing them to a room.
Three things a teacher can do here, and they are kept apart on
purpose because conflating any two of them would be wrong:
browse which slide is on this screen right now;
Use this slide which slide the library card shows;
Present start the slideshow, from the beginning.
Browsing is not presenting: coming in here, looking through every
slide and leaving again must not mark the session as presented or
move its retention timer by a second. Choosing the picture is not
presenting either, and it does not change where a presentation
starts.
"""
def __init__(self, presentation, parent, on_close=None, on_present=None):
super().__init__(title=presentation.title)
self.presentation = presentation
self.on_close = on_close
self.on_present = on_present
self.page = 1
self.set_transient_for(parent)
self.set_modal(True)
self.set_default_size(1000, 760)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
outer.set_border_width(16)
self.add(outer)
heading = Gtk.Label(label=presentation.title, xalign=0)
heading.get_style_context().add_class("heading")
heading.set_line_wrap(True)
outer.pack_start(heading, False, False, 0)
self.slide = Gtk.Image()
frame = Gtk.EventBox()
frame.get_style_context().add_class("thumb")
frame.add(self.slide)
outer.pack_start(frame, True, True, 0)
# Browsing.
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
browse.set_halign(Gtk.Align.CENTER)
self.back = Gtk.Button(label="◀ Previous")
self.back.connect("clicked", lambda *_: self.go(-1))
browse.pack_start(self.back, False, False, 0)
self.counter = Gtk.Label()
self.counter.get_style_context().add_class("subtle")
browse.pack_start(self.counter, False, False, 12)
self.forward = Gtk.Button(label="Next ▶")
self.forward.connect("clicked", lambda *_: self.go(1))
browse.pack_start(self.forward, False, False, 0)
outer.pack_start(browse, False, False, 0)
# The three things this window is for.
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.choose = Gtk.Button(label="Use this slide as the picture")
self.choose.connect("clicked", self._choose)
actions.pack_start(self.choose, False, False, 0)
actions.pack_start(Gtk.Box(), True, True, 0)
close = Gtk.Button(label="Back to Presentations")
close.connect("clicked", lambda *_: self.close_quietly())
actions.pack_start(close, False, False, 0)
present = Gtk.Button(label="Present")
present.get_style_context().add_class("primary")
present.connect("clicked", self._present)
actions.pack_start(present, False, False, 0)
outer.pack_start(actions, False, False, 0)
self.connect("key-press-event", self._key)
self.connect("delete-event", lambda *_: self.close_quietly())
self.connect("size-allocate", lambda *_: self.show_page(False))
self._drawn_at = (0, 0, 0)
self.show_page()
def show_page(self, force=True):
allocation = self.slide.get_allocation()
width = max(320, allocation.width or 900)
want = (self.page, width, 0)
if want == self._drawn_at and not force:
return
path = self.presentation.page_image(self.page)
pixbuf = scaled_pixbuf(path, width) if path.exists() else None
if pixbuf is not None:
self.slide.set_from_pixbuf(pixbuf)
self._drawn_at = want
total = self.presentation.pages
chosen = self.presentation.thumbnail_page
note = " · this is the library picture" if self.page == chosen else ""
self.counter.set_text(f"Slide {self.page} of {total}{note}")
self.back.set_sensitive(self.page > 1)
self.forward.set_sensitive(self.page < total)
self.choose.set_sensitive(self.page != chosen)
def go(self, delta):
target = self.page + delta
if 1 <= target <= self.presentation.pages:
self.page = target
self.show_page()
def _choose(self, *_):
# Writes the chosen slide and nothing else. Not a presentation,
# and not a change to where the slideshow starts.
self.presentation.set_thumbnail_page(self.page)
self.show_page()
if self.on_close is not None:
self.on_close()
def _present(self, *_):
self.destroy()
if self.on_present is not None:
self.on_present(self.presentation)
def close_quietly(self):
"""Leave without having presented anything."""
self.destroy()
if self.on_close is not None:
self.on_close()
return True
def _key(self, _widget, event):
key = event.keyval
if key in (Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_Page_Down,
Gdk.KEY_space):
self.go(1)
elif key in (Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up):
self.go(-1)
elif key == Gdk.KEY_Escape:
self.close_quietly()
else:
return False
return True
class Library(Gtk.Window):
"""The window the panel button opens."""
def __init__(self):
super().__init__(title="Presentations")
self.set_default_size(1180, 820)
self.presenter = None
self._signature = None
self._apply_style()
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
outer.set_border_width(20)
self.add(outer)
heading = Gtk.Label(label="Presentations", xalign=0)
heading.get_style_context().add_class("heading")
outer.pack_start(heading, False, False, 0)
self.note = Gtk.Label(xalign=0)
self.note.get_style_context().add_class("subtle")
outer.pack_start(self.note, False, False, 0)
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
outer.pack_start(scroller, True, True, 8)
self.grid = Gtk.FlowBox()
self.grid.set_valign(Gtk.Align.START)
self.grid.set_max_children_per_line(4)
self.grid.set_min_children_per_line(1)
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
self.grid.set_row_spacing(16)
self.grid.set_column_spacing(16)
self.grid.set_homogeneous(True)
scroller.add(self.grid)
self.connect("key-press-event", self._key)
self.connect("destroy", lambda *_: Gtk.main_quit())
self.refresh()
GLib.timeout_add_seconds(REFRESH_SECONDS, self._tick)
def _apply_style(self):
css = Gtk.CssProvider()
css.load_from_data(STYLE.format(**load_colours()).encode("utf-8"))
Gtk.StyleContext.add_provider_for_screen(
self.get_screen(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
# -- the wall of cards ----------------------------------------
def _tick(self):
self.refresh()
return True
def signature(self, presentations):
"""What the library looks like, so it is only rebuilt when it changes.
Rebuilding a FlowBox under a teacher's pointer every two seconds
would make the buttons unclickable.
"""
return tuple(
(p.id, p.state, p.pages, p.thumbnail_page, p.title)
for p in presentations
)
def refresh(self):
presentations = library.library()
signature = self.signature(presentations)
if signature == self._signature:
return
self._signature = signature
for child in self.grid.get_children():
self.grid.remove(child)
if not presentations:
self.note.set_text(
"Download a presentation in Chrome and it will appear here.")
empty = Gtk.Label(
label="Nothing here yet.\n\n"
"Download a PowerPoint or PDF in Chrome, and it will "
"appear on this screen by itself.")
empty.get_style_context().add_class("empty")
empty.set_justify(Gtk.Justification.CENTER)
self.grid.add(empty)
else:
# Three states, and a file that could not be opened is not
# the same as one still being got ready. Telling a teacher
# something is "being prepared" when it never will be is how
# somebody ends up waiting through a Bible study for it.
ready = sum(1 for p in presentations if p.ready)
broken = sum(1 for p in presentations if p.failed)
working = len(presentations) - ready - broken
parts = [f"{ready} ready to present"]
if working:
parts.append(f"{working} still being prepared")
if broken:
parts.append(f"{broken} could not be opened")
self.note.set_text(", ".join(parts) + ".")
for presentation in presentations:
self.grid.add(self.card(presentation))
self.grid.show_all()
def card(self, presentation):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.get_style_context().add_class("card")
box.set_size_request(CARD_WIDTH, -1)
picture = Gtk.Image()
picture.get_style_context().add_class("thumb")
pixbuf = None
if presentation.thumbnail.exists():
pixbuf = scaled_pixbuf(presentation.thumbnail, CARD_WIDTH - 28)
if pixbuf is not None:
picture.set_from_pixbuf(pixbuf)
else:
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
picture.set_pixel_size(96)
box.pack_start(picture, False, False, 0)
title = Gtk.Label(label=presentation.title, xalign=0)
title.get_style_context().add_class("card-title")
title.set_line_wrap(True)
title.set_max_width_chars(26)
box.pack_start(title, False, False, 0)
note = Gtk.Label(xalign=0)
note.get_style_context().add_class("card-note")
box.pack_start(note, False, False, 0)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
if presentation.ready:
note.set_text(f"{presentation.pages} slides")
present = Gtk.Button(label="Present")
present.get_style_context().add_class("primary")
present.connect("clicked",
lambda *_: self.start_presenting(presentation))
buttons.pack_start(present, True, True, 0)
look = Gtk.Button(label="Quick Look")
look.connect("clicked", lambda *_: self.quick_look(presentation))
buttons.pack_start(look, True, True, 0)
elif presentation.failed:
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
#
# Never the converter's error text, never a path, never a
# terminal. The one useful fact is that their own download is
# still where they left it.
note.set_text("This file could not be opened.\n"
"Your download in Chrome is still there.")
note.set_line_wrap(True)
else:
note.set_text("Getting this ready…")
remove = Gtk.Button(label="Remove")
remove.get_style_context().add_class("danger")
remove.connect("clicked", lambda *_: self.remove(presentation))
buttons.pack_start(remove, True, True, 0)
box.pack_start(buttons, False, False, 0)
return box
# -- what the buttons do --------------------------------------
def start_presenting(self, presentation, start_page=1):
if not presentation.ready:
return
first = presentation.page_image(start_page)
if not first.exists():
self.trouble(presentation,
"This presentation is not ready yet.")
return
try:
lock = library.presenting_lock()
lock.parent.mkdir(parents=True, exist_ok=True)
lock.write_text(presentation.id)
except OSError:
# A lock that could not be written only costs the cleanup its
# politeness. It must not stop a teacher presenting.
pass
self.presenter = Presenter(
presentation, start_page, on_finish=self._presented)
self.presenter.show_all()
self.presenter.present()
def _presented(self):
self.presenter = None
self._signature = None
self.refresh()
self.present_window()
def present_window(self):
self.show_all()
self.present()
def quick_look(self, presentation):
if not presentation.ready:
return
window = QuickLook(
presentation, self,
on_close=self._looked,
on_present=lambda p: self.start_presenting(p),
)
window.show_all()
def _looked(self):
# Coming back from Quick Look is not a presentation. The only
# thing that may have changed is which slide the card shows.
self._signature = None
self.refresh()
def remove(self, presentation):
dialog = Gtk.MessageDialog(
transient_for=self,
modal=True,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.NONE,
text=f"Remove “{presentation.title}”?",
)
dialog.format_secondary_text(
"It will be taken off this screen.\n"
"Your download in Chrome is not affected.")
dialog.add_button("Keep it", Gtk.ResponseType.CANCEL)
dialog.add_button("Remove", Gtk.ResponseType.OK)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
answer = dialog.run()
dialog.destroy()
if answer != Gtk.ResponseType.OK:
return
shutil.rmtree(presentation.directory, ignore_errors=True)
self._signature = None
self.refresh()
def trouble(self, presentation, message):
dialog = Gtk.MessageDialog(
transient_for=self,
modal=True,
message_type=Gtk.MessageType.INFORMATION,
buttons=Gtk.ButtonsType.OK,
text=presentation.title,
)
dialog.format_secondary_text(message)
dialog.run()
dialog.destroy()
def _key(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
# Escape in the library closes the library, which is the same
# thing it does everywhere else in this room.
self.destroy()
return True
return False
def main():
GLib.set_prgname(APP_ID)
GLib.set_application_name("Presentations")
# A device that does not offer Presentations does not open them. The
# panel has no button, so nothing should reach here; saying it out
# loud is for the technician who ran the command by hand.
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
print("classroom-presentations: PRESENTATIONS_ENABLED is false on "
"this device; not starting", flush=True)
return 0
window = Library()
window.show_all()
Gtk.main()
return 0
if __name__ == "__main__":
sys.exit(main())
+6 -2
View File
@@ -62,7 +62,7 @@ sys.path.insert(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
) )
from classroom_config import enabled, read_device_config # noqa: E402 from classroom_config import feature, read_device_config # noqa: E402
HOME_PAGE = "https://app.wonderink.org/" HOME_PAGE = "https://app.wonderink.org/"
@@ -682,7 +682,11 @@ def main():
# A device that has no Wonder Ink has no Wonder Ink. The panel does not # A device that has no Wonder Ink has no Wonder Ink. The panel does not
# offer it, so nothing should reach here; saying it out loud is for the # offer it, so nothing should reach here; saying it out loud is for the
# technician who ran the command by hand and wants to know why. # technician who ran the command by hand and wants to know why.
if not enabled(config, "WONDERINK_ENABLED", default=True): # feature(), not enabled(..., default=True). The default belongs in
# classroom_config.FEATURES with every other application's, so that
# the panel and the command can never disagree about whether a device
# offers this — which is the whole reason that table exists.
if not feature(config, "WONDERINK_ENABLED"):
say("WONDERINK_ENABLED is false on this device; not starting") say("WONDERINK_ENABLED is false on this device; not starting")
return 0 return 0
@@ -0,0 +1,28 @@
[Unit]
Description=Ekklesia Classroom presentation import
Documentation=file:///usr/local/bin/classroom-presentation-import
# Nothing graphical: it copies, converts and renders files. It is a user
# service rather than a system one because the downloads it watches and
# the library it fills both belong to the classroom account, and a root
# service writing into a teacher's home would have to be told about every
# one of those permissions.
PartOf=graphical-session.target
[Service]
ExecStart=/usr/local/bin/classroom-presentation-import
# It exits 0 immediately on a device with PRESENTATIONS_ENABLED=false, and
# on-failure means such a device is left alone rather than restarted in a
# loop forever. A device that does have Presentations gets the watcher
# back if it ever dies, because a teacher whose download never appears has
# no way at all to find out why.
Restart=on-failure
RestartSec=10
Slice=session.slice
# LibreOffice converting a large deck must never be what a teacher notices
# about the machine. The room's own applications come first; this is the
# only thing here that is allowed to take a while.
Nice=10
IOSchedulingClass=idle
[Install]
WantedBy=plasma-workspace.target
+88 -10
View File
@@ -85,8 +85,16 @@ check("a desktop can have Wonder Ink taken away",
check("and keeps everything else", check("and keeps everything else",
desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True) desktop_no_ink["DVD"] is True and desktop_no_ink["PARENT_CALL"] is True)
check("an existing room that has never heard of these keeps them all", unset = module.wanted({})
all(module.wanted({}).values()), str(module.wanted({}))) check("an existing room that has never heard of these keeps all three",
unset["WONDERINK"] and unset["DVD"] and unset["PARENT_CALL"],
str(unset))
# The one that must NOT follow that rule. Every existing classroom has
# Wonder Ink, a drive and Parent Call, so an upgrade has to leave all
# three on. No existing classroom has Presentations, so an upgrade has to
# leave it off — a new button on a bar nobody asked for is a support call.
check("but Presentations is off until a device asks for it",
unset["PRESENTATIONS"] is False, str(unset))
print("3. a drive can only ever take the player away") print("3. a drive can only ever take the player away")
@@ -126,13 +134,18 @@ check("and an unconfigured machine keeps every application",
print("5. the bar that comes out, evaluated in node") print("5. the bar that comes out, evaluated in node")
STUB = r""" STUB = r"""
const fs = require("fs"); const fs = require("fs");
const captured = {widgets: [], config: {}}; const captured = {widgets: [], config: {}, launchers: []};
let current = null; let current = null;
function makeWidget(type) { function makeWidget(type) {
const w = { const w = {
type: type, type: type,
currentConfigGroup: [], currentConfigGroup: [],
writeConfig: function (k, v) { captured.config[type + "." + k] = String(v); }, writeConfig: function (k, v) {
captured.config[type + "." + k] = String(v);
// Quick Launch widgets share a config key, so the last one
// written would otherwise be the only one anybody could see.
if (k === "launcherUrls") { captured.launchers.push(String(v)); }
},
reloadConfig: function () {} reloadConfig: function () {}
}; };
captured.widgets.push(type); captured.widgets.push(type);
@@ -172,8 +185,15 @@ else:
check("a classroom gets Wonder Ink, the browser and the player, in order", check("a classroom gets Wonder Ink, the browser and the player, in order",
every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}", every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
(every or {}).get("config", {}).get(LAUNCH, why)) (every or {}).get("config", {}).get(LAUNCH, why))
CALL = "file:///usr/share/applications/ekklesia-parent-call.desktop"
HELP = "file:///usr/share/applications/ekklesia-manual.desktop"
check("and Parent Call in the corner", check("and Parent Call in the corner",
every and "org.kde.plasma.quicklaunch" in every["widgets"]) every and CALL in every["launchers"],
str((every or {}).get("launchers")))
check("and Help beside it",
every and HELP in every["launchers"],
str((every or {}).get("launchers")))
no_ink, why = bar({"WONDERINK_ENABLED": "false"}) no_ink, why = bar({"WONDERINK_ENABLED": "false"})
check("a Bible study desktop has the browser first and no gap", check("a Bible study desktop has the browser first and no gap",
@@ -188,8 +208,50 @@ else:
bible and bible["config"].get(LAUNCH) == WEB, bible and bible["config"].get(LAUNCH) == WEB,
(bible or {}).get("config", {}).get(LAUNCH, why)) (bible or {}).get("config", {}).get(LAUNCH, why))
check("with no Parent Call button on it", check("with no Parent Call button on it",
bible and "org.kde.plasma.quicklaunch" not in bible["widgets"], bible and CALL not in bible["launchers"],
str((bible or {}).get("widgets"))) str((bible or {}).get("launchers")))
# Help is not optional. A teacher who needs the manual is by
# definition one who does not know where to look, so every room has
# it — what differs is which pages it opens on.
check("but Help is still there, because every room has a manual",
bible and HELP in bible["launchers"],
str((bible or {}).get("launchers")))
SLIDES = "applications:ekklesia-presentations.desktop"
# THE DOCK ORDER IS THE THING NOT TO BREAK.
#
# Every existing room has Wonder Ink, the browser and the player in
# that order, and teachers have been pressing them in that order for
# months. Presentations is appended, so all three keep their place on
# every machine that upgrades into this release.
module.has_optical_drive = lambda: True
slides, why = bar({"PRESENTATIONS_ENABLED": "true"})
check("Presentations goes on the END of the row, after the player",
slides and slides["config"].get(LAUNCH)
== f"{INK},{WEB},{DISC},{SLIDES}",
(slides or {}).get("config", {}).get(LAUNCH, why))
without, why = bar({"PRESENTATIONS_ENABLED": "false"})
check("and is absent entirely when the device does not offer it",
without and SLIDES not in without["config"].get(LAUNCH, ""),
(without or {}).get("config", {}).get(LAUNCH, why))
check("which leaves the other three exactly where they were",
without and without["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
(without or {}).get("config", {}).get(LAUNCH, why))
# The prayer-room laptop: the browser and Presentations, nothing else.
module.has_optical_drive = lambda: False
study, why = bar({"PRESENTATIONS_ENABLED": "true",
"WONDERINK_ENABLED": "false",
"PARENT_CALL_ENABLED": "false",
"DEVICE_TYPE": "laptop"})
check("the Bible study laptop gets the browser and Presentations",
study and study["config"].get(LAUNCH) == f"{WEB},{SLIDES}",
(study or {}).get("config", {}).get(LAUNCH, why))
check("and still no Parent Call on it",
study and CALL not in study["launchers"],
str((study or {}).get("launchers")))
check("and the status corner is untouched", check("and the status corner is untouched",
bible and "org.kde.plasma.volume" in bible["widgets"] bible and "org.kde.plasma.volume" in bible["widgets"]
and "org.kde.plasma.digitalclock" in bible["widgets"] and "org.kde.plasma.digitalclock" in bible["widgets"]
@@ -227,12 +289,28 @@ print("7. a disabled application does not start either")
# talked into running it by a stale desktop entry or a technician's # talked into running it by a stale desktop entry or a technician's
# muscle memory. # muscle memory.
for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"), for script, setting in (("classroom-wonderink", "WONDERINK_ENABLED"),
("classroom-parent-call", "PARENT_CALL_ENABLED")): ("classroom-parent-call", "PARENT_CALL_ENABLED"),
("classroom-presentations", "PRESENTATIONS_ENABLED"),
("classroom-presentation-import",
"PRESENTATIONS_ENABLED")):
text = (root / "scripts" / script).read_text() text = (root / "scripts" / script).read_text()
check(f"{script} refuses when {setting} is false", check(f"{script} refuses when {setting} is false",
setting in text and "not starting" in text) setting in text and "not starting" in text)
check(f"{script} defaults to starting, so existing rooms are unaffected", check(f"{script} asks the shared helper rather than reading the file",
'default=True' in text or 'feature(' in text) "feature(" in text and "read_device_config()" in text)
# And each of them defaults the way its own room needs, which is not the
# same answer for all four. Asserting one rule for all of them would pass
# for the wrong reason on the one that differs.
from classroom_config import feature as _feature # noqa: E402
for setting, default, why in (
("WONDERINK_ENABLED", True, "every existing classroom has it"),
("DVD_ENABLED", True, "every existing classroom has it"),
("PARENT_CALL_ENABLED", True, "every existing classroom has it"),
("PRESENTATIONS_ENABLED", False, "no existing classroom has it")):
check(f"{setting} defaults to {default} — {why}",
_feature({}, setting) is default)
print() print()
print(f"{passed} passed, {failed} failed") print(f"{passed} passed, {failed} failed")
+263
View File
@@ -0,0 +1,263 @@
#!/bin/bash
# The Presentations library: what it imports, what it refuses, what it
# keeps and what it is never allowed to delete.
#
# No LibreOffice and no poppler here. Those are exercised by the lab and
# by the release validator against a real Debian; this is the part that
# has to be right whatever is installed — when a download counts as
# finished, whose files may be touched, what a teacher's choice of
# thumbnail does and does not change, and which of the two retention
# clocks is running.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import os
import shutil
import sys
import tempfile
import time
from datetime import timedelta
from pathlib import Path
root = Path(sys.argv[1])
sys.path.insert(0, str(root / "lib"))
import classroom_presentations as library
passed = failed = 0
def check(label, condition, detail=""):
global passed, failed
if condition:
print(f" ok {label}")
passed += 1
else:
print(f" FAIL {label}")
if detail:
print(f" {detail}")
failed += 1
work = Path(tempfile.mkdtemp(prefix="classroom-presentations-"))
downloads = work / "Downloads"
wonderink = downloads / "Wonder Ink"
shelf = work / "library"
wonderink.mkdir(parents=True)
shelf.mkdir(parents=True)
def download(name, contents="x" * 400, age=60):
"""A file sitting in Downloads, finished `age` seconds ago."""
path = downloads / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents)
when = time.time() - age
os.utime(path, (when, when))
return path
def entry(**data):
"""A presentation in the library, without running an import."""
directory = shelf / data.get("id", "one")
directory.mkdir(parents=True, exist_ok=True)
base = {
"id": directory.name,
"state": "ready",
"title": "A session",
"pages": 10,
"thumbnail_page": 1,
"imported_at": library.stamp(),
"last_presented_at": None,
"times_presented": 0,
}
base.update(data)
presentation = library.Presentation(directory, base)
presentation.save()
(directory / "slides.pdf").write_text("%PDF-1.4")
return presentation
print("1. a download that has finished, and ones that have not")
done = download("Session 7 - Romans.pptx")
check("a settled PowerPoint is importable", library.importable(done))
arriving = download("Still arriving.pptx")
partial = downloads / "Still arriving.pptx.crdownload"
partial.write_text("half")
check("one with a .crdownload beside it is not",
not library.importable(arriving),
"Chrome renames the .crdownload away when the transfer completes")
check("and the .crdownload itself is never a candidate",
not library.importable(partial))
fresh = download("Just landed.pdf", age=0)
check("one still being written is not",
not library.importable(fresh),
"a file copied from a USB stick has no .crdownload to go by")
check("something that is not a presentation is not",
not library.importable(download("notes.txt")))
empty = download("Nothing.pdf", contents="", age=60)
check("a nought-byte file is not", not library.importable(empty))
print("2. Wonder Ink's worksheets are not presentations")
worksheet = wonderink / "Colouring page.pdf"
worksheet.write_text("%PDF-1.4" + "x" * 400)
os.utime(worksheet, (time.time() - 60, time.time() - 60))
check("a file in the Wonder Ink folder is recognised as Wonder Ink's",
library.within_wonderink(worksheet, downloads))
check("and one beside it in Downloads is not",
not library.within_wonderink(done, downloads))
names = [p.name for p in library.candidates(downloads)]
check("so it is not offered for import", "Colouring page.pdf" not in names,
str(names))
check("the finished session is", "Session 7 - Romans.pptx" in names, str(names))
check("and nothing unfinished is",
"Still arriving.pptx" not in names and "Just landed.pdf" not in names,
str(names))
print("3. titles a teacher would recognise")
check("an ordinary name is kept",
library.readable_title("Session 7 - Romans.pptx") == "Session 7 - Romans",
library.readable_title("Session 7 - Romans.pptx"))
check("underscores become spaces",
library.readable_title("Session_7_Romans.pdf") == "Session 7 Romans",
library.readable_title("Session_7_Romans.pdf"))
check("a browser's duplicate counter goes",
library.readable_title("Bible Study (1).pdf") == "Bible Study",
library.readable_title("Bible Study (1).pdf"))
check("a percent-escaped space is decoded",
library.readable_title("Session%207.pdf") == "Session 7",
library.readable_title("Session%207.pdf"))
hashed = library.readable_title(
"a4f39c2e91bb4d7e.pdf", "Session 7 of the autumn study\nmore")
check("a hash falls back to the document's own first words",
hashed == "Session 7 of the autumn study", hashed)
check("and something with no name at all still gets one",
library.readable_title("...pdf", "") == "Presentation",
library.readable_title("...pdf", ""))
print("4. two downloads of the same name are two presentations")
first = entry(id="first", title="Session 7")
second = entry(id="second", title="Session 7")
# The bug this replaces: excluding the import being named BY TITLE also
# removed the other presentation's identical title from the set, so a
# PDF and a PPTX of one session both came out called "Session 7".
taken = library.titles_in_use(shelf, exclude="second")
check("the other presentation's title is still in the way",
"Session 7" in taken, str(taken))
check("so the second gets a title of its own",
library.unique_title("Session 7", taken) == "Session 7 (2)",
library.unique_title("Session 7", taken))
check("and a name nothing else has is left alone",
library.unique_title("Session 8", taken) == "Session 8")
shutil.rmtree(shelf / "first")
shutil.rmtree(shelf / "second")
print("5. the thumbnail is the teacher's choice, and only that")
# The first slide of a Bible study session is a generic opening shared by
# every session; the one that names the session is usually the second.
p = entry(id="thumb", pages=12)
before = dict(p.data)
p.set_thumbnail_page(2)
after = library.load(shelf / "thumb")
check("the chosen slide is remembered", after.thumbnail_page == 2,
str(after.thumbnail_page))
check("it survives being read back from disk, so it survives a restart",
library.load(shelf / "thumb").thumbnail_page == 2)
check("choosing it did not mark the session as presented",
after.last_presented_at is None and after.times_presented == 0)
check("and did not move the retention clock",
after.data["imported_at"] == before["imported_at"]
and after.data.get("last_presented_at") is None)
check("a slide past the end is clamped rather than stored",
entry(id="thumb2", pages=5, thumbnail_page=99).thumbnail_page == 5)
check("and a nonsense one does not crash the card",
entry(id="thumb3", pages=5, thumbnail_page="banana").thumbnail_page == 1)
print("6. the two retention clocks")
old = library.now() - timedelta(days=181)
recent = library.now() - timedelta(days=100)
never = entry(id="never", imported_at=library.stamp(old))
check("never presented, imported over 180 days ago, is expired",
never.expired(), str(never.expires_at()))
young = entry(id="young", imported_at=library.stamp(recent))
check("never presented but only 100 days old is not",
not young.expired(), str(young.expires_at()))
shown = entry(id="shown",
imported_at=library.stamp(old),
last_presented_at=library.stamp(library.now() - timedelta(days=2)),
times_presented=3)
check("presented two days ago is kept, however old the import is",
not shown.expired(),
"presenting has to start the 30-day clock and stop the 180-day one")
stale = entry(id="stale",
imported_at=library.stamp(old),
last_presented_at=library.stamp(library.now() - timedelta(days=31)),
times_presented=1)
check("presented 31 days ago is expired", stale.expired())
print("7. presenting is the only thing that resets the clock")
p = entry(id="counted", pages=4)
check("a fresh import has never been presented",
p.last_presented_at is None and p.times_presented == 0)
p.mark_presented()
reread = library.load(shelf / "counted")
check("presenting is recorded", reread.last_presented_at is not None)
check("and counted", reread.times_presented == 1)
first_due = reread.expires_at()
time.sleep(1.1)
reread.mark_presented()
again = library.load(shelf / "counted")
check("presenting again starts the thirty days over",
again.expires_at() > first_due, f"{first_due} -> {again.expires_at()}")
check("and counts again", again.times_presented == 2)
print("8. an import that was interrupted is not shown to a teacher")
half = entry(id="half", state="importing")
check("it is in the library's own list",
any(x.id == "half" for x in library.library(shelf)))
check("but it is not ready, so no card offers Present",
not library.load(shelf / "half").ready)
broken = shelf / "broken"
broken.mkdir()
(broken / "presentation.json").write_text("{ this is not json")
check("a truncated metadata file is skipped rather than crashing the library",
all(x.id != "broken" for x in library.library(shelf)),
"a card a teacher cannot open is worse than no card")
print("9. the presenting lock cannot outlive a reboot")
check("it lives in the runtime directory",
"/run" in str(library.presenting_lock())
or str(library.presenting_lock()).startswith("/tmp"),
str(library.presenting_lock()))
print("10. the ledger remembers a download after its presentation is gone")
mark = library.fingerprint(done)
library.remember(mark, "some-id", shelf)
check("a fingerprint is path, size and time",
mark is not None and str(done.resolve()) in mark, str(mark))
check("it is written down", library.ledger(shelf).get(mark) == "some-id")
check("so Remove means removed, not removed until the next scan",
mark in library.ledger(shelf))
touched = download("Session 7 - Romans.pptx", contents="y" * 900)
check("but re-downloading the same session is a new presentation",
library.fingerprint(touched) != mark,
"a different size or time is a different download")
shutil.rmtree(work, ignore_errors=True)
print()
print(f"{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
PY
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Build a presentation to test the classroom's library with.
Representative test material, NOT the pastor's own files. The shape is
copied from the real Bible study decks and nothing else about them is:
slide 1 a generic opening, the same on every session
slide 2 the one that names the session
slide 3+ the session's content
That first pair is the whole reason Quick Look lets a teacher choose
which slide becomes the library thumbnail. A library that assumed slide
one would show every session under the same picture.
Writes a flat ODF presentation, which LibreOffice then converts to
whatever the test needs:
tools/lab/make-presentation --slides 12 --session 7 out.fodp
soffice --headless --convert-to pptx out.fodp
soffice --headless --convert-to pdf out.fodp
"""
import argparse
import html
import sys
HEADER = """<?xml version="1.0" encoding="UTF-8"?>
<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
office:version="1.3"
office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:automatic-styles>
<style:style style:name="title" style:family="presentation">
<style:graphic-properties draw:fill="none" draw:stroke="none"/>
<style:paragraph-properties fo:text-align="center"/>
<style:text-properties fo:font-size="40pt" fo:font-weight="bold"
style:font-name-complex="Liberation Sans"/>
</style:style>
<style:style style:name="body" style:family="presentation">
<style:graphic-properties draw:fill="none" draw:stroke="none"/>
<style:text-properties fo:font-size="24pt"/>
</style:style>
</office:automatic-styles>
<office:body>
<office:presentation>
"""
FOOTER = """ </office:presentation>
</office:body>
</office:document>
"""
PAGE = """ <draw:page draw:name="{name}" draw:master-page-name="Default">
<draw:frame draw:style-name="title" draw:layer="layout"
svg:width="24cm" svg:height="4cm" svg:x="1.4cm" svg:y="3cm">
<draw:text-box>{title}</draw:text-box>
</draw:frame>
<draw:frame draw:style-name="body" draw:layer="layout"
svg:width="24cm" svg:height="8cm" svg:x="1.4cm" svg:y="8cm">
<draw:text-box>{body}</draw:text-box>
</draw:frame>
</draw:page>
"""
# Deliberately ordinary words. Nothing here is anybody's lesson.
POINTS = (
"Read the passage aloud together",
"What does this tell us about God?",
"Where do you see this in your own week?",
"One thing to carry into Sunday",
)
def paragraphs(lines):
return "".join(
f"<text:p>{html.escape(line)}</text:p>" for line in lines
)
def build(slides, session):
pages = []
# Slide 1 — the generic opening every session shares.
pages.append(PAGE.format(
name="page1",
title=paragraphs(["Wednesday Bible Study"]),
body=paragraphs(["River of Glory", "Welcome"]),
))
# Slide 2 — the one a teacher has to be able to pick as the thumbnail.
pages.append(PAGE.format(
name="page2",
title=paragraphs([f"Session {session}"]),
body=paragraphs([f"Session {session} of the autumn study",
"Tonight's passage and questions"]),
))
for number in range(3, slides + 1):
pages.append(PAGE.format(
name=f"page{number}",
title=paragraphs([f"Point {number - 2}"]),
body=paragraphs([
POINTS[(number - 3) % len(POINTS)],
f"(slide {number} of {slides})",
]),
))
return HEADER + "".join(pages) + FOOTER
def main():
parser = argparse.ArgumentParser(
description="Build representative Bible study test material.")
parser.add_argument("output", help="where to write the .fodp")
parser.add_argument("--slides", type=int, default=10,
help="how many slides in total (minimum 2)")
parser.add_argument("--session", type=int, default=7,
help="the session number slide 2 announces")
arguments = parser.parse_args()
if arguments.slides < 2:
print("a test deck needs at least the opening and the session slide",
file=sys.stderr)
return 2
with open(arguments.output, "w", encoding="utf-8") as handle:
handle.write(build(arguments.slides, arguments.session))
print(f"{arguments.output}: {arguments.slides} slides, session "
f"{arguments.session}")
return 0
if __name__ == "__main__":
sys.exit(main())