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 (
+
+ );
+ }
+
+ return (
+
+
+
+ {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 (
-
- );
- }
-
- return (
-
-
- Paperless GPT
-
-
- {documents.length === 0 && (
-
-
- No documents found with filter tag{" "}
-
- {filterTag}
- {" "}
- found. Try{" "}
-
{
- setDocuments([]);
- setLoading(true);
- fetchDocuments();
- }}
- className="text-blue-600 hover:underline focus:outline-none"
- >
- reloading
-
- .
-
-
- )}
-
- {!documents.some((doc) => doc.suggested_title) && (
-
-
-
- Documents to Process
-
-
{
- setDocuments([]);
- setLoading(true);
- fetchDocuments();
- }}
- disabled={processing}
- className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 focus:outline-none"
- >
-
-
-
- {processing ? "Processing..." : "Generate Suggestions"}
-
-
-
-
-
-
-
- ID
-
-
- Title
-
-
-
-
- {documents.map((doc) => (
-
-
- {doc.id}
-
-
- {doc.title}
-
-
- ))}
-
-
-
-
- )}
-
- {documents.some((doc) => doc.suggested_title) && (
-
-
- Review and Edit Suggested Titles
-
-
-
-
- Back
-
-
- {updating ? "Updating..." : "Apply Suggestions"}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Documents Updated
-
-
-
- The documents have been successfully updated with the
- new titles.
-
-
-
-
-
- {
- setSuccessModalOpen(false);
- setDocuments([]);
- setLoading(true);
- axios.get("/api/documents").then((response) => {
- setDocuments(response.data);
- setLoading(false);
- });
- }}
- className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm"
- >
- OK
-
-
-
-
-
-
-
-
- );
-};
-
-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
+
+
+
+
+
+ {processing ? "Processing..." : "Generate Suggestions"}
+
+
+
+
+
+
+ setGenerateTitles(e.target.checked)}
+ className="dark:bg-gray-700 dark:border-gray-600"
+ />
+ Generate Titles
+
+
+ setGenerateTags(e.target.checked)}
+ className="dark:bg-gray-700 dark:border-gray-600"
+ />
+ Generate Tags
+
+
+
+
+ {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}
+
+ )}
+ .
+
+
+ Reload
+
+
+
+);
+
+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.
+
+
+
+
+
+
+ OK
+
+
+
+
+
+
+
+);
+
+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}
+
+ ))}
+
+
+
+
+
+
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) => (
+
+ ))}
+
+
+
+ Back
+
+
+ {updating ? "Updating..." : "Apply Suggestions"}
+
+
+
+);
+
+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