Enhance UX for tag selection (#5)

Fixes #1
This commit is contained in:
Icereed 2024-10-04 13:05:02 +02:00 committed by GitHub
parent e87d80e5bf
commit c7b5c6a060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
Tags []int `json:"tags"` Tags []string `json:"tags"`
SuggestedTitle string `json:"suggested_title,omitempty"` SuggestedTitle string `json:"suggested_title,omitempty"`
SuggestedTags []string `json:"suggested_tags,omitempty"` SuggestedTags []string `json:"suggested_tags,omitempty"`
} }
@ -94,6 +94,19 @@ func main() {
api.GET("/filter-tag", func(c *gin.Context) { api.GET("/filter-tag", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"tag": tagToFilter}) 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 // Serve static files for the frontend under /static
@ -214,15 +227,6 @@ func generateSuggestionsHandler(c *gin.Context) {
// updateDocumentsHandler updates documents with new titles // updateDocumentsHandler updates documents with new titles
func updateDocumentsHandler(c *gin.Context) { func updateDocumentsHandler(c *gin.Context) {
ctx := c.Request.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 var documents []Document
if err := c.ShouldBindJSON(&documents); err != nil { if err := c.ShouldBindJSON(&documents); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)}) c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request payload: %v", err)})
@ -230,7 +234,7 @@ func updateDocumentsHandler(c *gin.Context) {
return return
} }
err = updateDocuments(ctx, paperlessBaseURL, paperlessAPIToken, documents, paperlessGptTagID) err := updateDocuments(ctx, paperlessBaseURL, paperlessAPIToken, documents)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error updating documents: %v", err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error updating documents: %v", err)})
log.Printf("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 return nil, err
} }
allTags, err := getAllTags(ctx, baseURL, apiToken)
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))
for i, resultTagID := range result.Tags {
for tagName, tagID := range allTags {
if resultTagID == tagID {
tagNames[i] = tagName
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: result.Tags, Tags: tagNames,
}) })
} }
@ -489,7 +507,7 @@ Content:
return strings.TrimSpace(strings.Trim(completion.Choices[0].Content, "\"")), nil 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{} client := &http.Client{}
// Fetch all available tags // Fetch all available tags
@ -505,15 +523,14 @@ func updateDocuments(ctx context.Context, baseURL, apiToken string, documents []
updatedFields := make(map[string]interface{}) updatedFields := make(map[string]interface{})
newTags := []int{} newTags := []int{}
for _, tagID := range document.Tags {
if tagID != paperlessGptTagID {
newTags = append(newTags, tagID)
}
}
// Map suggested tag names to IDs // Map suggested tag names to IDs
for _, tagName := range document.SuggestedTags { for _, tagName := range document.SuggestedTags {
if tagID, exists := availableTags[tagName]; exists { if tagID, exists := availableTags[tagName]; exists {
// Skip the tag that we are filtering
if tagName == tagToFilter {
continue
}
newTags = append(newTags, tagID) newTags = append(newTags, tagID)
} else { } else {
log.Printf("Tag '%s' does not exist in paperless-ngx, skipping.", tagName) 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 updatedFields["title"] = suggestedTitle
// Send the update request
url := fmt.Sprintf("%s/api/documents/%d/", baseURL, documentID) url := fmt.Sprintf("%s/api/documents/%d/", baseURL, documentID)
jsonData, err := json.Marshal(updatedFields) jsonData, err := json.Marshal(updatedFields)

View file

@ -11,8 +11,11 @@
"@headlessui/react": "^2.1.8", "@headlessui/react": "^2.1.8",
"@heroicons/react": "^2.1.5", "@heroicons/react": "^2.1.5",
"axios": "^1.7.7", "axios": "^1.7.7",
"classnames": "^2.5.1",
"prop-types": "^15.8.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-tag-autocomplete": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.0", "@eslint/js": "^9.9.0",
@ -1861,6 +1864,11 @@
"node": ">= 6" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2905,7 +2913,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -3224,6 +3231,16 @@
"node": ">= 0.8.0" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -3281,6 +3298,22 @@
"react": "^18.3.1" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View file

@ -13,8 +13,11 @@
"@headlessui/react": "^2.1.8", "@headlessui/react": "^2.1.8",
"@heroicons/react": "^2.1.5", "@heroicons/react": "^2.1.5",
"axios": "^1.7.7", "axios": "^1.7.7",
"classnames": "^2.5.1",
"prop-types": "^15.8.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-tag-autocomplete": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.0", "@eslint/js": "^9.9.0",

View file

@ -7,32 +7,113 @@ import {
import { ArrowPathIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { ArrowPathIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import axios from "axios"; import axios from "axios";
import React, { Fragment, useEffect, useState } from "react"; 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 { interface Document {
id: number; id: number;
title: string; title: string;
content: string; content: string;
tags: string[];
suggested_title?: 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 DocumentProcessor: React.FC = () => {
const [documents, setDocuments] = useState<Document[]>([]); const [documents, setDocuments] = useState<Document[]>([]);
const [availableTags, setAvailableTags] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false); const [updating, setUpdating] = useState<boolean>(false);
const [successModalOpen, setSuccessModalOpen] = useState<boolean>(false); const [successModalOpen, setSuccessModalOpen] = useState<boolean>(false);
const [filterTag, setFilterTag] = useState<string | undefined>(undefined); 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 { try {
const response = await axios.get("/api/filter-tag"); // API endpoint to fetch filter tag const apiDocuments: ApiDocument[] = documents.map((doc) => ({
setFilterTag(response.data?.tag); ...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) { } 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 () => { const fetchDocuments = async () => {
try { try {
const response = await axios.get("/api/documents"); // API endpoint to fetch documents 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(() => { useEffect(() => {
if (documents.length === 0) { if (documents.length === 0) {
const interval = setInterval(() => { const interval = setInterval(() => {
@ -105,7 +148,6 @@ const DocumentProcessor: React.FC = () => {
Paperless GPT Paperless GPT
</h1> </h1>
{/* Handle empty documents with reload button */}
{documents.length === 0 && ( {documents.length === 0 && (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
<div className="text-xl font-semibold"> <div className="text-xl font-semibold">
@ -128,7 +170,7 @@ const DocumentProcessor: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Step 1: Document Preview */}
{!documents.some((doc) => doc.suggested_title) && ( {!documents.some((doc) => doc.suggested_title) && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@ -183,7 +225,6 @@ const DocumentProcessor: React.FC = () => {
</div> </div>
)} )}
{/* Step 2: Review Suggestions */}
{documents.some((doc) => doc.suggested_title) && ( {documents.some((doc) => doc.suggested_title) && (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-2xl font-semibold text-gray-700"> <h2 className="text-2xl font-semibold text-gray-700">
@ -234,23 +275,32 @@ const DocumentProcessor: React.FC = () => {
/> />
</td> </td>
<td className="px-4 py-3 text-sm text-gray-900"> <td className="px-4 py-3 text-sm text-gray-900">
<input <ReactTags
type="text" selected={doc.suggested_tags || []}
value={doc.suggested_tags?.join(", ")} suggestions={availableTags}
onChange={(e) => {
onAdd={(tag) => {
const updatedTags = [...(doc.suggested_tags || []), { value: tag.value as string, label: tag.label }];
const updatedDocuments = documents.map((d) => const updatedDocuments = documents.map((d) =>
d.id === doc.id d.id === doc.id
? { ? { ...d, suggested_tags: updatedTags }
...d,
suggested_tags: e.target.value
.split(",")
.map((tag) => tag.trim()),
}
: d : d
); );
setDocuments(updatedDocuments); 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> </td>
</tr> </tr>
@ -279,7 +329,6 @@ const DocumentProcessor: React.FC = () => {
</div> </div>
)} )}
{/* Success Modal */}
<Transition show={successModalOpen} as={Fragment}> <Transition show={successModalOpen} as={Fragment}>
<Dialog <Dialog
as="div" as="div"
@ -289,7 +338,6 @@ const DocumentProcessor: React.FC = () => {
onClose={setSuccessModalOpen} 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"> <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 <TransitionChild
as="div" as="div"
enter="ease-out duration-300" 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" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</TransitionChild> </TransitionChild>
{/* Centering trick */}
<span <span
className="hidden sm:inline-block sm:align-middle sm:h-screen" className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true" aria-hidden="true"
@ -310,7 +357,6 @@ const DocumentProcessor: React.FC = () => {
&#8203; &#8203;
</span> </span>
{/* Modal content */}
<TransitionChild <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -347,7 +393,6 @@ const DocumentProcessor: React.FC = () => {
<button <button
onClick={() => { onClick={() => {
setSuccessModalOpen(false); setSuccessModalOpen(false);
// Optionally reset or fetch documents again
setDocuments([]); setDocuments([]);
setLoading(true); setLoading(true);
axios.get("/api/documents").then((response) => { axios.get("/api/documents").then((response) => {
@ -369,4 +414,4 @@ const DocumentProcessor: React.FC = () => {
); );
}; };
export default DocumentProcessor; export default DocumentProcessor;