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>
</div>
<div class="modal-body">
<p id="modalToDoErrorMessage"></p>
<div>
<strong>{{index .Translation "category modal todo text"}}</strong>
<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>
</div>
<div>
@ -74,10 +74,21 @@
<div>
<strong>{{index .Translation "category modal todo completion"}}</strong> <span id="modalTodoCompletionTime"></span>
</div>
<div>
<img id="modalTodoImage" class="img-fluid" style="display: none;">
</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">
<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-success" id="saveButton" style="display: none;" onclick="saveEditedTodo()">{{index .Translation "category modal save button"}}</button>
</div>
@ -146,10 +157,10 @@
{{ if not .IsDone }}
<tr draggable="true" id="todo-{{.ID}}" ondragstart="dragStart(event);">
<!-- 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>
{{ 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 }}
{{ if not .Image }}
@ -159,17 +170,17 @@
<td><img class="todo-image" src='{{ printf "%s" .Image }}' width="64px" height="64px"></td>
{{ end }}
<td class="todo-created">{{ .TimeCreated }}</td>
<td class="todo-due">{{ .Due }}</td>
<td class="todo-due-unix" style="display: none;">{{ .DueUnix }}</td>
<td>
<td class="todo-created text-wrap text-break">{{ .TimeCreated }}</td>
<td class="todo-due text-wrap text-break">{{ .Due }}</td>
<td class="todo-due-unix text-wrap text-break" style="display: none;">{{ .DueUnix }}</td>
<td class="text-wrap text-break">
<button class="btn btn-success" onclick="markAsDoneRefresh('{{.ID}}');">
<img src='/static/images/check.svg'>
</button>
<button class="btn btn-danger" onclick="openDeleteModal('{{.ID}}');">
<img src='/static/images/trash3-fill.svg'>
</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">
</button>
</td>
@ -192,10 +203,10 @@
{{ if .IsDone }}
<tr>
<!-- 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>
{{ 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 }}
{{ if not .Image }}
@ -205,13 +216,13 @@
<td><img src='{{ printf "%s" .Image }}' width="64px" height="64px"></td>
{{ end }}
<td>{{ .TimeCreated }}</td>
<td>{{ .CompletionTime }}</td>
<td>
<td class="text-wrap text-break">{{ .TimeCreated }}</td>
<td class="text-wrap text-break">{{ .CompletionTime }}</td>
<td class="text-wrap text-break">
<button class="btn btn-danger" onclick="deleteTodoRefresh('{{.ID}}');">
<img src='/static/images/trash3-fill.svg'>
</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">
</button>
</td>
@ -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();

3
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))`,
)

19
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
}

94
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()

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/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

1
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

10
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"
}
]
}

10
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": "Скачать Вложенный Файл"
}
]
}
Loading…
Cancel
Save