Pika commited on
Commit
871ebee
·
unverified ·
2 Parent(s): b1a5e3518d827e

Merge pull request #341 from Kishalll/feature/document-rename

Browse files
backend/app/routes/documents.py CHANGED
@@ -20,7 +20,14 @@ from sqlalchemy.orm import Session
20
 
21
  from app.database import get_db
22
  from app.models import User, Document
23
- from app.schemas import DocumentResponse, DocumentListResponse, DocumentStatusResponse, ChunkSettings, UploadUrl
 
 
 
 
 
 
 
24
  from app.auth import get_current_user
25
  from app.config import get_settings
26
  from app.tasks import process_document
@@ -417,6 +424,34 @@ def list_documents(
417
  )
418
 
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  @router.get("/{document_id}", response_model=DocumentResponse)
421
  def get_document(
422
  document_id: str,
 
20
 
21
  from app.database import get_db
22
  from app.models import User, Document
23
+ from app.schemas import (
24
+ DocumentResponse,
25
+ DocumentListResponse,
26
+ DocumentStatusResponse,
27
+ DocumentRename,
28
+ ChunkSettings,
29
+ UploadUrl,
30
+ )
31
  from app.auth import get_current_user
32
  from app.config import get_settings
33
  from app.tasks import process_document
 
424
  )
425
 
426
 
427
+ @router.patch("/{document_id}", response_model=DocumentResponse)
428
+ def rename_document(
429
+ document_id: str,
430
+ rename: DocumentRename,
431
+ user: User = Depends(get_current_user),
432
+ db: Session = Depends(get_db),
433
+ ):
434
+ """
435
+ Rename an uploaded document without changing its stored file or vector data.
436
+ """
437
+ doc = db.query(Document).filter(
438
+ Document.id == document_id,
439
+ Document.is_deleted.is_(False),
440
+ ).first()
441
+
442
+ if not doc:
443
+ raise HTTPException(status_code=404, detail="Document not found")
444
+
445
+ if str(doc.user_id) != str(user.id):
446
+ raise HTTPException(status_code=403, detail="You do not have permission to rename this document")
447
+
448
+ doc.original_name = rename.name
449
+ db.commit()
450
+ db.refresh(doc)
451
+
452
+ return DocumentResponse.model_validate(doc)
453
+
454
+
455
  @router.get("/{document_id}", response_model=DocumentResponse)
456
  def get_document(
457
  document_id: str,
backend/app/schemas.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Pydantic schemas for API request/response validation.
3
  """
4
- from pydantic import BaseModel, EmailStr, Field
5
  from typing import Optional, List
6
  from datetime import datetime
7
  from app.models import UserRole
@@ -125,6 +125,18 @@ class DocumentResponse(BaseModel):
125
  from_attributes = True
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  class DocumentStatusResponse(BaseModel):
129
  id: str
130
  status: str
 
1
  """
2
  Pydantic schemas for API request/response validation.
3
  """
4
+ from pydantic import BaseModel, EmailStr, Field, field_validator
5
  from typing import Optional, List
6
  from datetime import datetime
7
  from app.models import UserRole
 
125
  from_attributes = True
126
 
127
 
128
+ class DocumentRename(BaseModel):
129
+ name: str = Field(..., min_length=1, max_length=255)
130
+
131
+ @field_validator("name")
132
+ @classmethod
133
+ def validate_name(cls, value: str) -> str:
134
+ stripped = value.strip()
135
+ if not stripped:
136
+ raise ValueError("Document name cannot be empty")
137
+ return stripped
138
+
139
+
140
  class DocumentStatusResponse(BaseModel):
141
  id: str
142
  status: str
backend/tests/test_documents.py CHANGED
@@ -40,6 +40,66 @@ def test_upload_rejects_unsupported_extension_before_deep_validation(client, aut
40
  assert "not supported" in response.json()["detail"]
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_path, user):
44
  document = Document(
45
  user_id=user.id,
 
40
  assert "not supported" in response.json()["detail"]
41
 
42
 
43
+ def test_rename_document_updates_original_name(client, auth_headers, ready_document, db_session):
44
+ response = client.patch(
45
+ f"/api/v1/documents/{ready_document.id}",
46
+ headers=auth_headers,
47
+ json={"name": " renamed-report.pdf "},
48
+ )
49
+
50
+ assert response.status_code == 200
51
+ payload = response.json()
52
+ assert payload["id"] == ready_document.id
53
+ assert payload["original_name"] == "renamed-report.pdf"
54
+
55
+ db_session.refresh(ready_document)
56
+ assert ready_document.original_name == "renamed-report.pdf"
57
+ assert ready_document.filename == "ready.txt"
58
+
59
+
60
+ def test_rename_document_rejects_empty_name(client, auth_headers, ready_document):
61
+ response = client.patch(
62
+ f"/api/v1/documents/{ready_document.id}",
63
+ headers=auth_headers,
64
+ json={"name": " "},
65
+ )
66
+
67
+ assert response.status_code == 422
68
+
69
+
70
+ def test_rename_document_returns_404_for_missing_document(client, auth_headers):
71
+ response = client.patch(
72
+ "/api/v1/documents/00000000-0000-0000-0000-000000000000",
73
+ headers=auth_headers,
74
+ json={"name": "missing.pdf"},
75
+ )
76
+
77
+ assert response.status_code == 404
78
+
79
+
80
+ def test_rename_document_returns_403_for_other_users_document(client, auth_headers, db_session, other_user):
81
+ other_document = Document(
82
+ user_id=other_user.id,
83
+ filename="other.txt",
84
+ original_name="other.txt",
85
+ file_size=64,
86
+ status="ready",
87
+ )
88
+ db_session.add(other_document)
89
+ db_session.commit()
90
+ db_session.refresh(other_document)
91
+
92
+ response = client.patch(
93
+ f"/api/v1/documents/{other_document.id}",
94
+ headers=auth_headers,
95
+ json={"name": "renamed.txt"},
96
+ )
97
+
98
+ assert response.status_code == 403
99
+ db_session.refresh(other_document)
100
+ assert other_document.original_name == "other.txt"
101
+
102
+
103
  def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_path, user):
104
  document = Document(
105
  user_id=user.id,
frontend/src/app/dashboard/page.tsx CHANGED
@@ -78,7 +78,15 @@ export default function DashboardPage() {
78
  const [connectionError, setConnectionError] = useState("");
79
  const [documentsLoading, setDocumentsLoading] = useState(true);
80
 
 
 
 
 
 
 
 
81
  // Auth guard
 
82
  useEffect(() => {
83
  if (initialized && !user) router.replace("/login");
84
  }, [user, initialized, router]);
@@ -174,6 +182,7 @@ export default function DashboardPage() {
174
  setPdfPage(1);
175
  }}
176
  onDocumentsChange={loadDocuments}
 
177
  />
178
  );
179
 
 
78
  const [connectionError, setConnectionError] = useState("");
79
  const [documentsLoading, setDocumentsLoading] = useState(true);
80
 
81
+ const handleDocumentRenamed = useCallback((renamedDocument: DocInfo) => {
82
+ setDocuments((current) =>
83
+ current.map((document) => (document.id === renamedDocument.id ? renamedDocument : document))
84
+ );
85
+ setActiveDoc((current) => (current?.id === renamedDocument.id ? renamedDocument : current));
86
+ }, []);
87
+
88
  // Auth guard
89
+
90
  useEffect(() => {
91
  if (initialized && !user) router.replace("/login");
92
  }, [user, initialized, router]);
 
182
  setPdfPage(1);
183
  }}
184
  onDocumentsChange={loadDocuments}
185
+ onDocumentRenamed={handleDocumentRenamed}
186
  />
187
  );
188
 
frontend/src/components/document/DocumentSidebar.tsx CHANGED
@@ -7,6 +7,7 @@ import { api } from "@/lib/api";
7
  import { ScrollArea } from "@/components/ui/scroll-area";
8
  import { Skeleton } from "@/components/ui/skeleton";
9
  import { Button } from "@/components/ui/button";
 
10
  import { Badge } from "@/components/ui/badge";
11
  import { Progress } from "@/components/ui/progress";
12
  import {
@@ -23,6 +24,7 @@ interface Props {
23
  loading?: boolean;
24
  onSelectDoc: (doc: DocInfo) => void;
25
  onDocumentsChange: () => void;
 
26
  }
27
 
28
  function DocumentListSkeleton() {
@@ -47,13 +49,23 @@ function DocumentListSkeleton() {
47
  );
48
  }
49
 
50
- export default function DocumentSidebar({ documents = [], activeDoc, loading = false, onSelectDoc, onDocumentsChange }: Props) {
 
 
 
 
 
 
 
51
  const { t } = useTranslation();
52
  const [uploading, setUploading] = useState(false);
53
  const [uploadProgress, setUploadProgress] = useState(0);
54
  const [uploadError, setUploadError] = useState("");
55
  const [deleting, setDeleting] = useState<string | null>(null);
56
  const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
 
 
 
57
 
58
  const onDrop = useCallback(
59
  (acceptedFiles: File[]) => {
@@ -119,14 +131,67 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
119
  setSettingsDoc(doc);
120
  };
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  const handleDocumentKeyDown = (doc: DocInfo, e: React.KeyboardEvent) => {
123
- if (doc.status !== "ready") return;
124
  if (e.key === "Enter" || e.key === " ") {
125
  e.preventDefault();
126
  onSelectDoc(doc);
127
  }
128
  };
129
 
 
130
  const statusIcon = (status: string) => {
131
  switch (status) {
132
  case "ready":
@@ -205,28 +270,49 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
205
  </div>
206
  ) : (
207
  <div className="space-y-1 pb-3">
208
- {documents.map((doc) => (
209
- <div
210
- key={doc.id}
211
- role="button"
212
- tabIndex={doc.status === "ready" ? 0 : -1}
213
- aria-disabled={doc.status !== "ready"}
214
- aria-current={activeDoc?.id === doc.id ? "true" : undefined}
215
- aria-label={`Select document ${doc.original_name}. Status: ${doc.status}`}
216
- onClick={() => doc.status === "ready" && onSelectDoc(doc)}
217
- onKeyDown={(e) => handleDocumentKeyDown(doc, e)}
218
- className={`w-full text-left p-2.5 rounded-lg transition-all duration-200 group
219
- ${activeDoc?.id === doc.id
220
- ? "bg-primary/15 border border-primary/30"
221
- : "hover:bg-sidebar-accent border border-transparent"}
222
- ${doc.status !== "ready" ? "opacity-60 cursor-default" : "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"}`}
223
- >
224
- <div className="flex items-start gap-2.5">
225
- {statusIcon(doc.status)}
226
- <div className="flex-1 min-w-0">
227
- <p className="text-sm font-medium truncate leading-tight">
228
- {doc.original_name}
229
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  <p className="text-xs text-muted-foreground mt-1">
231
  {doc.summary || "📄 No summary available"}
232
  </p>
@@ -257,8 +343,8 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
257
  </Badge>
258
  )}
259
  </div>
260
- </div>
261
- <div className="flex items-center gap-1 shrink-0">
262
  {/* Action buttons (Settings and Delete) are only visible on hover and when the document is ready. The settings button is disabled if the document is not ready, and the delete button shows a loader when the document is being deleted. */}
263
  <Button
264
  variant="ghost"
@@ -286,10 +372,13 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
286
  <Trash2 className="w-3 h-3 text-destructive" />
287
  )}
288
  </Button>
 
289
  </div>
290
  </div>
291
- </div>
292
- ))}
 
 
293
  </div>
294
  )}
295
  </ScrollArea>
 
7
  import { ScrollArea } from "@/components/ui/scroll-area";
8
  import { Skeleton } from "@/components/ui/skeleton";
9
  import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
  import { Badge } from "@/components/ui/badge";
12
  import { Progress } from "@/components/ui/progress";
13
  import {
 
24
  loading?: boolean;
25
  onSelectDoc: (doc: DocInfo) => void;
26
  onDocumentsChange: () => void;
27
+ onDocumentRenamed: (doc: DocInfo) => void;
28
  }
29
 
30
  function DocumentListSkeleton() {
 
49
  );
50
  }
51
 
52
+ export default function DocumentSidebar({
53
+ documents = [],
54
+ activeDoc,
55
+ loading = false,
56
+ onSelectDoc,
57
+ onDocumentsChange,
58
+ onDocumentRenamed,
59
+ }: Props) {
60
  const { t } = useTranslation();
61
  const [uploading, setUploading] = useState(false);
62
  const [uploadProgress, setUploadProgress] = useState(0);
63
  const [uploadError, setUploadError] = useState("");
64
  const [deleting, setDeleting] = useState<string | null>(null);
65
  const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
66
+ const [editingDocId, setEditingDocId] = useState<string | null>(null);
67
+ const [draftName, setDraftName] = useState("");
68
+ const [renamingDocId, setRenamingDocId] = useState<string | null>(null);
69
 
70
  const onDrop = useCallback(
71
  (acceptedFiles: File[]) => {
 
131
  setSettingsDoc(doc);
132
  };
133
 
134
+ const startRename = (doc: DocInfo, e?: React.MouseEvent) => {
135
+ e?.stopPropagation();
136
+ setEditingDocId(doc.id);
137
+ setDraftName(doc.original_name);
138
+ };
139
+
140
+ const cancelRename = () => {
141
+ setEditingDocId(null);
142
+ setDraftName("");
143
+ };
144
+
145
+ const submitRename = async (doc: DocInfo) => {
146
+ const nextName = draftName.trim();
147
+
148
+ if (!nextName) {
149
+ toast.error("Document name cannot be empty");
150
+ return;
151
+ }
152
+
153
+ if (nextName === doc.original_name) {
154
+ cancelRename();
155
+ return;
156
+ }
157
+
158
+ const optimisticDoc = { ...doc, original_name: nextName };
159
+ onDocumentRenamed(optimisticDoc);
160
+ setRenamingDocId(doc.id);
161
+
162
+ try {
163
+ const updatedDoc = await api.renameDocument<DocInfo>(doc.id, nextName);
164
+ onDocumentRenamed(updatedDoc);
165
+ cancelRename();
166
+ toast.success("Document renamed");
167
+ } catch (err) {
168
+ onDocumentRenamed(doc);
169
+ const message = err instanceof Error ? err.message : "Rename failed";
170
+ toast.error(message);
171
+ } finally {
172
+ setRenamingDocId(null);
173
+ }
174
+ };
175
+
176
+ const handleRenameKeyDown = (doc: DocInfo, e: React.KeyboardEvent<HTMLInputElement>) => {
177
+ if (e.key === "Enter") {
178
+ e.preventDefault();
179
+ void submitRename(doc);
180
+ } else if (e.key === "Escape") {
181
+ e.preventDefault();
182
+ cancelRename();
183
+ }
184
+ };
185
+
186
  const handleDocumentKeyDown = (doc: DocInfo, e: React.KeyboardEvent) => {
187
+ if (editingDocId === doc.id || doc.status !== "ready") return;
188
  if (e.key === "Enter" || e.key === " ") {
189
  e.preventDefault();
190
  onSelectDoc(doc);
191
  }
192
  };
193
 
194
+
195
  const statusIcon = (status: string) => {
196
  switch (status) {
197
  case "ready":
 
270
  </div>
271
  ) : (
272
  <div className="space-y-1 pb-3">
273
+ {documents.map((doc) => {
274
+ const isEditing = editingDocId === doc.id;
275
+ const isRenaming = renamingDocId === doc.id;
276
+
277
+ return (
278
+ <div
279
+ key={doc.id}
280
+ role="button"
281
+ tabIndex={doc.status === "ready" ? 0 : -1}
282
+ aria-disabled={doc.status !== "ready"}
283
+ aria-current={activeDoc?.id === doc.id ? "true" : undefined}
284
+ aria-label={`Select document ${doc.original_name}. Status: ${doc.status}`}
285
+ onClick={() => doc.status === "ready" && !isEditing && onSelectDoc(doc)}
286
+ onKeyDown={(e) => handleDocumentKeyDown(doc, e)}
287
+ className={`w-full text-left p-2.5 rounded-lg transition-all duration-200 group
288
+ ${activeDoc?.id === doc.id
289
+ ? "bg-primary/15 border border-primary/30"
290
+ : "hover:bg-sidebar-accent border border-transparent"}
291
+ ${doc.status !== "ready" ? "opacity-60 cursor-default" : "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"}`}
292
+ >
293
+ <div className="flex items-start gap-2.5">
294
+ {statusIcon(doc.status)}
295
+ <div className="flex-1 min-w-0">
296
+ {isEditing ? (
297
+ <Input
298
+ value={draftName}
299
+ onChange={(e) => setDraftName(e.target.value)}
300
+ onClick={(e) => e.stopPropagation()}
301
+ onKeyDown={(e) => handleRenameKeyDown(doc, e)}
302
+ disabled={isRenaming}
303
+ autoFocus
304
+ className="h-7 px-2 text-sm font-medium"
305
+ />
306
+ ) : (
307
+ <p
308
+ className="text-sm font-medium truncate leading-tight"
309
+ onDoubleClick={(e) => startRename(doc, e)}
310
+ title="Double-click to rename"
311
+ >
312
+ {doc.original_name}
313
+ </p>
314
+ )}
315
+
316
  <p className="text-xs text-muted-foreground mt-1">
317
  {doc.summary || "📄 No summary available"}
318
  </p>
 
343
  </Badge>
344
  )}
345
  </div>
346
+ </div>
347
+ <div className="flex items-center gap-1 shrink-0">
348
  {/* Action buttons (Settings and Delete) are only visible on hover and when the document is ready. The settings button is disabled if the document is not ready, and the delete button shows a loader when the document is being deleted. */}
349
  <Button
350
  variant="ghost"
 
372
  <Trash2 className="w-3 h-3 text-destructive" />
373
  )}
374
  </Button>
375
+ </div>
376
  </div>
377
  </div>
378
+ );
379
+ })}
380
+
381
+
382
  </div>
383
  )}
384
  </ScrollArea>
frontend/src/lib/api.ts CHANGED
@@ -228,18 +228,11 @@ class ApiClient {
228
  return res.json();
229
  }
230
 
231
- async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
232
- const token = options?.token || this.getToken();
233
- const headers: HeadersInit = {};
234
- if (token) {
235
- headers["Authorization"] = `Bearer ${token}`;
236
- }
237
- // Don't set Content-Type — browser sets multipart boundary automatically
238
-
239
  const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
240
- method: "POST",
241
- headers,
242
- body: formData,
243
  ...options,
244
  });
245
 
@@ -247,39 +240,53 @@ class ApiClient {
247
  if (res.status === 401 && !options?._skipRefresh) {
248
  const newToken = await this.tryRefreshToken();
249
  if (newToken) {
250
- return this.postForm<T>(path, formData, { ...options, token: newToken, _skipRefresh: true });
251
  }
252
  }
253
 
254
  if (!res.ok) {
255
- throw new Error(await this.getErrorMessage(res, res.statusText || "Upload failed"));
256
  }
257
 
258
  return res.json();
259
  }
260
 
261
- async patch<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
 
 
 
 
 
 
 
 
 
 
 
262
  const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
263
- method: "PATCH",
264
- headers: this.getHeaders(options?.token),
265
- body: body ? JSON.stringify(body) : undefined,
266
  ...options,
267
  });
268
 
 
269
  if (res.status === 401 && !options?._skipRefresh) {
270
  const newToken = await this.tryRefreshToken();
271
  if (newToken) {
272
- return this.patch<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
273
  }
274
  }
275
 
276
  if (!res.ok) {
277
- throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
278
  }
279
 
280
  return res.json();
281
  }
282
 
 
 
283
  async delete<T>(path: string, options?: FetchOptions): Promise<T> {
284
  const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
285
  method: "DELETE",
 
228
  return res.json();
229
  }
230
 
231
+ async patch<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
 
 
 
 
 
 
 
232
  const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
233
+ method: "PATCH",
234
+ headers: this.getHeaders(options?.token),
235
+ body: body ? JSON.stringify(body) : undefined,
236
  ...options,
237
  });
238
 
 
240
  if (res.status === 401 && !options?._skipRefresh) {
241
  const newToken = await this.tryRefreshToken();
242
  if (newToken) {
243
+ return this.patch<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
244
  }
245
  }
246
 
247
  if (!res.ok) {
248
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
249
  }
250
 
251
  return res.json();
252
  }
253
 
254
+ async renameDocument<T>(documentId: string, name: string): Promise<T> {
255
+ return this.patch<T>(`/api/v1/documents/${documentId}`, { name });
256
+ }
257
+
258
+ async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
259
+ const token = options?.token || this.getToken();
260
+ const headers: HeadersInit = {};
261
+ if (token) {
262
+ headers["Authorization"] = `Bearer ${token}`;
263
+ }
264
+ // Don't set Content-Type — browser sets multipart boundary automatically
265
+
266
  const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
267
+ method: "POST",
268
+ headers,
269
+ body: formData,
270
  ...options,
271
  });
272
 
273
+ // Auto-refresh on 401
274
  if (res.status === 401 && !options?._skipRefresh) {
275
  const newToken = await this.tryRefreshToken();
276
  if (newToken) {
277
+ return this.postForm<T>(path, formData, { ...options, token: newToken, _skipRefresh: true });
278
  }
279
  }
280
 
281
  if (!res.ok) {
282
+ throw new Error(await this.getErrorMessage(res, res.statusText || "Upload failed"));
283
  }
284
 
285
  return res.json();
286
  }
287
 
288
+
289
+
290
  async delete<T>(path: string, options?: FetchOptions): Promise<T> {
291
  const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
292
  method: "DELETE",