diff --git a/CLAUDE.md b/CLAUDE.md index 88da333..d05329a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ User documentation is at @docs/ - **Data Fetching**: TanStack Query (React Query) - **Styling**: Tailwind CSS + Shadcn/ui - **Build Tool**: Webpack (via Next.js) +- **i18n compatible**: All front-end changes must also consider the translation keys ### API Backend (`api/` + `open_notebook/`) - **Framework**: FastAPI 0.104+ diff --git a/api/main.py b/api/main.py index b48db0c..bdc8eb9 100644 --- a/api/main.py +++ b/api/main.py @@ -85,7 +85,6 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Open Notebook API", description="API for Open Notebook - Research Assistant", - version="0.2.2", lifespan=lifespan, ) diff --git a/api/models.py b/api/models.py index 79c4520..0c96543 100644 --- a/api/models.py +++ b/api/models.py @@ -422,3 +422,25 @@ class SourceStatusResponse(BaseModel): class ErrorResponse(BaseModel): error: 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" + ) diff --git a/api/routers/notebooks.py b/api/routers/notebooks.py index 563ce5e..8a891c5 100644 --- a/api/routers/notebooks.py +++ b/api/routers/notebooks.py @@ -3,7 +3,13 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException, Query 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.domain.notebook import Notebook, Source 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) async def get_notebook(notebook_id: str): """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}") -async def delete_notebook(notebook_id: str): - """Delete a notebook.""" +@router.delete("/notebooks/{notebook_id}", response_model=NotebookDeleteResponse) +async def delete_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: notebook = await Notebook.get(notebook_id) if not notebook: 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: raise except Exception as e: diff --git a/frontend/src/app/(dashboard)/notebooks/components/NotebookCard.tsx b/frontend/src/app/(dashboard)/notebooks/components/NotebookCard.tsx index 1cfaee4..aa25e2c 100644 --- a/frontend/src/app/(dashboard)/notebooks/components/NotebookCard.tsx +++ b/frontend/src/app/(dashboard)/notebooks/components/NotebookCard.tsx @@ -13,8 +13,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks' -import { ConfirmDialog } from '@/components/common/ConfirmDialog' +import { useUpdateNotebook } from '@/lib/hooks/use-notebooks' +import { NotebookDeleteDialog } from './NotebookDeleteDialog' import { useState } from 'react' import { useTranslation } from '@/lib/hooks/use-translation' import { getDateLocale } from '@/lib/utils/date-locale' @@ -27,7 +27,6 @@ export function NotebookCard({ notebook }: NotebookCardProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const router = useRouter() const updateNotebook = useUpdateNotebook() - const deleteNotebook = useDeleteNotebook() const handleArchiveToggle = (e: React.MouseEvent) => { e.stopPropagation() @@ -37,11 +36,6 @@ export function NotebookCard({ notebook }: NotebookCardProps) { }) } - const handleDelete = () => { - deleteNotebook.mutate(notebook.id) - setShowDeleteDialog(false) - } - const handleCardClick = () => { router.push(`/notebooks/${encodeURIComponent(notebook.id)}`) } @@ -132,14 +126,11 @@ export function NotebookCard({ notebook }: NotebookCardProps) { - ) diff --git a/frontend/src/app/(dashboard)/notebooks/components/NotebookDeleteDialog.tsx b/frontend/src/app/(dashboard)/notebooks/components/NotebookDeleteDialog.tsx new file mode 100644 index 0000000..202f7ed --- /dev/null +++ b/frontend/src/app/(dashboard)/notebooks/components/NotebookDeleteDialog.tsx @@ -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 ( + + + + {t.notebooks.deleteNotebook} + + {t.notebooks.deleteNotebookDesc.replace('{name}', notebookName)} + + + +
+ {isLoadingPreview ? ( +
+ + {t.notebooks.deleteNotebookLoading} +
+ ) : previewError ? ( +
+ {t.common.error}: {previewError.message || 'Failed to load preview'} +
+ ) : preview ? ( + <> + {/* Notes section */} +
+ {preview.note_count > 0 ? ( +

+ {t.notebooks.deleteNotebookNotes.replace( + '{count}', + String(preview.note_count) + )} +

+ ) : ( +

{t.notebooks.deleteNotebookNoNotes}

+ )} +
+ + {/* Shared sources - always above the line */} + {preview.shared_source_count > 0 && ( +
+

+ {t.notebooks.deleteNotebookSharedSources.replace( + '{count}', + String(preview.shared_source_count) + )} +

+
+ )} + + {/* No sources message */} + {preview.exclusive_source_count === 0 && preview.shared_source_count === 0 && ( +
+

{t.notebooks.deleteNotebookNoSources}

+
+ )} + + {/* Exclusive sources section - below the line with radio buttons */} + {preview.exclusive_source_count > 0 && ( +
+

+ {t.notebooks.deleteNotebookExclusiveSources.replace( + '{count}', + String(preview.exclusive_source_count) + )} +

+ setSourceAction(value as 'keep' | 'delete')} + disabled={isDeleting} + > +
+ + +
+
+ + +
+
+
+ )} + + ) : null} +
+ + + {t.common.cancel} + + {isDeleting ? ( + <> + + {t.common.deleting} + + ) : ( + t.common.delete + )} + + +
+
+ ) +} diff --git a/frontend/src/app/(dashboard)/notebooks/components/NotebookHeader.tsx b/frontend/src/app/(dashboard)/notebooks/components/NotebookHeader.tsx index 9327768..76e36e6 100644 --- a/frontend/src/app/(dashboard)/notebooks/components/NotebookHeader.tsx +++ b/frontend/src/app/(dashboard)/notebooks/components/NotebookHeader.tsx @@ -5,8 +5,8 @@ import { NotebookResponse } from '@/lib/types/api' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Archive, ArchiveRestore, Trash2 } from 'lucide-react' -import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks' -import { ConfirmDialog } from '@/components/common/ConfirmDialog' +import { useUpdateNotebook } from '@/lib/hooks/use-notebooks' +import { NotebookDeleteDialog } from './NotebookDeleteDialog' import { formatDistanceToNow } from 'date-fns' import { getDateLocale } from '@/lib/utils/date-locale' import { InlineEdit } from '@/components/common/InlineEdit' @@ -22,7 +22,6 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const updateNotebook = useUpdateNotebook() - const deleteNotebook = useDeleteNotebook() const handleUpdateName = async (name: string) => { if (!name || name === notebook.name) return @@ -49,11 +48,6 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) { }) } - const handleDelete = () => { - deleteNotebook.mutate(notebook.id) - setShowDeleteDialog(false) - } - return ( <>
@@ -122,14 +116,12 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
- ) diff --git a/frontend/src/lib/api/notebooks.ts b/frontend/src/lib/api/notebooks.ts index 861f5c1..6e0a40d 100644 --- a/frontend/src/lib/api/notebooks.ts +++ b/frontend/src/lib/api/notebooks.ts @@ -1,5 +1,11 @@ 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 = { list: async (params?: { archived?: boolean; order_by?: string }) => { @@ -22,8 +28,18 @@ export const notebooksApi = { return response.data }, - delete: async (id: string) => { - await apiClient.delete(`/notebooks/${id}`) + deletePreview: async (id: string) => { + const response = await apiClient.get( + `/notebooks/${id}/delete-preview` + ) + return response.data + }, + + delete: async (id: string, deleteExclusiveSources: boolean = false) => { + const response = await apiClient.delete(`/notebooks/${id}`, { + params: { delete_exclusive_sources: deleteExclusiveSources }, + }) + return response.data }, addSource: async (notebookId: string, sourceId: string) => { @@ -34,5 +50,5 @@ export const notebooksApi = { removeSource: async (notebookId: string, sourceId: string) => { const response = await apiClient.delete(`/notebooks/${notebookId}/sources/${sourceId}`) return response.data - } + }, } \ No newline at end of file diff --git a/frontend/src/lib/hooks/use-notebooks.ts b/frontend/src/lib/hooks/use-notebooks.ts index 67b6086..9315c32 100644 --- a/frontend/src/lib/hooks/use-notebooks.ts +++ b/frontend/src/lib/hooks/use-notebooks.ts @@ -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() { const queryClient = useQueryClient() const { toast } = useToast() const { t } = useTranslation() return useMutation({ - mutationFn: (id: string) => notebooksApi.delete(id), + mutationFn: ({ + id, + deleteExclusiveSources = false, + }: { + id: string + deleteExclusiveSources?: boolean + }) => notebooksApi.delete(id, deleteExclusiveSources), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks }) + // Also invalidate sources since some may have been deleted + queryClient.invalidateQueries({ queryKey: ['sources'] }) toast({ title: t.common.success, description: t.notebooks.deleteSuccess, diff --git a/frontend/src/lib/locales/en-US/index.ts b/frontend/src/lib/locales/en-US/index.ts index 16447ce..30912ce 100644 --- a/frontend/src/lib/locales/en-US/index.ts +++ b/frontend/src/lib/locales/en-US/index.ts @@ -236,7 +236,15 @@ export const enUS = { archive: "Archive", unarchive: "Unarchive", 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", archivedNotebooks: "Archived Notebooks", emptyDescription: "Start by creating your first notebook to organize your research.", diff --git a/frontend/src/lib/locales/ja-JP/index.ts b/frontend/src/lib/locales/ja-JP/index.ts index 6b62044..b0bfafa 100644 --- a/frontend/src/lib/locales/ja-JP/index.ts +++ b/frontend/src/lib/locales/ja-JP/index.ts @@ -236,7 +236,15 @@ export const jaJP = { archive: "アーカイブ", unarchive: "アーカイブ解除", deleteNotebook: "ノートブックを削除", - deleteNotebookDesc: "このノートブックを削除しますか?この操作は元に戻せません。", + deleteNotebookDesc: "\"{name}\" を削除しますか?この操作は元に戻せません。", + deleteNotebookLoading: "削除プレビューを読み込み中...", + deleteNotebookNotes: "{count}件のノートが完全に削除されます。", + deleteNotebookNoNotes: "削除するノートはありません。", + deleteNotebookExclusiveSources: "{count}件のソースはこのノートブックにのみ存在します。", + deleteNotebookSharedSources: "{count}件のソースは他のノートブックと共有されており、リンクが解除されます。", + deleteNotebookNoSources: "このノートブックにソースはありません。", + deleteExclusiveSourcesLabel: "専用ソースを削除", + keepExclusiveSourcesLabel: "リンク解除して保持", activeNotebooks: "アクティブなノートブック", archivedNotebooks: "アーカイブ済みノートブック", emptyDescription: "最初のノートブックを作成してリサーチを整理しましょう。", diff --git a/frontend/src/lib/locales/pt-BR/index.ts b/frontend/src/lib/locales/pt-BR/index.ts index da244fe..2c7e56c 100644 --- a/frontend/src/lib/locales/pt-BR/index.ts +++ b/frontend/src/lib/locales/pt-BR/index.ts @@ -236,7 +236,15 @@ export const ptBR = { archive: "Arquivar", unarchive: "Desarquivar", 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", archivedNotebooks: "Cadernos Arquivados", emptyDescription: "Comece criando seu primeiro caderno para organizar sua pesquisa.", diff --git a/frontend/src/lib/locales/zh-CN/index.ts b/frontend/src/lib/locales/zh-CN/index.ts index 2fa0f61..590c07a 100644 --- a/frontend/src/lib/locales/zh-CN/index.ts +++ b/frontend/src/lib/locales/zh-CN/index.ts @@ -236,7 +236,15 @@ export const zhCN = { archive: "归档", unarchive: "取消归档", deleteNotebook: "删除笔记本", - deleteNotebookDesc: "您确定要删除此笔记本吗?此操作无法撤销。", + deleteNotebookDesc: "您确定要删除 \"{name}\" 吗?此操作无法撤销。", + deleteNotebookLoading: "正在加载删除预览...", + deleteNotebookNotes: "{count} 个笔记将被永久删除。", + deleteNotebookNoNotes: "没有要删除的笔记。", + deleteNotebookExclusiveSources: "{count} 个来源仅存在于此笔记本中。", + deleteNotebookSharedSources: "{count} 个来源与其他笔记本共享,将被取消关联。", + deleteNotebookNoSources: "此笔记本中没有来源。", + deleteExclusiveSourcesLabel: "删除专属来源", + keepExclusiveSourcesLabel: "取消关联并保留", activeNotebooks: "活动的笔记本", archivedNotebooks: "归档的笔记本", emptyDescription: "从创建您的第一个笔记本开始,组织您的研究。", diff --git a/frontend/src/lib/locales/zh-TW/index.ts b/frontend/src/lib/locales/zh-TW/index.ts index b3de8ac..441078a 100644 --- a/frontend/src/lib/locales/zh-TW/index.ts +++ b/frontend/src/lib/locales/zh-TW/index.ts @@ -236,7 +236,15 @@ export const zhTW = { archive: "封存", unarchive: "取消封存", deleteNotebook: "刪除筆記本", - deleteNotebookDesc: "您確定要刪除此筆記本嗎?此操作無法復原。", + deleteNotebookDesc: "您確定要刪除 \"{name}\" 嗎?此操作無法復原。", + deleteNotebookLoading: "正在載入刪除預覽...", + deleteNotebookNotes: "{count} 個筆記將被永久刪除。", + deleteNotebookNoNotes: "沒有要刪除的筆記。", + deleteNotebookExclusiveSources: "{count} 個來源僅存在於此筆記本中。", + deleteNotebookSharedSources: "{count} 個來源與其他筆記本共享,將被取消關聯。", + deleteNotebookNoSources: "此筆記本中沒有來源。", + deleteExclusiveSourcesLabel: "刪除專屬來源", + keepExclusiveSourcesLabel: "取消關聯並保留", activeNotebooks: "活動中的筆記本", archivedNotebooks: "封存的筆記本", emptyDescription: "從新增您的第一個筆記本開始,組織您的研究。", diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index 2fa8883..2991f1e 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -71,6 +71,21 @@ export interface UpdateNotebookRequest { 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 { title?: string content: string diff --git a/open_notebook/domain/CLAUDE.md b/open_notebook/domain/CLAUDE.md index b2ac4c8..b1b11cc 100644 --- a/open_notebook/domain/CLAUDE.md +++ b/open_notebook/domain/CLAUDE.md @@ -25,6 +25,8 @@ Two base classes support different persistence patterns: **ObjectModel** (mutabl ### notebook.py - **Notebook**: Research project container - `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) - `vectorize()`: Submit async embedding job (returns command_id, fire-and-forget) diff --git a/open_notebook/domain/notebook.py b/open_notebook/domain/notebook.py index fde4ecc..f24802a 100644 --- a/open_notebook/domain/notebook.py +++ b/open_notebook/domain/notebook.py @@ -85,6 +85,150 @@ class Notebook(ObjectModel): logger.exception(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): file_path: Optional[str] = None diff --git a/open_notebook/utils/CLAUDE.md b/open_notebook/utils/CLAUDE.md index 8c863f4..e811b25 100644 --- a/open_notebook/utils/CLAUDE.md +++ b/open_notebook/utils/CLAUDE.md @@ -39,8 +39,8 @@ Each utility is stateless and can be imported independently. ### chunking.py - **ContentType**: Enum (HTML, MARKDOWN, PLAIN) -- **CHUNK_SIZE**: 1500 characters (constant) -- **CHUNK_OVERLAP**: 225 characters (15% overlap) +- **CHUNK_SIZE**: constant +- **CHUNK_OVERLAP**: constant - **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(text, file_path)**: Combined detection (extension primary, heuristics fallback) diff --git a/open_notebook/utils/chunking.py b/open_notebook/utils/chunking.py index 3b70831..10ea0f0 100644 --- a/open_notebook/utils/chunking.py +++ b/open_notebook/utils/chunking.py @@ -22,8 +22,8 @@ from langchain_text_splitters import ( from loguru import logger # Constants -CHUNK_SIZE = 1500 # characters -CHUNK_OVERLAP = 225 # 15% of chunk size +CHUNK_SIZE = 1200 # characters +CHUNK_OVERLAP = 180 # 15% of chunk size 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. @@ -220,9 +222,7 @@ def _calculate_markdown_score(text: str) -> float: return min(score, 1.0) -def detect_content_type( - text: str, file_path: Optional[str] = None -) -> ContentType: +def detect_content_type(text: str, file_path: Optional[str] = None) -> ContentType: """ Detect content type using file extension (primary) and heuristics (fallback). @@ -352,12 +352,18 @@ def chunk_text( splitter = _get_html_splitter() # HTML splitter returns Document objects 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: splitter = _get_markdown_splitter() # Markdown splitter returns Document objects 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: # Plain text - use recursive splitter directly splitter = _get_plain_splitter()