Enhance UX for tag selection

Fixes #2
This commit is contained in:
Dominik Schröter 2024-10-04 12:04:37 +02:00
parent e87d80e5bf
commit 59a24231ad
4 changed files with 183 additions and 84 deletions

54
main.go
View file

@ -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)

View file

@ -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",

View file

@ -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",

View file

@ -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<Document, "suggested_tags"> & {
suggested_tags?: string[];
};
const DocumentProcessor: React.FC = () => {
const [documents, setDocuments] = useState<Document[]>([]);
const [availableTags, setAvailableTags] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [processing, setProcessing] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false);
const [successModalOpen, setSuccessModalOpen] = useState<boolean>(false);
const [filterTag, setFilterTag] = useState<string | undefined>(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<ApiDocument[]>("/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
</h1>
{/* Handle empty documents with reload button */}
{documents.length === 0 && (
<div className="flex items-center justify-center h-screen">
<div className="text-xl font-semibold">
@ -128,7 +170,7 @@ const DocumentProcessor: React.FC = () => {
</div>
</div>
)}
{/* Step 1: Document Preview */}
{!documents.some((doc) => doc.suggested_title) && (
<div className="space-y-6">
<div className="flex justify-between items-center">
@ -183,7 +225,6 @@ const DocumentProcessor: React.FC = () => {
</div>
)}
{/* Step 2: Review Suggestions */}
{documents.some((doc) => doc.suggested_title) && (
<div className="space-y-6">
<h2 className="text-2xl font-semibold text-gray-700">
@ -234,23 +275,32 @@ const DocumentProcessor: React.FC = () => {
/>
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<input
type="text"
value={doc.suggested_tags?.join(", ")}
onChange={(e) => {
<ReactTags
selected={doc.suggested_tags || []}
suggestions={availableTags}
onAdd={(tag) => {
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"
/>
</td>
</tr>
@ -279,7 +329,6 @@ const DocumentProcessor: React.FC = () => {
</div>
)}
{/* Success Modal */}
<Transition show={successModalOpen} as={Fragment}>
<Dialog
as="div"
@ -289,7 +338,6 @@ const DocumentProcessor: React.FC = () => {
onClose={setSuccessModalOpen}
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */}
<TransitionChild
as="div"
enter="ease-out duration-300"
@ -302,7 +350,6 @@ const DocumentProcessor: React.FC = () => {
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</TransitionChild>
{/* Centering trick */}
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
@ -310,7 +357,6 @@ const DocumentProcessor: React.FC = () => {
&#8203;
</span>
{/* Modal content */}
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
@ -347,7 +393,6 @@ const DocumentProcessor: React.FC = () => {
<button
onClick={() => {
setSuccessModalOpen(false);
// Optionally reset or fetch documents again
setDocuments([]);
setLoading(true);
axios.get("/api/documents").then((response) => {