feat: add correspondent management and update related components

This commit is contained in:
Dominik Schröter 2025-01-13 15:33:42 +01:00
parent 066ed0bb72
commit 91da6eff80
11 changed files with 120 additions and 20 deletions

View file

@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"image" "image"
"slices"
"strings" "strings"
"sync" "sync"
@ -61,6 +62,7 @@ func (app *App) getSuggestedTags(
content string, content string,
suggestedTitle string, suggestedTitle string,
availableTags []string, availableTags []string,
originalTags []string,
logger *logrus.Entry) ([]string, error) { logger *logrus.Entry) ([]string, error) {
likelyLanguage := getLikelyLanguage() likelyLanguage := getLikelyLanguage()
@ -71,6 +73,7 @@ func (app *App) getSuggestedTags(
err := tagTemplate.Execute(&promptBuffer, map[string]interface{}{ err := tagTemplate.Execute(&promptBuffer, map[string]interface{}{
"Language": likelyLanguage, "Language": likelyLanguage,
"AvailableTags": availableTags, "AvailableTags": availableTags,
"OriginalTags": originalTags,
"Title": suggestedTitle, "Title": suggestedTitle,
"Content": content, "Content": content,
}) })
@ -103,6 +106,12 @@ func (app *App) getSuggestedTags(
suggestedTags[i] = strings.TrimSpace(tag) suggestedTags[i] = strings.TrimSpace(tag)
} }
// append the original tags to the suggested tags
suggestedTags = append(suggestedTags, originalTags...)
// Remove duplicates
slices.Sort(suggestedTags)
suggestedTags = slices.Compact(suggestedTags)
// Filter out tags that are not in the available tags list // Filter out tags that are not in the available tags list
filteredTags := []string{} filteredTags := []string{}
for _, tag := range suggestedTags { for _, tag := range suggestedTags {
@ -278,7 +287,7 @@ func (app *App) generateDocumentSuggestions(ctx context.Context, suggestionReque
} }
if suggestionRequest.GenerateTags { if suggestionRequest.GenerateTags {
suggestedTags, err = app.getSuggestedTags(ctx, content, suggestedTitle, availableTagNames, docLogger) suggestedTags, err = app.getSuggestedTags(ctx, content, suggestedTitle, availableTagNames, doc.Tags, docLogger)
if err != nil { if err != nil {
mu.Lock() mu.Lock()
errorsList = append(errorsList, fmt.Errorf("Document %d: %v", documentID, err)) errorsList = append(errorsList, fmt.Errorf("Document %d: %v", documentID, err))

View file

@ -163,6 +163,11 @@ func (client *PaperlessClient) GetDocumentsByTags(ctx context.Context, tags []st
return nil, err return nil, err
} }
allCorrespondents, err := client.GetAllCorrespondents(ctx)
if err != nil {
return nil, err
}
documents := make([]Document, 0, len(documentsResponse.Results)) documents := make([]Document, 0, len(documentsResponse.Results))
for _, result := range documentsResponse.Results { for _, result := range documentsResponse.Results {
tagNames := make([]string, len(result.Tags)) tagNames := make([]string, len(result.Tags))
@ -175,11 +180,22 @@ func (client *PaperlessClient) GetDocumentsByTags(ctx context.Context, tags []st
} }
} }
correspondentName := ""
if result.Correspondent != 0 {
for name, id := range allCorrespondents {
if result.Correspondent == id {
correspondentName = name
break
}
}
}
documents = append(documents, Document{ documents = append(documents, Document{
ID: result.ID, ID: result.ID,
Title: result.Title, Title: result.Title,
Content: result.Content, Content: result.Content,
Tags: tagNames, Correspondent: correspondentName,
Tags: tagNames,
}) })
} }
@ -227,6 +243,12 @@ func (client *PaperlessClient) GetDocument(ctx context.Context, documentID int)
return Document{}, err return Document{}, err
} }
allCorrespondents, err := client.GetAllCorrespondents(ctx)
if err != nil {
return Document{}, err
}
// Match tag IDs to tag names
tagNames := make([]string, len(documentResponse.Tags)) tagNames := make([]string, len(documentResponse.Tags))
for i, resultTagID := range documentResponse.Tags { for i, resultTagID := range documentResponse.Tags {
for tagName, tagID := range allTags { for tagName, tagID := range allTags {
@ -237,11 +259,21 @@ func (client *PaperlessClient) GetDocument(ctx context.Context, documentID int)
} }
} }
// Match correspondent ID to correspondent name
correspondentName := ""
for name, id := range allCorrespondents {
if documentResponse.Correspondent == id {
correspondentName = name
break
}
}
return Document{ return Document{
ID: documentResponse.ID, ID: documentResponse.ID,
Title: documentResponse.Title, Title: documentResponse.Title,
Content: documentResponse.Content, Content: documentResponse.Content,
Tags: tagNames, Correspondent: correspondentName,
Tags: tagNames,
}, nil }, nil
} }
@ -302,8 +334,6 @@ func (client *PaperlessClient) UpdateDocuments(ctx context.Context, documents []
// remove autoTag to prevent infinite loop - this is required in case of undo // remove autoTag to prevent infinite loop - this is required in case of undo
tags = removeTagFromList(tags, autoTag) tags = removeTagFromList(tags, autoTag)
// keep previous tags
tags = append(tags, originalTags...)
// remove duplicates // remove duplicates
slices.Sort(tags) slices.Sort(tags)
tags = slices.Compact(tags) tags = slices.Compact(tags)

View file

@ -348,7 +348,8 @@ func TestUpdateDocuments(t *testing.T) {
// Expected updated fields // Expected updated fields
expectedFields := map[string]interface{}{ expectedFields := map[string]interface{}{
"title": "New Title", "title": "New Title",
"tags": []interface{}{float64(idTag1), float64(idTag2), float64(idTag3)}, // keep also previous tags // do not keep previous tags since the tag generation will already take care to include old ones:
"tags": []interface{}{float64(idTag2), float64(idTag3)},
} }
assert.Equal(t, expectedFields, updatedFields) assert.Equal(t, expectedFields, updatedFields)

View file

@ -11,7 +11,7 @@ type GetDocumentsApiResponse struct {
All []int `json:"all"` All []int `json:"all"`
Results []struct { Results []struct {
ID int `json:"id"` ID int `json:"id"`
Correspondent interface{} `json:"correspondent"` Correspondent int `json:"correspondent"`
DocumentType interface{} `json:"document_type"` DocumentType interface{} `json:"document_type"`
StoragePath interface{} `json:"storage_path"` StoragePath interface{} `json:"storage_path"`
Title string `json:"title"` Title string `json:"title"`
@ -38,7 +38,7 @@ type GetDocumentsApiResponse struct {
type GetDocumentApiResponse struct { type GetDocumentApiResponse struct {
ID int `json:"id"` ID int `json:"id"`
Correspondent interface{} `json:"correspondent"` Correspondent int `json:"correspondent"`
DocumentType interface{} `json:"document_type"` DocumentType interface{} `json:"document_type"`
StoragePath interface{} `json:"storage_path"` StoragePath interface{} `json:"storage_path"`
Title string `json:"title"` Title string `json:"title"`
@ -59,10 +59,11 @@ type GetDocumentApiResponse struct {
// Document is a stripped down version of the document object from paperless-ngx. // Document is a stripped down version of the document object from paperless-ngx.
// Response payload for /documents endpoint and part of request payload for /generate-suggestions endpoint // Response payload for /documents endpoint and part of request payload for /generate-suggestions endpoint
type Document struct { type Document struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Correspondent string `json:"correspondent"`
} }
// GenerateSuggestionsRequest is the request payload for generating suggestions for /generate-suggestions endpoint // GenerateSuggestionsRequest is the request payload for generating suggestions for /generate-suggestions endpoint

View file

@ -11,12 +11,14 @@ export interface Document {
title: string; title: string;
content: string; content: string;
tags: string[]; tags: string[];
correspondent: string;
} }
export interface GenerateSuggestionsRequest { export interface GenerateSuggestionsRequest {
documents: Document[]; documents: Document[];
generate_titles?: boolean; generate_titles?: boolean;
generate_tags?: boolean; generate_tags?: boolean;
generate_correspondents?: boolean;
} }
export interface DocumentSuggestion { export interface DocumentSuggestion {
@ -25,6 +27,7 @@ export interface DocumentSuggestion {
suggested_title?: string; suggested_title?: string;
suggested_tags?: string[]; suggested_tags?: string[];
suggested_content?: string; suggested_content?: string;
suggested_correspondent?: string;
} }
export interface TagOption { export interface TagOption {
@ -43,6 +46,7 @@ const DocumentProcessor: React.FC = () => {
const [filterTag, setFilterTag] = useState<string | null>(null); const [filterTag, setFilterTag] = useState<string | null>(null);
const [generateTitles, setGenerateTitles] = useState(true); const [generateTitles, setGenerateTitles] = useState(true);
const [generateTags, setGenerateTags] = useState(true); const [generateTags, setGenerateTags] = useState(true);
const [generateCorrespondents, setGenerateCorrespondents] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Custom hook to fetch initial data // Custom hook to fetch initial data
@ -81,6 +85,7 @@ const DocumentProcessor: React.FC = () => {
documents, documents,
generate_titles: generateTitles, generate_titles: generateTitles,
generate_tags: generateTags, generate_tags: generateTags,
generate_correspondents: generateCorrespondents,
}; };
const { data } = await axios.post<DocumentSuggestion[]>( const { data } = await axios.post<DocumentSuggestion[]>(
@ -137,6 +142,7 @@ const DocumentProcessor: React.FC = () => {
); );
}; };
const handleTitleChange = (docId: number, title: string) => { const handleTitleChange = (docId: number, title: string) => {
setSuggestions((prevSuggestions) => setSuggestions((prevSuggestions) =>
prevSuggestions.map((doc) => prevSuggestions.map((doc) =>
@ -145,6 +151,14 @@ const DocumentProcessor: React.FC = () => {
); );
}; };
const handleCorrespondentChange = (docId: number, correspondent: string) => {
setSuggestions((prevSuggestions) =>
prevSuggestions.map((doc) =>
doc.id === docId ? { ...doc, suggested_correspondent: correspondent } : doc
)
);
}
const resetSuggestions = () => { const resetSuggestions = () => {
setSuggestions([]); setSuggestions([]);
}; };
@ -214,6 +228,8 @@ const DocumentProcessor: React.FC = () => {
setGenerateTitles={setGenerateTitles} setGenerateTitles={setGenerateTitles}
generateTags={generateTags} generateTags={generateTags}
setGenerateTags={setGenerateTags} setGenerateTags={setGenerateTags}
generateCorrespondents={generateCorrespondents}
setGenerateCorrespondents={setGenerateCorrespondents}
onProcess={handleProcessDocuments} onProcess={handleProcessDocuments}
processing={processing} processing={processing}
onReload={reloadDocuments} onReload={reloadDocuments}
@ -225,6 +241,7 @@ const DocumentProcessor: React.FC = () => {
onTitleChange={handleTitleChange} onTitleChange={handleTitleChange}
onTagAddition={handleTagAddition} onTagAddition={handleTagAddition}
onTagDeletion={handleTagDeletion} onTagDeletion={handleTagDeletion}
onCorrespondentChange={handleCorrespondentChange}
onBack={resetSuggestions} onBack={resetSuggestions}
onUpdate={handleUpdateDocuments} onUpdate={handleUpdateDocuments}
updating={updating} updating={updating}

View file

@ -13,6 +13,9 @@ const DocumentCard: React.FC<DocumentCardProps> = ({ document }) => (
? `${document.content.substring(0, 100)}...` ? `${document.content.substring(0, 100)}...`
: document.content} : document.content}
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
Correspondent: <span className="font-bold text-blue-600 dark:text-blue-400">{document.correspondent}</span>
</p>
<div className="mt-4"> <div className="mt-4">
{document.tags.map((tag) => ( {document.tags.map((tag) => (
<span <span
@ -27,6 +30,9 @@ const DocumentCard: React.FC<DocumentCardProps> = ({ document }) => (
<div className="text-sm text-white p-2 bg-gray-800 dark:bg-gray-900 rounded-md w-full max-h-full overflow-y-auto"> <div className="text-sm text-white p-2 bg-gray-800 dark:bg-gray-900 rounded-md w-full max-h-full overflow-y-auto">
<h3 className="text-lg font-semibold text-white">{document.title}</h3> <h3 className="text-lg font-semibold text-white">{document.title}</h3>
<p className="mt-2 whitespace-pre-wrap">{document.content}</p> <p className="mt-2 whitespace-pre-wrap">{document.content}</p>
<p className="mt-2">
Correspondent: <span className="font-bold text-blue-400">{document.correspondent}</span>
</p>
<div className="mt-4"> <div className="mt-4">
{document.tags.map((tag) => ( {document.tags.map((tag) => (
<span <span

View file

@ -9,6 +9,8 @@ interface DocumentsToProcessProps {
setGenerateTitles: React.Dispatch<React.SetStateAction<boolean>>; setGenerateTitles: React.Dispatch<React.SetStateAction<boolean>>;
generateTags: boolean; generateTags: boolean;
setGenerateTags: React.Dispatch<React.SetStateAction<boolean>>; setGenerateTags: React.Dispatch<React.SetStateAction<boolean>>;
generateCorrespondents: boolean;
setGenerateCorrespondents: React.Dispatch<React.SetStateAction<boolean>>;
onProcess: () => void; onProcess: () => void;
processing: boolean; processing: boolean;
onReload: () => void; onReload: () => void;
@ -20,6 +22,8 @@ const DocumentsToProcess: React.FC<DocumentsToProcessProps> = ({
setGenerateTitles, setGenerateTitles,
generateTags, generateTags,
setGenerateTags, setGenerateTags,
generateCorrespondents,
setGenerateCorrespondents,
onProcess, onProcess,
processing, processing,
onReload, onReload,
@ -64,6 +68,15 @@ const DocumentsToProcess: React.FC<DocumentsToProcessProps> = ({
/> />
<span className="text-gray-700 dark:text-gray-200">Generate Tags</span> <span className="text-gray-700 dark:text-gray-200">Generate Tags</span>
</label> </label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={generateCorrespondents}
onChange={(e) => setGenerateCorrespondents(e.target.checked)}
className="dark:bg-gray-700 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-200">Generate Correspondents</span>
</label>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View file

@ -8,6 +8,7 @@ interface SuggestionCardProps {
onTitleChange: (docId: number, title: string) => void; onTitleChange: (docId: number, title: string) => void;
onTagAddition: (docId: number, tag: TagOption) => void; onTagAddition: (docId: number, tag: TagOption) => void;
onTagDeletion: (docId: number, index: number) => void; onTagDeletion: (docId: number, index: number) => void;
onCorrespondentChange: (docId: number, correspondent: string) => void;
} }
const SuggestionCard: React.FC<SuggestionCardProps> = ({ const SuggestionCard: React.FC<SuggestionCardProps> = ({
@ -16,6 +17,7 @@ const SuggestionCard: React.FC<SuggestionCardProps> = ({
onTitleChange, onTitleChange,
onTagAddition, onTagAddition,
onTagDeletion, onTagDeletion,
onCorrespondentChange,
}) => { }) => {
const sortedAvailableTags = availableTags.sort((a, b) => a.name.localeCompare(b.name)); const sortedAvailableTags = availableTags.sort((a, b) => a.name.localeCompare(b.name));
const document = suggestion.original_document; const document = suggestion.original_document;
@ -49,6 +51,9 @@ const SuggestionCard: React.FC<SuggestionCardProps> = ({
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Suggested Title
</label>
<input <input
type="text" type="text"
value={suggestion.suggested_title || ""} value={suggestion.suggested_title || ""}
@ -56,6 +61,9 @@ const SuggestionCard: React.FC<SuggestionCardProps> = ({
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-200" className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-200"
/> />
<div className="mt-4"> <div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Suggested Tags
</label>
<ReactTags <ReactTags
selected={ selected={
suggestion.suggested_tags?.map((tag, index) => ({ suggestion.suggested_tags?.map((tag, index) => ({
@ -99,6 +107,18 @@ const SuggestionCard: React.FC<SuggestionCardProps> = ({
}} }}
/> />
</div> </div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Suggested Correspondent
</label>
<input
type="text"
value={suggestion.suggested_correspondent || ""}
onChange={(e) => onCorrespondentChange(suggestion.id, e.target.value)}
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 mt-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-200"
placeholder="Correspondent"
/>
</div>
</div> </div>
</div> </div>
); );

View file

@ -8,6 +8,7 @@ interface SuggestionsReviewProps {
onTitleChange: (docId: number, title: string) => void; onTitleChange: (docId: number, title: string) => void;
onTagAddition: (docId: number, tag: TagOption) => void; onTagAddition: (docId: number, tag: TagOption) => void;
onTagDeletion: (docId: number, index: number) => void; onTagDeletion: (docId: number, index: number) => void;
onCorrespondentChange: (docId: number, correspondent: string) => void;
onBack: () => void; onBack: () => void;
onUpdate: () => void; onUpdate: () => void;
updating: boolean; updating: boolean;
@ -19,6 +20,7 @@ const SuggestionsReview: React.FC<SuggestionsReviewProps> = ({
onTitleChange, onTitleChange,
onTagAddition, onTagAddition,
onTagDeletion, onTagDeletion,
onCorrespondentChange,
onBack, onBack,
onUpdate, onUpdate,
updating, updating,
@ -36,6 +38,7 @@ const SuggestionsReview: React.FC<SuggestionsReviewProps> = ({
onTitleChange={onTitleChange} onTitleChange={onTitleChange}
onTagAddition={onTagAddition} onTagAddition={onTagAddition}
onTagDeletion={onTagDeletion} onTagDeletion={onTagDeletion}
onCorrespondentChange={onCorrespondentChange}
/> />
))} ))}
</div> </div>

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/documentprocessor.tsx","./src/experimentalocr.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/documentcard.tsx","./src/components/documentstoprocess.tsx","./src/components/nodocuments.tsx","./src/components/successmodal.tsx","./src/components/suggestioncard.tsx","./src/components/suggestionsreview.tsx"],"version":"5.6.2"} {"root":["./src/app.tsx","./src/documentprocessor.tsx","./src/experimentalocr.tsx","./src/history.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/documentcard.tsx","./src/components/documentstoprocess.tsx","./src/components/nodocuments.tsx","./src/components/sidebar.tsx","./src/components/successmodal.tsx","./src/components/suggestioncard.tsx","./src/components/suggestionsreview.tsx","./src/components/undocard.tsx"],"version":"5.7.2"}

View file

@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.2"} {"root":["./vite.config.ts"],"version":"5.7.2"}