From 67f1501b4ea28a6d2cad0735d0353713259188b4 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 1 Oct 2026 11:49:27 +0700 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20GET=20/content/export=20=E2=80=94?= =?UTF-8?q?=20download=20zip=20content+assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/content_editor.py | 52 +++++++++++++++++++- services/tests/test_content_editor_routes.py | 36 ++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/routes/content_editor.py b/routes/content_editor.py index a31af23..bddff71 100644 --- a/routes/content_editor.py +++ b/routes/content_editor.py @@ -25,8 +25,9 @@ import io import os import re as _re import shutil +import zipfile -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, send_file from config import ASSETS_DIR, CONTENT_DIR from services import repositories @@ -522,3 +523,52 @@ def upload_asset(): return jsonify({"success": True, "path": rel_path, "url": f"/assets/{rel_path}"}) finally: db.close() + + +# ── Bundle export (import dilakukan client-side per-file) ──────── + +def _add_dir_to_zip(zf: zipfile.ZipFile, dir_abs: str, prefix: str) -> int: + """Tambahkan semua file di dir_abs ke zip dengan prefix folder. + Ditulis per-file (zf.write) — tidak ada batasan ukuran total.""" + count = 0 + for base, _dirs, files in os.walk(dir_abs): + for fname in sorted(files): + abs_path = os.path.join(base, fname) + rel_inside = os.path.relpath(abs_path, dir_abs) + zf.write(abs_path, arcname=f"{prefix}/{rel_inside}") + count += 1 + return count + + +@content_editor_bp.route("/export", methods=["GET"]) +def export_bundle(): + db = SessionLocal() + try: + user, err = _require_teacher(db) + if err: + return err + scope = request.args.get("scope", "all") + if scope not in ("all", "content", "assets"): + return jsonify({"success": False, "message": "scope tidak valid (all|content|assets)"}), 400 + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + file_count = 0 + if scope in ("all", "content"): + file_count += _add_dir_to_zip(zf, _CONTENT_DIR_ABS, "content") + if scope in ("all", "assets"): + file_count += _add_dir_to_zip(zf, _ASSETS_DIR_ABS, "assets") + if file_count == 0: + return jsonify({"success": False, "message": "Tidak ada file untuk diekspor"}), 404 + + from datetime import datetime + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + buf.seek(0) + return send_file( + buf, + mimetype="application/zip", + as_attachment=True, + download_name=f"elemes-bundle-{scope}-{stamp}.zip", + ) + finally: + db.close() diff --git a/services/tests/test_content_editor_routes.py b/services/tests/test_content_editor_routes.py index 5ae10c3..89fec25 100644 --- a/services/tests/test_content_editor_routes.py +++ b/services/tests/test_content_editor_routes.py @@ -10,10 +10,12 @@ Mengunci kontrak: - regression: _render_markdown_string() == render_markdown_content() untuk file yang sama; """ +import io import os import pathlib import shutil import time +import zipfile import pytest @@ -634,3 +636,37 @@ def test_save_draft_stores_correct_author_id(client): assert draft.author_id == teacher.id finally: db.close() + + +# ── Export bundle ────────────────────────────────────────────────── + +def test_export_requires_teacher(client): + _seed_and_setup() + resp = client.get("/content/export") + assert resp.status_code == 401 + + +def test_export_returns_zip_with_content_and_assets(client): + _seed_and_setup() + _login_teacher(client) + (TEST_ASSETS / "diag.png").write_bytes(b"fake-png") + resp = client.get("/content/export") + assert resp.status_code == 200 + assert "application/zip" in resp.headers["Content-Type"] + assert resp.headers["Content-Disposition"].startswith("attachment; filename=") + zf = zipfile.ZipFile(io.BytesIO(resp.data)) + names = set(zf.namelist()) + assert "content/test_editor/hello.md" in names + assert "content/home.md" in names + assert "assets/diag.png" in names + + +def test_export_scope_content_excludes_assets(client): + _seed_and_setup() + _login_teacher(client) + (TEST_ASSETS / "diag.png").write_bytes(b"fake-png") + resp = client.get("/content/export?scope=content") + assert resp.status_code == 200 + names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist() + assert any(n.startswith("content/") for n in names) + assert not any(n.startswith("assets/") for n in names) From f1f0b57189805328c5ff4b4f7236fb5d2ff95889 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 1 Oct 2026 12:06:24 +0700 Subject: [PATCH 2/6] chore: tambah fflate untuk unzip client-side --- frontend/package-lock.json | 7 +++++++ frontend/package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1cbc542..452028c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@micropython/micropython-webassembly-pyscript": "^1.29.0-6", "codemirror": "^6.0.1", "dexie": "^4.4.6", + "fflate": "^0.8.3", "highlight.js": "^11.11.0", "isomorphic-dompurify": "^3.23.0", "katex": "^0.16.9", @@ -2104,6 +2105,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2ccc0b2..8e02b3a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@micropython/micropython-webassembly-pyscript": "^1.29.0-6", "codemirror": "^6.0.1", "dexie": "^4.4.6", + "fflate": "^0.8.3", "highlight.js": "^11.11.0", "isomorphic-dompurify": "^3.23.0", "katex": "^0.16.9", From 796fd478007ff40c543883541f916a1d83a039ab Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 1 Oct 2026 12:08:07 +0700 Subject: [PATCH 3/6] feat: exportBundle + importBundle per-file via endpoint existing --- .../src/lib/services/contentEditor.test.ts | 142 ++++++++++++++++++ frontend/src/lib/services/contentEditor.ts | 108 +++++++++++++ 2 files changed, 250 insertions(+) diff --git a/frontend/src/lib/services/contentEditor.test.ts b/frontend/src/lib/services/contentEditor.test.ts index 890de44..9d09de2 100644 --- a/frontend/src/lib/services/contentEditor.test.ts +++ b/frontend/src/lib/services/contentEditor.test.ts @@ -1,6 +1,26 @@ import { describe, it, expect, vi } from 'vitest'; import { deleteEntry, renameEntry } from './contentEditor'; +// FileReader tidak tersedia di environment 'node' vitest; uploadAsset +// menggunakannya via fileToBase64. Polyfill minimal agar test jalan. +class MockFileReader { + result: string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + readAsDataURL(file: Blob) { + file.arrayBuffer().then((buf) => { + const bytes = new Uint8Array(buf); + let bin = ''; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + this.result = `data:application/octet-stream;base64,${btoa(bin)}`; + this.onload?.(); + }); + } +} +if (typeof globalThis.FileReader === 'undefined') { + (globalThis as unknown as { FileReader: unknown }).FileReader = MockFileReader; +} + describe('deleteEntry', () => { it('mengirim force=true saat dipanggil dengan argumen force', async () => { const fetchMock = vi.fn().mockResolvedValue({ json: async () => ({ success: true }) }); @@ -90,4 +110,126 @@ describe('saveDraft and publishDraft API', () => { expect(call[0]).toBe('/api/content/drafts/draft-1/publish'); expect(call[1].method).toBe('POST'); }); +}); + +describe('exportBundle', () => { + it('memanggil /api/content/export dengan credentials dan mengembalikan blob', async () => { + const { exportBundle } = await import('./contentEditor'); + const blob = new Blob(['zip-bytes'], { type: 'application/zip' }); + global.fetch = vi.fn().mockResolvedValue( + new Response(blob, { status: 200, headers: { 'Content-Type': 'application/zip' } }), + ); + + const result = await exportBundle(); + + expect(global.fetch).toHaveBeenCalledWith('/api/content/export', { credentials: 'include' }); + expect(result.size).toBeGreaterThan(0); + }); + + it('mengirim scope param saat diberikan', async () => { + const { exportBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(new Blob(['x']), { status: 200 }), + ); + + await exportBundle('content'); + + expect(global.fetch).toHaveBeenCalledWith('/api/content/export?scope=content', { credentials: 'include' }); + }); + + it('melempar error pada respons non-ok', async () => { + const { exportBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: false }), { status: 401 }), + ); + + await expect(exportBundle()).rejects.toThrow(); + }); +}); + +describe('importBundle — orkestrasi per-file', () => { + it('mengirim .md baru via tree/file, .md existing via drafts+publish, asset via upload', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('/api/content/tree/folder')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/api/content/tree/file')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/publish')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/api/content/drafts')) { + return Promise.resolve(new Response(JSON.stringify({ success: true, draft_id: 'd-1' }), { status: 200 })); + } + if (url.includes('/api/content/assets/upload')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: false }), { status: 404 })); + }); + + const result = await importBundle({ + contentFiles: [ + { path: 'lesson/new.md', body: '# New\n' }, + { path: 'lesson/existing.md', body: '# Overwrite\n' }, + ], + assetFiles: [{ path: 'img/diag.png', bytes: new Uint8Array([1, 2, 3]) }], + mode: 'overwrite', + existingPaths: new Set(['lesson/existing.md']), + }); + + const calls = (global.fetch as ReturnType).mock.calls as [string, RequestInit?][]; + const bodies = calls.map(([u, i]) => [u, i?.body ? JSON.parse(String(i.body)) : null] as const); + + // folder dibuat dulu sebelum file di dalamnya + const folderIdx = calls.findIndex(([u]) => u.includes('/api/content/tree/folder')); + const newFileIdx = calls.findIndex(([u]) => u.includes('/api/content/tree/file')); + expect(folderIdx).toBeGreaterThanOrEqual(0); + expect(folderIdx).toBeLessThan(newFileIdx); + + // .md baru → tree/file dengan body sebagai template + expect(bodies.some(([u, b]) => u.includes('/api/content/tree/file') && b?.path === 'lesson/new.md' && b?.template === '# New\n')).toBe(true); + // .md existing → drafts lalu publish + expect(bodies.some(([u, b]) => u.includes('/api/content/drafts') && b?.target_path === 'lesson/existing.md' && b?.base_mtime === null)).toBe(true); + expect(calls.some(([u, i]) => u === '/api/content/drafts/d-1/publish' && i?.method === 'POST')).toBe(true); + // asset → upload dengan folder relatif + expect(bodies.some(([u, b]) => u.includes('/api/content/assets/upload') && b?.filename === 'diag.png' && b?.folder === 'img')).toBe(true); + + expect(result.success).toBe(true); + expect(result.imported).toBe(3); + }); + + it('mode skip tidak menyentuh file yang sudah ada (cek client-side via existingPaths)', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + + const result = await importBundle({ + contentFiles: [{ path: 'lesson/existing.md', body: '# x\n' }], + assetFiles: [], + mode: 'skip', + existingPaths: new Set(['lesson/existing.md']), + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + }); + + it('berhenti pada kegagalan pertama dan melaporkan path yang gagal', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: false, message: 'Permission denied' }), { status: 403 }), + ); + + const result = await importBundle({ + contentFiles: [{ path: 'lesson/a.md', body: '# a\n' }], + assetFiles: [], + mode: 'overwrite', + }); + + expect(result.success).toBe(false); + expect(result.failedPath).toBe('lesson/a.md'); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/services/contentEditor.ts b/frontend/src/lib/services/contentEditor.ts index 2a0b64b..bbc77f2 100644 --- a/frontend/src/lib/services/contentEditor.ts +++ b/frontend/src/lib/services/contentEditor.ts @@ -143,3 +143,111 @@ export async function uploadAsset(file: File, folder = '') { }); return res.json(); } + +// ── Bundle import/export (client-side zip, per-file transfer) ───── + +export async function exportBundle(scope: 'all' | 'content' | 'assets' = 'all'): Promise { + const url = scope === 'all' ? '/api/content/export' : `/api/content/export?scope=${scope}`; + const res = await fetch(url, { credentials: 'include' }); + if (!res.ok) { + throw new Error(`Export gagal (${res.status})`); + } + return res.blob(); +} + +export interface ImportFileSpec { + path: string; // relatif root (tanpa prefix content/ | assets/) + body?: string; // untuk .md (teks hasil decode) + bytes?: Uint8Array; // untuk asset biner +} + +export interface ImportOptions { + contentFiles: ImportFileSpec[]; + assetFiles: ImportFileSpec[]; + mode: 'skip' | 'overwrite'; + existingPaths?: Set; // path (relatif root) yang sudah ada di server + onProgress?: (done: number, total: number, currentPath: string) => void; +} + +export interface ImportSummary { + success: boolean; + imported: number; + skipped: number; + failedPath?: string; + message?: string; +} + +/** Pastikan semua folder parent dari relPath ada di server. + * POST /content/tree/folder; 409 "Folder sudah ada" dianggap sukses. */ +async function ensureFolders(root: 'content' | 'assets', relPath: string): Promise { + const parts = relPath.split('/').slice(0, -1); // buang filename + let cur = ''; + for (const part of parts) { + cur = cur ? `${cur}/${part}` : part; + const res = await createFolder(root, cur); + if (!res.success && res.message !== 'Folder sudah ada') { + throw new Error(`Gagal membuat folder ${cur}: ${res.message ?? 'unknown'}`); + } + } +} + +export async function importBundle(options: ImportOptions): Promise { + const { contentFiles, assetFiles, mode, existingPaths, onProgress } = options; + const total = contentFiles.length + assetFiles.length; + let done = 0; + let imported = 0; + let skipped = 0; + + const files: Array<{ root: 'content' | 'assets'; spec: ImportFileSpec }> = [ + ...contentFiles.map((spec) => ({ root: 'content' as const, spec })), + ...assetFiles.map((spec) => ({ root: 'assets' as const, spec })), + ]; + + for (const { root, spec } of files) { + if (mode === 'skip' && existingPaths?.has(spec.path)) { + skipped++; + onProgress?.(++done, total, spec.path); + continue; + } + try { + await ensureFolders(root, spec.path); + if (root === 'content') { + if (existingPaths?.has(spec.path)) { + // file sudah ada → jalur overwrite: draft + publish (base_mtime null = tanpa conflict check) + const draft = await saveDraft(spec.path, spec.body ?? '', null); + if (!draft.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: draft.message }; + } + const pub = await publishDraft(draft.draft_id!); + if (!pub.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: pub.message }; + } + } else { + // file baru → tree/file dengan body sebagai template + const res = await createFile(spec.path, spec.body); + if (!res.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: res.message }; + } + } + } else { + // asset → assets/upload (nama file = basename, folder = parent relatif) + const bytes = spec.bytes ?? new Uint8Array(); + const res = await uploadAsset( + new File([bytes.slice().buffer as ArrayBuffer], spec.path.split('/').pop() ?? 'file'), + spec.path.includes('/') ? spec.path.slice(0, spec.path.lastIndexOf('/')) : '', + ); + if (!res.success) { + return { success: false, imported, skipped, failedPath: spec.path, message: res.message }; + } + } + imported++; + onProgress?.(++done, total, spec.path); + } catch (e) { + return { + success: false, imported, skipped, failedPath: spec.path, + message: e instanceof Error ? e.message : String(e), + }; + } + } + return { success: true, imported, skipped }; +} From 961d2a0ac2580ce193d1c7514f78c436c5cab0f8 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 1 Oct 2026 12:09:12 +0700 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20extractBundle=20=E2=80=94=20unzip?= =?UTF-8?q?=20client-side=20via=20fflate=20dengan=20validasi=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/services/contentEditor.test.ts | 23 ++++++++++ frontend/src/lib/services/contentEditor.ts | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/frontend/src/lib/services/contentEditor.test.ts b/frontend/src/lib/services/contentEditor.test.ts index 9d09de2..9a078e2 100644 --- a/frontend/src/lib/services/contentEditor.test.ts +++ b/frontend/src/lib/services/contentEditor.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { deleteEntry, renameEntry } from './contentEditor'; +import { zipSync, strToU8 } from 'fflate'; // FileReader tidak tersedia di environment 'node' vitest; uploadAsset // menggunakannya via fileToBase64. Polyfill minimal agar test jalan. @@ -232,4 +233,26 @@ describe('importBundle — orkestrasi per-file', () => { expect(result.success).toBe(false); expect(result.failedPath).toBe('lesson/a.md'); }); +}); + +describe('extractBundle', () => { + it('memisahkan content .md dan asset biner, menandai entry tidak valid', async () => { + const { extractBundle } = await import('./contentEditor'); + const zipBytes = zipSync({ + 'content/lesson/a.md': strToU8('# A\n'), + 'assets/img/diag.png': new Uint8Array([1, 2, 3]), + 'evil.sh': strToU8('#!/bin/sh\n'), + 'content/bad.txt': strToU8('x'), + 'content/../../evil.md': strToU8('# evil\n'), + }); + const result = await extractBundle(new File([zipBytes], 'b.zip')); + expect(result.contentFiles.map((f) => f.path)).toEqual(['lesson/a.md']); + expect(result.assetFiles.map((f) => f.path)).toEqual(['img/diag.png']); + expect(result.invalidEntries.sort()).toEqual(['content/../../evil.md', 'content/bad.txt', 'evil.sh']); + }); + + it('menolak bytes zip yang tidak valid', async () => { + const { extractBundle } = await import('./contentEditor'); + await expect(extractBundle(new File([new Uint8Array([1, 2, 3])], 'b.zip'))).rejects.toThrow(); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/services/contentEditor.ts b/frontend/src/lib/services/contentEditor.ts index bbc77f2..ae064ca 100644 --- a/frontend/src/lib/services/contentEditor.ts +++ b/frontend/src/lib/services/contentEditor.ts @@ -1,3 +1,5 @@ +import { unzip, strFromU8 } from 'fflate'; + export interface TreeNode { type: 'file' | 'folder'; name: string; @@ -251,3 +253,47 @@ export async function importBundle(options: ImportOptions): Promise { + const buf = new Uint8Array(await file.arrayBuffer()); + const entries = await new Promise>((resolve, reject) => + unzip(buf, (err, data) => (err ? reject(new Error('File bukan zip yang valid')) : resolve(data))), + ); + + const contentFiles: ImportFileSpec[] = []; + const assetFiles: ImportFileSpec[] = []; + const invalidEntries: string[] = []; + + for (const [name, bytes] of Object.entries(entries)) { + if (name.endsWith('/')) continue; // directory entry + const parts = name.split('/'); + if ((parts[0] !== 'content' && parts[0] !== 'assets') || parts.length < 2) { + invalidEntries.push(name); + continue; + } + const rel = parts.slice(1).join('/'); + if (!rel || rel.startsWith('/') || rel.split('/').includes('..') || rel.split('/').includes('')) { + invalidEntries.push(name); + continue; + } + if (parts[0] === 'content') { + if (!rel.endsWith('.md')) { + invalidEntries.push(name); + continue; + } + contentFiles.push({ path: rel, body: strFromU8(bytes) }); + } else { + assetFiles.push({ path: rel, bytes }); + } + } + return { contentFiles, assetFiles, invalidEntries }; +} From ed7e7d1b2681e6acb82fed3b7e1f9d81df6e4b33 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 1 Oct 2026 12:11:58 +0700 Subject: [PATCH 5/6] feat: tombol export/import bundle di content editor --- .../src/routes/teacher/content/+page.svelte | 120 +++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/teacher/content/+page.svelte b/frontend/src/routes/teacher/content/+page.svelte index 3ae1062..b28340a 100644 --- a/frontend/src/routes/teacher/content/+page.svelte +++ b/frontend/src/routes/teacher/content/+page.svelte @@ -9,7 +9,7 @@ import LessonContentView from '$components/LessonContentView.svelte'; import QuizPreviewReadonly from '$components/QuizPreviewReadonly.svelte'; import { ContentEditorManager } from '$services/contentEditor.svelte'; - import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset } from '$services/contentEditor'; + import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset, exportBundle, extractBundle, importBundle } from '$services/contentEditor'; import type { TreeNode } from '$services/contentEditor'; import { upsertSection } from '$services/markdown'; @@ -603,6 +603,113 @@ let activeFileName = $derived(mgr.activePath?.split('/').pop() ?? ''); let isAssetFile = $derived(mgr.treeRoot === 'assets' && mgr.activePath !== null && !mgr.activePath.endsWith('/')); let isImageFile = $derived(isAssetFile && /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(mgr.activePath ?? '')); + + // ── Export / Import bundle ────────────────────────────────────── + let importExportBusy = $state(false); + let importProgress = $state<{ done: number; total: number; current: string } | null>(null); + + function flattenTreePaths(nodes: TreeNode[]): string[] { + const out: string[] = []; + const walk = (list: TreeNode[]) => { + for (const n of list) { + if (n.type === 'file') out.push(n.path); + if (n.children) walk(n.children); + } + }; + walk(nodes); + return out; + } + + function showToast(text: string, isError = false) { + const toast = document.createElement('div'); + toast.textContent = text; + toast.style.cssText = `position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:${isError ? '#b91c1c' : '#15803d'};color:#fff;padding:6px 16px;border-radius:8px;font-size:0.8rem;z-index:10000;max-width:90vw;`; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 4000); + } + + async function handleExport() { + importExportBusy = true; + try { + const blob = await exportBundle(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `elemes-bundle-${new Date().toISOString().slice(0, 10)}.zip`; + a.click(); + URL.revokeObjectURL(url); + showToast('Export berhasil — file sedang diunduh.'); + } catch (e) { + showToast(`Export gagal: ${e instanceof Error ? e.message : String(e)}`, true); + } finally { + importExportBusy = false; + } + } + + async function handleImportFile(file: File) { + importExportBusy = true; + importProgress = null; + try { + // 1. Reload tree SEBELUM extract supaya existingPaths fresh + await loadTrees(); + const existing = new Set([ + ...flattenTreePaths(mgr.contentTree), + ...flattenTreePaths(mgr.assetsTree), + ]); + + // 2. Extract zip di browser + const bundle = await extractBundle(file); + if (bundle.invalidEntries.length > 0) { + showToast(`${bundle.invalidEntries.length} entry diabaikan: ${bundle.invalidEntries.slice(0, 5).join(', ')}${bundle.invalidEntries.length > 5 ? '…' : ''}`, true); + } + if (bundle.contentFiles.length + bundle.assetFiles.length === 0) { + showToast('Bundle kosong — tidak ada yang diimport.', true); + return; + } + + // 3. Konfirmasi mode: OK = skip, Cancel = overwrite + const mode = confirm( + `Import ${bundle.contentFiles.length} materi + ${bundle.assetFiles.length} asset?\n\n` + + 'OK = lewati file yang sudah ada\nCancel = timpa file yang sudah ada', + ) ? 'skip' : 'overwrite'; + + // 4. Kirim per-file + const result = await importBundle({ + contentFiles: bundle.contentFiles, + assetFiles: bundle.assetFiles, + mode, + existingPaths: existing, + onProgress: (done, total, current) => { + importProgress = { done, total, current }; + }, + }); + showToast( + result.success + ? `Import selesai: ${result.imported} ditulis, ${result.skipped} dilewati.` + : `Import gagal di ${result.failedPath}: ${result.message}`, + !result.success, + ); + + // 5. Reload tree UI setelah import + await loadTrees(); + } catch (e) { + showToast(`Import gagal: ${e instanceof Error ? e.message : String(e)}`, true); + } finally { + importExportBusy = false; + importProgress = null; + } + } + + function handleImportClick() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.zip,application/zip'; + input.onchange = () => { + const file = input.files?.[0]; + if (file) handleImportFile(file); + }; + input.click(); + } @@ -675,6 +782,11 @@ > {#snippet saveActions()} + + + {#if importProgress} + Impor {importProgress.done}/{importProgress.total} + {/if} {#if mgr.activePath && !isAssetFile}