feat: add cascade deletion for notebooks with delete preview (#471)
* feat: decrease chunking size for maximum ollama compatibility * docs: improve i18n info on Claude.md * feat: add cascade deletion for notebooks with delete preview - Add Notebook.get_delete_preview() to show counts of affected items - Add Notebook.delete(delete_exclusive_sources) for cascade deletion - Always delete notes when notebook is deleted - Allow user to choose: delete or keep exclusive sources - Shared sources are always unlinked but never deleted - Add NotebookDeleteDialog component with radio button options - Add delete-preview API endpoint - Update delete endpoint with delete_exclusive_sources param - Add i18n support for all 5 locales Closes #77 * docs: remove harcoded config settings
This commit is contained in:
parent
f14020d385
commit
4e411e0488
19 changed files with 527 additions and 55 deletions
|
|
@ -57,6 +57,7 @@ User documentation is at @docs/
|
||||||
- **Data Fetching**: TanStack Query (React Query)
|
- **Data Fetching**: TanStack Query (React Query)
|
||||||
- **Styling**: Tailwind CSS + Shadcn/ui
|
- **Styling**: Tailwind CSS + Shadcn/ui
|
||||||
- **Build Tool**: Webpack (via Next.js)
|
- **Build Tool**: Webpack (via Next.js)
|
||||||
|
- **i18n compatible**: All front-end changes must also consider the translation keys
|
||||||
|
|
||||||
### API Backend (`api/` + `open_notebook/`)
|
### API Backend (`api/` + `open_notebook/`)
|
||||||
- **Framework**: FastAPI 0.104+
|
- **Framework**: FastAPI 0.104+
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,6 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Open Notebook API",
|
title="Open Notebook API",
|
||||||
description="API for Open Notebook - Research Assistant",
|
description="API for Open Notebook - Research Assistant",
|
||||||
version="0.2.2",
|
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -422,3 +422,25 @@ class SourceStatusResponse(BaseModel):
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
error: str
|
error: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# Notebook delete cascade models
|
||||||
|
class NotebookDeletePreview(BaseModel):
|
||||||
|
notebook_id: str = Field(..., description="ID of the notebook")
|
||||||
|
notebook_name: str = Field(..., description="Name of the notebook")
|
||||||
|
note_count: int = Field(..., description="Number of notes that will be deleted")
|
||||||
|
exclusive_source_count: int = Field(
|
||||||
|
..., description="Number of sources only in this notebook"
|
||||||
|
)
|
||||||
|
shared_source_count: int = Field(
|
||||||
|
..., description="Number of sources shared with other notebooks"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotebookDeleteResponse(BaseModel):
|
||||||
|
message: str = Field(..., description="Success message")
|
||||||
|
deleted_notes: int = Field(..., description="Number of notes deleted")
|
||||||
|
deleted_sources: int = Field(..., description="Number of exclusive sources deleted")
|
||||||
|
unlinked_sources: int = Field(
|
||||||
|
..., description="Number of sources unlinked from notebook"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ from typing import List, Optional
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from api.models import NotebookCreate, NotebookResponse, NotebookUpdate
|
from api.models import (
|
||||||
|
NotebookCreate,
|
||||||
|
NotebookDeletePreview,
|
||||||
|
NotebookDeleteResponse,
|
||||||
|
NotebookResponse,
|
||||||
|
NotebookUpdate,
|
||||||
|
)
|
||||||
from open_notebook.database.repository import ensure_record_id, repo_query
|
from open_notebook.database.repository import ensure_record_id, repo_query
|
||||||
from open_notebook.domain.notebook import Notebook, Source
|
from open_notebook.domain.notebook import Notebook, Source
|
||||||
from open_notebook.exceptions import InvalidInputError
|
from open_notebook.exceptions import InvalidInputError
|
||||||
|
|
@ -82,6 +88,35 @@ async def create_notebook(notebook: NotebookCreate):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/notebooks/{notebook_id}/delete-preview", response_model=NotebookDeletePreview
|
||||||
|
)
|
||||||
|
async def get_notebook_delete_preview(notebook_id: str):
|
||||||
|
"""Get a preview of what will be deleted when this notebook is deleted."""
|
||||||
|
try:
|
||||||
|
notebook = await Notebook.get(notebook_id)
|
||||||
|
if not notebook:
|
||||||
|
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||||
|
|
||||||
|
preview = await notebook.get_delete_preview()
|
||||||
|
|
||||||
|
return NotebookDeletePreview(
|
||||||
|
notebook_id=str(notebook.id),
|
||||||
|
notebook_name=notebook.name,
|
||||||
|
note_count=preview["note_count"],
|
||||||
|
exclusive_source_count=preview["exclusive_source_count"],
|
||||||
|
shared_source_count=preview["shared_source_count"],
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting delete preview for notebook {notebook_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error fetching notebook deletion preview: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/notebooks/{notebook_id}", response_model=NotebookResponse)
|
@router.get("/notebooks/{notebook_id}", response_model=NotebookResponse)
|
||||||
async def get_notebook(notebook_id: str):
|
async def get_notebook(notebook_id: str):
|
||||||
"""Get a specific notebook by ID."""
|
"""Get a specific notebook by ID."""
|
||||||
|
|
@ -255,17 +290,34 @@ async def remove_source_from_notebook(notebook_id: str, source_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/notebooks/{notebook_id}")
|
@router.delete("/notebooks/{notebook_id}", response_model=NotebookDeleteResponse)
|
||||||
async def delete_notebook(notebook_id: str):
|
async def delete_notebook(
|
||||||
"""Delete a notebook."""
|
notebook_id: str,
|
||||||
|
delete_exclusive_sources: bool = Query(
|
||||||
|
False,
|
||||||
|
description="Whether to delete sources that belong only to this notebook",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a notebook with cascade deletion.
|
||||||
|
|
||||||
|
Always deletes all notes associated with the notebook.
|
||||||
|
If delete_exclusive_sources is True, also deletes sources that belong only
|
||||||
|
to this notebook (not linked to any other notebooks).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
notebook = await Notebook.get(notebook_id)
|
notebook = await Notebook.get(notebook_id)
|
||||||
if not notebook:
|
if not notebook:
|
||||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||||
|
|
||||||
await notebook.delete()
|
result = await notebook.delete(delete_exclusive_sources=delete_exclusive_sources)
|
||||||
|
|
||||||
return {"message": "Notebook deleted successfully"}
|
return NotebookDeleteResponse(
|
||||||
|
message="Notebook deleted successfully",
|
||||||
|
deleted_notes=result["deleted_notes"],
|
||||||
|
deleted_sources=result["deleted_sources"],
|
||||||
|
unlinked_sources=result["unlinked_sources"],
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
|
import { useUpdateNotebook } from '@/lib/hooks/use-notebooks'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { NotebookDeleteDialog } from './NotebookDeleteDialog'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { getDateLocale } from '@/lib/utils/date-locale'
|
import { getDateLocale } from '@/lib/utils/date-locale'
|
||||||
|
|
@ -27,7 +27,6 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const updateNotebook = useUpdateNotebook()
|
const updateNotebook = useUpdateNotebook()
|
||||||
const deleteNotebook = useDeleteNotebook()
|
|
||||||
|
|
||||||
const handleArchiveToggle = (e: React.MouseEvent) => {
|
const handleArchiveToggle = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -37,11 +36,6 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
deleteNotebook.mutate(notebook.id)
|
|
||||||
setShowDeleteDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
router.push(`/notebooks/${encodeURIComponent(notebook.id)}`)
|
router.push(`/notebooks/${encodeURIComponent(notebook.id)}`)
|
||||||
}
|
}
|
||||||
|
|
@ -132,14 +126,11 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ConfirmDialog
|
<NotebookDeleteDialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
onOpenChange={setShowDeleteDialog}
|
onOpenChange={setShowDeleteDialog}
|
||||||
title={t.notebooks.deleteNotebook}
|
notebookId={notebook.id}
|
||||||
description={t.notebooks.deleteNotebookDesc.replace('{name}', notebook.name)}
|
notebookName={notebook.name}
|
||||||
confirmText={t.common.delete}
|
|
||||||
confirmVariant="destructive"
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
|
import { useNotebookDeletePreview, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
interface NotebookDeleteDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
notebookId: string
|
||||||
|
notebookName: string
|
||||||
|
redirectAfterDelete?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotebookDeleteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
notebookId,
|
||||||
|
notebookName,
|
||||||
|
redirectAfterDelete = false,
|
||||||
|
}: NotebookDeleteDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const [sourceAction, setSourceAction] = useState<'keep' | 'delete'>('keep')
|
||||||
|
|
||||||
|
// Reset state when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSourceAction('keep')
|
||||||
|
}
|
||||||
|
}, [open, notebookId])
|
||||||
|
|
||||||
|
// Fetch delete preview when dialog is open
|
||||||
|
const { data: preview, isLoading: isLoadingPreview, error: previewError } = useNotebookDeletePreview(
|
||||||
|
notebookId,
|
||||||
|
open
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteNotebook = useDeleteNotebook()
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
await deleteNotebook.mutateAsync({
|
||||||
|
id: notebookId,
|
||||||
|
deleteExclusiveSources: sourceAction === 'delete',
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
if (redirectAfterDelete) {
|
||||||
|
router.push('/notebooks')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDeleting = deleteNotebook.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t.notebooks.deleteNotebook}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t.notebooks.deleteNotebookDesc.replace('{name}', notebookName)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-3">
|
||||||
|
{isLoadingPreview ? (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
<span>{t.notebooks.deleteNotebookLoading}</span>
|
||||||
|
</div>
|
||||||
|
) : previewError ? (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{t.common.error}: {previewError.message || 'Failed to load preview'}
|
||||||
|
</div>
|
||||||
|
) : preview ? (
|
||||||
|
<>
|
||||||
|
{/* Notes section */}
|
||||||
|
<div className="text-sm">
|
||||||
|
{preview.note_count > 0 ? (
|
||||||
|
<p className="text-destructive font-medium">
|
||||||
|
{t.notebooks.deleteNotebookNotes.replace(
|
||||||
|
'{count}',
|
||||||
|
String(preview.note_count)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">{t.notebooks.deleteNotebookNoNotes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shared sources - always above the line */}
|
||||||
|
{preview.shared_source_count > 0 && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t.notebooks.deleteNotebookSharedSources.replace(
|
||||||
|
'{count}',
|
||||||
|
String(preview.shared_source_count)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No sources message */}
|
||||||
|
{preview.exclusive_source_count === 0 && preview.shared_source_count === 0 && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="text-muted-foreground">{t.notebooks.deleteNotebookNoSources}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Exclusive sources section - below the line with radio buttons */}
|
||||||
|
{preview.exclusive_source_count > 0 && (
|
||||||
|
<div className="pt-3 border-t space-y-3">
|
||||||
|
<p className="text-sm text-destructive font-medium">
|
||||||
|
{t.notebooks.deleteNotebookExclusiveSources.replace(
|
||||||
|
'{count}',
|
||||||
|
String(preview.exclusive_source_count)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<RadioGroup
|
||||||
|
value={sourceAction}
|
||||||
|
onValueChange={(value) => setSourceAction(value as 'keep' | 'delete')}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<RadioGroupItem value="delete" id="delete-sources" />
|
||||||
|
<Label htmlFor="delete-sources" className="text-sm cursor-pointer">
|
||||||
|
{t.notebooks.deleteExclusiveSourcesLabel}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<RadioGroupItem value="keep" id="keep-sources" />
|
||||||
|
<Label htmlFor="keep-sources" className="text-sm cursor-pointer">
|
||||||
|
{t.notebooks.keepExclusiveSourcesLabel}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>{t.common.cancel}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isDeleting || isLoadingPreview}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
{t.common.deleting}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t.common.delete
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,8 @@ import { NotebookResponse } from '@/lib/types/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Archive, ArchiveRestore, Trash2 } from 'lucide-react'
|
import { Archive, ArchiveRestore, Trash2 } from 'lucide-react'
|
||||||
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
|
import { useUpdateNotebook } from '@/lib/hooks/use-notebooks'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { NotebookDeleteDialog } from './NotebookDeleteDialog'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { getDateLocale } from '@/lib/utils/date-locale'
|
import { getDateLocale } from '@/lib/utils/date-locale'
|
||||||
import { InlineEdit } from '@/components/common/InlineEdit'
|
import { InlineEdit } from '@/components/common/InlineEdit'
|
||||||
|
|
@ -22,7 +22,6 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
|
||||||
const updateNotebook = useUpdateNotebook()
|
const updateNotebook = useUpdateNotebook()
|
||||||
const deleteNotebook = useDeleteNotebook()
|
|
||||||
|
|
||||||
const handleUpdateName = async (name: string) => {
|
const handleUpdateName = async (name: string) => {
|
||||||
if (!name || name === notebook.name) return
|
if (!name || name === notebook.name) return
|
||||||
|
|
@ -49,11 +48,6 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
deleteNotebook.mutate(notebook.id)
|
|
||||||
setShowDeleteDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="border-b pb-6">
|
<div className="border-b pb-6">
|
||||||
|
|
@ -122,14 +116,12 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<NotebookDeleteDialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
onOpenChange={setShowDeleteDialog}
|
onOpenChange={setShowDeleteDialog}
|
||||||
title={t.notebooks.deleteNotebook}
|
notebookId={notebook.id}
|
||||||
description={t.notebooks.deleteNotebookDesc.replace('{name}', notebook.name)}
|
notebookName={notebook.name}
|
||||||
confirmText={t.common.deleteForever}
|
redirectAfterDelete
|
||||||
confirmVariant="destructive"
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import { NotebookResponse, CreateNotebookRequest, UpdateNotebookRequest } from '@/lib/types/api'
|
import {
|
||||||
|
NotebookResponse,
|
||||||
|
CreateNotebookRequest,
|
||||||
|
UpdateNotebookRequest,
|
||||||
|
NotebookDeletePreview,
|
||||||
|
NotebookDeleteResponse,
|
||||||
|
} from '@/lib/types/api'
|
||||||
|
|
||||||
export const notebooksApi = {
|
export const notebooksApi = {
|
||||||
list: async (params?: { archived?: boolean; order_by?: string }) => {
|
list: async (params?: { archived?: boolean; order_by?: string }) => {
|
||||||
|
|
@ -22,8 +28,18 @@ export const notebooksApi = {
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string) => {
|
deletePreview: async (id: string) => {
|
||||||
await apiClient.delete(`/notebooks/${id}`)
|
const response = await apiClient.get<NotebookDeletePreview>(
|
||||||
|
`/notebooks/${id}/delete-preview`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string, deleteExclusiveSources: boolean = false) => {
|
||||||
|
const response = await apiClient.delete<NotebookDeleteResponse>(`/notebooks/${id}`, {
|
||||||
|
params: { delete_exclusive_sources: deleteExclusiveSources },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
addSource: async (notebookId: string, sourceId: string) => {
|
addSource: async (notebookId: string, sourceId: string) => {
|
||||||
|
|
@ -34,5 +50,5 @@ export const notebooksApi = {
|
||||||
removeSource: async (notebookId: string, sourceId: string) => {
|
removeSource: async (notebookId: string, sourceId: string) => {
|
||||||
const response = await apiClient.delete(`/notebooks/${notebookId}/sources/${sourceId}`)
|
const response = await apiClient.delete(`/notebooks/${notebookId}/sources/${sourceId}`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -71,15 +71,31 @@ export function useUpdateNotebook() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useNotebookDeletePreview(id: string, enabled: boolean = false) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...QUERY_KEYS.notebook(id), 'delete-preview'],
|
||||||
|
queryFn: () => notebooksApi.deletePreview(id),
|
||||||
|
enabled: !!id && enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteNotebook() {
|
export function useDeleteNotebook() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => notebooksApi.delete(id),
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
deleteExclusiveSources = false,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
deleteExclusiveSources?: boolean
|
||||||
|
}) => notebooksApi.delete(id, deleteExclusiveSources),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })
|
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })
|
||||||
|
// Also invalidate sources since some may have been deleted
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sources'] })
|
||||||
toast({
|
toast({
|
||||||
title: t.common.success,
|
title: t.common.success,
|
||||||
description: t.notebooks.deleteSuccess,
|
description: t.notebooks.deleteSuccess,
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,15 @@ export const enUS = {
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
unarchive: "Unarchive",
|
unarchive: "Unarchive",
|
||||||
deleteNotebook: "Delete Notebook",
|
deleteNotebook: "Delete Notebook",
|
||||||
deleteNotebookDesc: "Are you sure you want to delete this notebook? This action cannot be undone.",
|
deleteNotebookDesc: "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||||
|
deleteNotebookLoading: "Loading deletion preview...",
|
||||||
|
deleteNotebookNotes: "{count} note(s) will be permanently deleted.",
|
||||||
|
deleteNotebookNoNotes: "No notes to delete.",
|
||||||
|
deleteNotebookExclusiveSources: "{count} source(s) exist only in this notebook.",
|
||||||
|
deleteNotebookSharedSources: "{count} source(s) are shared with other notebooks and will be unlinked.",
|
||||||
|
deleteNotebookNoSources: "No sources in this notebook.",
|
||||||
|
deleteExclusiveSourcesLabel: "Delete exclusive sources",
|
||||||
|
keepExclusiveSourcesLabel: "Unlink and keep them",
|
||||||
activeNotebooks: "Active Notebooks",
|
activeNotebooks: "Active Notebooks",
|
||||||
archivedNotebooks: "Archived Notebooks",
|
archivedNotebooks: "Archived Notebooks",
|
||||||
emptyDescription: "Start by creating your first notebook to organize your research.",
|
emptyDescription: "Start by creating your first notebook to organize your research.",
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,15 @@ export const jaJP = {
|
||||||
archive: "アーカイブ",
|
archive: "アーカイブ",
|
||||||
unarchive: "アーカイブ解除",
|
unarchive: "アーカイブ解除",
|
||||||
deleteNotebook: "ノートブックを削除",
|
deleteNotebook: "ノートブックを削除",
|
||||||
deleteNotebookDesc: "このノートブックを削除しますか?この操作は元に戻せません。",
|
deleteNotebookDesc: "\"{name}\" を削除しますか?この操作は元に戻せません。",
|
||||||
|
deleteNotebookLoading: "削除プレビューを読み込み中...",
|
||||||
|
deleteNotebookNotes: "{count}件のノートが完全に削除されます。",
|
||||||
|
deleteNotebookNoNotes: "削除するノートはありません。",
|
||||||
|
deleteNotebookExclusiveSources: "{count}件のソースはこのノートブックにのみ存在します。",
|
||||||
|
deleteNotebookSharedSources: "{count}件のソースは他のノートブックと共有されており、リンクが解除されます。",
|
||||||
|
deleteNotebookNoSources: "このノートブックにソースはありません。",
|
||||||
|
deleteExclusiveSourcesLabel: "専用ソースを削除",
|
||||||
|
keepExclusiveSourcesLabel: "リンク解除して保持",
|
||||||
activeNotebooks: "アクティブなノートブック",
|
activeNotebooks: "アクティブなノートブック",
|
||||||
archivedNotebooks: "アーカイブ済みノートブック",
|
archivedNotebooks: "アーカイブ済みノートブック",
|
||||||
emptyDescription: "最初のノートブックを作成してリサーチを整理しましょう。",
|
emptyDescription: "最初のノートブックを作成してリサーチを整理しましょう。",
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,15 @@ export const ptBR = {
|
||||||
archive: "Arquivar",
|
archive: "Arquivar",
|
||||||
unarchive: "Desarquivar",
|
unarchive: "Desarquivar",
|
||||||
deleteNotebook: "Excluir Caderno",
|
deleteNotebook: "Excluir Caderno",
|
||||||
deleteNotebookDesc: "Tem certeza que deseja excluir este caderno? Esta ação não pode ser desfeita.",
|
deleteNotebookDesc: "Tem certeza que deseja excluir \"{name}\"? Esta ação não pode ser desfeita.",
|
||||||
|
deleteNotebookLoading: "Carregando prévia da exclusão...",
|
||||||
|
deleteNotebookNotes: "{count} nota(s) serão permanentemente excluídas.",
|
||||||
|
deleteNotebookNoNotes: "Nenhuma nota para excluir.",
|
||||||
|
deleteNotebookExclusiveSources: "{count} fonte(s) existem apenas neste caderno.",
|
||||||
|
deleteNotebookSharedSources: "{count} fonte(s) são compartilhadas com outros cadernos e serão desvinculadas.",
|
||||||
|
deleteNotebookNoSources: "Nenhuma fonte neste caderno.",
|
||||||
|
deleteExclusiveSourcesLabel: "Excluir fontes exclusivas",
|
||||||
|
keepExclusiveSourcesLabel: "Desvincular e manter",
|
||||||
activeNotebooks: "Cadernos Ativos",
|
activeNotebooks: "Cadernos Ativos",
|
||||||
archivedNotebooks: "Cadernos Arquivados",
|
archivedNotebooks: "Cadernos Arquivados",
|
||||||
emptyDescription: "Comece criando seu primeiro caderno para organizar sua pesquisa.",
|
emptyDescription: "Comece criando seu primeiro caderno para organizar sua pesquisa.",
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,15 @@ export const zhCN = {
|
||||||
archive: "归档",
|
archive: "归档",
|
||||||
unarchive: "取消归档",
|
unarchive: "取消归档",
|
||||||
deleteNotebook: "删除笔记本",
|
deleteNotebook: "删除笔记本",
|
||||||
deleteNotebookDesc: "您确定要删除此笔记本吗?此操作无法撤销。",
|
deleteNotebookDesc: "您确定要删除 \"{name}\" 吗?此操作无法撤销。",
|
||||||
|
deleteNotebookLoading: "正在加载删除预览...",
|
||||||
|
deleteNotebookNotes: "{count} 个笔记将被永久删除。",
|
||||||
|
deleteNotebookNoNotes: "没有要删除的笔记。",
|
||||||
|
deleteNotebookExclusiveSources: "{count} 个来源仅存在于此笔记本中。",
|
||||||
|
deleteNotebookSharedSources: "{count} 个来源与其他笔记本共享,将被取消关联。",
|
||||||
|
deleteNotebookNoSources: "此笔记本中没有来源。",
|
||||||
|
deleteExclusiveSourcesLabel: "删除专属来源",
|
||||||
|
keepExclusiveSourcesLabel: "取消关联并保留",
|
||||||
activeNotebooks: "活动的笔记本",
|
activeNotebooks: "活动的笔记本",
|
||||||
archivedNotebooks: "归档的笔记本",
|
archivedNotebooks: "归档的笔记本",
|
||||||
emptyDescription: "从创建您的第一个笔记本开始,组织您的研究。",
|
emptyDescription: "从创建您的第一个笔记本开始,组织您的研究。",
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,15 @@ export const zhTW = {
|
||||||
archive: "封存",
|
archive: "封存",
|
||||||
unarchive: "取消封存",
|
unarchive: "取消封存",
|
||||||
deleteNotebook: "刪除筆記本",
|
deleteNotebook: "刪除筆記本",
|
||||||
deleteNotebookDesc: "您確定要刪除此筆記本嗎?此操作無法復原。",
|
deleteNotebookDesc: "您確定要刪除 \"{name}\" 嗎?此操作無法復原。",
|
||||||
|
deleteNotebookLoading: "正在載入刪除預覽...",
|
||||||
|
deleteNotebookNotes: "{count} 個筆記將被永久刪除。",
|
||||||
|
deleteNotebookNoNotes: "沒有要刪除的筆記。",
|
||||||
|
deleteNotebookExclusiveSources: "{count} 個來源僅存在於此筆記本中。",
|
||||||
|
deleteNotebookSharedSources: "{count} 個來源與其他筆記本共享,將被取消關聯。",
|
||||||
|
deleteNotebookNoSources: "此筆記本中沒有來源。",
|
||||||
|
deleteExclusiveSourcesLabel: "刪除專屬來源",
|
||||||
|
keepExclusiveSourcesLabel: "取消關聯並保留",
|
||||||
activeNotebooks: "活動中的筆記本",
|
activeNotebooks: "活動中的筆記本",
|
||||||
archivedNotebooks: "封存的筆記本",
|
archivedNotebooks: "封存的筆記本",
|
||||||
emptyDescription: "從新增您的第一個筆記本開始,組織您的研究。",
|
emptyDescription: "從新增您的第一個筆記本開始,組織您的研究。",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,21 @@ export interface UpdateNotebookRequest {
|
||||||
archived?: boolean
|
archived?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotebookDeletePreview {
|
||||||
|
notebook_id: string
|
||||||
|
notebook_name: string
|
||||||
|
note_count: number
|
||||||
|
exclusive_source_count: number
|
||||||
|
shared_source_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotebookDeleteResponse {
|
||||||
|
message: string
|
||||||
|
deleted_notes: number
|
||||||
|
deleted_sources: number
|
||||||
|
unlinked_sources: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateNoteRequest {
|
export interface CreateNoteRequest {
|
||||||
title?: string
|
title?: string
|
||||||
content: string
|
content: string
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ Two base classes support different persistence patterns: **ObjectModel** (mutabl
|
||||||
### notebook.py
|
### notebook.py
|
||||||
- **Notebook**: Research project container
|
- **Notebook**: Research project container
|
||||||
- `get_sources()`, `get_notes()`, `get_chat_sessions()`: Navigate relationships
|
- `get_sources()`, `get_notes()`, `get_chat_sessions()`: Navigate relationships
|
||||||
|
- `get_delete_preview()`: Returns counts of notes, exclusive sources, and shared sources that would be affected by deletion
|
||||||
|
- `delete(delete_exclusive_sources)`: Cascade deletion - always deletes notes, optionally deletes exclusive sources, always unlinks all sources
|
||||||
|
|
||||||
- **Source**: Content item (file/URL)
|
- **Source**: Content item (file/URL)
|
||||||
- `vectorize()`: Submit async embedding job (returns command_id, fire-and-forget)
|
- `vectorize()`: Submit async embedding job (returns command_id, fire-and-forget)
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,150 @@ class Notebook(ObjectModel):
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise DatabaseOperationError(e)
|
raise DatabaseOperationError(e)
|
||||||
|
|
||||||
|
async def get_delete_preview(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get counts of items that would be affected by deleting this notebook.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
- note_count: Number of notes that will be deleted
|
||||||
|
- exclusive_source_count: Sources only in this notebook (can be deleted)
|
||||||
|
- shared_source_count: Sources in other notebooks (will be unlinked only)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
notebook_id = ensure_record_id(self.id)
|
||||||
|
|
||||||
|
# Count notes
|
||||||
|
note_result = await repo_query(
|
||||||
|
"SELECT count() as count FROM artifact WHERE out = $notebook_id GROUP ALL",
|
||||||
|
{"notebook_id": notebook_id},
|
||||||
|
)
|
||||||
|
note_count = note_result[0]["count"] if note_result else 0
|
||||||
|
|
||||||
|
# Get sources with count of references to OTHER notebooks
|
||||||
|
# If assigned_others = 0, source is exclusive to this notebook
|
||||||
|
# If assigned_others > 0, source is shared with other notebooks
|
||||||
|
source_counts = await repo_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
count(->reference[WHERE out != $notebook_id].out) as assigned_others
|
||||||
|
FROM (SELECT VALUE <-reference.in AS sources FROM $notebook_id)[0]
|
||||||
|
""",
|
||||||
|
{"notebook_id": notebook_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
exclusive_count = 0
|
||||||
|
shared_count = 0
|
||||||
|
for src in source_counts:
|
||||||
|
if src.get("assigned_others", 0) == 0:
|
||||||
|
exclusive_count += 1
|
||||||
|
else:
|
||||||
|
shared_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"note_count": note_count,
|
||||||
|
"exclusive_source_count": exclusive_count,
|
||||||
|
"shared_source_count": shared_count,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting delete preview for notebook {self.id}: {e}")
|
||||||
|
logger.exception(e)
|
||||||
|
raise DatabaseOperationError(e)
|
||||||
|
|
||||||
|
async def delete(self, delete_exclusive_sources: bool = False) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Delete notebook with cascade deletion of notes and optional source deletion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delete_exclusive_sources: If True, also delete sources that belong
|
||||||
|
only to this notebook. Default is False.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with counts: deleted_notes, deleted_sources, unlinked_sources
|
||||||
|
"""
|
||||||
|
if self.id is None:
|
||||||
|
raise InvalidInputError("Cannot delete notebook without an ID")
|
||||||
|
|
||||||
|
try:
|
||||||
|
notebook_id = ensure_record_id(self.id)
|
||||||
|
deleted_notes = 0
|
||||||
|
deleted_sources = 0
|
||||||
|
unlinked_sources = 0
|
||||||
|
|
||||||
|
# 1. Get and delete all notes linked to this notebook
|
||||||
|
notes = await self.get_notes()
|
||||||
|
for note in notes:
|
||||||
|
await note.delete()
|
||||||
|
deleted_notes += 1
|
||||||
|
logger.info(f"Deleted {deleted_notes} notes for notebook {self.id}")
|
||||||
|
|
||||||
|
# Delete artifact relationships
|
||||||
|
await repo_query(
|
||||||
|
"DELETE artifact WHERE out = $notebook_id",
|
||||||
|
{"notebook_id": notebook_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Handle sources
|
||||||
|
if delete_exclusive_sources:
|
||||||
|
# Find sources with count of references to OTHER notebooks
|
||||||
|
# If assigned_others = 0, source is exclusive to this notebook
|
||||||
|
source_counts = await repo_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
count(->reference[WHERE out != $notebook_id].out) as assigned_others
|
||||||
|
FROM (SELECT VALUE <-reference.in AS sources FROM $notebook_id)[0]
|
||||||
|
""",
|
||||||
|
{"notebook_id": notebook_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
for src in source_counts:
|
||||||
|
source_id = src.get("id")
|
||||||
|
if source_id and src.get("assigned_others", 0) == 0:
|
||||||
|
# Exclusive source - delete it
|
||||||
|
try:
|
||||||
|
source = await Source.get(str(source_id))
|
||||||
|
await source.delete()
|
||||||
|
deleted_sources += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to delete exclusive source {source_id}: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
unlinked_sources += 1
|
||||||
|
else:
|
||||||
|
# Just count sources that will be unlinked
|
||||||
|
source_result = await repo_query(
|
||||||
|
"SELECT count() as count FROM reference WHERE out = $notebook_id GROUP ALL",
|
||||||
|
{"notebook_id": notebook_id},
|
||||||
|
)
|
||||||
|
unlinked_sources = source_result[0]["count"] if source_result else 0
|
||||||
|
|
||||||
|
# Delete reference relationships (unlink all sources)
|
||||||
|
await repo_query(
|
||||||
|
"DELETE reference WHERE out = $notebook_id",
|
||||||
|
{"notebook_id": notebook_id},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Unlinked {unlinked_sources} sources, deleted {deleted_sources} "
|
||||||
|
f"exclusive sources for notebook {self.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Delete the notebook record itself
|
||||||
|
await super().delete()
|
||||||
|
logger.info(f"Deleted notebook {self.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"deleted_notes": deleted_notes,
|
||||||
|
"deleted_sources": deleted_sources,
|
||||||
|
"unlinked_sources": unlinked_sources,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting notebook {self.id}: {e}")
|
||||||
|
logger.exception(e)
|
||||||
|
raise DatabaseOperationError(f"Failed to delete notebook: {e}")
|
||||||
|
|
||||||
|
|
||||||
class Asset(BaseModel):
|
class Asset(BaseModel):
|
||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ Each utility is stateless and can be imported independently.
|
||||||
|
|
||||||
### chunking.py
|
### chunking.py
|
||||||
- **ContentType**: Enum (HTML, MARKDOWN, PLAIN)
|
- **ContentType**: Enum (HTML, MARKDOWN, PLAIN)
|
||||||
- **CHUNK_SIZE**: 1500 characters (constant)
|
- **CHUNK_SIZE**: constant
|
||||||
- **CHUNK_OVERLAP**: 225 characters (15% overlap)
|
- **CHUNK_OVERLAP**: constant
|
||||||
- **detect_content_type_from_extension(file_path)**: Detect type from file extension
|
- **detect_content_type_from_extension(file_path)**: Detect type from file extension
|
||||||
- **detect_content_type_from_heuristics(text)**: Detect type from content patterns (returns type + confidence)
|
- **detect_content_type_from_heuristics(text)**: Detect type from content patterns (returns type + confidence)
|
||||||
- **detect_content_type(text, file_path)**: Combined detection (extension primary, heuristics fallback)
|
- **detect_content_type(text, file_path)**: Combined detection (extension primary, heuristics fallback)
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ from langchain_text_splitters import (
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
CHUNK_SIZE = 1500 # characters
|
CHUNK_SIZE = 1200 # characters
|
||||||
CHUNK_OVERLAP = 225 # 15% of chunk size
|
CHUNK_OVERLAP = 180 # 15% of chunk size
|
||||||
HIGH_CONFIDENCE_THRESHOLD = 0.8 # Threshold for heuristics to override extension
|
HIGH_CONFIDENCE_THRESHOLD = 0.8 # Threshold for heuristics to override extension
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -73,7 +73,9 @@ _EXTENSION_TO_CONTENT_TYPE = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def detect_content_type_from_extension(file_path: Optional[str]) -> Optional[ContentType]:
|
def detect_content_type_from_extension(
|
||||||
|
file_path: Optional[str],
|
||||||
|
) -> Optional[ContentType]:
|
||||||
"""
|
"""
|
||||||
Detect content type from file extension.
|
Detect content type from file extension.
|
||||||
|
|
||||||
|
|
@ -220,9 +222,7 @@ def _calculate_markdown_score(text: str) -> float:
|
||||||
return min(score, 1.0)
|
return min(score, 1.0)
|
||||||
|
|
||||||
|
|
||||||
def detect_content_type(
|
def detect_content_type(text: str, file_path: Optional[str] = None) -> ContentType:
|
||||||
text: str, file_path: Optional[str] = None
|
|
||||||
) -> ContentType:
|
|
||||||
"""
|
"""
|
||||||
Detect content type using file extension (primary) and heuristics (fallback).
|
Detect content type using file extension (primary) and heuristics (fallback).
|
||||||
|
|
||||||
|
|
@ -352,12 +352,18 @@ def chunk_text(
|
||||||
splitter = _get_html_splitter()
|
splitter = _get_html_splitter()
|
||||||
# HTML splitter returns Document objects
|
# HTML splitter returns Document objects
|
||||||
docs = splitter.split_text(text)
|
docs = splitter.split_text(text)
|
||||||
chunks = [doc.page_content if hasattr(doc, "page_content") else str(doc) for doc in docs]
|
chunks = [
|
||||||
|
doc.page_content if hasattr(doc, "page_content") else str(doc)
|
||||||
|
for doc in docs
|
||||||
|
]
|
||||||
elif content_type == ContentType.MARKDOWN:
|
elif content_type == ContentType.MARKDOWN:
|
||||||
splitter = _get_markdown_splitter()
|
splitter = _get_markdown_splitter()
|
||||||
# Markdown splitter returns Document objects
|
# Markdown splitter returns Document objects
|
||||||
docs = splitter.split_text(text)
|
docs = splitter.split_text(text)
|
||||||
chunks = [doc.page_content if hasattr(doc, "page_content") else str(doc) for doc in docs]
|
chunks = [
|
||||||
|
doc.page_content if hasattr(doc, "page_content") else str(doc)
|
||||||
|
for doc in docs
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
# Plain text - use recursive splitter directly
|
# Plain text - use recursive splitter directly
|
||||||
splitter = _get_plain_splitter()
|
splitter = _get_plain_splitter()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue