diff --git a/package-lock.json b/package-lock.json index eaf1daba..e79a17eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "markdown-task-manager", - "version": "1.3.1", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markdown-task-manager", - "version": "1.3.1", + "version": "2.0.0", "devDependencies": { "html-minifier-terser": "^7.2.0" } diff --git a/src/index.html b/src/index.html index e524935f..54525c52 100644 --- a/src/index.html +++ b/src/index.html @@ -19,6 +19,10 @@

📋 Task Manager

+ @@ -112,6 +116,17 @@

💡 Comment ça marche ?

diff --git a/src/scripts/01-state.js b/src/scripts/01-state.js index 66525bd6..1488f660 100644 --- a/src/scripts/01-state.js +++ b/src/scripts/01-state.js @@ -2,6 +2,7 @@ let directoryHandle = null; let kanbanFileHandle = null; let currentKanbanContent = ''; + let lastKanbanModified = 0; let tasks = []; let config = {}; let activeFilters = []; // Array of {type: 'tag'|'category'|'user', value: string} @@ -14,3 +15,4 @@ let wasArchiveModalActive = false; let loadedFormatV2 = false; // true once the loaded kanban.md is V2 (status carried by a **Status** field) let loadedArchiveV2 = false; // true once the loaded archive.md carries the v2 format marker + let activityLogs = []; // Array of {time: Date, type: string, message: string} diff --git a/src/scripts/02-i18n.js b/src/scripts/02-i18n.js index 24e0d00d..e1612725 100644 --- a/src/scripts/02-i18n.js +++ b/src/scripts/02-i18n.js @@ -14,6 +14,7 @@ 'header.newTask': '➕ Task', 'header.archives': '📦 Archives', 'header.columns': '⚙️ Columns', + 'header.autoReload': 'Auto-Reload', // Filters 'filters.tags': 'Tags:', @@ -137,6 +138,7 @@ 'notif.projectDeleted': 'Project removed from list', 'notif.renameError': 'Error renaming', 'notif.projectRestored': 'Project restored automatically', + 'notif.fileChangedReloaded': 'kanban.md changed on disk. Board reloaded.', 'notif.taskMoved': 'Task moved!', 'notif.taskEdited': 'Task {id} updated!', 'notif.taskCreated': 'Task {id} created!', @@ -144,6 +146,21 @@ 'notif.taskDeleted': 'Task permanently deleted', 'notif.taskRestored': 'Task restored to its original column!', + // Activity logs + 'activity.title': 'Recent Activity', + 'activity.clear': 'Clear', + 'activity.externalReload': 'Board reloaded automatically from disk.', + 'activity.externalCreated': 'Task {id} ("{title}") was created externally.', + 'activity.externalMoved': 'Task {id} ("{title}") was moved from "{from}" to "{to}" externally.', + 'activity.externalEdited': 'Task {id} ("{title}") was modified externally.', + 'activity.externalDeleted': 'Task {id} was deleted externally.', + 'activity.localCreated': 'Task {id} ("{title}") created.', + 'activity.localMoved': 'Task {id} ("{title}") moved from "{from}" to "{to}".', + 'activity.localEdited': 'Task {id} ("{title}") updated.', + 'activity.localArchived': 'Task {id} ("{title}") archived.', + 'activity.localDeleted': 'Task {id} permanently deleted.', + 'activity.localRestored': 'Task {id} ("{title}") restored from archive.', + // Prompts and confirmations 'prompt.projectName': 'Project name (leave empty to use "{name}"):', 'prompt.renameProject': 'New project name:', @@ -193,6 +210,7 @@ 'header.newTask': '➕ Tâche', 'header.archives': '📦 Archives', 'header.columns': '⚙️ Colonnes', + 'header.autoReload': 'Rechargement auto', // Filters 'filters.tags': 'Tags:', @@ -316,6 +334,7 @@ 'notif.projectDeleted': 'Projet retiré de la liste', 'notif.renameError': 'Erreur lors du renommage', 'notif.projectRestored': 'Projet restauré automatiquement', + 'notif.fileChangedReloaded': 'kanban.md a été modifié sur le disque. Tableau rechargé.', 'notif.taskMoved': 'Tâche déplacée !', 'notif.taskEdited': 'Tâche {id} modifiée !', 'notif.taskCreated': 'Tâche {id} créée !', @@ -323,6 +342,21 @@ 'notif.taskDeleted': 'Tâche supprimée définitivement', 'notif.taskRestored': 'Tâche restaurée dans sa colonne d\'origine !', + // Activity logs + 'activity.title': 'Activité Récente', + 'activity.clear': 'Effacer', + 'activity.externalReload': 'Tableau rechargé automatiquement depuis le disque.', + 'activity.externalCreated': 'La tâche {id} ("{title}") a été créée de l\'extérieur.', + 'activity.externalMoved': 'La tâche {id} ("{title}") a été déplacée de "{from}" à "{to}" de l\'extérieur.', + 'activity.externalEdited': 'La tâche {id} ("{title}") a été modifiée de l\'extérieur.', + 'activity.externalDeleted': 'La tâche {id} a été supprimée de l\'extérieur.', + 'activity.localCreated': 'Tâche {id} ("{title}") créée.', + 'activity.localMoved': 'Tâche {id} ("{title}") déplacée de "{from}" à "{to}".', + 'activity.localEdited': 'Tâche {id} ("{title}") mise à jour.', + 'activity.localArchived': 'Tâche {id} ("{title}") archivée.', + 'activity.localDeleted': 'Tâche {id} supprimée définitivement.', + 'activity.localRestored': 'Tâche {id} ("{title}") restaurée depuis l\'archive.', + // Prompts and confirmations 'prompt.projectName': 'Nom du projet (laisser vide pour utiliser "{name}") :', 'prompt.renameProject': 'Nouveau nom du projet :', @@ -372,6 +406,7 @@ 'header.newTask': '➕ Aufgabe', 'header.archives': '📦 Archiv', 'header.columns': '⚙️ Spalten', + 'header.autoReload': 'Auto-Reload', // Filters 'filters.tags': 'Tags:', @@ -495,6 +530,7 @@ 'notif.projectDeleted': 'Projekt aus Liste gelöscht', 'notif.renameError': 'Problem beim Umbenennen', 'notif.projectRestored': 'Projekt erfolgreich wiederhergestellt', + 'notif.fileChangedReloaded': 'kanban.md auf dem Datenträger geändert. Board neu geladen.', 'notif.taskMoved': 'Aufgabe verschoben!', 'notif.taskEdited': 'Aufgabe {id} aktualisiert!', 'notif.taskCreated': 'Aufgabe {id} erstellt!', @@ -502,6 +538,21 @@ 'notif.taskDeleted': 'Aufgabe endgültig gelöscht', 'notif.taskRestored': 'Aufgabe in ursprünglicher Spalte zurückgesetzt!', + // Activity logs + 'activity.title': 'Letzte Aktivitäten', + 'activity.clear': 'Leeren', + 'activity.externalReload': 'Board automatisch vom Datenträger neu geladen.', + 'activity.externalCreated': 'Aufgabe {id} ("{title}") wurde extern erstellt.', + 'activity.externalMoved': 'Aufgabe {id} ("{title}") wurde extern von "{from}" nach "{to}" verschoben.', + 'activity.externalEdited': 'Aufgabe {id} ("{title}") wurde extern geändert.', + 'activity.externalDeleted': 'Aufgabe {id} wurde extern gelöscht.', + 'activity.localCreated': 'Aufgabe {id} ("{title}") erstellt.', + 'activity.localMoved': 'Aufgabe {id} ("{title}") von "{from}" nach "{to}" verschoben.', + 'activity.localEdited': 'Aufgabe {id} ("{title}") aktualisiert.', + 'activity.localArchived': 'Aufgabe {id} ("{title}") archiviert.', + 'activity.localDeleted': 'Aufgabe {id} endgültig gelöscht.', + 'activity.localRestored': 'Aufgabe {id} ("{title}") aus dem Archiv wiederhergestellt.', + // Prompts and confirmations 'prompt.projectName': 'Projektname (leer lassen, um "{name}" zu verwenden):', 'prompt.renameProject': 'Neuer Projektname:', @@ -550,6 +601,7 @@ 'header.newTask': '➕ 任务', 'header.archives': '📦 归档', 'header.columns': '⚙️ 列设置', + 'header.autoReload': '自动重新加载', // Filters 'filters.tags': '标签:', @@ -673,6 +725,7 @@ 'notif.projectDeleted': '项目已从列表中移除', 'notif.renameError': '重命名时出错', 'notif.projectRestored': '项目已自动恢复', + 'notif.fileChangedReloaded': 'kanban.md 已在磁盘上更改。看板已重新加载。', 'notif.taskMoved': '任务已移动!', 'notif.taskEdited': '任务 {id} 已更新!', 'notif.taskCreated': '任务 {id} 已创建!', @@ -680,6 +733,21 @@ 'notif.taskDeleted': '任务已永久删除', 'notif.taskRestored': '任务已恢复至原列!', + // Activity logs + 'activity.title': '最近动态', + 'activity.clear': '清空', + 'activity.externalReload': '看板已自动从磁盘重新加载。', + 'activity.externalCreated': '任务 {id} ("{title}") 已在外部创建。', + 'activity.externalMoved': '任务 {id} ("{title}") 已在外部从 "{from}" 移动到 "{to}"。', + 'activity.externalEdited': '任务 {id} ("{title}") 已在外部修改。', + 'activity.externalDeleted': '任务 {id} 已在外部删除。', + 'activity.localCreated': '任务 {id} ("{title}") 已创建。', + 'activity.localMoved': '任务 {id} ("{title}") 已从 "{from}" 移动到 "{to}"。', + 'activity.localEdited': '任务 {id} ("{title}") 已更新。', + 'activity.localArchived': '任务 {id} ("{title}") 已归档。', + 'activity.localDeleted': '任务 {id} 已永久删除。', + 'activity.localRestored': '任务 {id} ("{title}") 已从归档中恢复。', + // Prompts and confirmations 'prompt.projectName': '项目名称 (留空则使用 "{name}"):', 'prompt.renameProject': '新项目名称:', @@ -840,6 +908,13 @@ document.getElementById('newTaskBtn').innerHTML = t('header.newTask'); document.getElementById('archiveBtn').innerHTML = t('header.archives'); document.getElementById('manageColsBtn').innerHTML = t('header.columns'); + document.getElementById('autoReloadLabel').textContent = t('header.autoReload'); + + // Update activity log static texts + const activityLogTitle = document.getElementById('activityLogTitle'); + if (activityLogTitle) activityLogTitle.textContent = t('activity.title'); + const clearActivityLogBtn = document.getElementById('clearActivityLogBtn'); + if (clearActivityLogBtn) clearActivityLogBtn.textContent = t('activity.clear'); // Update language selector to match current language document.getElementById('languageSelector').value = currentLanguage; diff --git a/src/scripts/03-storage.js b/src/scripts/03-storage.js index bde2939b..239aedba 100644 --- a/src/scripts/03-storage.js +++ b/src/scripts/03-storage.js @@ -313,6 +313,45 @@ await switchProject(projectIndex); } }); + + // Start background poller to auto-reload kanban.md when it changes externally + setInterval(async () => { + if (!kanbanFileHandle) return; + + // Only poll if auto-reload is enabled + const autoReloadCheckbox = document.getElementById('autoReloadCheckbox'); + if (autoReloadCheckbox && !autoReloadCheckbox.checked) return; + + try { + const file = await kanbanFileHandle.getFile(); + if (file.lastModified > lastKanbanModified) { + const newText = await file.text(); + if (newText !== currentKanbanContent) { + console.log('kanban.md changed on disk. Reloading...'); + await loadKanbanFile(); + + // If the details modal is open, refresh it with the updated task data + const modal = document.getElementById('taskModal'); + if (modal && modal.classList.contains('active') && currentDetailTask) { + const updatedTask = tasks.find(t => t.id === currentDetailTask.id); + if (updatedTask) { + showTaskDetail(updatedTask); + } else { + closeModal(); + } + } + + showNotification(t('notif.fileChangedReloaded'), 'info'); + } else { + // Only lastModified changed (e.g. metadata touched), content is same. + lastKanbanModified = file.lastModified; + } + } + } catch (error) { + // Suppress check errors since they might be temporary lock/permission prompts + console.debug('Error checking kanban.md changes:', error); + } + }, 1000); }); // Select folder @@ -347,4 +386,43 @@ } }); + function logActivity(type, message) { + const entry = { + time: new Date(), + type: type, + message: message + }; + activityLogs.unshift(entry); + if (activityLogs.length > 50) { + activityLogs.pop(); + } + renderActivityLog(); + } + + function renderActivityLog() { + const listEl = document.getElementById('activityLogList'); + if (!listEl) return; + + listEl.innerHTML = activityLogs.map(log => { + const timeStr = log.time.toLocaleTimeString(); + let borderClass = ''; + if (log.type === 'reload') borderClass = 'reload'; + else if (log.type === 'create') borderClass = 'create'; + else if (log.type === 'delete' || log.type === 'archive') borderClass = 'delete'; + else if (log.type === 'edit' || log.type === 'move') borderClass = 'edit'; + + return ` +
+ [${timeStr}] + ${escapeHtml(log.message)} +
+ `; + }).join(''); + } + + function clearActivityLog() { + activityLogs = []; + renderActivityLog(); + } + // Load kanban.md file diff --git a/src/scripts/04-io.js b/src/scripts/04-io.js index e922c174..300e55b2 100644 --- a/src/scripts/04-io.js +++ b/src/scripts/04-io.js @@ -3,11 +3,66 @@ kanbanFileHandle = await directoryHandle.getFileHandle('kanban.md'); const file = await kanbanFileHandle.getFile(); currentKanbanContent = await file.text(); + lastKanbanModified = file.lastModified; console.log('File loaded, size:', currentKanbanContent.length); + + // Capture previous task list mapping task IDs to statuses to identify new/moved tasks + const oldTaskStatusMap = new Map(); + const oldTaskTitleMap = new Map(); + const wasLoadedBefore = tasks && tasks.length > 0; + if (wasLoadedBefore) { + tasks.forEach(t => { + oldTaskStatusMap.set(t.id, t.status); + oldTaskTitleMap.set(t.id, t.title); + }); + } + parseMarkdown(currentKanbanContent); await loadArchive(); // Load archive for historical autocomplete data updateAutocomplete(); + + // Compare tasks to identify new or moved items, only if there were tasks loaded before + if (wasLoadedBefore) { + let externalChangesCount = 0; + const newTaskIds = new Set(tasks.map(t => t.id)); + + tasks.forEach(task => { + if (!oldTaskStatusMap.has(task.id)) { + task.isNew = true; + logActivity('reload', t('activity.externalCreated', {id: task.id, title: task.title})); + externalChangesCount++; + } else { + if (oldTaskStatusMap.get(task.id) !== task.status) { + task.isMoved = true; + const oldStatus = oldTaskStatusMap.get(task.id); + const oldCol = config.columns.find(c => c.id === oldStatus); + const newCol = config.columns.find(c => c.id === task.status); + const oldColName = oldCol ? oldCol.name : oldStatus; + const newColName = newCol ? newCol.name : task.status; + logActivity('reload', t('activity.externalMoved', {id: task.id, title: task.title, from: oldColName, to: newColName})); + externalChangesCount++; + } + if (oldTaskTitleMap.get(task.id) !== task.title) { + logActivity('reload', t('activity.externalEdited', {id: task.id, title: task.title})); + externalChangesCount++; + } + } + }); + + // Track deleted tasks + oldTaskStatusMap.forEach((status, id) => { + if (!newTaskIds.has(id)) { + logActivity('reload', t('activity.externalDeleted', {id: id})); + externalChangesCount++; + } + }); + + if (externalChangesCount > 0) { + logActivity('reload', t('activity.externalReload')); + } + } + renderKanban(); // Auto-migrate a legacy (V1) kanban.md to V2 on disk: nothing is lost and future // AI reads/edits stay cheap. One-time — V2 files carry the format marker. diff --git a/src/scripts/06-filters.js b/src/scripts/06-filters.js index ff855487..d3016435 100644 --- a/src/scripts/06-filters.js +++ b/src/scripts/06-filters.js @@ -6,6 +6,11 @@ await writable.write(newContent); await writable.close(); currentKanbanContent = newContent; + + // Get the updated file metadata to avoid reloading what we just saved + const file = await kanbanFileHandle.getFile(); + lastKanbanModified = file.lastModified; + console.log('Auto-saved'); return true; } catch (error) { diff --git a/src/scripts/08-board.js b/src/scripts/08-board.js index 37abc3c0..3e8dc109 100644 --- a/src/scripts/08-board.js +++ b/src/scripts/08-board.js @@ -86,6 +86,13 @@ const due = dueStatus(task); if (due) taskEl.classList.add(`task-card--${due}`); + // Visual highlight for new or moved tasks from external changes + if (task.isNew) { + taskEl.classList.add('task-card--new'); + } else if (task.isMoved) { + taskEl.classList.add('task-card--moved'); + } + var priorityBadgeClass = "Default"; if (config.priorities) { const priority = config.priorities.find(p => clean(p) === task.priority); @@ -170,6 +177,7 @@ const oldIdx = tasks.findIndex(t => t.id === taskId); if (oldIdx < 0) return false; const task = tasks[oldIdx]; + const oldStatus = task.status; const statusChanged = newStatus && task.status !== newStatus; const targetStatus = newStatus || task.status; tasks.splice(oldIdx, 1); @@ -188,6 +196,15 @@ tasks.splice(insertIdx, 0, task); const posChanged = tasks.indexOf(task) !== oldIdx; if (newStatus) task.status = newStatus; + + if (statusChanged) { + const oldCol = config.columns.find(c => c.id === oldStatus); + const newCol = config.columns.find(c => c.id === newStatus); + const oldColName = oldCol ? oldCol.name : oldStatus; + const newColName = newCol ? newCol.name : newStatus; + logActivity('move', t('activity.localMoved', {id: task.id, title: task.title, from: oldColName, to: newColName})); + } + return statusChanged || posChanged; } diff --git a/src/scripts/09-task-detail.js b/src/scripts/09-task-detail.js index f7cc14ef..6d2baea3 100644 --- a/src/scripts/09-task-detail.js +++ b/src/scripts/09-task-detail.js @@ -269,6 +269,7 @@ task.subtasks = formSubtasks; task.notes = notes; showNotification(t('notif.taskEdited', {id: taskId}), 'success'); + logActivity('edit', t('activity.localEdited', {id: taskId, title: title})); } } else { // Create new task @@ -291,6 +292,7 @@ notes }); showNotification(t('notif.taskCreated', {id: newTaskId}), 'success'); + logActivity('create', t('activity.localCreated', {id: newTaskId, title: title})); } closeTaskModal(); diff --git a/src/scripts/10-archive.js b/src/scripts/10-archive.js index 18d8e7a7..7ed72a26 100644 --- a/src/scripts/10-archive.js +++ b/src/scripts/10-archive.js @@ -104,6 +104,7 @@ renderKanban(); closeModal(); showNotification(t('notif.taskArchived'), 'success'); + logActivity('archive', t('activity.localArchived', {id: task.id, title: task.title})); } } } @@ -144,6 +145,9 @@ } showNotification(t('notif.taskDeleted'), 'success'); + if (!fromArchive) { + logActivity('delete', t('activity.localDeleted', {id: taskId})); + } } } } @@ -228,6 +232,7 @@ renderKanban(); renderArchiveList(document.getElementById('archiveSearch').value); showNotification(t('notif.taskRestored'), 'success'); + logActivity('create', t('activity.localRestored', {id: task.id, title: task.title})); } } diff --git a/src/styles/02-board.css b/src/styles/02-board.css index ef62f080..1cfbc750 100644 --- a/src/styles/02-board.css +++ b/src/styles/02-board.css @@ -251,3 +251,105 @@ background: none; padding: 0; } + + /* Flash highlights for new/moved tasks */ + @keyframes highlightNew { + 0% { + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.6); /* green outline */ + background-color: rgba(34, 197, 94, 0.15); /* light green bg */ + } + 100% { + box-shadow: none; + background-color: var(--bg-secondary); + } + } + + @keyframes highlightMoved { + 0% { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.6); /* blue outline */ + background-color: rgba(59, 130, 246, 0.15); /* light blue bg */ + } + 100% { + box-shadow: none; + background-color: var(--bg-secondary); + } + } + + .task-card--new { + animation: highlightNew 5s ease-out forwards; + } + + .task-card--moved { + animation: highlightMoved 5s ease-out forwards; + } + + /* Activity Log / History Section */ + .activity-log-section { + margin: 2rem; + padding: 1.5rem; + background: var(--bg-secondary); + border-radius: 8px; + box-shadow: var(--shadow); + border: 1px solid var(--border-color); + } + + .activity-log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); + } + + .activity-log-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + } + + .activity-log-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-family: monospace; + font-size: 0.9rem; + } + + .activity-log-item { + display: flex; + gap: 1rem; + padding: 0.4rem 0.6rem; + border-radius: 4px; + background: var(--bg-primary); + border-left: 4px solid var(--accent); + } + + .activity-log-item.reload { + border-left-color: var(--priority-green); + } + + .activity-log-item.create { + border-left-color: var(--priority-blue); + } + + .activity-log-item.edit { + border-left-color: var(--priority-yellow); + } + + .activity-log-item.delete { + border-left-color: var(--priority-red); + } + + .activity-log-time { + color: var(--text-secondary); + flex-shrink: 0; + } + + .activity-log-msg { + color: var(--text-primary); + word-break: break-all; + } diff --git a/task-manager.html b/task-manager.html index 293f19a5..4950e2a6 100644 --- a/task-manager.html +++ b/task-manager.html @@ -1 +1,4696 @@ -Markdown Task Manager

📋 Task Manager

Bienvenue ! 👋

Sélectionnez le dossier contenant vos fichiers Markdown (kanban.md et archive.md)

💡 Comment ça marche ?

  1. Cliquez sur "Commencer" ci-dessus
  2. Sélectionnez le dossier contenant vos fichiers Markdown
  3. L'application charge automatiquement kanban.md
  4. Gérez vos tâches visuellement avec le Kanban
  5. Les modifications sont sauvegardées dans les fichiers Markdown

⚠️ Navigateurs supportés : Chrome 86+, Edge 86+, Opera 72+

\ No newline at end of file + + + + + + Markdown Task Manager + + + +
+
+

📋 Task Manager

+
+ + + + + + + + + + +
+
+
+ + +
+
+

Bienvenue ! 👋

+

Sélectionnez le dossier contenant vos fichiers Markdown (kanban.md et archive.md)

+ +
+

💡 Comment ça marche ?

+
    +
  1. Cliquez sur "Commencer" ci-dessus
  2. +
  3. Sélectionnez le dossier contenant vos fichiers Markdown
  4. +
  5. L'application charge automatiquement kanban.md
  6. +
  7. Gérez vos tâches visuellement avec le Kanban
  8. +
  9. Les modifications sont sauvegardées dans les fichiers Markdown
  10. +
+

+ ⚠️ Navigateurs supportés : Chrome 86+, Edge 86+, Opera 72+ +

+
+
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + + \ No newline at end of file