elemes/frontend/src/lib/services/contentEditor.ts

300 lines
9.6 KiB
TypeScript

import { unzip, strFromU8 } from 'fflate';
export interface TreeNode {
type: 'file' | 'folder';
name: string;
path: string;
ext?: string;
children?: TreeNode[];
}
export interface DraftResponse {
success: boolean;
source?: 'draft' | 'file';
draft_id: string | null;
body?: string;
base_mtime?: number | null;
message?: string;
}
export interface PreviewResponse {
success: boolean;
lesson_content?: string;
exercise_content?: string;
quiz_data?: unknown[];
slides?: string[];
active_tabs?: string[];
message?: string;
}
export async function getTree(root: 'content' | 'assets'): Promise<TreeNode[]> {
const res = await fetch(`/api/content/tree?root=${root}`, { credentials: 'include' });
const data = await res.json();
return data.success ? data.tree : [];
}
export async function getDraft(targetPath: string): Promise<DraftResponse> {
const res = await fetch(`/api/content/drafts?target_path=${encodeURIComponent(targetPath)}`, {
credentials: 'include',
});
return res.json();
}
export async function saveDraft(targetPath: string, body: string, baseMtime: number | null) {
const res = await fetch('/api/content/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ target_path: targetPath, body, base_mtime: baseMtime }),
});
return res.json();
}
/**
* @deprecated Use client-side renderMarkdownPreview from './markdown' instead.
* Kept for fallback or direct API verification purposes.
*/
export async function previewContent(body: string): Promise<PreviewResponse> {
const res = await fetch('/api/content/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ body }),
});
return res.json();
}
export async function publishDraft(draftId: string) {
const res = await fetch(`/api/content/drafts/${draftId}/publish`, {
method: 'POST',
credentials: 'include',
});
return res.json();
}
export async function discardDraft(draftId: string) {
const res = await fetch(`/api/content/drafts/${draftId}`, {
method: 'DELETE',
credentials: 'include',
});
return res.json();
}
export async function createFolder(root: 'content' | 'assets', path: string) {
const res = await fetch('/api/content/tree/folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ root, path }),
});
return res.json();
}
export async function createFile(path: string, template?: string) {
const res = await fetch('/api/content/tree/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ root: 'content', path, template }),
});
return res.json();
}
export async function renameEntry(
root: 'content' | 'assets',
oldPath: string,
newPath: string,
confirmCritical = false,
) {
const res = await fetch('/api/content/tree/rename', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ root, old_path: oldPath, new_path: newPath, confirm_critical: confirmCritical }),
});
return res.json();
}
export async function deleteEntry(root: 'content' | 'assets', path: string, confirmCritical = false, force = false) {
const res = await fetch(
`/api/content/tree/entry?root=${root}&path=${encodeURIComponent(path)}&force=${force}&confirm_critical=${confirmCritical}`,
{ method: 'DELETE', credentials: 'include' },
);
return res.json();
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(',')[1] ?? '');
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export async function uploadAsset(file: File, folder = '') {
const content_base64 = await fileToBase64(file);
const res = await fetch('/api/content/assets/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ filename: file.name, folder, content_base64 }),
});
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 };
}