fix: harden classroom presentations
This commit is contained in:
@@ -87,26 +87,9 @@ 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);
|
||||
// Help is intentionally absent from this release's teacher-facing panel.
|
||||
// Its launcher, source pages and build pipeline remain installed for the
|
||||
// documentation correction pass; only the unfinished entry point is hidden.
|
||||
|
||||
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
|
||||
leftGap.currentConfigGroup = ["General"];
|
||||
|
||||
@@ -82,15 +82,14 @@ NoiseStrength=2
|
||||
[org.kde.kdecoration2][$i]
|
||||
library=org.kde.breeze
|
||||
theme=Breeze
|
||||
# A pin and a close button, and nothing else.
|
||||
#
|
||||
# The pin is the Live View story: keeping a window above the others is a
|
||||
# window-manager state, not a feature the classroom has to invent. There is
|
||||
# deliberately no minimise button, because a minimised window is the one
|
||||
# thing a teacher cannot see the state of — measured, not assumed. Double
|
||||
# clicking the title bar still maximises, so nothing is unreachable.
|
||||
# A close button, and nothing else. `F` was the keep-above button, rendered
|
||||
# by Breeze as an unexplained double chevron beside Close. Classroom windows
|
||||
# that must stay above others request that state themselves; teachers do not
|
||||
# need a title-bar toggle for it. There is deliberately no minimise button,
|
||||
# because a minimised window is the one thing a teacher cannot see the state
|
||||
# of. Double clicking the title bar still maximises ordinary windows.
|
||||
ButtonsOnLeft=
|
||||
ButtonsOnRight=FX
|
||||
ButtonsOnRight=X
|
||||
ShowToolTips=false
|
||||
BorderSize=None
|
||||
BorderSizeAuto=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Window rules.
|
||||
#
|
||||
# There are two: one about video, one about the Parent Call card.
|
||||
# There are three: video, the Parent Call card, and the presentation surface.
|
||||
#
|
||||
# Everything else the classroom used to arrange by hand is ordinary window
|
||||
# manager behaviour and needs no rule: a dialog stays in front of the window
|
||||
@@ -17,8 +17,8 @@
|
||||
# 314-line window arbiter was doing.
|
||||
|
||||
[General]
|
||||
count=2
|
||||
rules=classroom-video,classroom-parent-call
|
||||
count=3
|
||||
rules=classroom-video,classroom-parent-call,classroom-presentation
|
||||
|
||||
[classroom-video]
|
||||
Description=Video is an appliance screen, not a window
|
||||
@@ -48,3 +48,20 @@ skippagerrule=2
|
||||
# button, rather than behind it.
|
||||
above=true
|
||||
aboverule=2
|
||||
|
||||
[classroom-presentation]
|
||||
Description=A presentation is the whole display, not a large window
|
||||
# The library and Quick Look have the same Wayland app id, so the stable
|
||||
# presenter title is part of this match. Matching the app alone would make
|
||||
# every Presentations window fullscreen.
|
||||
wmclass=ekklesia-presentations
|
||||
wmclassmatch=1
|
||||
wmclasscomplete=false
|
||||
title=Presenting
|
||||
titlematch=1
|
||||
fullscreen=true
|
||||
fullscreenrule=2
|
||||
noborder=true
|
||||
noborderrule=2
|
||||
skiptaskbar=true
|
||||
skiptaskbarrule=2
|
||||
|
||||
+28
-20
@@ -52,6 +52,8 @@ 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.
|
||||
Because playback is made from static page images, PowerPoint animations,
|
||||
video, audio and other interactive content are not supported.
|
||||
What it buys:
|
||||
|
||||
* **A teacher can never reach an editor**, because no editor ever runs
|
||||
@@ -74,14 +76,15 @@ interactive runs, so no UI integration package is needed.
|
||||
## Where things are
|
||||
|
||||
```
|
||||
~/Downloads/ the teacher's own download. NEVER touched.
|
||||
~/Downloads/ completed presentations move from here
|
||||
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
|
||||
|
||||
~/.local/share/ekklesia-classroom/presentations/
|
||||
imported.json which downloads have been dealt with
|
||||
<id>/
|
||||
presentation.json title, pages, thumbnail slide, timers
|
||||
Session 7 - Romans.pptx the managed copy, original name kept
|
||||
original/
|
||||
Session 7 - Romans.pptx the one 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
|
||||
@@ -108,20 +111,22 @@ A file is imported when **all** of these hold:
|
||||
* 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.
|
||||
Two downloads of the same name are two presentations in separate id
|
||||
directories. 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
|
||||
**Move and crash safety.** On one filesystem the completed download is
|
||||
renamed atomically. Across filesystems it is copied to a hidden temporary,
|
||||
flushed, published atomically, and only then removed from Downloads. The
|
||||
metadata and transaction fingerprint are written first. On startup, an
|
||||
interruption before the source was removed discards the partial destination
|
||||
and retries; an interruption after the move resumes conversion from the
|
||||
managed original. A presentation becomes ready only once every derived 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.
|
||||
**Remove**. Its managed original remains recoverable even when PDF or page
|
||||
generation fails.
|
||||
|
||||
## The three things kept apart
|
||||
|
||||
@@ -129,7 +134,7 @@ 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 |
|
||||
| **Library cover** | *Use as cover* 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 |
|
||||
|
||||
@@ -159,8 +164,9 @@ The cleanup runs hourly in the importer and:
|
||||
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;
|
||||
* removes the managed original and all derived assets together;
|
||||
* never touches unrelated files in `~/Downloads`, and therefore never a
|
||||
Wonder Ink worksheet;
|
||||
* tolerates a directory that is already half gone.
|
||||
|
||||
## The presenter
|
||||
@@ -181,11 +187,13 @@ 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.
|
||||
**Fullscreen is re-asserted, not assumed.** The presenter requests
|
||||
fullscreen after its Wayland surface is mapped, and its stable `Presenting`
|
||||
title lets a presenter-only KWin rule apply fullscreen and remove the border
|
||||
without affecting the library or Quick Look. `window-state-event` reasserts
|
||||
the state for as long as the presentation is running and stops the moment
|
||||
Escape is pressed. The library is hidden during playback and restored on
|
||||
exit; the Plasma panel itself is never hidden or reconfigured.
|
||||
|
||||
## Fonts
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 535 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 906 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 536 KiB |
+16
-15
@@ -497,21 +497,25 @@ sudo reboot
|
||||
- [ ] It does **not** appear in the library while it is still downloading
|
||||
- [ ] It appears by itself within a few seconds of the download finishing
|
||||
- [ ] The title is readable — not a hash, not a long filename
|
||||
- [ ] The original download is still in Chrome's downloads, untouched
|
||||
- [ ] The completed original moved out of Downloads into the managed library;
|
||||
there is no duplicate
|
||||
- [ ] Download the **PDF and the PPTX** of the same session: two cards,
|
||||
the second titled `(2)`, neither overwriting the other
|
||||
|
||||
### Quick Look
|
||||
|
||||
- [ ] **Quick Look** opens and shows slide 1
|
||||
- [ ] **Quick Look** opens on the currently selected cover slide
|
||||
- [ ] **Next** and **Previous** move through every slide
|
||||
- [ ] The text and layout are right — check a slide with a Bible verse
|
||||
- [ ] Go to the session-number slide, usually slide 2
|
||||
- [ ] **Use this slide as the picture** changes the card
|
||||
- [ ] **Use as cover** changes the card and shows **Current cover**
|
||||
- [ ] **Back to Presentations** returns without presenting
|
||||
- [ ] Close the library, open it again: the chosen picture is still there
|
||||
- [ ] Reboot, open it again: still there
|
||||
- [ ] `classroom-presentation-import --status` shows `presented never`
|
||||
- [ ] At 1920×1080 and 1366×768, scale 1.0 and the larger supported scale,
|
||||
the title, slide navigation, cover action, Back and Present all remain
|
||||
above the panel without manually resizing
|
||||
|
||||
That last line is the one that matters. Quick Look, browsing every slide
|
||||
and changing the picture must not have counted as a presentation.
|
||||
@@ -519,6 +523,7 @@ and changing the picture must not have counted as a presentation.
|
||||
### Present
|
||||
|
||||
- [ ] **Present** from the card fills the **external television**
|
||||
- [ ] No panel, title bar, border, desktop or other application is visible
|
||||
- [ ] It starts at slide **1**, not at the slide chosen for the picture
|
||||
- [ ] **Present** from inside Quick Look does the same
|
||||
- [ ] Space, the arrow keys and Page Up/Page Down all move slides
|
||||
@@ -546,21 +551,17 @@ Use a **test copy**. Not tomorrow's session.
|
||||
|
||||
- [ ] **Remove** asks first
|
||||
- [ ] The card goes, and its slides and pictures go with it
|
||||
- [ ] The original download in Chrome is untouched
|
||||
- [ ] Its managed original goes too; unrelated downloads and another similarly
|
||||
named presentation remain
|
||||
- [ ] It does not come back on the next scan or after a reboot
|
||||
|
||||
### The manual
|
||||
### Help is temporarily hidden
|
||||
|
||||
- [ ] **Help** is on the bar, bottom left
|
||||
- [ ] It opens with no address bar and no tabs
|
||||
- [ ] It shows Browser, Presentations, sound and the common pages
|
||||
- [ ] It does **not** mention Wonder Ink, Parent Call or the DVD
|
||||
- [ ] It works with the network unplugged
|
||||
- [ ] On a children's classroom machine it shows the opposite set
|
||||
- [ ] Print `/usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf`
|
||||
and check the conditional wording reads sensibly in a room that
|
||||
does not have every feature
|
||||
- [ ] The revision date is on the printed copy
|
||||
- [ ] No **Help** launcher is visible on the teacher-facing bar
|
||||
- [ ] The manual sources, generated HTML/PDF, launcher and feature-aware build
|
||||
pipeline remain installed for the later documentation correction pass
|
||||
- [ ] Presentations and Wonder Ink title bars show only Close, with no
|
||||
double-chevron keep-above control
|
||||
|
||||
### Wi-Fi — look, do not touch
|
||||
|
||||
|
||||
@@ -5,15 +5,14 @@ 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.
|
||||
WHY THE ORIGINAL LIVES IN THE MANAGED LIBRARY.
|
||||
|
||||
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.
|
||||
A teacher downloads a session into ordinary ~/Downloads. Once the download
|
||||
is complete, it is moved into this managed library. These locked-down
|
||||
machines have no general file manager, so leaving another copy in Downloads
|
||||
only wastes space and makes Remove misleading. The moved original is kept
|
||||
beside the generated PDF and page images, which means conversion failure is
|
||||
recoverable and cleanup has one contained directory to remove.
|
||||
|
||||
WHY EVERY FORMAT BECOMES A PDF.
|
||||
|
||||
@@ -459,6 +458,21 @@ def library(root=None):
|
||||
return found
|
||||
|
||||
|
||||
def remove(presentation):
|
||||
"""Delete exactly one managed entry, unless it is on screen now."""
|
||||
try:
|
||||
showing = presenting_lock().read_text().strip()
|
||||
except OSError:
|
||||
showing = ""
|
||||
|
||||
if presentation.id == showing:
|
||||
return False
|
||||
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
|
||||
return not presentation.directory.exists()
|
||||
|
||||
|
||||
def titles_in_use(root=None, exclude=None):
|
||||
"""Every title already on a card, optionally ignoring one presentation.
|
||||
|
||||
@@ -601,10 +615,10 @@ def ledger(root=None):
|
||||
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.
|
||||
The normal successful import moves the source out of Downloads, so it
|
||||
cannot be found by a later scan. The ledger is still the transaction
|
||||
record that makes an interrupted same- or cross-filesystem move safe:
|
||||
startup can tell which half-built directory belongs to which source.
|
||||
"""
|
||||
data = ledger(root)
|
||||
data[mark] = value
|
||||
|
||||
@@ -12,10 +12,9 @@ import button, no "where did you save it". This is the in between.
|
||||
|
||||
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 leaves a duplicate in Downloads. Once a supported download is
|
||||
complete, its one original is moved into the managed library. Generated
|
||||
PDFs and page images live beside it and can always be rebuilt from it.
|
||||
|
||||
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
|
||||
directory of its own inside Downloads and those are worksheets, not
|
||||
@@ -31,11 +30,13 @@ WHAT IT WILL NOT DO.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
@@ -71,14 +72,159 @@ def new_directory(root):
|
||||
return directory
|
||||
|
||||
|
||||
def sync_directory(directory):
|
||||
"""Best-effort persistence for a rename or unlink transaction."""
|
||||
try:
|
||||
descriptor = os.open(directory, os.O_RDONLY)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def move_download(source, destination):
|
||||
"""Move one completed download without risking it on another filesystem.
|
||||
|
||||
rename is atomic on one filesystem. Across filesystems, copy to a hidden
|
||||
temporary file, flush it, publish it atomically, and only then unlink the
|
||||
source. If unlinking fails, the published copy is removed and the source
|
||||
remains the one original for the next scan.
|
||||
"""
|
||||
source = os.fspath(source)
|
||||
destination = os.fspath(destination)
|
||||
temporary = destination + ".moving"
|
||||
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
|
||||
if os.path.exists(destination) or os.path.exists(temporary):
|
||||
raise FileExistsError(destination)
|
||||
|
||||
try:
|
||||
os.replace(source, destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
sync_directory(os.path.dirname(source))
|
||||
return
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
|
||||
try:
|
||||
with open(source, "rb") as incoming, open(temporary, "xb") as outgoing:
|
||||
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
|
||||
outgoing.flush()
|
||||
os.fsync(outgoing.fileno())
|
||||
|
||||
shutil.copystat(source, temporary, follow_symlinks=True)
|
||||
os.replace(temporary, destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
|
||||
try:
|
||||
os.unlink(source)
|
||||
except OSError:
|
||||
# Roll back to the still-complete source rather than leave two
|
||||
# originals or make a failed move look successful.
|
||||
try:
|
||||
os.unlink(destination)
|
||||
sync_directory(os.path.dirname(destination))
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
sync_directory(os.path.dirname(source))
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def forget_presentation(presentation_id, root):
|
||||
"""Remove transaction records for one interrupted presentation."""
|
||||
data = library.ledger(root)
|
||||
changed = False
|
||||
|
||||
for mark, value in list(data.items()):
|
||||
if value == presentation_id:
|
||||
data.pop(mark, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
library.write_json(library.ledger_path(root), data)
|
||||
|
||||
|
||||
def prepare(presentation):
|
||||
"""Build the derived PDF, pages and cover from a managed original."""
|
||||
data = presentation.data
|
||||
managed = presentation.managed_copy
|
||||
|
||||
if managed is None or not managed.is_file():
|
||||
raise RuntimeError("the managed original is missing")
|
||||
|
||||
# A restart may find conversion or rendering output from the interrupted
|
||||
# attempt. The managed original is the recovery point; everything else is
|
||||
# derived and is rebuilt cleanly.
|
||||
if presentation.slides != managed:
|
||||
try:
|
||||
presentation.slides.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
shutil.rmtree(presentation.pages_directory, ignore_errors=True)
|
||||
|
||||
try:
|
||||
presentation.thumbnail.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if data["format"] == "pdf":
|
||||
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(
|
||||
data.get("original_name", ""),
|
||||
library.first_text(presentation.slides)),
|
||||
library.titles_in_use(presentation.directory.parent,
|
||||
exclude=presentation.id),
|
||||
)
|
||||
|
||||
library.render_thumbnail(presentation.page_image(1),
|
||||
presentation.thumbnail)
|
||||
|
||||
data["state"] = "ready"
|
||||
data["error"] = None
|
||||
presentation.save()
|
||||
|
||||
say(f"imported {data['title']!r} ({pages} slides)")
|
||||
|
||||
return presentation
|
||||
|
||||
|
||||
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.
|
||||
Ordered so that an interruption is always recoverable. Metadata and the
|
||||
transaction record are written before the move. Startup either retries
|
||||
from Downloads or resumes from the moved managed original. A presentation
|
||||
becomes ready only once every derived file it needs is on disk.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
mark = library.fingerprint(source)
|
||||
@@ -87,7 +233,7 @@ def import_one(source, root=None):
|
||||
return None
|
||||
|
||||
directory = new_directory(root)
|
||||
managed = directory / source.name
|
||||
managed = directory / "original" / source.name
|
||||
|
||||
data = {
|
||||
"id": directory.name,
|
||||
@@ -95,7 +241,7 @@ def import_one(source, root=None):
|
||||
"title": library.readable_title(source.name),
|
||||
"original_name": source.name,
|
||||
"source_path": str(source),
|
||||
"managed_copy": source.name,
|
||||
"managed_copy": str(managed.relative_to(directory)),
|
||||
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
@@ -116,47 +262,22 @@ def import_one(source, root=None):
|
||||
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
|
||||
move_download(source, managed)
|
||||
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.
|
||||
# A failed move leaves the source in Downloads. Remove the transaction
|
||||
# and retry on the next scan; there is no half-owned file to show.
|
||||
forget_presentation(presentation.id, root)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
say(f"could not move {source.name}: {error}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return prepare(presentation)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# Conversion and rendering failures keep the managed original. The
|
||||
# teacher can remove it, and a technician can recover or inspect it;
|
||||
# no successfully downloaded file is lost because a derived asset
|
||||
# could not be made.
|
||||
data["state"] = "failed"
|
||||
data["error"] = str(error)[:300]
|
||||
presentation.save()
|
||||
@@ -167,36 +288,47 @@ def import_one(source, root=None):
|
||||
|
||||
|
||||
def sweep(root=None):
|
||||
"""Throw away imports that were interrupted, so they can happen again.
|
||||
"""Recover imports interrupted during a move, conversion or rendering.
|
||||
|
||||
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.
|
||||
If Downloads still has the source, discard only the incomplete managed
|
||||
copy and let the next scan retry. If the source has already been moved,
|
||||
the managed original is the recovery point and its derived assets are
|
||||
rebuilt in place. This distinction prevents a crash after the move from
|
||||
deleting the only copy of a successful download.
|
||||
"""
|
||||
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}")
|
||||
source_text = presentation.data.get("source_path")
|
||||
source = Path(source_text) if source_text else None
|
||||
managed = presentation.managed_copy
|
||||
|
||||
for mark, value in list(ledger.items()):
|
||||
if value == presentation.id:
|
||||
ledger.pop(mark, None)
|
||||
if (managed is not None and managed.is_file()
|
||||
and (source is None or not source.exists())):
|
||||
say(f"resuming an interrupted import: {presentation.title!r}")
|
||||
try:
|
||||
prepare(presentation)
|
||||
except Exception as error: # noqa: BLE001
|
||||
presentation.data["state"] = "failed"
|
||||
presentation.data["error"] = str(error)[:300]
|
||||
presentation.save()
|
||||
say(f"could not import {presentation.title!r}: {error}")
|
||||
stale += 1
|
||||
continue
|
||||
|
||||
say(f"retrying an interrupted move: {presentation.title!r}")
|
||||
forget_presentation(presentation.id, root)
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
stale += 1
|
||||
|
||||
if stale:
|
||||
library.write_json(library.ledger_path(root), ledger)
|
||||
|
||||
return stale
|
||||
|
||||
|
||||
@@ -222,9 +354,8 @@ def scan(root=None, downloads=None):
|
||||
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.
|
||||
Only ever the selected managed directory, including its one original and
|
||||
derived assets, and never the presentation on the screen.
|
||||
"""
|
||||
root = root if root is not None else library.library_root()
|
||||
lock = library.presenting_lock()
|
||||
@@ -254,8 +385,8 @@ def cleanup(root=None, moment=None):
|
||||
|
||||
say(f"removing {presentation.title!r} — {why}")
|
||||
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
removed += 1
|
||||
if library.remove(presentation):
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
+229
-56
@@ -4,7 +4,7 @@
|
||||
|
||||
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,
|
||||
retention. A teacher who has downloaded a session opens this,
|
||||
recognises tonight's by its picture, and presses Present.
|
||||
|
||||
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
|
||||
@@ -31,7 +31,6 @@ LibreOffice and poppler, and a picture cannot open a file dialog.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import gi
|
||||
@@ -106,17 +105,73 @@ button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
|
||||
padding: 6px 12px;
|
||||
font-size: 16px;
|
||||
}}
|
||||
.current-cover {{
|
||||
color: {on_accent};
|
||||
background-color: {accent};
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scaled_pixbuf(path, width):
|
||||
def scaled_pixbuf(path, width, height=-1):
|
||||
try:
|
||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
str(path), width, -1, True)
|
||||
str(path), width, height, True)
|
||||
except (GLib.Error, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def monitor_workarea(window=None):
|
||||
"""The usable logical pixels on the monitor, excluding Plasma's panel."""
|
||||
display = Gdk.Display.get_default()
|
||||
|
||||
if display is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
monitor = None
|
||||
|
||||
if window is not None and window.get_window() is not None:
|
||||
monitor = display.get_monitor_at_window(window.get_window())
|
||||
|
||||
if monitor is None:
|
||||
monitor = display.get_primary_monitor()
|
||||
|
||||
if monitor is None and display.get_n_monitors() > 0:
|
||||
monitor = display.get_monitor(0)
|
||||
|
||||
if monitor is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
return monitor.get_workarea()
|
||||
|
||||
|
||||
def monitor_geometry(window=None):
|
||||
"""The full logical monitor rectangle, including the panel area."""
|
||||
display = Gdk.Display.get_default()
|
||||
|
||||
if display is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
monitor = None
|
||||
|
||||
if window is not None and window.get_window() is not None:
|
||||
monitor = display.get_monitor_at_window(window.get_window())
|
||||
|
||||
if monitor is None:
|
||||
monitor = display.get_primary_monitor()
|
||||
|
||||
if monitor is None and display.get_n_monitors() > 0:
|
||||
monitor = display.get_monitor(0)
|
||||
|
||||
if monitor is None:
|
||||
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
|
||||
|
||||
return monitor.get_geometry()
|
||||
|
||||
|
||||
class Presenter(Gtk.Window):
|
||||
"""The slideshow. A fullscreen window showing one picture at a time.
|
||||
|
||||
@@ -130,19 +185,30 @@ class Presenter(Gtk.Window):
|
||||
press and nowhere else to end up.
|
||||
"""
|
||||
|
||||
def __init__(self, presentation, start_page=1, on_finish=None):
|
||||
super().__init__(title=presentation.title)
|
||||
def __init__(self, presentation, start_page=1, on_finish=None,
|
||||
display_geometry=None):
|
||||
# A stable title gives KWin a presenter-only match. The library and
|
||||
# Quick Look share this application's Wayland app id, so matching the
|
||||
# app id alone would fullscreen every window.
|
||||
super().__init__(title="Presenting")
|
||||
|
||||
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.display_geometry = display_geometry or monitor_geometry()
|
||||
# How many times fullscreen has been asked for without it
|
||||
# sticking. See _state.
|
||||
self.refused = 0
|
||||
|
||||
self.set_app_paintable(True)
|
||||
self.set_decorated(False)
|
||||
self.set_skip_taskbar_hint(True)
|
||||
self.set_role("classroom-presenter")
|
||||
self.set_default_size(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
self.set_size_request(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
self.fullscreen()
|
||||
# A slideshow is the only thing on the screen while it is running.
|
||||
self.set_keep_above(True)
|
||||
@@ -151,16 +217,48 @@ class Presenter(Gtk.Window):
|
||||
black.parse("#000000")
|
||||
self.override_background_color(Gtk.StateFlags.NORMAL, black)
|
||||
|
||||
# Paint an explicit full-window layer behind the image. The old
|
||||
# app-paintable Gtk.Window never drew its own background, so KWin
|
||||
# correctly made it fullscreen while the panel and desktop remained
|
||||
# visible through the transparent space around a portrait slide.
|
||||
self.image = Gtk.Image()
|
||||
self.add(self.image)
|
||||
self.image.set_halign(Gtk.Align.CENTER)
|
||||
self.image.set_valign(Gtk.Align.CENTER)
|
||||
stage = Gtk.Overlay()
|
||||
stage.set_size_request(
|
||||
self.display_geometry.width, self.display_geometry.height)
|
||||
background = Gtk.DrawingArea()
|
||||
background.connect("draw", self._paint_black)
|
||||
stage.add(background)
|
||||
stage.add_overlay(self.image)
|
||||
self.add(stage)
|
||||
|
||||
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.connect("map-event", self._mapped)
|
||||
|
||||
self._drawn_at = (0, 0, 0)
|
||||
|
||||
def _paint_black(self, _widget, context):
|
||||
context.set_source_rgb(0, 0, 0)
|
||||
context.paint()
|
||||
return False
|
||||
|
||||
def _mapped(self, *_):
|
||||
# GTK can make the first request before the Wayland surface exists.
|
||||
# Repeat it after mapping; KWin's matching rule is the compositor-side
|
||||
# guarantee, while this keeps the window correct on other desktops.
|
||||
self.fullscreen()
|
||||
GLib.idle_add(self._mapped_fullscreen)
|
||||
return False
|
||||
|
||||
def _mapped_fullscreen(self):
|
||||
if not self.finished:
|
||||
self.fullscreen()
|
||||
return False
|
||||
|
||||
def _state(self, _widget, event):
|
||||
"""Put the slideshow back on the whole screen if it is taken off it.
|
||||
|
||||
@@ -204,7 +302,9 @@ class Presenter(Gtk.Window):
|
||||
def show_page(self):
|
||||
path = self.presentation.page_image(self.page)
|
||||
allocation = self.get_allocation()
|
||||
want = (self.page, allocation.width, allocation.height)
|
||||
width = max(allocation.width, self.display_geometry.width)
|
||||
height = max(allocation.height, self.display_geometry.height)
|
||||
want = (self.page, width, height)
|
||||
|
||||
if want == self._drawn_at:
|
||||
return
|
||||
@@ -212,7 +312,7 @@ class Presenter(Gtk.Window):
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
pixbuf = self._fitted(path, allocation.width, allocation.height)
|
||||
pixbuf = self._fitted(path, width, height)
|
||||
|
||||
if pixbuf is None:
|
||||
return
|
||||
@@ -244,12 +344,30 @@ class Presenter(Gtk.Window):
|
||||
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)),
|
||||
fitted_width = max(1, int(original.get_width() * ratio))
|
||||
fitted_height = max(1, int(original.get_height() * ratio))
|
||||
fitted = original.scale_simple(
|
||||
fitted_width,
|
||||
fitted_height,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
)
|
||||
|
||||
# Make the entire client buffer opaque. On Plasma Wayland, painting a
|
||||
# container behind a smaller Gtk.Image still left the surface outside
|
||||
# the image transparent: KWin reported a correct 1920x1080 fullscreen
|
||||
# window, but the panel and desktop showed through the letterbox. A
|
||||
# full-size RGB pixbuf makes black part of the presented content.
|
||||
canvas = GdkPixbuf.Pixbuf.new(
|
||||
GdkPixbuf.Colorspace.RGB, False, 8, width, height)
|
||||
canvas.fill(0x000000ff)
|
||||
fitted.copy_area(
|
||||
0, 0, fitted_width, fitted_height, canvas,
|
||||
(width - fitted_width) // 2,
|
||||
(height - fitted_height) // 2,
|
||||
)
|
||||
|
||||
return canvas
|
||||
|
||||
# -- moving about ---------------------------------------------
|
||||
|
||||
def go(self, delta):
|
||||
@@ -309,11 +427,15 @@ class Presenter(Gtk.Window):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self.on_finish is not None:
|
||||
self.on_finish()
|
||||
|
||||
on_finish = self.on_finish
|
||||
self.destroy()
|
||||
|
||||
# Map and raise the library only after the fullscreen surface is gone.
|
||||
# Showing it underneath a still-live fullscreen window could leave the
|
||||
# nested KWin session with no newly damaged return surface on Escape.
|
||||
if on_finish is not None:
|
||||
on_finish()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -324,7 +446,7 @@ class QuickLook(Gtk.Window):
|
||||
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;
|
||||
Use as cover which slide the library card shows;
|
||||
Present start the slideshow, from the beginning.
|
||||
|
||||
Browsing is not presenting: coming in here, looking through every
|
||||
@@ -340,11 +462,21 @@ class QuickLook(Gtk.Window):
|
||||
self.presentation = presentation
|
||||
self.on_close = on_close
|
||||
self.on_present = on_present
|
||||
self.page = 1
|
||||
# Open on the familiar card image. Teachers can immediately see what
|
||||
# "cover" means, while Present still starts independently at slide 1.
|
||||
self.page = presentation.thumbnail_page
|
||||
|
||||
self.set_transient_for(parent)
|
||||
self.set_modal(True)
|
||||
self.set_default_size(1000, 760)
|
||||
workarea = monitor_workarea(parent)
|
||||
window_width = min(1000, max(560, int(workarea.width * 0.86)))
|
||||
# Leave enough room for decorations and a panel even on compositors
|
||||
# that report the full monitor as the work area (the nested Plasma lab
|
||||
# does). At 1366x768 this remains comfortably usable at larger scale.
|
||||
window_height = min(760, max(420, int(workarea.height * 0.75)))
|
||||
self.set_default_size(window_width, window_height)
|
||||
self.preview_width = max(320, window_width - 32)
|
||||
self.preview_height = max(180, window_height - 210)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
outer.set_border_width(16)
|
||||
@@ -365,7 +497,7 @@ class QuickLook(Gtk.Window):
|
||||
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
browse.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
self.back = Gtk.Button(label="◀ Previous")
|
||||
self.back = Gtk.Button(label="Previous")
|
||||
self.back.connect("clicked", lambda *_: self.go(-1))
|
||||
browse.pack_start(self.back, False, False, 0)
|
||||
|
||||
@@ -373,7 +505,7 @@ class QuickLook(Gtk.Window):
|
||||
self.counter.get_style_context().add_class("subtle")
|
||||
browse.pack_start(self.counter, False, False, 12)
|
||||
|
||||
self.forward = Gtk.Button(label="Next ▶")
|
||||
self.forward = Gtk.Button(label="Next")
|
||||
self.forward.connect("clicked", lambda *_: self.go(1))
|
||||
browse.pack_start(self.forward, False, False, 0)
|
||||
|
||||
@@ -382,9 +514,15 @@ class QuickLook(Gtk.Window):
|
||||
# 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 = Gtk.Button(label="Use as cover")
|
||||
self.choose.connect("clicked", self._choose)
|
||||
actions.pack_start(self.choose, False, False, 0)
|
||||
|
||||
self.cover = Gtk.Label(label="Current cover")
|
||||
self.cover.get_style_context().add_class("current-cover")
|
||||
self.cover_action = Gtk.Stack()
|
||||
self.cover_action.add_named(self.choose, "choose")
|
||||
self.cover_action.add_named(self.cover, "current")
|
||||
actions.pack_start(self.cover_action, False, False, 0)
|
||||
|
||||
actions.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
@@ -408,14 +546,18 @@ class QuickLook(Gtk.Window):
|
||||
|
||||
def show_page(self, force=True):
|
||||
allocation = self.slide.get_allocation()
|
||||
width = max(320, allocation.width or 900)
|
||||
want = (self.page, width, 0)
|
||||
width = min(self.preview_width,
|
||||
max(1, allocation.width or self.preview_width))
|
||||
height = min(self.preview_height,
|
||||
max(1, allocation.height or self.preview_height))
|
||||
want = (self.page, width, height)
|
||||
|
||||
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
|
||||
pixbuf = (scaled_pixbuf(path, width, height)
|
||||
if path.exists() else None)
|
||||
|
||||
if pixbuf is not None:
|
||||
self.slide.set_from_pixbuf(pixbuf)
|
||||
@@ -423,12 +565,12 @@ class QuickLook(Gtk.Window):
|
||||
|
||||
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.counter.set_text(f"Slide {self.page} of {total}")
|
||||
|
||||
self.back.set_sensitive(self.page > 1)
|
||||
self.forward.set_sensitive(self.page < total)
|
||||
self.choose.set_sensitive(self.page != chosen)
|
||||
self.cover_action.set_visible_child_name(
|
||||
"current" if self.page == chosen else "choose")
|
||||
|
||||
def go(self, delta):
|
||||
target = self.page + delta
|
||||
@@ -483,7 +625,7 @@ class Library(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="Presentations")
|
||||
|
||||
self.set_default_size(1180, 820)
|
||||
self.set_default_size(680, 420)
|
||||
self.presenter = None
|
||||
self._signature = None
|
||||
|
||||
@@ -512,7 +654,7 @@ class Library(Gtk.Window):
|
||||
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)
|
||||
self.grid.set_homogeneous(False)
|
||||
scroller.add(self.grid)
|
||||
|
||||
self.connect("key-press-event", self._key)
|
||||
@@ -544,6 +686,29 @@ class Library(Gtk.Window):
|
||||
for p in presentations
|
||||
)
|
||||
|
||||
def _fit_window(self, count):
|
||||
"""Size to the cards while staying inside the panel's work area."""
|
||||
workarea = monitor_workarea(self)
|
||||
maximum_width = max(420, int(workarea.width * 0.92))
|
||||
maximum_height = max(360, int(workarea.height * 0.90))
|
||||
|
||||
if count == 0:
|
||||
width = min(maximum_width, 640)
|
||||
height = min(maximum_height, 300)
|
||||
else:
|
||||
# FlowBox includes the card's CSS padding and the three buttons'
|
||||
# natural widths when deciding whether another child fits. Budget
|
||||
# that chrome here too, or two cards can wrap into two rows while
|
||||
# the window is sized for one.
|
||||
card_with_gap = CARD_WIDTH + 40
|
||||
available = max(1, (maximum_width - 40) // card_with_gap)
|
||||
columns = min(count, available, 4)
|
||||
rows = (count + columns - 1) // columns
|
||||
width = min(maximum_width, max(420, 40 + columns * card_with_gap))
|
||||
height = min(maximum_height, 130 + rows * 340)
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
def refresh(self):
|
||||
presentations = library.library()
|
||||
signature = self.signature(presentations)
|
||||
@@ -557,12 +722,10 @@ class Library(Gtk.Window):
|
||||
self.grid.remove(child)
|
||||
|
||||
if not presentations:
|
||||
self.note.set_text(
|
||||
"Download a presentation in Chrome and it will appear here.")
|
||||
self.note.set_text("")
|
||||
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.")
|
||||
label="No presentations yet\n\n"
|
||||
"Download a PowerPoint or PDF to get started.")
|
||||
empty.get_style_context().add_class("empty")
|
||||
empty.set_justify(Gtk.Justification.CENTER)
|
||||
self.grid.add(empty)
|
||||
@@ -589,31 +752,36 @@ class Library(Gtk.Window):
|
||||
self.grid.add(self.card(presentation))
|
||||
|
||||
self.grid.show_all()
|
||||
self._fit_window(len(presentations))
|
||||
|
||||
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
|
||||
picture_frame = Gtk.EventBox()
|
||||
picture_frame.get_style_context().add_class("thumb")
|
||||
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
|
||||
|
||||
if presentation.thumbnail.exists():
|
||||
pixbuf = scaled_pixbuf(presentation.thumbnail, CARD_WIDTH - 28)
|
||||
|
||||
if pixbuf is not None:
|
||||
picture.set_from_pixbuf(pixbuf)
|
||||
picture = Gtk.Image()
|
||||
pixbuf = scaled_pixbuf(
|
||||
presentation.thumbnail, CARD_WIDTH - 28, 180)
|
||||
if pixbuf is not None:
|
||||
picture.set_from_pixbuf(pixbuf)
|
||||
else:
|
||||
picture = Gtk.Image()
|
||||
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
|
||||
picture.set_pixel_size(96)
|
||||
|
||||
box.pack_start(picture, False, False, 0)
|
||||
picture_frame.add(picture)
|
||||
box.pack_start(picture_frame, 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)
|
||||
title.set_max_width_chars(32)
|
||||
title.set_size_request(CARD_WIDTH - 28, -1)
|
||||
box.pack_start(title, False, False, 0)
|
||||
|
||||
note = Gtk.Label(xalign=0)
|
||||
@@ -637,11 +805,9 @@ class Library(Gtk.Window):
|
||||
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.")
|
||||
# Never the converter's error text, a path or a terminal.
|
||||
# The managed original remains recoverable until Remove.
|
||||
note.set_text("This file could not be opened.")
|
||||
note.set_line_wrap(True)
|
||||
else:
|
||||
note.set_text("Getting this ready…")
|
||||
@@ -678,9 +844,12 @@ class Library(Gtk.Window):
|
||||
pass
|
||||
|
||||
self.presenter = Presenter(
|
||||
presentation, start_page, on_finish=self._presented)
|
||||
presentation, start_page, on_finish=self._presented,
|
||||
display_geometry=monitor_geometry(self))
|
||||
self.hide()
|
||||
self.presenter.show_all()
|
||||
self.presenter.present()
|
||||
self.presenter.fullscreen()
|
||||
|
||||
def _presented(self):
|
||||
self.presenter = None
|
||||
@@ -715,12 +884,12 @@ class Library(Gtk.Window):
|
||||
modal=True,
|
||||
message_type=Gtk.MessageType.QUESTION,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
text=f"Remove “{presentation.title}”?",
|
||||
text="Remove this presentation?",
|
||||
)
|
||||
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)
|
||||
f"“{presentation.title}”\n\n"
|
||||
"This will delete it from this computer.")
|
||||
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||||
dialog.add_button("Remove", Gtk.ResponseType.OK)
|
||||
dialog.set_default_response(Gtk.ResponseType.CANCEL)
|
||||
|
||||
@@ -730,7 +899,11 @@ class Library(Gtk.Window):
|
||||
if answer != Gtk.ResponseType.OK:
|
||||
return
|
||||
|
||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||
if not library.remove(presentation):
|
||||
self.trouble(
|
||||
presentation,
|
||||
"Finish presenting before removing this presentation.")
|
||||
return
|
||||
self._signature = None
|
||||
self.refresh()
|
||||
|
||||
|
||||
@@ -191,8 +191,8 @@ else:
|
||||
check("and Parent Call in the corner",
|
||||
every and CALL in every["launchers"],
|
||||
str((every or {}).get("launchers")))
|
||||
check("and Help beside it",
|
||||
every and HELP in every["launchers"],
|
||||
check("and the unfinished Help launcher is hidden",
|
||||
every and HELP not in every["launchers"],
|
||||
str((every or {}).get("launchers")))
|
||||
|
||||
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
|
||||
@@ -210,11 +210,10 @@ else:
|
||||
check("with no Parent Call button on it",
|
||||
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"],
|
||||
# The source and launcher remain installed, but the unfinished digital
|
||||
# Help entry point is deliberately absent from the teacher-facing bar.
|
||||
check("and unfinished Help stays hidden",
|
||||
bible and HELP not in bible["launchers"],
|
||||
str((bible or {}).get("launchers")))
|
||||
SLIDES = "applications:ekklesia-presentations.desktop"
|
||||
|
||||
|
||||
@@ -14,11 +14,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||
import os
|
||||
import errno
|
||||
import importlib.util
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from importlib.machinery import SourceFileLoader
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
@@ -26,6 +29,14 @@ sys.path.insert(0, str(root / "lib"))
|
||||
|
||||
import classroom_presentations as library
|
||||
|
||||
loader = SourceFileLoader(
|
||||
"classroom_presentation_import",
|
||||
str(root / "scripts" / "classroom-presentation-import"),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
importer = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(importer)
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
@@ -242,19 +253,175 @@ check("it lives in the runtime directory",
|
||||
or str(library.presenting_lock()).startswith("/tmp"),
|
||||
str(library.presenting_lock()))
|
||||
|
||||
print("10. the ledger remembers a download after its presentation is gone")
|
||||
print("10. the ledger identifies a download transaction")
|
||||
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",
|
||||
check("so an interrupted move can be matched after a restart",
|
||||
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")
|
||||
|
||||
print("11. a completed download is moved into the managed library")
|
||||
original_render_pages = library.render_pages
|
||||
original_render_thumbnail = library.render_thumbnail
|
||||
original_first_text = library.first_text
|
||||
original_convert = library.convert_to_pdf
|
||||
|
||||
|
||||
def fake_render_pages(_pdf, directory, width=library.SLIDE_WIDTH,
|
||||
progress=None):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "page-001.png").write_bytes(b"png")
|
||||
return 1, ""
|
||||
|
||||
|
||||
def fake_render_thumbnail(_source, destination,
|
||||
width=library.THUMBNAIL_WIDTH):
|
||||
destination.write_bytes(b"thumbnail")
|
||||
return True
|
||||
|
||||
|
||||
library.render_pages = fake_render_pages
|
||||
library.render_thumbnail = fake_render_thumbnail
|
||||
library.first_text = lambda _pdf, pages=2: ""
|
||||
|
||||
moved = download("Move me.pdf", contents="%PDF-1.4 moved")
|
||||
moved_contents = moved.read_bytes()
|
||||
managed = importer.import_one(moved, shelf)
|
||||
check("the source leaves Downloads after import", not moved.exists())
|
||||
check("the one original is kept in the managed entry",
|
||||
managed is not None and managed.managed_copy.read_bytes() == moved_contents,
|
||||
str(managed.managed_copy if managed else None))
|
||||
check("its PDF and page images are derived assets",
|
||||
managed is not None and managed.ready and managed.slides.exists()
|
||||
and managed.page_image(1).exists())
|
||||
|
||||
print("12. conversion failure does not lose the downloaded original")
|
||||
library.convert_to_pdf = lambda _source, _destination: (False, "broken deck")
|
||||
broken_source = download("Broken deck.pptx", contents="not really a deck")
|
||||
failed_import = importer.import_one(broken_source, shelf)
|
||||
check("the failed source was still moved out of Downloads",
|
||||
not broken_source.exists())
|
||||
check("a failed card retains the managed original",
|
||||
failed_import is not None and failed_import.failed
|
||||
and failed_import.managed_copy.read_text() == "not really a deck")
|
||||
check("the failure did not create a second original",
|
||||
failed_import is not None
|
||||
and list((failed_import.directory / "original").iterdir())
|
||||
== [failed_import.managed_copy])
|
||||
|
||||
print("13. a cross-filesystem move publishes before deleting")
|
||||
cross_source = download("Across filesystems.pdf", contents="cross-device")
|
||||
cross_destination = work / "other-device" / cross_source.name
|
||||
real_replace = importer.os.replace
|
||||
source_text = str(cross_source)
|
||||
destination_text = str(cross_destination)
|
||||
|
||||
|
||||
def cross_replace(source, destination):
|
||||
if os.fspath(source) == source_text and os.fspath(destination) == destination_text:
|
||||
raise OSError(errno.EXDEV, "different filesystems")
|
||||
return real_replace(source, destination)
|
||||
|
||||
|
||||
importer.os.replace = cross_replace
|
||||
try:
|
||||
importer.move_download(cross_source, cross_destination)
|
||||
finally:
|
||||
importer.os.replace = real_replace
|
||||
|
||||
check("the cross-filesystem destination is complete",
|
||||
cross_destination.read_text() == "cross-device")
|
||||
check("only the destination remains after it is durable",
|
||||
not cross_source.exists()
|
||||
and not cross_destination.with_name(cross_destination.name + ".moving").exists())
|
||||
|
||||
print("14. restart recovery never deletes the only moved original")
|
||||
resume_dir = shelf / "resume"
|
||||
resume_original = resume_dir / "original" / "Resume.pdf"
|
||||
resume_original.parent.mkdir(parents=True)
|
||||
resume_original.write_text("%PDF-1.4 resume")
|
||||
resume_data = {
|
||||
"id": "resume",
|
||||
"state": "importing",
|
||||
"title": "Resume",
|
||||
"original_name": "Resume.pdf",
|
||||
"source_path": str(downloads / "Resume.pdf"),
|
||||
"managed_copy": "original/Resume.pdf",
|
||||
"format": "pdf",
|
||||
"imported_at": library.stamp(),
|
||||
"last_presented_at": None,
|
||||
"times_presented": 0,
|
||||
"thumbnail_page": 1,
|
||||
"pages": 0,
|
||||
"error": None,
|
||||
}
|
||||
library.Presentation(resume_dir, resume_data).save()
|
||||
importer.sweep(shelf)
|
||||
resumed = library.load(resume_dir)
|
||||
check("an import interrupted after the move resumes in place",
|
||||
resumed is not None and resumed.ready)
|
||||
check("and its original survives recovery", resume_original.exists())
|
||||
|
||||
print("15. removal is isolated and refuses an active presentation")
|
||||
remove_one = entry(id="remove-one", title="Similar title")
|
||||
keep_one = entry(id="keep-one", title="Similar title (2)")
|
||||
(remove_one.directory / "original").mkdir()
|
||||
(remove_one.directory / "original" / "one.pdf").write_text("one")
|
||||
(keep_one.directory / "original").mkdir()
|
||||
(keep_one.directory / "original" / "two.pdf").write_text("two")
|
||||
check("removing one managed entry succeeds", library.remove(remove_one))
|
||||
check("a similarly named presentation and its files are untouched",
|
||||
keep_one.directory.exists()
|
||||
and (keep_one.directory / "original" / "two.pdf").exists())
|
||||
|
||||
runtime = work / "runtime"
|
||||
runtime.mkdir()
|
||||
old_runtime = os.environ.get("XDG_RUNTIME_DIR")
|
||||
os.environ["XDG_RUNTIME_DIR"] = str(runtime)
|
||||
library.presenting_lock().write_text(keep_one.id)
|
||||
check("an actively presented entry cannot be removed",
|
||||
not library.remove(keep_one) and keep_one.directory.exists())
|
||||
library.presenting_lock().unlink()
|
||||
if old_runtime is None:
|
||||
os.environ.pop("XDG_RUNTIME_DIR", None)
|
||||
else:
|
||||
os.environ["XDG_RUNTIME_DIR"] = old_runtime
|
||||
|
||||
library.render_pages = original_render_pages
|
||||
library.render_thumbnail = original_render_thumbnail
|
||||
library.first_text = original_first_text
|
||||
library.convert_to_pdf = original_convert
|
||||
|
||||
print("16. teacher-facing wording and compositor policy")
|
||||
ui = (root / "scripts" / "classroom-presentations").read_text()
|
||||
layout = (root / "config" / "plasma" / "lookandfeel" / "contents"
|
||||
/ "layouts" / "org.kde.plasma.desktop-layout.js").read_text()
|
||||
kwin = (root / "config" / "plasma" / "xdg" / "kwinrc").read_text()
|
||||
rules = (root / "config" / "plasma" / "xdg" / "kwinrulesrc").read_text()
|
||||
check("the empty state has one concise browser-neutral instruction",
|
||||
'label="No presentations yet\\n\\n"' in ui
|
||||
and "Download a PowerPoint or PDF to get started." in ui
|
||||
and "Download a presentation in Chrome" not in ui)
|
||||
check("cover selection uses the requested plain wording",
|
||||
'label="Use as cover"' in ui and 'label="Current cover"' in ui
|
||||
and "this is the library picture" not in ui)
|
||||
check("Remove no longer promises a copy in Chrome",
|
||||
"This will delete it from this computer." in ui
|
||||
and "Your download in Chrome" not in ui)
|
||||
check("the presenter has a compositor-enforced fullscreen rule",
|
||||
"title=Presenting" in rules and "fullscreen=true" in rules
|
||||
and "fullscreenrule=2" in rules)
|
||||
check("normal title bars contain only Close",
|
||||
"ButtonsOnRight=X" in kwin and "ButtonsOnRight=FX" not in kwin)
|
||||
check("the unfinished Help launcher is absent from the panel",
|
||||
"ekklesia-manual.desktop" not in layout)
|
||||
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
print()
|
||||
|
||||
@@ -202,7 +202,8 @@ check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restr
|
||||
print()
|
||||
print("4d. clicking a launcher never hides an application")
|
||||
check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout)
|
||||
check("there is no minimise button", "ButtonsOnRight=FX" in
|
||||
check("there is no minimise button or keep-above chevron",
|
||||
"ButtonsOnRight=X" in
|
||||
(root / "config/plasma/xdg/kwinrc").read_text())
|
||||
|
||||
print()
|
||||
|
||||
@@ -316,12 +316,13 @@ grep -q 'PRESENTATIONS_ENABLED' /run/ekklesia-classroom/features.json
|
||||
classroom-manual --features | grep -qE 'PRESENTATIONS_ENABLED +shown'
|
||||
classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden'
|
||||
|
||||
# The panel offers Presentations only where it is enabled, and the three
|
||||
# applications that were already on the bar keep their order.
|
||||
# The panel offers Presentations only where it is enabled, keeps the classroom
|
||||
# applications in order, and does not expose the separately installed manual
|
||||
# as a teacher-facing Help launcher.
|
||||
layout=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
|
||||
grep -q 'var PRESENTATIONS = true;' "$layout"
|
||||
grep -q 'ekklesia-presentations.desktop' "$layout"
|
||||
grep -q 'ekklesia-manual.desktop' "$layout"
|
||||
! grep -q 'ekklesia-manual.desktop' "$layout"
|
||||
python3 - "$layout" <<'ORDER_CHECK'
|
||||
import re
|
||||
import sys
|
||||
|
||||
Reference in New Issue
Block a user