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)}
+ {
+ selectedColor = key;
+ }}
+ class="h-7 w-7 rounded-full border-solid border-black bg-[--bg] touch:h-10 touch:w-10"
+ class:border-2={selectedColor === key}
+ style:--bg={hex}
+ />
+ {/each}
+
+
+
{
+ 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}
+