diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 6635cf5..fe323cd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* +.direnv/ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f408e4a --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1733392399, + "narHash": "sha256-kEsTJTUQfQFIJOcLYFt/RvNxIK653ZkTBIs4DG+cBns=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d0797a04b81caeae77bcff10a9dde78bc17f5661", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8db58aa --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + }; + + outputs = {self, nixpkgs}: let + supportedSystems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; + forEachSupportedSystem = f: + nixpkgs.lib.genAttrs supportedSystems (system: + f { + pkgs = import nixpkgs { + inherit system; + }; + }); + in { + devShells = forEachSupportedSystem ({pkgs}: { + default = pkgs.mkShell { + packages = with pkgs; [ + nodejs + pnpm + nodePackages.typescript-language-server + prettierd + ]; + shellHook = '' + if [ ! -d node_modules ]; then + echo "Use pnpm to install dependencies" + fi + ''; + }; + }); + }; +} diff --git a/src/lib/components/settings/BoolSetting.svelte b/src/lib/components/settings/BoolSetting.svelte index b6cda49..a9f991e 100644 --- a/src/lib/components/settings/BoolSetting.svelte +++ b/src/lib/components/settings/BoolSetting.svelte @@ -27,7 +27,7 @@ {/if} - + {#if description && showDescription}
diff --git a/src/lib/components/settings/todo/todoist/Modal.svelte b/src/lib/components/settings/todo/todoist/Modal.svelte new file mode 100644 index 0000000..5128242 --- /dev/null +++ b/src/lib/components/settings/todo/todoist/Modal.svelte @@ -0,0 +1,120 @@ + + + + +
+ + Dlool erstellt ein neues Todoist projekt, die Erinerrungen in diesem Projekt werden automatisch + aktualisiert. Bitte ändere die Beschreibung deswegen nicht + + +

Neues Projekt

+ + +

Farbe

+
+ {#each objectEntries(TODOIST_COLORS) as [key, hex] (key)} +
+ + { + disabled = true; + try { + const { id } = await createProject(name, selectedColor); + sendToast({ + type: 'success', + content: i('todoist.listCreated'), + timeout: 5_000 + }); + dispatch('finish', id); + } catch { + disabled = false; + sendDefaultErrorToast(); + } + }} + {disabled} + > + Erstellen! + +
diff --git a/src/lib/constants/settings.ts b/src/lib/constants/settings.ts index ff5ecc9..41ccfe4 100644 --- a/src/lib/constants/settings.ts +++ b/src/lib/constants/settings.ts @@ -1,4 +1,4 @@ -import { get, type Readable } from 'svelte/store'; +import { get, writable, type Readable } from 'svelte/store'; import { DeviceTablet, Calendar, @@ -6,7 +6,8 @@ import { PaintBrush, User, BookOpen, - type IconSource + type IconSource, + Check } from 'svelte-hero-icons'; import { i } from '$lib/i18n/store'; import { isApple } from '$lib/stores'; @@ -58,6 +59,13 @@ export const settings: Setting[] = [ label: i('settings.assignments'), icon: BookOpen }, + { type: "hr" }, + { + type: 'link', + uri: '/todo', + label: writable("Todo-Integration"), // TODO: i18n + icon: Check + }, { type: 'hr', show: (browser) => { diff --git a/src/lib/constants/todoistColors.ts b/src/lib/constants/todoistColors.ts new file mode 100644 index 0000000..2591b94 --- /dev/null +++ b/src/lib/constants/todoistColors.ts @@ -0,0 +1,22 @@ +export const TODOIST_COLORS = { + berry_red: '#b8256f', + red: '#db4035', + orange: '#ff9933', + yellow: '#fad000', + olive_green: '#afb83b', + lime_green: '#7ecc49', + green: '#299438', + mint_green: '#6accbc', + teal: '#158fad', + sky_blue: '#14aaf5', + light_blue: '#96c3eb', + blue: '#4073ff', + grape: '#884dff', + violet: '#af38eb', + lavender: '#eb96eb', + magenta: '#e05194', + salmon: '#ff8d85', + charcoal: '#808080', + grey: '#b8b8b8', + taupe: '#ccac93' +}; diff --git a/src/lib/locales/de.ts b/src/lib/locales/de.ts index 5100994..4f0559f 100644 --- a/src/lib/locales/de.ts +++ b/src/lib/locales/de.ts @@ -134,6 +134,7 @@ const de = { 'title.settings': 'Einstellungen', 'title.settings.profile': 'Profil Einstellungen', 'title.settings.general': 'Allgemeine Einstellungen', + 'title.settings.todo': 'ToDo Synchronisation', 'title.holiday': 'Ferien', 'home.subtitle': 'Das Hausaufgabenheft der nächsten Generation für Deine ganze Klasse', @@ -717,6 +718,19 @@ Deine bisherigen Einstellungen sind leider nicht mit der neuen Version kompatibe 'profile.links.reqs': 'Deine Beitritts-Anfragen', 'profile.links.settings': 'Deine Profil Einstellungen', + 'todoist.enable': "Aktiviere todoist Synchronisation", + 'todoist.createListButton': "Erstelle eine neue Todoist Liste", + 'todoist.createList': "Erstelle eine Todoist Liste", + 'todoist.listCreated': "Todist Liste erstellt", + 'todoist.tasksCreated': { + counts: { + default: "Aufgaben zum Todoist Projekt hinzugefügt", + 1: "Eine Aufgabe zum Todoist Projekt hinzugefügt", + 2: "Zwei Aufgabe zum Todoist Projekt hinzugefügt", + 25: "25 Aufgabe zum Todoist Projekt hinzugefügt", + } + }, + literal: '$literal' } as const satisfies I18nDict; diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index 204da1f..2af8783 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -131,6 +131,7 @@ const en = { 'title.settings': 'Settings', 'title.settings.profile': 'Profile settings', 'title.settings.general': 'General settings', + 'title.settings.todo': 'ToDo Syncing', 'title.holiday': 'Holiday', 'home.subtitle': 'Next generation homework for your entire class', @@ -705,6 +706,12 @@ Your current settings sadly won't be compatible withthe new version. But you can 'profile.links.reqs': 'Your Join-Requests', 'profile.links.settings': 'Your Profile Settings', + 'todoist.enable': "Activate todoist syncing", + 'todoist.createListButton': "Create a new list", + 'todoist.createList': "Create a Todoist list", + 'todoist.listCreated': "Todist list created", + 'todoist.tasksCreated': "Added tasks to the Todoist project", + literal: '$literal' } as const satisfies I18nDict; diff --git a/src/lib/utils/store/svocal.ts b/src/lib/utils/store/svocal.ts index 55d5aeb..e922cf9 100644 --- a/src/lib/utils/store/svocal.ts +++ b/src/lib/utils/store/svocal.ts @@ -70,6 +70,17 @@ const sv = { 'settings.homework.smart-subjects': ['settings.homework.smart-subjects', () => true], 'settings.reduceMotion': ['settings.reduceMotion', () => false], 'settings.tagsInOverview': ['settings.tagsInOverview', () => true], + 'settings.todo.todoist.code': ['settings.todo.todoist.code', () => null as string | null], + 'settings.todo.todoist.enabled': ['settings.todo.todoist.enabled', () => false], + 'settings.todo.todoist.listId': ['settings.todo.todoist.listId', () => null as string | null], + 'settings.todo.todoist.projectIds': [ + 'settings.todo.todoist.projectIds', + () => ({}) as Record + ], + 'settings.todo.todoist.taskIds': [ + 'settings.todo.todoist.taskIds', + () => ({}) as Record + ], 'dlool-version': ['dlool-version', () => '2'], 'assignments.order.key': ['assignments.order.key', () => 'due' satisfies OrderKey as OrderKey], 'assignments.order.direction': [ diff --git a/src/lib/utils/todoist/createNewSection.ts b/src/lib/utils/todoist/createNewSection.ts new file mode 100644 index 0000000..47a78ff --- /dev/null +++ b/src/lib/utils/todoist/createNewSection.ts @@ -0,0 +1,33 @@ +import { Method } from '$lib/utils/api'; +import { svocal } from '$lib/utils/store/svocal'; +import { internalSubjectRepresentation } from '$lib/utils/subjects/internal'; +import { get } from 'svelte/store'; +import { z } from 'zod'; + +const todoistToken = svocal('settings.todo.todoist.code'); +const todoistProjectId = svocal('settings.todo.todoist.listId'); +const todoistSections = svocal('settings.todo.todoist.projectIds'); + +const scheme = z.object({ id: z.string() }); + +export async function createSection(name: string) { + const subject = internalSubjectRepresentation(name); + + if (get(todoistSections)[subject]) { + return { id: get(todoistSections)[subject] }; + } + + const res = await fetch('https://api.todoist.com/rest/v2/sections', { + method: Method.POST, + headers: { Authorization: `Bearer ${get(todoistToken)}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, project_id: get(todoistProjectId) }) + }).then((r) => r.json()); + const parsed = scheme.parse(res); + + todoistSections.update((prev) => { + prev[subject] = parsed.id; + return prev; + }); + + return parsed; +} diff --git a/src/lib/utils/todoist/createNewTask.ts b/src/lib/utils/todoist/createNewTask.ts new file mode 100644 index 0000000..e40513d --- /dev/null +++ b/src/lib/utils/todoist/createNewTask.ts @@ -0,0 +1,97 @@ +import { Method } from '$lib/utils/api'; +import type { CustomDate } from '$lib/utils/dates/custom'; +import { svocal } from '$lib/utils/store/svocal'; +import { get } from 'svelte/store'; +import { z } from 'zod'; + +const todoistToken = svocal('settings.todo.todoist.code'); +const todoisttaskIds = svocal('settings.todo.todoist.taskIds'); + +const fmtDayMonth = (dayOrMonth: number) => dayOrMonth.toString().padStart(2, '0'); + +const newScheme = z.object({ + creator_id: z.string(), + created_at: z.string(), + assignee_id: z.nullable(z.unknown()), + assigner_id: z.nullable(z.unknown()), + comment_count: z.number().min(0), + is_completed: z.boolean(), + content: z.string(), + description: z.string(), + due: z.object({}), + duration: z.nullable(z.unknown()), + id: z.string(), + labels: z.array(z.unknown()), + order: z.number(), + priority: z.number(), + project_id: z.string(), + section_id: z.string(), + parent_id: z.nullable(z.string()), + url: z.string().url() +}); + +const retrieveScheme = z.object({ + id: z.string(), + content: z.string(), + due: z.object({ + date: z.string() + }) +}); + +type TaskProps = { + content: string; + sectionId: string; + dloolId: string; + due: CustomDate; +}; + +export async function createNewTask({ content, sectionId, dloolId, due }: TaskProps): Promise<{ + id: string; +}> { + const todoistId = get(todoisttaskIds)[dloolId]; + const formattedDate = `${due.year}-${fmtDayMonth(due.month)}-${fmtDayMonth(due.day)}`; + if (!todoistId) { + return await fetch('https://api.todoist.com/rest/v2/tasks', { + method: Method.POST, + headers: { Authorization: `Bearer ${get(todoistToken)}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content, + section_id: sectionId, + due_string: formattedDate + }) + }) + .then((r) => r.json()) + .then(newScheme.parse); + } + + const alreadyCreated = await fetch(`https://api.todoist.com/rest/v2/tasks/${todoistId}`, { + method: Method.GET, + headers: { Authorization: `Bearer ${get(todoistToken)}` } + }) + .then((r) => r.json()) + .then(retrieveScheme.parse); + + if (typeof alreadyCreated === 'string') { + return { id: alreadyCreated }; + } + + if (content === alreadyCreated.content && formattedDate === alreadyCreated.due.date) { + return { id: todoistId }; + } + + const updatedTask = await fetch(`https://api.todoist.com/rest/v2/tasks/${todoistId}`, { + method: Method.POST, + headers: { + Authorization: `Bearer ${get(todoistToken)}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + content, + due_string: formattedDate + }) + }) + .then((r) => r.json()) + .then(z.object({ id: z.string() }).parse); + + return updatedTask; +} diff --git a/src/routes/homework/+page.svelte b/src/routes/homework/+page.svelte index 1153822..16fd092 100644 --- a/src/routes/homework/+page.svelte +++ b/src/routes/homework/+page.svelte @@ -1,25 +1,59 @@ + + @@ -112,3 +146,36 @@ {/if}
+ +{#if $todoistEnabled && data.data} + {#await data.data then assignmentData} + {#if assignmentData} + + {/if} + {/await} +{/if} diff --git a/src/routes/settings/todo/+page.svelte b/src/routes/settings/todo/+page.svelte new file mode 100644 index 0000000..5ff069a --- /dev/null +++ b/src/routes/settings/todo/+page.svelte @@ -0,0 +1,95 @@ + + + + +
+ Du kannst Dlool mit einer ToDo-App Syncrhoniesieren + +
+

Todoist

+
+ + { + window.location.href = `https://todoist.com/oauth/authorize?${objToQueryParams({ + client_id: PUBLIC_TODOIST_CLIENT_ID, + scope: 'data:read_write', + state: PUBLIC_TODOIST_SECRET + })}`; + }} + disabled={!!$todoistCode} + > + Login + + + {#if $todoistListId} + + { + activationRunning = true; + }} + > + + + + {/if} +
+ + {#if $todoistCode} + { + if (!$todoistListId) { + activationRunning = true; + } + }} + /> + {/if} +
+
+ + +
+
+ { + activationRunning = false; + todoistListId.set(id); + todoistSections.set({}); + todoistTasks.set({}); + }} + /> +
+
diff --git a/src/routes/settings/todo/todoist/+server.ts b/src/routes/settings/todo/todoist/+server.ts new file mode 100644 index 0000000..50c7919 --- /dev/null +++ b/src/routes/settings/todo/todoist/+server.ts @@ -0,0 +1,25 @@ +import { redirect, type RequestHandler } from '@sveltejs/kit'; +import { PUBLIC_TODOIST_SECRET, PUBLIC_TODOIST_CLIENT_ID } from '$env/static/public'; +import { TODOIST_CLIENT_SECRET } from '$env/static/private'; +import { z } from 'zod'; + +export const GET: RequestHandler = async ({ url: { searchParams }, fetch }) => { + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const passedUrl = searchParams.get('redirect'); + + if (state !== PUBLIC_TODOIST_SECRET || !code || !passedUrl) { + redirect(302, '/settings/todo?error=true'); + } + + const url = new URL('https://todoist.com/oauth/access_token'); + url.searchParams.append('client_id', PUBLIC_TODOIST_CLIENT_ID); + url.searchParams.append('client_secret', TODOIST_CLIENT_SECRET); + url.searchParams.append('code', code); + url.searchParams.append('redirect_uri', `http://${passedUrl}/settings/todo/todoist`); + const res = await fetch(url, { method: 'POST' }) + .then((res) => res.json()) + .then(z.object({ access_token: z.string().min(1) }).parse); + + redirect(302, `/settings/todo?todoist=${res.access_token}`); +};