diff --git a/pages/category.html b/pages/category.html
index 6431d2f..541954f 100644
--- a/pages/category.html
+++ b/pages/category.html
@@ -57,10 +57,10 @@
+
{{index .Translation "category modal todo text"}}
-
@@ -74,10 +74,21 @@
{{index .Translation "category modal todo completion"}}
-
![]()
+
+
![]()
+
+
+
+
+
+
+
+
@@ -146,10 +157,10 @@
{{ if not .IsDone }}
- {{ if lt (len .Text) 25 }}
+ {{ if lt (len .Text) 35 }}
{{ .Text }} |
{{ else }}
- {{ printf "%.25s" .Text }}...... |
+ {{ printf "%.35s" .Text }}...... |
{{ end }}
{{ if not .Image }}
@@ -159,17 +170,17 @@
 |
{{ end }}
- {{ .TimeCreated }} |
- {{ .Due }} |
- {{ .DueUnix }} |
-
+ | {{ .TimeCreated }} |
+ {{ .Due }} |
+ {{ .DueUnix }} |
+
- |
@@ -192,10 +203,10 @@
{{ if .IsDone }}
- {{ if lt (len .Text) 25 }}
+ {{ if lt (len .Text) 35 }}
{{ .Text }} |
{{ else }}
- {{ printf "%.25s" .Text }}...... |
+ {{ printf "%.35s" .Text }}...... |
{{ end }}
{{ if not .Image }}
@@ -205,13 +216,13 @@
 |
{{ end }}
- {{ .TimeCreated }} |
- {{ .CompletionTime }} |
-
+ | {{ .TimeCreated }} |
+ {{ .CompletionTime }} |
+
-
+
|
@@ -308,7 +319,7 @@ async function drop(event) {
let viewedTodoID;
-function openTodoModal(id, text, created, due, completionTime, image, editable) {
+function openTodoModal(id, text, created, due, completionTime, image, hasFile, editable) {
viewedTodoID = id;
document.getElementById('modalTodoTextDisplay').innerText = text;
@@ -321,11 +332,17 @@ function openTodoModal(id, text, created, due, completionTime, image, editable)
let img = document.getElementById('modalTodoImage');
if (img) {
img.src = image;
- img.style.display = 'block';
+ img.style.display = 'inline';
} else {
img.style.display = 'none';
}
+ if (hasFile) {
+ document.getElementById("modalTodoFileDownload").style.display = "inline";
+ } else {
+ document.getElementById("modalTodoFileDownload").style.display = "none";
+ }
+
let editButton = document.getElementById("editButton");
if (editable) {
// Show "Edit" button
@@ -345,9 +362,20 @@ async function saveEditedTodo() {
document.getElementById('modalTodoDueDisplay').innerText = updatedDue;
const updatedDueUnix = Date.parse(updatedDue) / 1000;
- toggleEditMode(false);
- await updateTodo(viewedTodoID, {"text":updatedText, "dueUnix":updatedDueUnix, "isDone":false});
+ let response = await updateTodo(viewedTodoID, {"text":updatedText, "dueUnix":updatedDueUnix, "isDone":false});
+ if (!response.ok) {
+ document.getElementById("modalToDoErrorMessage").innerText = await response.text();
+ return;
+ }
+
+ let result = await uploadAttachedFile(viewedTodoID);
+ if (!result) {
+ alert("Failed to upload attachment file");
+ return;
+ }
+
+ toggleEditMode(false);
window.location.reload();
}
@@ -358,10 +386,54 @@ function toggleEditMode(isEditing) {
document.getElementById('modalTodoTextInput').style.display = isEditing ? 'inline' : 'none';
document.getElementById('modalTodoDueDisplay').style.display = isEditing ? 'none' : 'inline';
document.getElementById('modalTodoDueInput').style.display = isEditing ? 'inline' : 'none';
+ document.getElementById('modalTodoFile').style.display = isEditing ? 'inline' : 'none';
+ document.getElementById('modalTodoFileDownload').style.display = isEditing ? 'none' : 'inline';
document.getElementById('editButton').style.display = isEditing ? 'none' : 'inline';
document.getElementById('saveButton').style.display = isEditing ? 'inline' : 'none';
}
+async function downloadAttachedFile() {
+ await fetch("/api/todo/file/"+viewedTodoID, {})
+ .then(res => res.blob())
+ .then(blob => {
+ var file = window.URL.createObjectURL(blob);
+ var link = document.createElement("a");
+ link.href = file;
+ link.download = "fileAttachment-"+viewedTodoID;
+ link.innerText = "Download link";
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ });
+}
+
+async function uploadAttachedFile(todoID) {
+ let todoFileInput = document.getElementById("modalFileInput");
+ if (todoFileInput.files.length === 0 ) {
+ return false;
+ }
+ if (todoFileInput.files.item(0).size > 3145728) {
+ todoFileInput.setCustomValidity("File size exceeded 3MB");
+ return false;
+ }
+
+ let file = todoFileInput.files[0];
+ let data = new FormData();
+ data.append("file", file);
+ let response = await fetch("/api/todo/file/"+todoID, {
+ method: "POST",
+ body: data
+ });
+
+ if (!response.ok) {
+ document.getElementById("modalToDoErrorMessage").innerText = "";
+ return false;
+ } else {
+ document.getElementById("modalToDoErrorMessage").innerText = await response.text();
+ return true;
+ }
+}
+
document.addEventListener('DOMContentLoaded', async function() {
document.getElementById("newTodoText").focus();
diff --git a/src/db/db.go b/src/db/db.go
index fb80438..79594d1 100644
--- a/src/db/db.go
+++ b/src/db/db.go
@@ -1,6 +1,6 @@
/*
dela - web TODO list
- Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
+ Copyright (C) 2023, 2024, 2025 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@@ -79,6 +79,7 @@ func setUpTables(db *DB) error {
is_done INTEGER,
completion_time_unix INTEGER,
image BLOB,
+ file BLOB,
FOREIGN KEY(group_id) REFERENCES todo_groups(id),
FOREIGN KEY(owner_email) REFERENCES users(email))`,
)
diff --git a/src/db/todo.go b/src/db/todo.go
index f6ad5b8..0855f58 100644
--- a/src/db/todo.go
+++ b/src/db/todo.go
@@ -1,6 +1,6 @@
/*
dela - web TODO list
- Copyright (C) 2023, 2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
+ Copyright (C) 2023, 2024, 2025 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@@ -36,6 +36,7 @@ type Todo struct {
IsDone bool `json:"isDone"`
CompletionTimeUnix uint64 `json:"completionTimeUnix"`
Image []byte `json:"image"`
+ File []byte `json:"file"`
TimeCreated string
CompletionTime string
Due string
@@ -62,6 +63,7 @@ func scanTodo(rows *sql.Rows) (*Todo, error) {
&newTodo.IsDone,
&newTodo.CompletionTimeUnix,
&newTodo.Image,
+ &newTodo.File,
)
if err != nil {
return nil, err
@@ -119,7 +121,7 @@ func (db *DB) GetTodos() ([]*Todo, error) {
// Creates a new TODO in the database
func (db *DB) CreateTodo(todo Todo) error {
_, err := db.Exec(
- "INSERT INTO todos(group_id, text, time_created_unix, due_unix, owner_email, is_done, completion_time_unix, image) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO todos(group_id, text, time_created_unix, due_unix, owner_email, is_done, completion_time_unix, image, file) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
todo.GroupID,
todo.Text,
todo.TimeCreatedUnix,
@@ -128,6 +130,7 @@ func (db *DB) CreateTodo(todo Todo) error {
todo.IsDone,
todo.CompletionTimeUnix,
todo.Image,
+ todo.File,
)
return err
@@ -146,13 +149,14 @@ func (db *DB) DeleteTodo(id uint64) error {
// Updates TODO's due date, text, done state, completion time and group id with image
func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error {
_, err := db.Exec(
- "UPDATE todos SET group_id=?, due_unix=?, text=?, is_done=?, completion_time_unix=?, image=? WHERE id=?",
+ "UPDATE todos SET group_id=?, due_unix=?, text=?, is_done=?, completion_time_unix=?, image=?, file=? WHERE id=?",
updatedTodo.GroupID,
updatedTodo.DueUnix,
updatedTodo.Text,
updatedTodo.IsDone,
updatedTodo.CompletionTimeUnix,
updatedTodo.Image,
+ updatedTodo.File,
todoID,
)
@@ -192,6 +196,10 @@ func (db *DB) UpdateTodoSoft(todoID uint64, updatedTodo Todo) error {
updates = append(updates, "image=?")
args = append(args, updatedTodo.Image)
}
+ if !bytes.Equal(updatedTodo.File, originalTodo.File) && updatedTodo.File != nil {
+ updates = append(updates, "file=?")
+ args = append(args, updatedTodo.File)
+ }
if len(updates) == 0 {
return nil
@@ -311,3 +319,8 @@ func (db *DB) GetUserTodosDue(userEmail string, tMinusSec uint64) ([]*Todo, erro
return todos, nil
}
+
+func (db *DB) UpdateTodoFile(todoID uint64, newFile []byte) error {
+ _, err := db.Exec("UPDATE todos SET file=? WHERE id=?", newFile, todoID)
+ return err
+}
diff --git a/src/server/endpoints.go b/src/server/endpoints.go
index 4143548..2e5bb00 100644
--- a/src/server/endpoints.go
+++ b/src/server/endpoints.go
@@ -422,6 +422,100 @@ func (s *Server) EndpointUserGet(w http.ResponseWriter, req *http.Request) {
w.Write(userDBBytes)
}
+func (s *Server) EndpointTodoFile(w http.ResponseWriter, req *http.Request) {
+ defer req.Body.Close()
+
+ if req.Method != http.MethodPost && req.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Check authentication information
+ if !IsUserAuthorizedReq(req, s.db) {
+ http.Error(w, "Invalid user auth data", http.StatusForbidden)
+ return
+ }
+
+ // Obtain TODO ID
+ todoIDStr := path.Base(req.URL.Path)
+ todoID, err := strconv.ParseUint(todoIDStr, 10, 64)
+ if err != nil {
+ http.Error(w, "Invalid TODO ID", http.StatusBadRequest)
+ return
+ }
+
+ // Check if the user owns this TODO
+ if !s.db.DoesUserOwnTodo(todoID, GetEmailFromReq(req)) {
+ http.Error(w, "You don't own this TODO", http.StatusForbidden)
+ return
+ }
+
+ todo, err := s.db.GetTodo(todoID)
+ if err != nil {
+ http.Error(w, "Failed to retrieve this TODO", http.StatusInternalServerError)
+ logger.Error("[Server][EndpointTodoFile] Failed to get TODO with ID %d: %s", todoID, err)
+ return
+ }
+
+ switch req.Method {
+ case http.MethodGet:
+ // Retrieve file and send it
+ _, err := w.Write(todo.File)
+ if err != nil {
+ http.Error(w, "Failed to send file", http.StatusInternalServerError)
+ logger.Error("[Server][EndpointTodoFile] Failed to send TODO's file with ID %d: %s", todoID, err)
+ return
+ }
+
+ case http.MethodPost:
+ // Retrieve file and update database
+ // Parse form
+ err := req.ParseMultipartForm(int64(MaxTodoFileSizeBytes))
+ if err != nil {
+ logger.Error("[Server][EndpointTodoFile] Failed to parse multipart form: %s", err)
+ http.Error(w, "Failed to parse form", http.StatusBadRequest)
+ return
+ }
+
+ formFile, fileHeader, err := req.FormFile("file")
+ if err != nil {
+ logger.Error("[Server][EndpointTodoFile] Failed to retrieve file file from form: %s", err)
+ http.Error(w, "Failed to retrieve file", http.StatusInternalServerError)
+ return
+ }
+ defer formFile.Close()
+
+ // Check if thumbnail is good to go
+ if fileHeader.Size > int64(MaxTodoFileSizeBytes) {
+ logger.Error("[Server][EndpointTodoFile] File file is too big (%d)", fileHeader.Size)
+ http.Error(w, "Attachment File is too big", http.StatusBadRequest)
+ return
+ }
+
+ // Save attachment to database
+ fileData, err := io.ReadAll(formFile)
+ if err != nil {
+ logger.Error("[Server][EndpointTodoFile] Failed to read file from form: %s", err)
+ http.Error(w, "Failed to read Attachment File", http.StatusInternalServerError)
+ return
+ }
+
+ err = s.db.UpdateTodoFile(todoID, fileData)
+ if err != nil {
+ logger.Error("[Server][EndpointTodoFile] Failed to save attachment file: %s", err)
+ http.Error(w, "Failed to save Attachment File", http.StatusInternalServerError)
+ return
+ }
+
+ logger.Info("[Server][EndpointTodoFile] Successfully saved \"%s\" (%vMB) for %s (todoID: %d)",
+ fileHeader.Filename,
+ float32(fileHeader.Size)/1024.0/1024.0,
+ GetEmailFromReq(req),
+ todoID,
+ )
+ }
+}
+
func (s *Server) EndpointTodoUpdate(w http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
diff --git a/src/server/server.go b/src/server/server.go
index 7fc3bb1..1fa8737 100644
--- a/src/server/server.go
+++ b/src/server/server.go
@@ -305,6 +305,7 @@ func New(config conf.Conf) (*Server, error) {
mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
mux.HandleFunc("/api/todo/update/", server.EndpointTodoUpdate) // Specific
+ mux.HandleFunc("/api/todo/file/", server.EndpointTodoFile) // Specific
mux.HandleFunc("/api/todo/markdone/", server.EndpointTodoMarkDone) // Specific
mux.HandleFunc("/api/group/create", server.EndpointTodoGroupCreate) // Non specific
mux.HandleFunc("/api/group/get/", server.EndpointTodoGroupGet) // Specific
diff --git a/src/server/validation.go b/src/server/validation.go
index 85f3abb..6bcf91b 100644
--- a/src/server/validation.go
+++ b/src/server/validation.go
@@ -34,6 +34,7 @@ const (
MaxEmailLength uint = 60
MaxPasswordLength uint = 250
MaxTodoTextLength uint = 250
+ MaxTodoFileSizeBytes uint = 3145728 // 3MB
)
// Check if user is valid. Returns false and a reason-string if not
diff --git a/translations/ENG/category.json b/translations/ENG/category.json
index f71f7a4..d1bb6a8 100644
--- a/translations/ENG/category.json
+++ b/translations/ENG/category.json
@@ -130,6 +130,16 @@
"id": "category js button show todo",
"message": "Show To Do",
"translation": "Show To Do"
+ },
+ {
+ "id": "category modal file",
+ "message": "Attach File",
+ "translation": "Attach File"
+ },
+ {
+ "id": "category file download button",
+ "message": "Download Attached File",
+ "translation": "Download Attached File"
}
]
}
\ No newline at end of file
diff --git a/translations/RU/category.json b/translations/RU/category.json
index 60c3c72..4a5a225 100644
--- a/translations/RU/category.json
+++ b/translations/RU/category.json
@@ -130,6 +130,16 @@
"id": "category js button show todo",
"message": "Show To Do",
"translation": "К невыполненным"
+ },
+ {
+ "id": "category modal file",
+ "message": "Attach File",
+ "translation": "Вложить Файл"
+ },
+ {
+ "id": "category file download button",
+ "message": "Download Attached File",
+ "translation": "Скачать Вложенный Файл"
}
]
}
\ No newline at end of file