29 Commits

Author SHA1 Message Date
4fc8e81e97 Remove tag handling routes from server.js and use only tasks.js
Fix #22
2025-02-02 19:28:31 +01:00
37a7e5e67a Implement tag management with SQLite; update save and retrieve endpoints 2025-02-02 19:17:35 +01:00
c98aea1b06 Add container name for org-todo-pwa service in docker-compose.yml 2025-01-31 19:07:36 +01:00
b277b1f8f0 Enhance task synchronization and logging; update service worker caching strategy 2025-01-31 18:59:16 +01:00
2a7005f53c Enhance logging for unauthorized access and task/tag operations; remove unused debug statement 2025-01-30 22:14:25 +01:00
23a6d9b10f Refactor logging implementation and enhance authentication logging 2025-01-30 22:02:35 +01:00
2ebf92a5d5 Update Dockerfile, docker-compose, and package.json; add versioning script and styles for version display
Fix #21
2025-01-30 18:51:25 +01:00
d7e96db210 Removes app.js 2025-01-30 18:30:25 +01:00
4a9139e4d8 Split app.js in to smaller chunks 2025-01-30 18:18:29 +01:00
e74871bf94 Add SQLite session store and configure session middleware 2025-01-29 22:13:07 +01:00
a9dfb8d54d Enhance service worker with update handling and improve task scheduling format 2025-01-29 21:57:04 +01:00
502938b8cf Add timepicker initialization and combine scheduled date with time input 2025-01-29 21:43:06 +01:00
5413323a3c Refactor task formatting and extend session cookie expiration to one month 2025-01-29 21:31:11 +01:00
dfa616fc52 Update form placeholders and button text for improved clarity and localization 2025-01-26 19:16:09 +01:00
38fbf13fd7 Hide navigation and sidenav elements by default in the CSS 2025-01-25 20:49:50 +01:00
b39f0e7339 Set default date for datepicker to tomorrow and update navigation visibility 2025-01-25 12:05:33 +01:00
1122de442b Refactor navigation and form elements for improved usability and localization 2025-01-25 11:21:26 +01:00
09772d859d Fixar inloggningen. Menyn fortfarande ett problem 2025-01-25 10:13:59 +01:00
05d6cba8ef Fix #20 2025-01-25 09:04:25 +01:00
6864cfa14e Tar bort rest av basic auth 2025-01-25 08:38:54 +01:00
6159fc4ee8 .vscode 2025-01-25 08:23:05 +01:00
6520dcb78c Lägger till .vscode för att dölja ikoner etc. 2025-01-25 08:22:55 +01:00
c993848278 Merge pull request 'Längre intervall på healthcheck' (#17) from reset into main
Reviewed-on: #17
2025-01-24 23:17:55 +01:00
87feaa9880 Längre intervall på healthcheck 2025-01-24 23:17:02 +01:00
23a39247a1 Implement session-based authentication and add logout functionality
Fix #15
2025-01-24 22:02:54 +01:00
da2f568acf Implement server-side authentication and update login UI texts 2025-01-24 21:54:05 +01:00
1edcefbd64 Ta bort kolon vid tomma taggar.
Fixar #12
2025-01-24 21:40:16 +01:00
276587f1dc Merge pull request 'cleanup' (#11) from cleanup into main
Reviewed-on: #11
2025-01-24 21:34:53 +01:00
51656c0ee4 Merge pull request 'Lägg till autentisering och loggning, refaktorisera uppgifter till router' (#9) from cleanup into main
Reviewed-on: #9
2025-01-24 21:26:50 +01:00
22 changed files with 2442 additions and 361 deletions

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"files.exclude": {
"**/*.png": true,
"**/*.jpg": true,
"**/*.gif": true,
"**/*.svg": true,
"**/*.ico": true
}
}

View File

@@ -1,23 +1,24 @@
# Use the official Node.js image as the base image
FROM node:14
# Set the working directory
# Create app directory
WORKDIR /usr/src/app
# Copy package.json and package-lock.json
# Install app dependencies
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application code
# Copy app source code
COPY . .
# Run the build script to update the version number
RUN node build.js
# Expose the port the app runs on
EXPOSE 3044
# Set the DEBUG environment variable
ENV DEBUG=app
# Command to run the application
# Command to run the app
CMD ["node", "server.js"]

17
build.js Normal file
View File

@@ -0,0 +1,17 @@
const fs = require('fs');
const path = require('path');
// Generate version number with timestamp
const version = new Date().toISOString().replace(/[-:.]/g, '').slice(0, 15);
// Read the HTML file
const indexPath = path.join(__dirname, 'public', 'index.html');
let indexHtml = fs.readFileSync(indexPath, 'utf8');
// Replace the version placeholder with the generated version number
indexHtml = indexHtml.replace(/<!-- VERSION_PLACEHOLDER -->/g, `Version: ${version}`);
// Write the updated HTML back to the file
fs.writeFileSync(indexPath, indexHtml);
console.log(`Version number updated to: ${version}`);

View File

@@ -2,7 +2,7 @@ version: '3.8'
services:
org-todo-pwa:
image: org-todo-pwa
container_name: org-todo-pwa
build: .
ports:
- "3044:3044"
@@ -13,9 +13,11 @@ services:
- DEBUG=app
- AUTH_USERNAME=fredrik
- AUTH_PASSWORD=apa
- SESSION_SECRET=superheimlich # Add your session secret key
- NODE_ENV=production # Ensure the environment is set to production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3044"]
interval: 1m30s
interval: 5m
timeout: 10s
retries: 3
deploy:
@@ -24,5 +26,4 @@ services:
- "traefik.http.routers.plan.tls=true"
- "traefik.http.routers.plan.tls.certresolver=myhttpchallenge"
- "traefik.http.routers.plan.rule=Host(`todo.casablanca.wahlberg.se`)"
- "traefik.http.routers.plan.entrypoints=websecure"
- "traefik.http.routers.plan.entrypoints=websecure"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -4,95 +4,13 @@ const logger = createLogger({
level: 'info',
format: format.combine(
format.timestamp(),
format.json()
format.printf(info => `${info.timestamp} ${info.level}: ${info.message}`)
),
transports: [
new transports.Console(),
new transports.File({ filename: 'app.log' })
new transports.File({ filename: '/data/app.log' })
]
});
});
module.exports = logger;
// filepath: /home/fredrik/dev/org-todo-pwa/routes/tasks.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const logger = require('../logger');
const auth = require('../middleware/auth');
const router = express.Router();
const dataDir = '/data';
// Ensure the /data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Ensure the tags.json file exists
const tagsFilePath = path.join(dataDir, 'tags.json');
if (!fs.existsSync(tagsFilePath)) {
fs.writeFileSync(tagsFilePath, JSON.stringify([]));
}
// Protect the /add-task endpoint with authentication
router.post('/add-task', auth, async (req, res) => {
const { subject, description, scheduled } = req.body;
const currentDateTime = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
let orgFormattedData = `
* TODO ${subject}
SCHEDULED: <${scheduled}>
:LOGBOOK:
- State "TODO" from "TODO" [${currentDateTime}]
:END:
`;
if (description) {
orgFormattedData = `
* TODO ${subject}
${description}
SCHEDULED: <${scheduled}>
:LOGBOOK:
- State "TODO" from "TODO" [${currentDateTime}]
:END:
`;
}
const filePath = path.join(dataDir, 'tasks.org');
try {
await fs.promises.appendFile(filePath, orgFormattedData);
res.send({ message: 'Task added successfully!' });
} catch (err) {
logger.error('Error writing to file:', err);
res.status(500).send('Error writing to file.');
}
});
// Endpoint to save tags
router.post('/save-tags', auth, async (req, res) => {
const { tags } = req.body;
const filePath = path.join(dataDir, 'tags.json');
try {
await fs.promises.writeFile(filePath, JSON.stringify(tags));
res.send({ message: 'Tags saved successfully!' });
} catch (err) {
logger.error('Error saving tags:', err);
res.status(500).send('Error saving tags.');
}
});
// Endpoint to retrieve tags
router.get('/get-tags', auth, async (req, res) => {
const filePath = path.join(dataDir, 'tags.json');
try {
const data = await fs.promises.readFile(filePath);
const tags = JSON.parse(data);
res.send(tags);
} catch (err) {
logger.error('Error retrieving tags:', err);
res.status(500).send('Error retrieving tags.');
}
});
module.exports = router;

View File

@@ -1,15 +1,12 @@
const basicAuth = require('basic-auth');
const logger = require('../logger');
const auth = (req, res, next) => {
const user = basicAuth(req);
const username = process.env.AUTH_USERNAME; // Use environment variables
const password = process.env.AUTH_PASSWORD; // Use environment variables
if (user && user.name === username && user.pass === password) {
if (req.session && req.session.user) {
return next();
} else {
res.set('WWW-Authenticate', 'Basic realm="401"');
return res.status(401).send('Authentication required.');
res.status(401).send('Authentication required.');
logger.error(`Unauthorized access attempted from IP: ${req.ip}`);
}
};

1774
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,25 @@
{
"name": "pwa",
"name": "org-todo-pwa",
"version": "1.0.0",
"description": "",
"main": "app.js",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"build": "node build.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"basic-auth": "^2.0.1",
"body-parser": "^1.20.3",
"debug": "^4.4.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fs": "^0.0.1-security"
}
"body-parser": "^1.19.0",
"connect-sqlite3": "^0.9.11",
"cookie-parser": "^1.4.5",
"debug": "^4.3.1",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-session": "^1.17.1",
"fs": "^0.0.1-security",
"winston": "^3.17.0"
}
}

View File

@@ -1,132 +0,0 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}
document.addEventListener('DOMContentLoaded', function() {
const loginForm = document.getElementById('loginForm');
const loginContainer = document.getElementById('loginContainer');
const appContainer = document.getElementById('appContainer');
const loginMessage = document.getElementById('loginMessage');
// Check if user is already logged in
if (sessionStorage.getItem('loggedIn') === 'true') {
loginContainer.style.display = 'none';
appContainer.style.display = 'block';
loadTags();
}
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
// Simple authentication check (replace with your own logic)
if (username === 'fredrik' && password === 'apa') {
sessionStorage.setItem('loggedIn', 'true');
loginContainer.style.display = 'none';
appContainer.style.display = 'block';
loadTags();
} else {
loginMessage.textContent = 'Invalid username or password';
}
});
document.getElementById('taskForm').addEventListener('submit', function(e) {
e.preventDefault();
// Get form values
const subject = document.getElementById('subject').value;
const description = document.getElementById('description').value;
const scheduled = document.getElementById('scheduled').value;
const tagsInput = document.getElementById('tags').value;
const tags = tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag).join(':');
// Structure data for Org mode
const taskData = {
subject: `${subject} :${tags}:`,
description,
scheduled
};
// Save tags to server
const savedTags = JSON.parse(localStorage.getItem('tags')) || [];
const newTags = tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag && !savedTags.includes(tag));
const allTags = [...savedTags, ...newTags];
localStorage.setItem('tags', JSON.stringify(allTags));
fetch('/save-tags', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tags: allTags })
}).then(() => {
loadTags(); // Force refresh tags after saving
});
// Send data to backend using fetch
fetch('/add-task', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(taskData)
})
.then(response => response.json())
.then(data => {
document.getElementById('responseMessage').textContent = data.message;
})
.catch(error => {
document.getElementById('responseMessage').textContent = "Error saving task!";
});
});
// Set tomorrow's date as the default for the date input
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const tomorrowString = tomorrow.toISOString().split('T')[0];
document.getElementById('scheduled').value = tomorrowString;
// Initialize flatpickr with Swedish locale and Monday as the first day of the week
flatpickr("#scheduled", {
weekNumbers: true, // Show week numbers
firstDayOfWeek: 1 // Start weeks on Monday
});
// Load tags from server and initialize autocomplete
function loadTags() {
fetch('/get-tags')
.then(response => response.json())
.then(tags => {
localStorage.setItem('tags', JSON.stringify(tags));
const autocompleteData = {};
tags.forEach(tag => {
autocompleteData[tag] = null; // Materialize autocomplete requires a key-value pair
});
const tagsInput = document.getElementById('tags');
M.Autocomplete.init(tagsInput, {
data: autocompleteData,
onAutocomplete: function(selectedTag) {
const currentTags = tagsInput.value.split(',').map(tag => tag.trim()).filter(tag => tag);
if (!currentTags.includes(selectedTag)) {
currentTags.push(selectedTag);
tagsInput.value = currentTags.join(', ');
}
}
});
})
.catch(error => {
console.error('Error loading tags:', error);
});
}
});

72
public/css/style.css Normal file
View File

@@ -0,0 +1,72 @@
body {
font-family: Arial, sans-serif;
}
.container {
width: 300px;
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
}
#responseMessage {
text-align: center;
color: green;
}
/* Add styles for the version number */
#version {
color: #888; /* Subtle gray color */
font-size: 0.8em; /* Smaller font size */
text-align: right; /* Align text to the right */
margin: 0; /* Remove any default margin */
padding: 0; /* Remove any default padding */
}
.menu {
position: relative;
display: inline-block;
}
.hamburger {
font-size: 24px;
cursor: pointer;
background: none;
border: none;
}
.menu-content {
display: none;
position: absolute;
background-color: #f9f9f9;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
.menu-content button {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
background: none;
border: none;
width: 100%;
text-align: left;
}
.menu-content button:hover {
background-color: #f1f1f1;
}
.menu.show .menu-content {
display: block;
}
nav, .sidenav {
display: none;
}

View File

@@ -1,28 +1,24 @@
<!DOCTYPE html>
<html lang="sv-SE">
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fredriks todos</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json" />
<!-- Flatpickr for dates -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="css/style.css">
<link rel="manifest" href="manifest.json">
</head>
<body>
<div id="loginContainer" class="container">
<h1 class="center-align">Login</h1>
<h1 class="center-align">Logga in</h1>
<form id="loginForm">
<div class="input-field">
<label for="username">Username:</label>
<input type="text" id="username" required>
<input type="text" id="username" placeholder="Användarnamn" required>
<label for="username">Användarnamn</label>
</div>
<div class="input-field">
<label for="password">Password:</label>
<input type="password" id="password" required>
<input type="password" id="password" placeholder="Lösenord" required>
<label for="password">Lösenord</label>
</div>
<button class="btn waves-effect waves-light" type="submit">Login</button>
</form>
@@ -31,43 +27,49 @@
<div id="appContainer" class="container" style="display:none;">
<h1 class="center-align">TODO</h1>
<nav>
<div class="nav-wrapper">
<a href="#" class="brand-logo">Menu</a>
<a href="#" data-target="mobile-demo" class="sidenav-trigger"><i class="material-icons">menu</i></a>
</div>
</nav>
<ul class="sidenav" id="mobile-demo">
<li><a id="logoutButton">Logout</a></li>
</ul>
<form id="taskForm">
<div class="input-field">
<label for="subject">Uppgift:</label>
<input type="text" id="subject" required>
<input type="text" id="subject" placeholder="Vad ska göras?" required autocomplete="off">
<label for="subject">Uppgift</label>
</div>
<div class="input-field">
<label for="description">Beskrivning:</label>
<textarea id="description" class="materialize-textarea"></textarea>
<textarea id="description" class="materialize-textarea" placeholder="Beskrivning"></textarea>
<label for="description">Mer detaljer</label>
</div>
<div class="input-field">
<label for="scheduled">Datum:</label>
<input type="date" id="scheduled" lang="sv-SE" required>
<input type="text" id="scheduled" class="datepicker" required>
<label for="scheduled">Planerat datum</label>
</div>
<div class="input-field">
<label for="tags">Tags (separated by commas):</label>
<input type="text" id="tags" class="autocomplete">
<input type="text" id="time" class="timepicker">
<label for="time">Tid (valfritt)</label>
</div>
<button class="btn waves-effect waves-light" type="submit">Spara</button>
<div class="input-field">
<input type="text" id="tags" placeholder="Taggar" autocomplete="off">
<label for="tags">Taggar</label>
</div>
<button class="btn waves-effect waves-light" type="submit">Spara uppgift</button>
<p id="responseMessage"></p>
<p id="version"><!-- VERSION_PLACEHOLDER --></p>
</form>
<p id="responseMessage" class="center-align"></p>
</div>
<script src="app.js"></script>
<script>
// Set today's date as the default for the date input
const today = new Date().toISOString().split('T')[0];
document.getElementById('scheduled').value = today;
// Initialize flatpickr with Swedish locale and Monday as the first day of the week
flatpickr("#scheduled", {
weekNumbers: true, // Show week numbers
firstDayOfWeek: 1 // Start weeks on Monday
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script src="js/auth.js" type="module"></script>
<script src="js/tasks.js" type="module"></script>
<script src="js/tags.js" type="module"></script>
<script src="js/utils.js" type="module"></script>
<script src="js/main.js" type="module"></script>
</body>
</html>

25
public/js/auth.js Normal file
View File

@@ -0,0 +1,25 @@
export function checkSession() {
return fetch('/check-session')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
}
export function login(username, password) {
return fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa(username + ':' + password)
}
});
}
export function logout() {
return fetch('/logout', {
method: 'POST'
});
}

218
public/js/main.js Normal file
View File

@@ -0,0 +1,218 @@
import { checkSession, login, logout } from './auth.js';
import { saveTask } from './tasks.js';
import { saveTags, loadTags } from './tags.js';
import { idb } from './utils.js';
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New update available
console.log('New content is available; please refresh.');
if (confirm('New version available. Do you want to update?')) {
window.location.reload();
}
} else {
// Content is cached for offline use
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}
document.addEventListener('DOMContentLoaded', async function() {
const loginForm = document.getElementById('loginForm');
const loginContainer = document.getElementById('loginContainer');
const appContainer = document.getElementById('appContainer');
const loginMessage = document.getElementById('loginMessage');
const logoutButton = document.getElementById('logoutButton');
const taskForm = document.getElementById('taskForm');
const sidenav = document.querySelector('.sidenav');
if (!loginForm || !loginContainer || !appContainer || !loginMessage || !logoutButton || !taskForm || !sidenav) {
console.error('One or more elements are missing in the DOM');
return;
}
// Initialize Materialize components
M.Sidenav.init(sidenav);
// Calculate tomorrow's date
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
// Initialize datepicker with tomorrow as the default date and disable past dates
M.Datepicker.init(document.querySelectorAll('.datepicker'), {
format: 'yyyy-mm-dd',
defaultDate: tomorrow,
setDefaultDate: true,
firstDay: 1,
minDate: today // Disable past dates
});
// Initialize timepicker
M.Timepicker.init(document.querySelectorAll('.timepicker'), {
twelveHour: false // Use 24-hour format
});
// Check if user is already logged in
checkSession()
.then(data => {
if (data.loggedIn) {
loginContainer.style.display = 'none';
appContainer.style.display = 'block';
loadTags().then(tags => {
localStorage.setItem('tags', JSON.stringify(tags));
const autocompleteData = {};
tags.forEach(tag => {
autocompleteData[tag] = null; // Materialize autocomplete requires a key-value pair
});
const tagsInput = document.getElementById('tags');
M.Autocomplete.init(tagsInput, {
data: autocompleteData,
onAutocomplete: function(selectedTag) {
const currentTags = tagsInput.value.split(',').map(tag => tag.trim()).filter(tag => tag);
if (!currentTags.includes(selectedTag)) {
currentTags.push(selectedTag);
tagsInput.value = currentTags.join(', ');
}
}
});
});
} else {
loginContainer.style.display = 'block';
appContainer.style.display = 'none';
}
})
.catch(error => {
console.error('Error checking session:', error);
loginMessage.textContent = 'Error checking session. Please try again later.';
});
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
login(username, password)
.then(response => {
if (response.ok) {
sessionStorage.setItem('loggedIn', 'true');
loginContainer.style.display = 'none';
appContainer.style.display = 'block';
loadTags().then(tags => {
localStorage.setItem('tags', JSON.stringify(tags));
const autocompleteData = {};
tags.forEach(tag => {
autocompleteData[tag] = null; // Materialize autocomplete requires a key-value pair
});
const tagsInput = document.getElementById('tags');
M.Autocomplete.init(tagsInput, {
data: autocompleteData,
onAutocomplete: function(selectedTag) {
const currentTags = tagsInput.value.split(',').map(tag => tag.trim()).filter(tag => tag);
if (!currentTags.includes(selectedTag)) {
currentTags.push(selectedTag);
tagsInput.value = currentTags.join(', ');
}
}
});
});
} else {
loginMessage.textContent = 'Invalid username or password';
}
})
.catch(error => {
loginMessage.textContent = 'Error logging in';
});
});
logoutButton.addEventListener('click', function() {
logout()
.then(response => {
if (response.ok) {
sessionStorage.removeItem('loggedIn');
loginContainer.style.display = 'block';
appContainer.style.display = 'none';
}
})
.catch(error => {
console.error('Error logging out:', error);
});
});
taskForm.addEventListener('submit', async function(e) {
e.preventDefault();
// Get form values
const subject = document.getElementById('subject').value;
const description = document.getElementById('description').value;
const scheduled = document.getElementById('scheduled').value;
const time = document.getElementById('time').value;
const tagsInput = document.getElementById('tags').value;
const tags = tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag).join(':');
// Combine scheduled date and time if time is provided
const scheduledDateTime = time ? `${scheduled}T${time}:00` : scheduled;
// Structure data for Org mode
const taskData = {
subject: tags ? `${subject} :${tags}:` : subject,
description,
scheduled: scheduledDateTime
};
// Save tags to server
saveTags(tags.split(',').map(tag => tag.trim()).filter(tag => tag))
.then(() => {
loadTags(); // Force refresh tags after saving
});
// Save task to server or IndexedDB if offline
try {
const data = await saveTask(taskData);
document.getElementById('responseMessage').textContent = data.message;
taskForm.reset(); // Reset the form after saving the task
} catch (error) {
if (error.status === 401) {
sessionStorage.removeItem('loggedIn');
loginContainer.style.display = 'block';
appContainer.style.display = 'none';
} else {
document.getElementById('responseMessage').textContent = "Error saving task!";
}
}
});
// Synchronize tasks when back online
window.addEventListener('online', async () => {
const db = await idb.openDB('org-todo-pwa', 1);
const tasks = await db.getAll('tasks');
for (const task of tasks) {
try {
await saveTask(task);
await db.delete('tasks', task.id);
console.log(`Task synchronized: ${task.subject}`);
} catch (error) {
console.error('Error synchronizing task:', error);
}
}
});
});

17
public/js/tags.js Normal file
View File

@@ -0,0 +1,17 @@
export async function saveTags(newTags) {
const existingTags = await loadTags();
const allTags = Array.from(new Set([...existingTags, ...newTags]));
return fetch('/save-tags', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tags: allTags })
});
}
export function loadTags() {
return fetch('/get-tags')
.then(response => response.json());
}

30
public/js/tasks.js Normal file
View File

@@ -0,0 +1,30 @@
import { idb } from './utils.js';
export async function saveTask(taskData) {
if (navigator.onLine) {
try {
const response = await fetch('/add-task', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(taskData)
});
return await response.json();
} catch (error) {
throw error;
}
} else {
try {
const db = await idb.openDB('org-todo-pwa', 1, {
upgrade(db) {
db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
}
});
await db.add('tasks', taskData);
return { message: "Task saved offline!" };
} catch (error) {
throw error;
}
}
}

16
public/js/utils.js Normal file
View File

@@ -0,0 +1,16 @@
export const idb = {
openDB(name, version, { upgrade }) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, version);
request.onupgradeneeded = (event) => {
upgrade(request.result, event.oldVersion, event.newVersion, request.transaction);
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
};

View File

@@ -1,28 +1,61 @@
self.addEventListener('install', (event) => {
const CACHE_NAME = 'org-todo-pwa-cache-v1';
const urlsToCache = [
'/',
'/index.html',
'/css/style.css',
'/js/auth.js',
'/js/tasks.js',
'/js/tags.js',
'/js/utils.js',
'/js/main.js',
'https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open('task-manager-cache').then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/style.css',
'/app.js',
'/manifest.json',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png'
]);
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
})
);
});
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/get-tags') || event.request.url.includes('/save-tags')) {
// Bypass cache for tags endpoints
event.respondWith(fetch(event.request));
} else {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
return cachedResponse || fetch(event.request);
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request).then(
response => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
}
);
})
);
}
);
});

View File

@@ -1,26 +0,0 @@
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f4f4f9;
}
.container {
width: 300px;
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
}
#responseMessage {
text-align: center;
color: green;
}

38
routes/auth.js Normal file
View File

@@ -0,0 +1,38 @@
const express = require('express');
const basicAuth = require('basic-auth');
const logger = require('../logger');
const router = express.Router();
router.post('/login', (req, res) => {
const user = basicAuth(req);
const username = process.env.AUTH_USERNAME;
const password = process.env.AUTH_PASSWORD;
if (user && user.name === username && user.pass === password) {
req.session.user = user.name;
res.status(200).send('Login successful');
logger.info(`User ${user.name} logged in`);
} else {
res.status(401).send('Authentication required');
}
});
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).send('Error logging out');
}
res.clearCookie('connect.sid');
res.status(200).send('Logout successful');
});
});
router.get('/check-session', (req, res) => {
if (req.session.user) {
res.json({ loggedIn: true });
} else {
res.json({ loggedIn: false });
}
});
module.exports = router;

View File

@@ -1,12 +1,19 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const debug = require('debug')('app');
const auth = require('../middleware/auth');
const logger = require('../logger');
const sqlite3 = require('sqlite3').verbose();
const router = express.Router();
const dataDir = '/data';
const db = new sqlite3.Database('/data/sessions.sqlite', (err) => {
if (err) {
console.error('Error opening database:', err);
}
});
// Ensure the /data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
@@ -23,57 +30,76 @@ router.post('/add-task', auth, async (req, res) => {
const { subject, description, scheduled } = req.body;
const currentDateTime = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
let orgFormattedData = `
* TODO ${subject}
SCHEDULED: <${scheduled}>
// Format the scheduled date and time for Org mode
const scheduledDate = new Date(scheduled);
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const dayName = dayNames[scheduledDate.getUTCDay()];
let formattedScheduled;
if (scheduledDate.getUTCHours() === 0 && scheduledDate.getUTCMinutes() === 0) {
// No time provided, format without time
formattedScheduled = `<${scheduledDate.getUTCFullYear()}-${String(scheduledDate.getUTCMonth() + 1).padStart(2, '0')}-${String(scheduledDate.getUTCDate()).padStart(2, '0')} ${dayName}>`;
} else {
// Time provided, format with time
formattedScheduled = `<${scheduledDate.getUTCFullYear()}-${String(scheduledDate.getUTCMonth() + 1).padStart(2, '0')}-${String(scheduledDate.getUTCDate()).padStart(2, '0')} ${dayName} ${String(scheduledDate.getUTCHours()).padStart(2, '0')}:${String(scheduledDate.getUTCMinutes()).padStart(2, '0')}>`;
}
let orgFormattedData = `* TODO ${subject}
SCHEDULED: ${formattedScheduled}
:LOGBOOK:
- State "TODO" from "TODO" [${currentDateTime}]
:END:`;
:END:
`;
if (description) {
orgFormattedData = `
* TODO ${subject}
orgFormattedData = `* TODO ${subject}
${description}
SCHEDULED: <${scheduled}>
SCHEDULED: ${formattedScheduled}
:LOGBOOK:
- State "TODO" from "TODO" [${currentDateTime}]
:END:`;
:END:
`;
}
const filePath = path.join(dataDir, 'tasks.org');
try {
await fs.promises.appendFile(filePath, orgFormattedData);
res.send({ message: 'Task added successfully!' });
} catch (err) {
debug('Error writing to file:', err);
res.status(500).send('Error writing to file.');
res.json({ message: 'Task added successfully' });
logger.info(`Task added: ${orgFormattedData}`);
} catch (error) {
logger.error('Error writing to tasks.org file:', error);
res.status(500).json({ message: 'Error adding task' });
}
});
// Endpoint to save tags
router.post('/save-tags', auth, async (req, res) => {
const { tags } = req.body;
const filePath = path.join(dataDir, 'tags.json');
try {
await fs.promises.writeFile(filePath, JSON.stringify(tags));
res.send({ message: 'Tags saved successfully!' });
} catch (err) {
debug('Error saving tags:', err);
res.status(500).send('Error saving tags.');
}
const placeholders = tags.map(() => '(?)').join(',');
const sql = `INSERT OR IGNORE INTO tags (tag) VALUES ${placeholders}`;
db.run(sql, tags, function(err) {
if (err) {
logger.error('Error saving tags:', err);
res.status(500).send('Error saving tags.');
} else {
res.send({ message: 'Tags saved successfully!' });
logger.info(`New tags saved: ${tags}`);
}
});
});
// Endpoint to retrieve tags
router.get('/get-tags', auth, async (req, res) => {
const filePath = path.join(dataDir, 'tags.json');
try {
const data = await fs.promises.readFile(filePath);
const tags = JSON.parse(data);
res.send(tags);
} catch (err) {
debug('Error retrieving tags:', err);
res.status(500).send('Error retrieving tags.');
}
db.all('SELECT tag FROM tags', [], (err, rows) => {
if (err) {
logger.error('Error retrieving tags:', err);
res.status(500).json({ error: 'Error retrieving tags' });
} else {
const tags = rows.map(row => row.tag);
res.json(tags);
}
});
});
module.exports = router;

View File

@@ -1,16 +1,58 @@
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const debug = require('debug')('app');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const SQLiteStore = require('connect-sqlite3')(session);
const sqlite3 = require('sqlite3').verbose();
const tasksRouter = require('./routes/tasks');
const authRouter = require('./routes/auth');
const authMiddleware = require('./middleware/auth');
const logger = require('./logger');
const app = express();
const port = 3044;
const db = new sqlite3.Database('/data/sessions.sqlite', (err) => {
if (err) {
console.error('Error opening database:', err);
} else {
db.run(`CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT UNIQUE
)`, (err) => {
if (err) {
console.error('Error creating tags table:', err);
}
});
}
});
app.use(bodyParser.json());
app.use(cookieParser());
app.use(express.static('public'));
app.use('/', tasksRouter);
// Configure session middleware with SQLite store
app.use(session({
secret: process.env.SESSION_SECRET || 'default_secret', // Use a strong secret in production
resave: false,
saveUninitialized: false,
store: new SQLiteStore({
db: 'sessions.sqlite',
dir: '/data',
ttl: 30 * 24 * 60 * 60 // 1 month
}),
cookie: {
secure: process.env.NODE_ENV === 'production', // Ensure cookies are only sent over HTTPS in production
maxAge: 30 * 24 * 60 * 60 * 1000 // 1 month
}
}));
app.use('/', authRouter);
app.use('/', authMiddleware, tasksRouter);
app.listen(port, () => {
debug(`Server running at http://localhost:${port}`);
logger.info(`Server running at http://localhost:${port}`);
});