diff --git a/apps/api/package.json b/apps/api/package.json index 0eb7467..7040602 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -57,6 +57,8 @@ "@nestjs/schematics": "^11.0.1", "@nestjs/testing": "^11.1.12", "@swc/core": "^1.10.18", + "@types/adm-zip": "^0.5.7", + "@types/archiver": "^7.0.0", "@types/express": "^5.0.1", "@types/highlight.js": "^10.1.0", "@types/ioredis": "^5.0.0", diff --git a/apps/api/src/knowledge/dto/import-export.dto.ts b/apps/api/src/knowledge/dto/import-export.dto.ts new file mode 100644 index 0000000..2676b82 --- /dev/null +++ b/apps/api/src/knowledge/dto/import-export.dto.ts @@ -0,0 +1,52 @@ +import { + IsString, + IsOptional, + IsEnum, + IsArray, + ArrayMinSize, +} from "class-validator"; + +/** + * Export format enum + */ +export enum ExportFormat { + MARKDOWN = "markdown", + JSON = "json", +} + +/** + * DTO for export query parameters + */ +export class ExportQueryDto { + @IsOptional() + @IsEnum(ExportFormat, { message: "format must be either 'markdown' or 'json'" }) + format?: ExportFormat = ExportFormat.MARKDOWN; + + @IsOptional() + @IsArray({ message: "entryIds must be an array" }) + @IsString({ each: true, message: "each entryId must be a string" }) + entryIds?: string[]; +} + +/** + * Import result for a single entry + */ +export interface ImportResult { + filename: string; + success: boolean; + entryId?: string; + slug?: string; + title?: string; + error?: string; +} + +/** + * Response DTO for import operation + */ +export interface ImportResponseDto { + success: boolean; + totalFiles: number; + imported: number; + failed: number; + results: ImportResult[]; +} diff --git a/apps/api/src/knowledge/dto/index.ts b/apps/api/src/knowledge/dto/index.ts index f33dd5a..9cf0ec2 100644 --- a/apps/api/src/knowledge/dto/index.ts +++ b/apps/api/src/knowledge/dto/index.ts @@ -10,3 +10,9 @@ export { RecentEntriesDto, } from "./search-query.dto"; export { GraphQueryDto } from "./graph-query.dto"; +export { + ExportQueryDto, + ExportFormat, + ImportResult, + ImportResponseDto, +} from "./import-export.dto"; diff --git a/apps/api/src/knowledge/import-export.controller.ts b/apps/api/src/knowledge/import-export.controller.ts new file mode 100644 index 0000000..ffd244c --- /dev/null +++ b/apps/api/src/knowledge/import-export.controller.ts @@ -0,0 +1,93 @@ +import { + Controller, + Post, + Get, + Query, + UseGuards, + UseInterceptors, + UploadedFile, + Res, + BadRequestException, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { Response } from "express"; +import { ImportExportService } from "./services/import-export.service"; +import { ExportQueryDto, ExportFormat, ImportResponseDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; + +/** + * Controller for knowledge import/export endpoints + * All endpoints require authentication and workspace context + */ +@Controller("knowledge") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class ImportExportController { + constructor(private readonly importExportService: ImportExportService) {} + + /** + * POST /api/knowledge/import + * Import knowledge entries from uploaded file (.md or .zip) + * Requires: MEMBER role or higher + */ + @Post("import") + @RequirePermission(Permission.WORKSPACE_MEMBER) + @UseInterceptors(FileInterceptor("file")) + async importEntries( + @Workspace() workspaceId: string, + @CurrentUser() user: any, + @UploadedFile() file: Express.Multer.File + ): Promise { + if (!file) { + throw new BadRequestException("No file uploaded"); + } + + const result = await this.importExportService.importEntries( + workspaceId, + user.id, + file + ); + + return { + success: result.failed === 0, + totalFiles: result.totalFiles, + imported: result.imported, + failed: result.failed, + results: result.results, + }; + } + + /** + * GET /api/knowledge/export + * Export knowledge entries as a zip file + * Query params: + * - format: 'markdown' (default) or 'json' + * - entryIds: optional array of entry IDs to export (exports all if not provided) + * Requires: Any workspace member + */ + @Get("export") + @RequirePermission(Permission.WORKSPACE_ANY) + async exportEntries( + @Workspace() workspaceId: string, + @Query() query: ExportQueryDto, + @Res() res: Response + ): Promise { + const format = query.format || ExportFormat.MARKDOWN; + const entryIds = query.entryIds; + + const { stream, filename } = await this.importExportService.exportEntries( + workspaceId, + format, + entryIds + ); + + // Set response headers + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + + // Pipe the archive stream to response + stream.pipe(res); + } +} diff --git a/apps/api/src/knowledge/knowledge.module.ts b/apps/api/src/knowledge/knowledge.module.ts index 92cfa69..06924f9 100644 --- a/apps/api/src/knowledge/knowledge.module.ts +++ b/apps/api/src/knowledge/knowledge.module.ts @@ -5,17 +5,24 @@ import { KnowledgeService } from "./knowledge.service"; import { KnowledgeController } from "./knowledge.controller"; import { SearchController } from "./search.controller"; import { KnowledgeStatsController } from "./stats.controller"; +import { ImportExportController } from "./import-export.controller"; import { LinkResolutionService, SearchService, LinkSyncService, GraphService, StatsService, + ImportExportService, } from "./services"; @Module({ imports: [PrismaModule, AuthModule], - controllers: [KnowledgeController, SearchController, KnowledgeStatsController], + controllers: [ + KnowledgeController, + SearchController, + KnowledgeStatsController, + ImportExportController, + ], providers: [ KnowledgeService, LinkResolutionService, @@ -23,6 +30,7 @@ import { LinkSyncService, GraphService, StatsService, + ImportExportService, ], exports: [KnowledgeService, LinkResolutionService, SearchService], }) diff --git a/apps/api/src/knowledge/services/import-export.service.spec.ts b/apps/api/src/knowledge/services/import-export.service.spec.ts new file mode 100644 index 0000000..c05de87 --- /dev/null +++ b/apps/api/src/knowledge/services/import-export.service.spec.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { BadRequestException } from "@nestjs/common"; +import { ImportExportService } from "./import-export.service"; +import { KnowledgeService } from "../knowledge.service"; +import { PrismaService } from "../../prisma/prisma.service"; +import { ExportFormat } from "../dto"; +import { EntryStatus, Visibility } from "@prisma/client"; + +describe("ImportExportService", () => { + let service: ImportExportService; + let knowledgeService: KnowledgeService; + let prisma: PrismaService; + + const workspaceId = "workspace-123"; + const userId = "user-123"; + + const mockEntry = { + id: "entry-123", + workspaceId, + slug: "test-entry", + title: "Test Entry", + content: "Test content", + summary: "Test summary", + status: EntryStatus.PUBLISHED, + visibility: Visibility.WORKSPACE, + createdAt: new Date(), + updatedAt: new Date(), + tags: [ + { + tag: { + id: "tag-1", + name: "TypeScript", + slug: "typescript", + color: "#3178c6", + }, + }, + ], + }; + + const mockKnowledgeService = { + create: vi.fn(), + findAll: vi.fn(), + }; + + const mockPrismaService = { + knowledgeEntry: { + findMany: vi.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImportExportService, + { + provide: KnowledgeService, + useValue: mockKnowledgeService, + }, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(ImportExportService); + knowledgeService = module.get(KnowledgeService); + prisma = module.get(PrismaService); + + vi.clearAllMocks(); + }); + + describe("importEntries", () => { + it("should import a single markdown file successfully", async () => { + const markdown = `--- +title: Test Entry +status: PUBLISHED +tags: + - TypeScript + - Testing +--- + +This is the content of the entry.`; + + const file: Express.Multer.File = { + fieldname: "file", + originalname: "test.md", + encoding: "utf-8", + mimetype: "text/markdown", + size: markdown.length, + buffer: Buffer.from(markdown), + stream: null as any, + destination: "", + filename: "", + path: "", + }; + + mockKnowledgeService.create.mockResolvedValue({ + id: "entry-123", + slug: "test-entry", + title: "Test Entry", + }); + + const result = await service.importEntries(workspaceId, userId, file); + + expect(result.totalFiles).toBe(1); + expect(result.imported).toBe(1); + expect(result.failed).toBe(0); + expect(result.results[0].success).toBe(true); + expect(result.results[0].title).toBe("Test Entry"); + expect(mockKnowledgeService.create).toHaveBeenCalledWith( + workspaceId, + userId, + expect.objectContaining({ + title: "Test Entry", + content: "This is the content of the entry.", + status: EntryStatus.PUBLISHED, + tags: ["TypeScript", "Testing"], + }) + ); + }); + + it("should use filename as title if frontmatter title is missing", async () => { + const markdown = `This is content without frontmatter.`; + + const file: Express.Multer.File = { + fieldname: "file", + originalname: "my-entry.md", + encoding: "utf-8", + mimetype: "text/markdown", + size: markdown.length, + buffer: Buffer.from(markdown), + stream: null as any, + destination: "", + filename: "", + path: "", + }; + + mockKnowledgeService.create.mockResolvedValue({ + id: "entry-123", + slug: "my-entry", + title: "my-entry", + }); + + const result = await service.importEntries(workspaceId, userId, file); + + expect(result.imported).toBe(1); + expect(mockKnowledgeService.create).toHaveBeenCalledWith( + workspaceId, + userId, + expect.objectContaining({ + title: "my-entry", + content: "This is content without frontmatter.", + }) + ); + }); + + it("should reject invalid file types", async () => { + const file: Express.Multer.File = { + fieldname: "file", + originalname: "test.txt", + encoding: "utf-8", + mimetype: "text/plain", + size: 100, + buffer: Buffer.from("test"), + stream: null as any, + destination: "", + filename: "", + path: "", + }; + + await expect( + service.importEntries(workspaceId, userId, file) + ).rejects.toThrow(BadRequestException); + }); + + it("should handle import errors gracefully", async () => { + const markdown = `--- +title: Test Entry +--- + +Content`; + + const file: Express.Multer.File = { + fieldname: "file", + originalname: "test.md", + encoding: "utf-8", + mimetype: "text/markdown", + size: markdown.length, + buffer: Buffer.from(markdown), + stream: null as any, + destination: "", + filename: "", + path: "", + }; + + mockKnowledgeService.create.mockRejectedValue( + new Error("Database error") + ); + + const result = await service.importEntries(workspaceId, userId, file); + + expect(result.totalFiles).toBe(1); + expect(result.imported).toBe(0); + expect(result.failed).toBe(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].error).toBe("Database error"); + }); + + it("should reject empty markdown content", async () => { + const markdown = `--- +title: Empty Entry +--- + +`; + + const file: Express.Multer.File = { + fieldname: "file", + originalname: "empty.md", + encoding: "utf-8", + mimetype: "text/markdown", + size: markdown.length, + buffer: Buffer.from(markdown), + stream: null as any, + destination: "", + filename: "", + path: "", + }; + + const result = await service.importEntries(workspaceId, userId, file); + + expect(result.imported).toBe(0); + expect(result.failed).toBe(1); + expect(result.results[0].error).toBe("Empty content"); + }); + }); + + describe("exportEntries", () => { + it("should export entries as markdown format", async () => { + mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]); + + const result = await service.exportEntries( + workspaceId, + ExportFormat.MARKDOWN + ); + + expect(result.filename).toMatch(/knowledge-export-\d{4}-\d{2}-\d{2}\.zip/); + expect(result.stream).toBeDefined(); + expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({ + where: { workspaceId }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + title: "asc", + }, + }); + }); + + it("should export only specified entries", async () => { + const entryIds = ["entry-123", "entry-456"]; + mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]); + + await service.exportEntries(workspaceId, ExportFormat.JSON, entryIds); + + expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({ + where: { + workspaceId, + id: { in: entryIds }, + }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + title: "asc", + }, + }); + }); + + it("should throw error when no entries found", async () => { + mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await expect( + service.exportEntries(workspaceId, ExportFormat.MARKDOWN) + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/apps/api/src/knowledge/services/import-export.service.ts b/apps/api/src/knowledge/services/import-export.service.ts new file mode 100644 index 0000000..4225cdf --- /dev/null +++ b/apps/api/src/knowledge/services/import-export.service.ts @@ -0,0 +1,315 @@ +import { Injectable, BadRequestException } from "@nestjs/common"; +import { EntryStatus, Visibility } from "@prisma/client"; +import * as archiver from "archiver"; +import * as AdmZip from "adm-zip"; +import * as matter from "gray-matter"; +import { Readable } from "stream"; +import { PrismaService } from "../../prisma/prisma.service"; +import { KnowledgeService } from "../knowledge.service"; +import type { ExportFormat, ImportResult } from "../dto"; +import type { CreateEntryDto } from "../dto/create-entry.dto"; + +/** + * Service for handling knowledge entry import/export operations + */ +@Injectable() +export class ImportExportService { + constructor( + private readonly prisma: PrismaService, + private readonly knowledgeService: KnowledgeService + ) {} + + /** + * Import entries from uploaded file(s) + * Accepts single .md file or .zip containing multiple .md files + */ + async importEntries( + workspaceId: string, + userId: string, + file: Express.Multer.File + ): Promise<{ results: ImportResult[]; totalFiles: number; imported: number; failed: number }> { + const results: ImportResult[] = []; + + try { + if (file.mimetype === "text/markdown" || file.originalname.endsWith(".md")) { + // Single markdown file + const result = await this.importSingleMarkdown( + workspaceId, + userId, + file.originalname, + file.buffer.toString("utf-8") + ); + results.push(result); + } else if ( + file.mimetype === "application/zip" || + file.mimetype === "application/x-zip-compressed" || + file.originalname.endsWith(".zip") + ) { + // Zip file containing multiple markdown files + const zipResults = await this.importZipFile(workspaceId, userId, file.buffer); + results.push(...zipResults); + } else { + throw new BadRequestException( + "Invalid file type. Only .md and .zip files are accepted." + ); + } + } catch (error) { + throw new BadRequestException( + `Failed to import file: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + + const imported = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + return { + results, + totalFiles: results.length, + imported, + failed, + }; + } + + /** + * Import a single markdown file + */ + private async importSingleMarkdown( + workspaceId: string, + userId: string, + filename: string, + content: string + ): Promise { + try { + // Parse frontmatter + const parsed = matter(content); + const frontmatter = parsed.data; + const markdownContent = parsed.content.trim(); + + if (!markdownContent) { + return { + filename, + success: false, + error: "Empty content", + }; + } + + // Build CreateEntryDto from frontmatter and content + const createDto: CreateEntryDto = { + title: frontmatter.title || filename.replace(/\.md$/, ""), + content: markdownContent, + summary: frontmatter.summary, + status: this.parseStatus(frontmatter.status), + visibility: this.parseVisibility(frontmatter.visibility), + tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : undefined, + changeNote: "Imported from markdown file", + }; + + // Create the entry + const entry = await this.knowledgeService.create( + workspaceId, + userId, + createDto + ); + + return { + filename, + success: true, + entryId: entry.id, + slug: entry.slug, + title: entry.title, + }; + } catch (error) { + return { + filename, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * Import entries from a zip file + */ + private async importZipFile( + workspaceId: string, + userId: string, + buffer: Buffer + ): Promise { + const results: ImportResult[] = []; + + try { + const zip = new AdmZip(buffer); + const zipEntries = zip.getEntries(); + + for (const zipEntry of zipEntries) { + // Skip directories and non-markdown files + if (zipEntry.isDirectory || !zipEntry.entryName.endsWith(".md")) { + continue; + } + + const content = zipEntry.getData().toString("utf-8"); + const result = await this.importSingleMarkdown( + workspaceId, + userId, + zipEntry.entryName, + content + ); + results.push(result); + } + } catch (error) { + throw new BadRequestException( + `Failed to extract zip file: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + + return results; + } + + /** + * Export entries as a zip file + */ + async exportEntries( + workspaceId: string, + format: ExportFormat, + entryIds?: string[] + ): Promise<{ stream: Readable; filename: string }> { + // Fetch entries + const entries = await this.fetchEntriesForExport(workspaceId, entryIds); + + if (entries.length === 0) { + throw new BadRequestException("No entries found to export"); + } + + // Create archive + const archive = archiver("zip", { + zlib: { level: 9 }, + }); + + // Add entries to archive + for (const entry of entries) { + if (format === "markdown") { + const markdown = this.entryToMarkdown(entry); + const filename = `${entry.slug}.md`; + archive.append(markdown, { name: filename }); + } else { + // JSON format + const json = JSON.stringify(entry, null, 2); + const filename = `${entry.slug}.json`; + archive.append(json, { name: filename }); + } + } + + // Finalize archive + archive.finalize(); + + // Generate filename + const timestamp = new Date().toISOString().split("T")[0]; + const filename = `knowledge-export-${timestamp}.zip`; + + return { + stream: archive, + filename, + }; + } + + /** + * Fetch entries for export + */ + private async fetchEntriesForExport( + workspaceId: string, + entryIds?: string[] + ): Promise { + const where: any = { workspaceId }; + + if (entryIds && entryIds.length > 0) { + where.id = { in: entryIds }; + } + + const entries = await this.prisma.knowledgeEntry.findMany({ + where, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + title: "asc", + }, + }); + + return entries.map((entry) => ({ + id: entry.id, + slug: entry.slug, + title: entry.title, + content: entry.content, + summary: entry.summary, + status: entry.status, + visibility: entry.visibility, + tags: entry.tags.map((et) => et.tag.name), + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + })); + } + + /** + * Convert entry to markdown format with frontmatter + */ + private entryToMarkdown(entry: any): string { + const frontmatter: Record = { + title: entry.title, + status: entry.status, + visibility: entry.visibility, + }; + + if (entry.summary) { + frontmatter.summary = entry.summary; + } + + if (entry.tags && entry.tags.length > 0) { + frontmatter.tags = entry.tags; + } + + frontmatter.createdAt = entry.createdAt.toISOString(); + frontmatter.updatedAt = entry.updatedAt.toISOString(); + + // Build frontmatter string + const frontmatterStr = Object.entries(frontmatter) + .map(([key, value]) => { + if (Array.isArray(value)) { + return `${key}:\n - ${value.join("\n - ")}`; + } + return `${key}: ${value}`; + }) + .join("\n"); + + return `---\n${frontmatterStr}\n---\n\n${entry.content}`; + } + + /** + * Parse status from frontmatter + */ + private parseStatus(value: any): EntryStatus | undefined { + if (!value) return undefined; + const statusMap: Record = { + DRAFT: EntryStatus.DRAFT, + PUBLISHED: EntryStatus.PUBLISHED, + ARCHIVED: EntryStatus.ARCHIVED, + }; + return statusMap[String(value).toUpperCase()]; + } + + /** + * Parse visibility from frontmatter + */ + private parseVisibility(value: any): Visibility | undefined { + if (!value) return undefined; + const visibilityMap: Record = { + PRIVATE: Visibility.PRIVATE, + WORKSPACE: Visibility.WORKSPACE, + PUBLIC: Visibility.PUBLIC, + }; + return visibilityMap[String(value).toUpperCase()]; + } +} diff --git a/apps/api/src/knowledge/services/index.ts b/apps/api/src/knowledge/services/index.ts index fcbde1a..2c3c2e3 100644 --- a/apps/api/src/knowledge/services/index.ts +++ b/apps/api/src/knowledge/services/index.ts @@ -8,3 +8,4 @@ export { LinkSyncService } from "./link-sync.service"; export { SearchService } from "./search.service"; export { GraphService } from "./graph.service"; export { StatsService } from "./stats.service"; +export { ImportExportService } from "./import-export.service"; diff --git a/apps/web/src/app/(authenticated)/knowledge/page.tsx b/apps/web/src/app/(authenticated)/knowledge/page.tsx index 0545a15..f2620cc 100644 --- a/apps/web/src/app/(authenticated)/knowledge/page.tsx +++ b/apps/web/src/app/(authenticated)/knowledge/page.tsx @@ -4,6 +4,7 @@ import { useState, useMemo } from "react"; import { EntryStatus } from "@mosaic/shared"; import { EntryList } from "@/components/knowledge/EntryList"; import { EntryFilters } from "@/components/knowledge/EntryFilters"; +import { ImportExportActions } from "@/components/knowledge"; import { mockEntries, mockTags } from "@/lib/api/knowledge"; import Link from "next/link"; import { Plus } from "lucide-react"; @@ -99,22 +100,35 @@ export default function KnowledgePage() { return (
{/* Header */} -
-
-

Knowledge Base

-

- Documentation, guides, and knowledge entries -

+
+
+
+

Knowledge Base

+

+ Documentation, guides, and knowledge entries +

+
+ + {/* Create button */} + + + Create Entry +
- {/* Create button */} - - - Create Entry - + {/* Import/Export Actions */} +
+ { + // TODO: Refresh the entry list when real API is connected + // For now, this would trigger a refetch of the entries + window.location.reload(); + }} + /> +
{/* Filters */} diff --git a/apps/web/src/components/knowledge/ImportExportActions.tsx b/apps/web/src/components/knowledge/ImportExportActions.tsx new file mode 100644 index 0000000..23cdce7 --- /dev/null +++ b/apps/web/src/components/knowledge/ImportExportActions.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useState, useRef } from "react"; +import { Upload, Download, Loader2, CheckCircle2, XCircle } from "lucide-react"; + +interface ImportResult { + filename: string; + success: boolean; + entryId?: string; + slug?: string; + title?: string; + error?: string; +} + +interface ImportResponse { + success: boolean; + totalFiles: number; + imported: number; + failed: number; + results: ImportResult[]; +} + +interface ImportExportActionsProps { + selectedEntryIds?: string[]; + onImportComplete?: () => void; +} + +export function ImportExportActions({ + selectedEntryIds = [], + onImportComplete, +}: ImportExportActionsProps) { + const [isImporting, setIsImporting] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const [showImportDialog, setShowImportDialog] = useState(false); + const fileInputRef = useRef(null); + + /** + * Handle import file selection + */ + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + /** + * Handle file upload and import + */ + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!file.name.endsWith(".md") && !file.name.endsWith(".zip")) { + alert("Please upload a .md or .zip file"); + return; + } + + setIsImporting(true); + setShowImportDialog(true); + setImportResult(null); + + try { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch("/api/knowledge/import", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Import failed"); + } + + const result: ImportResponse = await response.json(); + setImportResult(result); + + // Notify parent component + if (result.imported > 0 && onImportComplete) { + onImportComplete(); + } + } catch (error) { + console.error("Import error:", error); + alert(error instanceof Error ? error.message : "Failed to import file"); + setShowImportDialog(false); + } finally { + setIsImporting(false); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + /** + * Handle export + */ + const handleExport = async (format: "markdown" | "json" = "markdown") => { + setIsExporting(true); + + try { + // Build query params + const params = new URLSearchParams({ + format, + }); + + // Add selected entry IDs if any + if (selectedEntryIds.length > 0) { + selectedEntryIds.forEach((id) => params.append("entryIds", id)); + } + + const response = await fetch(`/api/knowledge/export?${params.toString()}`, { + method: "GET", + }); + + if (!response.ok) { + throw new Error("Export failed"); + } + + // Get filename from Content-Disposition header + const contentDisposition = response.headers.get("Content-Disposition"); + const filenameMatch = contentDisposition?.match(/filename="(.+)"/); + const filename = filenameMatch?.[1] || `knowledge-export-${format}.zip`; + + // Download file + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error("Export error:", error); + alert("Failed to export entries"); + } finally { + setIsExporting(false); + } + }; + + /** + * Close import dialog + */ + const handleCloseImportDialog = () => { + setShowImportDialog(false); + setImportResult(null); + }; + + return ( + <> + {/* Action Buttons */} +
+ {/* Import Button */} + + + {/* Export Dropdown */} +
+ + + {/* Dropdown Menu */} +
+
+ + + {selectedEntryIds.length > 0 && ( +
+ {selectedEntryIds.length} selected +
+ )} +
+
+
+
+ + {/* Hidden File Input */} + + + {/* Import Result Dialog */} + {showImportDialog && ( +
+
+ {/* Header */} +
+

+ {isImporting ? "Importing..." : "Import Results"} +

+
+ + {/* Content */} +
+ {isImporting && ( +
+ + Processing file... +
+ )} + + {importResult && ( +
+ {/* Summary */} +
+
+
Total Files
+
+ {importResult.totalFiles} +
+
+
+
Imported
+
+ {importResult.imported} +
+
+
+
Failed
+
+ {importResult.failed} +
+
+
+ + {/* Results List */} + {importResult.results.length > 0 && ( +
+

Details

+ {importResult.results.map((result, index) => ( +
+ {result.success ? ( + + ) : ( + + )} +
+
+ {result.title || result.filename} +
+ {result.success ? ( +
+ {result.slug && `Slug: ${result.slug}`} +
+ ) : ( +
{result.error}
+ )} +
+
+ ))} +
+ )} +
+ )} +
+ + {/* Footer */} + {!isImporting && ( +
+ +
+ )} +
+
+ )} + + ); +} diff --git a/apps/web/src/components/knowledge/index.ts b/apps/web/src/components/knowledge/index.ts index 1132045..a6a0929 100644 --- a/apps/web/src/components/knowledge/index.ts +++ b/apps/web/src/components/knowledge/index.ts @@ -6,3 +6,4 @@ export { EntryViewer } from "./EntryViewer"; export { EntryEditor } from "./EntryEditor"; export { EntryMetadata } from "./EntryMetadata"; export { VersionHistory } from "./VersionHistory"; +export { ImportExportActions } from "./ImportExportActions"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5b481..1e5d0de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,12 +126,21 @@ importers: '@swc/core': specifier: ^1.10.18 version: 1.15.11 + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.7 + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 '@types/express': specifier: ^5.0.1 version: 5.0.6 '@types/highlight.js': specifier: ^10.1.0 version: 10.1.0 + '@types/ioredis': + specifier: ^5.0.0 + version: 5.0.0 '@types/node': specifier: ^22.13.4 version: 22.19.7 @@ -1167,6 +1176,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1689,6 +1701,12 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/adm-zip@0.5.7': + resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} + + '@types/archiver@7.0.0': + resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1837,6 +1855,10 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/ioredis@5.0.0': + resolution: {integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==} + deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed. + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1868,6 +1890,9 @@ packages: '@types/react@19.2.10': resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/sanitize-html@2.16.0': resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} @@ -2448,6 +2473,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2772,6 +2801,10 @@ packages: delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3339,6 +3372,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3541,6 +3578,12 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -4108,6 +4151,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -4304,6 +4355,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5813,6 +5867,8 @@ snapshots: optionalDependencies: '@types/node': 22.19.7 + '@ioredis/commands@1.5.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -6259,6 +6315,14 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/adm-zip@0.5.7': + dependencies: + '@types/node': 22.19.7 + + '@types/archiver@7.0.0': + dependencies: + '@types/readdir-glob': 1.1.5 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -6452,6 +6516,12 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/ioredis@5.0.0': + dependencies: + ioredis: 5.9.2 + transitivePeerDependencies: + - supports-color + '@types/json-schema@7.0.15': {} '@types/marked@6.0.0': @@ -6487,6 +6557,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 22.19.7 + '@types/sanitize-html@2.16.0': dependencies: htmlparser2: 8.0.2 @@ -7225,6 +7299,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -7543,6 +7619,8 @@ snapshots: dependencies: robust-predicates: 3.0.2 + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -8094,6 +8172,20 @@ snapshots: internmap@2.0.3: {} + ioredis@5.9.2: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} @@ -8275,6 +8367,10 @@ snapshots: lodash-es@4.17.23: {} + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -8843,6 +8939,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} regexp-to-ast@0.5.0: {} @@ -9135,6 +9237,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@3.10.0: {}