Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)
- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das
Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“
(umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im
verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen
(/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021,
übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge
(/feeds/folders/order/).
- Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +;
Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte;
Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste.
- Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem
E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel
verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie
auf E-Ink.
- Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor,
Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie,
Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster,
Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau
und Details – einzigartig, überall gleich, nichts gespeichert.
- Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
|
|
|
/*
|
|
|
|
|
* Book covers, end-to-end encrypted (docs/library.md). A cover the server has
|
|
|
|
|
* (has_cover) is fetched and decrypted here; a book without one gets it from its own
|
|
|
|
|
* file — on a device that keeps the books (the desktop and Android apps, or the
|
|
|
|
|
* browser's cache): the EPUB's cover image or a PDF's first page, scaled to 300 px,
|
|
|
|
|
* encrypted with the user's key and sent once. The server never sees a cover.
|
|
|
|
|
*
|
|
|
|
|
* Gently: one book at a time, when the page is idle, never while a book is open, and
|
|
|
|
|
* not at all on e-ink or in diora Reader (the list there has no covers). Decrypted
|
|
|
|
|
* covers are kept on this device (IndexedDB) and handed out as object URLs.
|
|
|
|
|
*
|
|
|
|
|
* DioraCovers.url(book) an object URL, or null (and it is looked for, once)
|
|
|
|
|
* DioraCovers.onReady(fn) fn(bookId) when one becomes available
|
|
|
|
|
*
|
|
|
|
|
* Chrome-61-safe: no ?. / ??.
|
|
|
|
|
*/
|
|
|
|
|
(function () {
|
|
|
|
|
'use strict';
|
|
|
|
|
const MAX_W = 300, MAX_H = 450, QUALITY = 0.72;
|
|
|
|
|
const html = document.documentElement;
|
|
|
|
|
const off = () => html.classList.contains('nui-reader-app') || (document.body && document.body.classList.contains('eink'));
|
|
|
|
|
const readerOpen = () => {
|
|
|
|
|
const o = document.getElementById('reader-overlay');
|
|
|
|
|
return !!o && o.style.display !== 'none'; // open: '' (the markup starts with none)
|
|
|
|
|
};
|
|
|
|
|
const localBooks = () => !!(window.dioraDesktop || window.DioraNative); // the app keeps every file
|
|
|
|
|
|
|
|
|
|
const urls = new Map(); // id -> object URL
|
|
|
|
|
const tried = new Set(); // looked for this session
|
|
|
|
|
const queue = [];
|
|
|
|
|
const listeners = [];
|
|
|
|
|
let busy = false;
|
|
|
|
|
|
|
|
|
|
// ---- this device's store ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
let dbp = null;
|
|
|
|
|
function db() {
|
|
|
|
|
if (!dbp) {
|
|
|
|
|
dbp = new Promise(resolve => {
|
|
|
|
|
try {
|
|
|
|
|
const req = indexedDB.open('diora_covers', 1);
|
|
|
|
|
req.onupgradeneeded = () => req.result.createObjectStore('covers');
|
|
|
|
|
req.onsuccess = () => resolve(req.result);
|
|
|
|
|
req.onerror = () => resolve(null);
|
|
|
|
|
} catch (e) { resolve(null); }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return dbp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readLocal(id) {
|
|
|
|
|
const d = await db();
|
|
|
|
|
if (!d) return null;
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
try {
|
|
|
|
|
const req = d.transaction('covers').objectStore('covers').get(String(id));
|
|
|
|
|
req.onsuccess = () => resolve(req.result || null);
|
|
|
|
|
req.onerror = () => resolve(null);
|
|
|
|
|
} catch (e) { resolve(null); }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeLocal(id, blob) {
|
|
|
|
|
const d = await db();
|
|
|
|
|
if (!d) return;
|
|
|
|
|
try { d.transaction('covers', 'readwrite').objectStore('covers').put(blob, String(id)); } catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ready(id, blob) {
|
|
|
|
|
if (urls.has(id)) URL.revokeObjectURL(urls.get(id));
|
|
|
|
|
urls.set(id, URL.createObjectURL(blob));
|
|
|
|
|
listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- finding one ------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function key() {
|
|
|
|
|
return typeof getOrCreateEncKey === 'function' ? getOrCreateEncKey() : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fromServer(id) {
|
|
|
|
|
const r = await fetch('/books/' + id + '/cover/', {credentials: 'same-origin'});
|
|
|
|
|
if (!r.ok) return null;
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
const plain = await decryptBytes(await key(), d.cover_iv, d.cover_ct);
|
|
|
|
|
return new Blob([plain], {type: 'image/jpeg'});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The book's own file, decrypted: the browser's cache, or the app's store.
|
|
|
|
|
async function bookBytes(id) {
|
|
|
|
|
let rec = typeof _getCachedBook === 'function' ? await _getCachedBook(id) : null;
|
|
|
|
|
if (!rec && localBooks()) {
|
|
|
|
|
const r = await fetch('/books/' + id + '/data/', {credentials: 'same-origin'});
|
|
|
|
|
if (r.ok) rec = await r.json();
|
|
|
|
|
}
|
|
|
|
|
if (!rec || !rec.data_ct) return null;
|
|
|
|
|
return decryptBytes(await key(), rec.data_iv, rec.data_ct);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function epubCover(bytes) {
|
|
|
|
|
const zip = await JSZip.loadAsync(bytes);
|
|
|
|
|
const container = zip.file('META-INF/container.xml');
|
|
|
|
|
if (!container) return null;
|
|
|
|
|
const cdoc = new DOMParser().parseFromString(await container.async('text'), 'application/xml');
|
|
|
|
|
const root = cdoc.querySelector('rootfile');
|
|
|
|
|
if (!root) return null;
|
|
|
|
|
const opfPath = root.getAttribute('full-path');
|
|
|
|
|
const dir = opfPath.indexOf('/') >= 0 ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : '';
|
|
|
|
|
const opf = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
|
|
|
|
|
const items = Array.prototype.slice.call(opf.getElementsByTagName('item'));
|
|
|
|
|
const meta = Array.prototype.slice.call(opf.getElementsByTagName('meta')).filter(m => m.getAttribute('name') === 'cover')[0];
|
|
|
|
|
const isImage = i => /^image\//.test(i.getAttribute('media-type') || '');
|
|
|
|
|
let item = items.filter(i => /\bcover-image\b/.test(i.getAttribute('properties') || ''))[0] ||
|
|
|
|
|
(meta ? items.filter(i => i.getAttribute('id') === meta.getAttribute('content') && isImage(i))[0] : null) ||
|
|
|
|
|
items.filter(i => isImage(i) && /cover/i.test((i.getAttribute('id') || '') + (i.getAttribute('href') || '')))[0];
|
|
|
|
|
if (!item) return null;
|
|
|
|
|
const path = decodeURIComponent(resolveEpubPath(dir, item.getAttribute('href')));
|
|
|
|
|
const file = zip.file(path) || zip.file(path.replace(/^\//, ''));
|
|
|
|
|
if (!file) return null;
|
|
|
|
|
return new Blob([await file.async('arraybuffer')], {type: item.getAttribute('media-type')});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pdfCover(bytes) {
|
|
|
|
|
if (typeof pdfjsLib === 'undefined') return null;
|
PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet)
- 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird
abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die
gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker
(_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts-
verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel;
ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite.
- 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in
Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen
Seite (eine fast leere Seite wird nicht unendlich groß); nächste und
vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten
nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die
Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn'
gemeldet (E-Ink-Refresh).
- pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf,
Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab).
- Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten
immer „Seite 1“).
- Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu,
wenn er ruht.
- openBook hängt seine Listener über _readerListen an; closeReader nimmt
sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und
ein zweites Buch geben das alte Dokument frei; der entschlüsselte
PDF-Puffer wird nicht mehr gehalten.
- Invertieren dreht die Farbtöne zurück (Fotos keine Negative).
- e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten,
Outline, Links und einer fast leeren Seite.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00
|
|
|
const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes))
|
|
|
|
|
: pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise;
|
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)
- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das
Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“
(umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im
verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen
(/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021,
übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge
(/feeds/folders/order/).
- Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +;
Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte;
Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste.
- Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem
E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel
verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie
auf E-Ink.
- Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor,
Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie,
Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster,
Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau
und Details – einzigartig, überall gleich, nichts gespeichert.
- Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
|
|
|
const canvas = document.createElement('canvas');
|
PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet)
- 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird
abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die
gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker
(_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts-
verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel;
ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite.
- 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in
Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen
Seite (eine fast leere Seite wird nicht unendlich groß); nächste und
vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten
nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die
Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn'
gemeldet (E-Ink-Refresh).
- pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf,
Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab).
- Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten
immer „Seite 1“).
- Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu,
wenn er ruht.
- openBook hängt seine Listener über _readerListen an; closeReader nimmt
sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und
ein zweites Buch geben das alte Dokument frei; der entschlüsselte
PDF-Puffer wird nicht mehr gehalten.
- Invertieren dreht die Farbtöne zurück (Fotos keine Negative).
- e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten,
Outline, Links und einer fast leeren Seite.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00
|
|
|
try {
|
|
|
|
|
const page = await pdf.getPage(1);
|
|
|
|
|
const v = page.getViewport({scale: 1});
|
|
|
|
|
const scale = Math.min(MAX_W / v.width, MAX_H / v.height);
|
|
|
|
|
const viewport = page.getViewport({scale});
|
|
|
|
|
canvas.width = Math.round(viewport.width);
|
|
|
|
|
canvas.height = Math.round(viewport.height);
|
|
|
|
|
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
|
|
|
|
} finally {
|
|
|
|
|
try { pdf.destroy(); } catch (e) {}
|
|
|
|
|
}
|
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht)
- Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das
Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“
(umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im
verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen
(/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021,
übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge
(/feeds/folders/order/).
- Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +;
Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte;
Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste.
- Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem
E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel
verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie
auf E-Ink.
- Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor,
Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie,
Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster,
Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau
und Details – einzigartig, überall gleich, nichts gespeichert.
- Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
|
|
|
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Any image to a small JPEG.
|
|
|
|
|
async function shrink(blob) {
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
try {
|
|
|
|
|
const img = await new Promise((resolve, reject) => {
|
|
|
|
|
const i = new Image();
|
|
|
|
|
i.onload = () => resolve(i);
|
|
|
|
|
i.onerror = reject;
|
|
|
|
|
i.src = url;
|
|
|
|
|
});
|
|
|
|
|
const scale = Math.min(1, MAX_W / img.naturalWidth, MAX_H / img.naturalHeight);
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
|
|
|
|
|
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
|
|
|
|
|
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
return await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY));
|
|
|
|
|
} finally {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function extract(book) {
|
|
|
|
|
const bytes = await bookBytes(book.id);
|
|
|
|
|
if (!bytes) return null;
|
|
|
|
|
let blob = book.type === 'pdf' ? await pdfCover(bytes) : await epubCover(bytes);
|
|
|
|
|
if (!blob) return null;
|
|
|
|
|
if (book.type !== 'pdf') blob = await shrink(blob);
|
|
|
|
|
if (!blob) return null;
|
|
|
|
|
const enc = await encryptBytes(await key(), new Uint8Array(await blob.arrayBuffer()));
|
|
|
|
|
fetch('/books/' + book.id + '/cover/', {
|
|
|
|
|
method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
|
|
|
|
|
body: JSON.stringify({cover_ct: enc.ciphertext, cover_iv: enc.iv}),
|
|
|
|
|
}).then(() => { book.has_cover = true; }).catch(() => {});
|
|
|
|
|
return blob;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function find(book) {
|
|
|
|
|
const local = await readLocal(book.id);
|
|
|
|
|
if (local) return local;
|
|
|
|
|
let blob = null;
|
|
|
|
|
if (book.has_cover) blob = await fromServer(book.id).catch(() => null);
|
|
|
|
|
if (!blob && !book.has_cover && book.keyOk !== false) blob = await extract(book).catch(() => null);
|
|
|
|
|
if (blob) writeLocal(book.id, blob);
|
|
|
|
|
return blob;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- the queue: one at a time, when idle -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const idle = fn => (window.requestIdleCallback ? requestIdleCallback(fn, {timeout: 3000}) : setTimeout(fn, 400));
|
|
|
|
|
|
|
|
|
|
function pump() {
|
|
|
|
|
if (busy || !queue.length) return;
|
|
|
|
|
if (readerOpen()) { setTimeout(pump, 5000); return; }
|
|
|
|
|
busy = true;
|
|
|
|
|
idle(async () => {
|
|
|
|
|
const book = queue.shift();
|
|
|
|
|
try {
|
|
|
|
|
const blob = await find(book);
|
|
|
|
|
if (blob) ready(book.id, blob);
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
busy = false;
|
|
|
|
|
setTimeout(pump, 150);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function url(book) {
|
|
|
|
|
if (!book || off()) return null;
|
|
|
|
|
if (urls.has(book.id)) return urls.get(book.id);
|
|
|
|
|
if (!tried.has(book.id)) {
|
|
|
|
|
tried.add(book.id);
|
|
|
|
|
// Stored here already: at once, without waiting in the queue.
|
|
|
|
|
readLocal(book.id).then(blob => {
|
|
|
|
|
if (blob) ready(book.id, blob);
|
|
|
|
|
else { queue.push(book); pump(); }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A book read just now: its file is open anyway (the reader calls this).
|
|
|
|
|
function forget(id) {
|
|
|
|
|
tried.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.DioraCovers = {url, onReady(fn) { listeners.push(fn); }, forget};
|
|
|
|
|
})();
|