Add Markdown import from Obsidian (Stage 8)
- md-to-tiptap.ts: remark-based converter (headings, lists, blockquotes, code blocks, bold/italic/strike, links, images, hr) - Obsidian ![[wikilink]] stripped, [[link|alias]] → plain text - POST /api/admin/import-md: parses frontmatter (gray-matter) + converts content - LessonEditor: "Импорт .md" button populates editor without auto-save - ROADMAP: marked Stages 2, 3, 5, 6, 7, 8 as complete, fixed numbering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import matter from "gray-matter";
|
||||
import { mdToTiptap } from "@/lib/md-to-tiptap";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
if (!file.name.endsWith(".md")) {
|
||||
return NextResponse.json({ error: "Only .md files are supported" }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = await file.text();
|
||||
const { data: fm, content } = matter(raw);
|
||||
|
||||
// Extract known frontmatter fields (Obsidian-compatible naming)
|
||||
const title =
|
||||
typeof fm.title === "string" ? fm.title.trim() : null;
|
||||
const kinescopeId =
|
||||
(fm.kinescopeId ?? fm.kinescope_id ?? fm.videoId ?? fm.video_id ?? "") as string;
|
||||
const order =
|
||||
typeof fm.order === "number" ? fm.order : null;
|
||||
const published =
|
||||
typeof fm.published === "boolean" ? fm.published : null;
|
||||
|
||||
const tiptapContent = mdToTiptap(content);
|
||||
|
||||
return NextResponse.json({ title, kinescopeId, order, published, content: tiptapContent });
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Save, Eye } from "lucide-react";
|
||||
import { Save, Eye, FileUp } from "lucide-react";
|
||||
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
|
||||
|
||||
interface LessonData {
|
||||
@@ -32,6 +32,8 @@ export function LessonEditor({
|
||||
const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId);
|
||||
const [published, setPublished] = useState(lesson.published);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
@@ -78,6 +80,34 @@ export function LessonEditor({
|
||||
input.click();
|
||||
}, [editor]);
|
||||
|
||||
const importMd = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".md";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file || !editor) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/admin/import-md", { method: "POST", body: fd });
|
||||
if (!res.ok) throw new Error("Ошибка импорта");
|
||||
const data = await res.json();
|
||||
if (data.title) setTitle(data.title);
|
||||
if (data.kinescopeId) setKinescopeId(data.kinescopeId);
|
||||
if (data.published !== null) setPublished(data.published);
|
||||
if (data.content) editor.commands.setContent(data.content);
|
||||
} catch {
|
||||
setImportError("Не удалось импортировать файл");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}, [editor]);
|
||||
|
||||
function handleSave() {
|
||||
if (!editor) return;
|
||||
startTransition(async () => {
|
||||
@@ -125,6 +155,17 @@ export function LessonEditor({
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={importMd}
|
||||
disabled={importing || pending}
|
||||
className="btn-aubade flex items-center gap-1.5 px-4 py-2 text-sm"
|
||||
title="Импортировать из .md файла Obsidian"
|
||||
style={{ opacity: importing || pending ? 0.6 : 1 }}
|
||||
>
|
||||
<FileUp size={14} />
|
||||
{importing ? "Импорт..." : "Импорт .md"}
|
||||
</button>
|
||||
<a
|
||||
href={`/courses/${courseSlug}/lessons/${lesson.id}`}
|
||||
target="_blank"
|
||||
@@ -149,6 +190,12 @@ export function LessonEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importError && (
|
||||
<p className="text-xs px-3 py-2" style={{ background: "oklch(0.577 0.245 27.325 / 0.1)", color: "oklch(0.577 0.245 27.325)", border: "1px solid oklch(0.577 0.245 27.325 / 0.3)" }}>
|
||||
{importError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Mark = { type: string; attrs?: Record<string, unknown> };
|
||||
|
||||
type TipTapNode = {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TipTapNode[];
|
||||
text?: string;
|
||||
marks?: Mark[];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type MdastNode = Record<string, any>;
|
||||
|
||||
// ── Inline converter ──────────────────────────────────────────────────────────
|
||||
|
||||
function convertInline(nodes: MdastNode[], marks: Mark[] = []): TipTapNode[] {
|
||||
const result: TipTapNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
switch (node.type) {
|
||||
case "text": {
|
||||
if (!node.value) break;
|
||||
const n: TipTapNode = { type: "text", text: node.value as string };
|
||||
if (marks.length) n.marks = marks;
|
||||
result.push(n);
|
||||
break;
|
||||
}
|
||||
|
||||
case "strong":
|
||||
result.push(...convertInline(node.children, [...marks, { type: "bold" }]));
|
||||
break;
|
||||
|
||||
case "emphasis":
|
||||
result.push(...convertInline(node.children, [...marks, { type: "italic" }]));
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
result.push(...convertInline(node.children, [...marks, { type: "strike" }]));
|
||||
break;
|
||||
|
||||
case "inlineCode":
|
||||
result.push({
|
||||
type: "text",
|
||||
text: node.value as string,
|
||||
marks: [...marks, { type: "code" }],
|
||||
});
|
||||
break;
|
||||
|
||||
case "link": {
|
||||
const linkMark: Mark = {
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: node.url ?? "#",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
class: null,
|
||||
},
|
||||
};
|
||||
result.push(...convertInline(node.children, [...marks, linkMark]));
|
||||
break;
|
||||
}
|
||||
|
||||
case "image":
|
||||
// Only HTTP/HTTPS images — local Obsidian paths are not resolvable here
|
||||
if (typeof node.url === "string" && /^https?:\/\//.test(node.url)) {
|
||||
result.push({
|
||||
type: "image",
|
||||
attrs: {
|
||||
src: node.url,
|
||||
alt: node.alt ?? null,
|
||||
title: node.title ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "break":
|
||||
result.push({ type: "hardBreak" });
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try to extract content from unknown inline nodes
|
||||
if (Array.isArray(node.children)) {
|
||||
result.push(...convertInline(node.children, marks));
|
||||
} else if (node.value) {
|
||||
const n: TipTapNode = { type: "text", text: node.value as string };
|
||||
if (marks.length) n.marks = marks;
|
||||
result.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Block converter ───────────────────────────────────────────────────────────
|
||||
|
||||
function convertBlock(nodes: MdastNode[]): TipTapNode[] {
|
||||
const result: TipTapNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
switch (node.type) {
|
||||
case "paragraph": {
|
||||
const content = convertInline(node.children ?? []);
|
||||
result.push(content.length ? { type: "paragraph", content } : { type: "paragraph" });
|
||||
break;
|
||||
}
|
||||
|
||||
case "heading": {
|
||||
const content = convertInline(node.children ?? []);
|
||||
result.push({
|
||||
type: "heading",
|
||||
attrs: { level: node.depth ?? 2 },
|
||||
content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "blockquote": {
|
||||
const content = convertBlock(node.children ?? []);
|
||||
result.push({ type: "blockquote", content });
|
||||
break;
|
||||
}
|
||||
|
||||
case "code": {
|
||||
result.push({
|
||||
type: "codeBlock",
|
||||
attrs: { language: node.lang ?? null },
|
||||
content: [{ type: "text", text: node.value as string }],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const listType = node.ordered ? "orderedList" : "bulletList";
|
||||
const items = (node.children as MdastNode[]).map((item) => ({
|
||||
type: "listItem",
|
||||
content: convertBlock(item.children ?? []),
|
||||
}));
|
||||
const listNode: TipTapNode = { type: listType, content: items };
|
||||
if (node.ordered) listNode.attrs = { start: (node.start as number) ?? 1 };
|
||||
result.push(listNode);
|
||||
break;
|
||||
}
|
||||
|
||||
case "thematicBreak":
|
||||
result.push({ type: "horizontalRule" });
|
||||
break;
|
||||
|
||||
// Skip html, definitions, footnotes, etc.
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a Markdown string to a TipTap/ProseMirror JSON document.
|
||||
* Handles: headings, paragraphs, bold, italic, strike, inline code,
|
||||
* code blocks, blockquotes, lists (nested), links, images (HTTP only),
|
||||
* horizontal rules, hard breaks.
|
||||
*
|
||||
* Obsidian-specific syntax (![[wikilink]], [[link]]) is silently ignored
|
||||
* since local file paths are not available during server-side import.
|
||||
*/
|
||||
export function mdToTiptap(markdown: string): object {
|
||||
// Strip Obsidian wikilinks: [[link]] → plain text, ![[image]] → removed
|
||||
const cleaned = markdown
|
||||
.replace(/!\[\[([^\]]+)\]\]/g, "") // remove ![[image]] embeds
|
||||
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target, alias) => alias ?? target); // [[link|alias]] → alias or target
|
||||
|
||||
const tree = unified().use(remarkParse).parse(cleaned);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const content = convertBlock((tree as any).children ?? []);
|
||||
|
||||
if (content.length === 0) {
|
||||
content.push({ type: "paragraph" });
|
||||
}
|
||||
|
||||
return { type: "doc", content };
|
||||
}
|
||||
Reference in New Issue
Block a user