diff --git a/plugins/todo/frontend/src/index.js b/plugins/todo/frontend/src/index.js index 6d3d7ac..4b2e3b0 100644 --- a/plugins/todo/frontend/src/index.js +++ b/plugins/todo/frontend/src/index.js @@ -340,18 +340,45 @@ }); } + function notificationRequests() { + return sortTodos(todos).filter(function (todo) { + return todo.status === 'open' && todo.reminderAt; + }).map(function (todo) { + var dueAt = new Date(todo.reminderAt); + if (isNaN(dueAt.getTime())) return null; + var title = todo.title || tr('ui.untitled', null, 'Untitled todo'); + return { + id: todo.id, + dueAt: dueAt.toISOString(), + title: tr('ui.notificationTitle', null, 'Todo reminder'), + body: tr('ui.notificationBody', { title: title }, title) + }; + }).filter(function (item) { return item !== null; }); + } + + function syncNotifications() { + if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve(); + return api.notifications.replace(notificationRequests()).catch(function (err) { + statusText = tr('ui.notificationError', { error: err && err.message ? err.message : String(err) }, 'Could not schedule reminders: ' + (err && err.message ? err.message : String(err))); + statusClass = 'error'; + }); + } + function persist() { - if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); - return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) { + if (!api || !api.settings || typeof api.settings.write !== 'function') return syncNotifications(); + return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () { + return syncNotifications(); + }).catch(function (err) { statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err))); statusClass = 'error'; }); } function loadStored() { - if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); + if (!api || !api.settings || typeof api.settings.read !== 'function') return syncNotifications(); return api.settings.read().then(function (settings) { todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY])); + return syncNotifications(); }).catch(function (err) { statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load todos: ' + (err && err.message ? err.message : String(err))); statusClass = 'error'; @@ -589,6 +616,7 @@ searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos')); addBtn.textContent = tr('ui.add', null, 'Add Todo'); render(); + syncNotifications().then(render); }); } }; diff --git a/plugins/todo/locales/en.json b/plugins/todo/locales/en.json index 75207c0..eed0874 100644 --- a/plugins/todo/locales/en.json +++ b/plugins/todo/locales/en.json @@ -19,6 +19,9 @@ "ui.unassigned": "Unassigned", "ui.saveError": "Could not save todos: {error}", "ui.loadError": "Could not load todos: {error}", + "ui.notificationError": "Could not schedule reminders: {error}", + "ui.notificationTitle": "Todo reminder", + "ui.notificationBody": "{title}", "ui.titlePlaceholder": "Todo title", "ui.descriptionPlaceholder": "Optional description", "ui.priority.low": "Low", diff --git a/plugins/todo/locales/ru.json b/plugins/todo/locales/ru.json index 70efa63..2c6a7e8 100644 --- a/plugins/todo/locales/ru.json +++ b/plugins/todo/locales/ru.json @@ -19,6 +19,9 @@ "ui.unassigned": "Не назначено", "ui.saveError": "Не удалось сохранить задачи: {error}", "ui.loadError": "Не удалось загрузить задачи: {error}", + "ui.notificationError": "Не удалось запланировать напоминания: {error}", + "ui.notificationTitle": "Напоминание о задаче", + "ui.notificationBody": "{title}", "ui.titlePlaceholder": "Название задачи", "ui.descriptionPlaceholder": "Необязательное описание", "ui.priority.low": "Низкий", diff --git a/plugins/todo/plugin.json b/plugins/todo/plugin.json index c517ad1..5c919f3 100644 --- a/plugins/todo/plugin.json +++ b/plugins/todo/plugin.json @@ -12,10 +12,14 @@ "todo.list", "todo.workspace" ], + "requires": [ + "verstak/core/notifications/v1" + ], "permissions": [ "files.read", "storage.namespace", - "ui.register" + "ui.register", + "notifications.schedule" ], "frontend": { "entry": "frontend/src/index.js" diff --git a/scripts/smoke-todo-plugin.js b/scripts/smoke-todo-plugin.js index acb172a..b6c107b 100755 --- a/scripts/smoke-todo-plugin.js +++ b/scripts/smoke-todo-plugin.js @@ -146,10 +146,12 @@ function loadComponent(document, emittedEvents) { function makeApi(initialSettings = {}, initialLocale = 'en') { const settings = { ...initialSettings }; + const notificationCalls = []; let locale = initialLocale; const localeListeners = []; return { settings, + notificationCalls, settingsApi: { read: async (key) => (key ? settings[key] : { ...settings }), write: async (key, value) => { @@ -163,6 +165,11 @@ function makeApi(initialSettings = {}, initialLocale = 'en') { { name: 'Project', relativePath: 'Project', type: 'folder' }, ], }, + notifications: { + replace: async (items) => { + notificationCalls.push((Array.isArray(items) ? items : []).map((item) => ({ ...item }))); + }, + }, setLocale(nextLocale) { locale = nextLocale; localeListeners.slice().forEach((listener) => listener(locale)); @@ -171,6 +178,7 @@ function makeApi(initialSettings = {}, initialLocale = 'en') { return { settings: this.settingsApi, files: this.files, + notifications: this.notifications, i18n: { getLocale: () => locale, t: (key, params, fallback) => { @@ -208,6 +216,8 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch'); if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace'); if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read'); + if (!manifest.permissions.includes('notifications.schedule')) throw new Error('todo manifest must request notifications.schedule'); + if (!manifest.requires.includes('verstak/core/notifications/v1')) throw new Error('todo manifest must require native notifications'); if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view'); if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item'); @@ -233,6 +243,14 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored'); if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored'); if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered'); + const scheduledAfterCreate = apiState.notificationCalls.at(-1) || []; + if (scheduledAfterCreate.length !== 1 + || scheduledAfterCreate[0].id !== createdTodo.id + || scheduledAfterCreate[0].dueAt !== new Date(createdTodo.reminderAt).toISOString() + || scheduledAfterCreate[0].title !== 'Todo reminder' + || scheduledAfterCreate[0].body !== 'Prepare project review') { + throw new Error('open Todo reminder was not scheduled as a native notification'); + } apiState.setLocale('ru'); if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) { @@ -252,6 +270,9 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) { throw new Error('mark done did not persist completed state'); } + if ((apiState.notificationCalls.at(-1) || []).length !== 0) { + throw new Error('completed Todo reminder was not removed from native notification schedules'); + } if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action'); if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion');