diff --git a/README.md b/README.md index 6b741fe..d46db3a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ a tui-style browser startpage, built with svelte. features: -- task list with multiple backend options (local, todoist, google tasks (chrome only)) +- task list with multiple backend options (local, todoist, google tasks (chrome only), microsoft todo (chrome only)) - smart task input with natural date and project parsing - weather summary (from open-meteo) - customizable quick links with icons @@ -37,13 +37,37 @@ features: - drag the "=" to reorder links in the settings. - tasks - you can force refresh the task/weather widgets by clicking the top left panel labels - - the 'x tasks' text is a clickable link to either or . + - the 'x tasks' text is a clickable link to the current backend web app: + - todoist: + - google tasks: + - microsoft todo: - when adding tasks, you can add due dates by typing naturally like "tmrw", "friday", "dec 25", "jan 1 3pm", etc. - assign tasks to projects/lists by typing `#projectname` anywhere in the task input. - completed tasks are hidden after 5 minutes. - the ping stat measures how long a request to takes. don't take it too seriously. - here's a matching [firefox color theme](https://color.firefox.com/?theme=XQAAAAK3BAAAAAAAAABBqYhm849SCicxcUhA3DJozHnOMuotJJDtxcajvY2nrbwtWf53IW6FuMhmsQBmHjQtYV0LyoGIJnESUiSA8WGCMfXU1SYqmE_CaU8iA8bQXAYc2jrXIT6bjoi8T-cSTCi2_9o7kcESfauVKnMZKEKJIeeuT9qsP4Z_T2ya4LBqvZWjm1-pHOmWMq1OU0wrgs4bkzHQWozn4dcm22eBmWyWR55FkcmEsPvvHzhHCZ2ZMQrPXQqrOBLr79GTkJUGa5oslhWTp2LYqdD2gNQ1a8_c5-F91bPVmQerXZWpp-OZ11D1Ai6t1ydqjbVKD3RrGXYJwhcQaAxCKa_ft4VoGrVBq8AXYeJOZdXuOxnYXGhOXXSK_NybBfJLm-2W28qSSdoiW0pTL-iFan3xQQeC0WlSrnRYrRjh7HkgLuI-Ft8Fq5kNC7nVXoo8j9Ml_q2AO_RhE116j_MECbspxaJP58juayX_wNty3V2g5zUsf0gSqpEWGT02oZAF2z6LABKRWTO28wIoMUDvj9WAQGsup95WAmNW7g4WMEIgaiJhmBz9koq0wV7gHQtJB_0x2lJ7WQ488bJi8LvqnW-VT3kZ3GJtyv-yXmRJ)! +## microsoft todo setup (chrome only) + +1. create a microsoft entra app registration (single-page/public client is fine for this extension flow). +2. add delegated microsoft graph permissions: + - `Tasks.ReadWrite` + - `offline_access` + - `openid` + - `profile` +3. add redirect uri: + - `https://.chromiumapp.org/microsoft` + - you can get `` after loading the extension in chrome. +4. in re-start settings: + - set task backend to `microsoft todo` + - paste `microsoft client id` (application/client id from app registration) + - optionally set `microsoft tenant` (`common` by default) + - click `[sign in with microsoft]` + +notes: +- `common` allows personal + organizational microsoft accounts (if your app registration supports both). +- this backend currently targets chrome/edge only because it uses `chrome.identity.launchWebAuthFlow`. + ## development / build from source 1. clone this repo. diff --git a/package-lock.json b/package-lock.json index b33f31f..0e1f468 100644 --- a/package-lock.json +++ b/package-lock.json @@ -893,6 +893,7 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -1075,6 +1076,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1370,6 +1372,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1526,6 +1529,7 @@ "integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1598,6 +1602,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/plugins/build-manifest.js b/plugins/build-manifest.js index f3104ea..d44c34d 100644 --- a/plugins/build-manifest.js +++ b/plugins/build-manifest.js @@ -40,6 +40,17 @@ export function buildManifest() { (p) => p !== 'identity' ) } + if (manifest.host_permissions) { + manifest.host_permissions = manifest.host_permissions.filter( + (host) => + host !== 'https://graph.microsoft.com/*' && + host !== + 'https://login.microsoftonline.com/*' + ) + if (manifest.host_permissions.length === 0) { + delete manifest.host_permissions + } + } } if (!fs.existsSync(outDir)) { diff --git a/public/manifest.json b/public/manifest.json index b22a793..4175f06 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -10,6 +10,10 @@ "homepage": "index.html" }, "permissions": ["identity"], + "host_permissions": [ + "https://graph.microsoft.com/*", + "https://login.microsoftonline.com/*" + ], "oauth2": { "client_id": "489393578728-r6p53q4oe7ngcm6r4kmtgbk17s2cgpk8.apps.googleusercontent.com", "scopes": ["https://www.googleapis.com/auth/tasks"] diff --git a/src/lib/backends/index.js b/src/lib/backends/index.js index 7710fd7..70682a7 100644 --- a/src/lib/backends/index.js +++ b/src/lib/backends/index.js @@ -1,6 +1,7 @@ import TodoistBackend from './todoist-backend.js' import LocalStorageBackend from './localstorage-backend.js' import GoogleTasksBackendExtension from './google-tasks-backend.js' +import MicrosoftTodoBackend from './microsoft-todo-backend.js' export function createTaskBackend(type, config) { switch (type) { @@ -10,9 +11,16 @@ export function createTaskBackend(type, config) { return new LocalStorageBackend(config) case 'google-tasks': return new GoogleTasksBackendExtension(config) + case 'microsoft-todo': + return new MicrosoftTodoBackend(config) default: throw new Error(`Unknown backend type: ${type}`) } } -export { TodoistBackend, LocalStorageBackend, GoogleTasksBackendExtension } +export { + TodoistBackend, + LocalStorageBackend, + GoogleTasksBackendExtension, + MicrosoftTodoBackend, +} diff --git a/src/lib/backends/microsoft-todo-backend.js b/src/lib/backends/microsoft-todo-backend.js new file mode 100644 index 0000000..20687fa --- /dev/null +++ b/src/lib/backends/microsoft-todo-backend.js @@ -0,0 +1,532 @@ +import TaskBackend from './task-backend.js' +import { isChrome } from '../utils/browser-detect.js' + +const MS_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0' +const MS_SCOPE = 'openid profile offline_access Tasks.ReadWrite' + +function toBase64Url(bytes) { + const str = btoa(String.fromCharCode(...bytes)) + return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +async function generateCodeChallenge(verifier) { + const data = new TextEncoder().encode(verifier) + const digest = await crypto.subtle.digest('SHA-256', data) + return toBase64Url(new Uint8Array(digest)) +} + +function makeCodeVerifier() { + const bytes = crypto.getRandomValues(new Uint8Array(64)) + return toBase64Url(bytes) +} + +function makeState() { + const bytes = crypto.getRandomValues(new Uint8Array(24)) + return toBase64Url(bytes) +} + +function parseDueDate(dueDateTime) { + if (!dueDateTime?.dateTime) { + return { due: null, dueDate: null, hasTime: false } + } + + const rawDateTime = dueDateTime.dateTime + const dateOnly = rawDateTime.split('T')[0] + const midnightPattern = /T00:00(?::00(?:\.0+)?)?$/ + const hasTime = !midnightPattern.test(rawDateTime) + + if (!hasTime) { + return { + due: { date: dateOnly }, + dueDate: new Date(`${dateOnly}T23:59:59`), + hasTime: false, + } + } + + const source = + dueDateTime.timeZone === 'UTC' ? `${rawDateTime}Z` : rawDateTime + const parsed = new Date(source) + if (Number.isNaN(parsed.getTime())) { + return { due: null, dueDate: null, hasTime: false } + } + + return { + due: { date: rawDateTime }, + dueDate: parsed, + hasTime: true, + } +} + +export function mapMicrosoftTask(task, listName = '', order = 0) { + const due = parseDueDate(task.dueDateTime) + return { + id: task.id, + content: task.title || '', + checked: task.status === 'completed', + completed_at: task.completedDateTime?.dateTime + ? task.completedDateTime.dateTime.endsWith('Z') + ? task.completedDateTime.dateTime + : `${task.completedDateTime.dateTime}Z` + : null, + due: due.due, + due_date: due.dueDate, + has_time: due.hasTime, + project_id: task.listId || null, + project_name: listName, + labels: [], + label_names: [], + child_order: order, + is_deleted: false, + } +} + +export function normalizeDueForGraph(due) { + if (!due) return null + + if (due.includes('T')) { + const localDate = new Date(due) + if (Number.isNaN(localDate.getTime())) return null + const utcDateTime = localDate.toISOString().replace(/\.\d{3}Z$/, '') + return { + dateTime: utcDateTime, + timeZone: 'UTC', + } + } + + return { + dateTime: `${due}T00:00:00`, + timeZone: 'UTC', + } +} + +class MicrosoftTodoBackend extends TaskBackend { + constructor(config = {}) { + super(config) + this.baseUrl = MS_GRAPH_BASE_URL + this.scope = MS_SCOPE + this.clientId = config.clientId || '' + this.tenant = config.tenant || 'common' + + this.dataKey = 'microsoft_todo_data' + this.defaultListIdKey = 'microsoft_todo_default_list' + this.refreshTokenKey = 'microsoft_todo_refresh_token' + this.data = JSON.parse(localStorage.getItem(this.dataKey) ?? '{}') + this.defaultListId = localStorage.getItem(this.defaultListIdKey) ?? '' + this.refreshToken = localStorage.getItem(this.refreshTokenKey) ?? null + this.accessToken = null + this.accessTokenExpiresAt = 0 + this.tokenPromise = null + } + + get authorizeUrl() { + return `https://login.microsoftonline.com/${this.tenant}/oauth2/v2.0/authorize` + } + + get tokenUrl() { + return `https://login.microsoftonline.com/${this.tenant}/oauth2/v2.0/token` + } + + assertAuthAvailable() { + if (!isChrome()) { + throw new Error( + 'Chrome identity API not available. Microsoft To Do only works in Chrome.' + ) + } + if (!this.clientId) { + throw new Error('Microsoft client ID is required.') + } + } + + async launchAuthFlow(url, interactive) { + return new Promise((resolve, reject) => { + chrome.identity.launchWebAuthFlow( + { url, interactive }, + (responseUrl) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)) + return + } + + if (!responseUrl) { + reject(new Error('Authentication canceled')) + return + } + + resolve(responseUrl) + } + ) + }) + } + + async signIn() { + this.assertAuthAvailable() + + const redirectUri = chrome.identity.getRedirectURL('microsoft') + const state = makeState() + const codeVerifier = makeCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + + const params = new URLSearchParams({ + client_id: this.clientId, + response_type: 'code', + redirect_uri: redirectUri, + response_mode: 'query', + scope: this.scope, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + prompt: 'select_account', + }) + + const authUrl = `${this.authorizeUrl}?${params.toString()}` + const callbackUrl = await this.launchAuthFlow(authUrl, true) + const callback = new URL(callbackUrl) + + if (callback.searchParams.get('state') !== state) { + throw new Error('Invalid authentication state') + } + + const code = callback.searchParams.get('code') + if (!code) { + throw new Error('No authorization code received') + } + + await this.exchangeCodeForToken(code, codeVerifier, redirectUri) + return this.accessToken + } + + async exchangeCodeForToken(code, codeVerifier, redirectUri) { + const body = new URLSearchParams({ + client_id: this.clientId, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + scope: this.scope, + }) + + const response = await fetch(this.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }) + + if (!response.ok) { + throw new Error(`Microsoft token exchange failed: ${response.status}`) + } + + const tokenData = await response.json() + this.setTokenData(tokenData) + } + + setTokenData(tokenData) { + this.accessToken = tokenData.access_token || null + const expiresIn = Number(tokenData.expires_in || 3600) + this.accessTokenExpiresAt = Date.now() + (expiresIn - 60) * 1000 + + if (tokenData.refresh_token) { + this.refreshToken = tokenData.refresh_token + localStorage.setItem(this.refreshTokenKey, this.refreshToken) + } + } + + async refreshAccessToken() { + if (!this.refreshToken) { + throw new Error('No refresh token available') + } + + const redirectUri = chrome.identity.getRedirectURL('microsoft') + const body = new URLSearchParams({ + client_id: this.clientId, + grant_type: 'refresh_token', + refresh_token: this.refreshToken, + redirect_uri: redirectUri, + scope: this.scope, + }) + + const response = await fetch(this.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }) + + if (!response.ok) { + // Clear all local auth state so the UI treats this as an expired sign-in. + this.accessToken = null + this.accessTokenExpiresAt = null + this.refreshToken = null + localStorage.removeItem(this.refreshTokenKey) + throw new Error('Authentication expired. Please sign in again.') + } + + const tokenData = await response.json() + this.setTokenData(tokenData) + return this.accessToken + } + + async getAccessToken(interactive = false) { + this.assertAuthAvailable() + + if (this.tokenPromise) { + return this.tokenPromise + } + + this.tokenPromise = (async () => { + if ( + this.accessToken && + this.accessTokenExpiresAt && + Date.now() < this.accessTokenExpiresAt + ) { + return this.accessToken + } + + if (this.refreshToken) { + return this.refreshAccessToken() + } + + if (interactive) { + await this.signIn() + return this.accessToken + } + + throw new Error('Authentication expired. Please sign in again.') + })() + + try { + return await this.tokenPromise + } finally { + this.tokenPromise = null + } + } + + async signOut() { + this.accessToken = null + this.accessTokenExpiresAt = 0 + this.refreshToken = null + this.clearLocalData() + } + + async apiRequest(endpoint, options = {}) { + let token = await this.getAccessToken(false) + const url = endpoint.startsWith('http') + ? endpoint + : `${this.baseUrl}${endpoint}` + + const makeRequest = async (accessToken) => + fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + let response = await makeRequest(token) + + if (response.status === 401) { + token = await this.refreshAccessToken() + response = await makeRequest(token) + } + + if (!response.ok) { + throw new Error( + `Microsoft Graph request failed: ${response.status} ${response.statusText}` + ) + } + + if ( + response.status === 204 || + response.headers.get('content-length') === '0' + ) { + return null + } + + return response.json() + } + + async getAllPages(endpoint) { + let nextUrl = `${this.baseUrl}${endpoint}` + const items = [] + + while (nextUrl) { + const data = await this.apiRequest(nextUrl) + items.push(...(data.value || [])) + nextUrl = data['@odata.nextLink'] || null + } + + return items + } + + async sync(resourceTypes = ['lists', 'tasks']) { + try { + let newLists = this.data.lists || [] + let newTasks = this.data.tasks || [] + + if (resourceTypes.includes('lists')) { + newLists = await this.getAllPages('/me/todo/lists') + + const hasValidList = newLists.some( + (list) => list.id === this.defaultListId + ) + if (!this.defaultListId || !hasValidList) { + this.defaultListId = newLists[0]?.id || '' + localStorage.setItem(this.defaultListIdKey, this.defaultListId) + } + } + + if (resourceTypes.includes('tasks')) { + const taskPromises = newLists.map(async (list) => { + const listTasks = await this.getAllPages( + `/me/todo/lists/${list.id}/tasks` + ) + return listTasks.map((task, index) => ({ + ...task, + listId: list.id, + listName: list.displayName, + orderIndex: index, + })) + }) + const taskArrays = await Promise.all(taskPromises) + newTasks = taskArrays.flat() + } + + this.data = { + lists: newLists, + tasks: newTasks, + } + localStorage.setItem(this.dataKey, JSON.stringify(this.data)) + return this.data + } catch (error) { + if (error.message?.includes('Authentication expired')) { + this.clearLocalData() + } + throw error + } + } + + getTasks() { + if (!this.data.tasks) return [] + const recentThreshold = new Date(Date.now() - 5 * 60 * 1000) + + const mappedTasks = this.data.tasks + .filter((task) => { + if (task.status !== 'completed') return true + if (!task.completedDateTime?.dateTime) return false + const dateTimeStr = task.completedDateTime.dateTime + const hasTimezone = + /Z$/.test(dateTimeStr) || /[+-]\d{2}:\d{2}$/.test(dateTimeStr) + const isoString = hasTimezone ? dateTimeStr : `${dateTimeStr}Z` + const completedAt = new Date(isoString) + return completedAt > recentThreshold + }) + .map((task) => + mapMicrosoftTask(task, task.listName || '', task.orderIndex ?? 0) + ) + + return MicrosoftTodoBackend.sortTasks(mappedTasks) + } + + static sortTasks(tasks) { + return tasks.sort((a, b) => { + if (a.checked !== b.checked) return a.checked ? 1 : -1 + + if (a.checked && a.completed_at && b.completed_at) { + const diff = + new Date(b.completed_at).getTime() - + new Date(a.completed_at).getTime() + if (diff !== 0) return diff + } + + if (!a.due_date && b.due_date) return 1 + if (a.due_date && !b.due_date) return -1 + + if (a.due_date && b.due_date) { + const diff = a.due_date.getTime() - b.due_date.getTime() + if (diff !== 0) return diff + } + + return (a.child_order ?? 0) - (b.child_order ?? 0) + }) + } + + async addTask(content, due, listId) { + const targetListId = listId || this.defaultListId + if (!targetListId) { + throw new Error('No Microsoft To Do list available') + } + + const taskData = { title: content } + const dueDateTime = normalizeDueForGraph(due) + if (dueDateTime) taskData.dueDateTime = dueDateTime + + return this.apiRequest(`/me/todo/lists/${targetListId}/tasks`, { + method: 'POST', + body: JSON.stringify(taskData), + }) + } + + async completeTask(taskId) { + const task = this.data.tasks?.find((t) => t.id === taskId) + const listId = task?.listId || this.defaultListId + const completedDateTime = new Date().toISOString().replace(/\.\d{3}Z$/, '') + + return this.apiRequest(`/me/todo/lists/${listId}/tasks/${taskId}`, { + method: 'PATCH', + body: JSON.stringify({ + status: 'completed', + completedDateTime: { + dateTime: completedDateTime, + timeZone: 'UTC', + }, + }), + }) + } + + async uncompleteTask(taskId) { + const task = this.data.tasks?.find((t) => t.id === taskId) + const listId = task?.listId || this.defaultListId + + return this.apiRequest(`/me/todo/lists/${listId}/tasks/${taskId}`, { + method: 'PATCH', + body: JSON.stringify({ + status: 'notStarted', + completedDateTime: null, + }), + }) + } + + async editTaskName(taskId, newContent) { + const task = this.data.tasks?.find((t) => t.id === taskId) + const listId = task?.listId || this.defaultListId + + return this.apiRequest(`/me/todo/lists/${listId}/tasks/${taskId}`, { + method: 'PATCH', + body: JSON.stringify({ title: newContent }), + }) + } + + async deleteTask(taskId) { + const task = this.data.tasks?.find((t) => t.id === taskId) + const listId = task?.listId || this.defaultListId + return this.apiRequest(`/me/todo/lists/${listId}/tasks/${taskId}`, { + method: 'DELETE', + }) + } + + clearLocalData() { + localStorage.removeItem(this.dataKey) + localStorage.removeItem(this.defaultListIdKey) + localStorage.removeItem(this.refreshTokenKey) + this.data = {} + this.defaultListId = '' + this.accessToken = null + this.accessTokenExpiresAt = 0 + } +} + +export default MicrosoftTodoBackend diff --git a/src/lib/backends/microsoft-todo-backend.test.js b/src/lib/backends/microsoft-todo-backend.test.js new file mode 100644 index 0000000..322accd --- /dev/null +++ b/src/lib/backends/microsoft-todo-backend.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import MicrosoftTodoBackend, { + mapMicrosoftTask, + normalizeDueForGraph, +} from './microsoft-todo-backend.js' + +describe('microsoft todo backend helpers', () => { + it('maps date-only dueDateTime as no-time task', () => { + const mapped = mapMicrosoftTask( + { + id: '1', + title: 'pay bill', + status: 'notStarted', + dueDateTime: { + dateTime: '2026-03-20T00:00:00', + timeZone: 'UTC', + }, + }, + 'Personal', + 7 + ) + + expect(mapped.has_time).toBe(false) + expect(mapped.due?.date).toBe('2026-03-20') + expect(mapped.due_date?.getFullYear()).toBe(2026) + expect(mapped.due_date?.getMonth()).toBe(2) + expect(mapped.due_date?.getDate()).toBe(20) + expect(mapped.due_date?.getHours()).toBe(23) + expect(mapped.project_name).toBe('Personal') + expect(mapped.child_order).toBe(7) + }) + + it('normalizes due for graph date and datetime formats', () => { + expect(normalizeDueForGraph('2026-03-20')).toEqual({ + dateTime: '2026-03-20T00:00:00', + timeZone: 'UTC', + }) + + const normalized = normalizeDueForGraph('2026-03-20T09:30:00') + expect(normalized?.timeZone).toBe('UTC') + expect(normalized?.dateTime).toMatch(/^2026-03-20T\d{2}:30:00$/) + }) + + it('sorts unchecked first, then by due date', () => { + const sorted = MicrosoftTodoBackend.sortTasks([ + { + id: 'a', + checked: false, + due_date: new Date('2026-03-10T23:59:59'), + child_order: 2, + }, + { + id: 'b', + checked: true, + completed_at: '2026-03-09T08:00:00Z', + due_date: null, + child_order: 1, + }, + { + id: 'c', + checked: false, + due_date: new Date('2026-03-08T23:59:59'), + child_order: 3, + }, + ]) + + expect(sorted.map((task) => task.id)).toEqual(['c', 'a', 'b']) + }) +}) diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 7dcb2c4..b3aeeae 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -21,27 +21,30 @@ let { showSettings = false, closeSettings } = $props() const prevDomains = new WeakMap() - // Check if Google Tasks is available (Chrome only) - const googleTasksAvailable = isChrome() + // Task backends that depend on chrome.identity are Chrome-only + const chromeIdentityAvailable = isChrome() // @ts-ignore const version = __APP_VERSION__ let googleTasksApi = $state(null) - let signingIn = $state(false) - let signInError = $state('') + let microsoftTodoApi = $state(null) + let googleSigningIn = $state(false) + let googleSignInError = $state('') + let microsoftSigningIn = $state(false) + let microsoftSignInError = $state('') function googleSignInLabel() { if (settings.googleTasksSignedIn) return 'sign out' - if (signInError) return signInError - if (signingIn) return 'signing in...' + if (googleSignInError) return googleSignInError + if (googleSigningIn) return 'signing in...' return 'sign in with google' } async function handleGoogleSignIn() { try { - signingIn = true - signInError = '' + googleSigningIn = true + googleSignInError = '' if (!googleTasksApi) { googleTasksApi = createTaskBackend('google-tasks') @@ -52,10 +55,10 @@ saveSettings(settings) } catch (err) { console.error('google sign in failed:', err) - signInError = 'sign in failed' + googleSignInError = 'sign in failed' settings.googleTasksSignedIn = false } finally { - signingIn = false + googleSigningIn = false } } @@ -68,12 +71,72 @@ await googleTasksApi.signOut() settings.googleTasksSignedIn = false saveSettings(settings) - signInError = '' + googleSignInError = '' } catch (err) { console.error('google sign out failed:', err) } } + function microsoftSignInLabel() { + if (settings.microsoftTodoSignedIn) return 'sign out' + if (microsoftSignInError) return microsoftSignInError + if (microsoftSigningIn) return 'signing in...' + return 'sign in with microsoft' + } + + function getMicrosoftConfig() { + return { + clientId: settings.microsoftTodoClientId?.trim(), + tenant: settings.microsoftTodoTenant?.trim() || 'common', + } + } + + async function handleMicrosoftSignIn() { + const clientId = settings.microsoftTodoClientId?.trim() + if (!clientId) { + microsoftSignInError = 'client id required' + settings.microsoftTodoSignedIn = false + return + } + + try { + microsoftSigningIn = true + microsoftSignInError = '' + microsoftTodoApi = createTaskBackend( + 'microsoft-todo', + getMicrosoftConfig() + ) + + await microsoftTodoApi.signIn() + settings.microsoftTodoSignedIn = true + saveSettings(settings) + } catch (err) { + console.error('microsoft sign in failed:', err) + microsoftSignInError = 'sign in failed' + settings.microsoftTodoSignedIn = false + } finally { + microsoftSigningIn = false + } + } + + async function handleMicrosoftSignOut() { + try { + if (!microsoftTodoApi) { + microsoftTodoApi = createTaskBackend( + 'microsoft-todo', + getMicrosoftConfig() + ) + } + + await microsoftTodoApi.signOut() + settings.microsoftTodoSignedIn = false + saveSettings(settings) + microsoftSignInError = '' + } catch (err) { + console.error('microsoft sign out failed:', err) + } + } + let iconPickerOpen = $state(null) let iconPickerRef = $state(null) @@ -436,13 +499,19 @@ > todoist - {#if googleTasksAvailable} + {#if chromeIdentityAvailable} google tasks + + microsoft todo + {/if} @@ -466,13 +535,48 @@ onclick={settings.googleTasksSignedIn ? handleGoogleSignOut : handleGoogleSignIn} - disabled={signingIn} + disabled={googleSigningIn} > [{googleSignInLabel()}] {/if} + {#if settings.taskBackend === 'microsoft-todo'} +
+ + +
+
+ + +
+
+
+ microsoft todo authentication +
+ +
+ {/if} +
weather forecast
diff --git a/src/lib/components/Tasks.svelte b/src/lib/components/Tasks.svelte index 1e321d4..6d33e42 100644 --- a/src/lib/components/Tasks.svelte +++ b/src/lib/components/Tasks.svelte @@ -22,6 +22,8 @@ let initialLoad = $state(true) let previousToken = $state(null) let previousBackend = $state(null) + let previousMicrosoftClientId = $state('') + let previousMicrosoftTenant = $state('common') let taskCount = $derived(tasks.filter((task) => !task.checked).length) let taskLabel = $derived(taskCount === 1 ? 'task' : 'tasks') let backendUrl = $derived.by(() => { @@ -29,6 +31,8 @@ return 'https://app.todoist.com/app' if (settings.taskBackend === 'google-tasks') return 'https://tasks.google.com' + if (settings.taskBackend === 'microsoft-todo') + return 'https://to-do.office.com/tasks/' return null }) let newTaskContent = $state('') @@ -123,24 +127,43 @@ $effect(() => { const backend = settings.taskBackend const token = settings.todoistApiToken - const googleSignedIn = settings.googleTasksSignedIn + const googleTasksSignedIn = settings.googleTasksSignedIn + const microsoftTodoSignedIn = settings.microsoftTodoSignedIn + const microsoftClientId = settings.microsoftTodoClientId + const microsoftTenant = settings.microsoftTodoTenant if (untrack(() => initialLoad)) { initialLoad = false previousToken = token previousBackend = backend + previousMicrosoftClientId = microsoftClientId + previousMicrosoftTenant = microsoftTenant return } // Clear local data if: // 1. Todoist token changed - // 2. Backend changed (switching between local/todoist/google-tasks) + // 2. Backend changed (switching between local/todoist/google-tasks/microsoft-todo) + // 3. Microsoft client config changed const tokenChanged = backend === 'todoist' && previousToken !== token const backendChanged = previousBackend !== backend - const clearLocalData = tokenChanged || backendChanged + const microsoftConfigChanged = + backend === 'microsoft-todo' && + (previousMicrosoftClientId !== microsoftClientId || + previousMicrosoftTenant !== microsoftTenant) + + if (microsoftConfigChanged && settings.microsoftTodoSignedIn) { + // Microsoft client configuration changed, so require re-auth. + // Reset the signed-in flag to avoid misleading "authentication expired" errors. + settings.microsoftTodoSignedIn = false + } + const clearLocalData = + tokenChanged || backendChanged || microsoftConfigChanged previousToken = token previousBackend = backend + previousMicrosoftClientId = microsoftClientId + previousMicrosoftTenant = microsoftTenant initializeAPI(backend, token, clearLocalData) }) @@ -169,8 +192,36 @@ return } + if (backend === 'microsoft-todo' && !isChrome()) { + resetState('microsoft todo only works in chrome') + return + } + + if ( + backend === 'microsoft-todo' && + !settings.microsoftTodoClientId?.trim() + ) { + resetState('no microsoft client id') + return + } + + if (backend === 'microsoft-todo' && !settings.microsoftTodoSignedIn) { + resetState('not signed in to microsoft') + return + } + try { - const config = backend === 'google-tasks' ? undefined : { token } + const config = + backend === 'google-tasks' + ? undefined + : backend === 'microsoft-todo' + ? { + clientId: settings.microsoftTodoClientId.trim(), + tenant: + settings.microsoftTodoTenant?.trim() || + 'common', + } + : { token } api = createTaskBackend(backend, config) if (clearLocalData) { @@ -214,6 +265,11 @@ id: tl.id, name: tl.title, })) + } else if (settings.taskBackend === 'microsoft-todo') { + availableProjects = (api.data?.lists || []).map((list) => ({ + id: list.id, + name: list.displayName, + })) } } catch (err) { // Check if this is an auth error for Google Tasks @@ -223,6 +279,12 @@ ) { settings.googleTasksSignedIn = false error = 'google sign in expired' + } else if ( + settings.taskBackend === 'microsoft-todo' && + err.message?.includes('Authentication expired') + ) { + settings.microsoftTodoSignedIn = false + error = 'microsoft sign in expired' } else { error = `failed to sync tasks` } diff --git a/src/lib/stores/settings-store.svelte.js b/src/lib/stores/settings-store.svelte.js index 477edbe..eeee9ac 100644 --- a/src/lib/stores/settings-store.svelte.js +++ b/src/lib/stores/settings-store.svelte.js @@ -28,6 +28,9 @@ let defaultSettings = { taskBackend: 'local', todoistApiToken: '', googleTasksSignedIn: false, + microsoftTodoSignedIn: false, + microsoftTodoClientId: '', + microsoftTodoTenant: 'common', locationMode: 'manual', latitude: null, longitude: null,