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:
Luis Novo 2026-01-25 14:56:14 -03:00 committed by GitHub
parent f14020d385
commit 4e411e0488
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 527 additions and 55 deletions

View file

@ -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+

View file

@ -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,
) )

View file

@ -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"
)

View file

@ -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:

View file

@ -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}
/> />
</> </>
) )

View file

@ -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>
)
}

View file

@ -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}
/> />
</> </>
) )

View file

@ -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
} },
} }

View file

@ -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,

View file

@ -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.",

View file

@ -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: "最初のノートブックを作成してリサーチを整理しましょう。",

View file

@ -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.",

View file

@ -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: "从创建您的第一个笔记本开始,组织您的研究。",

View file

@ -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: "從新增您的第一個筆記本開始,組織您的研究。",

View file

@ -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

View file

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

View file

@ -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

View file

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

View file

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