Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ Open `convert.html` on the served site (or `node tools/pptx-to-html.mjs talk.ppt
converter could not handle.
- **Download** a single HTML file you can double-click, or the deck folder as a ZIP.
- **Save in this browser** to come back to a conversion without downloading it. Saved
decks are listed on the page and kept in this browser on this computer until you remove
them or clear site data. They are not synced anywhere.
decks appear under "Your decks" on the main page, with Open and Remove. They are kept in
this browser on this computer until you remove them or clear site data.

The folder holds `slides.html`, `strings.json` and `images/`. `strings.json` lists
every paragraph, speaker note and alt text under a stable ID, in the format described
Expand Down
62 changes: 62 additions & 0 deletions assets/saved-decks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Decks converted in the browser and saved there, shared by the landing page and
* the converter. Each is kept as its original .pptx in IndexedDB (too large for
* sessionStorage) and converted again when opened. Nothing leaves the browser.
*/
const DB_NAME = 'decks-session';
const STORE = 'decks';

export const hasStorage = typeof indexedDB !== 'undefined';

const openDb = () => new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});

async function run(mode, action) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const transaction = db.transaction(STORE, mode);
const request = action(transaction.objectStore(STORE));
transaction.oncomplete = () => resolve(request?.result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
} finally {
db.close();
}
}

/** Newest first. Never throws: blocked storage just means an empty list. */
export async function listSaved() {
if (!hasStorage) return [];
try {
return ((await run('readonly', (s) => s.getAll())) ?? []).sort((a, b) => b.savedAt - a.savedAt);
} catch {
return [];
}
}

export const getSaved = (id) => run('readonly', (s) => s.get(id));
export const removeSaved = (id) => run('readwrite', (s) => s.delete(id));

/** `bytes` is the original .pptx as a Uint8Array. */
export async function saveDeck({ name, bytes, title, slug, author, slides }) {
// Ask the browser not to evict saved decks under storage pressure; harmless if refused.
navigator.storage?.persist?.().catch(() => {});
const record = {
id: `${name}:${bytes.byteLength}`,
name,
title,
slug,
author,
slides,
bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
savedAt: Date.now(),
};
await run('readwrite', (s) => s.put(record));
return record.id;
}
146 changes: 24 additions & 122 deletions convert.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,23 +118,6 @@
#preview.hidden { display: none; }
#preview iframe { width: 100%; height: 100%; border: 0; display: block; }
#closePreview { position: fixed; top: 14px; right: 16px; z-index: 60; background: rgba(0, 0, 0, 0.6); }

.saved-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
.saved-head h2 { margin: 0; flex: 1; }
.saved-head button { padding: 5px 11px; font-size: 0.82rem; }
.saved { list-style: none; margin: 0; padding: 0; }
.saved li {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 0;
border-top: 1px solid var(--brand-border);
}
.saved li:first-child { border-top: 0; padding-top: 0; }
.saved .what { flex: 1; min-width: 0; }
.saved .what b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.saved .what span { color: var(--brand-muted); font-size: 0.82rem; }
.saved button { padding: 6px 12px; font-size: 0.85rem; }
</style>
</head>
<body>
Expand All @@ -158,19 +141,6 @@ <h1>Convert a PowerPoint to HTML</h1>

<div class="status" id="status"></div>

<section class="panel hidden" id="saved">
<div class="saved-head">
<h2>Saved in this browser</h2>
<span class="note" id="savedTotal"></span>
<button id="removeAllBtn">Remove all</button>
</div>
<ul class="saved" id="savedList"></ul>
<p class="note" style="margin-top: 14px">
Kept in this browser only, on this computer. They stay until you remove them or
clear site data, and are not synced anywhere, so download anything you need
elsewhere.
</p>
</section>
<section class="panel hidden" id="result">
<h2 id="resultTitle">Converted</h2>
<div class="stats" id="stats"></div>
Expand Down Expand Up @@ -255,6 +225,7 @@ <h2>This page needs a web server</h2>
import { pptxToHtml } from './tools/pptx-html.mjs';
import { readPptx, makeZip } from './tools/zip-tools.mjs';
import { assembleStandaloneHtml } from './tools/standalone-html.mjs';
import { hasStorage, saveDeck, getSaved } from './assets/saved-decks.mjs';

const REVEAL_VERSION = '5.2.1';
const BASE = window.DECK_ASSET_BASE;
Expand Down Expand Up @@ -333,7 +304,7 @@ <h2>This page needs a web server</h2>
const guess = fields?.slug || slugify(name.replace(/\.pptx$/i, ''));
$('slug').value = guess;
const probe = pptxToHtml(files, { slug: guess, description: `Converted from ${name}.` });
$('title').value = fields?.title || probe.deck.title;
$('title').value = fields?.title || (probe.deck.title === guess ? name.replace(/\.pptx$/i, '') : probe.deck.title);
$('author').value = fields?.author ?? probe.deck.author;
current.result = probe;
$('saveBtn').textContent = 'Save in this browser';
Expand All @@ -354,113 +325,29 @@ <h2>This page needs a web server</h2>
await loadBytes(file.name, new Uint8Array(await file.arrayBuffer()));
}

/* ------------------------------------------- saved in this session -- */

/*
* Decks are kept as the original .pptx in IndexedDB (large files do not fit
* in sessionStorage) and converted again when opened, which takes well under
* a second. They stay until the user removes them or clears site data for this
* origin. They live in this browser only, and are never sent anywhere.
*/
const DB_NAME = 'decks-session';
const STORE = 'decks';
const hasStorage = typeof indexedDB !== 'undefined';
// Ask the browser not to evict saved decks under storage pressure; harmless if refused.
navigator.storage?.persist?.().catch(() => {});

const openDb = () => new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});

async function dbRun(mode, action) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const transaction = db.transaction(STORE, mode);
const request = action(transaction.objectStore(STORE));
transaction.oncomplete = () => resolve(request?.result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
} finally {
db.close();
}
}

async function listSaved() {
const all = (await dbRun('readonly', (s) => s.getAll())) ?? [];
return all.sort((a, b) => b.savedAt - a.savedAt);
}

async function renderSaved() {
if (!hasStorage) return;
let decks = [];
try { decks = await listSaved(); } catch { /* storage blocked: no list */ }
$('saved').classList.toggle('hidden', decks.length === 0);
const total = decks.reduce((n, d) => n + d.bytes.byteLength, 0);
$('savedTotal').textContent = decks.length ? `${(total / 1048576).toFixed(1)} MB used` : '';
$('savedList').innerHTML = decks.map((d) => `
<li data-id="${escapeHtml(d.id)}">
<div class="what"><b>${escapeHtml(d.title || d.name)}</b>
<span>${escapeHtml(d.name)} · ${d.slides} slides · ${(d.bytes.byteLength / 1048576).toFixed(1)} MB · saved ${new Date(d.savedAt).toLocaleString()}</span></div>
<button data-act="open">Open</button>
<button data-act="remove">Remove</button>
</li>`).join('');
}
/* ------------------------------------------------------ save in browser -- */

$('removeAllBtn').addEventListener('click', async () => {
if (!confirm('Remove every saved deck from this browser?')) return;
await dbRun('readwrite', (s) => s.clear());
$('saveBtn').textContent = 'Save in this browser';
await renderSaved();
});
if (hasStorage) $('saveBtn').classList.remove('hidden');

$('saveBtn').addEventListener('click', async () => {
if (!current) return;
try {
await dbRun('readwrite', (s) => s.put({
id: `${current.name}:${current.bytes.byteLength}`,
await saveDeck({
name: current.name,
bytes: current.bytes,
title: $('title').value,
slug: $('slug').value,
author: $('author').value,
slides: current.result.stats.slides,
bytes: current.bytes.buffer.slice(current.bytes.byteOffset, current.bytes.byteOffset + current.bytes.byteLength),
savedAt: Date.now(),
}));
});
$('saveBtn').textContent = 'Saved ✓';
setStatus('Saved in this browser. It stays until you remove it or clear site data.', 'ok');
await renderSaved();
$('status').innerHTML = 'Saved. Find it under "Your decks" on the <a href="index.html">main page</a>.';
$('status').className = 'status ok';
} catch (error) {
setStatus(`Could not save: ${error?.message || 'browser storage is full or blocked'}.`, 'error');
}
});

$('savedList').addEventListener('click', async (event) => {
const button = event.target.closest('button[data-act]');
if (!button) return;
const id = button.closest('li').dataset.id;
if (button.dataset.act === 'remove') {
await dbRun('readwrite', (s) => s.delete(id));
await renderSaved();
return;
}
const record = await dbRun('readonly', (s) => s.get(id));
if (record) {
await loadBytes(record.name, new Uint8Array(record.bytes), record);
$('saveBtn').textContent = 'Saved ✓';
$('result').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});

if (hasStorage) {
$('saveBtn').classList.remove('hidden');
renderSaved();
}

/* ------------------------------------------------------- downloads -- */

function deckFiles() {
Expand Down Expand Up @@ -612,6 +499,7 @@ <h2>This page needs a web server</h2>
frame.srcdoc = previewDocument(html, deck);
$('preview').classList.remove('hidden');
$('closePreview').classList.remove('hidden');
document.documentElement.style.overflow = 'hidden';
frame.addEventListener('load', () => frame.contentWindow?.focus(), { once: true });
setStatus('');
} catch (error) {
Expand All @@ -622,6 +510,7 @@ <h2>This page needs a web server</h2>
const closePreview = () => {
$('preview').classList.add('hidden');
$('closePreview').classList.add('hidden');
document.documentElement.style.overflow = '';
$('previewFrame').removeAttribute('srcdoc');
previewUrls.forEach((u) => URL.revokeObjectURL(u));
previewUrls = [];
Expand Down Expand Up @@ -651,6 +540,19 @@ <h2>This page needs a web server</h2>
$('tabStrings').addEventListener('click', () => showTab('strings'));
$('tabHtml').addEventListener('click', () => showTab('html'));
$('tabWarn').addEventListener('click', () => showTab('warn'));

// ?open=<id> comes from "Open" on a saved deck on the main page: convert it again and preview it.
const openId = new URLSearchParams(window.location.search).get('open');
if (openId && hasStorage) {
const record = await getSaved(openId).catch(() => null);
if (record) {
await loadBytes(record.name, new Uint8Array(record.bytes), record);
$('saveBtn').textContent = 'Saved ✓';
$('previewBtn').click();
} else {
setStatus('That saved deck is no longer in this browser.', 'error');
}
}
</script>
</body>
</html>
44 changes: 44 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@
padding: 0.12em 0.4em;
font-size: 0.88em;
}
.hidden { display: none; }

.section-title {
margin: 48px 0 0;
font-size: 1.1rem;
color: var(--brand-muted);
font-weight: 600;
}

#mine .grid { margin-top: 16px; }

.import-cta {
margin: 36px 0 0;
padding-top: 22px;
Expand Down Expand Up @@ -183,14 +194,18 @@ <h1>Decks as Code</h1>
<a class="convert-btn" href="convert.html">Convert a new deck</a>
</header>
<div id="content"></div>
<section id="mine" class="hidden"></section>
<p class="import-cta">
Prefer Markdown slides?
<a href="import.html">Import a .pptx as Markdown &rarr;</a>
</p>
</div>

<script type="module">
import { listSaved, removeSaved } from './assets/saved-decks.mjs';

const content = document.getElementById('content');
const mine = document.getElementById('mine');

const escapeHtml = (value) =>
String(value ?? '').replace(/[&<>"']/g, (ch) => ({
Expand Down Expand Up @@ -306,6 +321,34 @@ <h2><a href="${href}">${escapeHtml(deck.title || deck.slug)}</a></h2>
.join('')}</div>`;
}

/** Decks converted in this browser: kept locally, so they get their own section. */
async function renderMine() {
const decks = await listSaved();
mine.classList.toggle('hidden', decks.length === 0);
mine.innerHTML = decks.length ? `
<h2 class="section-title">Your decks</h2>
<div class="grid">${decks.map((d) => `
<article class="card" data-id="${escapeHtml(d.id)}">
<h2><a href="convert.html?open=${encodeURIComponent(d.id)}">${escapeHtml(d.title || d.name)}</a></h2>
<p>${escapeHtml(d.name)} · ${d.slides} slides</p>
<div class="meta">
<span>${escapeHtml(formatDate(d.savedAt))}</span>
<span class="downloads">
<a href="convert.html?open=${encodeURIComponent(d.id)}">Open</a> ·
<a href="#" data-remove="${escapeHtml(d.id)}">Remove</a>
</span>
</div>
</article>`).join('')}</div>` : '';
}

mine.addEventListener('click', async (event) => {
const link = event.target.closest('[data-remove]');
if (!link) return;
event.preventDefault();
await removeSaved(link.dataset.remove);
await renderMine();
});

if (window.location.protocol === 'file:') {
explainFileProtocol();
} else {
Expand All @@ -316,6 +359,7 @@ <h2><a href="${href}">${escapeHtml(deck.title || deck.slug)}</a></h2>
} else if (!loadFailed) {
renderEmpty('No decks found.');
}
await renderMine();
}
</script>
</body>
Expand Down
1 change: 1 addition & 0 deletions tools/build-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ async function main() {
await copyInto('assets/theme.css');
await copyInto('assets/pptx-slide.css');
await copyInto('assets/deck-i18n.js');
await copyInto('assets/saved-decks.mjs');
const deckHtml = await readFile(join(ROOT, 'deck.html'), 'utf8');
const rewritten = deckHtml.replace(
/window\.DECK_ASSET_BASE = '[^']*';/,
Expand Down
Loading