Compare commits
8 Commits
f5ee52080c
...
d51c7da972
| Author | SHA1 | Date |
|---|---|---|
|
|
d51c7da972 | |
|
|
2780e5f4a9 | |
|
|
c127309dd8 | |
|
|
ed7e7d1b26 | |
|
|
961d2a0ac2 | |
|
|
796fd47800 | |
|
|
f1f0b57189 | |
|
|
67f1501b4e |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,26 @@
|
|||
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.
|
||||
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 () => {
|
||||
|
|
@ -90,4 +111,148 @@ 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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { unzip, strFromU8 } from 'fflate';
|
||||
|
||||
export interface TreeNode {
|
||||
type: 'file' | 'folder';
|
||||
name: string;
|
||||
|
|
@ -143,3 +145,155 @@ 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<Blob> {
|
||||
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<string>; // 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<void> {
|
||||
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<ImportSummary> {
|
||||
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 };
|
||||
}
|
||||
|
||||
export interface ExtractedBundle {
|
||||
contentFiles: ImportFileSpec[];
|
||||
assetFiles: ImportFileSpec[];
|
||||
invalidEntries: string[]; // entry di luar content/|assets/ | bukan .md di content/ | path mencurigakan
|
||||
}
|
||||
|
||||
/** Extract zip di browser (pola zip = output /content/export:
|
||||
* top-level `content/**.md` dan `assets/**`). Entry tidak valid
|
||||
* dikumpulkan di invalidEntries — tidak diimport, dilaporkan ke user. */
|
||||
export async function extractBundle(file: File): Promise<ExtractedBundle> {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const entries = await new Promise<Record<string, Uint8Array>>((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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@
|
|||
* Both Footer.svelte and service-worker.ts import from here.
|
||||
* Bump this number after every feature/bugfix release.
|
||||
*/
|
||||
export const APP_VERSION = 32;
|
||||
export const APP_VERSION = 33;
|
||||
|
|
|
|||
|
|
@ -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<string>([
|
||||
...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();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -675,6 +782,11 @@
|
|||
>
|
||||
<!-- Save / Publish actions (shared across all header modes) -->
|
||||
{#snippet saveActions()}
|
||||
<button class="panel-btn" onclick={handleExport} disabled={importExportBusy} title="Export semua materi & assets (zip)">📤</button>
|
||||
<button class="panel-btn" onclick={handleImportClick} disabled={importExportBusy} title="Import materi & assets dari zip (extract di browser, kirim per-file)">📥</button>
|
||||
{#if importProgress}
|
||||
<span class="ie-progress" role="status">Impor {importProgress.done}/{importProgress.total}</span>
|
||||
{/if}
|
||||
{#if mgr.activePath && !isAssetFile}
|
||||
<button class="panel-btn toolbar-toggle" class:active={featureSidebarOpen} onclick={() => featureSidebarOpen = !featureSidebarOpen} title="Tampilkan/sembunyikan panel Fitur">🧩</button>
|
||||
<button class="panel-btn save-btn" onclick={() => handleSaveAction()} disabled={mgr.saving || (!mgr.dirty && mgr.activeTab !== 'velxio')} title="Simpan">
|
||||
|
|
@ -1090,6 +1202,12 @@
|
|||
.panel-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.panel-btn.save-btn { color: var(--color-text-muted); }
|
||||
.panel-btn.publish-btn { color: var(--color-primary, #339af0); font-weight: 600; }
|
||||
.ie-progress {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.15rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Editor main: tab panels (left) + feature sidebar (right) */
|
||||
.editor-main {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
"""E2E verify: tombol export/import bundle di content editor (lms-dev).
|
||||
|
||||
Auth: cookie student_token=141214 (HttpOnly, untuk /api) + localStorage
|
||||
(studi) + reload supaya auth.init() jalan. Sama seperti recipe skill.
|
||||
|
||||
Langkah:
|
||||
1. Login guru -> buka /teacher/content
|
||||
2. Klik tombol export (📤) -> tangkap download event -> simpan zip, cek isinya
|
||||
3. Klik tombol import (📥) -> pilih probe zip via file_chooser -> accept confirm
|
||||
4. Cek via API /api/content/tree bahwa imported/probe.md muncul
|
||||
|
||||
Menulis zip probe sendiri (content/imported/probe.md + assets/imported/probe.png).
|
||||
"""
|
||||
import io
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "https://sinau-c-dev.manakin-gentoo.ts.net"
|
||||
TEACHER_TOKEN = "141214"
|
||||
PROBE_ZIP = Path("/tmp/elemes-probe-bundle.zip")
|
||||
DOWNLOAD_DIR = Path("/tmp/elemes-e2e-downloads")
|
||||
|
||||
|
||||
def make_probe_zip() -> Path:
|
||||
if PROBE_ZIP.exists():
|
||||
return PROBE_ZIP
|
||||
with zipfile.ZipFile(PROBE_ZIP, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("content/imported/probe.md", "# Probe Import\n\nMateri test.\n")
|
||||
zf.writestr("assets/imported/probe.png", b"\x89PNG\r\n\x1a\nfake-png")
|
||||
return PROBE_ZIP
|
||||
|
||||
|
||||
def main() -> int:
|
||||
make_probe_zip()
|
||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context(
|
||||
accept_downloads=True,
|
||||
ignore_https_errors=True,
|
||||
service_workers="block",
|
||||
viewport={"width": 1280, "height": 800},
|
||||
)
|
||||
ctx.add_cookies([{
|
||||
"name": "student_token",
|
||||
"value": TEACHER_TOKEN,
|
||||
"domain": "sinau-c-dev.manakin-gentoo.ts.net",
|
||||
"path": "/",
|
||||
"http_only": True,
|
||||
"same_site": "Lax",
|
||||
}])
|
||||
page = ctx.new_page()
|
||||
page.add_init_script(f"localStorage.setItem('student_token','{TEACHER_TOKEN}');")
|
||||
|
||||
pageerrors = []
|
||||
page.on("pageerror", lambda e: pageerrors.append(str(e)))
|
||||
|
||||
# ── Login & buka editor ──
|
||||
page.goto(f"{BASE}/teacher/content", wait_until="networkidle")
|
||||
page.reload(wait_until="networkidle")
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
# Guard: pastikan editor guru tampil
|
||||
if page.locator(".content-editor-layout").count() == 0:
|
||||
print("FAIL: .content-editor-layout tidak muncul (auth? gate?)")
|
||||
print("pageerrors:", pageerrors)
|
||||
browser.close()
|
||||
return 1
|
||||
print("OK: content editor tampil")
|
||||
|
||||
# ── Export ──
|
||||
export_btn = page.locator("button.panel-btn", has_text="📤")
|
||||
if export_btn.count() == 0:
|
||||
print("FAIL: tombol export 📤 tidak ditemukan")
|
||||
browser.close()
|
||||
return 1
|
||||
print("OK: tombol export ditemukan")
|
||||
|
||||
with page.expect_download(timeout=20000) as dl_info:
|
||||
export_btn.first.click()
|
||||
download = dl_info.value
|
||||
dest = DOWNLOAD_DIR / download.suggested_filename
|
||||
download.save_as(dest)
|
||||
print(f"OK: download tersimpan -> {dest} ({dest.stat().st_size} bytes)")
|
||||
|
||||
# Cek isi zip
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert "content/home.md" in names, f"zip tidak berisi content/home.md: {sorted(names)[:8]}"
|
||||
assert any(n.startswith("assets/") for n in names), "zip tidak berisi assets/"
|
||||
print(f"OK: zip berisi {len(names)} entry; ada content/ dan assets/")
|
||||
|
||||
# ── Import ──
|
||||
import_btn = page.locator("button.panel-btn", has_text="📥")
|
||||
if import_btn.count() == 0:
|
||||
print("FAIL: tombol import 📥 tidak ditemukan")
|
||||
browser.close()
|
||||
return 1
|
||||
print("OK: tombol import ditemukan")
|
||||
|
||||
confirmed = {"mode": None}
|
||||
|
||||
def on_dialog(dialog):
|
||||
# window.confirm: OK = skip, Cancel = overwrite -> pilih overwrite
|
||||
# (probe file belum ada, jadi mode tidak terlalu penting; pilih skip/OK)
|
||||
print(f" dialog: {dialog.type} -> accept (skip mode)")
|
||||
confirmed["mode"] = "skip"
|
||||
dialog.accept()
|
||||
|
||||
page.on("dialog", on_dialog)
|
||||
|
||||
with page.expect_file_chooser(timeout=20000) as fc_info:
|
||||
import_btn.first.click()
|
||||
fc = fc_info.value
|
||||
fc.set_files(str(PROBE_ZIP))
|
||||
print("OK: file probe dipilih")
|
||||
|
||||
# Tunggu toast/import selesai (progress -> toast -> reload tree)
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
# ── Verify via API tree ──
|
||||
tree = page.evaluate(
|
||||
"""async () => {
|
||||
const r = await fetch('/api/content/tree?root=content', { credentials: 'include' });
|
||||
const j = await r.json();
|
||||
return j.success ? j.tree : null;
|
||||
}"""
|
||||
)
|
||||
flat = []
|
||||
def walk(nodes):
|
||||
for n in nodes or []:
|
||||
flat.append(n["path"])
|
||||
if n.get("children"):
|
||||
walk(n["children"])
|
||||
walk(tree)
|
||||
ok_imported = "imported/probe.md" in flat
|
||||
print(f"OK imported/probe.md di tree: {ok_imported}")
|
||||
if not ok_imported:
|
||||
print(" tree sample:", flat[:10])
|
||||
print("FAIL: imported/probe.md tidak ada di tree")
|
||||
print("pageerrors:", pageerrors)
|
||||
browser.close()
|
||||
return 1
|
||||
|
||||
# ── Verify asset via API tree assets ──
|
||||
atree = page.evaluate(
|
||||
"""async () => {
|
||||
const r = await fetch('/api/content/tree?root=assets', { credentials: 'include' });
|
||||
const j = await r.json();
|
||||
return j.success ? j.tree : null;
|
||||
}"""
|
||||
)
|
||||
aflat = []
|
||||
def walk_a(nodes):
|
||||
for n in nodes or []:
|
||||
aflat.append(n["path"])
|
||||
if n.get("children"):
|
||||
walk_a(n["children"])
|
||||
walk_a(atree)
|
||||
ok_asset = "imported/probe.png" in aflat
|
||||
print(f"OK imported/probe.png di assets tree: {ok_asset}")
|
||||
|
||||
# ── Overwrite round: import ulang mode overwrite, pastikan isi berubah ──
|
||||
# (mode ditentukan dari confirm -> kedua kalinya tekan Cancel)
|
||||
confirmed["mode"] = None
|
||||
|
||||
def on_dialog2(dialog):
|
||||
dialog.dismiss() # Cancel = overwrite
|
||||
|
||||
page.remove_listener("dialog", on_dialog)
|
||||
page.on("dialog", on_dialog2)
|
||||
|
||||
with page.expect_file_chooser(timeout=20000) as fc_info2:
|
||||
import_btn.first.click()
|
||||
fc2 = fc_info2.value
|
||||
fc2.set_files(str(PROBE_ZIP))
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
body = page.evaluate(
|
||||
"""async () => {
|
||||
const r = await fetch('/api/content/drafts?target_path=imported/probe.md', { credentials: 'include' });
|
||||
const j = await r.json();
|
||||
return j.success ? (j.body || '') : 'ERR';
|
||||
}"""
|
||||
)
|
||||
print(f"OK body probe.md setelah overwrite: {body.strip()[:40]!r}")
|
||||
if not body.startswith("# Probe Import"):
|
||||
print("FAIL: isi probe.md tidak sesuai setelah overwrite")
|
||||
browser.close()
|
||||
return 1
|
||||
|
||||
print("pageerrors:", pageerrors)
|
||||
if pageerrors:
|
||||
# filter 502/favicon infra noise
|
||||
real = [e for e in pageerrors if "502" not in e and "favicon" not in e]
|
||||
if real:
|
||||
print("FAIL: pageerrors nyata:", real)
|
||||
browser.close()
|
||||
return 1
|
||||
|
||||
print("\nALL E2E CHECKS PASSED")
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue