Files
mdtask/scripts/mdtask.php
2026-01-05 22:13:32 -05:00

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&#10;&#10;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>