diff --git a/main.go b/main.go index 7c5a3bb..c408c64 100644 --- a/main.go +++ b/main.go @@ -51,13 +51,28 @@ type GetDocumentsApiResponse struct { } `json:"results"` } +// 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 type Document struct { - ID int `json:"id"` - Title string `json:"title"` - Content string `json:"content"` - Tags []string `json:"tags"` - SuggestedTitle string `json:"suggested_title,omitempty"` - SuggestedTags []string `json:"suggested_tags,omitempty"` + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Tags []string `json:"tags"` +} + +// GenerateSuggestionsRequest is the request payload for generating suggestions for /generate-suggestions endpoint +type GenerateSuggestionsRequest struct { + Documents []Document `json:"documents"` + GenerateTitles bool `json:"generate_titles,omitempty"` + GenerateTags bool `json:"generate_tags,omitempty"` +} + +// DocumentSuggestion is the response payload for /generate-suggestions endpoint and the request payload for /update-documents endpoint (as an array) +type DocumentSuggestion struct { + ID int `json:"id"` + OriginalDocument Document `json:"original_document"` + SuggestedTitle string `json:"suggested_title,omitempty"` + SuggestedTags []string `json:"suggested_tags,omitempty"` } var ( @@ -207,14 +222,14 @@ func documentsHandler(c *gin.Context) { func generateSuggestionsHandler(c *gin.Context) { ctx := c.Request.Context() - var documents []Document - if err := c.ShouldBindJSON(&documents); err != nil { + var suggestionRequest GenerateSuggestionsRequest + if err := c.ShouldBindJSON(&suggestionRequest); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)}) log.Printf("Invalid request payload: %v", err) return } - results, err := processDocuments(ctx, documents) + results, err := generateDocumentSuggestions(ctx, suggestionRequest) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error processing documents: %v", err)}) log.Printf("Error processing documents: %v", err) @@ -227,7 +242,7 @@ func generateSuggestionsHandler(c *gin.Context) { // updateDocumentsHandler updates documents with new titles func updateDocumentsHandler(c *gin.Context) { ctx := c.Request.Context() - var documents []Document + var documents []DocumentSuggestion if err := c.ShouldBindJSON(&documents); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)}) log.Printf("Invalid request payload: %v", err) @@ -244,50 +259,6 @@ func updateDocumentsHandler(c *gin.Context) { c.Status(http.StatusOK) } -func getIDMappingForTags(ctx context.Context, baseURL, apiToken string, tagsToFilter []string) (map[string]int, error) { - url := fmt.Sprintf("%s/api/tags/", baseURL) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", fmt.Sprintf("Token %s", apiToken)) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("Error fetching tags: %d, %s", resp.StatusCode, string(bodyBytes)) - } - - var tagsResponse struct { - Results []struct { - ID int `json:"id"` - Name string `json:"name"` - } `json:"results"` - } - - err = json.NewDecoder(resp.Body).Decode(&tagsResponse) - if err != nil { - return nil, err - } - - tagIDMapping := make(map[string]int) - for _, tag := range tagsResponse.Results { - for _, filterTag := range tagsToFilter { - if tag.Name == filterTag { - tagIDMapping[tag.Name] = tag.ID - } - } - } - - return tagIDMapping, nil -} - func getDocumentsByTags(ctx context.Context, baseURL, apiToken string, tags []string) ([]Document, error) { tagQueries := make([]string, len(tags)) for i, tag := range tags { @@ -348,7 +319,7 @@ func getDocumentsByTags(ctx context.Context, baseURL, apiToken string, tags []st return documents, nil } -func processDocuments(ctx context.Context, documents []Document) ([]Document, error) { +func generateDocumentSuggestions(ctx context.Context, suggestionRequest GenerateSuggestionsRequest) ([]DocumentSuggestion, error) { llm, err := createLLM() if err != nil { return nil, fmt.Errorf("failed to create LLM client: %v", err) @@ -369,6 +340,9 @@ func processDocuments(ctx context.Context, documents []Document) ([]Document, er availableTagNames = append(availableTagNames, tagName) } + documents := suggestionRequest.Documents + documentSuggestions := []DocumentSuggestion{} + var wg sync.WaitGroup var mu sync.Mutex errors := make([]error, 0) @@ -385,27 +359,50 @@ func processDocuments(ctx context.Context, documents []Document) ([]Document, er content = content[:5000] } - suggestedTitle, err := getSuggestedTitle(ctx, llm, content) - if err != nil { - mu.Lock() - errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err)) - mu.Unlock() - log.Printf("Error processing document %d: %v", documentID, err) - return + var suggestedTitle string + var suggestedTags []string + + if suggestionRequest.GenerateTitles { + suggestedTitle, err = getSuggestedTitle(ctx, llm, content) + if err != nil { + mu.Lock() + errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err)) + mu.Unlock() + log.Printf("Error processing document %d: %v", documentID, err) + return + } } - suggestedTags, err := getSuggestedTags(ctx, llm, content, suggestedTitle, availableTagNames) - if err != nil { - mu.Lock() - errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err)) - mu.Unlock() - log.Printf("Error generating tags for document %d: %v", documentID, err) - return + if suggestionRequest.GenerateTags { + suggestedTags, err = getSuggestedTags(ctx, llm, content, suggestedTitle, availableTagNames) + if err != nil { + mu.Lock() + errors = append(errors, fmt.Errorf("Document %d: %v", documentID, err)) + mu.Unlock() + log.Printf("Error generating tags for document %d: %v", documentID, err) + return + } } mu.Lock() - doc.SuggestedTitle = suggestedTitle - doc.SuggestedTags = suggestedTags + suggestion := DocumentSuggestion{ + ID: documentID, + OriginalDocument: *doc, + } + // Titles + if suggestionRequest.GenerateTitles { + suggestion.SuggestedTitle = suggestedTitle + } else { + suggestion.SuggestedTitle = doc.Title + } + + // Tags + if suggestionRequest.GenerateTags { + suggestion.SuggestedTags = suggestedTags + } else { + suggestion.SuggestedTags = removeTagFromList(doc.Tags, tagToFilter) + } + documentSuggestions = append(documentSuggestions, suggestion) mu.Unlock() log.Printf("Document %d processed successfully.", documentID) }(&documents[i]) @@ -417,7 +414,17 @@ func processDocuments(ctx context.Context, documents []Document) ([]Document, er return nil, errors[0] } - return documents, nil + return documentSuggestions, nil +} + +func removeTagFromList(tags []string, tagToRemove string) []string { + filteredTags := []string{} + for _, tag := range tags { + if tag != tagToRemove { + filteredTags = append(filteredTags, tag) + } + } + return filteredTags } func getSuggestedTags(ctx context.Context, llm llms.Model, content string, suggestedTitle string, availableTags []string) ([]string, error) { @@ -507,7 +514,7 @@ Content: return strings.TrimSpace(strings.Trim(completion.Choices[0].Content, "\"")), nil } -func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []Document) error { +func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []DocumentSuggestion) error { client := &http.Client{} // Fetch all available tags @@ -524,8 +531,13 @@ func updateDocuments(ctx context.Context, baseURL, apiToken string, documents [] newTags := []int{} + tags := document.SuggestedTags + if len(tags) == 0 { + tags = document.OriginalDocument.Tags + } + // Map suggested tag names to IDs - for _, tagName := range document.SuggestedTags { + for _, tagName := range tags { if tagID, exists := availableTags[tagName]; exists { // Skip the tag that we are filtering if tagName == tagToFilter { @@ -543,7 +555,11 @@ func updateDocuments(ctx context.Context, baseURL, apiToken string, documents [] if len(suggestedTitle) > 128 { suggestedTitle = suggestedTitle[:128] } - updatedFields["title"] = suggestedTitle + if suggestedTitle != "" { + updatedFields["title"] = suggestedTitle + } else { + log.Printf("No valid title found for document %d, skipping.", documentID) + } // Send the update request url := fmt.Sprintf("%s/api/documents/%d/", baseURL, documentID) diff --git a/web-app/index.html b/web-app/index.html index 7764df0..fcdbc4f 100644 --- a/web-app/index.html +++ b/web-app/index.html @@ -1,13 +1,13 @@ - + Paperless GPT - +
- + \ No newline at end of file diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 773903e..a87d828 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import DocumentProcessor from './components/DocumentProcessor'; +import DocumentProcessor from './DocumentProcessor'; import './index.css'; const App: React.FC = () => { diff --git a/web-app/src/DocumentProcessor.tsx b/web-app/src/DocumentProcessor.tsx new file mode 100644 index 0000000..f3745e5 --- /dev/null +++ b/web-app/src/DocumentProcessor.tsx @@ -0,0 +1,247 @@ +import axios from "axios"; +import React, { useCallback, useEffect, useState } from "react"; +import "react-tag-autocomplete/example/src/styles.css"; // Ensure styles are loaded +import DocumentsToProcess from "./components/DocumentsToProcess"; +import NoDocuments from "./components/NoDocuments"; +import SuccessModal from "./components/SuccessModal"; +import SuggestionsReview from "./components/SuggestionsReview"; + +export interface Document { + id: number; + title: string; + content: string; + tags: string[]; +} + +export interface GenerateSuggestionsRequest { + documents: Document[]; + generate_titles?: boolean; + generate_tags?: boolean; +} + +export interface DocumentSuggestion { + id: number; + original_document: Document; + suggested_title?: string; + suggested_tags?: string[]; +} + +export interface TagOption { + id: string; + name: string; +} + +const DocumentProcessor: React.FC = () => { + const [documents, setDocuments] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + const [loading, setLoading] = useState(true); + const [processing, setProcessing] = useState(false); + const [updating, setUpdating] = useState(false); + const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false); + const [filterTag, setFilterTag] = useState(null); + const [generateTitles, setGenerateTitles] = useState(true); + const [generateTags, setGenerateTags] = useState(true); + const [error, setError] = useState(null); + + // Custom hook to fetch initial data + const fetchInitialData = useCallback(async () => { + try { + const [filterTagRes, documentsRes, tagsRes] = await Promise.all([ + axios.get<{ tag: string }>("/api/filter-tag"), + axios.get("/api/documents"), + axios.get>("/api/tags"), + ]); + + setFilterTag(filterTagRes.data.tag); + setDocuments(documentsRes.data); + const tags = Object.keys(tagsRes.data).map((tag) => ({ + id: tag, + name: tag, + })); + setAvailableTags(tags); + } catch (err) { + console.error("Error fetching initial data:", err); + setError("Failed to fetch initial data."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchInitialData(); + }, [fetchInitialData]); + + const handleProcessDocuments = async () => { + setProcessing(true); + setError(null); + try { + const requestPayload: GenerateSuggestionsRequest = { + documents, + generate_titles: generateTitles, + generate_tags: generateTags, + }; + + const { data } = await axios.post( + "/api/generate-suggestions", + requestPayload + ); + setSuggestions(data); + } catch (err) { + console.error("Error generating suggestions:", err); + setError("Failed to generate suggestions."); + } finally { + setProcessing(false); + } + }; + + const handleUpdateDocuments = async () => { + setUpdating(true); + setError(null); + try { + await axios.patch("/api/update-documents", suggestions); + setIsSuccessModalOpen(true); + setSuggestions([]); + } catch (err) { + console.error("Error updating documents:", err); + setError("Failed to update documents."); + } finally { + setUpdating(false); + } + }; + + const handleTagAddition = (docId: number, tag: TagOption) => { + setSuggestions((prevSuggestions) => + prevSuggestions.map((doc) => + doc.id === docId + ? { + ...doc, + suggested_tags: [...(doc.suggested_tags || []), tag.name], + } + : doc + ) + ); + }; + + const handleTagDeletion = (docId: number, index: number) => { + setSuggestions((prevSuggestions) => + prevSuggestions.map((doc) => + doc.id === docId + ? { + ...doc, + suggested_tags: doc.suggested_tags?.filter( + (_, i) => i !== index + ), + } + : doc + ) + ); + }; + + const handleTitleChange = (docId: number, title: string) => { + setSuggestions((prevSuggestions) => + prevSuggestions.map((doc) => + doc.id === docId + ? { ...doc, suggested_title: title } + : doc + ) + ); + }; + + const resetSuggestions = () => { + setSuggestions([]); + }; + + const reloadDocuments = async () => { + setLoading(true); + setError(null); + try { + const { data } = await axios.get("/api/documents"); + setDocuments(data); + } catch (err) { + console.error("Error reloading documents:", err); + setError("Failed to reload documents."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (documents.length === 0) { + const interval = setInterval(async () => { + setError(null); + try { + const { data } = await axios.get("/api/documents"); + setDocuments(data); + } catch (err) { + console.error("Error reloading documents:", err); + setError("Failed to reload documents."); + } + }, 500); + return () => clearInterval(interval); + } + }, [documents]); + + + if (loading) { + return ( +
+
Loading documents...
+
+ ); + } + + return ( +
+
+

Paperless GPT

+
+ + {error && ( +
+ {error} +
+ )} + + {documents.length === 0 ? ( + + ) : suggestions.length === 0 ? ( + + ) : ( + + )} + + { + setIsSuccessModalOpen(false); + reloadDocuments(); + }} + /> +
+ ); +}; + +export default DocumentProcessor; \ No newline at end of file diff --git a/web-app/src/components/DocumentCard.tsx b/web-app/src/components/DocumentCard.tsx new file mode 100644 index 0000000..bc6ec2a --- /dev/null +++ b/web-app/src/components/DocumentCard.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { Document } from "../DocumentProcessor"; + +interface DocumentCardProps { + document: Document; +} + +const DocumentCard: React.FC = ({ document }) => ( +
+

{document.title}

+

+ {document.content.length > 100 + ? `${document.content.substring(0, 100)}...` + : document.content} +

+
+ {document.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+

{document.title}

+

{document.content}

+
+ {document.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+
+); + +export default DocumentCard; \ No newline at end of file diff --git a/web-app/src/components/DocumentProcessor.tsx b/web-app/src/components/DocumentProcessor.tsx deleted file mode 100644 index a1bea32..0000000 --- a/web-app/src/components/DocumentProcessor.tsx +++ /dev/null @@ -1,417 +0,0 @@ -import { - Dialog, - DialogTitle, - Transition, - TransitionChild, -} from "@headlessui/react"; -import { ArrowPathIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; -import axios from "axios"; -import React, { Fragment, useEffect, useState } from "react"; -import { ReactTags } from "react-tag-autocomplete"; -import "react-tag-autocomplete/example/src/styles.css"; // Ensure styles are loaded - -interface Document { - id: number; - title: string; - content: string; - tags: string[]; - suggested_title?: string; - suggested_tags?: { value: string; label: string }[]; -} - -type ApiDocument = Omit & { - suggested_tags?: string[]; -}; - -const DocumentProcessor: React.FC = () => { - const [documents, setDocuments] = useState([]); - const [availableTags, setAvailableTags] = useState<{ value: string; label: string }[]>([]); - const [loading, setLoading] = useState(true); - const [processing, setProcessing] = useState(false); - const [updating, setUpdating] = useState(false); - const [successModalOpen, setSuccessModalOpen] = useState(false); - const [filterTag, setFilterTag] = useState(undefined); - - useEffect(() => { - const fetchData = async () => { - try { - const [filterTagResponse, documentsResponse, tagsResponse] = - await Promise.all([ - axios.get("/api/filter-tag"), - axios.get("/api/documents"), - axios.get("/api/tags"), - ]); - - setFilterTag(filterTagResponse.data?.tag); - const rawDocuments = documentsResponse.data as ApiDocument[]; - const documents = rawDocuments.map((doc) => ({ - ...doc, - suggested_tags: doc.tags.map((tag) => ({ value: tag, label: tag })), - })); - console.log(documents); - setDocuments(documents); - - // Store available tags as objects with value and label - // tagsResponse.data is a map of name to id - const tags = Object.entries(tagsResponse.data).map(([name]) => ({ - value: name, - label: name, - })); - setAvailableTags(tags); - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, []); - - const handleProcessDocuments = async () => { - setProcessing(true); - try { - const apiDocuments: ApiDocument[] = documents.map((doc) => ({ - ...doc, - suggested_tags: doc.suggested_tags?.map((tag) => tag.value) || [], - })); - - const response = await axios.post("/api/generate-suggestions", apiDocuments); - setDocuments(response.data.map((doc) => ({ - ...doc, - suggested_tags: doc.suggested_tags?.map((tag) => ({ value: tag, label: tag })) || [], - }))); - } catch (error) { - console.error("Error generating suggestions:", error); - } finally { - setProcessing(false); - } - }; - - const handleUpdateDocuments = async () => { - setUpdating(true); - try { - const apiDocuments: ApiDocument[] = documents.map((doc) => ({ - ...doc, - tags: [], // Remove tags from the API document - suggested_tags: doc.suggested_tags?.map((tag) => tag.value) || [], - })); - await axios.patch("/api/update-documents", apiDocuments); - setSuccessModalOpen(true); - } catch (error) { - console.error("Error updating documents:", error); - } finally { - setUpdating(false); - } - }; - - const resetSuggestions = () => { - const resetDocs = documents.map((doc) => ({ - ...doc, - suggested_title: undefined, - suggested_tags: [], - })); - setDocuments(resetDocs); - }; - - const fetchDocuments = async () => { - try { - const response = await axios.get("/api/documents"); // API endpoint to fetch documents - setDocuments(response.data); - } catch (error) { - console.error("Error fetching documents:", error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (documents.length === 0) { - const interval = setInterval(() => { - fetchDocuments(); - }, 500); - return () => clearInterval(interval); - } - }, [documents]); - - if (loading) { - return ( -
-
Loading documents...
-
- ); - } - - return ( -
-

- Paperless GPT -

- - {documents.length === 0 && ( -
-
- No documents found with filter tag{" "} - - {filterTag} - {" "} - found. Try{" "} - - . -
-
- )} - - {!documents.some((doc) => doc.suggested_title) && ( -
-
-

- Documents to Process -

- - -
-
- - - - - - - - - {documents.map((doc) => ( - - - - - ))} - -
- ID - - Title -
- {doc.id} - - {doc.title} -
-
-
- )} - - {documents.some((doc) => doc.suggested_title) && ( -
-

- Review and Edit Suggested Titles -

-
- - - - - - - - - - - {documents.map( - (doc) => - doc.suggested_title && ( - - - - - - - ) - )} - -
- ID - - Original Title - - Suggested Title - - Suggested Tags -
- {doc.id} - - {doc.title} - - { - const updatedDocuments = documents.map((d) => - d.id === doc.id - ? { ...d, suggested_title: e.target.value } - : d - ); - setDocuments(updatedDocuments); - }} - className="w-full border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - - { - const updatedTags = [...(doc.suggested_tags || []), { value: tag.value as string, label: tag.label }]; - const updatedDocuments = documents.map((d) => - d.id === doc.id - ? { ...d, suggested_tags: updatedTags } - : d - ); - setDocuments(updatedDocuments); - }} - onDelete={(i) => { - const updatedTags = doc.suggested_tags?.filter( - (_, index) => index !== i - ); - const updatedDocuments = documents.map((d) => - d.id === doc.id - ? { ...d, suggested_tags: updatedTags } - : d - ); - setDocuments(updatedDocuments); - }} - allowNew={false} - placeholderText="Add a tag" - /> -
-
-
- - -
-
- )} - - - -
- -
- - - - - -
-
-
-
-
- - Documents Updated - -
-

- The documents have been successfully updated with the - new titles. -

-
-
-
-
- -
-
-
-
-
-
-
- ); -}; - -export default DocumentProcessor; \ No newline at end of file diff --git a/web-app/src/components/DocumentsToProcess.tsx b/web-app/src/components/DocumentsToProcess.tsx new file mode 100644 index 0000000..aec66a9 --- /dev/null +++ b/web-app/src/components/DocumentsToProcess.tsx @@ -0,0 +1,77 @@ +import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon"; +import React from "react"; +import { Document } from "../DocumentProcessor"; +import DocumentCard from "./DocumentCard"; + +interface DocumentsToProcessProps { + documents: Document[]; + generateTitles: boolean; + setGenerateTitles: React.Dispatch>; + generateTags: boolean; + setGenerateTags: React.Dispatch>; + onProcess: () => void; + processing: boolean; + onReload: () => void; +} + +const DocumentsToProcess: React.FC = ({ + documents, + generateTitles, + setGenerateTitles, + generateTags, + setGenerateTags, + onProcess, + processing, + onReload, +}) => ( +
+
+

Documents to Process

+
+ + +
+
+ +
+ + +
+ +
+ {documents.map((doc) => ( + + ))} +
+
+); + +export default DocumentsToProcess; \ No newline at end of file diff --git a/web-app/src/components/NoDocuments.tsx b/web-app/src/components/NoDocuments.tsx new file mode 100644 index 0000000..2c94b51 --- /dev/null +++ b/web-app/src/components/NoDocuments.tsx @@ -0,0 +1,36 @@ +import { ArrowPathIcon } from "@heroicons/react/24/outline"; +import React from "react"; + +interface NoDocumentsProps { + filterTag: string | null; + onReload: () => void; + processing: boolean; +} + +const NoDocuments: React.FC = ({ + filterTag, + onReload, + processing, +}) => ( +
+

+ No documents found with filter tag{" "} + {filterTag && ( + + {filterTag} + + )} + . +

+ +
+); + +export default NoDocuments; \ No newline at end of file diff --git a/web-app/src/components/SuccessModal.tsx b/web-app/src/components/SuccessModal.tsx new file mode 100644 index 0000000..e06ce25 --- /dev/null +++ b/web-app/src/components/SuccessModal.tsx @@ -0,0 +1,84 @@ +import { Dialog, DialogTitle, Transition } from "@headlessui/react"; +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +import React, { Fragment } from "react"; + +interface SuccessModalProps { + isOpen: boolean; + onClose: () => void; +} + +const SuccessModal: React.FC = ({ isOpen, onClose }) => ( + + +
+ +
+ + + + + +
+
+
+
+
+ + Documents Updated + +
+

+ The documents have been successfully updated with the new titles and tags. +

+
+
+
+
+ +
+
+
+
+
+
+); + +export default SuccessModal; \ No newline at end of file diff --git a/web-app/src/components/SuggestionCard.tsx b/web-app/src/components/SuggestionCard.tsx new file mode 100644 index 0000000..25da077 --- /dev/null +++ b/web-app/src/components/SuggestionCard.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { ReactTags } from "react-tag-autocomplete"; +import { DocumentSuggestion, TagOption } from "../DocumentProcessor"; + +interface SuggestionCardProps { + suggestion: DocumentSuggestion; + availableTags: TagOption[]; + onTitleChange: (docId: number, title: string) => void; + onTagAddition: (docId: number, tag: TagOption) => void; + onTagDeletion: (docId: number, index: number) => void; +} + +const SuggestionCard: React.FC = ({ + suggestion, + availableTags, + onTitleChange, + onTagAddition, + onTagDeletion, +}) => { + const document = suggestion.original_document; + return ( +
+
+
+

+ {document.title} +

+

+ {document.content.length > 40 + ? `${document.content.substring(0, 40)}...` + : document.content} +

+
+ {document.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+
+

{document.content}

+
+
+
+
+ onTitleChange(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" + /> +
+ ({ + id: index.toString(), + name: tag, + label: tag, + value: index.toString(), + })) || [] + } + suggestions={availableTags.map((tag) => ({ + id: tag.id, + name: tag.name, + label: tag.name, + value: tag.id, + }))} + onAdd={(tag) => + onTagAddition(suggestion.id, { + id: String(tag.label), + name: String(tag.value), + }) + } + onDelete={(index) => onTagDeletion(suggestion.id, index)} + allowNew={true} + placeholderText="Add a tag" + classNames={{ + root: "react-tags dark:bg-gray-800", + rootIsActive: "is-active", + rootIsDisabled: "is-disabled", + rootIsInvalid: "is-invalid", + label: "react-tags__label", + tagList: "react-tags__list", + tagListItem: "react-tags__list-item", + tag: "react-tags__tag dark:bg-blue-900 dark:text-blue-200", + tagName: "react-tags__tag-name", + comboBox: "react-tags__combobox dark:bg-gray-700 dark:text-gray-200", + input: "react-tags__combobox-input dark:bg-gray-700 dark:text-gray-200", + listBox: "react-tags__listbox dark:bg-gray-700 dark:text-gray-200", + option: "react-tags__listbox-option dark:bg-gray-700 dark:text-gray-200 hover:bg-blue-500 dark:hover:bg-blue-800", + optionIsActive: "is-active", + highlight: "react-tags__highlight dark:bg-gray-800", + }} + /> +
+
+
+ ); +}; + +export default SuggestionCard; \ No newline at end of file diff --git a/web-app/src/components/SuggestionsReview.tsx b/web-app/src/components/SuggestionsReview.tsx new file mode 100644 index 0000000..3a1f648 --- /dev/null +++ b/web-app/src/components/SuggestionsReview.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { DocumentSuggestion, TagOption } from "../DocumentProcessor"; +import SuggestionCard from "./SuggestionCard"; + +interface SuggestionsReviewProps { + suggestions: DocumentSuggestion[]; + availableTags: TagOption[]; + onTitleChange: (docId: number, title: string) => void; + onTagAddition: (docId: number, tag: TagOption) => void; + onTagDeletion: (docId: number, index: number) => void; + onBack: () => void; + onUpdate: () => void; + updating: boolean; +} + +const SuggestionsReview: React.FC = ({ + suggestions, + availableTags, + onTitleChange, + onTagAddition, + onTagDeletion, + onBack, + onUpdate, + updating, +}) => ( +
+

+ Review and Edit Suggested Titles +

+
+ {suggestions.map((doc) => ( + + ))} +
+
+ + +
+
+); + +export default SuggestionsReview; \ No newline at end of file diff --git a/web-app/tsconfig.app.tsbuildinfo b/web-app/tsconfig.app.tsbuildinfo index abb7240..daa87b6 100644 --- a/web-app/tsconfig.app.tsbuildinfo +++ b/web-app/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/documentprocessor.tsx"],"version":"5.6.2"} \ No newline at end of file +{"root":["./src/app.tsx","./src/documentprocessor.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"} \ No newline at end of file