diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dc6fc6f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,85 @@
+# Project
+* text eol=lf
+
+*.cmd eol=crlf
+
+*.cpp diff=cpp
+*.h diff=cpp
+*.java diff=java
+*.kt text diff=kotlin
+*.kts text diff=kotlin
+*.sh text eol=lf
+
+# Documents
+*.doc binary diff=exif
+*.docx binary diff=docx
+*.dot binary diff=exif
+*.dotx binary diff=exif
+*.xls binary diff=exif
+*.xlsx binary diff=exif
+*.xlt binary diff=exif
+*.xltm binary diff=exif
+*.odb binary diff=exif
+*.odf binary diff=exif
+*.odg binary diff=exif
+*.odi binary diff=exif
+*.odp binary diff=exif
+*.ods binary diff=exif
+*.odt binary diff=odt
+*.otc binary diff=exif
+*.otg binary diff=exif
+*.oti binary diff=exif
+*.otp binary diff=exif
+*.ots binary diff=exif
+*.ott binary diff=exif
+*.pdf binary diff=exif
+*.ppt binary diff=exif
+*.pptx binary diff=exif
+*.ps binary diff=exif
+
+# Fonts
+*.eot binary
+*.otf binary diff=exif
+*.ttc binary diff=exif
+*.ttf binary diff=exif
+*.woff binary
+*.woff2 binary
+
+# Audio Visual
+*.fla binary diff=exif
+*.flv binary diff=exif
+*.mov binary diff=exif
+*.mp3 binary diff=exif
+*.mp4 binary diff=exif
+*.swf binary diff=exif
+
+# Images
+*.ai binary diff=exif
+*.bmp binary diff=exif
+*.gif binary diff=exif
+*.hqx binary
+*.icns binary
+*.ico binary
+*.jpeg binary diff=exif
+*.jpg binary diff=exif
+*.png binary diff=exif
+*.psd binary diff=exif
+*.svg text
+*.tif binary diff=exif
+*.tiff binary diff=exif
+
+# Archives
+*.7z binary
+*.cab binary
+*.ear binary
+*.gz binary diff=exif
+*.jar binary
+*.rar binary diff=exif
+*.tar binary
+*.tgz binary
+*.war binary
+*.zip binary diff=exif
+
+gradlew binary
+gradlew.bat binary
+
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..23172a2
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @opatry
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..06aa0b2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,25 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. …
+2. …
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..3228bca
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: request
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..8a2fea1
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,8 @@
+### Description
+Please explain the changes you made here.
+
+### Checklist
+- [ ] I have read the [CONTRIBUTING](../../blob/main/CONTRIBUTING.md) guide
+- [ ] Code compiles correctly
+- [ ] Created tests which fail without the change (if possible)
+- [ ] All tests passing
diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml
new file mode 100644
index 0000000..ec06d4e
--- /dev/null
+++ b/.github/workflows/Build.yml
@@ -0,0 +1,34 @@
+name: Build & Test
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'adopt'
+
+ - name: Build
+ run: ./gradlew --no-daemon build
+
+ - name: Test
+ run: ./gradlew --no-daemon test
+
+ - name: Publish Test Reports
+ uses: mikepenz/action-junit-report@v4
+ if: success() || failure()
+ with:
+ report_paths: '**/build/test-results/test/TEST-*.xml'
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c0cce75
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.gradle/
+*.iml
+
+local.properties
+
+build/
+
+.DS_Store
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..6a11d9e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,11 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+.name
+compiler.xml
+gradle.xml
+jarRepositories.xml
+kotlinc.xml
+misc.xml
+vcs.xml
+uiDesigner.xml
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..fa206a4
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..79ee123
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copyright/opatry.xml b/.idea/copyright/opatry.xml
new file mode 100644
index 0000000..3e21e22
--- /dev/null
+++ b/.idea/copyright/opatry.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml
new file mode 100644
index 0000000..587055d
--- /dev/null
+++ b/.idea/copyright/profiles_settings.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/.issuetracker b/.issuetracker
new file mode 100644
index 0000000..57f9f28
--- /dev/null
+++ b/.issuetracker
@@ -0,0 +1,7 @@
+# Integration with Issue Tracker
+#
+# (note that '\' need to be escaped).
+
+[issuetracker "GitHub Rule"]
+ regex = "#(\\d+)"
+ url = "https://github.com/opatry/ticktick-kt/issues/$1"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f507a09
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,10 @@
+# Contributing
+
+You are more than welcome to help improving this toy project 🤝.
+
+The rules are simple:
+- Do your best to follow commit messages pattern
+- Open a Pull Request with explanation for your change (if needed)
+- Try to cover any bug fix with a non regression test
+
+Happy coding!
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..448cb5e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Olivier Patry
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d3b41c1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+[![Build Status](https://github.com/opatry/ticktick-kt/actions/workflows/Build.yml/badge.svg)](https://github.com/opatry/ticktick-kt/actions/workflows/Build.yml)
+
+# TickTick REST API Kotlin bindings
+
+Kotlin binding for [TickTick REST API](https://developer.ticktick.com/api#/openapi) using [Ktor Http Client](https://ktor.io/) and [Gson](https://github.com/google/gson) as Json marshaller.
+
+## Example
+
+```kotlin
+runBlocking {
+ val permissions = TickTickServiceAuthenticator.Permission.entries
+ val config = HttpTickTickServiceAuthenticator.ApplicationConfig(
+ redirectUrl = "http://localhost:8888",
+ clientId = System.getenv("TICKTICK_API_CLIENT_ID"),
+ clientSecret = System.getenv("TICKTICK_API_CLIENT_SECRET"),
+ )
+ val authenticator: TickTickServiceAuthenticator = HttpTickTickServiceAuthenticator(config)
+
+ val code = authenticator.authorize(permissions) { url ->
+ withContext(Dispatchers.IO) {
+ Desktop.getDesktop().browse(URI.create(url))
+ }
+ }
+
+ val token = authenticator.getToken(code, permissions)
+
+ val httpClient = HttpClient(CIO) {
+ CurlUserAgent()
+ install(ContentNegotiation) {
+ gson()
+ }
+ install(Auth) {
+ bearer {
+ sendWithoutRequest { true }
+ loadTokens {
+ BearerTokens(token.accessToken, "")
+ }
+ }
+ }
+ defaultRequest {
+ url("https://api.ticktick.com")
+ }
+ }
+ val tickTickService: TickTickService = HttpTickTickService(httpClient)
+
+ val projects = tickTickService.getProjects()
+ if (projects.isEmpty()) {
+ println("No project found, creating one")
+ val project = tickTickService.createProject(ProjectCreationRequest("My whole new project"))
+ println("Project ${project.name} (#${project.id}) created")
+ } else {
+ projects.sortedBy(Project::sortOrder).forEach { project ->
+ println("Tasks of project ${project.name} (#${project.id})")
+ tickTickService.getProjectData(project.id).tasks
+ .sortedWith(compareBy(Task::status, Task::sortOrder))
+ .forEach { task ->
+ println("\t${task.status} Task: ${task.title} ${task.priority} (#${task.id})")
+ task.items
+ ?.sortedWith(compareBy(ChecklistItem::status, ChecklistItem::sortOrder))
+ ?.forEach { checklistItem ->
+ println("\t\t${checklistItem.status} ${checklistItem.title} (#${checklistItem.id})")
+ }
+ }
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..af644b2
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+plugins {
+ alias(libs.plugins.jetbrains.kotlin.jvm) apply false
+}
+
+val ticktickKtVersion = libs.versions.ticktickKt.get()
+
+allprojects {
+ group = "net.opatry"
+ version = ticktickKtVersion
+
+ repositories {
+ mavenCentral()
+ google()
+ }
+}
+
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..91d7c1a
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+#
+# Copyright (c) 2024 Olivier Patry
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the "Software"),
+# to deal in the Software without restriction, including without limitation
+# the rights to use, copy, modify, merge, publish, distribute, sublicense,
+# and/or sell copies of the Software, and to permit persons to whom the Software
+# is furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+#
+
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..16b06b3
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,31 @@
+[versions]
+ticktickKt = "1.0.0"
+
+kotlin = "1.9.20"
+kotlinx-coroutines = "1.7.3"
+
+ktor = "2.2.3"
+
+[libraries]
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
+
+ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
+ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
+ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
+ktor-serialization-gson = { module = "io.ktor:ktor-serialization-gson", version.ref = "ktor" }
+
+gson = "com.google.code.gson:gson:2.10.1"
+
+junit4 = "junit:junit:4.13.2"
+
+[bundles]
+ktor-server = [ "ktor-server-core", "ktor-server-netty" ]
+ktor-client = [ "ktor-client-core", "ktor-client-auth", "ktor-client-cio", "ktor-client-content-negotiation", "ktor-client-logging", "ktor-serialization-gson"]
+
+[plugins]
+jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..41d9927
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a595206
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..a5859c7
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,218 @@
+#!/bin/sh
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/lib/.gitignore b/lib/.gitignore
new file mode 100644
index 0000000..9eca9b6
--- /dev/null
+++ b/lib/.gitignore
@@ -0,0 +1,2 @@
+build
+bin
diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts
new file mode 100644
index 0000000..3d283e8
--- /dev/null
+++ b/lib/build.gradle.kts
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+plugins {
+ alias(libs.plugins.jetbrains.kotlin.jvm)
+}
+
+dependencies {
+ api(libs.bundles.ktor.client)
+ implementation(libs.bundles.ktor.server)
+ implementation(libs.gson)
+
+ testImplementation(libs.junit4)
+ testImplementation(libs.ktor.client.mock)
+ testImplementation(libs.kotlinx.coroutines.test)
+}
diff --git a/lib/src/main/java/net/opatry/ticktick/TickTickService.kt b/lib/src/main/java/net/opatry/ticktick/TickTickService.kt
new file mode 100644
index 0000000..8bd390d
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/TickTickService.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick
+
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.request.delete
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import net.opatry.ticktick.entity.Project
+import net.opatry.ticktick.entity.ProjectCreationRequest
+import net.opatry.ticktick.entity.ProjectData
+import net.opatry.ticktick.entity.ProjectUpdateRequest
+import net.opatry.ticktick.entity.Task
+import net.opatry.ticktick.entity.TaskCreationRequest
+import net.opatry.ticktick.entity.TaskUpdateRequest
+
+interface TickTickService {
+ // region Task
+
+ suspend fun getTask(projectId: String, taskId: String): Task
+
+ suspend fun createTask(request: TaskCreationRequest): Task
+
+ suspend fun updateTask(taskId: String, request: TaskUpdateRequest): Task
+
+ suspend fun completeTask(projectId: String, taskId: String)
+
+ suspend fun deleteTask(projectId: String, taskId: String)
+
+ // endregion
+
+ // region Project
+
+ suspend fun getProjects(): List
+
+ suspend fun getProject(projectId: String): Project
+
+ suspend fun getProjectData(projectId: String): ProjectData
+
+ suspend fun createProject(request: ProjectCreationRequest): Project
+
+ suspend fun updateProject(projectId: String, request: ProjectUpdateRequest): Project
+
+ suspend fun deleteProject(projectId: String)
+
+ // endregion
+}
+
+class HttpTickTickService(private val httpClient: HttpClient) : TickTickService {
+
+ private companion object {
+ suspend inline fun HttpClient.getOrThrow(endpoint: String, parameters: Map = emptyMap()): T {
+ val response = get(endpoint) {
+ contentType(ContentType.Application.Json)
+ parameters.forEach { (k, v) ->
+ parameter(k, v)
+ }
+ }
+
+ if (response.status.isSuccess()) {
+ return response.body()
+ } else {
+ throw ClientRequestException(response, response.bodyAsText())
+ }
+ }
+
+ suspend inline fun HttpClient.postOrThrow(endpoint: String, body: T): R {
+ val response = post(endpoint) {
+ contentType(ContentType.Application.Json)
+ setBody(body)
+ }
+
+ if (response.status.isSuccess()) {
+ return response.body()
+ } else {
+ throw ClientRequestException(response, response.bodyAsText())
+ }
+ }
+
+ suspend inline fun HttpClient.deleteOrThrow(endpoint: String) {
+ val response = delete(endpoint)
+
+ if (response.status.isSuccess()) {
+ return response.body()
+ } else {
+ throw ClientRequestException(response, response.bodyAsText())
+ }
+ }
+ }
+
+ // region Task
+
+ override suspend fun getTask(projectId: String, taskId: String): Task {
+ return httpClient.getOrThrow("open/v1/project/${projectId}/task/${taskId}")
+ }
+
+ override suspend fun createTask(request: TaskCreationRequest): Task {
+ return httpClient.postOrThrow("open/v1/task", request)
+ }
+
+ override suspend fun updateTask(taskId: String, request: TaskUpdateRequest): Task {
+ return httpClient.postOrThrow("open/v1/task/${taskId}", request)
+ }
+
+ override suspend fun completeTask(projectId: String, taskId: String) {
+ val response = httpClient.post("open/v1/project/${projectId}/task/${taskId}/complete")
+
+ if (!response.status.isSuccess()) {
+ throw ClientRequestException(response, response.bodyAsText())
+ }
+ }
+
+ override suspend fun deleteTask(projectId: String, taskId: String) {
+ httpClient.deleteOrThrow("open/v1/project/${projectId}/task/${taskId}")
+ }
+
+ // endregion
+
+ // region Project
+
+ override suspend fun getProjects(): List {
+ return httpClient.getOrThrow("open/v1/project")
+ }
+
+ override suspend fun getProject(projectId: String): Project {
+ return httpClient.getOrThrow("open/v1/project/${projectId}")
+ }
+
+ override suspend fun getProjectData(projectId: String): ProjectData {
+ return httpClient.getOrThrow("open/v1/project/${projectId}/data")
+ }
+
+ override suspend fun createProject(request: ProjectCreationRequest): Project {
+ return httpClient.postOrThrow("open/v1/project", request)
+ }
+
+ override suspend fun updateProject(projectId: String, request: ProjectUpdateRequest): Project {
+ return httpClient.postOrThrow("open/v1/project/${projectId}", request)
+ }
+
+ override suspend fun deleteProject(projectId: String) {
+ httpClient.deleteOrThrow("open/v1/project/${projectId}")
+ }
+
+ // endregion
+}
\ No newline at end of file
diff --git a/lib/src/main/java/net/opatry/ticktick/TickTickServiceAuthenticator.kt b/lib/src/main/java/net/opatry/ticktick/TickTickServiceAuthenticator.kt
new file mode 100644
index 0000000..5c7ce28
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/TickTickServiceAuthenticator.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick
+
+import com.google.gson.annotations.SerializedName
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.plugins.CurlUserAgent
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.parameter
+import io.ktor.client.request.post
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.Url
+import io.ktor.http.contentType
+import io.ktor.http.fullPath
+import io.ktor.http.isSuccess
+import io.ktor.serialization.gson.gson
+import io.ktor.server.application.call
+import io.ktor.server.config.ApplicationConfig
+import io.ktor.server.engine.ApplicationEngine
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.netty.Netty
+import io.ktor.server.response.respond
+import io.ktor.server.routing.get
+import io.ktor.server.routing.routing
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import net.opatry.ticktick.TickTickServiceAuthenticator.OAuthToken.TokenType.Bearer
+import net.opatry.ticktick.TickTickServiceAuthenticator.OAuthToken.TokenType.Mac
+import net.opatry.ticktick.TickTickServiceAuthenticator.Permission.TasksRead
+import net.opatry.ticktick.TickTickServiceAuthenticator.Permission.TasksWrite
+import java.util.*
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+interface TickTickServiceAuthenticator {
+
+ /**
+ * @property TasksWrite `"tasks:write"` permission scope
+ * @property TasksRead `"tasks:read"` permission scope
+ */
+ enum class Permission(val scope: String) {
+ TasksWrite("tasks:write"),
+ TasksRead("tasks:read"),
+ }
+
+ /**
+ * @property accessToken The access token issued by the authorization server.
+ * @property tokenType The type of the token issued.
+ * @property expiresIn The lifetime in seconds of the access token. For example, the value `"3600"` denotes that the access token will expire in one hour from the time the response was generated. If omitted, the authorization server SHOULD provide the expiration time via other means or document the default value.
+ * @property
+ */
+ data class OAuthToken(
+
+ @SerializedName("access_token")
+ val accessToken: String,
+
+ @SerializedName("token_type")
+ val tokenType: TokenType,
+
+ @SerializedName("expires_in")
+ val expiresIn: Long? = 0,
+
+ @SerializedName("refresh_token")
+ val refreshToken: String? = null,
+
+ @SerializedName("scope")
+ val scope: Permission? = null,
+
+ @SerializedName("state")
+ val state: String? = null,
+ ) {
+ /**
+ * Value is case insensitive.
+ *
+ * @property Bearer `"bearer"` token type defined in [RFC6750](https://datatracker.ietf.org/doc/html/rfc6750) is utilized by simply including the access token string in the request.
+ * @property Mac `"mac"` token type defined in [OAuth-HTTP-MAC](https://datatracker.ietf.org/doc/html/rfc6749#ref-OAuth-HTTP-MAC) is utilized by issuing a Message Authentication Code (MAC) key together with the access token that is used to sign certain components of the HTTP requests.
+ */
+ enum class TokenType {
+
+ @SerializedName("bearer")
+ Bearer,
+
+ @SerializedName("mac")
+ Mac,
+ }
+ }
+
+ /**
+ * @param permissions Permission scope. The currently available scopes are [Permission.TasksWrite], [Permission.TasksRead]
+ * @param requestUserAuthorization The URL to which to request user authorization before direction
+ *
+ * @return auth code
+ *
+ * @see Permission
+ */
+ suspend fun authorize(permissions: List, requestUserAuthorization: suspend (url: String) -> Unit): String
+
+ /**
+ * @param code The code obtained through [authorize].
+ * @param permissions Permission scope. The currently available scopes are [Permission.TasksWrite], [Permission.TasksRead]
+ *
+ * @return OAuth access token
+ *
+ * @see Permission
+ */
+ suspend fun getToken(code: String, permissions: List): OAuthToken
+}
+
+class HttpTickTickServiceAuthenticator(private val config: ApplicationConfig) : TickTickServiceAuthenticator {
+
+ /**
+ * @property redirectUrl Redirect url
+ * @property clientId OAuth2 Client ID
+ * @property clientSecret OAuth2 Client Secret
+ */
+ data class ApplicationConfig(
+ val redirectUrl: String,
+ val clientId: String,
+ val clientSecret: String,
+ )
+
+ private companion object {
+ const val TICKTICK_ROOT_URL = "https://ticktick.com"
+ }
+
+ private val httpClient: HttpClient by lazy {
+ HttpClient(CIO) {
+ CurlUserAgent()
+ install(ContentNegotiation) {
+ gson()
+ }
+ defaultRequest {
+ url(TICKTICK_ROOT_URL)
+ }
+ }
+ }
+
+ private sealed class AuthRequest {
+ data object Pending : AuthRequest()
+ data class Code(val code: String) : AuthRequest()
+ }
+
+ override suspend fun authorize(permissions: List, requestUserAuthorization: suspend (url: String) -> Unit): String {
+ val uuid = UUID.randomUUID()
+ val params = mapOf(
+ "client_id" to config.clientId,
+ "scope" to permissions.joinToString("%20", transform = TickTickServiceAuthenticator.Permission::scope),
+ "state" to uuid.toString(),
+ "redirect_uri" to config.redirectUrl,
+ "response_type" to "code",
+ ).entries.joinToString(prefix = "?", separator = "&") {
+ "${it.key}=${it.value}"
+ }
+
+ var server: ApplicationEngine? = null
+ return try {
+ suspendCoroutine { continuation ->
+ // FIXME calling several times this in parallel with fail
+ val url = Url(config.redirectUrl)
+ server = embeddedServer(Netty, port = url.port, host = url.host) {
+ routing {
+ get(url.fullPath.takeIf(String::isNotEmpty) ?: "/") {
+ try {
+ val queryParams = call.request.queryParameters
+ require(uuid == UUID.fromString(requireNotNull(queryParams["state"])))
+ val authCode = AuthRequest.Code(requireNotNull(queryParams["code"]))
+ call.respond(HttpStatusCode.OK)
+ continuation.resumeWith(Result.success(authCode.code))
+ } catch (e: Exception) {
+ call.respond(HttpStatusCode.BadRequest)
+ continuation.resumeWithException(e)
+ }
+ }
+ }
+ }.start(wait = false)
+
+ CoroutineScope(continuation.context).launch {
+ requestUserAuthorization("$TICKTICK_ROOT_URL/oauth/authorize$params")
+ }
+ }
+ } finally {
+ server?.stop()
+ server = null
+ }
+ }
+
+ override suspend fun getToken(code: String, permissions: List): TickTickServiceAuthenticator.OAuthToken {
+ val scope = permissions.joinToString(" ", transform = TickTickServiceAuthenticator.Permission::scope)
+ val response = httpClient.post("oauth/token") {
+ parameter("client_id", config.clientId)
+ parameter("client_secret", config.clientSecret)
+ parameter("code", code)
+ parameter("grant_type", "authorization_code")
+ parameter("scope", scope)
+ parameter("redirect_uri", config.redirectUrl)
+ contentType(ContentType.Application.FormUrlEncoded)
+ }
+
+ if (response.status.isSuccess()) {
+ return response.body()
+ } else {
+ throw ClientRequestException(response, response.bodyAsText())
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/ChecklistItem.kt b/lib/src/main/java/net/opatry/ticktick/entity/ChecklistItem.kt
new file mode 100644
index 0000000..b9ee034
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/ChecklistItem.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+import net.opatry.ticktick.entity.ChecklistItem.Status
+import net.opatry.ticktick.entity.ChecklistItem.Status.Completed
+import net.opatry.ticktick.entity.ChecklistItem.Status.Normal
+
+/**
+ * @property id Subtask identifier
+ * @property title Subtask title
+ * @property status The completion status of subtask **Value:** [Status.Normal], [Status.Completed]
+ * @property completedTime Subtask completed time in `"yyyy-MM-dd'T'HH:mm:ssZ"` **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property isAllDay All day
+ * @property sortOrder Subtask sort order **Example:** `234444`
+ * @property startDate Subtask start date time in `"yyyy-MM-dd'T'HH:mm:ssZ"` **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property timeZone Subtask timezone **Example:** `"America/Los_Angeles"`
+ */
+data class ChecklistItem(
+
+ @SerializedName("id")
+ val id: String,
+
+ @SerializedName("title")
+ val title: String,
+
+ @SerializedName("status")
+ val status: Status,
+
+ @SerializedName("completedTime")
+ val completedTime: String? = null,
+
+ @SerializedName("isAllDay")
+ val isAllDay: Boolean,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+
+ @SerializedName("startDate")
+ val startDate: String? = null,
+
+ @SerializedName("timeZone")
+ val timeZone: String,
+) {
+
+ /**
+ * @property Normal 0
+ * @property Completed 1
+ */
+ enum class Status(val value: Int) {
+
+ @SerializedName("0")
+ Normal(0),
+
+ @SerializedName("1")
+ Completed(1),
+ }
+}
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/ChecklistItemEdit.kt b/lib/src/main/java/net/opatry/ticktick/entity/ChecklistItemEdit.kt
new file mode 100644
index 0000000..b8ceb24
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/ChecklistItemEdit.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+
+
+/**
+ * @property title Subtask title
+ * @property status The completion status of subtask
+ * @property completedTime Completed time in `"yyyy-MM-dd'T'HH:mm:ssZ"` format **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property isAllDay All day
+ * @property sortOrder The order of subtask
+ * @property startDate Start date and time in `"yyyy-MM-dd'T'HH:mm:ssZ"` format
+ * @property timeZone The time zone in which the Start time is specified
+ */
+data class ChecklistItemEdit(
+
+ @SerializedName("title")
+ val title: String? = null,
+
+ @SerializedName("status")
+ val status: ChecklistItem.Status? = null,
+
+ @SerializedName("completedTime")
+ val completedTime: String? = null,
+
+ @SerializedName("isAllDay")
+ val isAllDay: Boolean? = null,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+
+ @SerializedName("startDate")
+ val startDate: String? = null,
+
+ @SerializedName("timeZone")
+ val timeZone: String? = null,
+)
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/Column.kt b/lib/src/main/java/net/opatry/ticktick/entity/Column.kt
new file mode 100644
index 0000000..967e423
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/Column.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * @property id Column identifier
+ * @property projectId Project identifier
+ * @property name Column name
+ * @property sortOrder Order value
+ */
+data class Column(
+
+ @SerializedName("id")
+ val id: String,
+
+ @SerializedName("projectId")
+ val projectId: String,
+
+ @SerializedName("name")
+ val name: String,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+)
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/ErrorResponse.kt b/lib/src/main/java/net/opatry/ticktick/entity/ErrorResponse.kt
new file mode 100644
index 0000000..b6777c6
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/ErrorResponse.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+import net.opatry.ticktick.entity.ErrorResponse.Error.AccessDenied
+import net.opatry.ticktick.entity.ErrorResponse.Error.InvalidClient
+import net.opatry.ticktick.entity.ErrorResponse.Error.InvalidGrant
+import net.opatry.ticktick.entity.ErrorResponse.Error.InvalidRequest
+import net.opatry.ticktick.entity.ErrorResponse.Error.InvalidScope
+import net.opatry.ticktick.entity.ErrorResponse.Error.MethodNotAllowed
+import net.opatry.ticktick.entity.ErrorResponse.Error.ServerError
+import net.opatry.ticktick.entity.ErrorResponse.Error.TemporarilyUnavailable
+import net.opatry.ticktick.entity.ErrorResponse.Error.UnauthorizedClient
+import net.opatry.ticktick.entity.ErrorResponse.Error.UnsupportedGrantType
+import net.opatry.ticktick.entity.ErrorResponse.Error.UnsupportedResponseType
+
+/**
+ * @property error Type of error
+ * @property errorDescription Error description
+ * @property scope provided scope in case of [InvalidScope], `null` otherwise
+ * @property errorUri A URI identifying a human-readable web page with information about the error, used to provide the client developer with additional information about the error.
+ */
+data class ErrorResponse(
+
+ @SerializedName("error")
+ val error: Error,
+
+ @SerializedName("error_description")
+ val errorDescription: String? = null,
+
+ @SerializedName("error_uri")
+ val errorUri: String? = null,
+
+ @SerializedName("scope")
+ val scope: String? = null,
+) {
+ /**
+ * @property MethodNotAllowed `"method_not_allowed"`
+ * @property InvalidRequest `"invalid_request"`
+ * @property InvalidClient `"invalid_client"`
+ * @property InvalidGrant `"invalid_grant"`
+ * @property InvalidScope `"invalid_scope"`
+ * @property UnauthorizedClient `"unauthorized_client"`
+ * @property AccessDenied `"access_denied"`
+ * @property UnsupportedGrantType `"unsupported_grant_type"`
+ * @property UnsupportedResponseType `"unsupported_response_type"`
+ * @property ServerError `"server_error"`
+ * @property TemporarilyUnavailable `"temporarily_unavailable"`
+ */
+ enum class Error {
+
+ @SerializedName("method_not_allowed")
+ MethodNotAllowed,
+
+ @SerializedName("invalid_request")
+ InvalidRequest,
+
+ @SerializedName("invalid_client")
+ InvalidClient,
+
+ @SerializedName("invalid_grant")
+ InvalidGrant,
+
+ @SerializedName("invalid_scope")
+ InvalidScope,
+
+ @SerializedName("unauthorized_client")
+ UnauthorizedClient,
+
+ @SerializedName("access_denied")
+ AccessDenied,
+
+ @SerializedName("unsupported_grant_type")
+ UnsupportedGrantType,
+
+ @SerializedName("unsupported_response_type")
+ UnsupportedResponseType,
+
+ @SerializedName("server_error")
+ ServerError,
+
+ @SerializedName("temporarily_unavailable")
+ TemporarilyUnavailable,
+ }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/Project.kt b/lib/src/main/java/net/opatry/ticktick/entity/Project.kt
new file mode 100644
index 0000000..3b3dcf6
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/Project.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+import net.opatry.ticktick.entity.Project.Kind
+import net.opatry.ticktick.entity.Project.Kind.Note
+import net.opatry.ticktick.entity.Project.Kind.Task
+import net.opatry.ticktick.entity.Project.Permission
+import net.opatry.ticktick.entity.Project.Permission.Comment
+import net.opatry.ticktick.entity.Project.Permission.Read
+import net.opatry.ticktick.entity.Project.Permission.Write
+import net.opatry.ticktick.entity.Project.ViewMode
+import net.opatry.ticktick.entity.Project.ViewMode.Kanban
+import net.opatry.ticktick.entity.Project.ViewMode.List
+import net.opatry.ticktick.entity.Project.ViewMode.Timeline
+
+/**
+ * @property id Project identifier
+ * @property name Project name
+ * @property color Project color
+ * @property sortOrder Order value
+ * @property isClosed Project closed
+ * @property groupId Project group identifier
+ * @property viewMode view mode, [ViewMode.List], [ViewMode.Kanban], [ViewMode.Timeline]
+ * @property permission [Permission.Read], [Permission.Write] or [Permission.Comment]
+ * @property kind [Kind.Task] or [Kind.Note]
+ */
+data class Project(
+
+ @SerializedName("id")
+ val id: String,
+
+ @SerializedName("name")
+ val name: String,
+
+ @SerializedName("color")
+ val color: String? = null,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+
+ @SerializedName("closed")
+ val isClosed: Boolean,
+
+ @SerializedName("groupId")
+ val groupId: String? = null,
+
+ @SerializedName("viewMode")
+ val viewMode: ViewMode? = null,
+
+ @SerializedName("permission")
+ val permission: Permission? = null,
+
+ @SerializedName("kind")
+ val kind: Kind? = null,
+) {
+ /**
+ * @property Read `"read"` permission
+ * @property Write `"write"` permission
+ * @property Comment `"comment"` permission
+ */
+ enum class Permission {
+
+ @SerializedName("read")
+ Read,
+
+ @SerializedName("write")
+ Write,
+
+ @SerializedName("comment")
+ Comment,
+ }
+
+ /**
+ * @property List `"list"` view mode
+ * @property Kanban `"kanban"` view mode
+ * @property Timeline `"timeline"` view mode
+ */
+ enum class ViewMode {
+
+ @SerializedName("list")
+ List,
+
+ @SerializedName("kanban")
+ Kanban,
+
+ @SerializedName("timeline")
+ Timeline,
+ }
+
+ /**
+ * @property Task `"TASK"` kind
+ * @property Note `"NOTE"` kind
+ */
+ enum class Kind {
+
+ @SerializedName("TASK")
+ Task,
+
+ @SerializedName("NOTE")
+ Note,
+ }
+}
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/ProjectCreationRequest.kt b/lib/src/main/java/net/opatry/ticktick/entity/ProjectCreationRequest.kt
new file mode 100644
index 0000000..8c99cbe
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/ProjectCreationRequest.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * @property name name of the project
+ * @property color color of project, e.g. "#F18181"
+ * @property sortOrder sort order value of the project
+ * @property viewMode view mode, [Project.ViewMode.List], [Project.ViewMode.Kanban], [Project.ViewMode.Timeline]
+ * @property kind project kind, [Project.Kind.Task], [Project.Kind.Note]
+ */
+data class ProjectCreationRequest(
+
+ @SerializedName("name")
+ val name: String,
+
+ @SerializedName("color")
+ val color: String? = null,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+
+ @SerializedName("viewMode")
+ val viewMode: Project.ViewMode? = null,
+
+ @SerializedName("kind")
+ val kind: Project.Kind? = null,
+)
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/ProjectData.kt b/lib/src/main/java/net/opatry/ticktick/entity/ProjectData.kt
new file mode 100644
index 0000000..32b398e
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/ProjectData.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * @property project Project info
+ * @property tasks Undone tasks under project
+ * @property columns Columns under project
+ */
+data class ProjectData(
+
+ @SerializedName("project")
+ val project: Project,
+
+ @SerializedName("tasks")
+ val tasks: List,
+
+ @SerializedName("columns")
+ val columns: List,
+)
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/ProjectUpdateRequest.kt b/lib/src/main/java/net/opatry/ticktick/entity/ProjectUpdateRequest.kt
new file mode 100644
index 0000000..42f0466
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/ProjectUpdateRequest.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * @property name name of the project
+ * @property color color of project
+ * @property sortOrder sort order value, default 0
+ * @property viewMode view mode, [Project.ViewMode.List], [Project.ViewMode.Kanban], [Project.ViewMode.Timeline]
+ * @property kind project kind, [Project.Kind.Task], [Project.Kind.Note]
+ */
+data class ProjectUpdateRequest(
+
+ @SerializedName("name")
+ val name: String? = null,
+
+ @SerializedName("color")
+ val color: String? = null,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = 0,
+
+ @SerializedName("viewMode")
+ val viewMode: Project.ViewMode? = null,
+
+ @SerializedName("kind")
+ val kind: Project.Kind? = null,
+)
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/Task.kt b/lib/src/main/java/net/opatry/ticktick/entity/Task.kt
new file mode 100644
index 0000000..6d13b9d
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/Task.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+import net.opatry.ticktick.entity.Task.Priority
+import net.opatry.ticktick.entity.Task.Priority.High
+import net.opatry.ticktick.entity.Task.Priority.Low
+import net.opatry.ticktick.entity.Task.Priority.Medium
+import net.opatry.ticktick.entity.Task.Priority.None
+import net.opatry.ticktick.entity.Task.Status
+import net.opatry.ticktick.entity.Task.Status.Completed
+import net.opatry.ticktick.entity.Task.Status.Normal
+
+/**
+ * @property id Task identifier
+ * @property projectId Task project id
+ * @property title Task title
+ * @property isAllDay All day
+ * @property completedTime Task completed time in `"yyyy-MM-dd'T'HH:mm:ssZ"` **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property content Task content
+ * @property desc Task description of checklist
+ * @property dueDate Task due date time in `"yyyy-MM-dd'T'HH:mm:ssZ"` **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property items Subtasks of Task
+ * @property priority Task priority **Value:** [Priority.None], [Priority.Low], [Priority.Medium], [Priority.High]
+ * @property reminders List of reminder triggers **Example:** `[ "TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S" ]`
+ * @property repeatFlag Recurring rules of task **Example:** `"RRULE:FREQ=DAILY;INTERVAL=1"`
+ * @property sortOrder Task sort order **Example:** `12345`
+ * @property startDate Start date time in `"yyyy-MM-dd'T'HH:mm:ssZ"` **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property status Task completion status **Value:** [Status.Normal], [Status.Completed]
+ * @property timeZone Task timezone **Example:** `"America/Los_Angeles"`
+ */
+data class Task(
+
+ @SerializedName("id")
+ val id: String,
+
+ @SerializedName("projectId")
+ val projectId: String,
+
+ @SerializedName("title")
+ val title: String,
+
+ @SerializedName("isAllDay")
+ val isAllDay: Boolean,
+
+ @SerializedName("completedTime")
+ val completedTime: String? = null,
+
+ @SerializedName("content")
+ val content: String,
+
+ @SerializedName("desc")
+ val desc: String,
+
+ @SerializedName("dueDate")
+ val dueDate: String? = null,
+
+ @SerializedName("items")
+ val items: List? = null,
+
+ @SerializedName("priority")
+ val priority: Priority,
+
+ @SerializedName("reminders")
+ val reminders: List? = null,
+
+ @SerializedName("repeatFlag")
+ val repeatFlag: String? = null,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+
+ @SerializedName("startDate")
+ val startDate: String,
+
+ @SerializedName("status")
+ val status: Status,
+
+ @SerializedName("timeZone")
+ val timeZone: String,
+
+ // unofficial/undocumented
+ @SerializedName("tags")
+ val tags: List? = null,
+
+ // unofficial/undocumented
+ @SerializedName("columnId")
+ val columnId: String? = null,
+) {
+ /**
+ * @property None 0
+ * @property Low 1
+ * @property Medium 3
+ * @property High 5
+ */
+ enum class Priority(val value: Int) {
+
+ @SerializedName("0")
+ None(0),
+
+ @SerializedName("1")
+ Low(1),
+
+ @SerializedName("3")
+ Medium(3),
+
+ @SerializedName("5")
+ High(5),
+ }
+
+ /**
+ * @property Normal 0
+ * @property Completed 2
+ */
+ enum class Status(val value: Int) {
+
+ @SerializedName("0")
+ Normal(0),
+
+ @SerializedName("2")
+ Completed(2),
+ }
+}
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/TaskCreationRequest.kt b/lib/src/main/java/net/opatry/ticktick/entity/TaskCreationRequest.kt
new file mode 100644
index 0000000..b4646d6
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/TaskCreationRequest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * @property title Task title
+ * @property content Task content
+ * @property desc Description of checklist
+ * @property isAllDay All day
+ * @property startDate Start date and time in `"yyyy-MM-dd'T'HH:mm:ssZ"` format **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property dueDate Due date and time in `"yyyy-MM-dd'T'HH:mm:ssZ"` format **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property timeZone The time zone in which the time is specified
+ * @property reminders Lists of reminders specific to the task
+ * @property repeatFlag Recurring rules of task
+ * @property priority The priority of task, default is "0"
+ * @property sortOrder The order of task
+ * @property items The list of subtasks
+ */
+data class TaskCreationRequest(
+
+ @SerializedName("title")
+ val title: String,
+
+ @SerializedName("content")
+ val content: String? = null,
+
+ @SerializedName("desc")
+ val desc: String? = null,
+
+ @SerializedName("isAllDay")
+ val isAllDay: Boolean? = null,
+
+ @SerializedName("startDate")
+ val startDate: String? = null,
+
+ @SerializedName("dueDate")
+ val dueDate: String? = null,
+
+ @SerializedName("timeZone")
+ val timeZone: String? = null,
+
+ @SerializedName("reminders")
+ val reminders: List? = null,
+
+ @SerializedName("repeatFlag")
+ val repeatFlag: String? = null,
+
+ @SerializedName("priority")
+ val priority: Task.Priority? = null,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+
+ @SerializedName("items")
+ val items: List? = null,
+)
diff --git a/lib/src/main/java/net/opatry/ticktick/entity/TaskUpdateRequest.kt b/lib/src/main/java/net/opatry/ticktick/entity/TaskUpdateRequest.kt
new file mode 100644
index 0000000..c46d745
--- /dev/null
+++ b/lib/src/main/java/net/opatry/ticktick/entity/TaskUpdateRequest.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.annotations.SerializedName
+
+
+/**
+ * @property projectId Project id.
+ * @property id Task id.
+ * @property title Task title
+ * @property content Task content
+ * @property desc Description of checklist
+ * @property isAllDay All day
+ * @property startDate Start date and time in `"yyyy-MM-dd'T'HH:mm:ssZ"` format **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property dueDate Due date and time in `"yyyy-MM-dd'T'HH:mm:ssZ"` format **Example:** `"2019-11-13T03:00:00+0000"`
+ * @property timeZone The time zone in which the time is specified
+ * @property reminders Lists of reminders specific to the task
+ * @property repeatFlag Recurring rules of task
+ * @property priority The priority of task, default is "0"
+ * @property sortOrder The order of task
+ * @property items The list of subtasks
+ */
+data class TaskUpdateRequest(
+
+ @SerializedName("projectId")
+ val projectId: String,
+
+ @SerializedName("id")
+ val id: String,
+
+ @SerializedName("title")
+ val title: String? = null,
+
+ @SerializedName("content")
+ val content: String? = null,
+
+ @SerializedName("desc")
+ val desc: String? = null,
+
+ @SerializedName("isAllDay")
+ val isAllDay: Boolean? = null,
+
+ @SerializedName("startDate")
+ val startDate: String? = null,
+
+ @SerializedName("dueDate")
+ val dueDate: String? = null,
+
+ @SerializedName("timeZone")
+ val timeZone: String? = null,
+
+ @SerializedName("reminders")
+ val reminders: List? = null,
+
+ @SerializedName("repeatFlag")
+ val repeatFlag: String? = null,
+
+ @SerializedName("priority")
+ val priority: Task.Priority? = null,
+
+ @SerializedName("sortOrder")
+ val sortOrder: Long? = null,
+
+ @SerializedName("items")
+ val items: List? = null,
+)
diff --git a/lib/src/test/java/net/opatry/ticktick/entity/EntityTest.kt b/lib/src/test/java/net/opatry/ticktick/entity/EntityTest.kt
new file mode 100644
index 0000000..91beadf
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/entity/EntityTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+
+package net.opatry.ticktick.entity
+
+import com.google.gson.Gson
+import net.opatry.ticktick.entity.data.EntityTestParam
+import net.opatry.ticktick.entity.data.projectData
+import net.opatry.ticktick.entity.data.projectDataTestData
+import net.opatry.ticktick.entity.data.taskData
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+class EntityTest(private val param: EntityTestParam) {
+
+ private val gson: Gson = Gson()
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: {0}")
+ fun data(): Iterable {
+ return buildList {
+ addAll(projectData)
+ addAll(projectDataTestData)
+ addAll(taskData)
+ }
+ }
+ }
+
+ @Test
+ fun `JSON payload properly mapped to Entity`() {
+ val entity = gson.fromJson(param.jsonPayload, param.entityClass)
+ assertEquals(param.expectedEntity, entity)
+ }
+}
diff --git a/lib/src/test/java/net/opatry/ticktick/entity/data/EntityTestParam.kt b/lib/src/test/java/net/opatry/ticktick/entity/data/EntityTestParam.kt
new file mode 100644
index 0000000..c5b953c
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/entity/data/EntityTestParam.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+
+package net.opatry.ticktick.entity.data
+
+data class EntityTestParam(val jsonPayload: String, val entityClass: Class<*>, val expectedEntity: Any) {
+ override fun toString(): String {
+ return "${entityClass.simpleName} ${jsonPayload.replace("[\\w]+", " ").take(40)}…"
+ }
+
+ companion object {
+ fun build(jsonPayload: String, expectedEntity: T): EntityTestParam {
+ return EntityTestParam(jsonPayload, expectedEntity::class.java, expectedEntity)
+ }
+ }
+}
diff --git a/lib/src/test/java/net/opatry/ticktick/entity/data/projectDataTestData.kt b/lib/src/test/java/net/opatry/ticktick/entity/data/projectDataTestData.kt
new file mode 100644
index 0000000..a036fea
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/entity/data/projectDataTestData.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.entity.data
+
+import net.opatry.ticktick.entity.ChecklistItem
+import net.opatry.ticktick.entity.Column
+import net.opatry.ticktick.entity.Project
+import net.opatry.ticktick.entity.ProjectData
+import net.opatry.ticktick.entity.Task
+
+val projectDataTestData = listOf(
+ EntityTestParam.build(
+ """{
+ project = {
+ id = "6226ff9877acee87727f6bca",
+ name = "project name",
+ color = "#F18181",
+ closed = false,
+ groupId = "6436176a47fd2e05f26ef56e",
+ viewMode = "list",
+ kind = "TASK"
+ },
+ tasks = [{
+ id = "6247ee29630c800f064fd145",
+ isAllDay = true,
+ projectId = "6226ff9877acee87727f6bca",
+ title = "Task Title",
+ content = "Task Content",
+ desc = "Task Description",
+ timeZone = "America/Los_Angeles",
+ repeatFlag = "RRULE:FREQ=DAILY;INTERVAL=1",
+ startDate = "2019-11-13T03:00:00+0000",
+ dueDate = "2019-11-14T03:00:00+0000",
+ reminders = [
+ "TRIGGER:P0DT9H0M0S",
+ "TRIGGER:PT0S"
+ ],
+ priority = 1,
+ status = 0,
+ completedTime = "2019-11-13T03:00:00+0000",
+ sortOrder = 12345,
+ items = [{
+ id = "6435074647fd2e6387145f20",
+ status = 0,
+ title = "Subtask Title",
+ sortOrder = 12345,
+ startDate = "2019-11-13T03:00:00+0000",
+ isAllDay = false,
+ timeZone = "America/Los_Angeles",
+ completedTime = "2019-11-13T03:00:00+0000"
+ }]
+ }],
+ columns = [{
+ id = "6226ff9e76e5fc39f2862d1b",
+ projectId = "6226ff9877acee87727f6bca",
+ name = "Column Name",
+ sortOrder = 0
+ }]
+ }""".trimIndent(),
+ ProjectData(
+ project = Project(
+ id = "6226ff9877acee87727f6bca",
+ name = "project name",
+ color = "#F18181",
+ isClosed = false,
+ groupId = "6436176a47fd2e05f26ef56e",
+ viewMode = Project.ViewMode.List,
+ kind = Project.Kind.Task
+ ),
+ tasks = listOf(
+ Task(
+ id = "6247ee29630c800f064fd145",
+ isAllDay = true,
+ projectId = "6226ff9877acee87727f6bca",
+ title = "Task Title",
+ content = "Task Content",
+ desc = "Task Description",
+ timeZone = "America/Los_Angeles",
+ repeatFlag = "RRULE:FREQ=DAILY;INTERVAL=1",
+ startDate = "2019-11-13T03:00:00+0000",
+ dueDate = "2019-11-14T03:00:00+0000",
+ reminders = listOf(
+ "TRIGGER:P0DT9H0M0S",
+ "TRIGGER:PT0S"
+ ),
+ priority = Task.Priority.Low,
+ status = Task.Status.Normal,
+ completedTime = "2019-11-13T03:00:00+0000",
+ sortOrder = 12345,
+ items = listOf(
+ ChecklistItem(
+ id = "6435074647fd2e6387145f20",
+ status = ChecklistItem.Status.Normal,
+ title = "Subtask Title",
+ sortOrder = 12345,
+ startDate = "2019-11-13T03:00:00+0000",
+ isAllDay = false,
+ timeZone = "America/Los_Angeles",
+ completedTime = "2019-11-13T03:00:00+0000"
+ )
+ )
+ )
+ ),
+ columns = listOf(
+ Column(
+ id = "6226ff9e76e5fc39f2862d1b",
+ projectId = "6226ff9877acee87727f6bca",
+ name = "Column Name",
+ sortOrder = 0
+ )
+ ),
+ )
+ )
+)
\ No newline at end of file
diff --git a/lib/src/test/java/net/opatry/ticktick/entity/data/projectTestData.kt b/lib/src/test/java/net/opatry/ticktick/entity/data/projectTestData.kt
new file mode 100644
index 0000000..b293099
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/entity/data/projectTestData.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+
+package net.opatry.ticktick.entity.data
+
+import net.opatry.ticktick.entity.Project
+
+val projectData = listOf(
+ EntityTestParam.build(
+ """{
+ "id": "6226ff9877acee87727f6bca",
+ "name": "project name",
+ "color": "#F18181",
+ "closed": false,
+ "groupId": "6436176a47fd2e05f26ef56e",
+ "viewMode": "list",
+ "kind": "TASK"
+ }""".trimIndent(),
+ Project(
+ id = "6226ff9877acee87727f6bca",
+ name = "project name",
+ color = "#F18181",
+ isClosed = false,
+ groupId = "6436176a47fd2e05f26ef56e",
+ viewMode = Project.ViewMode.List,
+ kind = Project.Kind.Task
+ )
+ ),
+)
diff --git a/lib/src/test/java/net/opatry/ticktick/entity/data/taskTestData.kt b/lib/src/test/java/net/opatry/ticktick/entity/data/taskTestData.kt
new file mode 100644
index 0000000..0a3eff0
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/entity/data/taskTestData.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+
+package net.opatry.ticktick.entity.data
+
+import net.opatry.ticktick.entity.ChecklistItem
+import net.opatry.ticktick.entity.Task
+
+val taskData = listOf(
+ EntityTestParam.build(
+ """{
+ "id" : "63b7bebb91c0a5474805fcd4",
+ "isAllDay" : true,
+ "projectId" : "6226ff9877acee87727f6bca",
+ "title" : "Task Title",
+ "content" : "Task Content",
+ "desc" : "Task Description",
+ "timeZone" : "America/Los_Angeles",
+ "repeatFlag" : "RRULE:FREQ=DAILY;INTERVAL=1",
+ "startDate" : "2019-11-13T03:00:00+0000",
+ "dueDate" : "2019-11-14T03:00:00+0000",
+ "reminders" : [ "TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S" ],
+ "priority" : 1,
+ "status" : 0,
+ "completedTime" : "2019-11-13T03:00:00+0000",
+ "sortOrder" : 12345,
+ "items" : [ {
+ "id" : "6435074647fd2e6387145f20",
+ "status" : 0,
+ "title" : "Item Title",
+ "sortOrder" : 12345,
+ "startDate" : "2019-11-13T03:00:00+0000",
+ "isAllDay" : false,
+ "timeZone" : "America/Los_Angeles",
+ "completedTime" : "2019-11-13T03:00:00+0000"
+ } ]
+ }""".trimIndent(),
+ Task(
+ id = "63b7bebb91c0a5474805fcd4",
+ isAllDay = true,
+ projectId = "6226ff9877acee87727f6bca",
+ title = "Task Title",
+ content = "Task Content",
+ desc = "Task Description",
+ timeZone = "America/Los_Angeles",
+ repeatFlag = "RRULE:FREQ=DAILY;INTERVAL=1",
+ startDate = "2019-11-13T03:00:00+0000",
+ dueDate = "2019-11-14T03:00:00+0000",
+ reminders = listOf("TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S"),
+ priority = Task.Priority.Low,
+ status = Task.Status.Normal,
+ completedTime = "2019-11-13T03:00:00+0000",
+ sortOrder = 12345,
+ items = listOf(
+ ChecklistItem(
+ id = "6435074647fd2e6387145f20",
+ status = ChecklistItem.Status.Normal,
+ title = "Item Title",
+ sortOrder = 12345,
+ startDate = "2019-11-13T03:00:00+0000",
+ isAllDay = false,
+ timeZone = "America/Los_Angeles",
+ completedTime = "2019-11-13T03:00:00+0000"
+ )
+ )
+ )
+ ),
+)
diff --git a/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectDataTest.kt b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectDataTest.kt
new file mode 100644
index 0000000..4e2dd0e
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectDataTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.service
+
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.request.HttpRequestData
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import io.ktor.utils.io.ByteReadChannel
+import kotlinx.coroutines.runBlocking
+import net.opatry.ticktick.entity.data.projectDataTestData
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class TickTickServiceProjectDataTest {
+
+ @Test
+ fun `TickTickService getProjectData`() {
+ val testData = projectDataTestData.first()
+
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(testData.jsonPayload),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val projectData = todoService.getProjectData("6226ff9877acee87727f6bca")
+ assertEquals("/open/v1/project/6226ff9877acee87727f6bca/data", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Get, request?.method)
+ assertEquals(testData.expectedEntity, projectData)
+ }
+ }
+
+ @Test
+ fun `TickTickService getProjectData failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.getProjectData("6226ff9877acee87727f6bca")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectTest.kt b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectTest.kt
new file mode 100644
index 0000000..1c81824
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectTest.kt
@@ -0,0 +1,295 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.service
+
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.request.HttpRequestData
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import io.ktor.utils.io.ByteReadChannel
+import kotlinx.coroutines.runBlocking
+import net.opatry.ticktick.entity.Project
+import net.opatry.ticktick.entity.ProjectCreationRequest
+import net.opatry.ticktick.entity.ProjectUpdateRequest
+import net.opatry.ticktick.entity.data.projectData
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class TickTickServiceProjectTest {
+
+ @Test
+ fun `TickTickService getProjects`() {
+ val testData = projectData.first()
+
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel("[${testData.jsonPayload}]"),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val projects = todoService.getProjects()
+ assertEquals("/open/v1/project", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Get, request?.method)
+ assertEquals(listOf(testData.expectedEntity), projects)
+ }
+ }
+
+ @Test
+ fun `TickTickService getProjects failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.getProjects()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService createProject`() {
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(
+ """{
+ "id": "6226ff9877acee87727f6bca",
+ "name": "project name",
+ "color": "#F18181",
+ "closed": false,
+ "groupId": "6436176a47fd2e05f26ef56e",
+ "viewMode": "list",
+ "kind": "TASK"
+ }""".trimIndent()
+ ),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val projectData = ProjectCreationRequest(
+ name = "project name",
+ color = "#F18181",
+ viewMode = Project.ViewMode.List,
+ kind = Project.Kind.Task
+ )
+
+ val project = todoService.createProject(projectData)
+ assertEquals("/open/v1/project", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Post, request?.method)
+ val expected = Project(
+ id = "6226ff9877acee87727f6bca",
+ name = "project name",
+ color = "#F18181",
+ isClosed = false,
+ groupId = "6436176a47fd2e05f26ef56e",
+ viewMode = Project.ViewMode.List,
+ kind = Project.Kind.Task
+ )
+ assertEquals(expected, project)
+ }
+ }
+
+ @Test
+ fun `TickTickService createProject failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.createProject(ProjectCreationRequest("Foo"))
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService getProject`() {
+ val testData = projectData.first()
+
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(testData.jsonPayload),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val project = todoService.getProject("2203306141")
+ assertEquals("/open/v1/project/2203306141", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Get, request?.method)
+ assertEquals(testData.expectedEntity, project)
+ }
+ }
+
+ @Test
+ fun `TickTickService getProject failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.getProject("2203306141")
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService updateProject`() {
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(
+ """{
+ "id": "6226ff9877acee87727f6bca",
+ "name": "project name2",
+ "color": "#F18181",
+ "closed": false,
+ "groupId": "6436176a47fd2e05f26ef56e",
+ "viewMode": "list",
+ "kind": "TASK"
+ }""".trimIndent()
+ ),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val project = todoService.updateProject("2203306141", ProjectUpdateRequest(name = "project name2"))
+ assertEquals("/open/v1/project/2203306141", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Post, request?.method)
+ val expected = Project(
+ id = "6226ff9877acee87727f6bca",
+ name = "project name2",
+ color = "#F18181",
+ isClosed = false,
+ groupId = "6436176a47fd2e05f26ef56e",
+ viewMode = Project.ViewMode.List,
+ kind = Project.Kind.Task
+ )
+ assertEquals(expected, project)
+ }
+ }
+
+ @Test
+ fun `TickTickService updateProject failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.updateProject("6247ee29630c800f064fd145", ProjectUpdateRequest(name = "Bar"))
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService deleteProject`() {
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.NoContent,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ todoService.deleteProject("6226ff9877acee87727f6bca")
+ assertEquals("/open/v1/project/6226ff9877acee87727f6bca", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Delete, request?.method)
+ }
+ }
+
+ @Test
+ fun `TickTickService deleteProject failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.deleteProject("6226ff9877acee87727f6bca")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTaskTest.kt b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTaskTest.kt
new file mode 100644
index 0000000..d6e1997
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTaskTest.kt
@@ -0,0 +1,403 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.service
+
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.ClientRequestException
+import io.ktor.client.request.HttpRequestData
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import io.ktor.utils.io.ByteReadChannel
+import kotlinx.coroutines.runBlocking
+import net.opatry.ticktick.entity.ChecklistItem
+import net.opatry.ticktick.entity.ChecklistItemEdit
+import net.opatry.ticktick.entity.Task
+import net.opatry.ticktick.entity.TaskCreationRequest
+import net.opatry.ticktick.entity.TaskUpdateRequest
+import net.opatry.ticktick.entity.data.taskData
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class TickTickServiceTaskTest {
+
+ @Test
+ fun `TickTickService createTask`() {
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(
+ """{
+ "id" : "63b7bebb91c0a5474805fcd4",
+ "isAllDay" : true,
+ "projectId" : "6226ff9877acee87727f6bca",
+ "title" : "Task Title",
+ "content" : "Task Content",
+ "desc" : "Task Description",
+ "timeZone" : "America/Los_Angeles",
+ "repeatFlag" : "RRULE:FREQ=DAILY;INTERVAL=1",
+ "startDate" : "2019-11-13T03:00:00+0000",
+ "dueDate" : "2019-11-14T03:00:00+0000",
+ "reminders" : [ "TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S" ],
+ "priority" : 1,
+ "status" : 0,
+ "completedTime" : "2019-11-13T03:00:00+0000",
+ "sortOrder" : 12345,
+ "items" : [ {
+ "id" : "6435074647fd2e6387145f20",
+ "status" : 0,
+ "title" : "Item Title",
+ "sortOrder" : 12345,
+ "startDate" : "2019-11-13T03:00:00+0000",
+ "isAllDay" : false,
+ "timeZone" : "America/Los_Angeles",
+ "completedTime" : "2019-11-13T03:00:00+0000"
+ } ]
+ }""".trimIndent()
+ ),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val taskData = TaskCreationRequest(
+ isAllDay = true,
+ title = "Task Title",
+ content = "Task Content",
+ desc = "Task Description",
+ timeZone = "America/Los_Angeles",
+ repeatFlag = "RRULE:FREQ=DAILY;INTERVAL=1",
+ startDate = "2019-11-13T03:00:00+0000",
+ dueDate = "2019-11-14T03:00:00+0000",
+ reminders = listOf("TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S"),
+ priority = Task.Priority.Low,
+ sortOrder = 12345,
+ items = listOf(
+ ChecklistItemEdit(
+ status = ChecklistItem.Status.Normal,
+ title = "Item Title",
+ sortOrder = 12345,
+ startDate = "2019-11-13T03:00:00+0000",
+ isAllDay = false,
+ timeZone = "America/Los_Angeles",
+ completedTime = "2019-11-13T03:00:00+0000"
+ )
+ )
+ )
+
+ val task = todoService.createTask(taskData)
+ assertEquals("/open/v1/task", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Post, request?.method)
+ val expected = Task(
+ id = "63b7bebb91c0a5474805fcd4",
+ isAllDay = true,
+ projectId = "6226ff9877acee87727f6bca",
+ title = "Task Title",
+ content = "Task Content",
+ desc = "Task Description",
+ timeZone = "America/Los_Angeles",
+ repeatFlag = "RRULE:FREQ=DAILY;INTERVAL=1",
+ startDate = "2019-11-13T03:00:00+0000",
+ dueDate = "2019-11-14T03:00:00+0000",
+ reminders = listOf("TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S"),
+ priority = Task.Priority.Low,
+ status = Task.Status.Normal,
+ completedTime = "2019-11-13T03:00:00+0000",
+ sortOrder = 12345,
+ items = listOf(
+ ChecklistItem(
+ id = "6435074647fd2e6387145f20",
+ status = ChecklistItem.Status.Normal,
+ title = "Item Title",
+ sortOrder = 12345,
+ startDate = "2019-11-13T03:00:00+0000",
+ isAllDay = false,
+ timeZone = "America/Los_Angeles",
+ completedTime = "2019-11-13T03:00:00+0000"
+ )
+ )
+ )
+ assertEquals(expected, task)
+ }
+ }
+
+ @Test
+ fun `TickTickService createTask failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.createTask(TaskCreationRequest("Foo", "6247ee29630c800f064fd145"))
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService getTask`() {
+ val testData = taskData.first()
+
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(testData.jsonPayload),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val task = todoService.getTask("6247ee29630c800f064fd145", "6247ee29630c800f064fd145")
+ assertEquals(
+ "/open/v1/project/6247ee29630c800f064fd145/task/6247ee29630c800f064fd145",
+ request?.url?.encodedPath
+ )
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Get, request?.method)
+ assertEquals(testData.expectedEntity, task)
+ }
+ }
+
+ @Test
+ fun `TickTickService getTask failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.getTask("6247ee29630c800f064fd145", "6247ee29630c800f064fd145")
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService updateTask`() {
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(
+ """{
+ "id" : "63b7bebb91c0a5474805fcd4",
+ "isAllDay" : true,
+ "projectId" : "6226ff9877acee87727f6bca",
+ "title" : "Task Title",
+ "content" : "Task Content",
+ "desc" : "Task Description",
+ "timeZone" : "America/Los_Angeles",
+ "repeatFlag" : "RRULE:FREQ=DAILY;INTERVAL=1",
+ "startDate" : "2019-11-13T03:00:00+0000",
+ "dueDate" : "2019-11-14T03:00:00+0000",
+ "reminders" : [ "TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S" ],
+ "priority" : 1,
+ "status" : 0,
+ "completedTime" : "2019-11-13T03:00:00+0000",
+ "sortOrder" : 12345,
+ "items" : [ {
+ "id" : "6435074647fd2e6387145f20",
+ "status" : 0,
+ "title" : "Item Title",
+ "sortOrder" : 12345,
+ "startDate" : "2019-11-13T03:00:00+0000",
+ "isAllDay" : false,
+ "timeZone" : "America/Los_Angeles",
+ "completedTime" : "2019-11-13T03:00:00+0000"
+ } ]
+ }""".trimIndent()
+ ),
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ val task = todoService.updateTask(
+ "63b7bebb91c0a5474805fcd4",
+ TaskUpdateRequest(projectId = "6226ff9877acee87727f6bca", id = "63b7bebb91c0a5474805fcd4", content = "Buy Coffee")
+ )
+ assertEquals("/open/v1/task/63b7bebb91c0a5474805fcd4", request?.url?.encodedPath)
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Post, request?.method)
+ val expected = Task(
+ id = "63b7bebb91c0a5474805fcd4",
+ isAllDay = true,
+ projectId = "6226ff9877acee87727f6bca",
+ title = "Task Title",
+ content = "Task Content",
+ desc = "Task Description",
+ timeZone = "America/Los_Angeles",
+ repeatFlag = "RRULE:FREQ=DAILY;INTERVAL=1",
+ startDate = "2019-11-13T03:00:00+0000",
+ dueDate = "2019-11-14T03:00:00+0000",
+ reminders = listOf("TRIGGER:P0DT9H0M0S", "TRIGGER:PT0S"),
+ priority = Task.Priority.Low,
+ status = Task.Status.Normal,
+ completedTime = "2019-11-13T03:00:00+0000",
+ sortOrder = 12345,
+ items = listOf(
+ ChecklistItem(
+ id = "6435074647fd2e6387145f20",
+ status = ChecklistItem.Status.Normal,
+ title = "Item Title",
+ sortOrder = 12345,
+ startDate = "2019-11-13T03:00:00+0000",
+ isAllDay = false,
+ timeZone = "America/Los_Angeles",
+ completedTime = "2019-11-13T03:00:00+0000"
+ )
+ )
+ )
+ assertEquals(expected, task)
+ }
+ }
+
+ @Test
+ fun `TickTickService updateTask failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.updateTask(
+ "6247ee29630c800f064fd145",
+ TaskUpdateRequest(projectId = "", id = "", content = "Bar")
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService completeTask`() {
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.NoContent,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ todoService.completeTask("6226ff9877acee87727f6bca", "6247ee29630c800f064fd145")
+ assertEquals(
+ "/open/v1/project/6226ff9877acee87727f6bca/task/6247ee29630c800f064fd145/complete",
+ request?.url?.encodedPath
+ )
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Post, request?.method)
+ }
+ }
+
+ @Test
+ fun `TickTickService completeTask failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.completeTask("6226ff9877acee87727f6bca", "6247ee29630c800f064fd145")
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `TickTickService deleteTask`() {
+ var request: HttpRequestData? = null
+ val mockEngine = MockEngine {
+ request = it
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.NoContent,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ todoService.deleteTask("6226ff9877acee87727f6bca", "6247ee29630c800f064fd145")
+ assertEquals(
+ "/open/v1/project/6226ff9877acee87727f6bca/task/6247ee29630c800f064fd145",
+ request?.url?.encodedPath
+ )
+ assertEquals("", request?.url?.encodedQuery)
+ assertEquals(HttpMethod.Delete, request?.method)
+ }
+ }
+
+ @Test
+ fun `TickTickService deleteTask failure`() {
+ val mockEngine = MockEngine {
+ respond(
+ content = ByteReadChannel(""),
+ status = HttpStatusCode.Forbidden,
+ headers = headersOf(HttpHeaders.ContentType, "application/json")
+ )
+ }
+
+ usingTickTickService(mockEngine) { todoService ->
+ assertThrows(ClientRequestException::class.java) {
+ runBlocking {
+ todoService.deleteTask("6226ff9877acee87727f6bca", "6247ee29630c800f064fd145")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTest.kt b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTest.kt
new file mode 100644
index 0000000..45c4d8e
--- /dev/null
+++ b/lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+package net.opatry.ticktick.service
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.HttpClientEngine
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.serialization.gson.gson
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import net.opatry.ticktick.HttpTickTickService
+import net.opatry.ticktick.TickTickService
+
+fun usingTickTickService(
+ httpClientEngine: HttpClientEngine,
+ test: suspend TestScope.(service: TickTickService) -> Unit
+) {
+ val httpClient = HttpClient(httpClientEngine) {
+ install(ContentNegotiation) {
+ gson()
+ }
+ }
+
+ runTest {
+ test(HttpTickTickService(httpClient))
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..a840d12
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2024 Olivier Patry
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the Software
+ * is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+rootProject.name = "ticktick-kt"
+
+include(":lib")