diff --git a/main.go b/main.go index ae9394e..7c5a3bb 100644 --- a/main.go +++ b/main.go @@ -55,7 +55,7 @@ type Document struct { ID int `json:"id"` Title string `json:"title"` Content string `json:"content"` - Tags []int `json:"tags"` + Tags []string `json:"tags"` SuggestedTitle string `json:"suggested_title,omitempty"` SuggestedTags []string `json:"suggested_tags,omitempty"` } @@ -94,6 +94,19 @@ func main() { api.GET("/filter-tag", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"tag": tagToFilter}) }) + // get all tags + api.GET("/tags", func(c *gin.Context) { + ctx := c.Request.Context() + + tags, err := getAllTags(ctx, paperlessBaseURL, paperlessAPIToken) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error fetching tags: %v", err)}) + log.Printf("Error fetching tags: %v", err) + return + } + + c.JSON(http.StatusOK, tags) + }) } // Serve static files for the frontend under /static @@ -214,15 +227,6 @@ func generateSuggestionsHandler(c *gin.Context) { // updateDocumentsHandler updates documents with new titles func updateDocumentsHandler(c *gin.Context) { ctx := c.Request.Context() - - tagIDMapping, err := getIDMappingForTags(ctx, paperlessBaseURL, paperlessAPIToken, []string{tagToFilter}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error fetching tag ID: %v", err)}) - log.Printf("Error fetching tag ID: %v", err) - return - } - paperlessGptTagID := tagIDMapping[tagToFilter] - var documents []Document if err := c.ShouldBindJSON(&documents); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)}) @@ -230,7 +234,7 @@ func updateDocumentsHandler(c *gin.Context) { return } - err = updateDocuments(ctx, paperlessBaseURL, paperlessAPIToken, documents, paperlessGptTagID) + err := updateDocuments(ctx, paperlessBaseURL, paperlessAPIToken, documents) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error updating documents: %v", err)}) log.Printf("Error updating documents: %v", err) @@ -317,13 +321,27 @@ func getDocumentsByTags(ctx context.Context, baseURL, apiToken string, tags []st return nil, err } + allTags, err := getAllTags(ctx, baseURL, apiToken) + if err != nil { + return nil, err + } documents := make([]Document, 0, len(documentsResponse.Results)) for _, result := range documentsResponse.Results { + tagNames := make([]string, len(result.Tags)) + for i, resultTagID := range result.Tags { + for tagName, tagID := range allTags { + if resultTagID == tagID { + tagNames[i] = tagName + break + } + } + } + documents = append(documents, Document{ ID: result.ID, Title: result.Title, Content: result.Content, - Tags: result.Tags, + Tags: tagNames, }) } @@ -489,7 +507,7 @@ Content: return strings.TrimSpace(strings.Trim(completion.Choices[0].Content, "\"")), nil } -func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []Document, paperlessGptTagID int) error { +func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []Document) error { client := &http.Client{} // Fetch all available tags @@ -505,15 +523,14 @@ func updateDocuments(ctx context.Context, baseURL, apiToken string, documents [] updatedFields := make(map[string]interface{}) newTags := []int{} - for _, tagID := range document.Tags { - if tagID != paperlessGptTagID { - newTags = append(newTags, tagID) - } - } // Map suggested tag names to IDs for _, tagName := range document.SuggestedTags { if tagID, exists := availableTags[tagName]; exists { + // Skip the tag that we are filtering + if tagName == tagToFilter { + continue + } newTags = append(newTags, tagID) } else { log.Printf("Tag '%s' does not exist in paperless-ngx, skipping.", tagName) @@ -528,6 +545,7 @@ func updateDocuments(ctx context.Context, baseURL, apiToken string, documents [] } updatedFields["title"] = suggestedTitle + // Send the update request url := fmt.Sprintf("%s/api/documents/%d/", baseURL, documentID) jsonData, err := json.Marshal(updatedFields) diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 1a4df13..c19339a 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -11,8 +11,11 @@ "@headlessui/react": "^2.1.8", "@heroicons/react": "^2.1.5", "axios": "^1.7.7", + "classnames": "^2.5.1", + "prop-types": "^15.8.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-tag-autocomplete": "^7.3.0" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -1861,6 +1864,11 @@ "node": ">= 6" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2905,7 +2913,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3224,6 +3231,16 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3281,6 +3298,22 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-tag-autocomplete": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.3.0.tgz", + "integrity": "sha512-YgbGlZ4ZkfQw23yMIvw0gCsex0LtYojLiVsfAb8w05r1npMo5+Q699KXfrUZ3W01U7mrxvd/YcMcw7WVkDdZQA==", + "engines": { + "node": ">= 16.12.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/web-app/package.json b/web-app/package.json index cacce23..22f8e99 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -13,8 +13,11 @@ "@headlessui/react": "^2.1.8", "@heroicons/react": "^2.1.5", "axios": "^1.7.7", + "classnames": "^2.5.1", + "prop-types": "^15.8.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-tag-autocomplete": "^7.3.0" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/web-app/src/components/DocumentProcessor.tsx b/web-app/src/components/DocumentProcessor.tsx index 16f6752..a1bea32 100644 --- a/web-app/src/components/DocumentProcessor.tsx +++ b/web-app/src/components/DocumentProcessor.tsx @@ -7,32 +7,113 @@ import { 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?: 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); - const fetchFilterTag = async () => { + 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 response = await axios.get("/api/filter-tag"); // API endpoint to fetch filter tag - setFilterTag(response.data?.tag); + 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 fetching filter tag:", 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 @@ -44,44 +125,6 @@ const DocumentProcessor: React.FC = () => { } }; - useEffect(() => { - fetchFilterTag(); - fetchDocuments(); - }, []); - - const handleProcessDocuments = async () => { - setProcessing(true); - try { - const response = await axios.post("/api/generate-suggestions", documents); - setDocuments(response.data); - } catch (error) { - console.error("Error generating suggestions:", error); - } finally { - setProcessing(false); - } - }; - - const handleUpdateDocuments = async () => { - setUpdating(true); - try { - await axios.patch("/api/update-documents", documents); - setSuccessModalOpen(true); - } catch (error) { - console.error("Error updating documents:", error); - } finally { - setUpdating(false); - } - }; - - const resetSuggestions = () => { - const resetDocs = documents.map((doc) => ({ - ...doc, - suggested_title: undefined, - })); - setDocuments(resetDocs); - }; - - // while no documents are found we keep refreshing in the background useEffect(() => { if (documents.length === 0) { const interval = setInterval(() => { @@ -105,7 +148,6 @@ const DocumentProcessor: React.FC = () => { Paperless GPT - {/* Handle empty documents with reload button */} {documents.length === 0 && (
@@ -128,7 +170,7 @@ const DocumentProcessor: React.FC = () => {
)} - {/* Step 1: Document Preview */} + {!documents.some((doc) => doc.suggested_title) && (
@@ -183,7 +225,6 @@ const DocumentProcessor: React.FC = () => {
)} - {/* Step 2: Review Suggestions */} {documents.some((doc) => doc.suggested_title) && (

@@ -234,23 +275,32 @@ const DocumentProcessor: React.FC = () => { /> - { + { + 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: e.target.value - .split(",") - .map((tag) => tag.trim()), - } + ? { ...d, suggested_tags: updatedTags } : 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" + 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" /> @@ -279,7 +329,6 @@ const DocumentProcessor: React.FC = () => {

)} - {/* Success Modal */} { onClose={setSuccessModalOpen} >
- {/* Background overlay */} {
- {/* Centering trick */} - {/* Modal content */} {