fix: harden classroom presentations
This commit is contained in:
@@ -87,26 +87,9 @@ if (PARENT_CALL) {
|
|||||||
parentCall.writeConfig("maxSectionCount", 1);
|
parentCall.writeConfig("maxSectionCount", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HELP, beside Parent Call and not in the row with the applications.
|
// Help is intentionally absent from this release's teacher-facing panel.
|
||||||
//
|
// Its launcher, source pages and build pipeline remain installed for the
|
||||||
// Every room gets it: the manual is the one thing that is never optional,
|
// documentation correction pass; only the unfinished entry point is hidden.
|
||||||
// because a teacher who needs it is by definition a teacher who does not
|
|
||||||
// know where to look. It shows only the pages for the features THIS
|
|
||||||
// device has — see classroom-manual — so the button means the same thing
|
|
||||||
// in every room while what it opens does not.
|
|
||||||
//
|
|
||||||
// On the left, with Parent Call, for the same reason Parent Call is
|
|
||||||
// there: it is not one of the room's applications, and putting it in the
|
|
||||||
// middle row would move buttons teachers reach for without looking.
|
|
||||||
var help = panel.addWidget("org.kde.plasma.quicklaunch");
|
|
||||||
help.currentConfigGroup = ["General"];
|
|
||||||
help.writeConfig(
|
|
||||||
"launcherUrls",
|
|
||||||
"file:///usr/share/applications/ekklesia-manual.desktop"
|
|
||||||
);
|
|
||||||
help.writeConfig("showLauncherNames", false);
|
|
||||||
help.writeConfig("enablePopup", false);
|
|
||||||
help.writeConfig("maxSectionCount", 1);
|
|
||||||
|
|
||||||
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
|
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
|
||||||
leftGap.currentConfigGroup = ["General"];
|
leftGap.currentConfigGroup = ["General"];
|
||||||
|
|||||||
@@ -82,15 +82,14 @@ NoiseStrength=2
|
|||||||
[org.kde.kdecoration2][$i]
|
[org.kde.kdecoration2][$i]
|
||||||
library=org.kde.breeze
|
library=org.kde.breeze
|
||||||
theme=Breeze
|
theme=Breeze
|
||||||
# A pin and a close button, and nothing else.
|
# A close button, and nothing else. `F` was the keep-above button, rendered
|
||||||
#
|
# by Breeze as an unexplained double chevron beside Close. Classroom windows
|
||||||
# The pin is the Live View story: keeping a window above the others is a
|
# that must stay above others request that state themselves; teachers do not
|
||||||
# window-manager state, not a feature the classroom has to invent. There is
|
# need a title-bar toggle for it. There is deliberately no minimise button,
|
||||||
# deliberately no minimise button, because a minimised window is the one
|
# because a minimised window is the one thing a teacher cannot see the state
|
||||||
# thing a teacher cannot see the state of — measured, not assumed. Double
|
# of. Double clicking the title bar still maximises ordinary windows.
|
||||||
# clicking the title bar still maximises, so nothing is unreachable.
|
|
||||||
ButtonsOnLeft=
|
ButtonsOnLeft=
|
||||||
ButtonsOnRight=FX
|
ButtonsOnRight=X
|
||||||
ShowToolTips=false
|
ShowToolTips=false
|
||||||
BorderSize=None
|
BorderSize=None
|
||||||
BorderSizeAuto=false
|
BorderSizeAuto=false
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Window rules.
|
# 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
|
# 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
|
# manager behaviour and needs no rule: a dialog stays in front of the window
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
# 314-line window arbiter was doing.
|
# 314-line window arbiter was doing.
|
||||||
|
|
||||||
[General]
|
[General]
|
||||||
count=2
|
count=3
|
||||||
rules=classroom-video,classroom-parent-call
|
rules=classroom-video,classroom-parent-call,classroom-presentation
|
||||||
|
|
||||||
[classroom-video]
|
[classroom-video]
|
||||||
Description=Video is an appliance screen, not a window
|
Description=Video is an appliance screen, not a window
|
||||||
@@ -48,3 +48,20 @@ skippagerrule=2
|
|||||||
# button, rather than behind it.
|
# button, rather than behind it.
|
||||||
above=true
|
above=true
|
||||||
aboverule=2
|
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
|
This is not a new renderer. LibreOffice does exactly the rendering
|
||||||
Impress would have done live — it is the same engine — and poppler does
|
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 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:
|
What it buys:
|
||||||
|
|
||||||
* **A teacher can never reach an editor**, because no editor ever runs
|
* **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
|
## 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.
|
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
|
||||||
|
|
||||||
~/.local/share/ekklesia-classroom/presentations/
|
~/.local/share/ekklesia-classroom/presentations/
|
||||||
imported.json which downloads have been dealt with
|
imported.json which downloads have been dealt with
|
||||||
<id>/
|
<id>/
|
||||||
presentation.json title, pages, thumbnail slide, timers
|
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
|
slides.pdf what everything is rendered from
|
||||||
pages/page-001.png ... one per slide, 1920px wide
|
pages/page-001.png ... one per slide, 1920px wide
|
||||||
thumbnail.png the card picture, from the chosen slide
|
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
|
* its fingerprint — path, size, modification time — is not already in
|
||||||
`imported.json`.
|
`imported.json`.
|
||||||
|
|
||||||
Two downloads of the same name are two presentations. The second gets
|
Two downloads of the same name are two presentations in separate id
|
||||||
`(2)` after its title; neither overwrites the other.
|
directories. The second gets `(2)` after its title; neither overwrites the
|
||||||
|
other.
|
||||||
|
|
||||||
**Crash safety.** The metadata is written saying `importing` *before* any
|
**Move and crash safety.** On one filesystem the completed download is
|
||||||
work, and the fingerprint is recorded at the same moment so a file that
|
renamed atomically. Across filesystems it is copied to a hidden temporary,
|
||||||
crashes the converter cannot be retried forever. On startup the importer
|
flushed, published atomically, and only then removed from Downloads. The
|
||||||
sweeps every directory still saying `importing`, deletes it and forgets
|
metadata and transaction fingerprint are written first. On startup, an
|
||||||
its fingerprint, so an import interrupted by a reboot simply happens
|
interruption before the source was removed discards the partial destination
|
||||||
again. A presentation becomes visible to a teacher only once every file
|
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.
|
it needs is on disk.
|
||||||
|
|
||||||
A file that cannot be converted gets a card saying so, offering only
|
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
|
**Remove**. Its managed original remains recoverable even when PDF or page
|
||||||
says.
|
generation fails.
|
||||||
|
|
||||||
## The three things kept apart
|
## The three things kept apart
|
||||||
|
|
||||||
@@ -129,7 +134,7 @@ This is the part most worth not breaking.
|
|||||||
|
|
||||||
| | Changed by | Never changed by |
|
| | 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 |
|
| **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 |
|
| **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
|
directory so a crash cannot make a presentation permanently
|
||||||
un-removable;
|
un-removable;
|
||||||
* only ever removes a directory under the library root;
|
* only ever removes a directory under the library root;
|
||||||
* never touches `~/Downloads`, and therefore never a Wonder Ink
|
* removes the managed original and all derived assets together;
|
||||||
worksheet;
|
* never touches unrelated files in `~/Downloads`, and therefore never a
|
||||||
|
Wonder Ink worksheet;
|
||||||
* tolerates a directory that is already half gone.
|
* tolerates a directory that is already half gone.
|
||||||
|
|
||||||
## The presenter
|
## 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
|
Going past the last slide finishes, rather than showing a black "click to
|
||||||
exit" slide.
|
exit" slide.
|
||||||
|
|
||||||
**Fullscreen is re-asserted, not assumed.** Measured in the lab: focusing
|
**Fullscreen is re-asserted, not assumed.** The presenter requests
|
||||||
another window on the same workspace dropped the slideshow to half the
|
fullscreen after its Wayland surface is mapped, and its stable `Presenting`
|
||||||
screen with the library beside it. `window-state-event` puts it back for
|
title lets a presenter-only KWin rule apply fullscreen and remove the border
|
||||||
as long as the presentation is running, and stops the moment Escape is
|
without affecting the library or Quick Look. `window-state-event` reasserts
|
||||||
pressed so it can never fight the teacher's way out.
|
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
|
## 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 does **not** appear in the library while it is still downloading
|
||||||
- [ ] It appears by itself within a few seconds of the download finishing
|
- [ ] It appears by itself within a few seconds of the download finishing
|
||||||
- [ ] The title is readable — not a hash, not a long filename
|
- [ ] 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,
|
- [ ] Download the **PDF and the PPTX** of the same session: two cards,
|
||||||
the second titled `(2)`, neither overwriting the other
|
the second titled `(2)`, neither overwriting the other
|
||||||
|
|
||||||
### Quick Look
|
### 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
|
- [ ] **Next** and **Previous** move through every slide
|
||||||
- [ ] The text and layout are right — check a slide with a Bible verse
|
- [ ] The text and layout are right — check a slide with a Bible verse
|
||||||
- [ ] Go to the session-number slide, usually slide 2
|
- [ ] 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
|
- [ ] **Back to Presentations** returns without presenting
|
||||||
- [ ] Close the library, open it again: the chosen picture is still there
|
- [ ] Close the library, open it again: the chosen picture is still there
|
||||||
- [ ] Reboot, open it again: still there
|
- [ ] Reboot, open it again: still there
|
||||||
- [ ] `classroom-presentation-import --status` shows `presented never`
|
- [ ] `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
|
That last line is the one that matters. Quick Look, browsing every slide
|
||||||
and changing the picture must not have counted as a presentation.
|
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
|
||||||
|
|
||||||
- [ ] **Present** from the card fills the **external television**
|
- [ ] **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
|
- [ ] It starts at slide **1**, not at the slide chosen for the picture
|
||||||
- [ ] **Present** from inside Quick Look does the same
|
- [ ] **Present** from inside Quick Look does the same
|
||||||
- [ ] Space, the arrow keys and Page Up/Page Down all move slides
|
- [ ] 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
|
- [ ] **Remove** asks first
|
||||||
- [ ] The card goes, and its slides and pictures go with it
|
- [ ] 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
|
- [ ] 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
|
- [ ] No **Help** launcher is visible on the teacher-facing bar
|
||||||
- [ ] It opens with no address bar and no tabs
|
- [ ] The manual sources, generated HTML/PDF, launcher and feature-aware build
|
||||||
- [ ] It shows Browser, Presentations, sound and the common pages
|
pipeline remain installed for the later documentation correction pass
|
||||||
- [ ] It does **not** mention Wonder Ink, Parent Call or the DVD
|
- [ ] Presentations and Wonder Ink title bars show only Close, with no
|
||||||
- [ ] It works with the network unplugged
|
double-chevron keep-above control
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
### Wi-Fi — look, do not touch
|
### 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
|
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.
|
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
|
A teacher downloads a session into ordinary ~/Downloads. Once the download
|
||||||
~/Downloads. That file stays exactly where Chrome put it and is never
|
is complete, it is moved into this managed library. These locked-down
|
||||||
touched again — not renamed, not moved, not deleted, not by the library
|
machines have no general file manager, so leaving another copy in Downloads
|
||||||
and not by the cleanup. The library keeps its OWN copy, and everything
|
only wastes space and makes Remove misleading. The moved original is kept
|
||||||
after the import happens to that copy. So a teacher who tidies their
|
beside the generated PDF and page images, which means conversion failure is
|
||||||
downloads cannot break a presentation, and a cleanup that removes a
|
recoverable and cleanup has one contained directory to remove.
|
||||||
presentation cannot take a teacher's file with it.
|
|
||||||
|
|
||||||
WHY EVERY FORMAT BECOMES A PDF.
|
WHY EVERY FORMAT BECOMES A PDF.
|
||||||
|
|
||||||
@@ -459,6 +458,21 @@ def library(root=None):
|
|||||||
return found
|
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):
|
def titles_in_use(root=None, exclude=None):
|
||||||
"""Every title already on a card, optionally ignoring one presentation.
|
"""Every title already on a card, optionally ignoring one presentation.
|
||||||
|
|
||||||
@@ -601,10 +615,10 @@ def ledger(root=None):
|
|||||||
def remember(mark, value, root=None):
|
def remember(mark, value, root=None):
|
||||||
"""Record that this download has been dealt with.
|
"""Record that this download has been dealt with.
|
||||||
|
|
||||||
Kept even after a teacher removes the presentation, so that Remove
|
The normal successful import moves the source out of Downloads, so it
|
||||||
means removed rather than removed-until-the-next-scan. The original
|
cannot be found by a later scan. The ledger is still the transaction
|
||||||
download is still in Downloads — untouched, as always — and without
|
record that makes an interrupted same- or cross-filesystem move safe:
|
||||||
this the importer would put it straight back.
|
startup can tell which half-built directory belongs to which source.
|
||||||
"""
|
"""
|
||||||
data = ledger(root)
|
data = ledger(root)
|
||||||
data[mark] = value
|
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.
|
WHAT IT WILL NOT DO.
|
||||||
|
|
||||||
It never touches the teacher's download. The file Chrome wrote stays
|
It never leaves a duplicate in Downloads. Once a supported download is
|
||||||
where Chrome wrote it, with the name Chrome gave it, for as long as
|
complete, its one original is moved into the managed library. Generated
|
||||||
the teacher leaves it there. Everything the library does is done to
|
PDFs and page images live beside it and can always be rebuilt from it.
|
||||||
its own copy.
|
|
||||||
|
|
||||||
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
|
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
|
||||||
directory of its own inside Downloads and those are worksheets, not
|
directory of its own inside Downloads and those are worksheets, not
|
||||||
@@ -31,11 +30,13 @@ WHAT IT WILL NOT DO.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import errno
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||||
sys.path.insert(
|
sys.path.insert(
|
||||||
@@ -71,14 +72,159 @@ def new_directory(root):
|
|||||||
return directory
|
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):
|
def import_one(source, root=None):
|
||||||
"""Take one finished download into the library.
|
"""Take one finished download into the library.
|
||||||
|
|
||||||
Ordered so that an interruption is always recoverable. The metadata
|
Ordered so that an interruption is always recoverable. Metadata and the
|
||||||
is written first, saying "importing"; the sweep at startup throws
|
transaction record are written before the move. Startup either retries
|
||||||
away anything still saying that and lets it be imported again. A
|
from Downloads or resumes from the moved managed original. A presentation
|
||||||
presentation only becomes visible to a teacher once every file it
|
becomes ready only once every derived file it needs is on disk.
|
||||||
needs is on disk.
|
|
||||||
"""
|
"""
|
||||||
root = root if root is not None else library.library_root()
|
root = root if root is not None else library.library_root()
|
||||||
mark = library.fingerprint(source)
|
mark = library.fingerprint(source)
|
||||||
@@ -87,7 +233,7 @@ def import_one(source, root=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
directory = new_directory(root)
|
directory = new_directory(root)
|
||||||
managed = directory / source.name
|
managed = directory / "original" / source.name
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"id": directory.name,
|
"id": directory.name,
|
||||||
@@ -95,7 +241,7 @@ def import_one(source, root=None):
|
|||||||
"title": library.readable_title(source.name),
|
"title": library.readable_title(source.name),
|
||||||
"original_name": source.name,
|
"original_name": source.name,
|
||||||
"source_path": str(source),
|
"source_path": str(source),
|
||||||
"managed_copy": source.name,
|
"managed_copy": str(managed.relative_to(directory)),
|
||||||
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
|
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
|
||||||
"imported_at": library.stamp(),
|
"imported_at": library.stamp(),
|
||||||
"last_presented_at": None,
|
"last_presented_at": None,
|
||||||
@@ -116,47 +262,22 @@ def import_one(source, root=None):
|
|||||||
say(f"importing {source.name}")
|
say(f"importing {source.name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# THE TEACHER'S OWN DOWNLOAD IS COPIED, NEVER MOVED.
|
move_download(source, managed)
|
||||||
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
|
except Exception as error: # noqa: BLE001
|
||||||
# A card that says it could not be opened, rather than a card
|
# A failed move leaves the source in Downloads. Remove the transaction
|
||||||
# that opens onto nothing. The teacher's download is untouched
|
# and retry on the next scan; there is no half-owned file to show.
|
||||||
# either way, which is what the message says.
|
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["state"] = "failed"
|
||||||
data["error"] = str(error)[:300]
|
data["error"] = str(error)[:300]
|
||||||
presentation.save()
|
presentation.save()
|
||||||
@@ -167,36 +288,47 @@ def import_one(source, root=None):
|
|||||||
|
|
||||||
|
|
||||||
def sweep(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
|
If Downloads still has the source, discard only the incomplete managed
|
||||||
"importing" and a ledger entry claiming the download is dealt with.
|
copy and let the next scan retry. If the source has already been moved,
|
||||||
Both go, and the next scan imports it properly.
|
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()
|
root = root if root is not None else library.library_root()
|
||||||
|
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
ledger = library.ledger(root)
|
|
||||||
stale = 0
|
stale = 0
|
||||||
|
|
||||||
for presentation in library.library(root):
|
for presentation in library.library(root):
|
||||||
if presentation.state != "importing":
|
if presentation.state != "importing":
|
||||||
continue
|
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 (managed is not None and managed.is_file()
|
||||||
if value == presentation.id:
|
and (source is None or not source.exists())):
|
||||||
ledger.pop(mark, None)
|
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)
|
shutil.rmtree(presentation.directory, ignore_errors=True)
|
||||||
stale += 1
|
stale += 1
|
||||||
|
|
||||||
if stale:
|
|
||||||
library.write_json(library.ledger_path(root), ledger)
|
|
||||||
|
|
||||||
return stale
|
return stale
|
||||||
|
|
||||||
|
|
||||||
@@ -222,9 +354,8 @@ def scan(root=None, downloads=None):
|
|||||||
def cleanup(root=None, moment=None):
|
def cleanup(root=None, moment=None):
|
||||||
"""Remove presentations whose time is up.
|
"""Remove presentations whose time is up.
|
||||||
|
|
||||||
Only ever the library's own directory, and never the presentation on
|
Only ever the selected managed directory, including its one original and
|
||||||
the screen. The teacher's download is not part of this and is not
|
derived assets, and never the presentation on the screen.
|
||||||
looked at.
|
|
||||||
"""
|
"""
|
||||||
root = root if root is not None else library.library_root()
|
root = root if root is not None else library.library_root()
|
||||||
lock = library.presenting_lock()
|
lock = library.presenting_lock()
|
||||||
@@ -254,7 +385,7 @@ def cleanup(root=None, moment=None):
|
|||||||
|
|
||||||
say(f"removing {presentation.title!r} — {why}")
|
say(f"removing {presentation.title!r} — {why}")
|
||||||
|
|
||||||
shutil.rmtree(presentation.directory, ignore_errors=True)
|
if library.remove(presentation):
|
||||||
removed += 1
|
removed += 1
|
||||||
|
|
||||||
return removed
|
return removed
|
||||||
|
|||||||
+227
-54
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Three buttons on a card — Present, Quick Look, Remove — and nothing else.
|
Three buttons on a card — Present, Quick Look, Remove — and nothing else.
|
||||||
No storage locations, no file names, no import controls, no settings, no
|
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.
|
recognises tonight's by its picture, and presses Present.
|
||||||
|
|
||||||
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
|
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 os
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
@@ -106,17 +105,73 @@ button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 16px;
|
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:
|
try:
|
||||||
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||||
str(path), width, -1, True)
|
str(path), width, height, True)
|
||||||
except (GLib.Error, OSError):
|
except (GLib.Error, OSError):
|
||||||
return None
|
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):
|
class Presenter(Gtk.Window):
|
||||||
"""The slideshow. A fullscreen window showing one picture at a time.
|
"""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.
|
press and nowhere else to end up.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, presentation, start_page=1, on_finish=None):
|
def __init__(self, presentation, start_page=1, on_finish=None,
|
||||||
super().__init__(title=presentation.title)
|
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.presentation = presentation
|
||||||
self.on_finish = on_finish
|
self.on_finish = on_finish
|
||||||
self.page = min(max(start_page, 1), presentation.pages or 1)
|
self.page = min(max(start_page, 1), presentation.pages or 1)
|
||||||
self.counted = False
|
self.counted = False
|
||||||
self.finished = False
|
self.finished = False
|
||||||
|
self.display_geometry = display_geometry or monitor_geometry()
|
||||||
# How many times fullscreen has been asked for without it
|
# How many times fullscreen has been asked for without it
|
||||||
# sticking. See _state.
|
# sticking. See _state.
|
||||||
self.refused = 0
|
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()
|
self.fullscreen()
|
||||||
# A slideshow is the only thing on the screen while it is running.
|
# A slideshow is the only thing on the screen while it is running.
|
||||||
self.set_keep_above(True)
|
self.set_keep_above(True)
|
||||||
@@ -151,16 +217,48 @@ class Presenter(Gtk.Window):
|
|||||||
black.parse("#000000")
|
black.parse("#000000")
|
||||||
self.override_background_color(Gtk.StateFlags.NORMAL, black)
|
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.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("key-press-event", self._key)
|
||||||
self.connect("delete-event", lambda *_: self.finish())
|
self.connect("delete-event", lambda *_: self.finish())
|
||||||
self.connect("size-allocate", self._resized)
|
self.connect("size-allocate", self._resized)
|
||||||
self.connect("window-state-event", self._state)
|
self.connect("window-state-event", self._state)
|
||||||
|
self.connect("map-event", self._mapped)
|
||||||
|
|
||||||
self._drawn_at = (0, 0, 0)
|
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):
|
def _state(self, _widget, event):
|
||||||
"""Put the slideshow back on the whole screen if it is taken off it.
|
"""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):
|
def show_page(self):
|
||||||
path = self.presentation.page_image(self.page)
|
path = self.presentation.page_image(self.page)
|
||||||
allocation = self.get_allocation()
|
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:
|
if want == self._drawn_at:
|
||||||
return
|
return
|
||||||
@@ -212,7 +312,7 @@ class Presenter(Gtk.Window):
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
pixbuf = self._fitted(path, allocation.width, allocation.height)
|
pixbuf = self._fitted(path, width, height)
|
||||||
|
|
||||||
if pixbuf is None:
|
if pixbuf is None:
|
||||||
return
|
return
|
||||||
@@ -244,12 +344,30 @@ class Presenter(Gtk.Window):
|
|||||||
ratio = min(width / original.get_width(),
|
ratio = min(width / original.get_width(),
|
||||||
height / original.get_height())
|
height / original.get_height())
|
||||||
|
|
||||||
return original.scale_simple(
|
fitted_width = max(1, int(original.get_width() * ratio))
|
||||||
max(1, int(original.get_width() * ratio)),
|
fitted_height = max(1, int(original.get_height() * ratio))
|
||||||
max(1, int(original.get_height() * ratio)),
|
fitted = original.scale_simple(
|
||||||
|
fitted_width,
|
||||||
|
fitted_height,
|
||||||
GdkPixbuf.InterpType.BILINEAR,
|
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 ---------------------------------------------
|
# -- moving about ---------------------------------------------
|
||||||
|
|
||||||
def go(self, delta):
|
def go(self, delta):
|
||||||
@@ -309,11 +427,15 @@ class Presenter(Gtk.Window):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if self.on_finish is not None:
|
on_finish = self.on_finish
|
||||||
self.on_finish()
|
|
||||||
|
|
||||||
self.destroy()
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -324,7 +446,7 @@ class QuickLook(Gtk.Window):
|
|||||||
purpose because conflating any two of them would be wrong:
|
purpose because conflating any two of them would be wrong:
|
||||||
|
|
||||||
browse which slide is on this screen right now;
|
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.
|
Present start the slideshow, from the beginning.
|
||||||
|
|
||||||
Browsing is not presenting: coming in here, looking through every
|
Browsing is not presenting: coming in here, looking through every
|
||||||
@@ -340,11 +462,21 @@ class QuickLook(Gtk.Window):
|
|||||||
self.presentation = presentation
|
self.presentation = presentation
|
||||||
self.on_close = on_close
|
self.on_close = on_close
|
||||||
self.on_present = on_present
|
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_transient_for(parent)
|
||||||
self.set_modal(True)
|
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 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||||
outer.set_border_width(16)
|
outer.set_border_width(16)
|
||||||
@@ -365,7 +497,7 @@ class QuickLook(Gtk.Window):
|
|||||||
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||||
browse.set_halign(Gtk.Align.CENTER)
|
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))
|
self.back.connect("clicked", lambda *_: self.go(-1))
|
||||||
browse.pack_start(self.back, False, False, 0)
|
browse.pack_start(self.back, False, False, 0)
|
||||||
|
|
||||||
@@ -373,7 +505,7 @@ class QuickLook(Gtk.Window):
|
|||||||
self.counter.get_style_context().add_class("subtle")
|
self.counter.get_style_context().add_class("subtle")
|
||||||
browse.pack_start(self.counter, False, False, 12)
|
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))
|
self.forward.connect("clicked", lambda *_: self.go(1))
|
||||||
browse.pack_start(self.forward, False, False, 0)
|
browse.pack_start(self.forward, False, False, 0)
|
||||||
|
|
||||||
@@ -382,9 +514,15 @@ class QuickLook(Gtk.Window):
|
|||||||
# The three things this window is for.
|
# The three things this window is for.
|
||||||
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
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)
|
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)
|
actions.pack_start(Gtk.Box(), True, True, 0)
|
||||||
|
|
||||||
@@ -408,14 +546,18 @@ class QuickLook(Gtk.Window):
|
|||||||
|
|
||||||
def show_page(self, force=True):
|
def show_page(self, force=True):
|
||||||
allocation = self.slide.get_allocation()
|
allocation = self.slide.get_allocation()
|
||||||
width = max(320, allocation.width or 900)
|
width = min(self.preview_width,
|
||||||
want = (self.page, width, 0)
|
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:
|
if want == self._drawn_at and not force:
|
||||||
return
|
return
|
||||||
|
|
||||||
path = self.presentation.page_image(self.page)
|
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:
|
if pixbuf is not None:
|
||||||
self.slide.set_from_pixbuf(pixbuf)
|
self.slide.set_from_pixbuf(pixbuf)
|
||||||
@@ -423,12 +565,12 @@ class QuickLook(Gtk.Window):
|
|||||||
|
|
||||||
total = self.presentation.pages
|
total = self.presentation.pages
|
||||||
chosen = self.presentation.thumbnail_page
|
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}")
|
||||||
self.counter.set_text(f"Slide {self.page} of {total}{note}")
|
|
||||||
|
|
||||||
self.back.set_sensitive(self.page > 1)
|
self.back.set_sensitive(self.page > 1)
|
||||||
self.forward.set_sensitive(self.page < total)
|
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):
|
def go(self, delta):
|
||||||
target = self.page + delta
|
target = self.page + delta
|
||||||
@@ -483,7 +625,7 @@ class Library(Gtk.Window):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(title="Presentations")
|
super().__init__(title="Presentations")
|
||||||
|
|
||||||
self.set_default_size(1180, 820)
|
self.set_default_size(680, 420)
|
||||||
self.presenter = None
|
self.presenter = None
|
||||||
self._signature = None
|
self._signature = None
|
||||||
|
|
||||||
@@ -512,7 +654,7 @@ class Library(Gtk.Window):
|
|||||||
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
|
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||||
self.grid.set_row_spacing(16)
|
self.grid.set_row_spacing(16)
|
||||||
self.grid.set_column_spacing(16)
|
self.grid.set_column_spacing(16)
|
||||||
self.grid.set_homogeneous(True)
|
self.grid.set_homogeneous(False)
|
||||||
scroller.add(self.grid)
|
scroller.add(self.grid)
|
||||||
|
|
||||||
self.connect("key-press-event", self._key)
|
self.connect("key-press-event", self._key)
|
||||||
@@ -544,6 +686,29 @@ class Library(Gtk.Window):
|
|||||||
for p in presentations
|
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):
|
def refresh(self):
|
||||||
presentations = library.library()
|
presentations = library.library()
|
||||||
signature = self.signature(presentations)
|
signature = self.signature(presentations)
|
||||||
@@ -557,12 +722,10 @@ class Library(Gtk.Window):
|
|||||||
self.grid.remove(child)
|
self.grid.remove(child)
|
||||||
|
|
||||||
if not presentations:
|
if not presentations:
|
||||||
self.note.set_text(
|
self.note.set_text("")
|
||||||
"Download a presentation in Chrome and it will appear here.")
|
|
||||||
empty = Gtk.Label(
|
empty = Gtk.Label(
|
||||||
label="Nothing here yet.\n\n"
|
label="No presentations yet\n\n"
|
||||||
"Download a PowerPoint or PDF in Chrome, and it will "
|
"Download a PowerPoint or PDF to get started.")
|
||||||
"appear on this screen by itself.")
|
|
||||||
empty.get_style_context().add_class("empty")
|
empty.get_style_context().add_class("empty")
|
||||||
empty.set_justify(Gtk.Justification.CENTER)
|
empty.set_justify(Gtk.Justification.CENTER)
|
||||||
self.grid.add(empty)
|
self.grid.add(empty)
|
||||||
@@ -589,31 +752,36 @@ class Library(Gtk.Window):
|
|||||||
self.grid.add(self.card(presentation))
|
self.grid.add(self.card(presentation))
|
||||||
|
|
||||||
self.grid.show_all()
|
self.grid.show_all()
|
||||||
|
self._fit_window(len(presentations))
|
||||||
|
|
||||||
def card(self, presentation):
|
def card(self, presentation):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||||
box.get_style_context().add_class("card")
|
box.get_style_context().add_class("card")
|
||||||
box.set_size_request(CARD_WIDTH, -1)
|
box.set_size_request(CARD_WIDTH, -1)
|
||||||
|
|
||||||
picture = Gtk.Image()
|
picture_frame = Gtk.EventBox()
|
||||||
picture.get_style_context().add_class("thumb")
|
picture_frame.get_style_context().add_class("thumb")
|
||||||
pixbuf = None
|
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
|
||||||
|
|
||||||
if presentation.thumbnail.exists():
|
if presentation.thumbnail.exists():
|
||||||
pixbuf = scaled_pixbuf(presentation.thumbnail, CARD_WIDTH - 28)
|
picture = Gtk.Image()
|
||||||
|
pixbuf = scaled_pixbuf(
|
||||||
|
presentation.thumbnail, CARD_WIDTH - 28, 180)
|
||||||
if pixbuf is not None:
|
if pixbuf is not None:
|
||||||
picture.set_from_pixbuf(pixbuf)
|
picture.set_from_pixbuf(pixbuf)
|
||||||
else:
|
else:
|
||||||
|
picture = Gtk.Image()
|
||||||
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
|
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
|
||||||
picture.set_pixel_size(96)
|
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 = Gtk.Label(label=presentation.title, xalign=0)
|
||||||
title.get_style_context().add_class("card-title")
|
title.get_style_context().add_class("card-title")
|
||||||
title.set_line_wrap(True)
|
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)
|
box.pack_start(title, False, False, 0)
|
||||||
|
|
||||||
note = Gtk.Label(xalign=0)
|
note = Gtk.Label(xalign=0)
|
||||||
@@ -637,11 +805,9 @@ class Library(Gtk.Window):
|
|||||||
elif presentation.failed:
|
elif presentation.failed:
|
||||||
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
|
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
|
||||||
#
|
#
|
||||||
# Never the converter's error text, never a path, never a
|
# Never the converter's error text, a path or a terminal.
|
||||||
# terminal. The one useful fact is that their own download is
|
# The managed original remains recoverable until Remove.
|
||||||
# still where they left it.
|
note.set_text("This file could not be opened.")
|
||||||
note.set_text("This file could not be opened.\n"
|
|
||||||
"Your download in Chrome is still there.")
|
|
||||||
note.set_line_wrap(True)
|
note.set_line_wrap(True)
|
||||||
else:
|
else:
|
||||||
note.set_text("Getting this ready…")
|
note.set_text("Getting this ready…")
|
||||||
@@ -678,9 +844,12 @@ class Library(Gtk.Window):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
self.presenter = Presenter(
|
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.show_all()
|
||||||
self.presenter.present()
|
self.presenter.present()
|
||||||
|
self.presenter.fullscreen()
|
||||||
|
|
||||||
def _presented(self):
|
def _presented(self):
|
||||||
self.presenter = None
|
self.presenter = None
|
||||||
@@ -715,12 +884,12 @@ class Library(Gtk.Window):
|
|||||||
modal=True,
|
modal=True,
|
||||||
message_type=Gtk.MessageType.QUESTION,
|
message_type=Gtk.MessageType.QUESTION,
|
||||||
buttons=Gtk.ButtonsType.NONE,
|
buttons=Gtk.ButtonsType.NONE,
|
||||||
text=f"Remove “{presentation.title}”?",
|
text="Remove this presentation?",
|
||||||
)
|
)
|
||||||
dialog.format_secondary_text(
|
dialog.format_secondary_text(
|
||||||
"It will be taken off this screen.\n"
|
f"“{presentation.title}”\n\n"
|
||||||
"Your download in Chrome is not affected.")
|
"This will delete it from this computer.")
|
||||||
dialog.add_button("Keep it", Gtk.ResponseType.CANCEL)
|
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
||||||
dialog.add_button("Remove", Gtk.ResponseType.OK)
|
dialog.add_button("Remove", Gtk.ResponseType.OK)
|
||||||
dialog.set_default_response(Gtk.ResponseType.CANCEL)
|
dialog.set_default_response(Gtk.ResponseType.CANCEL)
|
||||||
|
|
||||||
@@ -730,7 +899,11 @@ class Library(Gtk.Window):
|
|||||||
if answer != Gtk.ResponseType.OK:
|
if answer != Gtk.ResponseType.OK:
|
||||||
return
|
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._signature = None
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
|
|||||||
@@ -191,8 +191,8 @@ else:
|
|||||||
check("and Parent Call in the corner",
|
check("and Parent Call in the corner",
|
||||||
every and CALL in every["launchers"],
|
every and CALL in every["launchers"],
|
||||||
str((every or {}).get("launchers")))
|
str((every or {}).get("launchers")))
|
||||||
check("and Help beside it",
|
check("and the unfinished Help launcher is hidden",
|
||||||
every and HELP in every["launchers"],
|
every and HELP not in every["launchers"],
|
||||||
str((every or {}).get("launchers")))
|
str((every or {}).get("launchers")))
|
||||||
|
|
||||||
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
|
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
|
||||||
@@ -210,11 +210,10 @@ else:
|
|||||||
check("with no Parent Call button on it",
|
check("with no Parent Call button on it",
|
||||||
bible and CALL not in bible["launchers"],
|
bible and CALL not in bible["launchers"],
|
||||||
str((bible or {}).get("launchers")))
|
str((bible or {}).get("launchers")))
|
||||||
# Help is not optional. A teacher who needs the manual is by
|
# The source and launcher remain installed, but the unfinished digital
|
||||||
# definition one who does not know where to look, so every room has
|
# Help entry point is deliberately absent from the teacher-facing bar.
|
||||||
# it — what differs is which pages it opens on.
|
check("and unfinished Help stays hidden",
|
||||||
check("but Help is still there, because every room has a manual",
|
bible and HELP not in bible["launchers"],
|
||||||
bible and HELP in bible["launchers"],
|
|
||||||
str((bible or {}).get("launchers")))
|
str((bible or {}).get("launchers")))
|
||||||
SLIDES = "applications:ekklesia-presentations.desktop"
|
SLIDES = "applications:ekklesia-presentations.desktop"
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
|
|
||||||
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
|
||||||
import os
|
import os
|
||||||
|
import errno
|
||||||
|
import importlib.util
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from importlib.machinery import SourceFileLoader
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
root = Path(sys.argv[1])
|
root = Path(sys.argv[1])
|
||||||
@@ -26,6 +29,14 @@ sys.path.insert(0, str(root / "lib"))
|
|||||||
|
|
||||||
import classroom_presentations as library
|
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
|
passed = failed = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -242,19 +253,175 @@ check("it lives in the runtime directory",
|
|||||||
or str(library.presenting_lock()).startswith("/tmp"),
|
or str(library.presenting_lock()).startswith("/tmp"),
|
||||||
str(library.presenting_lock()))
|
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)
|
mark = library.fingerprint(done)
|
||||||
library.remember(mark, "some-id", shelf)
|
library.remember(mark, "some-id", shelf)
|
||||||
check("a fingerprint is path, size and time",
|
check("a fingerprint is path, size and time",
|
||||||
mark is not None and str(done.resolve()) in mark, str(mark))
|
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("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))
|
mark in library.ledger(shelf))
|
||||||
touched = download("Session 7 - Romans.pptx", contents="y" * 900)
|
touched = download("Session 7 - Romans.pptx", contents="y" * 900)
|
||||||
check("but re-downloading the same session is a new presentation",
|
check("but re-downloading the same session is a new presentation",
|
||||||
library.fingerprint(touched) != mark,
|
library.fingerprint(touched) != mark,
|
||||||
"a different size or time is a different download")
|
"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)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restr
|
|||||||
print()
|
print()
|
||||||
print("4d. clicking a launcher never hides an application")
|
print("4d. clicking a launcher never hides an application")
|
||||||
check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout)
|
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())
|
(root / "config/plasma/xdg/kwinrc").read_text())
|
||||||
|
|
||||||
print()
|
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 'PRESENTATIONS_ENABLED +shown'
|
||||||
classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden'
|
classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden'
|
||||||
|
|
||||||
# The panel offers Presentations only where it is enabled, and the three
|
# The panel offers Presentations only where it is enabled, keeps the classroom
|
||||||
# applications that were already on the bar keep their order.
|
# 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
|
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 'var PRESENTATIONS = true;' "$layout"
|
||||||
grep -q 'ekklesia-presentations.desktop' "$layout"
|
grep -q 'ekklesia-presentations.desktop' "$layout"
|
||||||
grep -q 'ekklesia-manual.desktop' "$layout"
|
! grep -q 'ekklesia-manual.desktop' "$layout"
|
||||||
python3 - "$layout" <<'ORDER_CHECK'
|
python3 - "$layout" <<'ORDER_CHECK'
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
Reference in New Issue
Block a user