Browse Source

FEATURE: ToDo file attachments

master
parent
commit
5979f21c52
  1. 110
      pages/category.html
  2. 3
      src/db/db.go
  3. 19
      src/db/todo.go
  4. 94
      src/server/endpoints.go
  5. 1
      src/server/server.go
  6. 1
      src/server/validation.go
  7. 10
      translations/ENG/category.json
  8. 10
      translations/RU/category.json

110
pages/category.html

@ -57,10 +57,10 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p id="modalToDoErrorMessage"></p>
<div> <div>
<strong>{{index .Translation "category modal todo text"}}</strong> <strong>{{index .Translation "category modal todo text"}}</strong>
<span id="modalTodoTextDisplay"></span> <span id="modalTodoTextDisplay"></span>
<!-- <input type="text" id="modalTodoTextInput" class="form-control" style="display: none;"> -->
<textarea id="modalTodoTextInput" class="form-control" style="display: none;"></textarea> <textarea id="modalTodoTextInput" class="form-control" style="display: none;"></textarea>
</div> </div>
<div> <div>
@ -74,10 +74,21 @@
<div> <div>
<strong>{{index .Translation "category modal todo completion"}}</strong> <span id="modalTodoCompletionTime"></span> <strong>{{index .Translation "category modal todo completion"}}</strong> <span id="modalTodoCompletionTime"></span>
</div> </div>
<div>
<img id="modalTodoImage" class="img-fluid" style="display: none;"> <img id="modalTodoImage" class="img-fluid" style="display: none;">
</div> </div>
<div id="modalTodoFile" class="mb-3" style="display: none;">
<label for="modalFileInput">{{ index .Translation "category modal file" }}</label>
<input type="file" id="modalFileInput" class="form-control">
</div>
<div id="modalTodoFileDownload" class="mb-3">
<button class="btn btn-primary" onclick="downloadAttachedFile();">
{{ index .Translation "category file download button"}}
</button>
</div>
</div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{index .Translation "category modal close button"}}</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="toggleEditMode(false);">{{index .Translation "category modal close button"}}</button>
<button type="button" class="btn btn-primary" id="editButton" style="display: none;" onclick="toggleEditMode(true)">{{index .Translation "category modal edit button"}}</button> <button type="button" class="btn btn-primary" id="editButton" style="display: none;" onclick="toggleEditMode(true)">{{index .Translation "category modal edit button"}}</button>
<button type="button" class="btn btn-success" id="saveButton" style="display: none;" onclick="saveEditedTodo()">{{index .Translation "category modal save button"}}</button> <button type="button" class="btn btn-success" id="saveButton" style="display: none;" onclick="saveEditedTodo()">{{index .Translation "category modal save button"}}</button>
</div> </div>
@ -146,10 +157,10 @@
{{ if not .IsDone }} {{ if not .IsDone }}
<tr draggable="true" id="todo-{{.ID}}" ondragstart="dragStart(event);"> <tr draggable="true" id="todo-{{.ID}}" ondragstart="dragStart(event);">
<!-- Do not display long texts fully --> <!-- Do not display long texts fully -->
{{ if lt (len .Text) 25 }} {{ if lt (len .Text) 35 }}
<td class="todo-text text-wrap text-break">{{ .Text }}</td> <td class="todo-text text-wrap text-break">{{ .Text }}</td>
{{ else }} {{ else }}
<td class="todo-text text-wrap text-break">{{ printf "%.25s" .Text }}......</td> <td class="todo-text text-wrap text-break">{{ printf "%.35s" .Text }}......</td>
{{ end }} {{ end }}
{{ if not .Image }} {{ if not .Image }}
@ -159,17 +170,17 @@
<td><img class="todo-image" src='{{ printf "%s" .Image }}' width="64px" height="64px"></td> <td><img class="todo-image" src='{{ printf "%s" .Image }}' width="64px" height="64px"></td>
{{ end }} {{ end }}
<td class="todo-created">{{ .TimeCreated }}</td> <td class="todo-created text-wrap text-break">{{ .TimeCreated }}</td>
<td class="todo-due">{{ .Due }}</td> <td class="todo-due text-wrap text-break">{{ .Due }}</td>
<td class="todo-due-unix" style="display: none;">{{ .DueUnix }}</td> <td class="todo-due-unix text-wrap text-break" style="display: none;">{{ .DueUnix }}</td>
<td> <td class="text-wrap text-break">
<button class="btn btn-success" onclick="markAsDoneRefresh('{{.ID}}');"> <button class="btn btn-success" onclick="markAsDoneRefresh('{{.ID}}');">
<img src='/static/images/check.svg'> <img src='/static/images/check.svg'>
</button> </button>
<button class="btn btn-danger" onclick="openDeleteModal('{{.ID}}');"> <button class="btn btn-danger" onclick="openDeleteModal('{{.ID}}');">
<img src='/static/images/trash3-fill.svg'> <img src='/static/images/trash3-fill.svg'>
</button> </button>
<button class="btn btn-secondary" onclick="openTodoModal('{{.ID}}', String.raw`{{.Text}}`, '{{.TimeCreated}}', '{{.Due}}', null, '{{ printf "%s" .Image }}', true);"> <button class="btn btn-secondary" onclick="openTodoModal('{{.ID}}', String.raw`{{.Text}}`, '{{.TimeCreated}}', '{{.Due}}', null, '{{ printf "%s" .Image }}', {{if not .File }}false{{else}}true{{end}}, true);">
<img src="/static/images/journal-arrow-up.svg"> <img src="/static/images/journal-arrow-up.svg">
</button> </button>
</td> </td>
@ -192,10 +203,10 @@
{{ if .IsDone }} {{ if .IsDone }}
<tr> <tr>
<!-- Do not display long texts fully --> <!-- Do not display long texts fully -->
{{ if lt (len .Text) 25 }} {{ if lt (len .Text) 35 }}
<td class="todo-text text-wrap text-break">{{ .Text }}</td> <td class="todo-text text-wrap text-break">{{ .Text }}</td>
{{ else }} {{ else }}
<td class="todo-text text-wrap text-break">{{ printf "%.25s" .Text }}......</td> <td class="todo-text text-wrap text-break">{{ printf "%.35s" .Text }}......</td>
{{ end }} {{ end }}
{{ if not .Image }} {{ if not .Image }}
@ -205,13 +216,13 @@
<td><img src='{{ printf "%s" .Image }}' width="64px" height="64px"></td> <td><img src='{{ printf "%s" .Image }}' width="64px" height="64px"></td>
{{ end }} {{ end }}
<td>{{ .TimeCreated }}</td> <td class="text-wrap text-break">{{ .TimeCreated }}</td>
<td>{{ .CompletionTime }}</td> <td class="text-wrap text-break">{{ .CompletionTime }}</td>
<td> <td class="text-wrap text-break">
<button class="btn btn-danger" onclick="deleteTodoRefresh('{{.ID}}');"> <button class="btn btn-danger" onclick="deleteTodoRefresh('{{.ID}}');">
<img src='/static/images/trash3-fill.svg'> <img src='/static/images/trash3-fill.svg'>
</button> </button>
<button class="btn btn-secondary" onclick="openTodoModal('{{.ID}}', String.raw`{{.Text}}`, '{{.TimeCreated}}', '{{.Due}}', '{{.CompletionTime}}', '{{ printf "%s" .Image }}', false);"> <button class="btn btn-secondary" onclick="openTodoModal('{{.ID}}', String.raw`{{.Text}}`, '{{.TimeCreated}}', '{{.Due}}', '{{.CompletionTime}}', '{{ printf "%s" .Image }}', {{if not .File }}false{{else}}true{{end}}, false);">
<img src="/static/images/journal-arrow-up.svg"> <img src="/static/images/journal-arrow-up.svg">
</button> </button>
</td> </td>
@ -308,7 +319,7 @@ async function drop(event) {
let viewedTodoID; let viewedTodoID;
function openTodoModal(id, text, created, due, completionTime, image, editable) { function openTodoModal(id, text, created, due, completionTime, image, hasFile, editable) {
viewedTodoID = id; viewedTodoID = id;
document.getElementById('modalTodoTextDisplay').innerText = text; document.getElementById('modalTodoTextDisplay').innerText = text;
@ -321,11 +332,17 @@ function openTodoModal(id, text, created, due, completionTime, image, editable)
let img = document.getElementById('modalTodoImage'); let img = document.getElementById('modalTodoImage');
if (img) { if (img) {
img.src = image; img.src = image;
img.style.display = 'block'; img.style.display = 'inline';
} else { } else {
img.style.display = 'none'; img.style.display = 'none';
} }
if (hasFile) {
document.getElementById("modalTodoFileDownload").style.display = "inline";
} else {
document.getElementById("modalTodoFileDownload").style.display = "none";
}
let editButton = document.getElementById("editButton"); let editButton = document.getElementById("editButton");
if (editable) { if (editable) {
// Show "Edit" button // Show "Edit" button
@ -345,9 +362,20 @@ async function saveEditedTodo() {
document.getElementById('modalTodoDueDisplay').innerText = updatedDue; document.getElementById('modalTodoDueDisplay').innerText = updatedDue;
const updatedDueUnix = Date.parse(updatedDue) / 1000; 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(); window.location.reload();
} }
@ -358,10 +386,54 @@ function toggleEditMode(isEditing) {
document.getElementById('modalTodoTextInput').style.display = isEditing ? 'inline' : 'none'; document.getElementById('modalTodoTextInput').style.display = isEditing ? 'inline' : 'none';
document.getElementById('modalTodoDueDisplay').style.display = isEditing ? 'none' : 'inline'; document.getElementById('modalTodoDueDisplay').style.display = isEditing ? 'none' : 'inline';
document.getElementById('modalTodoDueInput').style.display = isEditing ? 'inline' : 'none'; 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('editButton').style.display = isEditing ? 'none' : 'inline';
document.getElementById('saveButton').style.display = isEditing ? 'inline' : 'none'; 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.addEventListener('DOMContentLoaded', async function() {
document.getElementById("newTodoText").focus(); document.getElementById("newTodoText").focus();

3
src/db/db.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list 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 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 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, is_done INTEGER,
completion_time_unix INTEGER, completion_time_unix INTEGER,
image BLOB, image BLOB,
file BLOB,
FOREIGN KEY(group_id) REFERENCES todo_groups(id), FOREIGN KEY(group_id) REFERENCES todo_groups(id),
FOREIGN KEY(owner_email) REFERENCES users(email))`, FOREIGN KEY(owner_email) REFERENCES users(email))`,
) )

19
src/db/todo.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list 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 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 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"` IsDone bool `json:"isDone"`
CompletionTimeUnix uint64 `json:"completionTimeUnix"` CompletionTimeUnix uint64 `json:"completionTimeUnix"`
Image []byte `json:"image"` Image []byte `json:"image"`
File []byte `json:"file"`
TimeCreated string TimeCreated string
CompletionTime string CompletionTime string
Due string Due string
@ -62,6 +63,7 @@ func scanTodo(rows *sql.Rows) (*Todo, error) {
&newTodo.IsDone, &newTodo.IsDone,
&newTodo.CompletionTimeUnix, &newTodo.CompletionTimeUnix,
&newTodo.Image, &newTodo.Image,
&newTodo.File,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -119,7 +121,7 @@ func (db *DB) GetTodos() ([]*Todo, error) {
// Creates a new TODO in the database // Creates a new TODO in the database
func (db *DB) CreateTodo(todo Todo) error { func (db *DB) CreateTodo(todo Todo) error {
_, err := db.Exec( _, 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.GroupID,
todo.Text, todo.Text,
todo.TimeCreatedUnix, todo.TimeCreatedUnix,
@ -128,6 +130,7 @@ func (db *DB) CreateTodo(todo Todo) error {
todo.IsDone, todo.IsDone,
todo.CompletionTimeUnix, todo.CompletionTimeUnix,
todo.Image, todo.Image,
todo.File,
) )
return err 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 // Updates TODO's due date, text, done state, completion time and group id with image
func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error { func (db *DB) UpdateTodo(todoID uint64, updatedTodo Todo) error {
_, err := db.Exec( _, 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.GroupID,
updatedTodo.DueUnix, updatedTodo.DueUnix,
updatedTodo.Text, updatedTodo.Text,
updatedTodo.IsDone, updatedTodo.IsDone,
updatedTodo.CompletionTimeUnix, updatedTodo.CompletionTimeUnix,
updatedTodo.Image, updatedTodo.Image,
updatedTodo.File,
todoID, todoID,
) )
@ -192,6 +196,10 @@ func (db *DB) UpdateTodoSoft(todoID uint64, updatedTodo Todo) error {
updates = append(updates, "image=?") updates = append(updates, "image=?")
args = append(args, updatedTodo.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 { if len(updates) == 0 {
return nil return nil
@ -311,3 +319,8 @@ func (db *DB) GetUserTodosDue(userEmail string, tMinusSec uint64) ([]*Todo, erro
return todos, nil 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
}

94
src/server/endpoints.go

@ -422,6 +422,100 @@ func (s *Server) EndpointUserGet(w http.ResponseWriter, req *http.Request) {
w.Write(userDBBytes) 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) { func (s *Server) EndpointTodoUpdate(w http.ResponseWriter, req *http.Request) {
defer req.Body.Close() defer req.Body.Close()

1
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/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
mux.HandleFunc("/api/todo/update/", server.EndpointTodoUpdate) // 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/todo/markdone/", server.EndpointTodoMarkDone) // Specific
mux.HandleFunc("/api/group/create", server.EndpointTodoGroupCreate) // Non specific mux.HandleFunc("/api/group/create", server.EndpointTodoGroupCreate) // Non specific
mux.HandleFunc("/api/group/get/", server.EndpointTodoGroupGet) // Specific mux.HandleFunc("/api/group/get/", server.EndpointTodoGroupGet) // Specific

1
src/server/validation.go

@ -34,6 +34,7 @@ const (
MaxEmailLength uint = 60 MaxEmailLength uint = 60
MaxPasswordLength uint = 250 MaxPasswordLength uint = 250
MaxTodoTextLength uint = 250 MaxTodoTextLength uint = 250
MaxTodoFileSizeBytes uint = 3145728 // 3MB
) )
// Check if user is valid. Returns false and a reason-string if not // Check if user is valid. Returns false and a reason-string if not

10
translations/ENG/category.json

@ -130,6 +130,16 @@
"id": "category js button show todo", "id": "category js button show todo",
"message": "Show To Do", "message": "Show To Do",
"translation": "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"
} }
] ]
} }

10
translations/RU/category.json

@ -130,6 +130,16 @@
"id": "category js button show todo", "id": "category js button show todo",
"message": "Show To Do", "message": "Show To Do",
"translation": "К невыполненным" "translation": "К невыполненным"
},
{
"id": "category modal file",
"message": "Attach File",
"translation": "Вложить Файл"
},
{
"id": "category file download button",
"message": "Download Attached File",
"translation": "Скачать Вложенный Файл"
} }
] ]
} }
Loading…
Cancel
Save