1233 lines
43 KiB
PHP
1233 lines
43 KiB
PHP
<?php
|
|
// mdtask - Single file PHP and Vue.js task management application
|
|
|
|
// Serve manifest.json
|
|
if (isset($_GET['manifest'])) {
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'name' => 'mdtask',
|
|
'short_name' => 'mdtask',
|
|
'description' => 'Task management app for markdown files',
|
|
'start_url' => './',
|
|
'display' => 'standalone',
|
|
'background_color' => '#4A90E2',
|
|
'theme_color' => '#4A90E2',
|
|
'orientation' => 'portrait',
|
|
'icons' => [
|
|
[
|
|
'src' => 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%234A90E2" width="100" height="100"/><text x="50" y="50" font-size="40" text-anchor="middle" dominant-baseline="central" fill="white" font-family="sans-serif" font-weight="bold">md</text></svg>',
|
|
'sizes' => '192x192',
|
|
'type' => 'image/svg+xml'
|
|
],
|
|
[
|
|
'src' => 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%234A90E2" width="100" height="100"/><text x="50" y="50" font-size="40" text-anchor="middle" dominant-baseline="central" fill="white" font-family="sans-serif" font-weight="bold">md</text></svg>',
|
|
'sizes' => '512x512',
|
|
'type' => 'image/svg+xml'
|
|
]
|
|
]
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Serve service worker
|
|
if (isset($_GET['sw'])) {
|
|
header('Content-Type: application/javascript');
|
|
?>
|
|
const CACHE_NAME = 'mdtask-v1';
|
|
const urlsToCache = [
|
|
'./',
|
|
'https://unpkg.com/vue@3/dist/vue.global.js'
|
|
];
|
|
|
|
self.addEventListener('install', event => {
|
|
event.waitUntil(
|
|
caches.open(CACHE_NAME)
|
|
.then(cache => cache.addAll(urlsToCache))
|
|
);
|
|
});
|
|
|
|
self.addEventListener('fetch', event => {
|
|
event.respondWith(
|
|
caches.match(event.request)
|
|
.then(response => response || fetch(event.request))
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', event => {
|
|
event.waitUntil(
|
|
caches.keys().then(cacheNames => {
|
|
return Promise.all(
|
|
cacheNames.map(cacheName => {
|
|
if (cacheName !== CACHE_NAME) {
|
|
return caches.delete(cacheName);
|
|
}
|
|
})
|
|
);
|
|
})
|
|
);
|
|
});
|
|
<?php
|
|
exit;
|
|
}
|
|
|
|
// API handler
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_GET['api'])) {
|
|
header('Content-Type: application/json');
|
|
|
|
$action = $_GET['api'];
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
try {
|
|
switch ($action) {
|
|
case 'readFile':
|
|
$path = $input['path'];
|
|
if (!file_exists($path)) {
|
|
echo json_encode(['error' => 'File not found']);
|
|
exit;
|
|
}
|
|
$content = file_get_contents($path);
|
|
$mtime = filemtime($path);
|
|
echo json_encode(['content' => $content, 'mtime' => $mtime]);
|
|
break;
|
|
|
|
case 'writeFile':
|
|
$path = $input['path'];
|
|
$content = $input['content'];
|
|
$expectedMtime = $input['mtime'];
|
|
|
|
// Check if file was modified externally
|
|
if (file_exists($path)) {
|
|
$currentMtime = filemtime($path);
|
|
if ($currentMtime != $expectedMtime) {
|
|
echo json_encode(['error' => 'File was modified externally', 'reload' => true]);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Create directory if it doesn't exist
|
|
$dir = dirname($path);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
file_put_contents($path, $content);
|
|
$newMtime = filemtime($path);
|
|
echo json_encode(['success' => true, 'mtime' => $newMtime]);
|
|
break;
|
|
|
|
case 'listFiles':
|
|
$dir = $input['dir'];
|
|
$pattern = $input['pattern'] ?? null;
|
|
|
|
if (!is_dir($dir)) {
|
|
echo json_encode(['files' => []]);
|
|
exit;
|
|
}
|
|
|
|
$files = [];
|
|
$items = scandir($dir);
|
|
|
|
foreach ($items as $item) {
|
|
if ($item === '.' || $item === '..') continue;
|
|
$fullPath = $dir . '/' . $item;
|
|
|
|
if (is_file($fullPath) && pathinfo($item, PATHINFO_EXTENSION) === 'md') {
|
|
if ($pattern === null || preg_match($pattern, $item)) {
|
|
$files[] = [
|
|
'name' => $item,
|
|
'path' => $fullPath
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
echo json_encode(['files' => $files]);
|
|
break;
|
|
|
|
default:
|
|
echo json_encode(['error' => 'Unknown action']);
|
|
}
|
|
} catch (Exception $e) {
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// HTML/JavaScript application
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>mdtask</title>
|
|
|
|
<!-- PWA Meta Tags -->
|
|
<meta name="theme-color" content="#4A90E2">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="mdtask">
|
|
<link rel="manifest" href="?manifest">
|
|
<link rel="apple-touch-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%234A90E2' width='100' height='100'/><text x='50' y='50' font-size='40' text-anchor='middle' dominant-baseline='central' fill='white' font-family='sans-serif' font-weight='bold'>md</text></svg>">
|
|
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #f5f5f5;
|
|
padding-bottom: 70px;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.header {
|
|
background: #4A90E2;
|
|
color: white;
|
|
padding: 15px;
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.task-list {
|
|
padding: 10px;
|
|
}
|
|
|
|
.task-item {
|
|
background: white;
|
|
margin-bottom: 8px;
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
user-select: none;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.task-item.selected {
|
|
border: 2px solid #4A90E2;
|
|
box-shadow: 0 2px 6px rgba(74, 144, 226, 0.3);
|
|
}
|
|
|
|
.task-item.dragging {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.task-item.active {
|
|
background: white;
|
|
}
|
|
|
|
.task-item.in-progress {
|
|
background: #FFFACD;
|
|
}
|
|
|
|
.task-item.completed {
|
|
background: #FFE4E1;
|
|
}
|
|
|
|
.task-item.cancelled {
|
|
background: #E8E8E8;
|
|
}
|
|
|
|
.drag-handle {
|
|
width: 24px;
|
|
height: 24px;
|
|
cursor: grab;
|
|
padding: 4px;
|
|
touch-action: none;
|
|
user-select: none;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 3px;
|
|
}
|
|
|
|
.drag-handle::before,
|
|
.drag-handle::after {
|
|
content: '';
|
|
width: 18px;
|
|
height: 2px;
|
|
background: #999;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.drag-handle span {
|
|
width: 18px;
|
|
height: 2px;
|
|
background: #999;
|
|
border-radius: 1px;
|
|
display: block;
|
|
}
|
|
|
|
.drag-handle:active::before,
|
|
.drag-handle:active::after,
|
|
.drag-handle:active span {
|
|
background: #4A90E2;
|
|
}
|
|
|
|
.drag-handle:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.task-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.task-name {
|
|
font-weight: 500;
|
|
font-size: 16px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.task-notes {
|
|
font-size: 13px;
|
|
color: #666;
|
|
font-style: italic;
|
|
}
|
|
|
|
.separator {
|
|
height: 4px;
|
|
background: #aaa;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.action-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: white;
|
|
border-top: 1px solid #ddd;
|
|
display: flex;
|
|
justify-content: space-around;
|
|
padding: 10px;
|
|
box-shadow: 0 -2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.action-btn {
|
|
flex: 1;
|
|
margin: 0 4px;
|
|
padding: 12px 8px;
|
|
background: #4A90E2;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.action-btn:active {
|
|
background: #357ABD;
|
|
}
|
|
|
|
.action-btn:disabled {
|
|
background: #ccc;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 1000;
|
|
padding: 20px;
|
|
}
|
|
|
|
.modal.active {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
max-width: 400px;
|
|
width: 100%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.modal-header {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
margin-bottom: 15px;
|
|
color: #333;
|
|
}
|
|
|
|
.file-list {
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.file-item {
|
|
padding: 12px;
|
|
margin: 5px 0;
|
|
background: #f5f5f5;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.file-item:active {
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.modal-btn {
|
|
flex: 1;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.modal-btn.primary {
|
|
background: #4A90E2;
|
|
color: white;
|
|
}
|
|
|
|
.modal-btn.secondary {
|
|
background: #ddd;
|
|
color: #333;
|
|
}
|
|
|
|
.editor {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: white;
|
|
z-index: 2000;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.editor.active {
|
|
display: flex;
|
|
}
|
|
|
|
.editor-header {
|
|
background: #4A90E2;
|
|
color: white;
|
|
padding: 15px;
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
}
|
|
|
|
.editor-content {
|
|
flex: 1;
|
|
padding: 15px;
|
|
}
|
|
|
|
.editor-textarea {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
padding: 12px;
|
|
font-size: 16px;
|
|
font-family: monospace;
|
|
resize: none;
|
|
}
|
|
|
|
.editor-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 15px;
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: #999;
|
|
}
|
|
|
|
.error-message {
|
|
background: #ffebee;
|
|
color: #c62828;
|
|
padding: 12px;
|
|
margin: 10px;
|
|
border-radius: 6px;
|
|
text-align: center;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div class="header">{{ currentFileName }}</div>
|
|
|
|
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
|
|
|
|
<div class="task-list" v-if="tasks.length > 0">
|
|
<div
|
|
v-for="(task, index) in tasks"
|
|
:key="task.id"
|
|
:class="['task-item', task.status, { selected: task.selected, dragging: draggedTask === task }]"
|
|
@dragover="onDragOver(task, $event)"
|
|
@drop="onDrop(task, $event)"
|
|
>
|
|
<div v-if="task.isSeparator" class="separator"></div>
|
|
<template v-else>
|
|
<div
|
|
class="drag-handle"
|
|
draggable="true"
|
|
@dragstart="onDragStart(task, $event)"
|
|
@dragend="onDragEnd"
|
|
@touchstart="onTouchStart(task, $event)"
|
|
@touchmove="onTouchMove(task, $event)"
|
|
@touchend="onTouchEnd($event)"
|
|
@touchcancel="onTouchCancel($event)"
|
|
><span></span></div>
|
|
<div
|
|
class="task-content"
|
|
@click="toggleTaskSelection(task)"
|
|
@dblclick="editSingleTask(task)"
|
|
>
|
|
<div class="task-name">{{ task.name }}</div>
|
|
<div class="task-notes" v-if="task.firstNoteLine">{{ task.firstNoteLine }}</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="empty-state">
|
|
No tasks found
|
|
</div>
|
|
|
|
<div class="action-bar">
|
|
<template v-if="selectedTasks.length === 0">
|
|
<button class="action-btn" @click="addTask">Add</button>
|
|
<button class="action-btn" @click="openFileList">Open</button>
|
|
<button class="action-btn" @click="tidyTasks">Tidy</button>
|
|
</template>
|
|
<template v-else>
|
|
<button class="action-btn" :disabled="selectedTasks.length > 1" @click="editTask">Edit</button>
|
|
<button class="action-btn" @click="deleteTasks">Delete</button>
|
|
<button class="action-btn" @click="completeTasks">Complete</button>
|
|
<button class="action-btn" @click="startTasks">Start</button>
|
|
<button class="action-btn" @click="cancelTasks">Cancel</button>
|
|
<button class="action-btn" @click="moveTasks">Move</button>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- File picker modal -->
|
|
<div :class="['modal', { active: showFileModal }]" @click="showFileModal = false">
|
|
<div class="modal-content" @click.stop>
|
|
<div class="modal-header">{{ fileModalTitle }}</div>
|
|
<div class="file-list">
|
|
<div
|
|
v-for="file in fileList"
|
|
:key="file.path"
|
|
class="file-item"
|
|
@click="selectFile(file)"
|
|
>
|
|
{{ file.name }}
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn secondary" @click="showFileModal = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Task editor -->
|
|
<div :class="['editor', { active: showEditor }]">
|
|
<div class="editor-header">{{ editorTitle }}</div>
|
|
<div class="editor-content">
|
|
<textarea class="editor-textarea" v-model="editorText" placeholder="Task name Optional task notes..."></textarea>
|
|
</div>
|
|
<div class="editor-actions">
|
|
<button class="modal-btn secondary" @click="cancelEdit">Cancel</button>
|
|
<button class="modal-btn primary" @click="saveEdit">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
<script>
|
|
const { createApp } = Vue;
|
|
|
|
createApp({
|
|
data() {
|
|
return {
|
|
tasks: [],
|
|
currentFilePath: '../Daily/Inbox.md',
|
|
currentFileName: 'Inbox',
|
|
fileMtime: null,
|
|
draggedTask: null,
|
|
touchDraggedTask: null,
|
|
touchStartY: 0,
|
|
showFileModal: false,
|
|
fileModalTitle: '',
|
|
fileModalMode: '',
|
|
fileList: [],
|
|
showEditor: false,
|
|
editorTitle: '',
|
|
editorText: '',
|
|
editingTask: null,
|
|
errorMessage: '',
|
|
nextTaskId: 1
|
|
};
|
|
},
|
|
computed: {
|
|
selectedTasks() {
|
|
return this.tasks.filter(t => !t.isSeparator && t.selected);
|
|
}
|
|
},
|
|
mounted() {
|
|
this.loadFile(this.currentFilePath);
|
|
|
|
// Register service worker for PWA
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('?sw')
|
|
.then(registration => console.log('Service Worker registered'))
|
|
.catch(error => console.log('Service Worker registration failed:', error));
|
|
}
|
|
},
|
|
methods: {
|
|
async apiCall(action, data) {
|
|
try {
|
|
const response = await fetch(`?api=${action}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.error) {
|
|
if (result.reload) {
|
|
alert('File was modified externally. Reloading...');
|
|
this.loadFile(this.currentFilePath);
|
|
} else {
|
|
this.errorMessage = result.error;
|
|
setTimeout(() => this.errorMessage = '', 3000);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return result;
|
|
} catch (e) {
|
|
this.errorMessage = 'Network error: ' + e.message;
|
|
setTimeout(() => this.errorMessage = '', 3000);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async loadFile(path) {
|
|
const result = await this.apiCall('readFile', { path });
|
|
if (!result) return;
|
|
|
|
this.currentFilePath = path;
|
|
this.fileMtime = result.mtime;
|
|
this.parseTasks(result.content);
|
|
|
|
// Extract file name for header
|
|
const fileName = path.split('/').pop().replace('.md', '');
|
|
this.currentFileName = fileName;
|
|
},
|
|
|
|
parseTasks(content) {
|
|
this.tasks = [];
|
|
const lines = content.split('\n');
|
|
let currentTask = null;
|
|
let foundSeparator = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
// Skip the header line
|
|
if (line.startsWith('# ')) continue;
|
|
|
|
// Check for separator
|
|
if (line.trim() === '-----' && !foundSeparator) {
|
|
if (currentTask) {
|
|
this.tasks.push(currentTask);
|
|
currentTask = null;
|
|
}
|
|
this.tasks.push({
|
|
id: this.nextTaskId++,
|
|
isSeparator: true,
|
|
selected: false
|
|
});
|
|
foundSeparator = true;
|
|
continue;
|
|
}
|
|
|
|
// Check if line is empty (end of task)
|
|
if (line.trim() === '') {
|
|
if (currentTask) {
|
|
this.tasks.push(currentTask);
|
|
currentTask = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Start of a new task or continuation
|
|
if (currentTask === null) {
|
|
// Parse status and task name
|
|
let status = 'active';
|
|
let name = line;
|
|
|
|
if (line.startsWith('X ')) {
|
|
status = 'completed';
|
|
name = line.substring(2);
|
|
} else if (line.startsWith('~ ')) {
|
|
status = 'in-progress';
|
|
name = line.substring(2);
|
|
} else if (line.startsWith('C ')) {
|
|
status = 'cancelled';
|
|
name = line.substring(2);
|
|
}
|
|
|
|
currentTask = {
|
|
id: this.nextTaskId++,
|
|
name: name,
|
|
notes: [],
|
|
status: status,
|
|
selected: false,
|
|
isSeparator: false,
|
|
firstNoteLine: null
|
|
};
|
|
} else {
|
|
// This is a note line
|
|
currentTask.notes.push(line);
|
|
if (currentTask.firstNoteLine === null) {
|
|
currentTask.firstNoteLine = line;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Don't forget the last task
|
|
if (currentTask) {
|
|
this.tasks.push(currentTask);
|
|
}
|
|
},
|
|
|
|
generateMarkdown() {
|
|
let lines = ['# ' + this.currentFileName, ''];
|
|
|
|
for (const task of this.tasks) {
|
|
if (task.isSeparator) {
|
|
lines.push('-----');
|
|
lines.push('');
|
|
continue;
|
|
}
|
|
|
|
let prefix = '';
|
|
if (task.status === 'completed') prefix = 'X ';
|
|
else if (task.status === 'in-progress') prefix = '~ ';
|
|
else if (task.status === 'cancelled') prefix = 'C ';
|
|
|
|
lines.push(prefix + task.name);
|
|
|
|
if (task.notes.length > 0) {
|
|
lines.push(...task.notes);
|
|
}
|
|
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
},
|
|
|
|
async saveFile() {
|
|
const content = this.generateMarkdown();
|
|
const result = await this.apiCall('writeFile', {
|
|
path: this.currentFilePath,
|
|
content: content,
|
|
mtime: this.fileMtime
|
|
});
|
|
|
|
if (result && result.success) {
|
|
this.fileMtime = result.mtime;
|
|
this.clearSelection();
|
|
}
|
|
},
|
|
|
|
toggleTaskSelection(task) {
|
|
task.selected = !task.selected;
|
|
},
|
|
|
|
clearSelection() {
|
|
this.tasks.forEach(t => t.selected = false);
|
|
},
|
|
|
|
// Drag and drop
|
|
onDragStart(task, event) {
|
|
this.draggedTask = task;
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
},
|
|
|
|
onDragOver(task, event) {
|
|
if (this.draggedTask && this.draggedTask !== task) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
}
|
|
},
|
|
|
|
onDrop(task, event) {
|
|
event.preventDefault();
|
|
|
|
if (this.draggedTask && this.draggedTask !== task) {
|
|
const draggedIndex = this.tasks.indexOf(this.draggedTask);
|
|
const targetIndex = this.tasks.indexOf(task);
|
|
|
|
// Remove from old position
|
|
this.tasks.splice(draggedIndex, 1);
|
|
|
|
// Insert at new position
|
|
const newIndex = draggedIndex < targetIndex ? targetIndex : targetIndex;
|
|
this.tasks.splice(newIndex, 0, this.draggedTask);
|
|
|
|
this.saveFile();
|
|
}
|
|
},
|
|
|
|
onDragEnd() {
|
|
this.draggedTask = null;
|
|
},
|
|
|
|
// Touch drag for mobile
|
|
onTouchStart(task, event) {
|
|
this.touchDraggedTask = task;
|
|
this.touchStartY = event.touches[0].clientY;
|
|
// Prevent default to stop scrolling while dragging from handle
|
|
event.preventDefault();
|
|
},
|
|
|
|
onTouchMove(task, event) {
|
|
if (!this.touchDraggedTask) return;
|
|
|
|
event.preventDefault();
|
|
const touchY = event.touches[0].clientY;
|
|
|
|
// Find which task element we're over
|
|
const elements = document.elementsFromPoint(event.touches[0].clientX, touchY);
|
|
|
|
for (const el of elements) {
|
|
if (el.classList && el.classList.contains('task-item')) {
|
|
// Find the task index
|
|
const taskItems = Array.from(document.querySelectorAll('.task-item'));
|
|
const targetIndex = taskItems.indexOf(el);
|
|
|
|
if (targetIndex !== -1) {
|
|
const targetTask = this.tasks[targetIndex];
|
|
|
|
if (targetTask && targetTask !== this.touchDraggedTask && !targetTask.isSeparator) {
|
|
const draggedIndex = this.tasks.indexOf(this.touchDraggedTask);
|
|
|
|
if (draggedIndex !== -1 && draggedIndex !== targetIndex) {
|
|
// Remove from old position
|
|
this.tasks.splice(draggedIndex, 1);
|
|
|
|
// Insert at new position
|
|
const newIndex = draggedIndex < targetIndex ? targetIndex : targetIndex;
|
|
this.tasks.splice(newIndex, 0, this.touchDraggedTask);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
|
|
onTouchEnd(event) {
|
|
if (this.touchDraggedTask) {
|
|
this.saveFile();
|
|
this.touchDraggedTask = null;
|
|
}
|
|
},
|
|
|
|
onTouchCancel(event) {
|
|
this.touchDraggedTask = null;
|
|
},
|
|
|
|
// Actions
|
|
addTask() {
|
|
this.editorTitle = 'New Task';
|
|
this.editorText = '';
|
|
this.editingTask = null;
|
|
this.showEditor = true;
|
|
},
|
|
|
|
editTask() {
|
|
if (this.selectedTasks.length !== 1) return;
|
|
|
|
const task = this.selectedTasks[0];
|
|
this.editingTask = task;
|
|
this.editorTitle = 'Edit Task';
|
|
|
|
// Include status prefix in editor
|
|
let prefix = '';
|
|
if (task.status === 'completed') prefix = 'X ';
|
|
else if (task.status === 'in-progress') prefix = '~ ';
|
|
else if (task.status === 'cancelled') prefix = 'C ';
|
|
|
|
let text = prefix + task.name;
|
|
if (task.notes.length > 0) {
|
|
text += '\n\n' + task.notes.join('\n');
|
|
}
|
|
this.editorText = text;
|
|
this.showEditor = true;
|
|
},
|
|
|
|
editSingleTask(task) {
|
|
this.clearSelection();
|
|
task.selected = true;
|
|
this.editTask();
|
|
},
|
|
|
|
cancelEdit() {
|
|
this.showEditor = false;
|
|
this.editorText = '';
|
|
this.editingTask = null;
|
|
},
|
|
|
|
saveEdit() {
|
|
const lines = this.editorText.split('\n');
|
|
if (lines.length === 0 || lines[0].trim() === '') {
|
|
alert('Task name is required');
|
|
return;
|
|
}
|
|
|
|
// Parse status and task name from first line
|
|
let status = 'active';
|
|
let name = lines[0].trim();
|
|
|
|
if (name.startsWith('X ')) {
|
|
status = 'completed';
|
|
name = name.substring(2);
|
|
} else if (name.startsWith('~ ')) {
|
|
status = 'in-progress';
|
|
name = name.substring(2);
|
|
} else if (name.startsWith('C ')) {
|
|
status = 'cancelled';
|
|
name = name.substring(2);
|
|
}
|
|
|
|
const notes = [];
|
|
let firstNoteLine = null;
|
|
|
|
// Skip empty lines after the name
|
|
let i = 1;
|
|
while (i < lines.length && lines[i].trim() === '') i++;
|
|
|
|
// Collect notes (skip blank lines)
|
|
for (; i < lines.length; i++) {
|
|
if (lines[i].trim() !== '') {
|
|
notes.push(lines[i]);
|
|
if (firstNoteLine === null) {
|
|
firstNoteLine = lines[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.editingTask) {
|
|
// Update existing task
|
|
this.editingTask.name = name;
|
|
this.editingTask.notes = notes;
|
|
this.editingTask.firstNoteLine = firstNoteLine;
|
|
this.editingTask.status = status;
|
|
} else {
|
|
// Create new task at top
|
|
this.tasks.unshift({
|
|
id: this.nextTaskId++,
|
|
name: name,
|
|
notes: notes,
|
|
status: status,
|
|
selected: false,
|
|
isSeparator: false,
|
|
firstNoteLine: firstNoteLine
|
|
});
|
|
}
|
|
|
|
this.showEditor = false;
|
|
this.editorText = '';
|
|
this.editingTask = null;
|
|
this.saveFile();
|
|
},
|
|
|
|
deleteTasks() {
|
|
const count = this.selectedTasks.length;
|
|
const message = count === 1
|
|
? 'Delete this task?'
|
|
: `Delete ${count} tasks?`;
|
|
|
|
if (confirm(message)) {
|
|
this.tasks = this.tasks.filter(t => !t.selected);
|
|
this.saveFile();
|
|
}
|
|
},
|
|
|
|
completeTasks() {
|
|
this.selectedTasks.forEach(t => t.status = 'completed');
|
|
this.saveFile();
|
|
},
|
|
|
|
startTasks() {
|
|
this.selectedTasks.forEach(t => t.status = 'in-progress');
|
|
this.saveFile();
|
|
},
|
|
|
|
cancelTasks() {
|
|
this.selectedTasks.forEach(t => t.status = 'cancelled');
|
|
this.saveFile();
|
|
},
|
|
|
|
async moveTasks() {
|
|
const result = await this.apiCall('listFiles', {
|
|
dir: '../Tasks'
|
|
});
|
|
|
|
if (!result) return;
|
|
|
|
this.fileList = result.files;
|
|
this.fileModalTitle = 'Move to Project';
|
|
this.fileModalMode = 'move';
|
|
this.showFileModal = true;
|
|
},
|
|
|
|
async openFileList() {
|
|
const result = await this.apiCall('listFiles', {
|
|
dir: '../Tasks'
|
|
});
|
|
|
|
if (!result) return;
|
|
|
|
// Add Inbox to top if not current file
|
|
if (this.currentFilePath !== '../Daily/Inbox.md') {
|
|
this.fileList = [
|
|
{ name: 'Inbox', path: '../Daily/Inbox.md' },
|
|
...result.files
|
|
];
|
|
} else {
|
|
this.fileList = result.files;
|
|
}
|
|
|
|
this.fileModalTitle = 'Open Project';
|
|
this.fileModalMode = 'open';
|
|
this.showFileModal = true;
|
|
},
|
|
|
|
async tidyTasks() {
|
|
const result = await this.apiCall('listFiles', {
|
|
dir: '../Daily',
|
|
pattern: '/^\\d{4}-\\d{2}-\\d{2} \\w{3}\\.md$/'
|
|
});
|
|
|
|
if (!result) return;
|
|
|
|
this.fileList = result.files;
|
|
this.fileModalTitle = 'Tidy to Daily Page';
|
|
this.fileModalMode = 'tidy';
|
|
this.showFileModal = true;
|
|
},
|
|
|
|
async selectFile(file) {
|
|
this.showFileModal = false;
|
|
|
|
if (this.fileModalMode === 'open') {
|
|
await this.loadFile(file.path);
|
|
} else if (this.fileModalMode === 'move') {
|
|
await this.moveTasksToFile(file.path);
|
|
} else if (this.fileModalMode === 'tidy') {
|
|
await this.tidyToDaily(file.path);
|
|
}
|
|
},
|
|
|
|
async moveTasksToFile(targetPath) {
|
|
if (this.selectedTasks.length === 0) return;
|
|
|
|
// Read target file
|
|
const result = await this.apiCall('readFile', { path: targetPath });
|
|
if (!result) return;
|
|
|
|
// Parse target file
|
|
const targetLines = result.content.split('\n');
|
|
const header = targetLines[0] || '# Tasks';
|
|
|
|
// Build new target content with moved tasks at top
|
|
let newTargetLines = [header, ''];
|
|
|
|
// Add moved tasks
|
|
for (const task of this.selectedTasks) {
|
|
let prefix = '';
|
|
if (task.status === 'completed') prefix = 'X ';
|
|
else if (task.status === 'in-progress') prefix = '~ ';
|
|
else if (task.status === 'cancelled') prefix = 'C ';
|
|
|
|
newTargetLines.push(prefix + task.name);
|
|
if (task.notes.length > 0) {
|
|
newTargetLines.push(...task.notes);
|
|
}
|
|
newTargetLines.push('');
|
|
}
|
|
|
|
// Add existing tasks
|
|
for (let i = 1; i < targetLines.length; i++) {
|
|
newTargetLines.push(targetLines[i]);
|
|
}
|
|
|
|
// Save target file
|
|
await this.apiCall('writeFile', {
|
|
path: targetPath,
|
|
content: newTargetLines.join('\n'),
|
|
mtime: result.mtime
|
|
});
|
|
|
|
// Remove from current file
|
|
this.tasks = this.tasks.filter(t => !t.selected);
|
|
await this.saveFile();
|
|
},
|
|
|
|
async tidyToDaily(dailyPath) {
|
|
// Read daily file
|
|
const result = await this.apiCall('readFile', { path: dailyPath });
|
|
let dailyContent = result ? result.content : '';
|
|
let dailyMtime = result ? result.mtime : null;
|
|
|
|
// Parse daily file to find existing tasks
|
|
const dailyLines = dailyContent.split('\n');
|
|
let tasksHeaderIndex = -1;
|
|
const existingTasks = new Map(); // Map of task name (without status) to line index
|
|
|
|
for (let i = 0; i < dailyLines.length; i++) {
|
|
if (dailyLines[i] === '#### Tasks') {
|
|
tasksHeaderIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If tasks section doesn't exist, add it
|
|
if (tasksHeaderIndex === -1) {
|
|
if (dailyLines[dailyLines.length - 1] !== '') {
|
|
dailyLines.push('');
|
|
}
|
|
dailyLines.push('#### Tasks');
|
|
dailyLines.push('');
|
|
tasksHeaderIndex = dailyLines.length - 2;
|
|
}
|
|
|
|
// Parse existing tasks in daily file
|
|
let currentDailyTask = null;
|
|
let currentDailyTaskStartIndex = -1;
|
|
|
|
for (let i = tasksHeaderIndex + 1; i < dailyLines.length; i++) {
|
|
const line = dailyLines[i];
|
|
|
|
if (line.trim() === '') {
|
|
if (currentDailyTask) {
|
|
existingTasks.set(currentDailyTask, currentDailyTaskStartIndex);
|
|
currentDailyTask = null;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (currentDailyTask === null) {
|
|
// Start of a task
|
|
let taskName = line;
|
|
if (line.startsWith('X ') || line.startsWith('~ ') || line.startsWith('C ')) {
|
|
taskName = line.substring(2);
|
|
}
|
|
currentDailyTask = taskName;
|
|
currentDailyTaskStartIndex = i;
|
|
}
|
|
}
|
|
|
|
if (currentDailyTask) {
|
|
existingTasks.set(currentDailyTask, currentDailyTaskStartIndex);
|
|
}
|
|
|
|
// Process tasks to move/copy
|
|
const tasksToRemove = [];
|
|
const tasksToAdd = [];
|
|
|
|
for (const task of this.tasks) {
|
|
if (task.isSeparator) continue;
|
|
|
|
if (task.status === 'completed' || task.status === 'cancelled') {
|
|
tasksToRemove.push(task);
|
|
tasksToAdd.push(task);
|
|
} else if (task.status === 'in-progress') {
|
|
tasksToAdd.push(task);
|
|
}
|
|
}
|
|
|
|
// Update or add tasks to daily file
|
|
for (const task of tasksToAdd) {
|
|
let prefix = '';
|
|
if (task.status === 'completed') prefix = 'X ';
|
|
else if (task.status === 'in-progress') prefix = '~ ';
|
|
else if (task.status === 'cancelled') prefix = 'C ';
|
|
|
|
if (existingTasks.has(task.name)) {
|
|
// Update existing task
|
|
const existingIndex = existingTasks.get(task.name);
|
|
dailyLines[existingIndex] = prefix + task.name;
|
|
|
|
// Remove old notes
|
|
let j = existingIndex + 1;
|
|
while (j < dailyLines.length && dailyLines[j].trim() !== '' && !dailyLines[j].startsWith('X ') && !dailyLines[j].startsWith('~ ') && !dailyLines[j].startsWith('C ') && !dailyLines[j].match(/^[^X~C]/)) {
|
|
dailyLines.splice(j, 1);
|
|
}
|
|
|
|
// Add new notes
|
|
if (task.notes.length > 0) {
|
|
dailyLines.splice(existingIndex + 1, 0, ...task.notes);
|
|
}
|
|
} else {
|
|
// Add new task at end
|
|
dailyLines.push(prefix + task.name);
|
|
if (task.notes.length > 0) {
|
|
dailyLines.push(...task.notes);
|
|
}
|
|
dailyLines.push('');
|
|
}
|
|
}
|
|
|
|
// Save daily file
|
|
await this.apiCall('writeFile', {
|
|
path: dailyPath,
|
|
content: dailyLines.join('\n'),
|
|
mtime: dailyMtime
|
|
});
|
|
|
|
// Remove completed/cancelled tasks from current file
|
|
this.tasks = this.tasks.filter(t => !tasksToRemove.includes(t));
|
|
await this.saveFile();
|
|
}
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|