diff --git a/config/icons/ekklesia-presentations.svg b/config/icons/ekklesia-presentations.svg
new file mode 100644
index 0000000..c9b1b01
--- /dev/null
+++ b/config/icons/ekklesia-presentations.svg
@@ -0,0 +1,35 @@
+
+
diff --git a/config/plasma/applications/ekklesia-presentations.desktop b/config/plasma/applications/ekklesia-presentations.desktop
new file mode 100644
index 0000000..9fa9d40
--- /dev/null
+++ b/config/plasma/applications/ekklesia-presentations.desktop
@@ -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
diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js
index 0b3020f..7c33924 100644
--- a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js
+++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js
@@ -12,19 +12,22 @@
// WHICH APPLICATIONS THIS DEVICE OFFERS.
//
-// classroom-desktop-layout rewrites the three lines between the markers
-// to match device.conf and this machine's hardware, and nothing else in
+// classroom-desktop-layout rewrites the lines between the markers to
+// 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
// 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
// allowed to decide what it is for.
//
-// All true here is the shipped default, which is what an existing
-// classroom must come up as.
+// The first three are true here, which is what an existing classroom must
+// 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 */
var WONDERINK = true;
var DVD = true;
var PARENT_CALL = true;
+var PRESENTATIONS = false;
/* CLASSROOM-FEATURES-END */
var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
@@ -84,6 +87,27 @@ if (PARENT_CALL) {
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");
leftGap.currentConfigGroup = ["General"];
leftGap.writeConfig("expanding", true);
@@ -114,6 +138,17 @@ if (DVD) {
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(","));
// THE ORDER STAYS PUT WHEN AN APPLICATION OPENS.
diff --git a/docs/presentations.md b/docs/presentations.md
new file mode 100644
index 0000000..719ed40
--- /dev/null
+++ b/docs/presentations.md
@@ -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
+ /
+ 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".
diff --git a/docs/screenshots/presentations-after-escape.png b/docs/screenshots/presentations-after-escape.png
new file mode 100644
index 0000000..61c4c8b
Binary files /dev/null and b/docs/screenshots/presentations-after-escape.png differ
diff --git a/docs/screenshots/presentations-library.png b/docs/screenshots/presentations-library.png
new file mode 100644
index 0000000..0d5503f
Binary files /dev/null and b/docs/screenshots/presentations-library.png differ
diff --git a/docs/screenshots/presentations-presenting.png b/docs/screenshots/presentations-presenting.png
new file mode 100644
index 0000000..3c544ca
Binary files /dev/null and b/docs/screenshots/presentations-presenting.png differ
diff --git a/docs/screenshots/presentations-quicklook.png b/docs/screenshots/presentations-quicklook.png
new file mode 100644
index 0000000..b9f3b26
Binary files /dev/null and b/docs/screenshots/presentations-quicklook.png differ
diff --git a/docs/screenshots/presentations-thumbnail-chosen.png b/docs/screenshots/presentations-thumbnail-chosen.png
new file mode 100644
index 0000000..51a9ba0
Binary files /dev/null and b/docs/screenshots/presentations-thumbnail-chosen.png differ
diff --git a/lib/classroom_config.py b/lib/classroom_config.py
index ec16190..1bd72de 100644
--- a/lib/classroom_config.py
+++ b/lib/classroom_config.py
@@ -101,6 +101,19 @@ DVD_ENABLED=true
# in the corner is then one more thing to explain.
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.
#
# 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
# in the corner is then one more thing to explain.
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,
"DVD_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,
}
diff --git a/lib/classroom_presentations.py b/lib/classroom_presentations.py
new file mode 100644
index 0000000..fb101ec
--- /dev/null
+++ b/lib/classroom_presentations.py
@@ -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
diff --git a/lib/classroom_style.py b/lib/classroom_style.py
index 8197dce..ecc6081 100644
--- a/lib/classroom_style.py
+++ b/lib/classroom_style.py
@@ -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:
-one template, one set of measurements, one look.
+The DVD control bar, Parent Call and the Presentations library all draw
+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 classroom_config import scaled
@@ -34,3 +36,96 @@ def render(scale, template=None):
css = css.replace(f"@{token}@", str(scaled(value, scale)))
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
diff --git a/packaging/debian/control.template b/packaging/debian/control.template
index 26e73ca..306b322 100644
--- a/packaging/debian/control.template
+++ b/packaging/debian/control.template
@@ -4,7 +4,7 @@ Section: misc
Priority: optional
Architecture: amd64
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
Conflicts: plasma-welcome, plasma-discover, kdeconnect, kup-backup
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
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
a phone pairing daemon are all things plasma-desktop recommends and a
room a teacher walks into should not have.
diff --git a/packaging/debian/postinst b/packaging/debian/postinst
index 4d322b3..49877dc 100755
--- a/packaging/debian/postinst
+++ b/packaging/debian/postinst
@@ -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-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
# at the same time as it. Enabled rather than started: this install has
# just run the same command directly, below.
diff --git a/scripts/classroom-desktop-layout b/scripts/classroom-desktop-layout
index 8a2afae..c4d330b 100755
--- a/scripts/classroom-desktop-layout
+++ b/scripts/classroom-desktop-layout
@@ -3,7 +3,7 @@
"""Match the classroom's panel to the machine it is on.
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.
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.
+ 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
player take the player away from a machine that has a drive;
nothing can put it on a machine that has none, because a
@@ -39,6 +44,7 @@ again at the next login.
"""
import glob
+import json
import os
import re
import sys
@@ -61,10 +67,25 @@ FEATURE_LINES = (
("WONDERINK", "WONDERINK_ENABLED"),
("DVD", "DVD_ENABLED"),
("PARENT_CALL", "PARENT_CALL_ENABLED"),
+ ("PRESENTATIONS", "PRESENTATIONS_ENABLED"),
)
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
# 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
@@ -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():
path = sys.argv[1] if len(sys.argv) > 1 else LAYOUT
@@ -137,8 +184,11 @@ def main():
return 0
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)
if after == before:
diff --git a/scripts/classroom-parent-call b/scripts/classroom-parent-call
index 6e51516..10e47d5 100755
--- a/scripts/classroom-parent-call
+++ b/scripts/classroom-parent-call
@@ -105,95 +105,10 @@ OVERALL_TIMEOUT = 210.0
CLOSE_AFTER_SUCCESS = 4.0
-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
+# The palette lives in classroom_style, so this card, the Presentations
+# library and anything else the classroom draws cannot drift apart over
+# what "the accent colour" means.
+from classroom_style import load_colours # noqa: E402,F401
STYLE = """
diff --git a/scripts/classroom-presentation-import b/scripts/classroom-presentation-import
new file mode 100755
index 0000000..41aebb1
--- /dev/null
+++ b/scripts/classroom-presentation-import
@@ -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())
diff --git a/scripts/classroom-presentations b/scripts/classroom-presentations
new file mode 100755
index 0000000..cdf007a
--- /dev/null
+++ b/scripts/classroom-presentations
@@ -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())
diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink
index a2d7dbb..e9d2378 100755
--- a/scripts/classroom-wonderink
+++ b/scripts/classroom-wonderink
@@ -62,7 +62,7 @@ sys.path.insert(
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/"
@@ -682,7 +682,11 @@ def main():
# 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
# 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")
return 0
diff --git a/systemd/user/ekklesia-classroom-presentations.service b/systemd/user/ekklesia-classroom-presentations.service
new file mode 100644
index 0000000..f1b10b6
--- /dev/null
+++ b/systemd/user/ekklesia-classroom-presentations.service
@@ -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
diff --git a/tests/test-classroom-desktop-layout.sh b/tests/test-classroom-desktop-layout.sh
index 22365a0..3dc54e7 100755
--- a/tests/test-classroom-desktop-layout.sh
+++ b/tests/test-classroom-desktop-layout.sh
@@ -85,8 +85,16 @@ check("a desktop can have Wonder Ink taken away",
check("and keeps everything else",
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",
- all(module.wanted({}).values()), str(module.wanted({})))
+unset = 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")
@@ -126,13 +134,18 @@ check("and an unconfigured machine keeps every application",
print("5. the bar that comes out, evaluated in node")
STUB = r"""
const fs = require("fs");
-const captured = {widgets: [], config: {}};
+const captured = {widgets: [], config: {}, launchers: []};
let current = null;
function makeWidget(type) {
const w = {
type: type,
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 () {}
};
captured.widgets.push(type);
@@ -172,8 +185,15 @@ else:
check("a classroom gets Wonder Ink, the browser and the player, in order",
every and every["config"].get(LAUNCH) == f"{INK},{WEB},{DISC}",
(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",
- 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"})
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 or {}).get("config", {}).get(LAUNCH, why))
check("with no Parent Call button on it",
- bible and "org.kde.plasma.quicklaunch" not in bible["widgets"],
- str((bible or {}).get("widgets")))
+ bible and CALL not in bible["launchers"],
+ 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",
bible and "org.kde.plasma.volume" 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
# muscle memory.
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()
check(f"{script} refuses when {setting} is false",
setting in text and "not starting" in text)
- check(f"{script} defaults to starting, so existing rooms are unaffected",
- 'default=True' in text or 'feature(' in text)
+ check(f"{script} asks the shared helper rather than reading the file",
+ "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(f"{passed} passed, {failed} failed")
diff --git a/tests/test-classroom-presentations.sh b/tests/test-classroom-presentations.sh
new file mode 100755
index 0000000..4bcda51
--- /dev/null
+++ b/tests/test-classroom-presentations.sh
@@ -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
diff --git a/tools/lab/make-presentation b/tools/lab/make-presentation
new file mode 100755
index 0000000..1bf2534
--- /dev/null
+++ b/tools/lab/make-presentation
@@ -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 = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+FOOTER = """
+
+
+"""
+
+PAGE = """
+
+ {title}
+
+
+ {body}
+
+
+"""
+
+# 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"{html.escape(line)}" 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())