diff --git a/paperless-backend/src/database/entities/inbox-document.entity.ts b/paperless-backend/src/database/entities/inbox-document.entity.ts index 5de7692..e3bb6cc 100644 --- a/paperless-backend/src/database/entities/inbox-document.entity.ts +++ b/paperless-backend/src/database/entities/inbox-document.entity.ts @@ -59,6 +59,16 @@ export class InboxDocument { }) Rotations!: Record; + @Column({ + type: 'json', + nullable: true, + transformer: { + to: (v: number[] | null | undefined) => (v && v.length ? v : null), + from: (v: number[] | null) => v ?? [], + }, + }) + ManualSplitPages!: number[]; + @CreateDateColumn() CreatedAt!: Date; diff --git a/paperless-backend/src/inbox/inbox.controller.ts b/paperless-backend/src/inbox/inbox.controller.ts index 256e912..d3fdd2e 100644 --- a/paperless-backend/src/inbox/inbox.controller.ts +++ b/paperless-backend/src/inbox/inbox.controller.ts @@ -88,6 +88,17 @@ export class InboxController { await this.inboxService.deletePage(id, page, preferredUsername); } + @Post(':id/pages/:page/split') + @HttpCode(204) + async toggleManualSplit( + @Param('id') id: string, + @Param('page', ParseIntPipe) page: number, + @Request() req: any, + ): Promise { + const preferredUsername: string | null = req.user?.preferredUsername ?? null; + await this.inboxService.toggleManualSplit(id, page, preferredUsername); + } + @Post(':id/reset-edits') @HttpCode(204) async resetEdits(@Param('id') id: string, @Request() req: any): Promise { diff --git a/paperless-backend/src/inbox/inbox.service.ts b/paperless-backend/src/inbox/inbox.service.ts index a917b35..d3047ce 100644 --- a/paperless-backend/src/inbox/inbox.service.ts +++ b/paperless-backend/src/inbox/inbox.service.ts @@ -21,6 +21,7 @@ export interface InboxFile { source: InboxSource; pageCount: number; deletedPages: number[]; + manualSplitPages: number[]; rotations: Record; barcodes: MatchedBarcode[]; createdAt: string; @@ -60,6 +61,7 @@ export class InboxService { source: doc.Source, pageCount: doc.PageCount, deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b), + manualSplitPages: [...(doc.ManualSplitPages ?? [])].sort((a, b) => a - b), rotations: { ...(doc.Rotations ?? {}) }, barcodes: await this.barcodeScanner.getMatched(doc), createdAt: doc.CreatedAt.toISOString(), @@ -142,7 +144,7 @@ export class InboxService { } /** - * Setzt alle markierten Bearbeitungen (DeletedPages, Rotations) zurück. + * Setzt alle markierten Bearbeitungen (DeletedPages, Rotations, ManualSplitPages) zurück. */ async resetEdits(id: string, preferredUsername: string | null): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); @@ -155,9 +157,31 @@ export class InboxService { doc.Rotations = {}; changed = true; } + if (doc.ManualSplitPages && doc.ManualSplitPages.length > 0) { + doc.ManualSplitPages = []; + changed = true; + } if (changed) await this.documentRepo.save(doc); } + /** + * Setzt oder entfernt einen manuellen Trennpunkt vor der angegebenen Seite. + */ + async toggleManualSplit(id: string, page: number, preferredUsername: string | null): Promise { + const { doc } = await this.resolveDocument(id, preferredUsername); + if (!Number.isInteger(page) || page < 2 || page > doc.PageCount) { + throw new BadRequestException('Ungültige Seitennummer für Trennung'); + } + const splits = new Set(doc.ManualSplitPages ?? []); + if (splits.has(page)) { + splits.delete(page); + } else { + splits.add(page); + } + doc.ManualSplitPages = Array.from(splits).sort((a, b) => a - b); + await this.documentRepo.save(doc); + } + async deleteDocument(id: string, preferredUsername: string | null): Promise { const { doc } = await this.resolveDocument(id, preferredUsername); const dir = this.pageCache.documentDir(doc.Id); diff --git a/paperless-frontend/src/api/inbox.ts b/paperless-frontend/src/api/inbox.ts index 6c1ce9d..1868b44 100644 --- a/paperless-frontend/src/api/inbox.ts +++ b/paperless-frontend/src/api/inbox.ts @@ -19,6 +19,7 @@ export interface InboxFile { source: InboxSource; pageCount: number; deletedPages: number[]; + manualSplitPages: number[]; rotations: Record; barcodes: InboxBarcode[]; createdAt: string; @@ -62,6 +63,9 @@ export const inboxApi = { remove: (id: string) => api.delete(`/api/inbox/${encodeURIComponent(id)}`).then((r) => r.data), + toggleSplit: (id: string, page: number) => + api.post(`/api/inbox/${encodeURIComponent(id)}/pages/${page}/split`).then(() => {}), + removePage: (id: string, page: number) => api .delete(`/api/inbox/${encodeURIComponent(id)}/pages/${page}`) diff --git a/paperless-frontend/src/pages/InboxDetailPage.tsx b/paperless-frontend/src/pages/InboxDetailPage.tsx index e79502e..1c9a2d5 100644 --- a/paperless-frontend/src/pages/InboxDetailPage.tsx +++ b/paperless-frontend/src/pages/InboxDetailPage.tsx @@ -10,6 +10,7 @@ import { QrcodeOutlined, RedoOutlined, RightOutlined, + ScissorOutlined, ThunderboltOutlined, UndoOutlined, UserOutlined, @@ -35,11 +36,12 @@ function buildDocuments( pageCount: number, splitPages: number[], deletedPages: number[], + manualSplitPages: number[], barcodes: InboxBarcode[], ): DocumentSegment[] { if (pageCount === 0) return []; const deleted = new Set(deletedPages); - const splits = new Set(splitPages.filter((p) => !deleted.has(p))); + const splits = new Set([...splitPages, ...manualSplitPages].filter((p) => !deleted.has(p))); const docs: DocumentSegment[] = []; let current: number[] = []; @@ -570,7 +572,7 @@ export default function InboxDetailPage() { const splitPages = file.barcodes .filter((b) => b.splitBefore) .map((b) => b.page); - return buildDocuments(file.pageCount, splitPages, file.deletedPages, file.barcodes); + return buildDocuments(file.pageCount, splitPages, file.deletedPages, file.manualSplitPages, file.barcodes); }, [file]); const effectivePages = useMemo(() => { @@ -700,6 +702,18 @@ export default function InboxDetailPage() { }; + const handleToggleSplit = async () => { + if (!file) return; + try { + await inboxApi.toggleSplit(file.id, selectedPage); + const list = await inboxApi.list(); + const refreshed = list.find((f) => f.id === file.id) ?? null; + if (refreshed) setFile(refreshed); + } catch { + message.error('Trennung konnte nicht gespeichert werden'); + } + }; + const handleResetEdits = async () => { if (!file) return; try { @@ -846,16 +860,16 @@ export default function InboxDetailPage() { const canPrev = effectiveIndex > 0; const canNext = effectiveIndex >= 0 && effectiveIndex < effectivePages.length - 1; const canDelete = effectivePages.length > 1; + const isSplitPage = file.manualSplitPages.includes(selectedPage); + const canSplit = selectedPage !== currentDoc?.pages[0]; const rotationCount = Object.keys(file.rotations ?? {}).length; - const pendingEdits = file.deletedPages.length + rotationCount; + const manualSplitCount = file.manualSplitPages.length; + const pendingEdits = file.deletedPages.length + rotationCount + manualSplitCount; const editsLabel = (() => { const parts: string[] = []; - if (file.deletedPages.length > 0) { - parts.push(`${file.deletedPages.length} zur Löschung markiert`); - } - if (rotationCount > 0) { - parts.push(`${rotationCount} gedreht`); - } + if (file.deletedPages.length > 0) parts.push(`${file.deletedPages.length} zur Löschung markiert`); + if (rotationCount > 0) parts.push(`${rotationCount} gedreht`); + if (manualSplitCount > 0) parts.push(`${manualSplitCount} manuell getrennt`); return parts.join(' · '); })(); @@ -1211,6 +1225,18 @@ export default function InboxDetailPage() { )} + {canSplit && ( + +