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()