From df65b26832a65f8e160abb37fa91d13b29d42718 Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Fri, 5 Jan 2024 00:40:42 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 85 ++++ .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 25 ++ .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/pull_request_template.md | 8 + .github/workflows/Build.yml | 34 ++ .gitignore | 8 + .idea/.gitignore | 11 + .idea/codeStyles/Project.xml | 132 ++++++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/copyright/opatry.xml | 6 + .idea/copyright/profiles_settings.xml | 3 + .issuetracker | 7 + CONTRIBUTING.md | 10 + LICENSE | 21 + README.md | 67 +++ build.gradle.kts | 38 ++ gradle.properties | 23 + gradle/libs.versions.toml | 31 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59821 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 218 ++++++++++ gradlew.bat | 89 ++++ lib/.gitignore | 2 + lib/build.gradle.kts | 35 ++ .../net/opatry/ticktick/TickTickService.kt | 174 ++++++++ .../ticktick/TickTickServiceAuthenticator.kt | 231 ++++++++++ .../opatry/ticktick/entity/ChecklistItem.kt | 79 ++++ .../ticktick/entity/ChecklistItemEdit.kt | 59 +++ .../java/net/opatry/ticktick/entity/Column.kt | 46 ++ .../opatry/ticktick/entity/ErrorResponse.kt | 106 +++++ .../net/opatry/ticktick/entity/Project.kt | 124 ++++++ .../ticktick/entity/ProjectCreationRequest.kt | 50 +++ .../net/opatry/ticktick/entity/ProjectData.kt | 42 ++ .../ticktick/entity/ProjectUpdateRequest.kt | 50 +++ .../java/net/opatry/ticktick/entity/Task.kt | 144 +++++++ .../ticktick/entity/TaskCreationRequest.kt | 78 ++++ .../ticktick/entity/TaskUpdateRequest.kt | 87 ++++ .../net/opatry/ticktick/entity/EntityTest.kt | 58 +++ .../ticktick/entity/data/EntityTestParam.kt | 36 ++ .../entity/data/projectDataTestData.kt | 134 ++++++ .../ticktick/entity/data/projectTestData.kt | 49 +++ .../ticktick/entity/data/taskTestData.kt | 88 ++++ .../service/TickTickServiceProjectDataTest.kt | 86 ++++ .../service/TickTickServiceProjectTest.kt | 295 +++++++++++++ .../service/TickTickServiceTaskTest.kt | 403 ++++++++++++++++++ .../ticktick/service/TickTickServiceTest.kt | 47 ++ settings.gradle.kts | 25 ++ 48 files changed, 3375 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/Build.yml create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/copyright/opatry.xml create mode 100644 .idea/copyright/profiles_settings.xml create mode 100644 .issuetracker create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 lib/.gitignore create mode 100644 lib/build.gradle.kts create mode 100644 lib/src/main/java/net/opatry/ticktick/TickTickService.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/TickTickServiceAuthenticator.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/ChecklistItem.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/ChecklistItemEdit.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/Column.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/ErrorResponse.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/Project.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/ProjectCreationRequest.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/ProjectData.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/ProjectUpdateRequest.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/Task.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/TaskCreationRequest.kt create mode 100644 lib/src/main/java/net/opatry/ticktick/entity/TaskUpdateRequest.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/entity/EntityTest.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/entity/data/EntityTestParam.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/entity/data/projectDataTestData.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/entity/data/projectTestData.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/entity/data/taskTestData.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectDataTest.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/service/TickTickServiceProjectTest.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTaskTest.kt create mode 100644 lib/src/test/java/net/opatry/ticktick/service/TickTickServiceTest.kt create mode 100644 settings.gradle.kts 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 @@ + + + + + + + + + + + \ 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 0000000000000000000000000000000000000000..41d9927a4d4fb3f96a785543079b8df6723c946b GIT binary patch literal 59821 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 literal 0 HcmV?d00001 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")