| import { useCallback } from "react"; |
| import { Document } from "../types"; |
| import { findConsecutiveDuplicates } from "../utils/grammarUtils"; |
| import { syncDocument as apiSyncDocument, uploadAndConvert, getConvertStatus, deleteDocument as apiDeleteDocument } from "../lib/apiClient"; |
|
|
| export function useDocumentOperations( |
| documents: Document[], |
| setDocuments: React.Dispatch<React.SetStateAction<Document[]>>, |
| activeDocId: string, |
| setActiveDocId: (id: string) => void, |
| editorText: string, |
| setEditorText: (val: string) => void, |
| isOnline: boolean, |
| syncStatus: string, |
| setSyncStatus: (status: "synced" | "syncing" | "offline" | "error") => void, |
| addLog: (text: string, type: "success" | "info" | "sync" | "error") => void, |
| setToast: (toast: { show: boolean; message: string; timestamp: string }) => void, |
| saveTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>, |
| autoSaveEnabled: boolean |
| ) { |
| const activeDoc = documents.find((d) => d.id === activeDocId) || documents[0]; |
|
|
| const createHistoryCheckpoint = useCallback( |
| (id: string, customLabel?: string) => { |
| setDocuments((prevDocs: Document[]) => |
| prevDocs.map((doc) => { |
| if (doc.id === id) { |
| const currentHistory = doc.history || []; |
| if (currentHistory.length > 0 && currentHistory[0].content === doc.content) { |
| if (customLabel && currentHistory[0].label !== customLabel) { |
| const updated = [...currentHistory]; |
| updated[0] = { ...updated[0], label: customLabel }; |
| return { ...doc, history: updated }; |
| } |
| return doc; |
| } |
| const newCheckpoint = { |
| id: `snap-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`, |
| timestamp: new Date().toISOString(), |
| content: doc.content, |
| version: doc.version, |
| label: customLabel || `Auto Backup (v${doc.version})`, |
| }; |
| return { |
| ...doc, |
| history: [newCheckpoint, ...currentHistory].slice(0, 15), |
| }; |
| } |
| return doc; |
| }) |
| ); |
| }, |
| [setDocuments] |
| ); |
|
|
| const syncDocument = async (id: string, text: string, triggerTeammateContribution = false) => { |
| const currentDoc = documents.find((d) => d.id === id); |
| if (!currentDoc) return; |
| if (!isOnline) { |
| setSyncStatus("offline"); |
| return; |
| } |
| setSyncStatus("syncing"); |
| try { |
| const data = await apiSyncDocument(id, text, currentDoc.version, triggerTeammateContribution, currentDoc.title); |
| setDocuments((prevDocs: Document[]) => |
| prevDocs.map((doc) => { |
| if (doc.id === id) { |
| return { ...doc, content: data.content, version: data.newVersion, lastSaved: data.timestamp, isSynced: true }; |
| } |
| return doc; |
| }) |
| ); |
| if (triggerTeammateContribution) { |
| if (id === activeDocId && data.content !== text) { |
| setEditorText(data.content); |
| } |
| if (data.logs && data.logs.length > 0) { |
| data.logs.forEach((logStr: string) => addLog(logStr, "sync")); |
| } |
| } else { |
| addLog("Document saved to cloud database successfully.", "success"); |
| createHistoryCheckpoint(id, "Auto Saved Checkpoint"); |
| } |
| setSyncStatus("synced"); |
| } catch (err: any) { |
| console.error("Sync error:", err); |
| setSyncStatus("error"); |
| const isFailedToFetch = err.message && err.message.includes("Failed to fetch"); |
| if (isFailedToFetch) { |
| addLog("Sync server is temporarily unreachable. Edits are safely buffered in local cache.", "info"); |
| } else { |
| addLog(`Sync failed: ${err.message || "Connection issue"}`, "error"); |
| } |
| } |
| }; |
|
|
| const handleEditorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| const val = e.target.value; |
| setEditorText(val); |
| setDocuments((prevDocs: Document[]) => |
| prevDocs.map((doc) => { |
| if (doc.id === activeDocId) { |
| return { ...doc, content: val, isSynced: false, lastSaved: new Date().toISOString() }; |
| } |
| return doc; |
| }) |
| ); |
| if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); |
| saveTimeoutRef.current = setTimeout(() => { |
| syncDocument(activeDocId, val, false); |
| }, 2000); |
| }; |
|
|
| const handleCreateDocument = ( |
| e: React.FormEvent, |
| newDocTitle: string, |
| setShowDocModal: (v: boolean) => void, |
| setNewDocTitle: (v: string) => void |
| ) => { |
| e.preventDefault(); |
| if (!newDocTitle.trim()) return; |
| const newId = `doc-${Date.now()}`; |
| const newDoc: Document = { |
| id: newId, |
| title: newDocTitle.endsWith(".md") ? newDocTitle : `${newDocTitle}.md`, |
| content: `# ${newDocTitle}\n\nStart typing or drop raw markdown text right here.`, |
| version: 1, |
| lastSaved: new Date().toISOString(), |
| isSynced: true, |
| }; |
| setDocuments((prev: Document[]) => [...prev, newDoc]); |
| setActiveDocId(newId); |
| setNewDocTitle(""); |
| setShowDocModal(false); |
| addLog(`Created new empty workspace segment "${newDoc.title}".`, "success"); |
| }; |
|
|
| const handleUploadAndConvert = async ( |
| e: React.FormEvent, |
| uploadFile: File | null, |
| uploadLoading: boolean, |
| setUploadLoading: (v: boolean) => void, |
| setUploadError: (v: string | null) => void, |
| setUploadFile: (v: File | null) => void, |
| setNewDocTitle: (v: string) => void, |
| setShowDocModal: (v: boolean) => void, |
| newDocTitle: string, |
| handleCreateDocumentFn: (e: React.FormEvent) => void |
| ) => { |
| e.preventDefault(); |
| if (uploadLoading) return; |
| if (!uploadFile) { |
| if (!newDocTitle.trim()) return; |
| handleCreateDocumentFn(e); |
| return; |
| } |
| setUploadLoading(true); |
| setUploadError(null); |
| addLog(`Uploading "${uploadFile.name}" for conversion...`, "sync"); |
| try { |
| const data = await uploadAndConvert(uploadFile); |
| const jobId = data.job_id; |
| let attempts = 0; |
| const maxAttempts = 60; |
| const poll = async () => { |
| try { |
| const jobData = await getConvertStatus(jobId); |
| if (jobData.status === "completed") { |
| const newId = `doc-${Date.now()}`; |
| const newDoc: Document = { |
| id: newId, |
| title: uploadFile.name, |
| content: jobData.markdown || "", |
| version: 1, |
| lastSaved: new Date().toISOString(), |
| isSynced: true, |
| }; |
| setDocuments((prev: Document[]) => [...prev, newDoc]); |
| setActiveDocId(newId); |
| setUploadFile(null); |
| setNewDocTitle(""); |
| setShowDocModal(false); |
| setUploadLoading(false); |
| addLog(`Successfully converted "${uploadFile.name}" to markdown!`, "success"); |
| } else if (jobData.status === "failed") { |
| throw new Error(jobData.error || "Conversion failed"); |
| } else { |
| attempts++; |
| if (attempts < maxAttempts) { |
| setTimeout(poll, 1000); |
| } else { |
| throw new Error("Conversion timed out after 60 seconds"); |
| } |
| } |
| } catch (pollErr: any) { |
| setUploadError(pollErr.message || "Error polling conversion job"); |
| setUploadLoading(false); |
| addLog(`Conversion failed: ${pollErr.message}`, "error"); |
| } |
| }; |
| setTimeout(poll, 1000); |
| } catch (err: any) { |
| setUploadError(err.message || "File upload error"); |
| setUploadLoading(false); |
| addLog(`File upload failed: ${err.message}`, "error"); |
| } |
| }; |
|
|
| const handleDeleteDocument = (id: string, e: React.MouseEvent) => { |
| e.stopPropagation(); |
| if (documents.length <= 1) { |
| addLog("Cannot delete the last remaining document in workspace.", "error"); |
| return; |
| } |
| const docToDelete = documents.find((d) => d.id === id); |
| const updated = documents.filter((d) => d.id !== id); |
| setDocuments(updated); |
| if (activeDocId === id) { |
| setActiveDocId(updated[0].id); |
| } |
| |
| apiDeleteDocument(id).catch(() => {}); |
| addLog(`Deleted document "${docToDelete?.title || id}".`, "info"); |
| }; |
|
|
| const handleExportMarkdownFile = () => { |
| const blob = new Blob([editorText], { type: "text/markdown;charset=utf-8;" }); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement("a"); |
| link.href = url; |
| link.setAttribute("download", activeDoc.title); |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| addLog(`Exported "${activeDoc.title}" as local markdown segment successfully.`, "success"); |
| }; |
|
|
| const handleCopyHtmlValue = (setCopiedDoc: (v: boolean) => void) => { |
| const mockHtml = editorText |
| .replace(/^#\s+(.*$)/gim, "<h1>$1</h1>") |
| .replace(/^##\s+(.*$)/gim, "<h2>$1</h2>") |
| .replace(/^###\s+(.*$)/gim, "<h3>$1</h3>") |
| .replace(/^\>\s+(.*$)/gim, "<blockquote>$1</blockquote>") |
| .replace(/\*\*(.*)\*\*/gim, "<strong>$1</strong>") |
| .replace(/\*(.*)\*/gim, "<em>$1</em>"); |
| navigator.clipboard.writeText(mockHtml); |
| setCopiedDoc(true); |
| setTimeout(() => setCopiedDoc(false), 2000); |
| addLog("Raw processed HTML segment copied to clipboard bounds.", "success"); |
| }; |
|
|
| const handlePrintPdf = () => { |
| |
| const element = document.getElementById("pdf-export-content"); |
| if (!element) { |
| addLog("PDF export failed: no content to render.", "error"); |
| return; |
| } |
| import("html2pdf.js").then((html2pdfModule) => { |
| const html2pdf = html2pdfModule.default; |
| const filename = activeDoc.title.replace(/\.[^/.]+$/, "") + ".pdf"; |
| html2pdf() |
| .set({ |
| margin: [10, 10, 10, 10], |
| filename, |
| image: { type: "jpeg", quality: 0.95 }, |
| html2canvas: { scale: 2, useCORS: true }, |
| jsPDF: { unit: "mm", format: "a4", orientation: "portrait" }, |
| }) |
| .from(element) |
| .save() |
| .then(() => { |
| addLog(`PDF exported: "${filename}"`, "success"); |
| }); |
| }); |
| }; |
|
|
| const handleFixConsecutiveDuplicate = (duplicateWord: string) => { |
| const escaped = duplicateWord.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); |
| const regex = new RegExp(`\\b(${escaped})\\s+\\1\\b`, "gi"); |
| const updated = editorText.replace(regex, "$1"); |
| setEditorText(updated); |
| setDocuments((prevDocs: Document[]) => |
| prevDocs.map((doc) => { |
| if (doc.id === activeDocId) { |
| return { ...doc, content: updated, isSynced: false, lastSaved: new Date().toISOString() }; |
| } |
| return doc; |
| }) |
| ); |
| syncDocument(activeDocId, updated, false); |
| addLog(`Consecutive duplicate phrase references resolved for word "${duplicateWord}".`, "success"); |
| }; |
|
|
| const handleFixAllConsecutiveDuplicates = () => { |
| const duplicates = findConsecutiveDuplicates(editorText); |
| if (duplicates.length === 0) return; |
| let text = editorText; |
| duplicates.forEach((dup) => { |
| const escaped = dup.word.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); |
| const regex = new RegExp(`\\b(${escaped})\\s+\\1\\b`, "gi"); |
| text = text.replace(regex, "$1"); |
| }); |
| setEditorText(text); |
| setDocuments((prevDocs: Document[]) => |
| prevDocs.map((doc) => { |
| if (doc.id === activeDocId) { |
| return { ...doc, content: text, isSynced: false, lastSaved: new Date().toISOString() }; |
| } |
| return doc; |
| }) |
| ); |
| syncDocument(activeDocId, text, false); |
| addLog(`Batch cleaned all ${duplicates.length} duplicate word sequences.`, "success"); |
| }; |
|
|
| return { |
| activeDoc, |
| createHistoryCheckpoint, |
| syncDocument, |
| handleEditorChange, |
| handleCreateDocument, |
| handleUploadAndConvert, |
| handleDeleteDocument, |
| handleExportMarkdownFile, |
| handleCopyHtmlValue, |
| handlePrintPdf, |
| handleFixConsecutiveDuplicate, |
| handleFixAllConsecutiveDuplicates, |
| }; |
| } |