mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-28 01:00:41 +00:00
PPTX import Phase 0: plumbing + lazy-load shell
Sets up the import-pipeline plumbing without doing any actual PPTX
parsing yet. Acceptance criteria from the spec's Phase 0:
- Vendor fflate (0.8.2 UMD) at src/vendor/fflate.min.js so the
importer can unzip .pptx packages without a build step. fflate
exposes window.fflate via the UMD shim.
- Add src/pptx-importer.js exposing window.ArtstrPptxImporter with
PPTX_IMAGE_PLACEHOLDER_URL, makePptxImportReport, and a stub
importPptxFile that throws "not implemented yet — Phase 1 lands
the deck-shell parser." Phase 1 will fill this out.
- Wire loadScriptOnce + ensurePptxImporterLoaded in index.html.
Both fflate and pptx-importer.js are lazy-loaded on the first
click of Import PPTX so the main bundle stays lean for users
who never use this path. Subsequent clicks are no-ops.
- Add an Import PowerPoint (.pptx) button + hidden file input to
the Deck Builder's Slides fieldset, next to the existing
Import slide buttons. Click flow:
button click -> ensurePptxImporterLoaded (with a "Loading…"
label on the button while scripts arrive) -> hidden file
input click -> change -> onPptxFileChosen -> import + report.
- Add the pptxImportReportModal shell with file name, summary
counts, placeholder note, and a collapsible warnings list.
Render is driven by the report data shape defined in spec
section 13. Close on background click or Close button.
Smoke-tested via CDP: first click lazy-loads fflate +
pptx-importer; subsequent calls reuse cached promises; file
picker triggers; Phase 0 stub returns the expected
not-implemented error which surfaces as a toast.
This commit is contained in:
1 parent
467fbcf5e4
commit
d710b84d19
3 files changed
+440
-23
No files matched your search
+383
-23
@@ -3038,6 +3038,10 @@
|
||||
<button type="button" id="deckImportFileBtn">Import from file</button>
|
||||
</div>
|
||||
<input id="deckSlideImportFile" type="file" accept="application/json,.json" hidden />
|
||||
<div class="buttonRow">
|
||||
<button type="button" id="importPptxBtn">Import PowerPoint (.pptx)</button>
|
||||
</div>
|
||||
<input id="pptxFileInput" type="file" accept=".pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation" hidden />
|
||||
<p class="hint" id="deckCountHint">No slides yet — add one to start your deck.</p>
|
||||
<p class="hint">Click a slide thumbnail to edit it. Drag thumbnails to reorder.</p>
|
||||
</fieldset>
|
||||
@@ -4581,6 +4585,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="pptxImportReportModal" aria-hidden="true" style="display:none">
|
||||
<div class="modalInner" style="max-width:520px">
|
||||
<h2 style="font-size:1.05rem;margin:0 0 8px;font-family:inherit">PowerPoint import report</h2>
|
||||
<p class="hint" id="pptxImportReportFile" style="margin:0 0 12px"></p>
|
||||
<div id="pptxImportReportBody" style="margin:0 0 12px"></div>
|
||||
<p class="hint" id="pptxImportReportPlaceholderNote" style="margin:0 0 12px;display:none">
|
||||
Imported images and unsupported objects show as placeholder images. Open each placeholder layer
|
||||
and paste your own image URL to restore the original content.
|
||||
</p>
|
||||
<details id="pptxImportReportWarningsWrap" style="margin:0 0 12px;display:none">
|
||||
<summary style="cursor:pointer;font-size:.85rem">Warnings</summary>
|
||||
<ul id="pptxImportReportWarnings" style="margin:8px 0 0;padding-left:18px;font-size:.82rem"></ul>
|
||||
</details>
|
||||
<div class="buttonRow" style="margin:0;justify-content:flex-end">
|
||||
<button type="button" id="pptxImportReportCloseBtn" class="primary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="layerGroupModal" aria-hidden="true" style="display:none">
|
||||
<div class="modalInner" style="max-width:380px">
|
||||
<h2 style="font-size:1.05rem;margin:0 0 8px;font-family:inherit">Name layer group</h2>
|
||||
@@ -5797,25 +5820,35 @@
|
||||
}, { timeoutMs: 4000 }).catch(() => []));
|
||||
}
|
||||
const receiptSets = await Promise.all(queries);
|
||||
const seenAddresses = new Set();
|
||||
const seenEventIds = new Set();
|
||||
const receiptsByAddress = new Map();
|
||||
const receiptsByEventId = new Map();
|
||||
for (const receipts of receiptSets) {
|
||||
for (const ev of receipts || []) {
|
||||
const aTag = (ev.tags || []).find((t) => t[0] === 'a' && t[1]);
|
||||
if (aTag) seenAddresses.add(aTag[1]);
|
||||
if (aTag) {
|
||||
const arr = receiptsByAddress.get(aTag[1]) || [];
|
||||
arr.push(ev);
|
||||
receiptsByAddress.set(aTag[1], arr);
|
||||
}
|
||||
const eTag = (ev.tags || []).find((t) => t[0] === 'e' && t[1]);
|
||||
if (eTag) seenEventIds.add(eTag[1]);
|
||||
if (eTag) {
|
||||
const arr = receiptsByEventId.get(eTag[1]) || [];
|
||||
arr.push(ev);
|
||||
receiptsByEventId.set(eTag[1], arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ev of freshEvents) {
|
||||
if (isUnlockedLocally(ev.id)) continue;
|
||||
const addr = addressByEvent.get(ev.id);
|
||||
if (addr && seenAddresses.has(addr)) {
|
||||
markUnlocked(ev.id, { source: 'relay-fastpath-a', address: addr });
|
||||
const addressReceipts = addr ? (receiptsByAddress.get(addr) || []) : [];
|
||||
const eventReceipts = receiptsByEventId.get(ev.id) || [];
|
||||
if (addressReceipts.length) {
|
||||
await restorePremiumUnlockFromReceipts(ev, addressReceipts, 'relay-fastpath-a');
|
||||
continue;
|
||||
}
|
||||
if (seenEventIds.has(ev.id)) {
|
||||
markUnlocked(ev.id, { source: 'relay-fastpath-e' });
|
||||
if (eventReceipts.length) {
|
||||
await restorePremiumUnlockFromReceipts(ev, eventReceipts, 'relay-fastpath-e');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -5898,8 +5931,7 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (statusToast?.dismiss) statusToast.dismiss();
|
||||
markUnlocked(eventId, {
|
||||
const unlockRecord = {
|
||||
creatorSats: result.creatorSats,
|
||||
platformSats: result.platformSats,
|
||||
creatorPreimage: result.creatorPreimage,
|
||||
@@ -5907,10 +5939,12 @@
|
||||
address: row.premium?.address || '',
|
||||
payload: decryptedPayload || undefined,
|
||||
previewJpegDataUrl: row.premium?.previewJpegDataUrl || undefined,
|
||||
});
|
||||
};
|
||||
// Persist to the Nostr-synced vault in the background so the unlock
|
||||
// survives localStorage.clear() and shows up on other devices.
|
||||
let vaultSaved = false;
|
||||
if (row.premium && decryptedPayload) {
|
||||
onStatus('Saving encrypted purchase vault…');
|
||||
const vaultItem = _buildVaultItemForUnlock({
|
||||
row,
|
||||
totalSats: min,
|
||||
@@ -5920,9 +5954,25 @@
|
||||
platformPreimage: result.platformPreimage,
|
||||
payload: decryptedPayload,
|
||||
});
|
||||
upsertPurchaseVaultItem(vaultItem).catch((err) => console.warn('[vault] upsert failed', err));
|
||||
try {
|
||||
await upsertPurchaseVaultItem(vaultItem);
|
||||
vaultSaved = true;
|
||||
_clearPendingVaultItem(vaultItem.a);
|
||||
} catch (err) {
|
||||
console.warn('[vault] upsert failed', err);
|
||||
_rememberPendingVaultItem(vaultItem, err);
|
||||
}
|
||||
}
|
||||
if (statusToast?.dismiss) statusToast.dismiss();
|
||||
markUnlocked(eventId, { ...unlockRecord, vaultSaved, vaultPending: row.premium && decryptedPayload && !vaultSaved });
|
||||
if (row.premium && decryptedPayload && !vaultSaved) {
|
||||
toastError(
|
||||
`Unlocked "${row.title || 'template'}" on this device, but the encrypted purchase vault did not sync. Do not clear browser data until your next sign-in or Purchased refresh retries it.`,
|
||||
{ duration: 12000 }
|
||||
);
|
||||
} else {
|
||||
toastSuccess(`Unlocked "${row.title || 'template'}" ✓${vaultSaved ? ' Saved to purchase vault.' : ''}`);
|
||||
}
|
||||
toastSuccess(`Unlocked "${row.title || 'template'}" ✓`);
|
||||
// Re-render the feed in place — the card will now show Import.
|
||||
if (typeof _currentFeedRerender === 'function') {
|
||||
try { _currentFeedRerender(); } catch {}
|
||||
@@ -5970,6 +6020,7 @@
|
||||
// writes lose to the latest one but no item is removed (merge keeps the
|
||||
// union).
|
||||
const VAULT_D_TAG = 'artstr:purchase-vault:v1';
|
||||
const VAULT_PENDING_KEY = 'casewrap-pending-purchase-vault-items';
|
||||
const NWC_SYNC_D_TAG = 'artstr:nwc-connection:v1';
|
||||
const NWC_SYNC_PREF_KEY = 'casewrap-nwc-sync-pref'; // 'on' | 'off'
|
||||
let _vaultLoadPromise = null;
|
||||
@@ -5977,6 +6028,16 @@
|
||||
|
||||
function _supportsVault() { return !!nostrEncryptCapability(); }
|
||||
|
||||
function _purchaseVaultRelays() {
|
||||
// Vault records are account-critical. Always use the user's current
|
||||
// relays plus the built-in public defaults so a cache wipe or relay-list
|
||||
// edit is less likely to orphan purchases.
|
||||
return [...new Set([
|
||||
...splitRelayList(state.community?.relays || ''),
|
||||
...DEFAULT_NOSTR_RELAYS,
|
||||
].map(normalizeRelayHealthRelay).filter(Boolean))];
|
||||
}
|
||||
|
||||
async function _selfEncrypt(plaintext) {
|
||||
const method = nostrEncryptCapability();
|
||||
if (!method) throw new Error('Your Nostr extension does not support encryption (NIP-44 or NIP-04).');
|
||||
@@ -6025,6 +6086,39 @@
|
||||
};
|
||||
}
|
||||
|
||||
function _pendingVaultItems() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(VAULT_PENDING_KEY) || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function _writePendingVaultItems(items) {
|
||||
try {
|
||||
const clean = (items || []).filter((item) => item?.a && item?.payload);
|
||||
if (clean.length) localStorage.setItem(VAULT_PENDING_KEY, JSON.stringify(clean));
|
||||
else localStorage.removeItem(VAULT_PENDING_KEY);
|
||||
} catch (err) {
|
||||
console.warn('[vault] pending write failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
function _rememberPendingVaultItem(item, err = null) {
|
||||
if (!item?.a || !item?.payload) return;
|
||||
const pending = _pendingVaultItems().filter((it) => it?.a !== item.a);
|
||||
pending.unshift({
|
||||
...item,
|
||||
pendingSince: Date.now(),
|
||||
lastError: err?.message || String(err || ''),
|
||||
});
|
||||
_writePendingVaultItems(pending.slice(0, 50));
|
||||
}
|
||||
|
||||
function _clearPendingVaultItem(addressCoord) {
|
||||
if (!addressCoord) return;
|
||||
_writePendingVaultItems(_pendingVaultItems().filter((item) => item?.a !== addressCoord));
|
||||
}
|
||||
|
||||
async function loadPurchaseVault({ force = false } = {}) {
|
||||
if (!force && _vaultCache) return _vaultCache;
|
||||
if (_vaultLoadPromise) return _vaultLoadPromise;
|
||||
@@ -6035,7 +6129,7 @@
|
||||
}
|
||||
_vaultLoadPromise = (async () => {
|
||||
try {
|
||||
const relays = splitRelayList(state.community?.relays || '');
|
||||
const relays = _purchaseVaultRelays();
|
||||
const events = await queryRelays(relays, {
|
||||
kinds: [30078], authors: [myPubkey], '#d': [VAULT_D_TAG], limit: 5,
|
||||
}, { timeoutMs: 4500 });
|
||||
@@ -6044,10 +6138,17 @@
|
||||
if (!latest) { _vaultCache = { v: 1, updatedAt: 0, items: [] }; return _vaultCache; }
|
||||
let plaintext;
|
||||
try { plaintext = await _selfDecrypt(latest.content); }
|
||||
catch (err) { console.warn('[vault] decrypt failed', err); _vaultCache = { v: 1, updatedAt: 0, items: [] }; return _vaultCache; }
|
||||
catch (err) {
|
||||
console.warn('[vault] decrypt failed', err);
|
||||
_vaultCache = null;
|
||||
throw new Error('Found a purchase vault on relays, but your Nostr extension could not decrypt it.');
|
||||
}
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(plaintext); } catch { parsed = null; }
|
||||
if (!parsed?.items) { _vaultCache = { v: 1, updatedAt: 0, items: [] }; return _vaultCache; }
|
||||
if (!parsed?.items) {
|
||||
_vaultCache = null;
|
||||
throw new Error('Found a purchase vault on relays, but its decrypted contents were not valid vault JSON.');
|
||||
}
|
||||
// Split mode: index carries stubs only; fetch per-item events and
|
||||
// merge their payloads onto the stubs. Stubs without a matching
|
||||
// per-item event still appear in the list (titles + amounts) but
|
||||
@@ -6132,7 +6233,15 @@
|
||||
],
|
||||
content,
|
||||
};
|
||||
return { signed: await publishCommunityEvent(evt), encryptedSize: content.length };
|
||||
const signed = await signNostrEvent(evt);
|
||||
const relays = _purchaseVaultRelays();
|
||||
const publishResult = await publishToRelays(signed, relays);
|
||||
const acceptedRelays = (publishResult.accepted || []).map((r) => r.relay).filter(Boolean);
|
||||
const verification = await verifyEventOnRelays(signed, acceptedRelays.length ? acceptedRelays : relays, 2);
|
||||
if (!verification.verified) {
|
||||
throw new Error(`Purchase vault event was accepted but could not be read back from relays (${dTag}).`);
|
||||
}
|
||||
return { signed, encryptedSize: content.length, publishResult, verification };
|
||||
}
|
||||
|
||||
// Publish the full vault. Tries single-event first; if encrypted size
|
||||
@@ -6152,6 +6261,7 @@
|
||||
// Split: publish each item as its own event, then publish the index
|
||||
// with stubs only.
|
||||
console.info(`[vault] single-vault size ${probe.length} > threshold ${VAULT_SIZE_THRESHOLD}; publishing per-item events.`);
|
||||
const itemFailures = [];
|
||||
for (const item of (vault.items || [])) {
|
||||
if (!item?.a) continue;
|
||||
try {
|
||||
@@ -6162,8 +6272,12 @@
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[vault] per-item publish failed for', item.a, err);
|
||||
itemFailures.push({ item, err });
|
||||
}
|
||||
}
|
||||
if (itemFailures.length) {
|
||||
throw new Error(`Could not publish ${itemFailures.length} purchase-vault item${itemFailures.length === 1 ? '' : 's'}; vault index was not updated.`);
|
||||
}
|
||||
const indexPlain = JSON.stringify({
|
||||
v: 1,
|
||||
updatedAt: vault.updatedAt || Date.now(),
|
||||
@@ -6176,14 +6290,37 @@
|
||||
}
|
||||
|
||||
async function upsertPurchaseVaultItem(item) {
|
||||
if (!_supportsVault()) return null;
|
||||
if (!state.community?.myPubkey) return null;
|
||||
if (!_supportsVault()) throw new Error('Your Nostr extension does not support encrypted purchase vaults.');
|
||||
if (!state.community?.myPubkey) throw new Error('Sign in with Nostr to save the purchase vault.');
|
||||
// Always refresh first to avoid stomping a concurrent device update.
|
||||
const current = await loadPurchaseVault({ force: true });
|
||||
const mergedItems = _vaultMerge(current.items || [], [item]);
|
||||
const next = { v: 1, updatedAt: Date.now(), items: mergedItems };
|
||||
try { return await _publishVault(next); }
|
||||
catch (err) { console.warn('[vault] publish failed', err); return null; }
|
||||
return await _publishVault(next);
|
||||
}
|
||||
|
||||
async function retryPendingVaultWrites({ silent = false } = {}) {
|
||||
const pending = _pendingVaultItems();
|
||||
if (!pending.length || !_supportsVault() || !state.community?.myPubkey) {
|
||||
return { attempted: 0, saved: 0, failed: pending.length };
|
||||
}
|
||||
let saved = 0;
|
||||
const failed = [];
|
||||
for (const item of pending) {
|
||||
try {
|
||||
await upsertPurchaseVaultItem(item);
|
||||
saved++;
|
||||
} catch (err) {
|
||||
console.warn('[vault] pending retry failed', err);
|
||||
failed.push({ ...item, lastError: err?.message || String(err || ''), lastRetryAt: Date.now() });
|
||||
}
|
||||
}
|
||||
_writePendingVaultItems(failed);
|
||||
if (!silent) {
|
||||
if (saved) toastSuccess(`Saved ${saved} pending purchase${saved === 1 ? '' : 's'} to your vault ✓`);
|
||||
if (failed.length) toastError(`${failed.length} purchase${failed.length === 1 ? '' : 's'} still could not sync to your vault.`, { duration: 9000 });
|
||||
}
|
||||
return { attempted: pending.length, saved, failed: failed.length };
|
||||
}
|
||||
|
||||
function _buildVaultItemForUnlock({ row, totalSats, creatorSats, platformSats, creatorPreimage, platformPreimage, payload }) {
|
||||
@@ -6207,6 +6344,71 @@
|
||||
};
|
||||
}
|
||||
|
||||
async function restorePremiumUnlockFromReceipts(event, receipts = [], source = 'relay-fastpath') {
|
||||
const address = premiumAddressCoord(event);
|
||||
if (!isPremiumEvent(event)) {
|
||||
markUnlocked(event.id, { source, address });
|
||||
return false;
|
||||
}
|
||||
const envelope = getPremiumEnvelope(event);
|
||||
if (!envelope) {
|
||||
markUnlocked(event.id, { source, address });
|
||||
return false;
|
||||
}
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await Premium.decryptPayload(envelope, {
|
||||
creatorPubkey: event.pubkey,
|
||||
dTag: premiumEventDTag(event),
|
||||
schemaVersion: envelope.payloadSchemaVersion || 5,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[vault] receipt-based premium decrypt failed', err);
|
||||
markUnlocked(event.id, { source, address, decryptError: err?.message || String(err || '') });
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = premiumEventTagValue(event, 'title') || payload?.meta?.title || payload?.title || '';
|
||||
const mode = normalizeTemplateMode(payload);
|
||||
const amountSats = getZapMin(event) || 0;
|
||||
const vaultItem = {
|
||||
a: address,
|
||||
eventId: event.id,
|
||||
creatorPubkey: event.pubkey || '',
|
||||
title,
|
||||
mode,
|
||||
unlockedAt: Date.now(),
|
||||
amountSats,
|
||||
previewJpegDataUrl: envelope?.preview?.dataUrl || '',
|
||||
payload,
|
||||
payment: {
|
||||
mode: 'relay-receipt-recovery-v1',
|
||||
receipts: (receipts || []).map((receipt) => ({
|
||||
receiptId: receipt.id || '',
|
||||
created_at: receipt.created_at || 0,
|
||||
amountSats,
|
||||
})),
|
||||
},
|
||||
};
|
||||
markUnlocked(event.id, {
|
||||
source,
|
||||
address,
|
||||
payload,
|
||||
previewJpegDataUrl: vaultItem.previewJpegDataUrl,
|
||||
amountSats,
|
||||
unlockedAt: vaultItem.unlockedAt,
|
||||
});
|
||||
try {
|
||||
await upsertPurchaseVaultItem(vaultItem);
|
||||
_clearPendingVaultItem(address);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[vault] receipt recovery vault save failed', err);
|
||||
_rememberPendingVaultItem(vaultItem, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate localStorage UNLOCK_CACHE_KEY from the vault so feed cards
|
||||
// come up as "unlocked" without a separate vault-vs-cache check.
|
||||
async function hydrateUnlockedFromVault() {
|
||||
@@ -6214,8 +6416,10 @@
|
||||
const vault = await loadPurchaseVault();
|
||||
const items = vault?.items || [];
|
||||
let restored = 0;
|
||||
let unavailable = 0;
|
||||
for (const it of items) {
|
||||
if (!it?.eventId) continue;
|
||||
if (!it?.payload) { unavailable++; continue; }
|
||||
if (isUnlockedLocally(it.eventId, it.a)) continue;
|
||||
// Store under the LATEST event id from the vault, but include
|
||||
// the address so a future edit-in-place still picks this up.
|
||||
@@ -6229,12 +6433,15 @@
|
||||
});
|
||||
restored++;
|
||||
}
|
||||
if (unavailable > 0 && typeof toastError === 'function') {
|
||||
toastError(`${unavailable} purchase vault entr${unavailable === 1 ? 'y is' : 'ies are'} missing decrypted design data. Try Refresh in Purchased, or re-open the original premium card.`, { duration: 9000 });
|
||||
}
|
||||
if (restored > 0 && typeof toastSuccess === 'function') {
|
||||
toastSuccess(restored === 1
|
||||
? 'Restored 1 premium unlock from your vault ✓'
|
||||
: `Restored ${restored} premium unlocks from your vault ✓`);
|
||||
}
|
||||
return { restored, total: items.length };
|
||||
return { restored, unavailable, total: items.length };
|
||||
} catch (err) {
|
||||
console.warn('[vault] hydrate failed', err);
|
||||
if (typeof toastError === 'function') toastError('Couldn’t restore premium unlocks from vault: ' + (err?.message || err));
|
||||
@@ -6437,6 +6644,7 @@
|
||||
wall.innerHTML = '<div class="hint" style="grid-column:1/-1">Loading purchased designs…</div>';
|
||||
}
|
||||
try {
|
||||
await retryPendingVaultWrites({ silent: true });
|
||||
const vault = await loadPurchaseVault({ force: opts.force !== false });
|
||||
_renderVaultItemsIntoWall(wall, vault?.items || [], false);
|
||||
} catch (err) {
|
||||
@@ -15215,7 +15423,7 @@
|
||||
persistAutosave();
|
||||
// Hydrate localStorage premium unlocks from the Nostr-synced
|
||||
// purchase vault — runs in the background so login isn't blocked.
|
||||
hydrateUnlockedFromVault().then(() => {
|
||||
retryPendingVaultWrites({ silent: false }).then(() => hydrateUnlockedFromVault()).then(() => {
|
||||
if (typeof _currentFeedRerender === 'function') {
|
||||
try { _currentFeedRerender(); } catch {}
|
||||
}
|
||||
@@ -23790,6 +23998,145 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PPTX import (Deck Builder) -------------------------------------
|
||||
//
|
||||
// Canonical spec: docs/PPTX_IMPORT_FEATURE.md. Zip parsing, XML
|
||||
// walking, and PowerPoint-to-Artstr conversion all live in
|
||||
// src/pptx-importer.js. That module + its fflate dependency are
|
||||
// lazy-loaded on the first Import PPTX click so the main bundle
|
||||
// stays lean for users who never use this path.
|
||||
const _loadedScripts = new Map();
|
||||
function loadScriptOnce(src) {
|
||||
if (_loadedScripts.has(src)) return _loadedScripts.get(src);
|
||||
const p = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
_loadedScripts.set(src, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function ensurePptxImporterLoaded() {
|
||||
if (window.ArtstrPptxImporter && window.fflate) return;
|
||||
const btn = $('importPptxBtn');
|
||||
const originalLabel = btn?.textContent;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Loading…'; }
|
||||
try {
|
||||
await loadScriptOnce('vendor/fflate.min.js');
|
||||
await loadScriptOnce('pptx-importer.js');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = originalLabel || 'Import PowerPoint (.pptx)'; }
|
||||
}
|
||||
if (!window.fflate) throw new Error('fflate did not load');
|
||||
if (!window.ArtstrPptxImporter) throw new Error('pptx-importer did not load');
|
||||
}
|
||||
|
||||
async function onImportPptxClick() {
|
||||
try {
|
||||
await ensurePptxImporterLoaded();
|
||||
} catch (e) {
|
||||
toastError(`Could not load PPTX importer: ${e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
const input = $('pptxFileInput');
|
||||
if (!input) return;
|
||||
input.value = '';
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function onPptxFileChosen(file) {
|
||||
if (!file) return;
|
||||
if (!/\.pptx$/i.test(file.name)) {
|
||||
toastError('Please choose a .pptx file.');
|
||||
return;
|
||||
}
|
||||
const report = window.ArtstrPptxImporter.makePptxImportReport(file.name);
|
||||
try {
|
||||
const payload = await window.ArtstrPptxImporter.importPptxFile(file, report);
|
||||
loadProjectFromText(JSON.stringify(payload));
|
||||
openPptxImportReportModal(report);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toastError(`Could not import PPTX: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openPptxImportReportModal(report) {
|
||||
const modal = $('pptxImportReportModal');
|
||||
if (!modal) return;
|
||||
const fileEl = $('pptxImportReportFile');
|
||||
const bodyEl = $('pptxImportReportBody');
|
||||
const placeholderNote = $('pptxImportReportPlaceholderNote');
|
||||
const warningsWrap = $('pptxImportReportWarningsWrap');
|
||||
const warningsList = $('pptxImportReportWarnings');
|
||||
const imp = report.imported || {};
|
||||
const ph = report.placeholders || {};
|
||||
|
||||
if (fileEl) fileEl.textContent = `Imported: ${report.fileName || '(unknown file)'}`;
|
||||
|
||||
const lines = [`Slides: ${report.slideCount || 0}`];
|
||||
if (imp.text) lines.push(`Text boxes: ${imp.text}`);
|
||||
if (imp.images) lines.push(`Image placeholders: ${imp.images}`);
|
||||
if (imp.shapes) lines.push(`Shapes: ${imp.shapes}`);
|
||||
if (imp.notes) lines.push(`Speaker notes: ${imp.notes}`);
|
||||
const placeholderTotal =
|
||||
(ph.charts || 0) + (ph.smartArt || 0) + (ph.tables || 0) + (ph.media || 0) + (ph.unknown || 0);
|
||||
if (placeholderTotal) {
|
||||
const parts = [];
|
||||
if (ph.charts) parts.push(`${ph.charts} chart${ph.charts === 1 ? '' : 's'}`);
|
||||
if (ph.smartArt) parts.push(`${ph.smartArt} SmartArt object${ph.smartArt === 1 ? '' : 's'}`);
|
||||
if (ph.tables) parts.push(`${ph.tables} table${ph.tables === 1 ? '' : 's'}`);
|
||||
if (ph.media) parts.push(`${ph.media} media item${ph.media === 1 ? '' : 's'}`);
|
||||
if (ph.unknown) parts.push(`${ph.unknown} unknown object${ph.unknown === 1 ? '' : 's'}`);
|
||||
lines.push(`Skipped (placeholders shown): ${parts.join(', ')}`);
|
||||
}
|
||||
if (bodyEl) {
|
||||
bodyEl.innerHTML = '';
|
||||
for (const line of lines) {
|
||||
const row = document.createElement('div');
|
||||
row.textContent = line;
|
||||
row.style.fontSize = '.85rem';
|
||||
row.style.lineHeight = '1.5';
|
||||
bodyEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholderNote) {
|
||||
const hasPlaceholders = (imp.images || 0) > 0 || placeholderTotal > 0;
|
||||
placeholderNote.style.display = hasPlaceholders ? '' : 'none';
|
||||
}
|
||||
|
||||
const warns = Array.isArray(report.warnings) ? report.warnings : [];
|
||||
if (warningsWrap && warningsList) {
|
||||
if (warns.length) {
|
||||
warningsList.innerHTML = '';
|
||||
for (const w of warns) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = w.message || w.code || '(unknown warning)';
|
||||
warningsList.appendChild(li);
|
||||
}
|
||||
warningsWrap.style.display = '';
|
||||
} else {
|
||||
warningsWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function closePptxImportReportModal() {
|
||||
const modal = $('pptxImportReportModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function attachEvents() {
|
||||
// Layout tabs: cover/disc live on the Template tab; customart/discdesign
|
||||
// on the Designer tab. applyLayout() sorts out the appMode crossing.
|
||||
@@ -23830,6 +24177,19 @@
|
||||
$('deckSorterAddBtn')?.addEventListener('click', deckAddBlankSlide);
|
||||
$('deckImportSlideBtn')?.addEventListener('click', openDeckSlideImportBrowser);
|
||||
$('deckImportFileBtn')?.addEventListener('click', deckImportSlideFromFile);
|
||||
$('importPptxBtn')?.addEventListener('click', onImportPptxClick);
|
||||
$('pptxFileInput')?.addEventListener('change', (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (f) onPptxFileChosen(f);
|
||||
});
|
||||
$('pptxImportReportCloseBtn')?.addEventListener('click', closePptxImportReportModal);
|
||||
const _pptxReportModal = $('pptxImportReportModal');
|
||||
if (_pptxReportModal) {
|
||||
_pptxReportModal.addEventListener('click', (e) => {
|
||||
if (e.target === _pptxReportModal) closePptxImportReportModal();
|
||||
});
|
||||
}
|
||||
$('deckThemeBgEnabled')?.addEventListener('change', (e) => {
|
||||
ensureDeck();
|
||||
pushHistory('toggle deck background');
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Artstr Studio — PPTX importer
|
||||
*
|
||||
* Canonical spec: docs/PPTX_IMPORT_FEATURE.md
|
||||
*
|
||||
* This module is lazy-loaded on first click of the Import PPTX
|
||||
* button. It depends on src/vendor/fflate.min.js being loaded
|
||||
* first (window.fflate).
|
||||
*
|
||||
* Phase 0: module shell + report data model only. importPptxFile
|
||||
* throws "not implemented yet" so the lazy-load + UI plumbing can
|
||||
* be verified end-to-end before Phase 1 lands the deck-shell
|
||||
* parser.
|
||||
*/
|
||||
|
||||
window.ArtstrPptxImporter = (function () {
|
||||
'use strict';
|
||||
|
||||
const PPTX_IMAGE_PLACEHOLDER_URL =
|
||||
'https://i.postimg.cc/9fY3n60q/example-image-replace-me.png';
|
||||
|
||||
function makePptxImportReport(fileName) {
|
||||
return {
|
||||
fileName: fileName || '',
|
||||
slideCount: 0,
|
||||
imported: {
|
||||
text: 0,
|
||||
images: 0,
|
||||
shapes: 0,
|
||||
groups: 0,
|
||||
backgrounds: 0,
|
||||
notes: 0,
|
||||
},
|
||||
placeholders: {
|
||||
charts: 0,
|
||||
smartArt: 0,
|
||||
tables: 0,
|
||||
media: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function importPptxFile(_file, _report) {
|
||||
throw new Error(
|
||||
'PPTX import is not implemented yet — Phase 1 lands the deck-shell parser.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
PPTX_IMAGE_PLACEHOLDER_URL,
|
||||
makePptxImportReport,
|
||||
importPptxFile,
|
||||
};
|
||||
})();
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(f){typeof module!='undefined'&&typeof exports=='object'?module.exports=f():typeof define!='undefined'&&define.amd?define(f):(typeof self!='undefined'?self:this).fflate=f()}(function(){var _e={};"use strict";var t=(typeof module!='undefined'&&typeof exports=='object'?function(_f){"use strict";var e,t=";var __w=require('worker_threads');__w.parentPort.on('message',function(m){onmessage({data:m})}),postMessage=function(m,t){__w.parentPort.postMessage(m,t)},close=process.exit;self=global";try{e=require("worker_threads").Worker}catch(e){}exports.default=e?function(r,n,o,a,s){var u=!1,i=new e(r+t,{eval:!0}).on("error",(function(e){return s(e,null)})).on("message",(function(e){return s(null,e)})).on("exit",(function(e){e&&!u&&s(Error("exited with code "+e),null)}));return i.postMessage(o,a),i.terminate=function(){return u=!0,e.prototype.terminate.call(i)},i}:function(e,t,r,n,o){setImmediate((function(){return o(Error("async operations unsupported - update to Node 12+ (or Node 10-11 with the --experimental-worker CLI flag)"),null)}));var a=function(){};return{terminate:a,postMessage:a}};return _f}:function(_f){"use strict";var e={};_f.default=function(r,t,s,a,n){var o=new Worker(e[t]||(e[t]=URL.createObjectURL(new Blob([r+';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})'],{type:"text/javascript"}))));return o.onmessage=function(e){var r=e.data,t=r.$e$;if(t){var s=Error(t[0]);s.code=t[1],s.stack=t[2],n(s,null)}else n(null,r)},o.postMessage(s,a),o};return _f})({}),n=Uint8Array,r=Uint16Array,e=Int32Array,i=new n([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),o=new n([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),s=new n([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),a=function(t,n){for(var i=new r(31),o=0;o<31;++o)i[o]=n+=1<<t[o-1];var s=new e(i[30]);for(o=1;o<30;++o)for(var a=i[o];a<i[o+1];++a)s[a]=a-i[o]<<5|o;return{b:i,r:s}},u=a(i,2),h=u.b,f=u.r;h[28]=258,f[258]=28;for(var l=a(o,0),c=l.b,p=l.r,v=new r(32768),d=0;d<32768;++d){var g=(43690&d)>>1|(21845&d)<<1;v[d]=((65280&(g=(61680&(g=(52428&g)>>2|(13107&g)<<2))>>4|(3855&g)<<4))>>8|(255&g)<<8)>>1}var y=function(t,n,e){for(var i=t.length,o=0,s=new r(n);o<i;++o)t[o]&&++s[t[o]-1];var a,u=new r(n);for(o=1;o<n;++o)u[o]=u[o-1]+s[o-1]<<1;if(e){a=new r(1<<n);var h=15-n;for(o=0;o<i;++o)if(t[o])for(var f=o<<4|t[o],l=n-t[o],c=u[t[o]-1]++<<l,p=c|(1<<l)-1;c<=p;++c)a[v[c]>>h]=f}else for(a=new r(i),o=0;o<i;++o)t[o]&&(a[o]=v[u[t[o]-1]++]>>15-t[o]);return a},m=new n(288);for(d=0;d<144;++d)m[d]=8;for(d=144;d<256;++d)m[d]=9;for(d=256;d<280;++d)m[d]=7;for(d=280;d<288;++d)m[d]=8;var b=new n(32);for(d=0;d<32;++d)b[d]=5;var w=y(m,9,0),x=y(m,9,1),z=y(b,5,0),k=y(b,5,1),M=function(t){for(var n=t[0],r=1;r<t.length;++r)t[r]>n&&(n=t[r]);return n},S=function(t,n,r){var e=n/8|0;return(t[e]|t[e+1]<<8)>>(7&n)&r},A=function(t,n){var r=n/8|0;return(t[r]|t[r+1]<<8|t[r+2]<<16)>>(7&n)},T=function(t){return(t+7)/8|0},D=function(t,r,e){return(null==r||r<0)&&(r=0),(null==e||e>t.length)&&(e=t.length),new n(t.subarray(r,e))};_e.FlateErrorCode={UnexpectedEOF:0,InvalidBlockType:1,InvalidLengthLiteral:2,InvalidDistance:3,StreamFinished:4,NoStreamHandler:5,InvalidHeader:6,NoCallback:7,InvalidUTF8:8,ExtraFieldTooLong:9,InvalidDate:10,FilenameTooLong:11,StreamFinishing:12,InvalidZipData:13,UnknownCompressionMethod:14};var C=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],I=function(t,n,r){var e=Error(n||C[t]);if(e.code=t,Error.captureStackTrace&&Error.captureStackTrace(e,I),!r)throw e;return e},U=function(t,r,e,a){var u=t.length,f=a?a.length:0;if(!u||r.f&&!r.l)return e||new n(0);var l=!e,p=l||2!=r.i,v=r.i;l&&(e=new n(3*u));var d=function(t){var r=e.length;if(t>r){var i=new n(Math.max(2*r,t));i.set(e),e=i}},g=r.f||0,m=r.p||0,b=r.b||0,w=r.l,z=r.d,C=r.m,U=r.n,F=8*u;do{if(!w){g=S(t,m,1);var E=S(t,m+1,3);if(m+=3,!E){var Z=t[(J=T(m)+4)-4]|t[J-3]<<8,q=J+Z;if(q>u){v&&I(0);break}p&&d(b+Z),e.set(t.subarray(J,q),b),r.b=b+=Z,r.p=m=8*q,r.f=g;continue}if(1==E)w=x,z=k,C=9,U=5;else if(2==E){var O=S(t,m,31)+257,G=S(t,m+10,15)+4,L=O+S(t,m+5,31)+1;m+=14;for(var H=new n(L),j=new n(19),N=0;N<G;++N)j[s[N]]=S(t,m+3*N,7);m+=3*G;var P=M(j),B=(1<<P)-1,Y=y(j,P,1);for(N=0;N<L;){var J,K=Y[S(t,m,B)];if(m+=15&K,(J=K>>4)<16)H[N++]=J;else{var Q=0,R=0;for(16==J?(R=3+S(t,m,3),m+=2,Q=H[N-1]):17==J?(R=3+S(t,m,7),m+=3):18==J&&(R=11+S(t,m,127),m+=7);R--;)H[N++]=Q}}var V=H.subarray(0,O),W=H.subarray(O);C=M(V),U=M(W),w=y(V,C,1),z=y(W,U,1)}else I(1);if(m>F){v&&I(0);break}}p&&d(b+131072);for(var X=(1<<C)-1,$=(1<<U)-1,_=m;;_=m){var tt=(Q=w[A(t,m)&X])>>4;if((m+=15&Q)>F){v&&I(0);break}if(Q||I(2),tt<256)e[b++]=tt;else{if(256==tt){_=m,w=null;break}var nt=tt-254;tt>264&&(nt=S(t,m,(1<<(it=i[N=tt-257]))-1)+h[N],m+=it);var rt=z[A(t,m)&$],et=rLine truncated
|
||||
Reference in new issue
Block a user