mirror of
https://github.com/icereed/paperless-gpt.git
synced 2025-03-12 21:08:00 -05:00
parent
e87d80e5bf
commit
c7b5c6a060
4 changed files with 183 additions and 84 deletions
54
main.go
54
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)
|
||||
|
|
37
web-app/package-lock.json
generated
37
web-app/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 response = await axios.get("/api/filter-tag"); // API endpoint to fetch filter tag
|
||||
setFilterTag(response.data?.tag);
|
||||
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 filter tag:", 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<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 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 = () => {
|
|||
​
|
||||
</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) => {
|
||||
|
|
Loading…
Reference in a new issue