diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a44e1c5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +ij_continuation_indent_size = 4 +tab_width = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 120 + +[*.{kt,kts}] +ij_kotlin_imports_layout = *,^ +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true + +[*.{yml,yaml,json,toml}] +indent_size = 2 +tab_width = 2 +ij_continuation_indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..72cf50d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto + +*.bat eol=crlf +*.eml eol=crlf +*.jar binary + +app-k9mail/build.gradle.kts merge=merge_gradle +app-thunderbird/build.gradle.kts merge=merge_gradle +app-k9mail/src/main/res/raw/changelog_master.xml merge=ours diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fe66b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Gradle +.gradle/ +local.properties + +# Kotlin +.kotlin/ + +# mdBook +book/ + +# Generated folders +bin/ +build/ +gen/ +out/ + +# Generated files +*.aab +*.apk +*.ap_ +*.class +*.dex + +# Keystore files +*.jks +*.keystore + +# Signing files +.signing/ +*.signing.properties + +# IDEA/Android Studio ignores +*iml +.idea/* + +# IDEA/Android Studio includes +!.idea/icon.png +!.idea/codeStyles/ +!.idea/fileTemplates/ + +# Android Studio captures folder +captures/ + +# Mac thumbnail db +.DS_Store + +# Screenshots +adb-screenshots/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..d108487 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,447 @@ + + + + \ 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/icon.png b/.idea/icon.png new file mode 100644 index 0000000..91b5dc7 Binary files /dev/null and b/.idea/icon.png differ diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..aabe6ec --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f3750f5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Code of Conduct + +This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details please see the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a415c81 --- /dev/null +++ b/NOTICE @@ -0,0 +1,3 @@ +K-9 Mail +Copyright 2008-2016, K-9 Mail Developers +Copyright 2005-2016, The Android Open Source Project diff --git a/README.md b/README.md index 48f642a..7fbd43f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,89 @@ -# k9-mail +# Thunderbird for Android -Android Mail Client \ No newline at end of file +Get it on Google Play +Get it on F-Droid +[![Latest release](https://img.shields.io/github/release/thunderbird/thunderbird-android.svg?style=for-the-badge&filter=THUNDERBIRD_*&logo=thunderbird)](https://github.com/thunderbird/thunderbird-android/releases/latest) +[![Latest beta release](https://img.shields.io/github/release/thunderbird/thunderbird-android.svg?include_prereleases&style=for-the-badge&label=beta&filter=THUNDERBIRD_*b*&logo=thunderbird)](https://github.com/thunderbird/thunderbird-android/releases) + +Thunderbird for Android is a powerful, privacy-focused email app. Effortlessly manage multiple email accounts from one app, with a Unified Inbox option for maximum productivity. Built on open-source technology and supported by a dedicated team of developers alongside a global community of volunteers, Thunderbird never treats your private data as a product. + +Thunderbird for Android is based on K-9 Mail, which comes with a rich history of success and functionality in open source email. + +## Download + +Thunderbird for Android can be downloaded from a couple of sources: + +- Thunderbird on [Google Play](https://play.google.com/store/apps/details?id=net.thunderbird.android&referrer=utm_campaign%3Dandroid_metadata%26utm_medium%3Dweb%26utm_source%3Dgithub.com%26utm_content%3Dlink) or [F-Droid](https://f-droid.org/packages/net.thunderbird.android) +- Thunderbird Beta on [Google Play](https://play.google.com/store/apps/details?id=net.thunderbird.android.beta&referrer=utm_campaign%3Dandroid_metadata%26utm_medium%3Dweb%26utm_source%3Dgithub.com%26utm_content%3Dlink) or [F-Droid](https://f-droid.org/packages/net.thunderbird.android.beta) +- [Github Releases](https://github.com/thunderbird/thunderbird-android/releases) +- [FFUpdater](https://f-droid.org/packages/de.marmaro.krt.ffupdater/) allows installing the latest versions from ftp.mozilla.org + +By using Thunderbird for Android Beta, you have early access to current development and are able to try new features earlier. + +Check out the [Release Notes](https://github.com/thunderbird/thunderbird-android/releases) to find out what changed in each version of Thunderbird for Android. + +The SHA-256 fingerprints for our signing certificates are available in [SECURITY.md](./SECURITY.md#verifying-fingerprints). + +## Need Help? Found a bug? Have an idea? Want to chat? + +If the app is not behaving like it should, or you are not sure if you've encountered a bug: + +- Check out our [knowledge base](https://support.mozilla.org/products/thunderbird-android) and [frequently asked questions](https://support.mozilla.org/kb/thunderbird-android-8-faq) +- Ask a question on our [support forum](https://support.mozilla.org/en-US/questions/new/thunderbird-android) + +If you are certain you've identified a bug in Thunderbird for Android and would like to help fix it: + +- File an issue on [our GitHub issue tracker](https://github.com/thunderbird/thunderbird-android/issues) + +If you have an idea how to improve Thunderbird for Android: + +- Tell us about and vote on your feature ideas on [connect.mozilla.org](https://connect.mozilla.org/t5/ideas/idb-p/ideas/label-name/thunderbird%20android). +- Join the discussion about the latest changes in the [Thunderbird Android Beta Topicbox](https://thunderbird.topicbox.com/groups/android-beta). + +The Thunderbird Community uses Matrix to communicate: + +- General chat about Thunderbird for Android and K-9 Mail: [#tb-android:mozilla.org](https://matrix.to/#/#tb-android:mozilla.org) +- Development and other ways to contribute: [#tb-android-dev:mozilla.org](https://matrix.to/#/#tb-android-dev:mozilla.org) +- Reach the broader Thunderbird Community in the [community space](https://matrix.to/#/#thunderbird-community:mozilla.org) + +## Contributing + +We welcome contributions from everyone. + +- Development: Have you done a little bit of Kotlin? The [CONTRIBUTING](docs/CONTRIBUTING.md) guide will help you get started +- Translations: Do you speak a language aside from English? [Translating is easy](https://hosted.weblate.org/projects/tb-android/) and just takes a few minutes for your first success. +- We have [a number of other contribution opportunities](https://blog.thunderbird.net/2024/09/contribute-to-thunderbird-for-android/) available. +- Thunderbird is supported solely by financial contributions from users like you. [Make a financial contribution today](https://www.thunderbird.net/donate/mobile/?form=tfa)! +- Make sure to check out the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). + +### Architecture Decision Records (ADR) + +We use [Architecture Decision Records](https://adr.github.io/) to document the architectural decisions made in the +development of Thunderbird for Android. You can find them in the [`docs/architecture/adr`](docs/architecture/adr) directory. + +For more information about our ADRs, please see the [ADRs README](docs/architecture/adr/README.md). + +We encourage team members and contributors to read through our ADRs to understand the architectural decisions that +have shaped this project so far. Feel free to propose new ADRs or suggest modifications to existing ones as needed. + +## K-9 Mail + +In June 2022, [K-9 Mail joined the Thunderbird family](https://k9mail.app/2022/06/13/K-9-Mail-and-Thunderbird.html) +as the foundation for Thunderbird on Android. Since then, we’ve been updating both apps to give +users the same solid experience, so it’s normal to notice that K-9 Mail and Thunderbird look and +feel nearly identical. They’re built on the same code, and that’s intentional. You'll notice some +features are selectively enabled for Thunderbird as opposed to K-9 Mail, usually when they are +simply a better fit for Thunderbird (like the import from K-9 functionality). + +If you prefer the robot dog and would like to keep K-9 Mail around, you can find it here: + +- [K-9 Mail on Google Play](https://play.google.com/store/apps/details?id=com.fsck.k9&utm_source=thunderbird-android-github&utm_campaign=download-section) +- [K-9 Mail on F-Droid](https://f-droid.org/packages/com.fsck.k9/) + +## Forking + +If you want to use a fork of this project please ensure that you replace the OAuth client setup in the `app-k9mail/src/{debug,release}/kotlin/app/k9mail/auth/K9OAuthConfigurationFactory.kt` and `app-thunderbird/src/{debug,daily,beta,release}/kotlin/net/thunderbird/android/auth/TbOAuthConfigurationFactory.kt` with your own OAuth client setup and ensure that the `redirectUri` is different to the one used in the main project. This is to prevent conflicts with the main app when both are installed on the same device. + +## License + +Thunderbird for Android is licensed under the [Apache License, Version 2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cf7c877 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Thunderbird for Android Security + +## Security Audit + +The code in this repository underwent an extensive security audit in collaboration with the Open Source Technology +Improvement Fund ([OSTIF](https://ostif.org/)) and [7ASecurity](https://7asecurity.com/) in the first half of 2023. For +more details, see +our [blog post](https://blog.thunderbird.net/2023/07/k-9-mail-collaborates-with-ostif-and-7asecurity-security-audit/). + +## Verifying Fingerprints + +These are the SHA-256 fingerprints for our signing certificates: + +- Thunderbird: `B6:52:47:79:B3:DB:BC:5A:C1:7A:5A:C2:71:DD:B2:9D:CF:BF:72:35:78:C2:38:E0:3C:3C:21:78:11:35:6D:D1` +- Thunderbird Beta: `05:6B:FA:FB:45:02:49:50:2F:D9:22:62:28:70:4C:25:29:E1:B8:22:DA:06:76:0D:47:A8:5C:95:57:74:1F:BD` +- K-9 Mail: `55:C8:A5:23:B9:73:35:F5:BF:60:DF:E8:A9:F3:E1:DD:E7:44:51:6D:93:57:E8:0A:92:5B:7B:22:E4:F5:55:24` + +You can use the following command to retrieve and [verify](https://developer.android.com/tools/apksigner#usage-verify) +the certificate before installation: + +```bash +apksigner verify -v --print-certs +``` + +## Reporting Vulnerabilities + +You can report a security vulnerability through the [vulnerability reporting form](https://github.com/thunderbird/thunderbird-android/security/advisories/new). + +We appreciate your support in making Thunderbird for Android as safe as possible! diff --git a/app-common/README.md b/app-common/README.md new file mode 100644 index 0000000..0f7a3a4 --- /dev/null +++ b/app-common/README.md @@ -0,0 +1,7 @@ +# App Common + +# App Common + +This is the central integration point for shared code among the K-9 Mail and Thunderbird for Android applications. Its purpose is to collect and organize the individual feature modules that contain the actual functionality, as well as the "glue code" and configurations that tie them together. + +By keeping the shared code focused on these boundaries, we can ensure that it remains lean and avoids unnecessary dependencies. This approach allows us to maintain a clean and modular architecture, making it easier to maintain and update the codebase. diff --git a/app-common/build.gradle.kts b/app-common/build.gradle.kts new file mode 100644 index 0000000..56ef685 --- /dev/null +++ b/app-common/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.app.common" + + buildFeatures { + buildConfig = true + } +} + +dependencies { + api(projects.legacy.common) + api(projects.legacy.ui.legacy) + + api(projects.feature.account.core) + api(projects.feature.launcher) + api(projects.feature.navigation.drawer.api) + + implementation(projects.legacy.core) + implementation(projects.core.android.account) + + implementation(projects.core.logging.api) + implementation(projects.core.logging.implComposite) + implementation(projects.core.logging.implConsole) + implementation(projects.core.logging.implLegacy) + implementation(projects.core.logging.implFile) + + implementation(projects.core.featureflag) + implementation(projects.core.ui.legacy.theme2.common) + + implementation(projects.feature.account.avatar.api) + implementation(projects.feature.account.avatar.impl) + implementation(projects.feature.account.setup) + implementation(projects.feature.mail.account.api) + implementation(projects.feature.migration.provider) + implementation(projects.feature.notification.api) + implementation(projects.feature.notification.impl) + implementation(projects.feature.widget.messageList) + + implementation(projects.mail.protocols.imap) + + implementation(libs.androidx.work.runtime) + implementation(libs.androidx.lifecycle.process) + + testImplementation(projects.feature.account.fake) +} diff --git a/app-common/src/main/AndroidManifest.xml b/app-common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0d71098 --- /dev/null +++ b/app-common/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt new file mode 100644 index 0000000..dcec479 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt @@ -0,0 +1,22 @@ +package net.thunderbird.app.common + +import com.fsck.k9.legacyCommonAppModules +import com.fsck.k9.legacyCoreModules +import com.fsck.k9.legacyUiModules +import net.thunderbird.app.common.account.appCommonAccountModule +import net.thunderbird.app.common.core.appCommonCoreModule +import net.thunderbird.app.common.feature.appCommonFeatureModule +import org.koin.core.module.Module +import org.koin.dsl.module + +val appCommonModule: Module = module { + includes(legacyCommonAppModules) + includes(legacyCoreModules) + includes(legacyUiModules) + + includes( + appCommonAccountModule, + appCommonCoreModule, + appCommonFeatureModule, + ) +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt new file mode 100644 index 0000000..1872ec7 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/BaseApplication.kt @@ -0,0 +1,154 @@ +package net.thunderbird.app.common + +import android.app.Application +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import androidx.lifecycle.ProcessLifecycleOwner +import app.k9mail.feature.widget.message.list.MessageListWidgetManager +import app.k9mail.legacy.di.DI +import com.fsck.k9.Core +import com.fsck.k9.K9 +import com.fsck.k9.MessagingListenerProvider +import com.fsck.k9.controller.MessagingController +import com.fsck.k9.job.WorkManagerConfigurationProvider +import com.fsck.k9.notification.NotificationChannelManager +import com.fsck.k9.ui.base.AppLanguageManager +import com.fsck.k9.ui.base.extensions.currentLocale +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import net.thunderbird.app.common.feature.LoggerLifecycleObserver +import net.thunderbird.core.common.exception.ExceptionHandler +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.logging.file.FileLogSink +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.ui.theme.manager.ThemeManager +import org.koin.android.ext.android.inject +import org.koin.core.module.Module +import org.koin.core.qualifier.named +import androidx.work.Configuration as WorkManagerConfiguration + +abstract class BaseApplication : Application(), WorkManagerConfiguration.Provider { + + private val messagingController: MessagingController by inject() + private val messagingListenerProvider: MessagingListenerProvider by inject() + private val themeManager: ThemeManager by inject() + private val appLanguageManager: AppLanguageManager by inject() + private val notificationChannelManager: NotificationChannelManager by inject() + private val messageListWidgetManager: MessageListWidgetManager by inject() + private val workManagerConfigurationProvider: WorkManagerConfigurationProvider by inject() + private val logger: Logger by inject() + private val syncDebugFileLogSink: FileLogSink by inject(named("syncDebug")) + + private val appCoroutineScope: CoroutineScope = MainScope() + private var appLanguageManagerInitialized = false + + override fun attachBaseContext(base: Context?) { + Core.earlyInit() + + // Start Koin early so it is ready by the time content providers are initialized. + DI.start(this, listOf(provideAppModule())) + Log.logger = logger + + super.attachBaseContext(base) + } + + override fun onCreate() { + super.onCreate() + + K9.init(this) + Core.init(this) + initializeAppLanguage() + updateNotificationChannelsOnAppLanguageChanges() + themeManager.init() + messageListWidgetManager.init() + + messagingListenerProvider.listeners.forEach { listener -> + messagingController.addListener(listener) + } + val originalHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(originalHandler)) + + ProcessLifecycleOwner.get().lifecycle.addObserver(LoggerLifecycleObserver(syncDebugFileLogSink)) + } + + abstract fun provideAppModule(): Module + + private fun initializeAppLanguage() { + appLanguageManager.init() + applyOverrideLocaleToConfiguration() + appLanguageManagerInitialized = true + listenForAppLanguageChanges() + } + + private fun applyOverrideLocaleToConfiguration() { + appLanguageManager.getOverrideLocale()?.let { overrideLocale -> + updateConfigurationWithLocale(superResources.configuration, overrideLocale) + } + } + + private fun listenForAppLanguageChanges() { + appLanguageManager.overrideLocale + .drop(1) // We already applied the initial value + .onEach { overrideLocale -> + val locale = overrideLocale ?: Locale.getDefault() + updateConfigurationWithLocale(superResources.configuration, locale) + } + .launchIn(appCoroutineScope) + } + + override fun onConfigurationChanged(newConfiguration: Configuration) { + applyOverrideLocaleToConfiguration() + super.onConfigurationChanged(superResources.configuration) + } + + private fun updateConfigurationWithLocale(configuration: Configuration, locale: Locale) { + Log.d("Updating application configuration with locale '$locale'") + + val newConfiguration = Configuration(configuration).apply { + currentLocale = locale + } + + @Suppress("DEPRECATION") + superResources.updateConfiguration(newConfiguration, superResources.displayMetrics) + } + + private val superResources: Resources + get() = super.getResources() + + // Creating a WebView instance triggers something that will cause the configuration of the Application's Resources + // instance to be reset to the default, i.e. not containing our locale override. Unfortunately, we're not notified + // about this event. So we're checking each time someone asks for the Resources instance whether we need to change + // the configuration again. Luckily, right now (Android 11), the platform is calling this method right after + // resetting the configuration. + override fun getResources(): Resources { + val resources = super.getResources() + + if (appLanguageManagerInitialized) { + appLanguageManager.getOverrideLocale()?.let { overrideLocale -> + if (resources.configuration.currentLocale != overrideLocale) { + Log.w("Resources configuration was reset. Re-applying locale override.") + appLanguageManager.applyOverrideLocale() + applyOverrideLocaleToConfiguration() + } + } + } + + return resources + } + + private fun updateNotificationChannelsOnAppLanguageChanges() { + appLanguageManager.appLocale + .distinctUntilChanged() + .onEach { notificationChannelManager.updateChannels() } + .launchIn(appCoroutineScope) + } + + override val workManagerConfiguration: WorkManagerConfiguration + get() = workManagerConfigurationProvider.getConfiguration() +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/account/AccountColorPicker.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/account/AccountColorPicker.kt new file mode 100644 index 0000000..3d73a28 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/account/AccountColorPicker.kt @@ -0,0 +1,27 @@ +package net.thunderbird.app.common.account + +import android.content.res.Resources +import app.k9mail.core.ui.legacy.theme2.common.R +import net.thunderbird.core.android.account.AccountManager + +internal class AccountColorPicker( + private val accountManager: AccountManager, + private val resources: Resources, +) { + fun pickColor(): Int { + val accounts = accountManager.getAccounts() + val usedAccountColors = accounts.map { it.chipColor }.toSet() + val accountColors = resources.getIntArray(R.array.account_colors).toList() + + val availableColors = accountColors - usedAccountColors + if (availableColors.isEmpty()) { + return accountColors.random() + } + + val defaultAccountColors = resources.getIntArray(R.array.default_account_colors) + return availableColors.shuffled().minByOrNull { color -> + val index = defaultAccountColors.indexOf(color) + if (index != -1) index else defaultAccountColors.size + } ?: error("availableColors must not be empty") + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/account/AccountCreator.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/account/AccountCreator.kt new file mode 100644 index 0000000..a3ad328 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/account/AccountCreator.kt @@ -0,0 +1,169 @@ +package net.thunderbird.app.common.account + +import android.content.Context +import app.k9mail.feature.account.common.domain.entity.Account +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import app.k9mail.feature.account.setup.AccountSetupExternalContract +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import com.fsck.k9.Core +import com.fsck.k9.Preferences +import com.fsck.k9.account.DeletePolicyProvider +import com.fsck.k9.controller.MessagingController +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace +import com.fsck.k9.mail.store.imap.ImapStoreSettings.createExtra +import com.fsck.k9.mail.store.imap.ImapStoreSettings.isSendClientInfo +import com.fsck.k9.mail.store.imap.ImapStoreSettings.isUseCompression +import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix +import com.fsck.k9.mailstore.SpecialLocalFoldersCreator +import com.fsck.k9.preferences.UnifiedInboxConfigurator +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.common.mail.Protocols +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection + +// TODO Move to feature/account/setup +@Suppress("LongParameterList") +internal class AccountCreator( + private val accountColorPicker: AccountColorPicker, + private val localFoldersCreator: SpecialLocalFoldersCreator, + private val preferences: Preferences, + private val context: Context, + private val messagingController: MessagingController, + private val deletePolicyProvider: DeletePolicyProvider, + private val avatarMonogramCreator: AvatarMonogramCreator, + private val unifiedInboxConfigurator: UnifiedInboxConfigurator, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : AccountSetupExternalContract.AccountCreator { + + @Suppress("TooGenericExceptionCaught") + override suspend fun createAccount(account: Account): AccountCreatorResult { + return try { + withContext(coroutineDispatcher) { AccountCreatorResult.Success(create(account)) } + } catch (e: Exception) { + Log.e(e, "Error while creating new account") + + AccountCreatorResult.Error(e.message ?: "Unknown create account error") + } + } + + private suspend fun create(account: Account): String { + val newAccount = preferences.newAccount(account.uuid) + + newAccount.email = account.emailAddress + + newAccount.avatar = AvatarDto( + avatarType = AvatarTypeDto.MONOGRAM, + avatarMonogram = avatarMonogramCreator.create(account.options.accountName, account.emailAddress), + avatarImageUri = null, + avatarIconName = null, + ) + + newAccount.setIncomingServerSettings(account.incomingServerSettings) + newAccount.outgoingServerSettings = account.outgoingServerSettings + + newAccount.oAuthState = account.authorizationState + + newAccount.name = account.options.accountName + newAccount.senderName = account.options.displayName + if (account.options.emailSignature != null) { + newAccount.signatureUse = true + newAccount.signature = account.options.emailSignature + } + newAccount.isNotifyNewMail = account.options.showNotification + newAccount.automaticCheckIntervalMinutes = account.options.checkFrequencyInMinutes + newAccount.displayCount = account.options.messageDisplayCount + + newAccount.deletePolicy = deletePolicyProvider.getDeletePolicy(newAccount.incomingServerSettings.type) + newAccount.chipColor = accountColorPicker.pickColor() + + localFoldersCreator.createSpecialLocalFolders(newAccount) + + account.specialFolderSettings?.let { specialFolderSettings -> + newAccount.setSpecialFolders(specialFolderSettings) + } + + newAccount.markSetupFinished() + + preferences.saveAccount(newAccount) + + unifiedInboxConfigurator.configureUnifiedInbox() + + Core.setServicesEnabled(context) + + messagingController.refreshFolderListBlocking(newAccount) + + if (account.options.checkFrequencyInMinutes == -1) { + messagingController.checkMail(newAccount, false, true, false, null) + } + + return newAccount.uuid + } + + /** + * Set special folders by name. + * + * Since the folder list hasn't been synced yet, we don't have database IDs for the folders. So we use the same + * mechanism that is used when importing settings. See [com.fsck.k9.mailstore.SpecialFolderUpdater] for details. + */ + private fun LegacyAccount.setSpecialFolders(specialFolders: SpecialFolderSettings) { + importedArchiveFolder = specialFolders.archiveSpecialFolderOption.toFolderServerId() + archiveFolderSelection = specialFolders.archiveSpecialFolderOption.toFolderSelection() + + importedDraftsFolder = specialFolders.draftsSpecialFolderOption.toFolderServerId() + draftsFolderSelection = specialFolders.draftsSpecialFolderOption.toFolderSelection() + + importedSentFolder = specialFolders.sentSpecialFolderOption.toFolderServerId() + sentFolderSelection = specialFolders.sentSpecialFolderOption.toFolderSelection() + + importedSpamFolder = specialFolders.spamSpecialFolderOption.toFolderServerId() + spamFolderSelection = specialFolders.spamSpecialFolderOption.toFolderSelection() + + importedTrashFolder = specialFolders.trashSpecialFolderOption.toFolderServerId() + trashFolderSelection = specialFolders.trashSpecialFolderOption.toFolderSelection() + } + + private fun SpecialFolderOption.toFolderServerId(): String? { + return when (this) { + is SpecialFolderOption.None -> null + is SpecialFolderOption.Regular -> remoteFolder.serverId.serverId + is SpecialFolderOption.Special -> remoteFolder.serverId.serverId + } + } + + private fun SpecialFolderOption.toFolderSelection(): SpecialFolderSelection { + return when (this) { + is SpecialFolderOption.None -> { + if (isAutomatic) SpecialFolderSelection.AUTOMATIC else SpecialFolderSelection.MANUAL + } + is SpecialFolderOption.Regular -> { + SpecialFolderSelection.MANUAL + } + is SpecialFolderOption.Special -> { + if (isAutomatic) SpecialFolderSelection.AUTOMATIC else SpecialFolderSelection.MANUAL + } + } + } +} + +private fun LegacyAccount.setIncomingServerSettings(serverSettings: ServerSettings) { + if (serverSettings.type == Protocols.IMAP) { + useCompression = serverSettings.isUseCompression + isSendClientInfoEnabled = serverSettings.isSendClientInfo + incomingServerSettings = serverSettings.copy( + extra = createExtra( + autoDetectNamespace = serverSettings.autoDetectNamespace, + pathPrefix = serverSettings.pathPrefix, + ), + ) + } else { + incomingServerSettings = serverSettings + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/account/AppCommonAccountModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/account/AppCommonAccountModule.kt new file mode 100644 index 0000000..125b31c --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/account/AppCommonAccountModule.kt @@ -0,0 +1,66 @@ +package net.thunderbird.app.common.account + +import app.k9mail.feature.account.setup.AccountSetupExternalContract +import net.thunderbird.app.common.account.data.DefaultAccountProfileLocalDataSource +import net.thunderbird.app.common.account.data.DefaultLegacyAccountWrapperManager +import net.thunderbird.core.android.account.AccountDefaultsProvider +import net.thunderbird.core.android.account.LegacyAccountWrapperManager +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator +import net.thunderbird.feature.account.avatar.DefaultAvatarMonogramCreator +import net.thunderbird.feature.account.core.AccountCoreExternalContract.AccountProfileLocalDataSource +import net.thunderbird.feature.account.core.featureAccountCoreModule +import net.thunderbird.feature.account.storage.legacy.featureAccountStorageLegacyModule +import org.koin.android.ext.koin.androidApplication +import org.koin.dsl.module + +internal val appCommonAccountModule = module { + includes( + featureAccountCoreModule, + featureAccountStorageLegacyModule, + ) + + single { + DefaultLegacyAccountWrapperManager( + accountManager = get(), + accountDataMapper = get(), + ) + } + + single { + DefaultAccountProfileLocalDataSource( + accountManager = get(), + dataMapper = get(), + ) + } + + single { + DefaultAccountDefaultsProvider( + resourceProvider = get(), + featureFlagProvider = get(), + ) + } + + factory { + AccountColorPicker( + accountManager = get(), + resources = get(), + ) + } + + factory { + DefaultAvatarMonogramCreator() + } + + factory { + AccountCreator( + accountColorPicker = get(), + localFoldersCreator = get(), + preferences = get(), + context = androidApplication(), + deletePolicyProvider = get(), + messagingController = get(), + avatarMonogramCreator = get(), + unifiedInboxConfigurator = get(), + ) + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/account/DefaultAccountDefaultsProvider.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/account/DefaultAccountDefaultsProvider.kt new file mode 100644 index 0000000..5cc4944 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/account/DefaultAccountDefaultsProvider.kt @@ -0,0 +1,138 @@ +package net.thunderbird.app.common.account + +import com.fsck.k9.CoreResourceProvider +import net.thunderbird.core.android.account.AccountDefaultsProvider +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT_AUTO +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_READ_RECEIPT +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTED_TEXT_SHOWN +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_PREFIX +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_STYLE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REMOTE_SEARCH_NUM_RESULTS +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REPLY_AFTER_QUOTE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_RINGTONE_URI +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_ASCENDING +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_TYPE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_STRIP_SIGNATURE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SYNC_INTERVAL +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_VISIBLE_LIMIT +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.UNASSIGNED_ACCOUNT_NUMBER +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.FolderMode +import net.thunderbird.core.android.account.Identity +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.android.account.ShowPictures +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.toFeatureFlagKey +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection +import net.thunderbird.feature.notification.NotificationLight +import net.thunderbird.feature.notification.NotificationSettings +import net.thunderbird.feature.notification.NotificationVibration + +@Suppress("MagicNumber") +internal class DefaultAccountDefaultsProvider( + private val resourceProvider: CoreResourceProvider, + private val featureFlagProvider: FeatureFlagProvider, +) : AccountDefaultsProvider { + + override fun applyDefaults(account: LegacyAccount) = with(account) { + applyLegacyDefaults() + } + + override fun applyOverwrites(account: LegacyAccount, storage: Storage) = with(account) { + if (storage.contains("${account.uuid}.notifyNewMail")) { + isNotifyNewMail = storage.getBoolean("${account.uuid}.notifyNewMail", false) + isNotifySelfNewMail = storage.getBoolean("${account.uuid}.notifySelfNewMail", true) + } else { + isNotifyNewMail = featureFlagProvider.provide( + "email_notification_default".toFeatureFlagKey(), + ).whenEnabledOrNot( + onEnabled = { true }, + onDisabledOrUnavailable = { false }, + ) + + isNotifySelfNewMail = featureFlagProvider.provide( + "email_notification_default".toFeatureFlagKey(), + ).whenEnabledOrNot( + onEnabled = { true }, + onDisabledOrUnavailable = { false }, + ) + } + } + + @Suppress("LongMethod") + private fun LegacyAccount.applyLegacyDefaults() { + automaticCheckIntervalMinutes = DEFAULT_SYNC_INTERVAL + idleRefreshMinutes = 24 + displayCount = DEFAULT_VISIBLE_LIMIT + accountNumber = UNASSIGNED_ACCOUNT_NUMBER + isNotifyNewMail = true + folderNotifyNewMailMode = FolderMode.ALL + isNotifySync = false + isNotifySelfNewMail = true + isNotifyContactsMailOnly = false + isIgnoreChatMessages = false + messagesNotificationChannelVersion = 0 + folderDisplayMode = FolderMode.NOT_SECOND_CLASS + folderSyncMode = FolderMode.FIRST_CLASS + folderPushMode = FolderMode.NONE + sortType = DEFAULT_SORT_TYPE + setSortAscending(DEFAULT_SORT_TYPE, DEFAULT_SORT_ASCENDING) + showPictures = ShowPictures.NEVER + isSignatureBeforeQuotedText = false + expungePolicy = Expunge.EXPUNGE_IMMEDIATELY + importedAutoExpandFolder = null + legacyInboxFolder = null + maxPushFolders = 10 + isSubscribedFoldersOnly = false + maximumPolledMessageAge = -1 + maximumAutoDownloadMessageSize = DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE + messageFormat = DEFAULT_MESSAGE_FORMAT + isMessageFormatAuto = DEFAULT_MESSAGE_FORMAT_AUTO + isMessageReadReceipt = DEFAULT_MESSAGE_READ_RECEIPT + quoteStyle = DEFAULT_QUOTE_STYLE + quotePrefix = DEFAULT_QUOTE_PREFIX + isDefaultQuotedTextShown = DEFAULT_QUOTED_TEXT_SHOWN + isReplyAfterQuote = DEFAULT_REPLY_AFTER_QUOTE + isStripSignature = DEFAULT_STRIP_SIGNATURE + isSyncRemoteDeletions = true + openPgpKey = NO_OPENPGP_KEY + isRemoteSearchFullText = false + remoteSearchNumResults = DEFAULT_REMOTE_SEARCH_NUM_RESULTS + isUploadSentMessages = true + isMarkMessageAsReadOnView = true + isMarkMessageAsReadOnDelete = true + isAlwaysShowCcBcc = false + lastSyncTime = 0L + lastFolderListRefreshTime = 0L + + setArchiveFolderId(null, SpecialFolderSelection.AUTOMATIC) + setDraftsFolderId(null, SpecialFolderSelection.AUTOMATIC) + setSentFolderId(null, SpecialFolderSelection.AUTOMATIC) + setSpamFolderId(null, SpecialFolderSelection.AUTOMATIC) + setTrashFolderId(null, SpecialFolderSelection.AUTOMATIC) + + identities = ArrayList() + + val identity = Identity( + signatureUse = false, + signature = null, + description = resourceProvider.defaultIdentityDescription(), + ) + identities.add(identity) + + updateNotificationSettings { + NotificationSettings( + isRingEnabled = true, + ringtone = DEFAULT_RINGTONE_URI, + light = NotificationLight.Disabled, + vibration = NotificationVibration.DEFAULT, + ) + } + + resetChangeMarkers() + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/account/data/DefaultAccountProfileLocalDataSource.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/account/data/DefaultAccountProfileLocalDataSource.kt new file mode 100644 index 0000000..8c4d57a --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/account/data/DefaultAccountProfileLocalDataSource.kt @@ -0,0 +1,38 @@ +package net.thunderbird.app.common.account.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import net.thunderbird.core.android.account.LegacyAccountWrapperManager +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.core.AccountCoreExternalContract.AccountProfileLocalDataSource +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.storage.mapper.AccountProfileDataMapper + +internal class DefaultAccountProfileLocalDataSource( + private val accountManager: LegacyAccountWrapperManager, + private val dataMapper: AccountProfileDataMapper, +) : AccountProfileLocalDataSource { + + override fun getById(accountId: AccountId): Flow { + return accountManager.getById(accountId) + .map { account -> + account?.let { dto -> + dataMapper.toDomain(dto.profile) + } + } + } + + override suspend fun update(accountProfile: AccountProfile) { + val currentAccount = accountManager.getById(accountProfile.id) + .firstOrNull() ?: return + + val accountProfile = dataMapper.toDto(accountProfile) + + val updatedAccount = currentAccount.copy( + profile = accountProfile, + ) + + accountManager.update(updatedAccount) + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/account/data/DefaultLegacyAccountWrapperManager.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/account/data/DefaultLegacyAccountWrapperManager.kt new file mode 100644 index 0000000..f8d7f96 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/account/data/DefaultLegacyAccountWrapperManager.kt @@ -0,0 +1,38 @@ +package net.thunderbird.app.common.account.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.thunderbird.core.android.account.AccountManager +import net.thunderbird.core.android.account.LegacyAccountWrapper +import net.thunderbird.core.android.account.LegacyAccountWrapperManager +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.storage.legacy.mapper.DefaultLegacyAccountWrapperDataMapper + +internal class DefaultLegacyAccountWrapperManager( + private val accountManager: AccountManager, + private val accountDataMapper: DefaultLegacyAccountWrapperDataMapper, +) : LegacyAccountWrapperManager { + + override fun getAll(): Flow> { + return accountManager.getAccountsFlow() + .map { list -> + list.map { account -> + accountDataMapper.toDomain(account) + } + } + } + + override fun getById(id: AccountId): Flow { + return accountManager.getAccountFlow(id.asRaw()).map { account -> + account?.let { + accountDataMapper.toDomain(it) + } + } + } + + override suspend fun update(account: LegacyAccountWrapper) { + accountManager.saveAccount( + accountDataMapper.toDto(account), + ) + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt new file mode 100644 index 0000000..c9d1d85 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/core/AppCommonCoreModule.kt @@ -0,0 +1,70 @@ +package net.thunderbird.app.common.core + +import android.content.Context +import kotlin.time.ExperimentalTime +import net.thunderbird.app.common.core.logging.DefaultLogLevelManager +import net.thunderbird.core.common.inject.getList +import net.thunderbird.core.common.inject.singleListOf +import net.thunderbird.core.logging.DefaultLogger +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogLevelManager +import net.thunderbird.core.logging.LogLevelProvider +import net.thunderbird.core.logging.LogSink +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.logging.composite.CompositeLogSink +import net.thunderbird.core.logging.console.ConsoleLogSink +import net.thunderbird.core.logging.file.AndroidFileSystemManager +import net.thunderbird.core.logging.file.FileLogSink +import org.koin.core.module.Module +import org.koin.core.qualifier.named +import org.koin.dsl.bind +import org.koin.dsl.module + +val appCommonCoreModule: Module = module { + single { + DefaultLogLevelManager() + }.bind() + + singleListOf( + { ConsoleLogSink(level = LogLevel.VERBOSE) }, + ) + + single { + CompositeLogSink( + logLevelProvider = get(), + sinks = getList(), + ) + } + + single { + @OptIn(ExperimentalTime::class) + DefaultLogger( + sink = get(), + ) + } + + single(named(SYNC_DEBUG_LOG)) { + CompositeLogSink( + logLevelProvider = get(), + sinks = getList(), + ) + } + + single(named(SYNC_DEBUG_LOG)) { + FileLogSink( + level = LogLevel.DEBUG, + fileName = "thunderbird-sync-debug", + fileLocation = get().filesDir.path, + fileSystemManager = AndroidFileSystemManager(get().contentResolver), + ) + } + + single(named(SYNC_DEBUG_LOG)) { + @OptIn(ExperimentalTime::class) + DefaultLogger( + sink = get(named(SYNC_DEBUG_LOG)), + ) + } +} + +internal const val SYNC_DEBUG_LOG = "syncDebug" diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/DefaultLogLevelManager.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/DefaultLogLevelManager.kt new file mode 100644 index 0000000..9dc4951 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/core/logging/DefaultLogLevelManager.kt @@ -0,0 +1,22 @@ +package net.thunderbird.app.common.core.logging + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import net.thunderbird.app.common.BuildConfig +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogLevelManager + +class DefaultLogLevelManager : LogLevelManager { + private val defaultLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO + private val logLevel = MutableStateFlow(defaultLevel) + + override fun override(level: LogLevel) { + logLevel.update { level } + } + + override fun restoreDefault() { + override(defaultLevel) + } + + override fun current(): LogLevel = logLevel.value +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AccountSetupFinishedLauncher.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AccountSetupFinishedLauncher.kt new file mode 100644 index 0000000..ebcca41 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AccountSetupFinishedLauncher.kt @@ -0,0 +1,17 @@ +package net.thunderbird.app.common.feature + +import android.content.Context +import app.k9mail.feature.launcher.FeatureLauncherExternalContract +import com.fsck.k9.activity.MessageList + +internal class AccountSetupFinishedLauncher( + private val context: Context, +) : FeatureLauncherExternalContract.AccountSetupFinishedLauncher { + override fun launch(accountUuid: String?) { + if (accountUuid != null) { + MessageList.launch(context, accountUuid) + } else { + MessageList.launch(context) + } + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt new file mode 100644 index 0000000..ddc8e2a --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/AppCommonFeatureModule.kt @@ -0,0 +1,27 @@ +package net.thunderbird.app.common.feature + +import app.k9mail.feature.launcher.FeatureLauncherExternalContract +import app.k9mail.feature.launcher.di.featureLauncherModule +import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract +import net.thunderbird.feature.notification.impl.inject.featureNotificationModule +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +internal val appCommonFeatureModule = module { + includes(featureLauncherModule) + includes(featureNotificationModule) + + factory { + AccountSetupFinishedLauncher( + context = androidContext(), + ) + } + + single { + NavigationDrawerConfigLoader(get()) + } + + single { + NavigationDrawerConfigWriter(get()) + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/LoggerLifecycleObserver.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/LoggerLifecycleObserver.kt new file mode 100644 index 0000000..d79d856 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/LoggerLifecycleObserver.kt @@ -0,0 +1,22 @@ +package net.thunderbird.app.common.feature + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.thunderbird.core.logging.file.FileLogSink + +class LoggerLifecycleObserver(val fileLogSink: FileLogSink?) : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) { + super.onStop(owner) + fileLogSink?.let { + owner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + it.flushAndCloseBuffer() + } + } + } + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/NavigationDrawerConfigLoader.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/NavigationDrawerConfigLoader.kt new file mode 100644 index 0000000..4aef579 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/NavigationDrawerConfigLoader.kt @@ -0,0 +1,12 @@ +package net.thunderbird.app.common.feature + +import com.fsck.k9.preferences.DrawerConfigManager +import kotlinx.coroutines.flow.Flow +import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract + +internal class NavigationDrawerConfigLoader(private val drawerConfigManager: DrawerConfigManager) : + NavigationDrawerExternalContract.DrawerConfigLoader { + override fun loadDrawerConfigFlow(): Flow { + return drawerConfigManager.getConfigFlow() + } +} diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/NavigationDrawerConfigWriter.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/NavigationDrawerConfigWriter.kt new file mode 100644 index 0000000..aae40c2 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/NavigationDrawerConfigWriter.kt @@ -0,0 +1,13 @@ +package net.thunderbird.app.common.feature + +import com.fsck.k9.preferences.DrawerConfigManager +import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract +import net.thunderbird.feature.navigation.drawer.api.NavigationDrawerExternalContract.DrawerConfig + +internal class NavigationDrawerConfigWriter( + private val drawerConfigManager: DrawerConfigManager, +) : NavigationDrawerExternalContract.DrawerConfigWriter { + override fun writeDrawerConfig(drawerConfig: DrawerConfig) { + drawerConfigManager.save(drawerConfig) + } +} diff --git a/app-common/src/main/res/xml/network_security_config.xml b/app-common/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..c7b0754 --- /dev/null +++ b/app-common/src/main/res/xml/network_security_config.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/app-common/src/test/kotlin/net/thunderbird/app/common/account/DefaultAccountDefaultsProviderTest.kt b/app-common/src/test/kotlin/net/thunderbird/app/common/account/DefaultAccountDefaultsProviderTest.kt new file mode 100644 index 0000000..20c2bda --- /dev/null +++ b/app-common/src/test/kotlin/net/thunderbird/app/common/account/DefaultAccountDefaultsProviderTest.kt @@ -0,0 +1,262 @@ +package net.thunderbird.app.common.account + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.fsck.k9.CoreResourceProvider +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT_AUTO +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_READ_RECEIPT +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTED_TEXT_SHOWN +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_PREFIX +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_QUOTE_STYLE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REMOTE_SEARCH_NUM_RESULTS +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_REPLY_AFTER_QUOTE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_RINGTONE_URI +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_ASCENDING +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SORT_TYPE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_STRIP_SIGNATURE +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_SYNC_INTERVAL +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.DEFAULT_VISIBLE_LIMIT +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.UNASSIGNED_ACCOUNT_NUMBER +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.FolderMode +import net.thunderbird.core.android.account.Identity +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.android.account.ShowPictures +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection +import net.thunderbird.feature.notification.NotificationLight +import net.thunderbird.feature.notification.NotificationSettings +import net.thunderbird.feature.notification.NotificationVibration +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class DefaultAccountDefaultsProviderTest { + + @Suppress("LongMethod") + @Test + fun `applyDefaults should return default values`() { + // arrange + val resourceProvider = mock { + on { defaultIdentityDescription() } doReturn "Default Identity" + } + val account = LegacyAccount( + uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b", + isSensitiveDebugLoggingEnabled = { false }, + ) + val identities = listOf( + Identity( + signatureUse = false, + signature = null, + description = resourceProvider.defaultIdentityDescription(), + ), + ) + val notificationSettings = NotificationSettings( + isRingEnabled = true, + ringtone = DEFAULT_RINGTONE_URI, + light = NotificationLight.Disabled, + vibration = NotificationVibration.DEFAULT, + ) + val testSubject = DefaultAccountDefaultsProvider( + resourceProvider = resourceProvider, + featureFlagProvider = { + FeatureFlagResult.Disabled + }, + ) + + // act + testSubject.applyDefaults(account) + + // assert + assertThat(account.automaticCheckIntervalMinutes).isEqualTo(DEFAULT_SYNC_INTERVAL) + assertThat(account.idleRefreshMinutes).isEqualTo(24) + assertThat(account.displayCount).isEqualTo(DEFAULT_VISIBLE_LIMIT) + assertThat(account.accountNumber).isEqualTo(UNASSIGNED_ACCOUNT_NUMBER) + assertThat(account.isNotifyNewMail).isTrue() + assertThat(account.folderNotifyNewMailMode).isEqualTo(FolderMode.ALL) + assertThat(account.isNotifySync).isFalse() + assertThat(account.isNotifySelfNewMail).isTrue() + assertThat(account.isNotifyContactsMailOnly).isFalse() + assertThat(account.isIgnoreChatMessages).isFalse() + assertThat(account.messagesNotificationChannelVersion).isEqualTo(0) + assertThat(account.folderDisplayMode).isEqualTo(FolderMode.NOT_SECOND_CLASS) + assertThat(account.folderSyncMode).isEqualTo(FolderMode.FIRST_CLASS) + assertThat(account.folderPushMode).isEqualTo(FolderMode.NONE) + assertThat(account.sortType).isEqualTo(DEFAULT_SORT_TYPE) + assertThat(account.isSortAscending(DEFAULT_SORT_TYPE)).isEqualTo(DEFAULT_SORT_ASCENDING) + assertThat(account.showPictures).isEqualTo(ShowPictures.NEVER) + assertThat(account.isSignatureBeforeQuotedText).isFalse() + assertThat(account.expungePolicy).isEqualTo(Expunge.EXPUNGE_IMMEDIATELY) + assertThat(account.importedAutoExpandFolder).isNull() + assertThat(account.legacyInboxFolder).isNull() + assertThat(account.maxPushFolders).isEqualTo(10) + assertThat(account.isSubscribedFoldersOnly).isFalse() + assertThat(account.maximumPolledMessageAge).isEqualTo(-1) + assertThat(account.maximumAutoDownloadMessageSize).isEqualTo(DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE) + assertThat(account.messageFormat).isEqualTo(DEFAULT_MESSAGE_FORMAT) + assertThat(account.isMessageFormatAuto).isEqualTo(DEFAULT_MESSAGE_FORMAT_AUTO) + assertThat(account.isMessageReadReceipt).isEqualTo(DEFAULT_MESSAGE_READ_RECEIPT) + assertThat(account.quoteStyle).isEqualTo(DEFAULT_QUOTE_STYLE) + assertThat(account.quotePrefix).isEqualTo(DEFAULT_QUOTE_PREFIX) + assertThat(account.isDefaultQuotedTextShown).isEqualTo(DEFAULT_QUOTED_TEXT_SHOWN) + assertThat(account.isReplyAfterQuote).isEqualTo(DEFAULT_REPLY_AFTER_QUOTE) + assertThat(account.isStripSignature).isEqualTo(DEFAULT_STRIP_SIGNATURE) + assertThat(account.isSyncRemoteDeletions).isTrue() + assertThat(account.openPgpKey).isEqualTo(NO_OPENPGP_KEY) + assertThat(account.isRemoteSearchFullText).isFalse() + assertThat(account.remoteSearchNumResults).isEqualTo(DEFAULT_REMOTE_SEARCH_NUM_RESULTS) + assertThat(account.isUploadSentMessages).isTrue() + assertThat(account.isMarkMessageAsReadOnView).isTrue() + assertThat(account.isMarkMessageAsReadOnDelete).isTrue() + assertThat(account.isAlwaysShowCcBcc).isFalse() + assertThat(account.lastSyncTime).isEqualTo(0L) + assertThat(account.lastFolderListRefreshTime).isEqualTo(0L) + + assertThat(account.archiveFolderId).isNull() + assertThat(account.archiveFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC) + assertThat(account.draftsFolderId).isNull() + assertThat(account.draftsFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC) + assertThat(account.sentFolderId).isNull() + assertThat(account.sentFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC) + assertThat(account.spamFolderId).isNull() + assertThat(account.spamFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC) + assertThat(account.trashFolderId).isNull() + assertThat(account.trashFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC) + assertThat(account.archiveFolderId).isNull() + assertThat(account.archiveFolderSelection).isEqualTo(SpecialFolderSelection.AUTOMATIC) + + assertThat(account.identities).isEqualTo(identities) + assertThat(account.notificationSettings).isEqualTo(notificationSettings) + + assertThat(account.isChangedVisibleLimits).isFalse() + } + + @Test + fun `applyOverwrites should return patched account when disabled`() { + // arrange + val resourceProvider = mock { + on { defaultIdentityDescription() } doReturn "Default Identity" + } + val account = LegacyAccount( + uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b", + isSensitiveDebugLoggingEnabled = { false }, + ) + val storage = mock { + on { contains("${account.uuid}.notifyNewMail") } doReturn false + on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false + on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false + } + val testSubject = DefaultAccountDefaultsProvider( + resourceProvider = resourceProvider, + featureFlagProvider = { + FeatureFlagResult.Disabled + }, + ) + + // act + testSubject.applyOverwrites(account, storage) + + // assert + assertThat(account.isNotifyNewMail).isFalse() + assertThat(account.isNotifySelfNewMail).isFalse() + } + + @Test + fun `applyOverwrites should return patched account when enabled`() { + // arrange + val resourceProvider = mock { + on { defaultIdentityDescription() } doReturn "Default Identity" + } + val account = LegacyAccount( + uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b", + isSensitiveDebugLoggingEnabled = { false }, + ) + val storage = mock { + on { contains("${account.uuid}.notifyNewMail") } doReturn false + on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false + on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false + } + val testSubject = DefaultAccountDefaultsProvider( + resourceProvider = resourceProvider, + featureFlagProvider = { + FeatureFlagResult.Enabled + }, + ) + + // act + testSubject.applyOverwrites(account, storage) + + // assert + assertThat(account.isNotifyNewMail).isTrue() + assertThat(account.isNotifySelfNewMail).isTrue() + } + + @Suppress("MaxLineLength") + @Test + fun `applyOverwrites updates account notification values from storage when storage contains isNotifyNewMail value`() { + // arrange + val resourceProvider = mock { + on { defaultIdentityDescription() } doReturn "Default Identity" + } + val account = LegacyAccount( + uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b", + isSensitiveDebugLoggingEnabled = { false }, + ) + val storage = mock { + on { contains("${account.uuid}.notifyNewMail") } doReturn true + on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false + on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false + } + val testSubject = DefaultAccountDefaultsProvider( + resourceProvider = resourceProvider, + featureFlagProvider = { + FeatureFlagResult.Enabled + }, + ) + + // act + testSubject.applyOverwrites(account, storage) + + // assert + assertThat(account.isNotifyNewMail).isFalse() + assertThat(account.isNotifySelfNewMail).isFalse() + } + + @Suppress("MaxLineLength") + @Test + fun `applyOverwrites updates account notification values from featureFlag values when storage does not contain isNotifyNewMail value`() { + // arrange + val resourceProvider = mock { + on { defaultIdentityDescription() } doReturn "Default Identity" + } + val account = LegacyAccount( + uuid = "cf728064-077d-4369-a0c7-7c2b21693d9b", + isSensitiveDebugLoggingEnabled = { false }, + ) + val storage = mock { + on { contains("${account.uuid}.notifyNewMail") } doReturn false + on { getBoolean("${account.uuid}.notifyNewMail", false) } doReturn false + on { getBoolean("${account.uuid}.notifySelfNewMail", false) } doReturn false + } + val testSubject = DefaultAccountDefaultsProvider( + resourceProvider = resourceProvider, + featureFlagProvider = { + FeatureFlagResult.Enabled + }, + ) + + // act + testSubject.applyOverwrites(account, storage) + + // assert + assertThat(account.isNotifyNewMail).isTrue() + assertThat(account.isNotifySelfNewMail).isTrue() + } +} diff --git a/app-common/src/test/kotlin/net/thunderbird/app/common/account/data/DefaultAccountProfileLocalDataSourceTest.kt b/app-common/src/test/kotlin/net/thunderbird/app/common/account/data/DefaultAccountProfileLocalDataSourceTest.kt new file mode 100644 index 0000000..0ccad85 --- /dev/null +++ b/app-common/src/test/kotlin/net/thunderbird/app/common/account/data/DefaultAccountProfileLocalDataSourceTest.kt @@ -0,0 +1,158 @@ +package net.thunderbird.app.common.account.data + +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import kotlinx.coroutines.test.runTest +import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_COLOR +import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_NAME +import net.thunderbird.core.android.account.Identity +import net.thunderbird.core.android.account.LegacyAccountWrapper +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountAvatarDataMapper +import net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountProfileDataMapper +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto +import net.thunderbird.feature.account.storage.profile.ProfileDto +import org.junit.Test + +class DefaultAccountProfileLocalDataSourceTest { + + @Test + fun `getById should return account profile`() = runTest { + // arrange + val accountId = AccountIdFactory.create() + val legacyAccount = createLegacyAccount(accountId) + val accountProfile = createAccountProfile(accountId) + val testSubject = createTestSubject(legacyAccount) + + // act & assert + testSubject.getById(accountId).test { + assertThat(awaitItem()).isEqualTo(accountProfile) + } + } + + @Test + fun `getById should return null when account is not found`() = runTest { + // arrange + val accountId = AccountIdFactory.create() + val testSubject = createTestSubject(null) + + // act & assert + testSubject.getById(accountId).test { + assertThat(awaitItem()).isEqualTo(null) + } + } + + @Test + fun `update should save account profile`() = runTest { + // arrange + val accountId = AccountIdFactory.create() + val legacyAccount = createLegacyAccount(accountId) + val accountProfile = createAccountProfile(accountId) + + val updatedName = "updatedName" + val updatedAccountProfile = accountProfile.copy(name = updatedName) + + val testSubject = createTestSubject(legacyAccount) + + // act & assert + testSubject.getById(accountId).test { + assertThat(awaitItem()).isEqualTo(accountProfile) + + testSubject.update(updatedAccountProfile) + + assertThat(awaitItem()).isEqualTo(updatedAccountProfile) + } + } + + private companion object Companion { + fun createLegacyAccount( + id: AccountId, + displayName: String = PROFILE_NAME, + color: Int = PROFILE_COLOR, + ): LegacyAccountWrapper { + return LegacyAccountWrapper( + isSensitiveDebugLoggingEnabled = { true }, + id = id, + name = displayName, + email = "demo@example.com", + profile = ProfileDto( + id = id, + name = displayName, + color = color, + avatar = AvatarDto( + avatarType = AvatarTypeDto.ICON, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = "star", + ), + ), + identities = listOf( + Identity( + signatureUse = false, + description = "Demo User", + ), + ), + incomingServerSettings = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ), + outgoingServerSettings = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ), + ) + } + + private fun createAccountProfile( + accountId: AccountId, + name: String = PROFILE_NAME, + color: Int = PROFILE_COLOR, + ): AccountProfile { + return AccountProfile( + id = accountId, + name = name, + color = color, + avatar = AccountAvatar.Icon( + name = "star", + ), + ) + } + + private fun createTestSubject( + legacyAccount: LegacyAccountWrapper?, + ): DefaultAccountProfileLocalDataSource { + return DefaultAccountProfileLocalDataSource( + accountManager = FakeLegacyAccountWrapperManager( + initialAccounts = if (legacyAccount != null) { + listOf(legacyAccount) + } else { + emptyList() + }, + ), + dataMapper = DefaultAccountProfileDataMapper( + avatarMapper = DefaultAccountAvatarDataMapper(), + ), + ) + } + } +} diff --git a/app-common/src/test/kotlin/net/thunderbird/app/common/account/data/FakeLegacyAccountWrapperManager.kt b/app-common/src/test/kotlin/net/thunderbird/app/common/account/data/FakeLegacyAccountWrapperManager.kt new file mode 100644 index 0000000..98c4aa8 --- /dev/null +++ b/app-common/src/test/kotlin/net/thunderbird/app/common/account/data/FakeLegacyAccountWrapperManager.kt @@ -0,0 +1,36 @@ +package net.thunderbird.app.common.account.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import net.thunderbird.core.android.account.LegacyAccountWrapper +import net.thunderbird.core.android.account.LegacyAccountWrapperManager +import net.thunderbird.feature.account.AccountId + +internal class FakeLegacyAccountWrapperManager( + initialAccounts: List = emptyList(), +) : LegacyAccountWrapperManager { + + private val accountsState = MutableStateFlow( + initialAccounts, + ) + private val accounts: StateFlow> = accountsState + + override fun getAll(): Flow> = accounts + + override fun getById(id: AccountId): Flow = accounts + .map { list -> + list.find { it.id == id } + } + + override suspend fun update(account: LegacyAccountWrapper) { + accountsState.update { currentList -> + currentList.toMutableList().apply { + removeIf { it.uuid == account.uuid } + add(account) + } + } + } +} diff --git a/app-k9mail/README.md b/app-k9mail/README.md new file mode 100644 index 0000000..bd9f9f9 --- /dev/null +++ b/app-k9mail/README.md @@ -0,0 +1,12 @@ +# K-9 Mail + +This is the source code repository for the K-9 Mail project. + +## Maintenance + +### F-Droid + +K-9 Mail is available on F-Droid. The apps metadata for F-Droid is available within the [metadata](fastlane/metadata) +folder. The metadata is setup according to +the [All About Descriptions, Graphics, and Screenshots](https://f-droid.org/en/docs/All_About_Descriptions_Graphics_and_Screenshots/) +and [Build Metadata Reference](https://f-droid.org/en/docs/Build_Metadata_Reference/). diff --git a/app-k9mail/badging/fossRelease-badging.txt b/app-k9mail/badging/fossRelease-badging.txt new file mode 100644 index 0000000..f3b25ca --- /dev/null +++ b/app-k9mail/badging/fossRelease-badging.txt @@ -0,0 +1,99 @@ +application-icon-120:'res/drawable-v26/ic_launcher.xml' +application-icon-160:'res/drawable-v26/ic_launcher.xml' +application-icon-240:'res/drawable-v26/ic_launcher.xml' +application-icon-320:'res/drawable-v26/ic_launcher.xml' +application-icon-480:'res/drawable-v26/ic_launcher.xml' +application-icon-640:'res/drawable-v26/ic_launcher.xml' +application-icon-65534:'res/drawable-v26/ic_launcher.xml' +application-label-ar:'بريد K-9' +application-label-be:'Пошта K-9' +application-label-bg:'K-9 Поща' +application-label-ca:'K-9 Mail' +application-label-co:'K-9 Mail' +application-label-cs:'K-9 Mail' +application-label-cy:'K-9 Mail' +application-label-da:'K-9 Mail' +application-label-de:'K-9 Mail' +application-label-el:'K-9 Mail' +application-label-en-GB:'K-9 Mail' +application-label-en:'K-9 Mail' +application-label-eo:'K-9 Retpoŝtilo' +application-label-es:'K-9 Mail' +application-label-et:'K-9 Mail' +application-label-eu:'K-9 Mail' +application-label-fa:'نامهٔ کی۹' +application-label-fi:'K-9 Mail' +application-label-fr:'Courriel K-9' +application-label-fy:'K-9 Mail' +application-label-ga:'K-9 Post' +application-label-gl:'K-9 Mail' +application-label-hr:'K-9 Mail' +application-label-hu:'K-9 Mail' +application-label-in:'Surel K-9' +application-label-is:'K-9 - Póstur' +application-label-it:'K-9 Mail' +application-label-iw:'K-9 דוא\"ל' +application-label-ja:'K-9 Mail' +application-label-ko:'K-9 메일' +application-label-lt:'K-9 paštas' +application-label-lv:'K-9 pasts' +application-label-nb:'K-9 E-post' +application-label-nl:'K-9 Mail' +application-label-nn:'K-9 e-post' +application-label-pl:'K-9 Mail' +application-label-pt-BR:'K-9 Mail' +application-label-pt-PT:'K-9 Mail' +application-label-pt:'Email K-9' +application-label-ro:'K-9 Mail' +application-label-ru:'Почта K-9' +application-label-sk:'K-9 Mail' +application-label-sl:'Pošta K-9' +application-label-sq:'K-9 Mail' +application-label-sr:'K-9 Mail' +application-label-sv:'K-9 Mail' +application-label-tr:'K-9 Posta' +application-label-uk:'K-9 Mail' +application-label-vi:'Thư K-9' +application-label-zh-CN:'K-9 Mail' +application-label-zh-TW:'K-9 Mail' +application-label-zh:'K-9 Mail' +application-label:'K-9 Mail' +application: label='K-9 Mail' icon='res/drawable-v26/ic_launcher.xml' +densities: '120' '160' '240' '320' '480' '640' '65534' +feature-group: label='' +install-location:'auto' +launchable-activity: name='com.fsck.k9.activity.MessageList' label='' icon='' +locales: '--_--' 'ar' 'be' 'bg' 'ca' 'co' 'cs' 'cy' 'da' 'de' 'el' 'en' 'en-GB' 'eo' 'es' 'et' 'eu' 'fa' 'fi' 'fr' 'fy' 'ga' 'gl' 'hr' 'hu' 'in' 'is' 'it' 'iw' 'ja' 'ko' 'lt' 'lv' 'nb' 'nl' 'nn' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'sk' 'sl' 'sq' 'sr' 'sv' 'tr' 'uk' 'vi' 'zh' 'zh-CN' 'zh-TW' +main +minSdkVersion:'21' +native-code: 'arm64-v8a' 'armeabi-v7a' 'x86' 'x86_64' +other-activities +other-receivers +other-services +package: name='com.fsck.k9' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +property: name='android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE' value='This service is used to maintain a continuous connection to an IMAP server to be able to provide instant notifications to the user when a new email arrives. Firebase Cloud Messaging is not suitable for this task, neither are mechanisms like AndroidX WorkManager. Other foreground service types aren't a good fit for this use case.' +provides-component:'app-widget' +supports-any-density: 'true' +supports-screens: 'small' 'normal' 'large' 'xlarge' +targetSdkVersion:'35' +uses-feature-not-required: name='android.hardware.camera' +uses-feature-not-required: name='android.hardware.touchscreen' +uses-library-not-required:'androidx.window.extensions' +uses-library-not-required:'androidx.window.sidecar' +uses-library-not-required:'com.sec.android.app.multiwindow' +uses-permission: name='android.permission.ACCESS_NETWORK_STATE' +uses-permission: name='android.permission.CAMERA' +uses-permission: name='android.permission.FOREGROUND_SERVICE' +uses-permission: name='android.permission.FOREGROUND_SERVICE_DATA_SYNC' maxSdkVersion='33' +uses-permission: name='android.permission.FOREGROUND_SERVICE_SPECIAL_USE' +uses-permission: name='android.permission.INTERNET' +uses-permission: name='android.permission.POST_NOTIFICATIONS' +uses-permission: name='android.permission.READ_CONTACTS' +uses-permission: name='android.permission.READ_SYNC_SETTINGS' +uses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED' +uses-permission: name='android.permission.SCHEDULE_EXACT_ALARM' +uses-permission: name='android.permission.USE_BIOMETRIC' +uses-permission: name='android.permission.USE_FINGERPRINT' +uses-permission: name='android.permission.VIBRATE' +uses-permission: name='android.permission.WAKE_LOCK' +uses-permission: name='com.fsck.k9.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION' diff --git a/app-k9mail/badging/fullRelease-badging.txt b/app-k9mail/badging/fullRelease-badging.txt new file mode 100644 index 0000000..224b639 --- /dev/null +++ b/app-k9mail/badging/fullRelease-badging.txt @@ -0,0 +1,100 @@ +application-icon-120:'res/drawable-v26/ic_launcher.xml' +application-icon-160:'res/drawable-v26/ic_launcher.xml' +application-icon-240:'res/drawable-v26/ic_launcher.xml' +application-icon-320:'res/drawable-v26/ic_launcher.xml' +application-icon-480:'res/drawable-v26/ic_launcher.xml' +application-icon-640:'res/drawable-v26/ic_launcher.xml' +application-icon-65534:'res/drawable-v26/ic_launcher.xml' +application-label-ar:'بريد K-9' +application-label-be:'Пошта K-9' +application-label-bg:'K-9 Поща' +application-label-ca:'K-9 Mail' +application-label-co:'K-9 Mail' +application-label-cs:'K-9 Mail' +application-label-cy:'K-9 Mail' +application-label-da:'K-9 Mail' +application-label-de:'K-9 Mail' +application-label-el:'K-9 Mail' +application-label-en-GB:'K-9 Mail' +application-label-en:'K-9 Mail' +application-label-eo:'K-9 Retpoŝtilo' +application-label-es:'K-9 Mail' +application-label-et:'K-9 Mail' +application-label-eu:'K-9 Mail' +application-label-fa:'نامهٔ کی۹' +application-label-fi:'K-9 Mail' +application-label-fr:'Courriel K-9' +application-label-fy:'K-9 Mail' +application-label-ga:'K-9 Post' +application-label-gl:'K-9 Mail' +application-label-hr:'K-9 Mail' +application-label-hu:'K-9 Mail' +application-label-in:'Surel K-9' +application-label-is:'K-9 - Póstur' +application-label-it:'K-9 Mail' +application-label-iw:'K-9 דוא\"ל' +application-label-ja:'K-9 Mail' +application-label-ko:'K-9 메일' +application-label-lt:'K-9 paštas' +application-label-lv:'K-9 pasts' +application-label-nb:'K-9 E-post' +application-label-nl:'K-9 Mail' +application-label-nn:'K-9 e-post' +application-label-pl:'K-9 Mail' +application-label-pt-BR:'K-9 Mail' +application-label-pt-PT:'K-9 Mail' +application-label-pt:'Email K-9' +application-label-ro:'K-9 Mail' +application-label-ru:'Почта K-9' +application-label-sk:'K-9 Mail' +application-label-sl:'Pošta K-9' +application-label-sq:'K-9 Mail' +application-label-sr:'K-9 Mail' +application-label-sv:'K-9 Mail' +application-label-tr:'K-9 Posta' +application-label-uk:'K-9 Mail' +application-label-vi:'Thư K-9' +application-label-zh-CN:'K-9 Mail' +application-label-zh-TW:'K-9 Mail' +application-label-zh:'K-9 Mail' +application-label:'K-9 Mail' +application: label='K-9 Mail' icon='res/drawable-v26/ic_launcher.xml' +densities: '120' '160' '240' '320' '480' '640' '65534' +feature-group: label='' +install-location:'auto' +launchable-activity: name='com.fsck.k9.activity.MessageList' label='' icon='' +locales: '--_--' 'ar' 'be' 'bg' 'ca' 'co' 'cs' 'cy' 'da' 'de' 'el' 'en' 'en-GB' 'eo' 'es' 'et' 'eu' 'fa' 'fi' 'fr' 'fy' 'ga' 'gl' 'hr' 'hu' 'in' 'is' 'it' 'iw' 'ja' 'ko' 'lt' 'lv' 'nb' 'nl' 'nn' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'sk' 'sl' 'sq' 'sr' 'sv' 'tr' 'uk' 'vi' 'zh' 'zh-CN' 'zh-TW' +main +minSdkVersion:'21' +native-code: 'arm64-v8a' 'armeabi-v7a' 'x86' 'x86_64' +other-activities +other-receivers +other-services +package: name='com.fsck.k9' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +property: name='android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE' value='This service is used to maintain a continuous connection to an IMAP server to be able to provide instant notifications to the user when a new email arrives. Firebase Cloud Messaging is not suitable for this task, neither are mechanisms like AndroidX WorkManager. Other foreground service types aren't a good fit for this use case.' +provides-component:'app-widget' +supports-any-density: 'true' +supports-screens: 'small' 'normal' 'large' 'xlarge' +targetSdkVersion:'35' +uses-feature-not-required: name='android.hardware.camera' +uses-feature-not-required: name='android.hardware.touchscreen' +uses-library-not-required:'androidx.window.extensions' +uses-library-not-required:'androidx.window.sidecar' +uses-library-not-required:'com.sec.android.app.multiwindow' +uses-permission: name='android.permission.ACCESS_NETWORK_STATE' +uses-permission: name='android.permission.CAMERA' +uses-permission: name='android.permission.FOREGROUND_SERVICE' +uses-permission: name='android.permission.FOREGROUND_SERVICE_DATA_SYNC' maxSdkVersion='33' +uses-permission: name='android.permission.FOREGROUND_SERVICE_SPECIAL_USE' +uses-permission: name='android.permission.INTERNET' +uses-permission: name='android.permission.POST_NOTIFICATIONS' +uses-permission: name='android.permission.READ_CONTACTS' +uses-permission: name='android.permission.READ_SYNC_SETTINGS' +uses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED' +uses-permission: name='android.permission.SCHEDULE_EXACT_ALARM' +uses-permission: name='android.permission.USE_BIOMETRIC' +uses-permission: name='android.permission.USE_FINGERPRINT' +uses-permission: name='android.permission.VIBRATE' +uses-permission: name='android.permission.WAKE_LOCK' +uses-permission: name='com.android.vending.BILLING' +uses-permission: name='com.fsck.k9.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION' diff --git a/app-k9mail/build.gradle.kts b/app-k9mail/build.gradle.kts new file mode 100644 index 0000000..eea1f07 --- /dev/null +++ b/app-k9mail/build.gradle.kts @@ -0,0 +1,175 @@ +plugins { + id(ThunderbirdPlugins.App.androidCompose) + alias(libs.plugins.dependency.guard) + id("thunderbird.app.version.info") + id("thunderbird.quality.badging") +} + +val testCoverageEnabled: Boolean by extra +if (testCoverageEnabled) { + apply(plugin = "jacoco") +} + +android { + namespace = "com.fsck.k9" + + defaultConfig { + applicationId = "com.fsck.k9" + testApplicationId = "com.fsck.k9.tests" + + versionCode = 39029 + versionName = "13.0" + + buildConfigField("String", "CLIENT_INFO_APP_NAME", "\"K-9 Mail\"") + } + + androidResources { + // Keep in sync with the resource string array "supported_languages" + localeFilters += listOf( + "ar", + "be", + "bg", + "ca", + "co", + "cs", + "cy", + "da", + "de", + "el", + "en", + "en-rGB", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "fy", + "ga", + "gl", + "hr", + "hu", + "in", + "is", + "it", + "iw", + "ja", + "ko", + "lt", + "lv", + "nb", + "nl", + "nn", + "pl", + "pt-rBR", + "pt-rPT", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "tr", + "uk", + "vi", + "zh-rCN", + "zh-rTW", + ) + } + + signingConfigs { + createSigningConfig(project, SigningType.K9_RELEASE, isUpload = false) + } + + buildTypes { + release { + signingConfig = signingConfigs.getByType(SigningType.K9_RELEASE) + + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro", + ) + } + + debug { + applicationIdSuffix = ".debug" + enableUnitTestCoverage = testCoverageEnabled + enableAndroidTestCoverage = testCoverageEnabled + + isMinifyEnabled = false + } + } + + flavorDimensions += listOf("app") + productFlavors { + create("foss") { + dimension = "app" + buildConfigField("String", "PRODUCT_FLAVOR_APP", "\"foss\"") + } + + create("full") { + dimension = "app" + buildConfigField("String", "PRODUCT_FLAVOR_APP", "\"full\"") + } + } + + packaging { + jniLibs { + excludes += listOf("kotlin/**") + } + + resources { + excludes += listOf( + "META-INF/*.kotlin_module", + "META-INF/*.version", + "kotlin/**", + "DebugProbesKt.bin", + ) + } + } +} + +dependencies { + implementation(projects.appCommon) + implementation(projects.core.ui.compose.theme2.k9mail) + implementation(projects.core.ui.legacy.theme2.k9mail) + implementation(projects.feature.launcher) + implementation(projects.feature.mail.message.list) + + implementation(projects.legacy.core) + implementation(projects.legacy.ui.legacy) + + implementation(projects.core.featureflag) + + implementation(projects.feature.account.settings.impl) + + "fossImplementation"(projects.feature.funding.noop) + "fullImplementation"(projects.feature.funding.googleplay) + implementation(projects.feature.migration.launcher.noop) + implementation(projects.feature.onboarding.migration.noop) + implementation(projects.feature.telemetry.noop) + implementation(projects.feature.widget.messageList) + implementation(projects.feature.widget.messageListGlance) + implementation(projects.feature.widget.shortcut) + implementation(projects.feature.widget.unread) + + implementation(libs.androidx.work.runtime) + + implementation(projects.feature.autodiscovery.api) + debugImplementation(projects.backend.demo) + debugImplementation(projects.feature.autodiscovery.demo) + + // Required for DependencyInjectionTest + testImplementation(projects.feature.account.api) + testImplementation(projects.feature.account.common) + testImplementation(projects.plugins.openpgpApiLib.openpgpApi) + testImplementation(libs.appauth) +} + +dependencyGuard { + configuration("fossReleaseRuntimeClasspath") + configuration("fullReleaseRuntimeClasspath") +} diff --git a/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt b/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt new file mode 100644 index 0000000..4297db7 --- /dev/null +++ b/app-k9mail/dependencies/fossReleaseRuntimeClasspath.txt @@ -0,0 +1,286 @@ +androidx.activity:activity-compose:1.10.1 +androidx.activity:activity-ktx:1.10.1 +androidx.activity:activity:1.10.1 +androidx.annotation:annotation-experimental:1.4.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.appcompat:appcompat-resources:1.7.1 +androidx.appcompat:appcompat:1.7.1 +androidx.arch.core:core-common:2.2.0 +androidx.arch.core:core-runtime:2.2.0 +androidx.autofill:autofill:1.3.0 +androidx.biometric:biometric:1.1.0 +androidx.browser:browser:1.3.0 +androidx.cardview:cardview:1.0.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.animation:animation-android:1.8.3 +androidx.compose.animation:animation-core-android:1.8.3 +androidx.compose.animation:animation-core:1.8.3 +androidx.compose.animation:animation:1.8.3 +androidx.compose.foundation:foundation-android:1.8.3 +androidx.compose.foundation:foundation-layout-android:1.8.3 +androidx.compose.foundation:foundation-layout:1.8.3 +androidx.compose.foundation:foundation:1.8.3 +androidx.compose.material3.adaptive:adaptive-android:1.1.0 +androidx.compose.material3.adaptive:adaptive-layout-android:1.1.0 +androidx.compose.material3.adaptive:adaptive-layout:1.1.0 +androidx.compose.material3.adaptive:adaptive-navigation-android:1.1.0 +androidx.compose.material3.adaptive:adaptive-navigation:1.1.0 +androidx.compose.material3.adaptive:adaptive:1.1.0 +androidx.compose.material3:material3-android:1.3.2 +androidx.compose.material3:material3:1.3.2 +androidx.compose.material:material-icons-core-android:1.7.8 +androidx.compose.material:material-icons-core:1.7.8 +androidx.compose.material:material-icons-extended-android:1.7.8 +androidx.compose.material:material-icons-extended:1.7.8 +androidx.compose.material:material-ripple-android:1.8.3 +androidx.compose.material:material-ripple:1.8.3 +androidx.compose.runtime:runtime-android:1.8.3 +androidx.compose.runtime:runtime-saveable-android:1.8.3 +androidx.compose.runtime:runtime-saveable:1.8.3 +androidx.compose.runtime:runtime:1.8.3 +androidx.compose.ui:ui-android:1.8.3 +androidx.compose.ui:ui-geometry-android:1.8.3 +androidx.compose.ui:ui-geometry:1.8.3 +androidx.compose.ui:ui-graphics-android:1.8.3 +androidx.compose.ui:ui-graphics:1.8.3 +androidx.compose.ui:ui-text-android:1.8.3 +androidx.compose.ui:ui-text:1.8.3 +androidx.compose.ui:ui-tooling-preview-android:1.8.3 +androidx.compose.ui:ui-tooling-preview:1.8.3 +androidx.compose.ui:ui-unit-android:1.8.3 +androidx.compose.ui:ui-unit:1.8.3 +androidx.compose.ui:ui-util-android:1.8.3 +androidx.compose.ui:ui-util:1.8.3 +androidx.compose.ui:ui:1.8.3 +androidx.compose:compose-bom:2025.07.00 +androidx.concurrent:concurrent-futures-ktx:1.1.0 +androidx.concurrent:concurrent-futures:1.1.0 +androidx.constraintlayout:constraintlayout-core:1.1.1 +androidx.constraintlayout:constraintlayout:2.2.1 +androidx.coordinatorlayout:coordinatorlayout:1.3.0 +androidx.core:core-ktx:1.16.0 +androidx.core:core-remoteviews:1.1.0 +androidx.core:core-splashscreen:1.0.1 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 +androidx.cursoradapter:cursoradapter:1.0.0 +androidx.customview:customview-poolingcontainer:1.0.0 +androidx.customview:customview:1.1.0 +androidx.datastore:datastore-core:1.0.0 +androidx.datastore:datastore-preferences-core:1.0.0 +androidx.datastore:datastore-preferences:1.0.0 +androidx.datastore:datastore:1.0.0 +androidx.documentfile:documentfile:1.0.0 +androidx.drawerlayout:drawerlayout:1.1.1 +androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2-views-helper:1.4.0 +androidx.emoji2:emoji2:1.4.0 +androidx.exifinterface:exifinterface:1.4.1 +androidx.fragment:fragment-compose:1.8.8 +androidx.fragment:fragment-ktx:1.8.8 +androidx.fragment:fragment:1.8.8 +androidx.glance:glance-appwidget-external-protobuf:1.1.1 +androidx.glance:glance-appwidget-proto:1.1.1 +androidx.glance:glance-appwidget:1.1.1 +androidx.glance:glance-material3:1.1.1 +androidx.glance:glance:1.1.1 +androidx.graphics:graphics-path:1.0.1 +androidx.interpolator:interpolator:1.0.0 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.9.2 +androidx.lifecycle:lifecycle-common-jvm:2.9.2 +androidx.lifecycle:lifecycle-common:2.9.2 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.2 +androidx.lifecycle:lifecycle-livedata-core:2.9.2 +androidx.lifecycle:lifecycle-livedata-ktx:2.9.2 +androidx.lifecycle:lifecycle-livedata:2.9.2 +androidx.lifecycle:lifecycle-process:2.9.2 +androidx.lifecycle:lifecycle-runtime-android:2.9.2 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.2 +androidx.lifecycle:lifecycle-runtime-compose:2.9.2 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.2 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.2 +androidx.lifecycle:lifecycle-runtime:2.9.2 +androidx.lifecycle:lifecycle-service:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.2 +androidx.lifecycle:lifecycle-viewmodel:2.9.2 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.1.0 +androidx.navigation:navigation-common-android:2.9.3 +androidx.navigation:navigation-common:2.9.3 +androidx.navigation:navigation-compose-android:2.9.3 +androidx.navigation:navigation-compose:2.9.3 +androidx.navigation:navigation-fragment:2.9.3 +androidx.navigation:navigation-runtime-android:2.9.3 +androidx.navigation:navigation-runtime:2.9.3 +androidx.navigation:navigation-ui:2.9.3 +androidx.preference:preference:1.2.1 +androidx.print:print:1.0.0 +androidx.profileinstaller:profileinstaller:1.4.1 +androidx.recyclerview:recyclerview:1.4.0 +androidx.resourceinspection:resourceinspection-annotation:1.0.1 +androidx.room:room-common:2.6.1 +androidx.room:room-ktx:2.6.1 +androidx.room:room-runtime:2.6.1 +androidx.savedstate:savedstate-android:1.3.1 +androidx.savedstate:savedstate-compose-android:1.3.1 +androidx.savedstate:savedstate-compose:1.3.1 +androidx.savedstate:savedstate-ktx:1.3.1 +androidx.savedstate:savedstate:1.3.1 +androidx.slidingpanelayout:slidingpanelayout:1.2.0 +androidx.sqlite:sqlite-framework:2.4.0 +androidx.sqlite:sqlite:2.4.0 +androidx.startup:startup-runtime:1.1.1 +androidx.swiperefreshlayout:swiperefreshlayout:1.1.0 +androidx.tracing:tracing-ktx:1.2.0 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.5.0 +androidx.vectordrawable:vectordrawable-animated:1.2.0 +androidx.vectordrawable:vectordrawable:1.2.0 +androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.viewpager2:viewpager2:1.1.0-beta02 +androidx.viewpager:viewpager:1.0.0 +androidx.webkit:webkit:1.14.0 +androidx.window.extensions.core:core:1.0.0 +androidx.window:window-core-android:1.3.0 +androidx.window:window-core:1.3.0 +androidx.window:window:1.3.0 +androidx.work:work-runtime-ktx:2.10.3 +androidx.work:work-runtime:2.10.3 +co.touchlab:stately-concurrency-jvm:2.1.0 +co.touchlab:stately-concurrency:2.1.0 +co.touchlab:stately-concurrent-collections-jvm:2.1.0 +co.touchlab:stately-concurrent-collections:2.1.0 +co.touchlab:stately-strict-jvm:2.1.0 +co.touchlab:stately-strict:2.1.0 +com.beetstra.jutf7:jutf7:1.0.0 +com.github.ByteHamster:SearchPreference:2.7.3 +com.github.bumptech.glide:annotations:4.16.0 +com.github.bumptech.glide:disklrucache:4.16.0 +com.github.bumptech.glide:gifdecoder:4.16.0 +com.github.bumptech.glide:glide:4.16.0 +com.github.skydoves:landscapist-android:2.5.1 +com.github.skydoves:landscapist-coil3-android:2.5.1 +com.github.skydoves:landscapist-coil3:2.5.1 +com.github.skydoves:landscapist:2.5.1 +com.google.android.flexbox:flexbox:3.0.0 +com.google.android.material:material:1.12.0 +com.google.errorprone:error_prone_annotations:2.15.0 +com.google.guava:listenablefuture:1.0 +com.jakewharton.timber:timber:5.0.1 +com.jcraft:jzlib:1.0.7 +com.mikepenz:fastadapter-extensions-drag:5.7.0 +com.mikepenz:fastadapter-extensions-expandable:5.7.0 +com.mikepenz:fastadapter-extensions-swipe:5.7.0 +com.mikepenz:fastadapter-extensions-utils:5.7.0 +com.mikepenz:fastadapter:5.7.0 +com.squareup.moshi:moshi:1.15.2 +com.squareup.okhttp3:okhttp:4.12.0 +com.squareup.okio:okio-jvm:3.16.0 +com.squareup.okio:okio:3.16.0 +com.takisoft.colorpicker:colorpicker:1.0.0 +com.takisoft.datetimepicker:datetimepicker:1.0.2 +com.takisoft.preferencex:preferencex-colorpicker:1.1.0 +com.takisoft.preferencex:preferencex-datetimepicker:1.1.0 +com.takisoft.preferencex:preferencex:1.1.0 +commons-io:commons-io:2.20.0 +de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 +de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 +de.hdodenhof:circleimageview:3.1.0 +io.coil-kt.coil3:coil-android:3.2.0 +io.coil-kt.coil3:coil-core-android:3.2.0 +io.coil-kt.coil3:coil-core:3.2.0 +io.coil-kt.coil3:coil-gif:3.2.0 +io.coil-kt.coil3:coil-network-core-android:3.2.0 +io.coil-kt.coil3:coil-network-core:3.2.0 +io.coil-kt.coil3:coil-network-okhttp-jvm:3.2.0 +io.coil-kt.coil3:coil-network-okhttp:3.2.0 +io.coil-kt.coil3:coil-video:3.2.0 +io.coil-kt.coil3:coil:3.2.0 +io.insert-koin:koin-android:4.1.0 +io.insert-koin:koin-androidx-compose:4.1.0 +io.insert-koin:koin-bom:4.1.0 +io.insert-koin:koin-compose-android:4.1.0 +io.insert-koin:koin-compose-viewmodel-android:4.1.0 +io.insert-koin:koin-compose-viewmodel:4.1.0 +io.insert-koin:koin-compose:4.1.0 +io.insert-koin:koin-core-jvm:4.1.0 +io.insert-koin:koin-core-viewmodel-android:4.1.0 +io.insert-koin:koin-core-viewmodel:4.1.0 +io.insert-koin:koin-core:4.1.0 +net.jcip:jcip-annotations:1.0 +net.openid:appauth:0.11.1 +org.apache.commons:commons-lang3:3.7 +org.apache.commons:commons-text:1.3 +org.apache.httpcomponents.client5:httpclient5:5.5 +org.apache.httpcomponents.core5:httpcore5-h2:5.3.4 +org.apache.httpcomponents.core5:httpcore5:5.3.4 +org.apache.james:apache-mime4j-core:0.8.13 +org.apache.james:apache-mime4j-dom:0.8.13 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.1 +org.jetbrains.androidx.savedstate:savedstate:1.3.1 +org.jetbrains.compose.animation:animation-core:1.8.2 +org.jetbrains.compose.animation:animation:1.8.2 +org.jetbrains.compose.annotation-internal:annotation:1.8.2 +org.jetbrains.compose.collection-internal:collection:1.8.2 +org.jetbrains.compose.components:components-resources-android:1.8.2 +org.jetbrains.compose.components:components-resources:1.8.2 +org.jetbrains.compose.components:components-ui-tooling-preview-android:1.8.2 +org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 +org.jetbrains.compose.foundation:foundation-layout:1.8.2 +org.jetbrains.compose.foundation:foundation:1.8.2 +org.jetbrains.compose.runtime:runtime-saveable:1.8.2 +org.jetbrains.compose.runtime:runtime:1.8.2 +org.jetbrains.compose.ui:ui-geometry:1.8.2 +org.jetbrains.compose.ui:ui-graphics:1.8.2 +org.jetbrains.compose.ui:ui-text:1.8.2 +org.jetbrains.compose.ui:ui-tooling-preview:1.8.2 +org.jetbrains.compose.ui:ui-unit:1.8.2 +org.jetbrains.compose.ui:ui-util:1.8.2 +org.jetbrains.compose.ui:ui:1.8.2 +org.jetbrains.kotlin:kotlin-android-extensions-runtime:2.2.0 +org.jetbrains.kotlin:kotlin-bom:2.2.0 +org.jetbrains.kotlin:kotlin-parcelize-runtime:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib:2.2.0 +org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0 +org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0 +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1 +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1 +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.0 +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.0 +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.0 +org.jetbrains.kotlinx:kotlinx-io-core:0.8.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0 +org.jetbrains:annotations:26.0.2 +org.jsoup:jsoup:1.19.1 +org.jspecify:jspecify:1.0.0 +org.minidns:minidns-client:1.1.1 +org.minidns:minidns-core:1.1.1 +org.minidns:minidns-dnssec:1.1.1 +org.minidns:minidns-hla:1.1.1 +org.minidns:minidns-iterative-resolver:1.1.1 +org.slf4j:slf4j-api:1.7.36 diff --git a/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt b/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt new file mode 100644 index 0000000..0b267e7 --- /dev/null +++ b/app-k9mail/dependencies/fullReleaseRuntimeClasspath.txt @@ -0,0 +1,300 @@ +androidx.activity:activity-compose:1.10.1 +androidx.activity:activity-ktx:1.10.1 +androidx.activity:activity:1.10.1 +androidx.annotation:annotation-experimental:1.4.1 +androidx.annotation:annotation-jvm:1.9.1 +androidx.annotation:annotation:1.9.1 +androidx.appcompat:appcompat-resources:1.7.1 +androidx.appcompat:appcompat:1.7.1 +androidx.arch.core:core-common:2.2.0 +androidx.arch.core:core-runtime:2.2.0 +androidx.autofill:autofill:1.3.0 +androidx.biometric:biometric:1.1.0 +androidx.browser:browser:1.3.0 +androidx.cardview:cardview:1.0.0 +androidx.collection:collection-jvm:1.5.0 +androidx.collection:collection-ktx:1.5.0 +androidx.collection:collection:1.5.0 +androidx.compose.animation:animation-android:1.8.3 +androidx.compose.animation:animation-core-android:1.8.3 +androidx.compose.animation:animation-core:1.8.3 +androidx.compose.animation:animation:1.8.3 +androidx.compose.foundation:foundation-android:1.8.3 +androidx.compose.foundation:foundation-layout-android:1.8.3 +androidx.compose.foundation:foundation-layout:1.8.3 +androidx.compose.foundation:foundation:1.8.3 +androidx.compose.material3.adaptive:adaptive-android:1.1.0 +androidx.compose.material3.adaptive:adaptive-layout-android:1.1.0 +androidx.compose.material3.adaptive:adaptive-layout:1.1.0 +androidx.compose.material3.adaptive:adaptive-navigation-android:1.1.0 +androidx.compose.material3.adaptive:adaptive-navigation:1.1.0 +androidx.compose.material3.adaptive:adaptive:1.1.0 +androidx.compose.material3:material3-android:1.3.2 +androidx.compose.material3:material3:1.3.2 +androidx.compose.material:material-icons-core-android:1.7.8 +androidx.compose.material:material-icons-core:1.7.8 +androidx.compose.material:material-icons-extended-android:1.7.8 +androidx.compose.material:material-icons-extended:1.7.8 +androidx.compose.material:material-ripple-android:1.8.3 +androidx.compose.material:material-ripple:1.8.3 +androidx.compose.runtime:runtime-android:1.8.3 +androidx.compose.runtime:runtime-saveable-android:1.8.3 +androidx.compose.runtime:runtime-saveable:1.8.3 +androidx.compose.runtime:runtime:1.8.3 +androidx.compose.ui:ui-android:1.8.3 +androidx.compose.ui:ui-geometry-android:1.8.3 +androidx.compose.ui:ui-geometry:1.8.3 +androidx.compose.ui:ui-graphics-android:1.8.3 +androidx.compose.ui:ui-graphics:1.8.3 +androidx.compose.ui:ui-text-android:1.8.3 +androidx.compose.ui:ui-text:1.8.3 +androidx.compose.ui:ui-tooling-preview-android:1.8.3 +androidx.compose.ui:ui-tooling-preview:1.8.3 +androidx.compose.ui:ui-unit-android:1.8.3 +androidx.compose.ui:ui-unit:1.8.3 +androidx.compose.ui:ui-util-android:1.8.3 +androidx.compose.ui:ui-util:1.8.3 +androidx.compose.ui:ui:1.8.3 +androidx.compose:compose-bom:2025.07.00 +androidx.concurrent:concurrent-futures-ktx:1.1.0 +androidx.concurrent:concurrent-futures:1.1.0 +androidx.constraintlayout:constraintlayout-core:1.1.1 +androidx.constraintlayout:constraintlayout:2.2.1 +androidx.coordinatorlayout:coordinatorlayout:1.3.0 +androidx.core:core-ktx:1.16.0 +androidx.core:core-remoteviews:1.1.0 +androidx.core:core-splashscreen:1.0.1 +androidx.core:core-viewtree:1.0.0 +androidx.core:core:1.16.0 +androidx.cursoradapter:cursoradapter:1.0.0 +androidx.customview:customview-poolingcontainer:1.0.0 +androidx.customview:customview:1.1.0 +androidx.datastore:datastore-core:1.0.0 +androidx.datastore:datastore-preferences-core:1.0.0 +androidx.datastore:datastore-preferences:1.0.0 +androidx.datastore:datastore:1.0.0 +androidx.documentfile:documentfile:1.0.0 +androidx.drawerlayout:drawerlayout:1.1.1 +androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2-views-helper:1.4.0 +androidx.emoji2:emoji2:1.4.0 +androidx.exifinterface:exifinterface:1.4.1 +androidx.fragment:fragment-compose:1.8.8 +androidx.fragment:fragment-ktx:1.8.8 +androidx.fragment:fragment:1.8.8 +androidx.glance:glance-appwidget-external-protobuf:1.1.1 +androidx.glance:glance-appwidget-proto:1.1.1 +androidx.glance:glance-appwidget:1.1.1 +androidx.glance:glance-material3:1.1.1 +androidx.glance:glance:1.1.1 +androidx.graphics:graphics-path:1.0.1 +androidx.interpolator:interpolator:1.0.0 +androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.9.2 +androidx.lifecycle:lifecycle-common-jvm:2.9.2 +androidx.lifecycle:lifecycle-common:2.9.2 +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.2 +androidx.lifecycle:lifecycle-livedata-core:2.9.2 +androidx.lifecycle:lifecycle-livedata-ktx:2.9.2 +androidx.lifecycle:lifecycle-livedata:2.9.2 +androidx.lifecycle:lifecycle-process:2.9.2 +androidx.lifecycle:lifecycle-runtime-android:2.9.2 +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.2 +androidx.lifecycle:lifecycle-runtime-compose:2.9.2 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.2 +androidx.lifecycle:lifecycle-runtime-ktx:2.9.2 +androidx.lifecycle:lifecycle-runtime:2.9.2 +androidx.lifecycle:lifecycle-service:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-android:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.2 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.2 +androidx.lifecycle:lifecycle-viewmodel:2.9.2 +androidx.loader:loader:1.0.0 +androidx.localbroadcastmanager:localbroadcastmanager:1.1.0 +androidx.navigation:navigation-common-android:2.9.3 +androidx.navigation:navigation-common:2.9.3 +androidx.navigation:navigation-compose-android:2.9.3 +androidx.navigation:navigation-compose:2.9.3 +androidx.navigation:navigation-fragment:2.9.3 +androidx.navigation:navigation-runtime-android:2.9.3 +androidx.navigation:navigation-runtime:2.9.3 +androidx.navigation:navigation-ui:2.9.3 +androidx.preference:preference:1.2.1 +androidx.print:print:1.0.0 +androidx.profileinstaller:profileinstaller:1.4.1 +androidx.recyclerview:recyclerview:1.4.0 +androidx.resourceinspection:resourceinspection-annotation:1.0.1 +androidx.room:room-common:2.6.1 +androidx.room:room-ktx:2.6.1 +androidx.room:room-runtime:2.6.1 +androidx.savedstate:savedstate-android:1.3.1 +androidx.savedstate:savedstate-compose-android:1.3.1 +androidx.savedstate:savedstate-compose:1.3.1 +androidx.savedstate:savedstate-ktx:1.3.1 +androidx.savedstate:savedstate:1.3.1 +androidx.slidingpanelayout:slidingpanelayout:1.2.0 +androidx.sqlite:sqlite-framework:2.4.0 +androidx.sqlite:sqlite:2.4.0 +androidx.startup:startup-runtime:1.1.1 +androidx.swiperefreshlayout:swiperefreshlayout:1.1.0 +androidx.tracing:tracing-ktx:1.2.0 +androidx.tracing:tracing:1.2.0 +androidx.transition:transition:1.5.0 +androidx.vectordrawable:vectordrawable-animated:1.2.0 +androidx.vectordrawable:vectordrawable:1.2.0 +androidx.versionedparcelable:versionedparcelable:1.1.1 +androidx.viewpager2:viewpager2:1.1.0-beta02 +androidx.viewpager:viewpager:1.0.0 +androidx.webkit:webkit:1.14.0 +androidx.window.extensions.core:core:1.0.0 +androidx.window:window-core-android:1.3.0 +androidx.window:window-core:1.3.0 +androidx.window:window:1.3.0 +androidx.work:work-runtime-ktx:2.10.3 +androidx.work:work-runtime:2.10.3 +co.touchlab:stately-concurrency-jvm:2.1.0 +co.touchlab:stately-concurrency:2.1.0 +co.touchlab:stately-concurrent-collections-jvm:2.1.0 +co.touchlab:stately-concurrent-collections:2.1.0 +co.touchlab:stately-strict-jvm:2.1.0 +co.touchlab:stately-strict:2.1.0 +com.android.billingclient:billing-ktx:7.1.1 +com.android.billingclient:billing:7.1.1 +com.beetstra.jutf7:jutf7:1.0.0 +com.github.ByteHamster:SearchPreference:2.7.3 +com.github.bumptech.glide:annotations:4.16.0 +com.github.bumptech.glide:disklrucache:4.16.0 +com.github.bumptech.glide:gifdecoder:4.16.0 +com.github.bumptech.glide:glide:4.16.0 +com.github.skydoves:landscapist-android:2.5.1 +com.github.skydoves:landscapist-coil3-android:2.5.1 +com.github.skydoves:landscapist-coil3:2.5.1 +com.github.skydoves:landscapist:2.5.1 +com.google.android.datatransport:transport-api:3.0.0 +com.google.android.datatransport:transport-backend-cct:3.1.8 +com.google.android.datatransport:transport-runtime:3.1.8 +com.google.android.flexbox:flexbox:3.0.0 +com.google.android.gms:play-services-base:18.5.0 +com.google.android.gms:play-services-basement:18.4.0 +com.google.android.gms:play-services-location:19.0.0 +com.google.android.gms:play-services-places-placereport:17.0.0 +com.google.android.gms:play-services-tasks:18.2.0 +com.google.android.material:material:1.12.0 +com.google.errorprone:error_prone_annotations:2.15.0 +com.google.firebase:firebase-encoders-json:18.0.0 +com.google.firebase:firebase-encoders-proto:16.0.0 +com.google.firebase:firebase-encoders:17.0.0 +com.google.guava:listenablefuture:1.0 +com.jakewharton.timber:timber:5.0.1 +com.jcraft:jzlib:1.0.7 +com.mikepenz:fastadapter-extensions-drag:5.7.0 +com.mikepenz:fastadapter-extensions-expandable:5.7.0 +com.mikepenz:fastadapter-extensions-swipe:5.7.0 +com.mikepenz:fastadapter-extensions-utils:5.7.0 +com.mikepenz:fastadapter:5.7.0 +com.squareup.moshi:moshi:1.15.2 +com.squareup.okhttp3:okhttp:4.12.0 +com.squareup.okio:okio-jvm:3.16.0 +com.squareup.okio:okio:3.16.0 +com.takisoft.colorpicker:colorpicker:1.0.0 +com.takisoft.datetimepicker:datetimepicker:1.0.2 +com.takisoft.preferencex:preferencex-colorpicker:1.1.0 +com.takisoft.preferencex:preferencex-datetimepicker:1.1.0 +com.takisoft.preferencex:preferencex:1.1.0 +commons-io:commons-io:2.20.0 +de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02 +de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0 +de.hdodenhof:circleimageview:3.1.0 +io.coil-kt.coil3:coil-android:3.2.0 +io.coil-kt.coil3:coil-core-android:3.2.0 +io.coil-kt.coil3:coil-core:3.2.0 +io.coil-kt.coil3:coil-gif:3.2.0 +io.coil-kt.coil3:coil-network-core-android:3.2.0 +io.coil-kt.coil3:coil-network-core:3.2.0 +io.coil-kt.coil3:coil-network-okhttp-jvm:3.2.0 +io.coil-kt.coil3:coil-network-okhttp:3.2.0 +io.coil-kt.coil3:coil-video:3.2.0 +io.coil-kt.coil3:coil:3.2.0 +io.insert-koin:koin-android:4.1.0 +io.insert-koin:koin-androidx-compose:4.1.0 +io.insert-koin:koin-bom:4.1.0 +io.insert-koin:koin-compose-android:4.1.0 +io.insert-koin:koin-compose-viewmodel-android:4.1.0 +io.insert-koin:koin-compose-viewmodel:4.1.0 +io.insert-koin:koin-compose:4.1.0 +io.insert-koin:koin-core-jvm:4.1.0 +io.insert-koin:koin-core-viewmodel-android:4.1.0 +io.insert-koin:koin-core-viewmodel:4.1.0 +io.insert-koin:koin-core:4.1.0 +javax.inject:javax.inject:1 +net.jcip:jcip-annotations:1.0 +net.openid:appauth:0.11.1 +org.apache.commons:commons-lang3:3.7 +org.apache.commons:commons-text:1.3 +org.apache.httpcomponents.client5:httpclient5:5.5 +org.apache.httpcomponents.core5:httpcore5-h2:5.3.4 +org.apache.httpcomponents.core5:httpcore5:5.3.4 +org.apache.james:apache-mime4j-core:0.8.13 +org.apache.james:apache-mime4j-dom:0.8.13 +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.1 +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.1 +org.jetbrains.androidx.savedstate:savedstate:1.3.1 +org.jetbrains.compose.animation:animation-core:1.8.2 +org.jetbrains.compose.animation:animation:1.8.2 +org.jetbrains.compose.annotation-internal:annotation:1.8.2 +org.jetbrains.compose.collection-internal:collection:1.8.2 +org.jetbrains.compose.components:components-resources-android:1.8.2 +org.jetbrains.compose.components:components-resources:1.8.2 +org.jetbrains.compose.components:components-ui-tooling-preview-android:1.8.2 +org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 +org.jetbrains.compose.foundation:foundation-layout:1.8.2 +org.jetbrains.compose.foundation:foundation:1.8.2 +org.jetbrains.compose.runtime:runtime-saveable:1.8.2 +org.jetbrains.compose.runtime:runtime:1.8.2 +org.jetbrains.compose.ui:ui-geometry:1.8.2 +org.jetbrains.compose.ui:ui-graphics:1.8.2 +org.jetbrains.compose.ui:ui-text:1.8.2 +org.jetbrains.compose.ui:ui-tooling-preview:1.8.2 +org.jetbrains.compose.ui:ui-unit:1.8.2 +org.jetbrains.compose.ui:ui-util:1.8.2 +org.jetbrains.compose.ui:ui:1.8.2 +org.jetbrains.kotlin:kotlin-android-extensions-runtime:2.2.0 +org.jetbrains.kotlin:kotlin-bom:2.2.0 +org.jetbrains.kotlin:kotlin-parcelize-runtime:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.0 +org.jetbrains.kotlin:kotlin-stdlib:2.2.0 +org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0 +org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0 +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2 +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1 +org.jetbrains.kotlinx:kotlinx-datetime:0.7.1 +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.0 +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.0 +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.0 +org.jetbrains.kotlinx:kotlinx-io-core:0.8.0 +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0 +org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0 +org.jetbrains:annotations:26.0.2 +org.jsoup:jsoup:1.19.1 +org.jspecify:jspecify:1.0.0 +org.minidns:minidns-client:1.1.1 +org.minidns:minidns-core:1.1.1 +org.minidns:minidns-dnssec:1.1.1 +org.minidns:minidns-hla:1.1.1 +org.minidns:minidns-iterative-resolver:1.1.1 +org.slf4j:slf4j-api:1.7.36 diff --git a/app-k9mail/proguard-rules.pro b/app-k9mail/proguard-rules.pro new file mode 100644 index 0000000..35075ad --- /dev/null +++ b/app-k9mail/proguard-rules.pro @@ -0,0 +1,64 @@ +# Add project specific ProGuard rules here. + +-dontobfuscate + +# Preserve the line number information for debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# Library specific rules +-dontnote android.net.http.* +-dontnote org.apache.commons.codec.** +-dontnote org.apache.http.** +-dontnote com.squareup.moshi.** +-dontnote com.github.amlcurran.showcaseview.** +-dontnote de.cketti.safecontentresolver.** +-dontnote com.tokenautocomplete.** + +-dontwarn okio.** +-dontwarn com.squareup.moshi.** + +# Glide +-keep public class * extends com.bumptech.glide.module.AppGlideModule +-keep public class * extends com.bumptech.glide.module.LibraryGlideModule +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { + **[] $VALUES; + public *; +} + +# Project specific rules +-dontnote com.fsck.k9.ui.messageview.** +-dontnote com.fsck.k9.view.** + +-assumevalues class * extends android.view.View { + boolean isInEditMode() return false; +} + +-keep public class org.openintents.openpgp.** + +-keepclassmembers class * extends androidx.appcompat.widget.SearchView { + public (android.content.Context); +} + +-keep class com.fsck.k9.mail.oauth.XOAuth2Response { *; } + +# okhttp rules +# see: https://github.com/square/okhttp/blob/master/okhttp/src/main/resources/META-INF/proguard/okhttp3.pro + +# JSR 305 annotations are for embedding nullability information. +-dontwarn javax.annotation.** + +# A resource is loaded with a relative path so the package of this class must be preserved. +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase + +# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java. +-dontwarn org.codehaus.mojo.animal_sniffer.* + +# OkHttp platform used only on JVM and when Conscrypt dependency is available. +-dontwarn okhttp3.internal.platform.ConscryptPlatform + +-dontwarn kotlinx.serialization.KSerializer +-dontwarn kotlinx.serialization.Serializable +-dontwarn org.apache.http.client.methods.CloseableHttpResponse +-dontwarn org.slf4j.impl.StaticLoggerBinder + +-keep,allowshrinking class com.tokenautocomplete.TokenCompleteTextView diff --git a/app-k9mail/src/debug/AndroidManifest.xml b/app-k9mail/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..689e9c6 --- /dev/null +++ b/app-k9mail/src/debug/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/app-k9mail/src/debug/kotlin/app/k9mail/auth/K9OAuthConfigurationFactory.kt b/app-k9mail/src/debug/kotlin/app/k9mail/auth/K9OAuthConfigurationFactory.kt new file mode 100644 index 0000000..38b617d --- /dev/null +++ b/app-k9mail/src/debug/kotlin/app/k9mail/auth/K9OAuthConfigurationFactory.kt @@ -0,0 +1,92 @@ +package app.k9mail.auth + +import com.fsck.k9.BuildConfig +import net.thunderbird.core.common.oauth.OAuthConfiguration +import net.thunderbird.core.common.oauth.OAuthConfigurationFactory + +@Suppress("ktlint:standard:max-line-length") +class K9OAuthConfigurationFactory : OAuthConfigurationFactory { + override fun createConfigurations(): Map, OAuthConfiguration> { + return mapOf( + createAolConfiguration(), + createFastmailConfiguration(), + createGmailConfiguration(), + createMicrosoftConfiguration(), + createYahooConfiguration(), + ) + } + + private fun createAolConfiguration(): Pair, OAuthConfiguration> { + return listOf( + "imap.aol.com", + "smtp.aol.com", + ) to OAuthConfiguration( + clientId = "dj0yJmk9cHYydkJkTUxHcXlYJmQ9WVdrOWVHZHhVVXN4VVV3bWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PTdm", + scopes = listOf("mail-w"), + authorizationEndpoint = "https://api.login.aol.com/oauth2/request_auth", + tokenEndpoint = "https://api.login.aol.com/oauth2/get_token", + redirectUri = "${BuildConfig.APPLICATION_ID}://oauth2redirect", + ) + } + + private fun createFastmailConfiguration(): Pair, OAuthConfiguration> { + return listOf( + "imap.fastmail.com", + "smtp.fastmail.com", + ) to OAuthConfiguration( + clientId = "353641ae", + scopes = listOf("https://www.fastmail.com/dev/protocol-imap", "https://www.fastmail.com/dev/protocol-smtp"), + authorizationEndpoint = "https://api.fastmail.com/oauth/authorize", + tokenEndpoint = "https://api.fastmail.com/oauth/refresh", + redirectUri = "${BuildConfig.APPLICATION_ID}://oauth2redirect", + ) + } + + private fun createGmailConfiguration(): Pair, OAuthConfiguration> { + return listOf( + "imap.gmail.com", + "imap.googlemail.com", + "smtp.gmail.com", + "smtp.googlemail.com", + ) to OAuthConfiguration( + clientId = "262622259280-5qb3vtj68d5dtudmaif4g9vd3cpar8r3.apps.googleusercontent.com", + scopes = listOf("https://mail.google.com/"), + authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint = "https://oauth2.googleapis.com/token", + redirectUri = "${BuildConfig.APPLICATION_ID}:/oauth2redirect", + ) + } + + private fun createMicrosoftConfiguration(): Pair, OAuthConfiguration> { + return listOf( + "outlook.office365.com", + "smtp.office365.com", + "smtp-mail.outlook.com", + ) to OAuthConfiguration( + clientId = "e647013a-ada4-4114-b419-e43d250f99c5", + scopes = listOf( + "openid", + "email", + "https://outlook.office.com/IMAP.AccessAsUser.All", + "https://outlook.office.com/SMTP.Send", + "offline_access", + ), + authorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token", + redirectUri = "msauth://com.fsck.k9.debug/VZF2DYuLYAu4TurFd6usQB2JPts%3D", + ) + } + + private fun createYahooConfiguration(): Pair, OAuthConfiguration> { + return listOf( + "imap.mail.yahoo.com", + "smtp.mail.yahoo.com", + ) to OAuthConfiguration( + clientId = "dj0yJmk9ejRCRU1ybmZjQlVBJmQ9WVdrOVVrZEViak4xYmxZbWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PTZj", + scopes = listOf("mail-w"), + authorizationEndpoint = "https://api.login.yahoo.com/oauth2/request_auth", + tokenEndpoint = "https://api.login.yahoo.com/oauth2/get_token", + redirectUri = "${BuildConfig.APPLICATION_ID}://oauth2redirect", + ) + } +} diff --git a/app-k9mail/src/debug/kotlin/app/k9mail/dev/DebugConfig.kt b/app-k9mail/src/debug/kotlin/app/k9mail/dev/DebugConfig.kt new file mode 100644 index 0000000..37ab465 --- /dev/null +++ b/app-k9mail/src/debug/kotlin/app/k9mail/dev/DebugConfig.kt @@ -0,0 +1,17 @@ +package app.k9mail.dev + +import app.k9mail.autodiscovery.api.AutoDiscovery +import app.k9mail.autodiscovery.demo.DemoAutoDiscovery +import com.fsck.k9.backend.BackendFactory +import org.koin.core.module.Module +import org.koin.core.qualifier.named + +fun Module.developmentModuleAdditions() { + single { DemoBackendFactory(backendStorageFactory = get()) } + single>(named("developmentBackends")) { + mapOf("demo" to get()) + } + single>(named("extraAutoDiscoveries")) { + listOf(DemoAutoDiscovery()) + } +} diff --git a/app-k9mail/src/debug/kotlin/app/k9mail/dev/DemoBackendFactory.kt b/app-k9mail/src/debug/kotlin/app/k9mail/dev/DemoBackendFactory.kt new file mode 100644 index 0000000..e0e5cec --- /dev/null +++ b/app-k9mail/src/debug/kotlin/app/k9mail/dev/DemoBackendFactory.kt @@ -0,0 +1,14 @@ +package app.k9mail.dev + +import app.k9mail.backend.demo.DemoBackend +import com.fsck.k9.backend.BackendFactory +import com.fsck.k9.backend.api.Backend +import com.fsck.k9.mailstore.K9BackendStorageFactory +import net.thunderbird.core.android.account.LegacyAccount + +class DemoBackendFactory(private val backendStorageFactory: K9BackendStorageFactory) : BackendFactory { + override fun createBackend(account: LegacyAccount): Backend { + val backendStorage = backendStorageFactory.createBackendStorage(account) + return DemoBackend(backendStorage) + } +} diff --git a/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt b/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt new file mode 100644 index 0000000..c1a641f --- /dev/null +++ b/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt @@ -0,0 +1,21 @@ +package app.k9mail.featureflag + +import net.thunderbird.core.featureflag.FeatureFlag +import net.thunderbird.core.featureflag.FeatureFlagFactory +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.toFeatureFlagKey + +class K9FeatureFlagFactory : FeatureFlagFactory { + override fun createFeatureCatalog(): List { + return listOf( + FeatureFlag("archive_marks_as_read".toFeatureFlagKey(), enabled = true), + FeatureFlag("new_account_settings".toFeatureFlagKey(), enabled = true), + FeatureFlag("disable_font_size_config".toFeatureFlagKey(), enabled = true), + FeatureFlag("email_notification_default".toFeatureFlagKey(), enabled = true), + FeatureFlag("enable_dropdown_drawer".toFeatureFlagKey(), enabled = true), + FeatureFlag("enable_dropdown_drawer_ui".toFeatureFlagKey(), enabled = true), + FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = true), + FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = true), + ) + } +} diff --git a/app-k9mail/src/debug/res/values/app_logo_colors.xml b/app-k9mail/src/debug/res/values/app_logo_colors.xml new file mode 100644 index 0000000..7939c36 --- /dev/null +++ b/app-k9mail/src/debug/res/values/app_logo_colors.xml @@ -0,0 +1,8 @@ + + + #5917ff + #7a45ff + #531ad8 + + #e3d9ff + diff --git a/app-k9mail/src/main/AndroidManifest.xml b/app-k9mail/src/main/AndroidManifest.xml new file mode 100644 index 0000000..222feb9 --- /dev/null +++ b/app-k9mail/src/main/AndroidManifest.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-k9mail/src/main/kotlin/app/k9mail/K9App.kt b/app-k9mail/src/main/kotlin/app/k9mail/K9App.kt new file mode 100644 index 0000000..a2ab33c --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/K9App.kt @@ -0,0 +1,8 @@ +package app.k9mail + +import net.thunderbird.app.common.BaseApplication +import org.koin.core.module.Module + +class K9App : BaseApplication() { + override fun provideAppModule(): Module = appModule +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/K9KoinModule.kt b/app-k9mail/src/main/kotlin/app/k9mail/K9KoinModule.kt new file mode 100644 index 0000000..958ae2a --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/K9KoinModule.kt @@ -0,0 +1,45 @@ +package app.k9mail + +import app.k9mail.auth.K9OAuthConfigurationFactory +import app.k9mail.dev.developmentModuleAdditions +import app.k9mail.feature.featureModule +import app.k9mail.feature.widget.shortcut.LauncherShortcutActivity +import app.k9mail.featureflag.K9FeatureFlagFactory +import app.k9mail.provider.providerModule +import app.k9mail.widget.widgetModule +import com.fsck.k9.AppConfig +import com.fsck.k9.BuildConfig +import com.fsck.k9.DefaultAppConfig +import com.fsck.k9.activity.MessageCompose +import com.fsck.k9.provider.UnreadWidgetProvider +import com.fsck.k9.widget.list.MessageListWidgetProvider +import net.thunderbird.app.common.appCommonModule +import net.thunderbird.core.common.oauth.OAuthConfigurationFactory +import net.thunderbird.core.featureflag.FeatureFlagFactory +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val appModule = module { + includes(appCommonModule) + + includes(widgetModule) + includes(featureModule) + includes(providerModule) + + single(named("ClientInfoAppName")) { BuildConfig.CLIENT_INFO_APP_NAME } + single(named("ClientInfoAppVersion")) { BuildConfig.VERSION_NAME } + single { appConfig } + single { K9OAuthConfigurationFactory() } + single { K9FeatureFlagFactory() } + + developmentModuleAdditions() +} + +val appConfig = DefaultAppConfig( + componentsToDisable = listOf( + MessageCompose::class.java, + LauncherShortcutActivity::class.java, + UnreadWidgetProvider::class.java, + MessageListWidgetProvider::class.java, + ), +) diff --git a/app-k9mail/src/main/kotlin/app/k9mail/feature/FeatureModule.kt b/app-k9mail/src/main/kotlin/app/k9mail/feature/FeatureModule.kt new file mode 100644 index 0000000..5fd16ed --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/feature/FeatureModule.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature + +import app.k9mail.feature.funding.api.FundingSettings +import app.k9mail.feature.funding.featureFundingModule +import app.k9mail.feature.migration.launcher.featureMigrationModule +import app.k9mail.feature.onboarding.migration.onboardingMigrationModule +import app.k9mail.feature.telemetry.telemetryModule +import net.thunderbird.feature.account.settings.featureAccountSettingsModule +import net.thunderbird.feature.mail.message.list.featureMessageListModule +import org.koin.dsl.module + +val featureModule = module { + includes(featureAccountSettingsModule) + includes(telemetryModule) + includes(featureFundingModule) + includes(onboardingMigrationModule) + includes(featureMigrationModule) + includes(featureMessageListModule) + + single { K9FundingSettings() } +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/feature/K9FundingSettings.kt b/app-k9mail/src/main/kotlin/app/k9mail/feature/K9FundingSettings.kt new file mode 100644 index 0000000..f539816 --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/feature/K9FundingSettings.kt @@ -0,0 +1,27 @@ +package app.k9mail.feature + +import app.k9mail.feature.funding.api.FundingSettings +import com.fsck.k9.K9 + +internal class K9FundingSettings : FundingSettings { + override fun getReminderReferenceTimestamp(): Long = K9.fundingReminderReferenceTimestamp + + override fun setReminderReferenceTimestamp(timestamp: Long) { + K9.fundingReminderReferenceTimestamp = timestamp + K9.saveSettingsAsync() + } + + override fun getReminderShownTimestamp() = K9.fundingReminderShownTimestamp + + override fun setReminderShownTimestamp(timestamp: Long) { + K9.fundingReminderShownTimestamp = timestamp + K9.saveSettingsAsync() + } + + override fun getActivityCounterInMillis(): Long = K9.fundingActivityCounterInMillis + + override fun setActivityCounterInMillis(activeTime: Long) { + K9.fundingActivityCounterInMillis = activeTime + K9.saveSettingsAsync() + } +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/provider/K9AppNameProvider.kt b/app-k9mail/src/main/kotlin/app/k9mail/provider/K9AppNameProvider.kt new file mode 100644 index 0000000..382eb20 --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/provider/K9AppNameProvider.kt @@ -0,0 +1,21 @@ +package app.k9mail.provider + +import android.content.Context +import com.fsck.k9.R +import com.fsck.k9.preferences.FilePrefixProvider +import net.thunderbird.core.common.provider.AppNameProvider +import net.thunderbird.core.common.provider.BrandNameProvider + +internal class K9AppNameProvider( + context: Context, +) : AppNameProvider, BrandNameProvider, FilePrefixProvider { + override val appName: String by lazy { + context.getString(R.string.app_name) + } + + override val brandName: String by lazy { + context.getString(R.string.app_name) + } + + override val filePrefix: String = "k9" +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/provider/K9FeatureThemeProvider.kt b/app-k9mail/src/main/kotlin/app/k9mail/provider/K9FeatureThemeProvider.kt new file mode 100644 index 0000000..8e0b3af --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/provider/K9FeatureThemeProvider.kt @@ -0,0 +1,21 @@ +package app.k9mail.provider + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.theme2.k9mail.K9MailTheme2 +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider + +internal class K9FeatureThemeProvider : FeatureThemeProvider { + @Composable + override fun WithTheme(content: @Composable () -> Unit) { + K9MailTheme2 { + content() + } + } + + @Composable + override fun WithTheme(darkTheme: Boolean, content: @Composable () -> Unit) { + K9MailTheme2(darkTheme = darkTheme) { + content() + } + } +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/provider/K9ThemeProvider.kt b/app-k9mail/src/main/kotlin/app/k9mail/provider/K9ThemeProvider.kt new file mode 100644 index 0000000..50b6c35 --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/provider/K9ThemeProvider.kt @@ -0,0 +1,12 @@ +package app.k9mail.provider + +import com.fsck.k9.R +import net.thunderbird.core.ui.theme.api.ThemeProvider + +internal class K9ThemeProvider : ThemeProvider { + override val appThemeResourceId = R.style.Theme_K9_DayNight + override val appLightThemeResourceId = R.style.Theme_K9_Light + override val appDarkThemeResourceId = R.style.Theme_K9_Dark + override val dialogThemeResourceId = R.style.Theme_K9_DayNight_Dialog + override val translucentDialogThemeResourceId = R.style.Theme_K9_DayNight_Dialog_Translucent +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/provider/ProviderModule.kt b/app-k9mail/src/main/kotlin/app/k9mail/provider/ProviderModule.kt new file mode 100644 index 0000000..07e2369 --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/provider/ProviderModule.kt @@ -0,0 +1,20 @@ +package app.k9mail.provider + +import com.fsck.k9.preferences.FilePrefixProvider +import net.thunderbird.core.common.provider.AppNameProvider +import net.thunderbird.core.common.provider.BrandNameProvider +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.core.ui.theme.api.ThemeProvider +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.binds +import org.koin.dsl.module + +internal val providerModule = module { + single { + K9AppNameProvider(androidContext()) + } binds arrayOf(AppNameProvider::class, BrandNameProvider::class, FilePrefixProvider::class) + + single { K9ThemeProvider() } + + single { K9FeatureThemeProvider() } +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/widget/K9MessageListWidgetConfig.kt b/app-k9mail/src/main/kotlin/app/k9mail/widget/K9MessageListWidgetConfig.kt new file mode 100644 index 0000000..f4538a1 --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/widget/K9MessageListWidgetConfig.kt @@ -0,0 +1,8 @@ +package app.k9mail.widget + +import app.k9mail.feature.widget.message.list.MessageListWidgetConfig +import com.fsck.k9.widget.list.MessageListWidgetProvider + +class K9MessageListWidgetConfig : MessageListWidgetConfig { + override val providerClass = MessageListWidgetProvider::class.java +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/widget/K9UnreadWidgetConfig.kt b/app-k9mail/src/main/kotlin/app/k9mail/widget/K9UnreadWidgetConfig.kt new file mode 100644 index 0000000..7b2ee87 --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/widget/K9UnreadWidgetConfig.kt @@ -0,0 +1,8 @@ +package app.k9mail.widget + +import app.k9mail.feature.widget.unread.UnreadWidgetConfig +import com.fsck.k9.provider.UnreadWidgetProvider + +class K9UnreadWidgetConfig : UnreadWidgetConfig { + override val providerClass = UnreadWidgetProvider::class.java +} diff --git a/app-k9mail/src/main/kotlin/app/k9mail/widget/WidgetModule.kt b/app-k9mail/src/main/kotlin/app/k9mail/widget/WidgetModule.kt new file mode 100644 index 0000000..0ea79d1 --- /dev/null +++ b/app-k9mail/src/main/kotlin/app/k9mail/widget/WidgetModule.kt @@ -0,0 +1,13 @@ +package app.k9mail.widget + +import app.k9mail.feature.widget.message.list.MessageListWidgetConfig +import app.k9mail.feature.widget.unread.UnreadWidgetConfig +import net.thunderbird.feature.widget.message.list.featureWidgetMessageListModule +import org.koin.dsl.module + +internal val widgetModule = module { + includes(featureWidgetMessageListModule) + + single { K9MessageListWidgetConfig() } + single { K9UnreadWidgetConfig() } +} diff --git a/app-k9mail/src/main/kotlin/com/fsck/k9/provider/UnreadWidgetProvider.kt b/app-k9mail/src/main/kotlin/com/fsck/k9/provider/UnreadWidgetProvider.kt new file mode 100644 index 0000000..36e15b4 --- /dev/null +++ b/app-k9mail/src/main/kotlin/com/fsck/k9/provider/UnreadWidgetProvider.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.provider + +import app.k9mail.feature.widget.unread.BaseUnreadWidgetProvider + +/** + * IMPORTANT: The fully qualified name for this class must be + * `com.fsck.k9.provider.UnreadWidgetProvider`. + * Otherwise widgets created with older versions of the app using a different name + * will stop working or disappear. + */ +class UnreadWidgetProvider : BaseUnreadWidgetProvider() diff --git a/app-k9mail/src/main/kotlin/com/fsck/k9/widget/list/MessageListWidgetProvider.kt b/app-k9mail/src/main/kotlin/com/fsck/k9/widget/list/MessageListWidgetProvider.kt new file mode 100644 index 0000000..65449f4 --- /dev/null +++ b/app-k9mail/src/main/kotlin/com/fsck/k9/widget/list/MessageListWidgetProvider.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.widget.list + +import app.k9mail.feature.widget.message.list.BaseMessageListWidgetProvider + +/** + * IMPORTANT: The fully qualified name for this class must be + * `com.fsck.k9.widget.list.MessageListWidgetProvider`. + * Otherwise widgets created with older versions of the app using a different name + * will stop working or disappear. + */ +class MessageListWidgetProvider : BaseMessageListWidgetProvider() diff --git a/app-k9mail/src/main/res/raw/changelog_master.xml b/app-k9mail/src/main/res/raw/changelog_master.xml new file mode 100644 index 0000000..a3d5fc4 --- /dev/null +++ b/app-k9mail/src/main/res/raw/changelog_master.xml @@ -0,0 +1,1543 @@ + + + + + + Sync logging duration is now limited to 24 hours + Client certificate was not displayed in SMTP settings + Outlook headers included unnecessary newlines when replying to a message + "Enable debug logging" did not provide verbose logging + Scrolling in a short email could trigger left/right swipe + Landscape scrolling only worked in center of Welcome and New Account screens + On IMAP servers with folder prefixes, some folder operations stopped working + Reverted word-wrapping change that broke HTML and table rendering + Application crashed when opening placeholder folder + + + Improved drawer navigation with a folder hierarchy + K-9 mail could crash when opening + + + Improve support for SMTPUTF8 and other UTF-8 email standards (RFC 6531, 6855) + Support Android's IME autofill for password managers when adding new account + Add support for avatars in account settings + Enable edge-to-edge support + New account settings page + Long words and links in email messages did not wrap properly + Swipe right to archive failed if the archive folder did not exist + Edit Text dialogs in Account Settings did not automatically request focus + Account Settings did not display account name + In Unified Inbox, tapping a non-main account email redirected to the main inbox + In dark system mode with light message theme, navigation bar was not clearly visible + Threaded View toggle did not automatically update the Threaded View display + Gmail prefixes were shown in the folder structure + Crash could occur when adding Gmail account after removing primary Gmail account + Application crashed on startup if left or right swipe gesture was set to 'None' + Crash could occur when clicking on a message thread + Unicode folder names were malformed when using server supporting UTF-8 + Crash occurred when attempting to access account general settings + Users had to manually configure Outlook IMAP and SMTP + + + Selected/read/unread message states were hard to distinguish visually + + + Improved drawer navigation with a folder hierarchy + Improve dark mode emails with algorithmic darkening + Welcome screen logo layout improved + Improved compatibility with some ISPs by using ehlo.thunderbird.net as the EHLO identifier + Selection now shown on active items by adjusting color priority + Folder settings now use switches instead of checkboxes + Adjust theming colors to improve contrast (message list improvements still outstanding) + Messages are now marked read by default when archiving + Font size config preference is removed in favor of OS and density settings + Account setup issues with outlook.com and hotmail.com resolved + Keyboard navigation bugs resolved + Star and attachment icon colors corrected + Paste of recipient lists into compose field now correctly parsed + Back button now returns to Drafts folder when editing a draft + Unified inbox appears when two or more accounts are configured + Dark and light mode switching issues resolved + Scroll indicator removed following review feedback + System navigation bar did not correctly blend with message content + "New Mail Notifications" setting did not persist + Navigation drawer "Show Unified inbox" setting did not work + Avoid crashes when selecting folders, fetching emails, adjusting accounts, or changing device orientation + Sharing multiple selected images from gallery only selected last image + TalkBack did not clearly announce contact picture tap action + Improved IMAP push stability when switching network connections + + + Attach all images when sharing from gallery, not just the last + Show the full changelog when it contains special characters + + + Server field pre-filled in manual account setup + Add a menu entry to empty the Spam folder + Introduce an option to use the system theme on Android 10 and older + Update PushService to run as a specialUse foreground service for Android 15 + Update Gmail OAuth client IDs to Thunderbird for Android + Preserve the <s> tag when sanitizing HTML content + Messages and star counts in the drawer update instantly + Folder names in the drawer are limited to two lines + Hide accounts setting now remembered across restarts + Account names in the drawer display as single lines when not set + Option to use text processing apps (like Translate) when selecting text + Recipient field now focused automatically when composing + Display the recipient's address instead of +1 and fix its size + Correct MIME type for .pkpass files + Accessibility improvements + Restart PushService after app update + Restrict displaying message search results to internal and system usages + Messages with one correspondent no longer showed "+1" incorrectly + Account email no longer duplicated in drawer header + + + Basic support for Android 15 + Add a link to the support article when signing in with Google + Account setup attempts email provider's autoconfig first, then falls back to ISPDB + Updated translations for multiple languages + The changelog now properly displays release versions + A wrong translation of the app name has been fixed + Dependencies have been updated to fix a couple of bugs + + + You can now support K-9 Mail development with financial contributions. + Account initials now use the display name + Account icons remain in the same position when selected + Help text linking to support page added for Gmail login issues + Unified inbox enabled only when multiple accounts are configured + Push service now starts reliably when expected + Correct default delete message action for QR-imported accounts. + Folder drawer updates properly on account configuration changes + + + We've fixed one of our top crashes to give you a more stable experience + + + Fixed crash when split screen mode is enabled + Updated translations + + + Fixed a crash when a message list swipe action was set to "None" + + + Changed a lot of internals to migrate to Material 3. Please note that the Material 3 UI is still in an early state. + Changed the order of toolbar actions in the message view to match the order in multi-select mode + Changed default value for "Fetch messages up to" to 128KiB + Fixed bug where toolbar icons were displayed in the wrong color after performing a swipe action + Fixed crash when cutting email addresses from a recipient field when composing a message + Fixed toolbar size when using a non-default system font size + Added Corsican, Korean, and Vietnamese translations + Updated translations + + + Fixed a crash when opening the screen to compose a message + + + Started converting the user interface to Material 3 + Added support for mailto: URI on NFC tag + Added experimental workaround for the app crashing in the background + Optimized some of the IMAP protocol interactions + Don't delay showing the notification when Push is active + Updated translations + + + Push: Notify user if permission to schedule exact alarms is missing + Renamed "Send client ID" setting to "Send client information" + IMAP: Added support for the \NonExistent LIST response attribute + IMAP: Issue EXPUNGE command after moving without MOVE extension + Updated translations; added Hebrew translation + + + Added DNSSEC support when looking for server settings during setup + Made a change to prevent some software keyboards from capitalizing/auto-correcting email addresses in account setup + Fixed a crash when a very long subject was used + Fixed displaying OAuth 2.0 error messages + Fixed rare crash when downloading an attachment + Added code to disallow line breaks in single line text inputs + Updated translations + + + New and improved account setup + Added option to return to the message list after marking a message as unread in the message view + Made it harder to accidentally trigger swipe actions in the message list screen + Improved screen reader experience in various places + IMAP: Added support for sending the ID command (that is required by some email providers) + Various other bug fixes and improvements; see changes for versions 6.7xx + Removed Hebrew and Korean translations because of how incomplete they were; volunteer translators welcome! + + + Fixed order of autoconfig discovery steps + Changed default display count to 100 + Enabled notifications for new accounts by default + Reverted the change to handling mailto: URIs introduced in K-9 Mail 6.716 because of unintended side effects + Fixed bug to make "colorize contacts" work again in message view + + + Small bug fixes and UI/UX polish in account setup + Fixed a crash caused by an interaction with OpenKeychain 6.0.0 + Focus on subject or body input fields when opening the compose screen via a mailto: URI + Removed Hebrew and Korean translations because less than 70% of strings were translated. Volunteers to update the translations are always welcome! + Updated remaining translations + + + Added image handling within the context menu for hyperlinks + Added ability to forward opened attachments to new emails + Improved certificate error screen during account setup + Improved settings import during account setup + Improved account setup UI/UX + Improved account setup rendering on small screens + Improved edit account server settings UI/UX + Fixed AlarmManager crash on Android 14 + Internal changes + Updated translations + + + Added screen to configure special folders during account setup + Added copy action to recipient dropdown in compose screen + Improved error messages during account setup + Internal changes + Updated translations + + + Note: For now please manually allow "alarms & reminders" in Android's app settings when using Push on Android 14 + Fixed crash when Push was enabled on Android 14 + IMAP: Improved handling of the response to the MOVE command + + + New server settings screens are now also used when editing an existing account + Made it harder to accidentally trigger swipe actions in the message list screen + Added option to return to the message list after marking a message as unread in the message view + Combined settings "Return to list after delete" and "Show next message after delete" into "After deleting or moving a message" + Added screen to ask for Android permissions in the onboarding flow + Added support for the IMAP MOVE extension + Tweaked setup theme to more closely match the rest of the app + Fixed notification sounds on WearOS devices + Fixed crash when entering an unsupported email address during account setup + Fixed crash when MX lookup during account setup returns an invalid host name + Respect app theme and language settings in new account setup + Set hints for software keyboards not to use auto-correct in server settings text fields + Updated translations + + + Simplified the app icon so it can be a vector drawable + Improved screen reader experience in various places + Improved logging on failing settings import + Improved display of some HTML messages + Changed background color in message view and compose screens when using dark theme + Fixed OAuth 2.0 with Yahoo and AOL + Fixed display issues when rendering a message/rfc822 inline part + Updated translations + + + Fixed bug where accounts using OAuth weren't set up properly in K-9 Mail 6.709 + Moved "Show only subscribed folders" setting to "Folders" section + + + Enabled the new and improved account setup flow + + + Changed compose icon in the message list widget to match the icon inside the app + Added information about the read state of a message to the data read by screen readers + A URI pasted when composing a message will now be surrounded by angle brackets + Improved error handling in many situations + Fixed bug where account name wasn't displayed in the message view when it should + Fixed inconsistent behavior when replying to messages + Fixed bug when saving a draft message using an identity without a display name + Fixed display issue when removing an account + Quite a few internal changes + Updated translations + + + Fixed bug where navigating to a different screen after using the system back button/gesture could crash the app + + + Fixed a bug that lead to folders appearing to be empty + Don't use nickname as display name when auto-completing recipient using the nickname + + + Adding to contacts should now allow you again to add the email address to an existing contact + Fixed display issue with recipients in message view screen + Fixed appearance of "Sign in with Google" button + Fixed bugs with importing and exporting identities + Manually entering names containing non-latin characters is now possible when composing messages + The app will no longer ask to save a draft when no changes have been made to an existing draft message + Updated translations + + + Changed primary action in message view header from "reply all" to "reply" + Fixed the app so it runs on devices that don't support home screen widgets + Fixed bug where "Cannot connect to crypto provider" was displayed when the problem wasn't the crypto provider + Updated translations + + + Internal changes + Updated translations + + + Fixed crash when the compose screen goes to the background, e.g. when adding an attachment + Updated translations + + + IMAP: Added support for sending the ID command (that is required by some email providers) + Updated translations + + + Don't attempt to open file: URIs in an email; tapping such a link will now copy the URL to the clipboard instead + Fixed a crash when trying to export accounts containing an identity without a sender name + Improved logging when exporting settings to a file fails + Fixed various crashes + Updated translations + + + Fixed settings import/export of identities without a name or description + + + Change primary action in message view header from "reply all" to "reply" + + + Reverted changes to home screen widgets because apparently not every device vendor has incorporated the relevant fixes in Android 12+ + + + Redesigned the message view screen; tap the message header containing sender/recipient names to see more details + Added a setting for three different message list densities: compact, default, relaxed + Added better support for right-to-left languages when composing messages + Search now also considers recipient addresses + Fixed a bug where notifications would sometimes reappear shortly after having been dismissed + IMAP: Fixed a bug where sometimes authentication errors were silently ignored + Various other small bug fixes and improvements; see changes for versions 6.5xx + + + Swiping between messages should now work more reliably + Fixed message details layout when contact pictures are disabled + Fixed disappearing toolbar buttons in the message view screen + Fixed a couple of rare crashes + Updated translations + + + Made small changes to the appearance of the message list + Added a font size setting for the account name when viewing a message + Use colored navigation bar on Android 8.1 and above + Fixed status bar color on Android 5 + IMAP: Fixed a bug where sometimes an authentication error was silently ignored + Updated translations + + + Cleaned up the message list design + Added setting for three different message list densities: compact, default, relaxed + Made small changes to the appearance of participant entries in the message details screen + Added validation for email address fields in "Edit identity" screen + Updated translations + + + Removed "Send…" submenu from the message view toolbar + Limit summary notification actions to messages for which individual notifications are currently being displayed + Fixed crash when trying to download an embedded image not using an http(s) URI + Internal changes + Updated translations + + + Respect "show contact names" setting in message details screen + Display folder name in message details screen + Small adjustment to the appearance of the "Download complete message" button + Internal changes + Updated translations + + + Fixed a crash when displaying a message without any recipients + Fetch BCC header when partially downloading a message + + + Redesign of the message view screen + Internal changes + + + POP3: Fixed bug that lead to messages not being deleted from the server + Fixed a crash when a message is deleted (remotely) while it is being dragged in the message list + Avoid crash when opening drafts from the message list widget + + + Tweaked theme colors + Added support for HTML messages using the <base> tag and relative links + Fixed the logic to skip the trash folder when deleting messages, e.g. when deleting spam + Fixed bug that could lead to messages not being marked as read on the server when they should have been + Internal changes + + + Fixed crash at app startup + + + Delete spam messages immediately without moving them to the trash folder + Changed the way home screen widgets are disabled when there is no account set up to work around a bug in Android versions prior to 12 + Mark recent changes as read when the snackbar is dismissed via swipe + + + The light and dark themes are now based on Material Design 2. This is still work in progress. + Added a floating compose button to the message list screen + Search now also considers recipient addresses + Always move to previous/next message when swiping left/right outside of the message body + Lists of folders are now sorted alphabetically in account settings + Added better support for right-to-left languages when composing messages + When sending a message, the associated draft message will be deleted immediately, bypassing the Trash folder + mailto:, matrix:, and xmpp: URIs in plain text messages are now turned into links + Fixed a bug where notifications would sometimes reappear shortly after having been dismissed + Various other bug fixes + + + Added swipe actions to the message list screen + Added support for swiping between messages + Added a monochromatic app icon for Android 13 + Fixed "K-9 Accounts" shortcuts (please remove existing shortcuts and add them again) + Fixed error reporting for (old) send failures + A lot of other bug fixes; see changes for versions 6.3xx + Added Western Frisian translation and updated others + + + Tweaked swipe actions in the message list screen + Fixed a bug where canceling account setup was showing a new account in the side drawer + Updated translations + + + Don't unexpectedly show and focus the "reply to" field when composing a message + Fixed a bug where sometimes toolbar buttons in the message view would affect another message than the one currently being displayed + Changed the way the app switches to the next/previous message to avoid a bug that could lead to the toolbar disappearing + Fall back to using IPv4 if connecting to a POP3 server using IPv6 fails + SMTP: Stop treating all TLS errors as certificate error + Added more (local) logging when creating and removing notifications + Fixed a couple of rare crashes + + + Fixed "K-9 Accounts" shortcuts (you probably have to remove existing shortcuts and add them again) + Fixed a couple of bugs and display issues in the message list widget + Fixed bug that could lead to a crypto provider popup showing before swiping to the next/previous message was completed + Some other minor bug fixes + Updated translations + + + Automatically scroll to the top after using pull to refresh, so new messages are visible + Fixed a bug that prevented the app from establishing a connection to the outgoing server under certain circumstances + Fixed a bug that could lead to messages being sent twice when the send button was double clicked + Changed the structure of HTML signatures in outgoing messages to increase compatibility + More bug fixes and internal changes + Updated translations + + + Added swipe actions to the message list screen + Changed the minimum size of the message list widget to 2x2 cells + Fixed a bug where the app would start using the light theme before switching to the dark theme resulting in a brief flash of white + More minor bug fixes and improvements + Updated translations + + + Fixed the message list background color in the dark theme + Fixed a small display issue where "Load up to X more" could have been displayed when it shouldn't have been + Updated translations + + + Added a monochromatic app icon for Android 13 + Changed the UI component used for the message list; now changes to the list will be animated + Removed the volume key navigation for list views because it doesn't play nice with the above change + Fixed a bug that lead to the search input field being focused on app start on some devices + Restored the previous behavior of "show next message after delete" + A lot of internal changes and some minor performance improvements + + + Fixed a bug that could lead to a crash when opening the message list + + + Allow unmasking the password field without authentication as soon as the original password in incoming/outgoing server settings has been overwritten completely + Don't crash when IMAP servers send attachment names that contain non-ASCII characters + Fixed a bug where the search input field would collapse back into its toolbar icon while it was being used + Removed support for Direct Share because the app can no longer use the number of times a person has been contacted to make useful suggestions + More internal changes + + + Fixed a bug that lead to search being broken + Fixed error reporting for (old) send failures + Fixed "strip signatures on reply" + Fixed a crash when tapping a toolbar action in message view before loading the message has finished + + + Fixed moving to next/previous message when sorting the message list by read/unread or starred/unstarred + Fixed a crash when a third-party app shared a file to K-9 Mail without granting access to it + Keep some more attributes when sanitizing HTML + A lot of internal changes and improvements + Updated translations + + + Fixed crash when viewing a message and OpenKeychain needed to display its user interface, e.g. to ask for a password + When composing a message containing consecutive spaces convert them to non-breaking spaces in the generated HTML part of the message + Fixed bug where moving a message to another folder wouldn't update the apps search index + Fixed small bug where not all cached values were removed when using "clear local messages" + Updated translations + + + Added support for swiping between messages + Fixed multiple bugs when there are notifications for more than 8 messages + Fixed bug that could lead to broken attachment names when large messages were only partially downloaded (IMAP) + Added Western Frisian translation + + + Increased timeout when sending messages because some users have reported problems with sending large attachments + Allow all URI schemes in HTML links + Fixed display bug with search in general settings + When composing messages don't hide Cc and Bcc fields if they contain incomplete email addresses + + + Added support for using OAuth 2.0 with Office365 accounts + Fixed a bug that could lead to two message lists being displayed on top of each other + Avoid a crash when trying to create new message notifications but the notification sound couldn't be accessed + Fixed a bug where multi-select mode was exited early in some cases + Don't require re-authorization when getting an OAuth token fails due to a temporary error + Updated translations + + + Added support for using OAuth 2.0 with Google, Yahoo, AOL, and personal Microsoft accounts (Office365 accounts are not supported yet) + Added "Unsubscribe" action that is displayed in the menu when viewing a message that contains an Unsubscribe header + Various bug fixes and improvements + Updated translations + + + Added support for using OAuth 2.0 with personal Microsoft accounts (Office365 accounts don't work yet) + Made the transition of existing Gmail accounts to OAuth 2.0 a bit easier + Fixed various small UI bugs + Updated translations + + + Added support for using OAuth 2.0 with Google accounts + Added support for using OAuth 2.0 with Yahoo and AOL accounts + Added "Unsubscribe" action that is displayed in the menu when viewing a message that contains an Unsubscribe header + Fixed a bug where unrelated notifications where cleared after synchronizing a folder containing no unread messages + Fixed rare problem with decoding format=flowed messages + Various other small bug fixes and improvements + Updated translations + + + Added support for setting the notification vibration pattern on Android 8+ + Added support for setting a custom notification light color on Android 8+ + Added a setting to configure the notification sound on Android 8+ (because some vendor-specific Android versions removed this feature from their user interface) + Restore 'new mail' notifications when the app is restarted + Open message from notification in Unified Inbox if possible + Hide sensitive information when sync and error notifications are displayed on the lock screen + Added a setting to suppress notifications for chat messages + Don't create notifications when manually refreshing the message list + Don't show notifications for new messages when syncing a folder for the first time + Removed the "hide subject in notifications" setting; use the "lock screen notifications" setting instead + Fixed back button behavior when opening a single message from a notification + A lot of other fixes related to notifications + Added support for entering Reply-To addresses when composing a message + Optionally show starred message count in side drawer + Require the user to authenticate before unmasking server passwords + Added a menu option to export the debug log under Settings → General settings → Debugging + IMAP: Removed setting to enable remote search (it's now enabled by default; still requires an additional button press) + Numerous other bug fixes and improvements (see change log entries for 5.9xx) + + + Fixed bug that crashed the app when setting a notification sound on certain devices + Fixed crash when rotating the device while a delete confirmation dialog was showing + + + Fixed settings import + Fixed bug where the configuration of a notification category wasn't properly synced with in-app notification settings + Fixed display issues when switching the language inside the app + Updated translations + + + The name and description of notification categories are now updated when the app language is changed + Fixed bug where notification settings might not have been properly restored on settings import + Fixed a crash when tapping the unread widget tries to open a folder that no longer exists + Updated translations + + + Don't create notifications when manually refreshing the message list + Fixed import and export of notification settings + Fixed bug that could lead to multiple notifications being created for the same message + Fixed bug that sometimes crashed the app after changing the notification sound + Added support for messages that specified the character set in multiple ways + A lot of internal improvements + Updated translations + + + Fixed bugs where notifications weren't removed when they should have been + Added a setting to configure the notification sound on Android 8+ (because some vendor-specific Android versions removed this feature from their user interface) + Automatically recover in situations where messages can't be sent because the Outbox folder is missing + Added more logging around 'new message' notifications + Always prompt which app to use when sharing links + Removed the the button bar in the drawer and switched back to the sticky footer + Don't expose MessageProvider to third-party apps + Updated the app to target the Android 12 API + + + Reworked the user interface for the vibration pattern setting + Reworked the notification light color setting + Improved display of accounts without a name + Tapping a "send failure" notification will now open the Outbox folder + Fixed bug that led to some messages not being displayed properly + Fixed various smaller bugs related to notifications + Changed the way settings are stored; this might fix the bug where all accounts are lost + + + POP3: Changed the way the list of supported authentication methods is retrieved from the server + IMAP: Removed setting to enable remote search (it's now enabled by default; still requires an additional button press) + Changed the default color for registered contacts to provide good contrast with both light and dark themes + Fixed search by sender name + Ignore spaces at the end of the username inserted by some software keyboards when using auto-completion + + + Only remove notifications for messages currently being displayed + Open message from notification in Unified Inbox if possible + Fixed stale message count for the Outbox folder + Fixed search field not working properly when using a hardware keyboard + Updated translations + + + Fixed a bug where sometimes the app created notifications when downloading old messages + Fixed settings import so the imported theme is applied right away + Updated translations + + + Added support for setting the notification vibration pattern on Android 8+ + Added support for setting a custom notification light color on Android 8+ + Fixed back button behavior when opening a single message from a notification + Improved behavior when clicking grouped notifications for new messages of an account + Hide sensitive information when sync and error notifications are displayed on the lock screen + The account color is now used for sync and error notifications + Hide notification vibration settings when there's no vibrator hardware + Fixed hotkey handling when using non-QWERTY keyboard layouts + Updated translations + + + Set the auto-expand folder to 'Inbox' by default + Restore 'new mail' notifications when the app is restarted + Switched ringtone picker so custom notification sounds can be supported (Android 7 and older) + Fixed bug where vibration for notifications couldn't be disabled (Android 7 and older) + Updated translations + + + Set subject when forwarding a message as attachment + Added a menu option to export the debug log under Settings → General settings → Debugging + Don't display "unread count" in notifications; only number of new messages + Properly decode multiple encoded words using the ISO-2022-JP charset (e.g. in subjects) + Fixed a crash when users left the message view screen before the download of an image attachment was complete + Don't crash when loading images without internet permission (could only happen with custom ROMs) + Fixed a rare crash when auto-completing email addresses in the compose screen + A lot of internal changes and cleanup + Updated translations + + + Tweaked toolbar in move/copy screens + Fixed a bug where line breaks were added when editing a draft + Fixed animation when switching accounts + Fixed crash that could occur when deleting an account + Updated translations + + + Removed the "default account" setting; the first account in the list is now automatically the default account + Removed the "hide subject in notifications" setting; use the "lock screen notifications" setting instead + Fixed bug where the list of displayed folders wasn't updated when a folder was added or removed from the server + Fixed lock screen notification when there's only one new message + Fixed bug that lead to newly created accounts not having an account color + + + Fixed missing notification sound + + + Added support for entering Reply-To addresses when composing a message + Display the unread message count for the Unified Inbox in the drawer + Fixed crash when using the split-screen view + Hide notification category settings on Android versions that don't support them + Update the sync notification for an account instead of adding and removing it for each folder + A lot of internal changes to the notification code - please report any bugs or odd behavior you encounter + + + Experimental: Added a toolbar with a "check mail" button to the navigation drawer + Experimental: Tweaked the mark as unread toolbar icon + Don't show notifications for new messages when syncing a folder for the first time + Optionally show starred message count in navigation drawer + Require the user to authenticate before unmasking server passwords + Properly decode format=flowed body before including the text in a reply + Added a setting to suppress notifications for chat messages + Small change to the user interface for searching messages + Make sure attachment box isn't cut off on small screens + Updated translations + + + Fixed the check for missing outgoing server credentials, again… hopefully 🤞 + + + Fixed the check for missing incoming/outgoing server credentials (introduced in K-9 Mail 5.804) + Changed the 'save attachment' icon (apparently floppy disks are no longer a thing) + + + Fixed a bug where Push didn't work with some servers + Don't connect to the incoming or outgoing server when passwords haven't been provided after import + Added missing scrollbars in screens showing the folder list + Tapping the app icon should now always bring the app to the foreground instead of adding another message list screen + Updated translations + + + Don't show the icon for the ongoing Push notification in the status bar (on versions older than Android 8.0) + Directly open system settings for notification categories (Android 8.0 and newer) + Fixed a bug where a notification wasn't removed when the associated message was deleted + Ignore unnecessary spaces when filtering the list of folders + Fixed crypto status icons not being clickable + Updated translations + + + Fixed a bug that was triggered when Push was enabled, but the server didn't support Push. If you've noticed high battery drain, this was probably it + Added support for archive and spam actions in the Unified Inbox + Fixed notification sounds/vibration. Sometimes notifications were audible when they shouldn't have been, sometimes the other way around + Display the star of starred messages in yellow + Don't show archive/spam action when no such folder is configured + Fixed a crash when saving a draft message with modified signature text + Fixed a crash when refreshing attachment previews + + + Tweaked the default font sizes + The name of the currently selected account is displayed in the top bar below the folder name (when using multiple accounts) + When selecting an account in the drawer the auto-expand folder is now displayed right away + Added support for user-installed CAs + Error notifications now contain the full error text + Fixed some crashes + Updated translations + + + Major redesign of the user interface + Changed periodic background sync and push implementations to work much more reliably + Deprecated support for the WebDAV protocol; new accounts can no longer be added + K-9 Mail now requires Android 5.0 and newer + Added support for Autocrypt setup message + Added support for encrypted subjects + Don't use default signature when setting up a new account + Allow installation on external media (Android 6+ with adopted storage) + Removed keyboard shortcuts for menu items; on some devices they conflicted with system shortcuts + Removed the broken option to store the message database on external storage + Removed the 'remote control' interface third-party apps could use to change some settings in K-9 Mail + A lot of other bug fixes, internal changes, and improvements + Check out the changelog entries for 5.7xx versions for a lot more details + + + Updated translations + + + Added an info screen when tapping the Push notification + Fixed a couple of crashes related to Push + Various small user interface fixes + Updated translations + + + Fixed bug that could prevent the device from going to sleep when Push was active + Fixed bug that lead to Push not being restarted when network connectivity returned + Fixed some issues with displaying the "What's new" notice + + + Inline attachments are now included in forwarded messages + Quick access to the "What's new" screen when the app was updated + Close and reopen Push connections when the active network has changed + Fixed a bug that lead to Push not working properly + Fixed some minor bugs + + + Fixed a bug in the network connectivity detection code for Push + + + Fixed Push-related crash with non-IMAP accounts + Don't show Push-related settings for non-IMAP accounts + + + Added back support for IMAP IDLE (Push) + Show unread count on account list in drawer + Added 'search everywhere' to menu when displaying results of a search in a single folder + Long-pressing the subject when viewing a message now copies the text to the clipboard + Removed "Notification opens unread messages" setting + Honor expunge policy when deleting messages without Trash folder (IMAP) + Fixed multiple bugs related to remote search (IMAP) + Fixed instances where the system language instead of the app language was used for some texts + Fixed bug that prevented forwarding encrypted attachments + Fixed various crashes + Updated translations + + + Added support for reordering accounts (in the settings screen) + Added the ability to discard changes and keep the original draft + Preserve whitespace when converting the message signature to HTML + Ignore invalid email addresses in the system contacts database + Clear message view when changing folders in split-screen mode + Small optimizations to improve the app startup time + Fixed crash when trying to compose a message from the message list widget + Lots of internal changes – please report any bugs or odd behavior you encounter + Updated translations + + + Added support for In-Reply-To parameter in mailto: URIs + Fixed various display bugs related to the drawer + Updated translations + + + Fixed a crash when checking all accounts from the drawer + Fixed a crash when configuring the outgoing server to not require sign-in + Fixed a bug that prevented importing a settings file not containing general settings + Fixed many issues related to the drawer + Fixed a UI glitch when deleting an account + Fixed a UI glitch when dismissing the delete confirmation dialog from a notification + Updated translations + + + Fixed display bug when opening Unified Inbox from a launcher shortcut + Fixed functionality to download and save linked images on Android 10+ + Moved 'show headers' functionality to a separate screen + Added save button to 'edit identity' screen + Added translation: Malayalam + + + Fixed bug where account wouldn't refresh automatically + Fixed crash when trying to compose a message + Fixed bug that could have lead to an empty email being sent + Fixed bug where refresh indicator in drawer disappeared too early + Back button now opens Unified Inbox before exiting the app + Added translations: Belarusian, British English + + + Fixed a bug that could crash the app at startup + + + Added support for using a client certificate and a password at the same time + Unified Inbox is now displayed on top of the folder list in the drawer + Improved the sort order of email addresses in the auto-complete popup + Fixed a bug when parsing recipient email addresses in the "compose" screen + Fixed bug where the folder list wasn't fetched from the server + Fixed a bug in the "import settings" screen + Updated translations + + + Updated "About" screen + Changed appearance of "Changelog" screen + Added "User forum" link to the "Settings" screen + Cleaned up some of the icons used in the app + Fixed bug when going back to previous steps when setting up an account + Updated translations + + + Updated password input fields to be able to reveal passwords + Added support for going back to previous steps when setting up an account + Fixed a couple of crashes + Updated translations + + + Prefer email addresses marked as default in email auto-completion popup + Internal changes + Updated translations + + + Fixed crash when trying to forward or reply to a message + Fixed crash when trying to compose a message from a search view + Fixed crash when message list couldn't be loaded + Fixed crash when setting up an account didn't work right away, e.g. wrong password was entered + + + Fixed bug that prevented some messages from being moved + Fixed message list display bug when multiple roles were assigned to the same folder + Number of messages in Outbox folder are now displayed in the side drawer + Tweaked message list appearance of Outbox + Message list footer is now hidden while list is loading + + + Fixed bug where updating drafts wouldn't upload the change to the server + Fixed bug where URLs were duplicated when saving drafts + Improved HTTP URL detection when URL is wrapped in parentheses + Don't show archive or spam action when displaying the corresponding folder + Show recipient name for all messages in Sent, Drafts, and Outbox folders + Use automatic identity selection when forwarding emails + Display message headers in original order + Updated translations + + + Fixed bug that lead to the message body not being displayed when it contained a signature ending with a URL + Added 'copy link text to clipboard' option when long-pressing links + Treat all whitespace as separator when detecting URLs + + + Fixed bug in preview text extraction that could stop synchronization + When displaying text/plain parts the signature is de-emphasized + Sharing a message now includes subject, date, sender, and recipients + No longer use ISO-8859-1 encoding for headers + + + Show image previews for attachments with application/octet-stream MIME type + Improved display of certain HTML messages + When auto-completing email addresses show starred contacts first + Improved preview text extraction + Improved support for encrypted subjects + Extended certificate dialog to show SHA-256 and SHA-512 fingerprints + Use correct OpenPGP key when sending signed-only email after switching identities + Fixed bug where using certain search text would crash the app + Fixed bug where state was lost on screen rotation in unread widget configuration + Many internal changes + Updated translations + + + Hide empty footer view in message list + Don't show standard message actions for messages in the Outbox + Tweaked UX of recipient chips + Refresh the list of folders when refreshing a folder + Apply global display settings to the message list when returning from settings screen + Many internal changes + Updated translations + + + Fixed display of text/plain parts containing text in a right-to-left language + Don't retry remote commands that failed with an error + Don't use default signature when setting up a new account + Fixed a bug that caused new message notifications to be skipped + Fixed a bug when uploading messages via IMAP failed + Fixed various bugs with POP3 accounts + Updated translations + + + Fixed bug that hid folders in the drawer behind the sticky footer + Fixed crash when server contains a folder named 'Outbox' + Avoid crash when adding an attachment fails + Removed support for using Shift JIS charsets when sending a message + Updated translations + + + Added swipe to refresh to side drawer + Display day of the week in the message list for messages younger than 7 days + Fixed bug that could lead to duplicated folders + Fixed bug that didn't allow to turn off encryption when replying to an encrypted email + Updated translations + + + IMAP: Fixed refreshing the folder list + Keep drawer open after selecting an account + Increased the size of the progress bar shown when refreshing a folder + Some smaller bug fixes + + + Fixed bug where reply/reply all/forward would open an empty new message screen + + + Fixed crash on startup with Android 7 and older + + + Fixed some bugs with periodic mail sync (mail was usually checked too often) + Fixed a couple of bugs where unnecessary data was kept in the local database + Fixed bug where a special folder wasn't removed locally when removed from the server + Fixed bug when creating signed-only messages with 'encrypt message subjects' enabled + Lots of internal changes + Updated translations + + + Fixed duplicate message bug after moving a message + Removed keyboard shortcuts for menu items; on some devices they conflicted with system shortcuts + Don't show fullscreen keyboard in landscape mode + Fixed bug where line breaks were ignored when loading draft messages + POP3: Fixed moving deleted messages to the Trash folder + + + Display larger preview for image attachments + Fixed import and export of folder settings + IMAP: Properly handle UIDVALIDITY changes + Various smaller bug fixes + Updated translations + + + Long-press on message in message list now selects the message + Click on contact picture in message list now selects the message + Fixed bug that disabled auto-completion when composing messages + Fixed dark/light theme issues + Removed 'Gestures' setting + Renamed "send again" to "edit as new message" + + + Fixed a bug in the new 'back button opens default folder' code + Fixed a crash on Android 5.x devices with old WebView versions + Fixed theme issues in some settings screens + + + Unified Inbox is now opened by default (if enabled) + Back button now opens default folder before exiting the app + Fixed bug where the input area for the message body wasn't growing properly + Keep read position when switching apps + Don't open external links inside the app on old Android/WebView versions + Update "auto BCC" recipients when switching accounts in message compose window + Fixed UI bug where BCC recipient and the date overlapped when viewing messages + Fixed bug that lead to display of inaccurate attachment sizes + Opt out of anonymous WebView metrics collection + Removed automatic display of "What's New" dialog because of display issues + Updated translations + + + Fixed bug that prevented contact pictures from being displayed + Display progress bar when syncing a folder + Fixed deleting messages when there's no Trash folder (IMAP) + Fixed a crash when encountering messages without a subject + Fixed bug that could lead to two message lists being displayed on top of each other + Stop trying to open external links inside the app when there's no app installed to handle the content + Updated translations + A lot of internal changes and improvements + + + Changed background color of the app icon + Added setting to mark a message as read when it is deleted (enabled by default) + Fixed crash when trying to reply to encrypted messages + Fixed configuration of special folders that lead to Inbox not being checked for new messages automatically + + + Fixed issue where going back to the app would open the default folder + Fixed crash when trying to create a new identity + Various smaller bug fixes + 'Choose folder' now displays folders in the same order as the side drawer + Updated translations + + + The back button now closes the side drawer instead of exiting the app immediately + Fixed "Download complete message" for POP3 accounts + Fixed bug that could lead to some messages not being synchronized with an IMAP server + Fixed bug where sometimes subjects weren't encoded properly + Fixed crash when changing theme + 'Manage folders' now displays folders in the same order as the side drawer + + + Use account color as accent color in drawer + Removed setting 'Start in Unified Inbox' + Fixed bug that displayed folders when the Unified Inbox was selected + Fixed bug where work was accidentally done in the main thread + Updated translations + + + Major redesign of the user interface + Temporarily disabled Push (IMAP IDLE) until we can make it work reliable; your accounts will be polled every 15 minutes instead + Deprecated support for the WebDAV protocol; new accounts can no longer be added + K-9 Mail now requires Android 5.0 and newer + Added support for Autocrypt setup message + Added support for encrypted subjects + Allow installation on external media (Android 6+ with adopted storage) + Removed the broken option to store the message database on external storage + Removed the 'remote control' interface third-party apps could use to change some settings in K-9 Mail + Fixed a lot of bugs and probably introduced some new ones. Please report bugs. + + + Allow some outdated HTML attributes so emails from popular internet services are displayed as intended + Fixed bug when moving or copying message (IMAP) + + + Fixed bug with some soft keyboards when auto-completing recipients + Fixed crash when decrypting messages + + + Updated translations + + + Fixed bug that lead to important files being stripped from the APK + + + Further improvements to encryption user experience + Added ability to forward a message as attachment + Improved the way we display text/plain messages + Improved rendering of RTL text + Import/Export maintain account ordering + Altered identity prioritization + Added option for limiting push connections to 5 + Added SMTP hostname privacy option + Fixed bug that caused draft messages to be lost + Fixed bug relating to encrypted attached emails + Updated translations + More bug fixes + + + Fixed bug that caused 'Quiet Time' to behave erratically + + + Fixed display issues with certain messages + + + Fixed bug that lead to some messages showing as empty + + + Avoid crash on Android 8.1 + Updated translations + Added Albanian translation + + + Fixed bug that could cause OpenPGP signature verification to fail when it shouldn't + Updated translations + + + Fixed bug that could lead to attachments not being displayed + Fixed bug where HTML messages weren't displayed correctly + Fixed crash when encountering invalid email addresses + + + Fixed display errors of plain text messages + Added translations: Indonesian, Breton + + + Improved OpenPGP support + Various bug fixes + Updated translations + + + New app icon + New widget: Message List + New OpenPGP flow, adhering to Autocrypt specification + Settings export uses Storage Access Framework + Better support for multi-window + Recipient search now includes nicknames + Many bugfixes and optimizations + Added translations: Esperanto, Gaelic (Scottish), Icelandic, Welsh + + + Fixed bug where automatic synchronization wouldn't restart after the device exited doze mode + + + Improved speed of local message search + + + Fixed crash when starting the app from the unread widget + + + Fixed bug where the message body wasn't displayed when no crypto provider was configured + + + Fixed bug where not all data was removed for deleted messages + Improved support for messages that didn't display correctly + Fixed bug with notification actions sometimes not working + Use "encrypted.asc" as filename for PGP/MIME emails + Updated translations + Many more bug fixes + + + Fixed bug with pinch to zoom gesture + Added setting for disabling 'mark all as read' confirmation dialog + Update full text search index when removing messages + Fixed display bug when replying to messages using dark theme + Don't hide Cc and Bcc if 'Always show Cc/Bcc' is enabled + Allow sending signed-only PGP/INLINE messages + Don't save drafts when message could be sent encrypted + More bug fixes + + + Fixed bug where BCC header line was accidentally included in sent messages + Fixed problem with getting the list of IMAP folders + Always show subject in message header when split mode is active + Hide crypto status indicator in contact dropdown when no crypto provider is configured + Fixed button to expand CC/BCC recipients in dark theme + Fixed various crashes + + + Fixed crash during database upgrade + Fixed various minor bugs + + + Fixed bug with status display of signed messages + Updated translations + + + Fixed crash when opening attached messages + Don't crash when message list contains messages for which we couldn't extract a preview + + + Added support for PGP/MIME + Added support for bundled notifications on Android 7+ and Android Wear + New option: only notify for messages from contacts + Ask for confirmation on "mark all as read" + Added support for sub-folders (WebDAV) + Added support for List-Post header + Added support for Server Name Indication + Disabled support for SSLv3 protocol/ciphers and all RC4 ciphers + Removed support for APG (use OpenKeychain instead) + Added server settings for more providers + Added translations: Bulgarian, Persian (Farsi), Croatian, Portuguese, Romanian, Slovenian, Serbian + Lots of smaller bug fixes and features + + + More user interface tweaks for encryption-related functionality + Message signing without encryption is now an expert feature that is disabled by default + Added support for directional pad to move to next/previous message + Worked around a bug when viewing attachments + Fixed notification grouping on Android Wear and Android 7.0 + Fixed notification actions on Android 7.0 + + + User interface tweaks for encryption-related functionality + Fixed crash caused by new message notifications + Fixed bug with downloading attachments + Fixed structure of emails created with K-9 Mail + Fixed bug where message list was displayed twice + Updated translations + + + Fixed dark theme + + + Fixed crash when selecting folder to move message + Fixed bug where wrong message format was used when replying + Fixed position of context menus on Android 7.0 + Fixed icon for encryption status of a message + Hide crypto status when no crypto provider is configured + Hide invalid email addresses of a system contact + Added support for linkifying URLs with new TLDs + Added server settings for more providers + + + Fixed replying to and forwarding of encrypted messages + Ask for confirmation on "mark all as read" + Suggest server name based on server type + Added support for esPass MIME type (application/vnd.espass-espass+zip) + Removed attachment indicator for encrypted messages + Don't add additional line break to the end of a message when sending + Removed broken support for sending messages as 8-bit via SMTP + Lots of internal changes and minor bug fixes + + + New option: only notify for messages from contacts + Added auto-configuration support for more providers + Improved PGP/MIME experience + Lots of internal improvements + + + Added support for List-Post header + Added support for sub-folders (WebDAV) + Display notification on authentication failures + Protect against the Surreptitious Sharing vulnerability + Re-enabled search in message bodies + Fixed support for PGP/INLINE + Fixed bug where some threads had multiple entries in the message list + Fixed 'reply to all' + More bug fixes + + + Added rudimentary support for reading and composing PGP/MIME messages + Added support for stacked single message notifications on Android Wear + Added setting to disable notifications during quiet time + Added setting to show confirmation dialog when discarding message + Added option to copy sender/recipient addresses to clipboard + Show warning when user tries to send an email without a subject + New database structure; temporarily disables fulltext search + Allow importing of settings created with newer versions of K-9 Mail + Added support for Server Name Indication + Disabled support for SSLv3 protocol/ciphers and all RC4 ciphers + Fixed bug where third-party apps couldn't delete messages from certain folders + Fixed bug in settings export with certain folder names + IMAP: Fall back to LOGIN command when AUTHENTICATE PLAIN fails + Added auto-configuration support for more providers + Added translations for Persian (Farsi) and Slovenian + Updated translations + More bug fixes + + + Fixed an issue caused by the latest Android System WebView update + + + Fixed a bug where messages where not always displayed on Android 5.x + + + Reverted all changes introduced with v5.104 except for the bugfixes related to Android 5.1 + + + Fixed crash when selecting multiple messages on Android 5.1 + Fixed settings export + Fixed some layout bugs + Added Serbian translation + Updated several translations + + + Added ability to customize lock screen notifications (Android 5.0+ only) + Fixed a bug where a certificate error was wrongly reported + Updated translation + + + Improved 'open' functionality for attachments + Removed APG legacy interface + Fixed bug in Russian translation + + + Fixed build problems that caused v5.100 to request the permissions READ_CALL_LOG and WRITE_CALL_LOG + + + Removed SSL/TLS session caching because it was causing problems + + + Dropped support for Android versions older than 4.0.3 + Added ability to use client certificates for authentication + Enabled support for TLSv1.1 and TLSv1.2 + Added SSL/TLS session caching + Finer grained control for notifications + Added support for delete confirmations in the message list + Added the option to show the password when setting up new accounts + Added privacy setting to omit the User-Agent header + Added privacy setting to use UTC as timezone in mail headers + Added auto configuration settings for various providers + Fixed HELO/EHLO with IPv6 address literals + Various bug fixes + Added translations: Latvian, Estonian, Norwegian Bokmål, Galician (Spain) + + + Added support for OpenPGP API v3 + Fixed problems with IMAP login + Updated translations + Fixed multiple bugs + + + Offer encrypted connection by default when manually setting up an account + Simplified options for authentication and security + Removed auto-configuration settings for all providers that didn't support encrypted connections + Improved compatibility with IMAP (proxy) servers + More small fixes and improvements + + + Avoid adding the same recipient twice when using "reply to all" + Fixed a bug with bitcoin URIs + Added mailbox.org to the list of providers + + + Added a slider to allow picking a font size for the message body (40% to 250%) in settings + Added support for KitKat's Storage Access Framework that allows you to attach multiple files at once + Added support for apps that don't know how to properly use Android's 'share' functionality + Fixed a bug with IMAP Push that could cause excessive battery drain + Another attempt at working around the display bug on Asus Transformer devices + Don't lose formatting of the quoted message when changing orientation while replying + Disabled pull-to-refresh in search views where remote search isn't allowed + More bug fixes + Updated Japanese translation + + + Fix issue 6064: Inline images don't display on KitKat + Update list of German Internet providers + Add provider Outlook.sk and Azet.sk to provider list + Update Brazilian Portuguese, Czech, Danish, Dutch, French, Greek, Hungarian, Polish, Russian, Slovak, Spanish, and Ukrainian translations + Fix POP3 STLS command + Use a locale-specific date in the header of a quoted message + Account preferences clean-up + Make IMAP autoconfig recognize "Draft" as drafts folder + Add posteo.de to providers.xml + Return proper error message when certificate couldn't be verified against global key store + Add support for bitcoin URIs + Change the way we harden SSL/TLS sockets. Disallow a couple of weak ciphers, bring known ones in a defined order and sort unknown ciphers at the end. Also re-enable SSLv3 because it's still used a lot. + Implement pruning of old certificates from LocalKeyStore. Certificates are deleted whenever server settings are changed or an account is deleted. + Fix inadequate certificate validation. Proper host name validation was not being performed for certificates kept in the local keystore. If an attacker could convince a user to accept and store an attacker's certificate, then that certificate could be used for MITM attacks, giving the attacker access to all connections to all servers in all accounts in K-9. + Users can now use different certificates for different servers on the same host (listening to different ports). + The above changes mean that users might have to re-accept certificates that they had previously accepted and are still using (but only if the certificate's Subject doesn't match the host that they are connecting to). + Make sure to return different colors for senders with different name, but the same mail address (e.g. mails sent by certain issue tracking systems). + With the new webview scrollview combo we've got loadinoverviewmode seems to behave better. + Fix file selection for import Using FLAG_ACTIVITY_NO_HISTORY will cause the file selection to fail when KitKat's "Open from" activity opens a third-party activity. + + + Overhauled how we do message view scrolling to fix a KitKat issue. Thanks to Joe Steele! + Hardened TLS cipher suites and versions + K-9 no longer adds blank lines to composed messages if there is no quoted text + Fixed serveral issues related to message drafts + Better cleanup of old data when deleting an account + Worked around a bug in KitKat that stopped settings import from working + Updated German, Greek, Japanese, Korean, Lithuanian, Portugese, Russian and Slovak translations + + + Code cleanups + Fixed a bug that could have caused message drafts to be sent before they were ready + Updates to German, Japanese, Russian, Slovak translations + Fix some small bugs in contact picture generation + Fetch attachments while MessageCompose activity is running + + + Updated auto-configuration settings to use IMAP for outlook.com + Updated auto-configuration settings for gmx.de + Notifications no longer show "null" when sending mail + Increased compatibility with custom ROMs and newer Samsung firmwares + Improved message generation + Dramatically improved SMTP 8BITMIME compliance + Updated Czech, French, German, Russian and Slovak translation + + + Remove remote/local store references when deleting accounts + Add visual indicator that a menu item opens a submenu + Make actions shown in message view menu configurable + Remove icons from the "Refile" submenu, as we don't show icons in any other submenu. + Add icon for the copy action + + + Fix erroneous SSL certificate warnings when connecting to a server that speaks STARTTLS + Updates to Russian and Slovakian translations + Major performance updates when opening folder lists + Fix a crashing bug related to random number generation on some 3rd party roms + Fixed a bug that prevented starring messages in the message list + + + Updates to Catalan, Czech, Dutch, Finnish, French, German, Korean, Spanish and Swedish translations + Several performance improvements and crash fixes + tweak message list item "read item" background color so you can see the item divider a bit better + Add back select/deselect action to the message list context menu + Default message list checkboxes to off again + move message list thread count up to the subject line + add back stars to the message list UI + Return to old style color chips for accounts, folders and messages. + Skip incorrectly formatted/parsed LSUB/LIST replies from IMAP servers + Autoconfigure SSL for provider gmx.de + Autoconfiguration for a comprehensive list of .ru mail providers. + Use Google's fix for the PRNG mess Source: http://android-developers.blogspot.de/2013/08/some-securerandom-thoughts.html + Add an actionbar item for "add account" to the account list (if there's room) + GMail-app-style generated colorful one-letter contact pictures for pictureless contacts + Tighten up the account list display for narrow-screened devices like the HTC One + Updated invalid certificate message to be a bit more user-friendly. + Restore super-dense message list layout when the user has selected 0 lines of message preview and no contact pictures + Rename "SSL" to "SSL/TLS" and "TLS" to "STARTTLS" to better explain what's really going on + + + Move 'share' menu item back, at least for the moment + + + Add some Russian ISPs for autoconfiguration + Polish and Slovak translation updates + Performance improvements + Build system improvements + Move 'share' menu item up a level in the menu to ease discoverability + + + Performance improvements + Added autoconfiguration for Fastmail.FM + New Slovak translation + Updated Italian and Russian translations + Don't save signature to identity header if identity doesn't use a signature + + + Major performance improvements in folder lists + Catalan, Chinese and Russian translation updates + Several bugfixes + + + Added additional shortcuts to the Folder list + Tweaks to checkboxes and color chip display + Added an "empty trash" option to the Account context menu + Never use extended notifications when privacy mode is enabled + Removed submenu from the account context menu to work a bug in some Galaxy devices + Only enable debug logging on debugging builds + Bumped the minSdkVersion to 8 since we use features from SDK 8 + Updated Russian, French, Greek, and Brazilian Portuguese translation + More bug fixes + + + Fixed bug that prevented moving to previous and next message in some situations + Updated Japanese, Czech, and Brazilian Portuguese translation + Fixed a couple of bugs + + + Added setting to automatically shrink messages to fit the screen width + Updated Greek translation + + + Checking mail from the Unified Inbox is now supported + Added "mark all as read" to the menu of the message list + Added sort by sender + Simplified status icons in the message list + Fixed inability to zoom out + Composing messages in right to left languages should now work better + Exclude folders Trash, Spam, and Outbox from "All messages" + Fixed white "flicker" when using the dark theme + Updated German translation + Many bug fixes + + + Changed appearance of the unread widget + Fixed crash when opening the app from notifications + Updated Finnish, Catalan translations + Multiple bug fixes + + + Changed the notification icon (Android 2.3+ only) + Restored showing the unread count on top of notification icons (Android 2.x only) + Show preview text of the latest message in a thread + Fixed 'empty trash' functionality for POP3 accounts + Added work-around for the auto-scroll issue of the message view on Jelly Bean + Changed navigation when coming from a notification + Don't return to home screen after forwarding/replying to a message + Changed button bar style in account setup screens (Android 3+) + Highlight selected message in the message list + Added a series of predefined account colors that will be used before resorting to random colors + Hide delete policy option not applicable to POP3 accounts + Fall back to character set ISO-2022-JP when ISO-2022-JP-2 is not available + Updated Korean, Danish, Finnish translations + More bug fixes + + + Fixed the changelog :) + + + Fix dialog message when deleting multiple messages from a notification + Message view / list: fix NPE when list is empty + If there is no message when resuming, K-9 should return to a MessageList. + Add a caching layer to EmailProvider. Database updates can be surprisingly slow. This lead to slow updates of the user interface which in turn made working with K-9 Mail not as fun as it should be. This commit hopefully changes that. + Updated Japanese translation + Updated French translation + Updated Czech localization + + + Korean translation update + Message header area overhauled + Holo theme improvements + Fix several NPEes + Finnish translation update + Improve last folder update time formatting. + Experimental change to move most of our refile buttons into a refile submenu. Specifically to elicit feedback. I don't expect this change to stick around in its current form. But I do want to get a sense of whether it's something that makes people happy or angry + Clean up date handling + Update German translation + Add animated notification icon for "check mail" + There's no good reason to exclude the Subject from the "full headers" view, especicially since we now play games with it sometimes showing up in the header and sometimes in the titlebar + Switch our font sizes to have a "default", which is the size described in the XML. + Add optional contact pictures to message list + Remove text selection menu item for JB and higher. Those versions have text selection support built-in (via long pressing the WebView). + Move message view theme setting from message view menu to global prefs by default. + Move "show all headers" into the menu (and out of the UI) + + + Bug fixes (threading, checkboxes) + German translation update + + + Bug fixes + Improved animations when showing the message list + + + Bug fixes + Updates to Finnish and French translations + Put back prev/next buttons in non-split message views + + + Added a setting to enable split-screen mode (display message list next to message content) + Show a thread as unread/starred if at least one message is unread/starred + Modified the preview lines setting to allow disabling message preview in message list + Remember the scroll position of the message list + Changed the color picker + Improved certificate failure notifications + Fixed a bug that prevented third-party apps from reading the number of unread messages + Fixed a bug that caused the app to load too many messages when you clicked "Load more messages" + Updated Finnish, French, German, Dutch translations + + + Fixed some bugs related to message threading + Improved search for folders in the folder list + Added support for wrapping long folder names in the folder list + Added a progress indicator for remote searches + Reworked messagelist progress indicators + Improved notifications on SSL certificate validation failures + Updated Finnish, French, Spanish translations + Fixes to the new notificiations + Improvements to database upgrade infrastructure + Added the ability to search all local messages from the folder list + Added button to show this about screen + Close thread view when last message has been moved/deleted + Performance improvements + + + Added 'Account settings' back to the account context menu + Added 'Refresh' and 'Settings' back to the folder context menu + Minor bug fixes + + + Added Jelly Bean-style notifications + + + diff --git a/app-k9mail/src/main/res/values-am/strings.xml b/app-k9mail/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/app-k9mail/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-ar/strings.xml b/app-k9mail/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..9d2912a --- /dev/null +++ b/app-k9mail/src/main/res/values-ar/strings.xml @@ -0,0 +1,4 @@ + + + بريد K-9 + diff --git a/app-k9mail/src/main/res/values-ast/strings.xml b/app-k9mail/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/app-k9mail/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-az/strings.xml b/app-k9mail/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-az/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-be/strings.xml b/app-k9mail/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..b230571 --- /dev/null +++ b/app-k9mail/src/main/res/values-be/strings.xml @@ -0,0 +1,4 @@ + + + Пошта K-9 + diff --git a/app-k9mail/src/main/res/values-bg/strings.xml b/app-k9mail/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..5216a7f --- /dev/null +++ b/app-k9mail/src/main/res/values-bg/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Поща + diff --git a/app-k9mail/src/main/res/values-bn/strings.xml b/app-k9mail/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..9e1dc1c --- /dev/null +++ b/app-k9mail/src/main/res/values-bn/strings.xml @@ -0,0 +1,4 @@ + + + K-9 মেইল + diff --git a/app-k9mail/src/main/res/values-br/strings.xml b/app-k9mail/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..e84481d --- /dev/null +++ b/app-k9mail/src/main/res/values-br/strings.xml @@ -0,0 +1,3 @@ + + + diff --git a/app-k9mail/src/main/res/values-bs/strings.xml b/app-k9mail/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-bs/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-ca/strings.xml b/app-k9mail/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-ca/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-co/strings.xml b/app-k9mail/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-co/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-cs/strings.xml b/app-k9mail/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-cs/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-cy/strings.xml b/app-k9mail/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-cy/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-da/strings.xml b/app-k9mail/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-da/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-de/strings.xml b/app-k9mail/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..f7944fb --- /dev/null +++ b/app-k9mail/src/main/res/values-de/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-el/strings.xml b/app-k9mail/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-el/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-en-rGB/strings.xml b/app-k9mail/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-enm/strings.xml b/app-k9mail/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/app-k9mail/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-eo/strings.xml b/app-k9mail/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..1a7ed3a --- /dev/null +++ b/app-k9mail/src/main/res/values-eo/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Retpoŝtilo + diff --git a/app-k9mail/src/main/res/values-es/strings.xml b/app-k9mail/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-es/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-et/strings.xml b/app-k9mail/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-et/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-eu/strings.xml b/app-k9mail/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-eu/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-fa/strings.xml b/app-k9mail/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..c96c659 --- /dev/null +++ b/app-k9mail/src/main/res/values-fa/strings.xml @@ -0,0 +1,4 @@ + + + نامهٔ کی۹ + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-fi/strings.xml b/app-k9mail/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-fi/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-fr/strings.xml b/app-k9mail/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..f3b0d13 --- /dev/null +++ b/app-k9mail/src/main/res/values-fr/strings.xml @@ -0,0 +1,4 @@ + + + Courriel K-9 + diff --git a/app-k9mail/src/main/res/values-fy/strings.xml b/app-k9mail/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..f7944fb --- /dev/null +++ b/app-k9mail/src/main/res/values-fy/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-ga/strings.xml b/app-k9mail/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..78d00fc --- /dev/null +++ b/app-k9mail/src/main/res/values-ga/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Post + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-gd/strings.xml b/app-k9mail/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..5cfbc0e --- /dev/null +++ b/app-k9mail/src/main/res/values-gd/strings.xml @@ -0,0 +1,4 @@ + + + Post K-9 + diff --git a/app-k9mail/src/main/res/values-gl/strings.xml b/app-k9mail/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-gl/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-gu/strings.xml b/app-k9mail/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..311567d --- /dev/null +++ b/app-k9mail/src/main/res/values-gu/strings.xml @@ -0,0 +1,4 @@ + + + કે-૯ મેલ + diff --git a/app-k9mail/src/main/res/values-hi/strings.xml b/app-k9mail/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..f36796c --- /dev/null +++ b/app-k9mail/src/main/res/values-hi/strings.xml @@ -0,0 +1,4 @@ + + + के-9 मेल + diff --git a/app-k9mail/src/main/res/values-hr/strings.xml b/app-k9mail/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-hr/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-hu/strings.xml b/app-k9mail/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-hu/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-hy/strings.xml b/app-k9mail/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..4f56869 --- /dev/null +++ b/app-k9mail/src/main/res/values-hy/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Նամակ + diff --git a/app-k9mail/src/main/res/values-in/strings.xml b/app-k9mail/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..2ecb405 --- /dev/null +++ b/app-k9mail/src/main/res/values-in/strings.xml @@ -0,0 +1,4 @@ + + + Surel K-9 + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-is/strings.xml b/app-k9mail/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..7679bae --- /dev/null +++ b/app-k9mail/src/main/res/values-is/strings.xml @@ -0,0 +1,4 @@ + + + K-9 - Póstur + diff --git a/app-k9mail/src/main/res/values-it/strings.xml b/app-k9mail/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..f7944fb --- /dev/null +++ b/app-k9mail/src/main/res/values-it/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-iw/strings.xml b/app-k9mail/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..544169a --- /dev/null +++ b/app-k9mail/src/main/res/values-iw/strings.xml @@ -0,0 +1,4 @@ + + + K-9 דוא\"ל + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-ja/strings.xml b/app-k9mail/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-ja/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-ka/strings.xml b/app-k9mail/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-ka/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-kab/strings.xml b/app-k9mail/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..5f57a58 --- /dev/null +++ b/app-k9mail/src/main/res/values-kab/strings.xml @@ -0,0 +1,4 @@ + + + Imayl K-9 + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-kk/strings.xml b/app-k9mail/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..7500928 --- /dev/null +++ b/app-k9mail/src/main/res/values-kk/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Пошта + \ No newline at end of file diff --git a/app-k9mail/src/main/res/values-ko/strings.xml b/app-k9mail/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..9f1a83b --- /dev/null +++ b/app-k9mail/src/main/res/values-ko/strings.xml @@ -0,0 +1,4 @@ + + + K-9 메일 + diff --git a/app-k9mail/src/main/res/values-lt/strings.xml b/app-k9mail/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..1eb22df --- /dev/null +++ b/app-k9mail/src/main/res/values-lt/strings.xml @@ -0,0 +1,4 @@ + + + K-9 paštas + diff --git a/app-k9mail/src/main/res/values-lv/strings.xml b/app-k9mail/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..2ded86f --- /dev/null +++ b/app-k9mail/src/main/res/values-lv/strings.xml @@ -0,0 +1,4 @@ + + + K-9 pasts + diff --git a/app-k9mail/src/main/res/values-ml/strings.xml b/app-k9mail/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..f115016 --- /dev/null +++ b/app-k9mail/src/main/res/values-ml/strings.xml @@ -0,0 +1,4 @@ + + + K-9 Mail + diff --git a/app-k9mail/src/main/res/values-nb/strings.xml b/app-k9mail/src/main/res/values-nb/strings.xml new file mode 100644 index 0000000..cc62bdc --- /dev/null +++ b/app-k9mail/src/main/res/values-nb/strings.xml @@ -0,0 +1,4 @@ + + + K-9 E-post + diff --git a/app-k9mail/src/main/res/values-night/themes.xml b/app-k9mail/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4ddbaf8 --- /dev/null +++ b/app-k9mail/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/api/build.gradle.kts b/backend/api/build.gradle.kts new file mode 100644 index 0000000..be15f68 --- /dev/null +++ b/backend/api/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.outcome) + implementation(projects.feature.mail.account.api) + implementation(projects.feature.mail.folder.api) + api(projects.mail.common) +} diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt new file mode 100644 index 0000000..d9808d4 --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/Backend.kt @@ -0,0 +1,94 @@ +package com.fsck.k9.backend.api + +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Part +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter + +interface Backend { + val supportsFlags: Boolean + val supportsExpunge: Boolean + val supportsMove: Boolean + val supportsCopy: Boolean + val supportsUpload: Boolean + val supportsTrashFolder: Boolean + val supportsSearchByDate: Boolean + val supportsFolderSubscriptions: Boolean + val isPushCapable: Boolean + + @Throws(MessagingException::class) + fun refreshFolderList(): FolderPathDelimiter? + + // TODO: Add a way to cancel the sync process + fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) + + @Throws(MessagingException::class) + fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) + + @Throws(MessagingException::class) + fun downloadMessageStructure(folderServerId: String, messageServerId: String) + + @Throws(MessagingException::class) + fun downloadCompleteMessage(folderServerId: String, messageServerId: String) + + @Throws(MessagingException::class) + fun setFlag(folderServerId: String, messageServerIds: List, flag: Flag, newState: Boolean) + + @Throws(MessagingException::class) + fun markAllAsRead(folderServerId: String) + + @Throws(MessagingException::class) + fun expunge(folderServerId: String) + + @Throws(MessagingException::class) + fun deleteMessages(folderServerId: String, messageServerIds: List) + + @Throws(MessagingException::class) + fun deleteAllMessages(folderServerId: String) + + @Throws(MessagingException::class) + fun moveMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? + + @Throws(MessagingException::class) + fun moveMessagesAndMarkAsRead( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? + + @Throws(MessagingException::class) + fun copyMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? + + @Throws(MessagingException::class) + fun search( + folderServerId: String, + query: String?, + requiredFlags: Set?, + forbiddenFlags: Set?, + performFullTextSearch: Boolean, + ): List + + @Throws(MessagingException::class) + fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) + + @Throws(MessagingException::class) + fun findByMessageId(folderServerId: String, messageId: String): String? + + @Throws(MessagingException::class) + fun uploadMessage(folderServerId: String, message: Message): String? + + @Throws(MessagingException::class) + fun sendMessage(message: Message) + + fun createPusher(callback: BackendPusherCallback): BackendPusher +} diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/BackendFolder.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendFolder.kt new file mode 100644 index 0000000..ad54686 --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendFolder.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.backend.api + +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageDownloadState +import java.util.Date + +// FIXME: add documentation +interface BackendFolder { + val name: String + val visibleLimit: Int + + fun getMessageServerIds(): Set + fun getAllMessagesAndEffectiveDates(): Map + fun destroyMessages(messageServerIds: List) + fun clearAllMessages() + fun getMoreMessages(): MoreMessages + fun setMoreMessages(moreMessages: MoreMessages) + fun setLastChecked(timestamp: Long) + fun setStatus(status: String?) + fun isMessagePresent(messageServerId: String): Boolean + fun getMessageFlags(messageServerId: String): Set + fun setMessageFlag(messageServerId: String, flag: Flag, value: Boolean) + fun saveMessage(message: Message, downloadState: MessageDownloadState) + fun getOldestMessageDate(): Date? + fun getFolderExtraString(name: String): String? + fun setFolderExtraString(name: String, value: String?) + fun getFolderExtraNumber(name: String): Long? + fun setFolderExtraNumber(name: String, value: Long) + + enum class MoreMessages { + UNKNOWN, + FALSE, + TRUE, + } +} diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusher.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusher.kt new file mode 100644 index 0000000..f77bda9 --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusher.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.backend.api + +interface BackendPusher { + fun start() + fun updateFolders(folderServerIds: Collection) + fun stop() + fun reconnect() +} diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusherCallback.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusherCallback.kt new file mode 100644 index 0000000..d503bb7 --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendPusherCallback.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.backend.api + +interface BackendPusherCallback { + fun onPushEvent(folderServerId: String) + fun onPushError(exception: Exception) + fun onPushNotSupported() +} diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/BackendStorage.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendStorage.kt new file mode 100644 index 0000000..a468209 --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/BackendStorage.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.backend.api + +import com.fsck.k9.mail.FolderType +import java.io.Closeable +import net.thunderbird.core.common.exception.MessagingException + +interface BackendStorage { + fun getFolder(folderServerId: String): BackendFolder + + fun getFolderServerIds(): List + + fun createFolderUpdater(): BackendFolderUpdater + + fun getExtraString(name: String): String? + fun setExtraString(name: String, value: String) + fun getExtraNumber(name: String): Long? + fun setExtraNumber(name: String, value: Long) +} + +interface BackendFolderUpdater : Closeable { + @Throws(MessagingException::class) + fun createFolders(folders: List): Set + fun deleteFolders(folderServerIds: List) + + @Throws(MessagingException::class) + fun changeFolder(folderServerId: String, name: String, type: FolderType) +} + +fun BackendFolderUpdater.createFolder(folder: FolderInfo): Long? = createFolders(listOf(folder)).firstOrNull() + +inline fun BackendStorage.updateFolders(block: BackendFolderUpdater.() -> T): T { + return createFolderUpdater().use { it.block() } +} diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/FolderInfo.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/FolderInfo.kt new file mode 100644 index 0000000..ea9ef8e --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/FolderInfo.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.backend.api + +import com.fsck.k9.mail.FolderType +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter + +data class FolderInfo( + val serverId: String, + val name: String, + val type: FolderType, + val folderPathDelimiter: FolderPathDelimiter? = null, +) diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/SyncConfig.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/SyncConfig.kt new file mode 100644 index 0000000..78c600e --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/SyncConfig.kt @@ -0,0 +1,19 @@ +package com.fsck.k9.backend.api + +import com.fsck.k9.mail.Flag +import java.util.Date + +data class SyncConfig( + val expungePolicy: ExpungePolicy, + val earliestPollDate: Date?, + val syncRemoteDeletions: Boolean, + val maximumAutoDownloadMessageSize: Int, + val defaultVisibleLimit: Int, + val syncFlags: Set, +) { + enum class ExpungePolicy { + IMMEDIATELY, + MANUALLY, + ON_POLL, + } +} diff --git a/backend/api/src/main/java/com/fsck/k9/backend/api/SyncListener.kt b/backend/api/src/main/java/com/fsck/k9/backend/api/SyncListener.kt new file mode 100644 index 0000000..026a8f8 --- /dev/null +++ b/backend/api/src/main/java/com/fsck/k9/backend/api/SyncListener.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.backend.api + +interface SyncListener { + fun syncStarted(folderServerId: String) + + fun syncAuthenticationSuccess() + + fun syncHeadersStarted(folderServerId: String) + fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int) + fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int) + + fun syncProgress(folderServerId: String, completed: Int, total: Int) + fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean) + fun syncRemovedMessage(folderServerId: String, messageServerId: String) + fun syncFlagChanged(folderServerId: String, messageServerId: String) + + fun syncFinished(folderServerId: String) + fun syncFailed(folderServerId: String, message: String, exception: Exception?) + + fun folderStatusChanged(folderServerId: String) +} diff --git a/backend/api/src/main/kotlin/net/thunderbird/backend/api/BackendFactory.kt b/backend/api/src/main/kotlin/net/thunderbird/backend/api/BackendFactory.kt new file mode 100644 index 0000000..911f08c --- /dev/null +++ b/backend/api/src/main/kotlin/net/thunderbird/backend/api/BackendFactory.kt @@ -0,0 +1,8 @@ +package net.thunderbird.backend.api + +import com.fsck.k9.backend.api.Backend +import net.thunderbird.feature.mail.account.api.BaseAccount + +interface BackendFactory { + fun createBackend(account: TAccount): Backend +} diff --git a/backend/api/src/main/kotlin/net/thunderbird/backend/api/BackendStorageFactory.kt b/backend/api/src/main/kotlin/net/thunderbird/backend/api/BackendStorageFactory.kt new file mode 100644 index 0000000..8214870 --- /dev/null +++ b/backend/api/src/main/kotlin/net/thunderbird/backend/api/BackendStorageFactory.kt @@ -0,0 +1,8 @@ +package net.thunderbird.backend.api + +import com.fsck.k9.backend.api.BackendStorage +import net.thunderbird.feature.mail.account.api.BaseAccount + +interface BackendStorageFactory { + fun createBackendStorage(account: TAccount): BackendStorage +} diff --git a/backend/api/src/main/kotlin/net/thunderbird/backend/api/folder/RemoteFolderCreator.kt b/backend/api/src/main/kotlin/net/thunderbird/backend/api/folder/RemoteFolderCreator.kt new file mode 100644 index 0000000..adbf6a3 --- /dev/null +++ b/backend/api/src/main/kotlin/net/thunderbird/backend/api/folder/RemoteFolderCreator.kt @@ -0,0 +1,68 @@ +package net.thunderbird.backend.api.folder + +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.folders.FolderServerId +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.mail.account.api.BaseAccount + +interface RemoteFolderCreator { + /** + * Creates a folder on the remote server. If the folder already exists and [mustCreate] is `false`, + * the operation will succeed returning [RemoteFolderCreationOutcome.Success.AlreadyExists]. + * + * @param folderServerId The folder server ID. + * @param mustCreate If `true`, the folder must be created returning + * [RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder]. If `false`, the folder will be created + * only if it doesn't exist. + * @param folderType The folder type. This requires special handling for some servers. Default [FolderType.REGULAR]. + * @return The result of the operation. + * @see RemoteFolderCreationOutcome.Success + * @see RemoteFolderCreationOutcome.Error + */ + suspend fun create( + folderServerId: FolderServerId, + mustCreate: Boolean, + folderType: FolderType = FolderType.REGULAR, + ): Outcome + + interface Factory { + fun create(account: BaseAccount): RemoteFolderCreator + } +} + +sealed interface RemoteFolderCreationOutcome { + sealed interface Success : RemoteFolderCreationOutcome { + /** + * Used to flag that the folder was created successfully. + */ + data object Created : Success + + /** + * Used to flag that the folder creation was skipped because the folder already exists and + * the creation is NOT mandatory. + */ + data object AlreadyExists : Success + } + + sealed interface Error : RemoteFolderCreationOutcome { + /** + * Used to flag that the folder creation has failed because the folder already exists and + * the creation is mandatory. + */ + data object AlreadyExists : Error + + /** + * Used to flag that the folder creation failed on the remote server. + * @param reason The reason why the folder creation failed. + */ + data class FailedToCreateRemoteFolder( + val reason: String, + ) : Error + + /** + * Used to flag that the Create Folder operation is not supported by the server. + * E.g. POP3 servers don't support creating archive folders. + */ + data object NotSupportedOperation : Error + } +} diff --git a/backend/demo/build.gradle.kts b/backend/demo/build.gradle.kts new file mode 100644 index 0000000..c440446 --- /dev/null +++ b/backend/demo/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) + alias(libs.plugins.kotlin.serialization) +} + +dependencies { + api(projects.backend.api) + implementation(projects.feature.mail.folder.api) + + implementation(libs.kotlinx.serialization.json) + + testImplementation(projects.mail.testing) +} diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandRefreshFolderList.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandRefreshFolderList.kt new file mode 100644 index 0000000..70f5dea --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandRefreshFolderList.kt @@ -0,0 +1,33 @@ +package app.k9mail.backend.demo + +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.updateFolders +import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter + +internal class CommandRefreshFolderList( + private val backendStorage: BackendStorage, + private val demoStore: DemoStore, +) { + + fun refreshFolderList(): FolderPathDelimiter? { + val localFolderServerIds = backendStorage.getFolderServerIds().toSet() + + backendStorage.updateFolders { + val remoteFolderServerIds = demoStore.getFolderIds() + val foldersServerIdsToCreate = remoteFolderServerIds - localFolderServerIds + val foldersToCreate = foldersServerIdsToCreate.mapNotNull { folderServerId -> + demoStore.getFolder(folderServerId)?.let { folderData -> + FolderInfo(folderServerId, folderData.name, folderData.type) + } + } + createFolders(foldersToCreate) + + val folderServerIdsToRemove = (localFolderServerIds - remoteFolderServerIds).toList() + deleteFolders(folderServerIdsToRemove) + } + + return FOLDER_DEFAULT_PATH_DELIMITER + } +} diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandSendMessage.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandSendMessage.kt new file mode 100644 index 0000000..d678eb5 --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandSendMessage.kt @@ -0,0 +1,32 @@ +package app.k9mail.backend.demo + +import app.k9mail.backend.demo.DemoHelper.createNewServerId +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.internet.MimeMessage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +internal class CommandSendMessage( + private val backendStorage: BackendStorage, + private val demoStore: DemoStore, +) { + + fun sendMessage(message: Message) { + val inboxServerId = demoStore.getInboxFolderId() + val backendFolder = backendStorage.getFolder(inboxServerId) + + val newMessage = message.copy(uid = createNewServerId()) + backendFolder.saveMessage(newMessage, MessageDownloadState.FULL) + } + + private fun Message.copy(uid: String): MimeMessage { + val outputStream = ByteArrayOutputStream() + writeTo(outputStream) + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + return MimeMessage.parseMimeMessage(inputStream, false).apply { + this.uid = uid + } + } +} diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandSync.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandSync.kt new file mode 100644 index 0000000..78d9734 --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/CommandSync.kt @@ -0,0 +1,40 @@ +package app.k9mail.backend.demo + +import com.fsck.k9.backend.api.BackendFolder.MoreMessages +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.mail.MessageDownloadState + +internal class CommandSync( + private val backendStorage: BackendStorage, + private val demoStore: DemoStore, +) { + + fun sync(folderServerId: String, listener: SyncListener) { + listener.syncStarted(folderServerId) + + val folder = demoStore.getFolder(folderServerId) + if (folder == null) { + listener.syncFailed(folderServerId, "Folder $folderServerId doesn't exist", null) + return + } + + val backendFolder = backendStorage.getFolder(folderServerId) + + val localMessageServerIds = backendFolder.getMessageServerIds() + if (localMessageServerIds.isNotEmpty()) { + listener.syncFinished(folderServerId) + return + } + + for (messageServerId in folder.messageServerIds) { + val message = demoStore.getMessage(folderServerId, messageServerId) + backendFolder.saveMessage(message, MessageDownloadState.FULL) + listener.syncNewMessage(folderServerId, messageServerId, isOldMessage = false) + } + + backendFolder.setMoreMessages(MoreMessages.FALSE) + + listener.syncFinished(folderServerId) + } +} diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoBackend.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoBackend.kt new file mode 100644 index 0000000..ca1e8a5 --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoBackend.kt @@ -0,0 +1,123 @@ +package app.k9mail.backend.demo + +import app.k9mail.backend.demo.DemoHelper.createNewServerId +import com.fsck.k9.backend.api.Backend +import com.fsck.k9.backend.api.BackendPusher +import com.fsck.k9.backend.api.BackendPusherCallback +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Part +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter + +class DemoBackend( + private val backendStorage: BackendStorage, +) : Backend { + private val demoStore by lazy { DemoStore() } + + private val commandSync by lazy { CommandSync(backendStorage, demoStore) } + private val commandRefreshFolderList by lazy { CommandRefreshFolderList(backendStorage, demoStore) } + private val commandSendMessage by lazy { CommandSendMessage(backendStorage, demoStore) } + + override val supportsFlags: Boolean = true + override val supportsExpunge: Boolean = false + override val supportsMove: Boolean = true + override val supportsCopy: Boolean = true + override val supportsUpload: Boolean = true + override val supportsTrashFolder: Boolean = true + override val supportsSearchByDate: Boolean = false + override val supportsFolderSubscriptions: Boolean = false + override val isPushCapable: Boolean = false + + override fun refreshFolderList(): FolderPathDelimiter? { + return commandRefreshFolderList.refreshFolderList() + } + + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + commandSync.sync(folderServerId, listener) + } + + override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun downloadMessageStructure(folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun setFlag(folderServerId: String, messageServerIds: List, flag: Flag, newState: Boolean) = Unit + + override fun markAllAsRead(folderServerId: String) = Unit + + override fun expunge(folderServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun deleteMessages(folderServerId: String, messageServerIds: List) = Unit + + override fun deleteAllMessages(folderServerId: String) = Unit + + override fun moveMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map { + // We do just enough to simulate a successful operation on the server. + return messageServerIds.associateWith { createNewServerId() } + } + + override fun moveMessagesAndMarkAsRead( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map { + // We do just enough to simulate a successful operation on the server. + return messageServerIds.associateWith { createNewServerId() } + } + + override fun copyMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map { + // We do just enough to simulate a successful operation on the server. + return messageServerIds.associateWith { createNewServerId() } + } + + override fun search( + folderServerId: String, + query: String?, + requiredFlags: Set?, + forbiddenFlags: Set?, + performFullTextSearch: Boolean, + ): List { + throw UnsupportedOperationException("not implemented") + } + + override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) { + throw UnsupportedOperationException("not implemented") + } + + override fun findByMessageId(folderServerId: String, messageId: String): String? { + throw UnsupportedOperationException("not implemented") + } + + override fun uploadMessage(folderServerId: String, message: Message): String { + return createNewServerId() + } + + override fun sendMessage(message: Message) { + commandSendMessage.sendMessage(message) + } + + override fun createPusher(callback: BackendPusherCallback): BackendPusher { + throw UnsupportedOperationException("not implemented") + } +} diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoDataLoader.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoDataLoader.kt new file mode 100644 index 0000000..32950fa --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoDataLoader.kt @@ -0,0 +1,30 @@ +package app.k9mail.backend.demo + +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.internet.MimeMessage +import java.io.InputStream +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream + +internal class DemoDataLoader { + + @OptIn(ExperimentalSerializationApi::class) + fun loadFolders(): DemoFolders { + return getResourceAsStream("/contents.json").use { inputStream -> + Json.decodeFromStream(inputStream) + } + } + + fun loadMessage(folderServerId: String, messageServerId: String): Message { + return getResourceAsStream("/$folderServerId/$messageServerId.eml").use { inputStream -> + MimeMessage.parseMimeMessage(inputStream, false).apply { + uid = messageServerId + } + } + } + + private fun getResourceAsStream(name: String): InputStream { + return this.javaClass.getResourceAsStream(name) ?: error("Resource '$name' not found") + } +} diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoFolder.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoFolder.kt new file mode 100644 index 0000000..6fb1d41 --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoFolder.kt @@ -0,0 +1,12 @@ +package app.k9mail.backend.demo + +import com.fsck.k9.mail.FolderType +import kotlinx.serialization.Serializable + +@Serializable +internal data class DemoFolder( + val name: String, + val type: FolderType, + val messageServerIds: List, + val subFolders: DemoFolders? = null, +) diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoFolders.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoFolders.kt new file mode 100644 index 0000000..a18a940 --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoFolders.kt @@ -0,0 +1,3 @@ +package app.k9mail.backend.demo + +internal typealias DemoFolders = Map diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoHelper.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoHelper.kt new file mode 100644 index 0000000..98bee23 --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoHelper.kt @@ -0,0 +1,7 @@ +package app.k9mail.backend.demo + +import java.util.UUID + +internal object DemoHelper { + fun createNewServerId() = UUID.randomUUID().toString() +} diff --git a/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoStore.kt b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoStore.kt new file mode 100644 index 0000000..07bca3f --- /dev/null +++ b/backend/demo/src/main/kotlin/app/k9mail/backend/demo/DemoStore.kt @@ -0,0 +1,54 @@ +package app.k9mail.backend.demo + +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.Message + +internal class DemoStore( + private val demoDataLoader: DemoDataLoader = DemoDataLoader(), +) { + private val demoFolders: DemoFolders by lazy { flattenDemoFolders(demoDataLoader.loadFolders()) } + + fun getFolder(folderServerId: String): DemoFolder? { + return demoFolders[folderServerId] + } + + fun getFolderIds(): Set { + return demoFolders.keys + } + + fun getInboxFolderId(): String { + return demoFolders.filterValues { it.type == FolderType.INBOX }.keys.first() + } + + fun getMessage(folderServerId: String, messageServerId: String): Message { + return demoDataLoader.loadMessage(folderServerId, messageServerId) + } + + // This is a workaround for the fact that the backend doesn't support nested folders + private fun flattenDemoFolders( + demoFolders: DemoFolders, + parentName: String = "", + parentServerId: String = "", + ): DemoFolders { + val flatFolders = mutableMapOf() + for ((folderServerId, demoFolder) in demoFolders) { + val fullName = if (parentName.isEmpty()) { + demoFolder.name + } else { + "$parentName/${demoFolder.name}" + } + val fullServerId = if (parentServerId.isEmpty()) { + folderServerId + } else { + "$parentServerId/$folderServerId" + } + flatFolders[fullServerId] = demoFolder.copy(name = fullName) + + val subFolders = demoFolder.subFolders + if (subFolders != null) { + flatFolders.putAll(flattenDemoFolders(demoFolder.subFolders, fullName, fullServerId)) + } + } + return flatFolders + } +} diff --git a/backend/demo/src/main/resources/contents.json b/backend/demo/src/main/resources/contents.json new file mode 100644 index 0000000..ba7d8fb --- /dev/null +++ b/backend/demo/src/main/resources/contents.json @@ -0,0 +1,88 @@ +{ + "inbox": { + "name": "Inbox", + "type": "INBOX", + "messageServerIds": [ + "intro", + "many_recipients", + "thread_1", + "thread_2", + "inline_image_data_uri", + "inline_image_attachment", + "localpart_exceeds_length_limit" + ] + }, + "trash": { + "name": "Trash", + "type": "TRASH", + "messageServerIds": [] + }, + "drafts": { + "name": "Drafts", + "type": "DRAFTS", + "messageServerIds": [] + }, + "sent": { + "name": "Sent", + "type": "SENT", + "messageServerIds": [] + }, + "archive": { + "name": "Archive", + "type": "ARCHIVE", + "messageServerIds": [] + }, + "spam": { + "name": "Spam", + "type": "SPAM", + "messageServerIds": [] + }, + "turing": { + "name": "Turing Awards", + "type": "REGULAR", + "messageServerIds": [ + "turing_award_1966", + "turing_award_1967", + "turing_award_1968", + "turing_award_1970", + "turing_award_1971", + "turing_award_1972", + "turing_award_1975", + "turing_award_1977", + "turing_award_1978", + "turing_award_1979", + "turing_award_1981", + "turing_award_1983", + "turing_award_1987", + "turing_award_1991", + "turing_award_1996" + ] + }, + "nested": { + "name": "Nested", + "type": "REGULAR", + "messageServerIds": [ + "nested_1" + ], + "subFolders": { + "nested_level_1": { + "name": "Nested Level 1", + "type": "REGULAR", + "messageServerIds": [ + "nested_level_1_1", + "nested_level_1_2" + ], + "subFolders": { + "nested_level_2": { + "name": "Nested Level 2", + "type": "REGULAR", + "messageServerIds": [ + "nested_level_2_1", + "nested_level_2_2" + ] + } + } + } + } + } +} diff --git a/backend/demo/src/main/resources/inbox/inline_image_attachment.eml b/backend/demo/src/main/resources/inbox/inline_image_attachment.eml new file mode 100644 index 0000000..3678248 --- /dev/null +++ b/backend/demo/src/main/resources/inbox/inline_image_attachment.eml @@ -0,0 +1,332 @@ +MIME-Version: 1.0 +From: "Test data" +Date: Tue, 14 Feb 2023 15:00:00 +0100 +Message-ID: +Subject: Inline image attachment +To: User +Content-Type: multipart/related; boundary=BOUNDARY + +--BOUNDARY +Content-Type: text/html; charset=UTF-8 + + + +

Inline image using a cid: URI to reference an attached image:

+ + + + +--BOUNDARY +Content-Type: image/png; name="thunderbird.png" +Content-Transfer-Encoding: base64 +Content-ID: +Content-Disposition: inline; filename="thunderbird.png" + +iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAEtWlUWHRYTUw6Y29tLmFkb2JlLnht +cAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQi +Pz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUg +NS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIy +LXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1s +bnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJo +dHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDov +L25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFk +b2JlLmNvbS94YXAvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hh +cC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9z +VHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjEyOCIKICAgZXhp +ZjpQaXhlbFlEaW1lbnNpb249IjEyOCIKICAgZXhpZjpDb2xvclNwYWNlPSIxIgogICB0aWZmOklt +YWdlV2lkdGg9IjEyOCIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMTI4IgogICB0aWZmOlJlc29sdXRp +b25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9u +PSI3Mi8xIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmls +ZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDI1LTAzLTA0VDE5OjEy +OjU5KzAxOjAwIgogICB4bXA6TWV0YWRhdGFEYXRlPSIyMDI1LTAzLTA0VDE5OjEyOjU5KzAxOjAw +Ij4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0 +RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5 +IFBob3RvIDIgMi42LjAiCiAgICAgIHN0RXZ0OndoZW49IjIwMjUtMDMtMDRUMTk6MTI6NTkrMDE6 +MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0 +aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PkBqBbkAAAGB +aUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRzytEURTHPzNDxAixUCwmYTU0fjSxUUZCSdMYZbCZ +eTNvRs2P13szabJVtooSG78W/AVslbVSRErWbIkNes6bUTPJnNu553O/957TveeCPZhUUkaVB1Lp +rB6Y9LkWQouumhccVNHMEO1hxdDG/P4ZKtrHHTYr3vRatSqf+9fqozFDAVut8Kii6VnhKeGZ1axm +8bZwq5IIR4VPhd26XFD41tIjRX62OF7kL4v1YGAc7E3CrngZR8pYSegpYXk5XalkTvm9j/USZyw9 +PyexU7wDgwCT+HAxzQTjeOlnRGYvvQzQJysq5HsK+bNkJFeRWSOPzgpxEmRxi5qT6jGJqugxGUny +Vv//9tVQBweK1Z0+qH4yzbduqNmC703T/Dw0ze8jcDzCRbqUnzmA4XfRN0ta1z40rsPZZUmL7MD5 +BrQ9aGE9XJAc4nZVhdcTaAhByzXULRV79rvP8T0E1+SrrmB3D3rkfOPyD2DqZ+MT1h/FAAAACXBI +WXMAAAsTAAALEwEAmpwYAAAgAElEQVR4nO29ebwmRXkv/q3qftfznn3OmY0BZoYBAdnRxKDmZxIQ +iEt+10RzzQ+SuIZEjahBXBL9GTXGeKMs0Zt7jbJqhKi43HsjKBEBBWTfZ2dgZs6s58xZ3vMu3V3P +/aO6qp7q7vfMDMy4RGo+Z97urv3Zn6equoHn0/Pp+fSrm8TPewD/mdNrPztZGhJiHMDiQGBcAGMS +GFBEEzFhU4to/Q3vGW3+PMf4PAE8h/Taz06WRgL5a2XgZVUhTq9LcWyfEMsHpBjskyIsi/2Dt62I +WkRxW1GnTTTfJZrtEO2aTdQP5xR96br3jG44nHN4ngAOIp3z2cngyEC+fkDKPxqR4teXhHK0cgBI +fraJAOyOk/aeOFk/nagfzCbqqmvfu+ihQ9nH8wSwn3TB5ftWDUvx0REpX7EkkMv75IFhXAGYThSm +FGEyUZhMFKYShZZSIACKAAWCAgACEhDKEBgMBIaCAEOBwJCUGAwkhgIJI032xkl3V5xs2h0nX/jm +9PwVez66gp7L/J4ngIK05NN7xKtrpbcuCeQlK0O5urQfnMcEPB0n2BjF2Bwl2JMoTMUKGtVpImho +U/qMzAOATDlTnMhVEIAgwtJSgNXlEo4pl7CqUkKfFNgZxe2nutHXdsfJxV973/jUs5nr8wTA0gWX +Tx8xIsV/OzKUrxkPZLVXOYfwBBu7MZ5KEsQEgAhEgBAAEeWRDqRIJnhsS5kLXl434D0yBHFirYIz +ahU0AkEb29HdO+P4L69579g9BzPn5wkAwO9dtq90ZCCvOrEU/Nf6AiJ+S5zgJ60ID3UjRCnCDYJ4 +ImIczbNJpdRRUNZWACMAV5myREHK5i8vhTizXsUptTL2xsnWZ7rxJV9676KvHsjcf+UJ4G1XzFx0 +bEn+t8WBrBXld4hwfyfGT9oRtseqgKOzCGIY55xNju+FEIxIqHcdrwvyy7LuiAgifX5CtYJzBuq0 +L07u2tKNXnnjJYtnF5r/rywBXHj59ClHh/Kba0rByqL8nYnCne0I93VidFTKuUBGh5tL8pFq8rUu +YM+yZbOqQV/4VfPqwycY8p+k/Z1Yq+LFfZXpyTh595feO3ZVLzj8ShLAW66YvvCMcvjlhhQymxcR +8O+tDu5ox0hUAihiyNKcJ4SAMdDMr8UzFwSqyEAnh0cU5HND0NoSRcSUEooUlgg8myMtekKtTEeU +w+8khAtvuGTxdLa7XzkCuOjK6U+dWQ7fXxSkWRcluHG2haluBEoih2CObPtj6ktbBJTyodXzTqwT +KKM9Msj3JIprnay4z0uLfJu2s8w9YWmptIGA33/so0d6cYRfGQJY8g97xX+tlW46tRy8JmvnNYnw +9ekmHpqfB5IEJAQgRIrnFJkyxTLBqQOTnyUQlwmOEM/dM1LFcLbzCn0i8eU7kwZZ99FePwiBxwA8 +BcJTRLRFAE8R8MzkJ1a1s3AJDxB+v/Tpj+vle15YDs7MPn+sG+Ore6fQirqanwywSTO0MAA3Rjdg +kUIQOt88swqFSwCy2daIMyxOeeQ74nBN+fEB13bK/bsB+h6A74Holr0fX7XzYOByWAlg2Wf2HAPg +9QD+dfv7Fm06nH0tlP7iypl/KkL+g60Ort+9B4kiJsb9MgRAcC40gkABQjpcCQFNJDpyk1fxgrXI +BQWjibxNQE6tkGkbxth8AoS3Afjx3r9dqQ4cGn46rCpg6T/s/rYAXk1EkZDyKiL62MRfjW09nH1m +05uumP7dsyrhd8rCl/v3zTXxr7v3IIFwIt0WEZlraDdLOFFPrJwQ0nG0bQvWUCukKnbjPAPziMcL +su5iSgCaLiYB/DNAn9/7tyufFVwPLwF8eucOAbHYkDkBHYDeMnHJ4usOZ78mvfHy6bEzy8EzY4Gs +8OePtVq4asdOJJarBBoViTVjFawYLmHzZITHd3aQkE8QQkhASpAItPWdBZ9np2nfnKz/ni2XqgYj +QWA52xXKSBBSXA/xohQDuBHAX+/9+MqN+wWM3+zhSUv/fuc4gJ15OUgE4B0T71/y+cPVt0kf+vzs +2heUgmP5s/WdCP9z2zbEaSRtbKCEi/+fUVzwomFUQgeOx3Z08Pav78b6vQRICSEDwHIps8ytUehb +6Y5Le3E/OUPOM/R4++k1i0EwQVDgZlKXCP8ogE/u/cSqBQNAJuX84EOX6NRUKcJar6QAIgGif1r6 +qYkPHL6+gTdfMX32cRnkTyUK/zIxgUjFICKsHC3h+3+xEm95yYiHfAA4cUkFLzp6AKJUBmToIx/w +YgBufhrxZPS8Fen8l4AsU3geBMeweUQgpfygobFbbB0CCGUBXEqgtSMf3HjByAc37pfBDx8BkDoV +SkH/cQJQmjuU+uSST2579eHqfjyQ/5Cd/Vd27Uan27GW9uffcASWDvS2gx/bFVuke6Fei0NfPxO/ +9yx55tWTa8uLBvIVQOs5WNXpkumXu4beDwHAUgDXALhh5IMbB3tOEIeRAEjRyXZSKeINYIRKiYDU +F5d8amLRoe77gsunl6wK5cn82SPtLjbM7LOIqTQaqJR7I/9/re3g4R0RfG5NEZsR7XqW5DO3Z/y5 +PMEJqJA/uYRhdQGfsGz7Asg+cpHL3wfovpEPbji91zwPpwR4IQAtAdKBk0O8+RtHHH3xUHc9KsXl +VWb1E4Dv7NkDEEHW+hAML0Zc6sMbb5zBAxOxVzch4Ev3zePd35321TfnemeFW24WliUNArPYJZcr +MsjiSDU8Y6WEyWBeCJMcRuVQjnDsxWoQfjLygQ1vKoLVYTECl3xy6zhIbRdAkBOPetTgARES8g93 +fvjIrx2Kvi+4fHrVmeXgiZFAlgEgAfBku4MvbnkKweAoEFY8owpE+LUVZRw9HGCyRdiwJ8KmyYQV +yYhzwDfQ+C1rM+fGpZfe0g1l6mVa88PGafCBocxbURQFbebVw7un/m7NZbzE4SGAjz/9ToAuzwMg +sxHCjfPRoH/45O3vGSrMPpB0weXT4+NSXH9cKfjtWhrrjQG0FOGreyaxDiEQBBk168tOC0dwfKRA +N5yXpQXrszMbwbPs2eqeNdxEHuHegk9mtZDg8tg4PCXUY1659QKi90/9/bGfNjUPTySQ1Ouz8jPL +ROlgzMRemMxNvRrAt59Nd++8cuZ/nlAK3sRX9xLotfwOEV40OAjqRNgQJYwD2RjSZHbyCM+t42LX +PdJr8CgU9TmRDgKRyCAoDxDTphfjN+EGZfYiuICRj29DEWIawLFEFAEYFxBjAI0LgXEijEOIFcOX +rjtj6lPH3mfaOKRpyce3LIVKtoIgGf3xWbJrlidw986PHvPrB9PX6y7bVzk2lPecUA49g48AtIjQ +UkCbCC0izBNhb6JwTyfG7jjxCmuk8+H1GK8pnxHrOYyk3G63hsE999WHNSS4wIBFJvcMwYnOtOt1 +yMv+j8m/O+btC0NPp0NvBCp1AZSSNgJmrH9Fzh007iEl0G6hAin1a4s/uvHlB9rNhVdMLzm9HGzP +Ih8wnA90QWiT+wsBnFoOsDRMp52KWU/fc+7THGXb9Q0++BzKpYQlqIJooWekOXvBQ77r0BPpfksi +ky9Y+/TW4UvX97T8eTqkBLD0U9trlCQXA+Ssf+sFGIT7HGU3T2ov4f87kH6WfHqvWBXIe48Mg5Fs +XkTQyCdCW2kV0CZgXhF2JoRnYgWhEgRJF5REoDgGJTEojgDFxmcCPBlDSl9yjqcMzpj+VSpFC7ML +ClQAqUycwf5koknCgiotw4jIa18IIcQVwx9Yv18Jf0htANVufQBQSygNUfr6iU0kp/5SQ4viVy39 +zKSYeN9IkcVg0xv7Sv9ndSlYXtRKF4QoRfoMEdZ3I2xud7A7ipDEMUgl8OUrg1GqYzVbBEAg00Wg +wOr6wg0avAkuHESR9+P6dnsEoVXlAuLfU/hcjWRMGt0NAaDfEEK8EcD1eQgWDv25pcX//6ZxxNFG +AA0zNQGntzhGhZ1DXr8iDF+262PH3tGrnzddMf2a36yE3yravNshQouA3YnCHc0WHp+dQZLEDnC5 +OgJuD0Bq/GnMmWxXTgQQQZASAxzne6If3LtNu/W5HXDls3SUETPIMksOZrwB622kZqR+PAHguKlP +rem5LnDoJEASXw5SDT5Ewy3+HI1haEtAEDMWk+SNAHoSwIpAXlmEfAWgRcD/nm3igZkZJHE3X7mI +uQw1Uup6CTcefx9ADFKxJg4ZQKTrA37zpCUIoN1DS1xwvTKDL498LtLNeNMdv1lC45NJ2/eMTp2/ +FAIfBNBz3eWQ2ACLP7L+QqjkDXbwJtJn1wDI3WvlCIKJCpooVlpOJb/Vq583XTH9u6tCuaIob32n +i8snduKne3ch6baZoUnenzB98ufKjJnnKT0uRSCwdpQC4hjU7QBxZDkPlvsAG+jKiGtKLXVLU8Ll +2XqeqDRqBJ6t5JGxved/pr4ACO8Zfv/aNb1g+pwJYPwjG46iuHsFpYs8uYEywAJKGzzgQFceAkgl +x4x/+Ml6UV8DQvxJEfffNtvE/9i6FVPtJqA04sxYSCm9jp5e++sSWSLx1ywscXICNnMjBYojULcN +kRILKeemueSQJew9z2Z6wxICnCCw/ZkKnr+aPtLeRq68flAG8Ne98PecCGD552YE4u71IBpw3JO6 +dYybLHEojvCEEQZZaSEIgVLqJUX9NSROyT67c66Jb+2cgFKJj0wfgjoKZwkDfjko9+dJBk4QSPPZ +s7Q9FXW0FyEYA3i6WnO+fuIQRfapIRIBoyIcnsknKG57mFvjRQhoYsjbOq8fvmRtzmMCniMBRHu3 +v5tUchbnNg484gDMqAMPCUY6pMAVKjmrqL9hKY/g9/fMzeEb27f7/RrEKQVNWMq273F+oZqCI1rz +jxG1JSrbHkGQgoACVAzqdnWZItOanFFsrUSV5X5W19CPZ/2nD4Wr4+104zDghipRBQIXFMH0WXsB +i//6yVUUdx8mQp8dMfFfNwvy//OTfc6GIuV39nz61NfwYn942b7Bc2ulfUE64Qfnmrh2+zYkfLKp +IcS7WT5UwtnHD+JFR9WxfLiMRX0hhush+ioS1VBACIEoIXRiQjtSmOso7JiJ8MTODv7XozP48eZ5 +xHbnjWTMxdbpjfsoAAgJEZY9f9Df9cPEPAcCQ7iL8sEiOg86ctn2ETGCyWbiCQKduO/TL/AePisv +YPnnZkR319PXQFGf7Y0KkAszmcwg2QRg992Z3UMCpNQx2XYaUrzGIH99u43rtj2jkZ/WMZMfqEj8 +4ZmjeN3pI3jh0hrK4f5pvBIKVEKBgarEeD+walEZv7GqD29+yQgIwO65GLdvaOJLP57E3U/Pw4Oy +2TJu55dou6BcgUOi+U/4dU2moQ/BwaOQ68eonbSa22dCHsFxXLC9iceD8FIAt/O5PysC6O7e+uci +ic/KnYXzUkYXWg7IlGF13f8it0lEsDL/NrEdsUpcgyTwqpOH8J7fXoqTltfzKvA5JAFgvBHidacO +4nWnDqITE25+YhaXfnsCO2di2792J1MECwXqdiBK6dKzmbdFjg8MT2ByouaJwdcIHK8oJxh29lDA +ubcAvQ0ZAjhoUI1/+ImQos4WkFrmjdzikjJT4+4QF/X+M/N/akcnpaWrSjsuWWR7uOCyfUefUy9v +/um+KVy/fSsAgcUDZXzi91bgnBOGUC8fxu2NBYkIeGBrC5feNIH7t7bSp4YAzGxkuqdQpsjN7urN +HAQlsz08w/3kJGn2iKDON5FUcj/C4IITADogWr7vH47fa6oeNNRIJRdBqWXOyk9dO2MoWVeQG1zZ +X2ZFm//J2y0UxLu3LuX9XvuXQ081E0U/2LMLoQQ+/8aVeOQjp+D3Th35mSMf0AA+fUUNN79zFW6/ +eDWOGArhjFljsCWguANnOHILjyGUuY85U4khXmRFm5EmwmHdcL7xRtwGVQKIKiBcyJs4KMgt/9yM +pKj7XodohlQ7cIdg7v5ZT4G7ijm/nP2BVmb7v2t6evc5pw1g8yfPxOvPXKSP6/0CpOOXVHH/pcfh +o+ePW+Tb+EGiA0ewFr4jBOcyGiaA5WIuVQEDFravght8ynA+tzNYGx7x4e1D73vC3ng2wJJPPPOP +qjnzIoq75+z+9GktZFJ3x1NvgUqO8h5aq1bZzrgJYgfBLWBvVsKTdi6LhrP3T+5oNY5bXLOcQgRL +BCq99txk4bwtU47IP+bnHeSBE59g13zY9nAQXL8Cuv0//80xvOaUIZz3TxuxYzp2NmDSheBvnCET +Ds68U8D1YiWpie17r50xA7FuofcDh4GsxAAgcByIjgWwFmASYPlls6+ibvtiirsvFUH4/oJRgZLo +3T6nZv1pJ9r9CCAVhmX9PLNl3EoEC5lE0UsUYduxi2t1o+MM4BWDiQ3UIQ3rGGJICSZRPvxsHfK3 +K1g7DS6P4c4yHW/blFk+VMIDlx6H154yYFUCSIGiLmD1MbfukWEOstxtVhM53v2BMFFvjeRsYS4F +kNbBS00uVwHvoqijB5vEf5jtb/yDjx2JOD7eIZeFUVlYl7LIzwVbmBoAnJqweovF6TXy30DAHYpQ +U4ZW0vkk5CPQIob9xWn35lQVpTBRaX1DBIp8YkpYe1Z6wBFSQumvcuUMEUgp8M9vPBJvPWuENR4B +SaQXdlKR7G8l5y4jbOTSqHgrxjNM7SSY8JFvgJRWc7YBAGQIYPlls2Ui9ZuaAAhI4uPGP/T4ao+O +kviNPidzkZ8JsXoHQQDNjxzJPBZPBXV0pTihtyvCV4kgizjbm6uBUeaZgZ1x1c21VaEM0Z5KFQ6h +ioCI0Xo2Jcq/NkU+9url+P3Thi3RUdQGQTlfhzIzYBOwh0Wd+59JZH9sNi/j6TJzmdoeSfyyvld8 +UgJOApyAbqdskEsAKI7+2Osuic534p3Sn0SLbI8olD8hk68UhFIQOYOvyAhUuPFta14XE/57QhAJ +y7KIcvN3tIO8KOfEwbmeWF1zbyRK0Z+Af5+1C0zb2V3Fl71hBc47sQG7UBbr+AVxBFnR5Ebmbfm2 +1MqYzo7bLK5z6s9TKbH2hRCr5bLjX8AJ4AyKOh7SKInssa3xDz1epaj7YkoHYES4pzCzSLSuIbP+ +zcRyR8bSPtPy5540ipevGXgzh4GZooAvnrnoTdiCHScOk+J0iLHKEw3TOrYtK0GF3y5vO1Y+1/N2 +CVp9XP3HK/Hbx/XrfQYqPW1kzQDeMTFJxtieq4D03osmWCPBlBfI2hVCsHwQUKr9DieA01W35SGW +4ujksQ88uhQAKOqcC6IKcog3nZOPaKXguYiWKPwYgJUWVsYRBqshvvjmEwEAdu8m9MsZkf5m9Ty3 +ATxCYARiON8ka0CSE+EG0VkxHzMRn22TSxLbHsOpTHFx1Z8chVpJaJfQYxyGfE8KGKnApQMs91tm +8ibFPAOWx+HkXCic5QiA6HRKNzcwjpQUdy4GAFJqpVvVM0iEW2fPGn1ZiQDD4SmxwGyOIFufUonw +/Q+ciVLgFlok04FcEnCOy3K9FAw5jBAA5wLGrC0TzjeI5gYlVxEx43STsgRj+hQCCJhdVw0lbv7L +Y/VdErkMNwrAinMvnOd8Ws9mKEiK5+v2itdiCBA4HQDk8s/NhABO4UuqZucKRd0Ll/3jPglSOiqX +Is1JiqIlVkMkhKX9wyhXmf5jvxqhTFKAcPbJizDS773LAQJAKcjDizLIMQjL2gnmOceQMbAT3oYh +EF6HuYrcSOQ2CQ+7mHJSOuSzbjE6UMZLV/frvQN2TLoFExnwlovTbGJGh/Xx2fgy0MnsMmZehOUg +Bahk1cA77++XAF4AoIY4snraFk7ixd3tGy+AoiXG/yJK18Cz6+Oe6CGcvWIVXnf0GmDpKsi+QXji +nu8RYFh7x3lrsGFKYbqjMlPSRGC4K2bIS9K/WOVFspUA6b3R1aYO4Nw5Li0sQjOqhBOQJQRo6WHa +CxnnG3MYACZmFZ6ZEXj/q1ZAGjvAciiHH1xwIYVTz4Cnmay1AQzCWQ3DdOyIejpDifbsyyURna71 +drpd2jjaRrxHnXcRqcVGbAu+9cv4U2YSqXpY0T+IV48vxtXNDgBA1gZ8FiUAlGgQpQTxkmOH0Vev +IpASD+9SmGyp3MRD4USrgBPnRs8aA5FzNtf/3A4Q0IiXcHEZk23bgC89bNwBeW9DKSCQ0C+OYmMD +gKenE2yZIQQSqFdLOPekIS0F0oE43cyojKd0ch7384uMJDARRPKIyVC2AJQ+Ea2i1ioJYKUQAkhi +2F00XD9H3dMQRyc59QAGLd/yJ1IoyQB/teYE/PNcBx0z/iBIQeKrDU0LenfQha9YZecyWA1wz/YI +u5tJzuUqSYdYImeFZxEihNPZVi0IJ77jlBES0j6+ec6Jx0yTOba2HLcpiIBy4CxqY3ATAev3dLF5 +WtkFKwLwBy9eDKjYm5e94QgzwkGZGIvBObGeyD7jYr8wxGzKJZoAhFJLJIAhLW0ye/RSESRAgpJo +qac/yP0aW8DorjcfdxK2KWB95PboiaDkYcfZATp/UX8Jq5cNuTECGG+UcOczXexuxg7hKVxKUnMb +h5c3T8oYcOT/GaNeMes+F1wiX9dzF9MwlrELKoGLFnJJ8tjODjZPE4ZqoTe3I0ZrWDYgGZwpF0Bw +NKivWBwnPykwWHjSIcM9hqhSAiCBUQlg2NtDZ6nPhWqtVLB2QOKMQDiE/vqyo/BbA/24sWleSGlk +dqjLK1+NmAm8+exj0rduOQCHUmBxfxm3belgYjZyc0h/Q6n/uEHHmYQjyeNiBpOsiDeXWaPPSA8r +gdN8KTTnO8/K9fHARBubpwljjZJHoZRi6o/PWuyeeNa+boB1l6lsJEU6QVtSeLh2lqxr08JXGVcU +IxJm1c0MIrumbyoTc98U4+T0eqzeh7etOAp3d2JsTdggAQgZpsPkbTspcMYxo07iGSADqJclFjXK +uG1TG9unIw/4RJoAKgGL7pHz6Y3Rx2HAfffsc17H9G9iDiZPwRmMgQCqbC2VWLmfPtPCln0K440S +Ak7YjCnPXNmPwkmnBEGWnEz7RlxZg8DCUoPSyQxLTF7wB65NlZj8YQkhhvVpF5kiGT6COLLtIMjN +BgBI4U1rTkRZCPyoE/sTMuJNMHbNqID+etmWt2NOiwxWAoz2V3Drxnk8sy9/2kcKoBbCSoFsMIff +e/EC/ozhgZfhS80cyWXpbBHOdUoBP9kyj6dnFBY1yqiVpNc2R+lgvQQkCXvC4UkWyfYksrfGz+vw +RH5zdoGJLxCnRqAe1JAEMAQiCCEdkrO7djJ2gUVeGvFb2hjE8bUaukR4Mkr36jECIZDeH5dDPlCS +AqUw8EDAARVIgf5qgLGBKm5d38TTU44IDBykAMqhs+xNXS4FilShecZdQSMIeV1OUOWARSi5dCDg +tk1z2DqjMNJXRn8lyItwNi+CwHBNMiS5v7wxx4kj2xJPWb8pEw9Aut0siU2gZEjbAARABgw5fIUv +G+yh3N8rjjgaMRHWRQk6ig2Wj7VSc22z7V9rlvWjl6drWqmVJGpliUWDNdy8YQ6bJ7u5cmEqCaT0 +Q7GAz8HZyJ3R9TyfMnVSfkQl8MPTpplEAT/YMIvtcwojjTIGqoFnnPJfG2MAcOySij9Qh6ni5wZu +cOC3N9nfdOD8baV6MarjJgX0axtAQBOAKegZg9lGzTP9VwlCvHhwGAmAxyMm0lJKNhsSg0ofQ7yT +LGuWDXjGE7/mwOuvBKiVJMb7q7hl/Sw27e3kgSuAaqD1s0GiQn4xxxh/XC1wpHM7wiCtGjrPg6Mm +UoTvrZ3GjjmFsf4K+iuBF8rOzoMTzuqxiofsFPIFyVCTcOv/PNjjBXnS+8Kt0QS96GdvBySAGggQ +MnDiwur7AqRz6aAILz5iJUIhtPjnr17JDrJS99tNg0ZHjfc7qBiDtgBagRRoVAI0qiEWNar43rpZ +rN/TzhUXAqiH6RpC+pBzdaJYhM/US6dnCIPgNn2AgFrJtceJNEoI331sGnvmCeMDVdRKEvWSLGb9 +gr/lQyX4Ytv36y38WUPG4CPjw9rBZ+4Nnvg4ILQEsLdUkQDmCARRrurlyl5cnw7MhYp1BPusRYuR +gDCnCM9ECcME0jI6yWqdtecG21cNcxIgm0y1SihRCSQG+0oYbVRx87pZrNvVtmV45Xqo1YJpN7uw +w8V9wvrl0qcUAI2yDvAYQJpynZhw06P7MN0BxgerqIQS/RV/LmBtMpTa+2bX3dn/ObJYA/y0kOAc +zn/J3ZPXK0u+BOhIIpoRQkBW+9JJZijJXCvfBQQprFy0GOOlEiLSn1uxcRWRUinvXAQQZWcHGIxN +z+ct+yyzcAHXVwkQCGC0UcZwXwW3rJvB2p0tv3KaaiVfZwMucmd0vxX7hlgZ8ushQxi5sbQiwr89 +NIm5CBgb0Mg3er9oHuZaZPKenuymRJ+ZKdPhRdG9wgM5xtKnLNezmyQG23EFQLRCALOkCMJwKAC+ +88SIHU8cpXnHDS1CTPrzpzsSRzjE+rV3ApCNISTtOZtJAPbN+bq8WHO5JAXQqISYaccY668gUYSb +184gVsAJS/JffkuDcOikgU4eo+dqAHBLxNVQ/2WJDwCaXYV/e2gKSkiM9VdQK0v0lQME0n+nb7Ze +Udq0p5vZ60HOJki/RsHVKD9AwuI/thNzxsA7JmYyiYCo5QqCACFbEsCsLiQhKlUbvrV0yXS/bSjV +4+O1PsRIX8hoiZhRcSZ0GfSPWuo2XsDUTMsDkplfVgrwVAoEqiWJQAqMD1Qx1FfBLeum8ciEkwTc ++KoGGqGGy+1m0Ey7BE0wJq7AJTIRMNtR+Or9e5CQwGhfBY1qiHIoUTVihsM7M25u3pi/9bsjEBiS +jWjKLAzpxR32iPVjJXP6zH/HoWkrvevM+wME5kMAs2Z4stKHpNXMU479JU/ULKrW0E2B1FJmR7CZ +sdDExHSUrA8AMrCLEQBhz0wL2Wqme4/IDQDSh/VSgCjF5NhgFYoI3187DSLCSUvr3tABHbwRZWC2 +yxDExLqQQKU10qYAAB2nSURBVH+oVYYnkNMyM50E/3rfHoSlEMONCvrrIQIp0FcKfHbvRf/sGgLo +Rgm6iRHzDE6stBm/dwjUPiCrvwTryX+fgAkxCx39i9u2TEp08xLAjOlQVvt8sc8tS5h9ffq+Xqmi +JgMk0G/lYqaFP1hzq7RYk41hJ0WI8OjG3Z6t0NN4YjfmslHWAaRqKLGov6ptgrXTeGj7vN+3BgVK +EhiqpEj2iI7Qz+yFrMiZaiW4/t7dCMMQQ/UKhuolyLR/G0E37WVYv0iKEQFP72kx6jM5ftSPC4ac +3i/sw3ANuT9z3523Ve2HK4VohiDabBqS9UGLYL8zJixT+2B8YBgxG0vXg4DtybZlTrYEg4uQTO2w +Q2+2Opib76JR93cC9UockIEUqJcDzHcT9KUhY0UK31+7D4kinHZEX66+EMBAWS8VdxWhJHU0Mtu2 +ud/TjPG1+/agWi1hoF7GSKOsXc1U7y80vqL2TLrpgUlPf3uUrUcKzy3PdUT5e9ue8KNbAKjTZEUt +xW6TAJ60XZarGXfNiXxvPz8Ii/qGkACIKH0vnw2pcbckP3DZPwYEoZswESb2uLeYFYVCzDNi/8x9 +NZQop1tw+mshhhvaJrh13TTufWbOcgffnUPQQZ1aKBBmjDeeds5G+Mp9u1GphOivljDaKEMKLXEq +XO8fZGp1Ynx/bdvXQ94gMu54Vq8zD8sSici2AVefFChqs3tTR66VAJ7gblkwOG47Mc8sfxglTISR +SgVdImsERlxGm8T0mvUkpEAwspSdKCase3qvh9gUBDlkFyUCoa+i9bEAMFwvYaivjMG+Mn64fho/ +3TJnyxYHnN2o7QwI2DbdxfX37kKtHKJRLWFsoIJACpQCgVpZeuMrItCF8u99apaJaD3uULgNJc5L +M4EhoyvSX88zMDqIwZ4TDwjUnnX0wW0EIR7REsDUAxAMLoKzQtOBm21i+gZIXb+EtOsUKU0IedWh +KdRoA9NGuOgIB3Ii3HrfZvCURXahBAS5eYPQqEgbAR3pK2OwVsZArYzbNkzj7qdmucAphpNtF3h6 +qoOv3bcbfdWyRn5/FaEU2uhL7Y7s+AolFweH5VjgK/e4T/gGIFSkgAQghbB7CiXMK+2ybTo/nrKw +yk7QPG7PZZ5YKf2gnHjfyB4itde82EEEIWT/CIwYIrtRhDyIEYAIhBga+TXP93QjMi4O35goSlUE +A4vsYB/ZuAszc+2chGJMkrV7nBRL76VwyAmkwKKBCgZqZfTXyrh94wx+/NSs5xp6cGNT27injRsf +3IN6tYRGtYRF/VWUQx2Db1Rkrrw3vgwuss+JgLs3zOCpvdoVKQmBmpSQEAggEAqCBBCk94EQlhhM +o/mXQrFrftIohTl1munyb2YgEB3RGFsv04JrARdfDobGmA7yf806gBb76Q5dAHUnY3Q5QyiFokkg +HDvSbncmRfjp49u8OWWFCVzTXuLzCqWOD5jrRQNVDNS0SvjJ5lncuWnWt9jhRD8BWLerhW8/PInB +ekUjv1Gx7fWVA8/dyo4hN54sQRAw107wqX/fBQCoCIFGIBCQPqMfCKREINJ7YZ+JFJkWqWbgfDg2 +uANbTggBtGdZvld+++yVp1FK0up+syWMkgRBYwQiCP3ZsV9BSm+mJEKXCB2lULbgNO2LHLJ4vuwf +QTAwagnkhu8/ZnP3Fw1cyCiuhhKl1CishAKL+jUyRxoV3Pv0LL7x8F7snIv052IBKEWYmo/x3cem +8O9P7MNQo4K+SojRRhX1ipYotZJEyCx+KvgtesbzFAGfvWUHurFCTQADYaA5X2pEhyLletJL2iH0 +6+3NIRdLBB5DZQBAsGcCBADqtkBxt1jXCfEkYF4QQeoHQgTvcG4HEI6tQDSx0YOuPTYGWA/ApJrk +e2KzYGCQSKMrRIRw+bFIpu8EILB52yQmds9g6dgAeqUs3osMXwColQOoToJEEWrlAOMDNeyd60AK +gd3NGDfcvwexUuivhGh2YgSBRDkMMNSooF4OMdwo2+heJbX4s/3wGfIoX6+8n26ew12bmuiTEgOB +TJeptatH6a8EQQlAQkCl+qObSmUb4PE6Kpq965vas76O4vlC3gq4o2H/oVSS8DdQhKPL9PvumOjh +HUdJjCgV/12iVALorvlJIG/AwokyAQFZ60cwuhzmYOiXbrrPcgtQvJ+fAzcmnwBseQU0KgHCVBKU +Q4GxgQoG62U0qjqSN9pfRRAEGOmvYaivgr5qiMFaGWP9FYv8akmiWpKeC1m0p9Bc8j2HZiwRAdsm +u/j7/z2BkSDASCmw4t2IfmMABt6frt9Vys2R0NO9Nnixo4k7NvhTWF4ENwEpAez80PJpEN0HlX5T +RyUAAaXFR7sGPFVAmG/PQwHoKEJCQIlFrfyOzMiNaGIfRCKgtGyNXfj40QObsHXnjJ2wEX+AQ3ai +3Fl9Irdty5ZT7jxhtRR4NsFIo4zFA3UM1ivoq5RQr4Qa8fUKFg/UMdIoW6KphBJhIL19g+acQfbQ +iTltbPo2ykIRsHVvFxd/5WmMBSGGgsAiPYQjghLMiSKBAEAA/bxpvxPEqU3ldKR/nCw1AJuTeaQj +xYUQE82rz9lgCSBt5T9ABPOyZaUSBINjEGaZ2HSeWjfTzVl0iezHmaSAfZef6QcQbnEDmZh1mi3K +VZSWr7HS4svfukd7GAqIEreJg0jfm9b4KR1+WIMfGSMCpJSolgO96UkA9YrESEP79YsHaxjrr2C0 +UUK9IiGF5sRaKUCQIj9SbHOIBZU7SGoIQpEOLsUK6Kbj3LK3i4/c8AyOCEP0GaNOaASbXymk5n5D +EGmZeaUQZ10di+DiCKT5UAc6TaDodfmmESHvNnc8nHWLRoImZ0GaskrjRzvEs41uM80ZxESIUyKI +CWhINlJv0FwsEHussRQuXqlXCkG48/7NWP/ULgtgfm6P7UnWnA+H8Ei57jgh6HYEauUQYbqnS0BL +hHIobDhXCIFSKFEKAygIy9UmGWIzRAjoPk2IJDYEmo5zy+4uPvP17VgahtrAY1Z9CEME+togXT8X +iBRhLlFceDJ1alSrsw3s4c9UPdD8VA/kG/jJH5hrSwDUad9BSk1pceMOiYi+QQSNIdapHsDs/Axi +CG0HkI6r9wvpDc60we0AShGfPbpUXnmK/kI3CB/7wi2Io1jvy2cIkCnQPeJIJYSAOyBqjnrxukkq +DWrlEGEYIJBaxJfCAOVSgEopgBDu+Jb587aTkZYiMSM+s4dAsb7/4/FpfOGbExgW0rp2kiNf6Gcl +pJIgJQQpBBSAqfS1ch6MLJKR5iGfDwJa+7QKz2GdDNwVpPx6jgB2/e0xHYD+zW73Vok+QSKAcMlq +x/3pn4ojzHY7iEFpSJjQJ40v6iRFTgL4eLf6TZSrKB99MkDA5PQcrvrmXVasRynADZcbpEjhTu0Y +hGSX1o3hbCVCqntISBAk9CEM4b0exiJcuf4pJTAjGQzRWXUEYGY+xhe+O4G7755BfyBTRKccDuGQ +LYzRZ9w/nU9EmOh29cupc3CCmwyEfQmIZyLEkbb8eyUBQAZ3N69+5USOADRk1Zft933B1EG5itKS +1RZ7eiyEfbNTiEl7ATEBldSFcZEQUzh93RkMBTNiskQhIIeXIhw/CiDCd3/4KJ7cMGElnhHHEdur +z0/xUIrcTsLsAAV0lf8SJ6sqUugZHU/kv1TC0HFMvpThyRiAsQLuXzeHL399B7qTCUqeaBeQSAkh +JQLJOF5Cl1EgbO1E2tbwOsowDwuwcRBCEai51zcY4cq7pYTgWp7tEYCoNe4iUmv9z63ob/sFI0sQ +DIwhPTAKImBqahc6RDYglBBQ5+9UYy6hfT0aD5UZgkrvhQDKR70QwfBiEBE+csV3sGdy1ur1mNxx +boOsbuoVdJWTkEZcm9dJJ/CPjUfk3hcUpNLDHAPjxmUn8beNxeQTYDcB1m1r4Ybv7sQ9d0+jxPS7 +4XCNYCYBIJgHIFASQEIKW9rd1OUjD9GaWRz1kYUXQzCloj9n+HGRCBDEPILyVT0JYOel4wSir2hX +0CAr7UQlKC07BqJSgzkwundyAlGirBEYg1C1/aWi3a4FMDbMsBLf1k4QKK8+A0FjGN0oxgc+8w3M +zncs0KMUCXFKEAbhZq8ff/mDRP5FEkZkGrXAX/9GrL4hMOOKdpgnMN9VuHftLK79+gTu+OEUmtNJ +yulwfjx4aFc4aSBg7YFAAHOJwoZ2V++nyLl8DAfswI3g+QJA3C4W/cTaAAAZ3NK86re9N8DmF7Vl +8CUQKX0CWCMe5nMsQqC04vjUWAOiTgsz87MpMgiRAsoZV89uI88JUP/e7SImQAYoH/tiyEofJvfN +4eNXfhudKPHP/MG9KcTcm4CNEd0834CPexIROdvBjNAYdt102MbgjGKF7bs7uPeBadz0jZ148r45 +lKJ0GTd16cKU67Vxx9w+4/qlZYzVv60TYX2rgygxB2bSmRiL3iJZOJ2FjARQCjS3F4UpG/+XwRdz +RYrqjX143U0Q4rW2EeKregLJvh2Itq0DAVh0xAswtnyVxTERMBEniLxQGbmeDFUKgawnYO0GY2V0 +W+g8eSfU/CxWHzWOS//itaj3VSxnh9JxqxSOms09f22LUQV8SKHwdwnboQHoxoSpmQjbd7Sxd1sX +nX2xd8TcxOfMNnMCedeW+QAochu/Fen9k+tabeyLY+SSnT+gvz/sCIF4obQcze5xu329dnzYkpBP +i8FlR899/gwvowcBrD0DwL0eNWYs+2jbesT7diIsVbDq1N9CYqgTQFMlmFXpvkIeoTKbH8ntESAz +YTuszMCjLjpr74Kam8TQQB1/c/F/wdiYe5mEhK+nBRuytX+EUwdCAKEC9u3soNMltGKF9nyCuE2I +WwmacwlaLWWll5W+SKVHigiDbAWHGJVBfmLaICNxCE+1OnimGyExUT63fOcjj03ALu4YOJm85iSo +47bZO5RSFoygoHRx89rzP5fFdSEBAMCiDzz+f4SQ55pB+mQjIEihu209kn27MHbMaaiPLEPCvISp +OLNL2GvBEAGT36aIRzAanEIpdNbfjWRqJ4JA4tJ3vharVx+hN3GyLrii4WvxXJpKAWx+dA57NrQt +skxdg0DO6RrZbgnW5qUdK/iI94jA3BPh6W6EDa1O8eKOlwqQZ+DEiaQ9A5rfl63sl7G0IHagVFvR +vPrsnMjJb29JU/3l79woiN7i7U0z4DX7BvpHQN0O2vt2or74aKtfEwUooZdas3PzBpcZKIzVygIe +AgAJgXD0CFASIZmdxO13PYmRwTqOWjEOSokzJvZ2LmIvhxDunL/xHp5pR7h14yyGA4m6lNaflkIb +rfqLwc5gE/ZeMFWj7wWMgOT3+npeKWzudPDQfAs7urH9xlGv7W0cRoWnfwzMuvMLxPoLkiz9XfPa +c28ryupJAPO3X7m1/rI/fzmIVrrDBoZqU81JQNA/hGR+BhSWEFTqiOAMrQQOkWYCToIx3vN2sqRY +SyFLFgACwdA4ZN8QkuldePDhTWjOt3DCcUdCphi27/SBCw5xOjNie2yghCcn27hlxxx2xdqLaUiJ +ipTWzTTEYIMunBiEsGrFINssArWIsKMb49FWB48225iME8SKve/fI3x2k9HZgmV7u4DiDmhuTx5h +OXvKQncvwuofRA9dW2BwLEAAAFB/2Ts2g+hPvaNinujS10FjGJ3JCQT9ozqODmLcTxan3rDMT9Fa +tSfXTWX9TFb7EI6ugGruw8Z1m7Fu4wRecMxyVGoVp0mY6E+Ue2eAiRqSEDh5RR+2T3WxbqqDLd0I +D7c72NyNMacUWkqvb5SFQEmInFQQKSG0iDCdKOyMYqxvd3F/s4XHWh1s70Zoxokn4o1k8JmfvB89 +drIGv7WPzMziDmh2dx7ZWQnBkyx9rnntuTcX5PSs4qXRSx7+miB6vS4pma9qXBOnH6nTQml0ORSn +dOWuvT0FXNzziaRAohxwsrpRId6xCdG2tUAS45WvOBWveuWLUKlVrRTgoeJE6YMh3C0EEa6/Yyd+ +unHOVz3kJFRVpkQAQKQBrYgU5tk6PeUusvNhoj9LAJQpC9e3117U1sgvUh897AmC2IpSbU3z6rPb ++Uo6LSgBAKB+1kV3gNRbBETVRfBMvywWKwARhCAhIEKz0Yg8KceJJjNSjxQ9OOYkhwGogGiMIFh0 +JJBEWP/4etz8wwfR31fF8mWjEFK6wA9ryv/4g8BJK/rQiRSe2t22RCfsOE1MQKFDhA7p8w8R6VL8 +yLavyrKT4MjPG3m605SZPIJIr6NWKvZ7IL9XCsp/2rzmlY/0LnAAEgAARv/qwXeD6LO6Q0BIqZFv +PvooGCAIENU+iLL7Rk4OUJ5R6UsAv2iWExyQfLcIUPPTiJ95HGpuEv39dfzJG16B415wJAC9oycU +zp0zMQK+3+7pXS38860T6ETuNSxmuEVALvq0m19Jv49Hv3tJIf99oDwhFL7csdPMx/i5f1tUhwgk +g1ua17/6nHymn/YrAQCg//y/uYfa868F0RKHFLYcxtQ9AFDUhgClW8qYEWMkCKcXE2QygY5ce3li +cYwmbDsirCIYXQHZP4Zuq4W77noIDz2yCcuXjqLRqEMJrb74sq5hdEXAYKOElx03gJn5BNsmO647 +7ovzmDUjeF/MkVVh+c+4UAHLkaOFLDLbs3lr34vLFHM/CdFCUD43evi6Aj/RTwckAQBg9JKHX4S4 ++xNABNmAhKNy3rIAwjJEbYA54uY/AX7e0IDTM3q4YeMFRExMImNPZLhRdeeR7NoCNbUdpUDg5S85 +AWecvgZj4yMIpLQ1eMQwIS0p5loR7l43g+8/speH4K2B5sYJ129GUhAfUw5P/thzNgSlK3vd+WxF +v1yPRDL8ZPO63/3QgoXY6A84jb7nvk+C1AeKdXQBRQoAMtRvCxeBYzeGVAjhtjL5ogFcWnhAt7A3 +hFTgSdh7BTWzF2pmF9TMbtTKIV72Gyfi5JNWYXh00LpwuqRuxsQTkjjBgxtn8L2HJzHfSdyw+Ph6 +GC/E/vcIgKu1IvqIu1rfJxF6pl4EoEX/IwgrpzevPqfQ7cumgyKAsb/dJpK9228DJS+zZjbgbAET +ucsuQgAQtQGISp8tZ3iED96DCCcSmMcZ6ZBXooXN2XKKoJqTUHOToNYM+kuEs379eBy5YhyDg/3o +768CaWAIYFKWCNt3zmDzjiae2N7G1qk4M7YCxBchvSA86en9TlOLfL5ymml/QdFPNIOwclrz2vM2 +FRYoSAdFAAAwcvF9SxBHD0JgMbfr3Dgp51KZQqJUg+wbAnnWMnx9m3USKA0ZC84uXFJoCWD9Z3Cc +k9++G2R6mYDaTVBrFtRtAlEXI40yjj5iGMuXjWJs0SCGhxqomaPraXOdToxde2exdVcTO/d1sWM2 +wVSLuXvZeXjqzMyTfRwCBDSnWFzfn7+u2xvxaZ+EoPxHzevO/2rPQgXpoAkAAEYuvvd3KO7+uwDS +zzjooLyeuH2nVkZHQ09ABpB9w0C5mkcO16VZAzsLRP4MWf2czzcteXZED/8ZREASQcWRNuM4oogA +EehX4MswnW92uvl+s2O2JUxYt2gfX8/28vkkw39pXv+qtyxcMJ+eFQEAwMhf3vMJqOiD3EVzLlxv +EWhiB7Jcg6gPAUHJlTdin+A4xGvINx55Vs7F4vkZ4rEWeoGU4rojj8cFVE6RvjdlhAAp5QlMoWKo +5iTQLVjKLeqjQDLalVUhH0WlcXrzy7+1gOFQnJ71Ww6CkWUfJogbjYjOv1ae/7nnIuVw1ZlHMrkN +am4S/qtokKp5g2jG1jxIwFwqb13BJP5Mx27hNp2w7/ZxJGYXr7zEx0A55NszD1ZVkRsjsZfhEgGd +GajpiYWRz+vnQsiwDEJC7kBQOv/ZIB94DhIAAEYu/mlI3c7NUMkrfB3NkmUM5hfb504yyL5hiGq/ +V03DLsPZaYYNK+dEePHzvDRht9w/p8xehSyheAZZZnwFqokNANSe0Vu3kv0Z6HaSCxQhEMQ0wvJL +m9ee9+h+GuyZnhMBAMDIe+6vUbt5Jyg5LafzGRZyHJcDHIGEhKwPQtYHQHbdIS1gm8xwLWV88xzM +TP9sXFl1wx4WRuM8CeOCVmx2GfHsSy/qzAGt6fS7TL3GmemvyOjzvYcWwsorm9eed/sCLe03PWcC +AIDhd901QlHnbqGSYxzlEtPj6G1sMV1riwgBURvQH5viNoKuBGf1Z16kJOBvGjTh2HSaPReYrNTO +GpKs3wxSihHP9HISabeuMwdBSXGbRelADD4gRlj5g+a15920cOH9p0NCAAAw/K6fHEGd1u0gOto9 +7SGmCyaZC50KACT0O4wrDYhaH8whUtdElqPdjdtGlVUlReI1bTP7oiuuNjyBxdrwvBKlrfp2k72T +T2XaK4BHBhLFqs00QQmC8p81rzs/t8Hz2aRDRgAAMPyOH49R3LkVSr0ww1q6QF4z5FKxNa3bEuU6 +RLUBlOsQUjgi2A+R+dY+GwTz2wvdbE40fFhc+hDp1bqorQ9lZoI4OQn1bLlf6/wOgtKFzevOv2GB +Vg4qHVICAIDhd93VR93296Diswon22OS3m6ZXvX4UnJY0S+fLlUhwko+/sAZtKdfXuDq2b7YOKzY +T6VU1NEIj1qa03OqmvJ6fD+BnP2JfiLMIiz/v81rz/vBggUPMh1yAgCA4XfdHVK3/Q0k0aszstNL +hnH1dRHn81KZfI6kdOEJYRkirIBkmAZqShnAZ20J2BEUimqlgKSjP/KYRFqvx92CoA2fiSE4LiV6 +AIrX71VGc/4uhJVzm9ee+8D+WjrYdFgIAAAW/c1TIt799Ocp6rxdmNBbBo+eFc1+XMro6yxXFohU +bp8LiJQYQth9VjL9To85CawysQpKz4OpyH2UwVNnRbN1BEBZ9bBgMuqrWDqkkdVNCEpnH0x8/2DS +YSMAk4Yu+tEfUdz9ggD1cykPLMT18ErljbzedfLbrgraA2yDvi3Ykw0PgIszon+h9rh6WHBTR/hN +BOEFzWvObeYLHJp02AkAAIb+4o7ViLrfgkpONM9ov7oxw/29LG+vxkLEwR86bvXyFpJAC4xTZzuX +02mZZ6fziaiNoPRXzet+98reDRya9DMhAAAYfve9JdWa+++Iu3+6cL89dD5QrApsrd62hq6aiRvw +9hYcS3F7vN2ChwtVYOHioh7FJgSl/9K89ryH9jO4Q5J+ZgRg0uBFt/8B4s5lUGppcYkioC/M/bkj +Uz3adQx/gJb5AqKfu475Zg5ADWUImwCFoHQdwsqfNa/6nf2sEB269DMnAAAYvOhHdSj1GcTdt8K8 +qxBAsbGVtQH80tgP5wNFXLo/dcLshF7qKauhdEfFAyiqy58I+QiC0lub15x7d3Gdw5d+LgRg0uBF +PzoFcfQvUMkZHqIBZvD15tKczi9aGNpvfD9fvnd+UZv7I8DecyBgGkHpY6Ix/tm5L5x5INRzyNPP +lQAAYOSDG0Wyd+ufIe5+CKSW231++wFHdjmmlwGX3yewgNhf0NgzRdjOI9pPe9m23SgiyOAbkKV3 +Na955a79Vz586edOACYN/vmdIZLoIoqj9wlSRzqJ0JN70ose0oHM3kTKfElrYc5euExGnRyQyHdl +CehAhjdABn/dvObcLQde+fClXxgCMGn40nVSTW57MyXx+wUlq4sMvvy+gkyZ/cb2czV6tsXbBGz0 +AAfM+bpuE0Hpeojgo81r3Bu6fhHSLxwBmDT8/nVCTW3/fUqit0IlvymAsjP6erlRaYleHgTQw04w +GbnK/jO70r2wK2fyCWIdZHADZPjZ5tXnHMR57p9d+oUlAJ4G3n7biFDJ2ymJ/4gHk/LJuHrcmjRZ ++/HNe7SXzd6fBCBgD2T4LQj5heY1597Xu9NfjPRLQQA8DbzttpOQRG8CqZcjSV4IYV5U7jj5oPT0 +AYjxwtU9L4mnEAR3AfJGDCy+ae7K01RBoV/I9EtHADwN/NmdNcTt80mp8wSpl5JK1gCQ+xXRNhXo +fi9Oz/DtrT7KbRDybkj5fUB+e+7qc7Yd+tn9bNIvNQFkU/9bbq2D6GSQOhWgF0LRsSC1CqSWAfA/ +LLzfzRfoQogJQGyBDNYD4gkAj0DI++auOrvHe9l++dJ/KgJYKA289YchqWQYwDCI9B8wBA2DfeaP +hJwURPtmv/w7uY+hPp+eT8+n59N/rvR/AbZg/nPHDyZDAAAAAElFTkSuQmCC +--BOUNDARY-- diff --git a/backend/demo/src/main/resources/inbox/inline_image_data_uri.eml b/backend/demo/src/main/resources/inbox/inline_image_data_uri.eml new file mode 100644 index 0000000..8cdaa6b --- /dev/null +++ b/backend/demo/src/main/resources/inbox/inline_image_data_uri.eml @@ -0,0 +1,16 @@ +MIME-Version: 1.0 +From: "Test data" +Date: Tue, 14 Feb 2023 14:23:00 +0100 +Message-ID: +Subject: Inline image (data: URI) +To: User +Content-Type: text/html; charset=UTF-8 + + + +

Inline image using a data: URI:

+ + + + + diff --git a/backend/demo/src/main/resources/inbox/intro.eml b/backend/demo/src/main/resources/inbox/intro.eml new file mode 100644 index 0000000..a5c7cee --- /dev/null +++ b/backend/demo/src/main/resources/inbox/intro.eml @@ -0,0 +1,9 @@ +MIME-Version: 1.0 +From: "Thunderbird" +Date: Thu, 23 Sep 2021 23:42:00 +0200 +Message-ID: +Subject: Welcome to Thunderbird for Android +To: User +Content-Type: text/plain; charset=UTF-8 + +Congratulations, you have managed to set up Thunderbird for Android's demo account. Have fun exploring the app. diff --git a/backend/demo/src/main/resources/inbox/localpart_exceeds_length_limit.eml b/backend/demo/src/main/resources/inbox/localpart_exceeds_length_limit.eml new file mode 100644 index 0000000..82f8d89 --- /dev/null +++ b/backend/demo/src/main/resources/inbox/localpart_exceeds_length_limit.eml @@ -0,0 +1,9 @@ +MIME-Version: 1.0 +From: Sender (local part exceeds maximum length) +Date: Thu, 15 Jun 2023 18:00:00 +0200 +Message-ID: +Subject: Localpart of email address exceeds 64 characters +To: User +Content-Type: text/plain; charset=UTF-8 + +You should still be able to read this message. diff --git a/backend/demo/src/main/resources/inbox/many_recipients.eml b/backend/demo/src/main/resources/inbox/many_recipients.eml new file mode 100644 index 0000000..678c764 --- /dev/null +++ b/backend/demo/src/main/resources/inbox/many_recipients.eml @@ -0,0 +1,42 @@ +MIME-Version: 1.0 +From: "Alice" , "Bob" +Sender: "Bernd" +Reply-To: +Date: Mon, 23 Jan 2023 12:00:00 +0100 +Message-ID: +Subject: Message details demo +To: "User 1" , + "User 2" , + "User 3" , + "User 4" , + "User 5" , + "User 6" , + "User 7" , + "User 8" , + "User 9" , + "User 10" , + "User 11" , + "User 12" , + "User 13" , + "User 14" , + "User 15" , + "User 16" , + "User 17" , + "User 18" , + "User 19" , + "User 20" +Cc: "Copy 1" , + "Copy 2" , + "Copy 3" +Bcc: "Blind 1" , + "Blind 2" , + "Blind 3" +Content-Type: text/plain; charset=UTF-8 + +This message contains… +- multiple addresses in the From: header +- a Sender: header +- a Reply-To: header +- multiple addresses in the To: header +- multiple addresses in the Cc: header +- multiple addresses in the Bcc: header diff --git a/backend/demo/src/main/resources/inbox/thread_1.eml b/backend/demo/src/main/resources/inbox/thread_1.eml new file mode 100644 index 0000000..bebb2a3 --- /dev/null +++ b/backend/demo/src/main/resources/inbox/thread_1.eml @@ -0,0 +1,9 @@ +MIME-Version: 1.0 +From: Alice +Date: Fri, 10 Feb 2023 10:00:00 +0100 +Message-ID: +Subject: Thread +To: Bob +Content-Type: text/plain; charset=UTF-8 + +This is the first message in this thread. diff --git a/backend/demo/src/main/resources/inbox/thread_2.eml b/backend/demo/src/main/resources/inbox/thread_2.eml new file mode 100644 index 0000000..9f76c73 --- /dev/null +++ b/backend/demo/src/main/resources/inbox/thread_2.eml @@ -0,0 +1,11 @@ +MIME-Version: 1.0 +From: Bob +Date: Fri, 10 Feb 2023 10:05:00 +0100 +Message-ID: +Subject: Re: Thread +To: Alice +In-Reply-To: +References: +Content-Type: text/plain; charset=UTF-8 + +This is the second message in this thread. diff --git a/backend/demo/src/main/resources/nested/nested_1.eml b/backend/demo/src/main/resources/nested/nested_1.eml new file mode 100644 index 0000000..710357a --- /dev/null +++ b/backend/demo/src/main/resources/nested/nested_1.eml @@ -0,0 +1,20 @@ +MIME-Version: 1.0 +From: "Nested User" +Date: Mon, 01 Jan 2024 12:00:00 -0400 +Message-ID: +Subject: Nested Message +To: User +Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320 + +--047d7b450b100959e604d85a5320 +Content-Type: text/plain; charset=UTF-8 + +This is a message in the Nested folder. + +--047d7b450b100959e604d85a5320 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a message in the Nested folder.
+ +--047d7b450b100959e604d85a5320-- diff --git a/backend/demo/src/main/resources/nested/nested_level_1/nested_level_1_1.eml b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_1_1.eml new file mode 100644 index 0000000..13db8dd --- /dev/null +++ b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_1_1.eml @@ -0,0 +1,20 @@ +MIME-Version: 1.0 +From: "Nested Level 1 User" +Date: Mon, 01 Jan 2024 12:00:00 -0400 +Message-ID: +Subject: Nested Level 1 Message +To: Nested Level 2 User +Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320 + +--047d7b450b100959e604d85a5320 +Content-Type: text/plain; charset=UTF-8 + +This is a message 1 in the Nested Level 1 folder. + +--047d7b450b100959e604d85a5320 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a message in the Nested Level 1 folder.
+ +--047d7b450b100959e604d85a5320-- diff --git a/backend/demo/src/main/resources/nested/nested_level_1/nested_level_1_2.eml b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_1_2.eml new file mode 100644 index 0000000..c8eb456 --- /dev/null +++ b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_1_2.eml @@ -0,0 +1,20 @@ +MIME-Version: 1.0 +From: "Nested Level 1 User" +Date: Mon, 01 Jan 2024 12:00:00 -0400 +Message-ID: +Subject: Nested Level 1 Message +To: Nested Level 2 User +Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320 + +--047d7b450b100959e604d85a5320 +Content-Type: text/plain; charset=UTF-8 + +This is a message 2 in the Nested Level 1 folder. + +--047d7b450b100959e604d85a5320 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a message in the Nested Level 1 folder.
+ +--047d7b450b100959e604d85a5320-- diff --git a/backend/demo/src/main/resources/nested/nested_level_1/nested_level_2/nested_level_2_1.eml b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_2/nested_level_2_1.eml new file mode 100644 index 0000000..50544ee --- /dev/null +++ b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_2/nested_level_2_1.eml @@ -0,0 +1,20 @@ +MIME-Version: 1.0 +From: "Nested Level 2 User" +Date: Mon, 01 Jan 2024 12:00:00 -0400 +Message-ID: +Subject: Nested Level 2 Message +To: Nested Level 1 User +Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320 + +--047d7b450b100959e604d85a5320 +Content-Type: text/plain; charset=UTF-8 + +This is a message 1 in the Nested Level 2 folder. + +--047d7b450b100959e604d85a5320 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a message in the Nested Level 2 folder.
+ +--047d7b450b100959e604d85a5320-- diff --git a/backend/demo/src/main/resources/nested/nested_level_1/nested_level_2/nested_level_2_2.eml b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_2/nested_level_2_2.eml new file mode 100644 index 0000000..dc9a10e --- /dev/null +++ b/backend/demo/src/main/resources/nested/nested_level_1/nested_level_2/nested_level_2_2.eml @@ -0,0 +1,20 @@ +MIME-Version: 1.0 +From: "Nested Level 2 User" +Date: Mon, 01 Jan 2024 12:00:00 -0400 +Message-ID: +Subject: Nested Level 2 Message +To: Nested Level 1 User +Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320 + +--047d7b450b100959e604d85a5320 +Content-Type: text/plain; charset=UTF-8 + +This is a message 2 in the Nested Level 2 folder. + +--047d7b450b100959e604d85a5320 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a message in the Nested Level 2 folder.
+ +--047d7b450b100959e604d85a5320-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1966.eml b/backend/demo/src/main/resources/turing/turing_award_1966.eml new file mode 100644 index 0000000..d8bfe3f --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1966.eml @@ -0,0 +1,84 @@ +MIME-Version: 1.0 +From: "Alan J. Perlis" +Date: Sat, 01 Jan 1966 12:00:00 -0400 +Message-ID: +Subject: The Synthesis of Algorithmic Systems +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7b450b100959e604d85a5320 + +--047d7b450b100959e604d85a5320 +Content-Type: text/plain; charset=UTF-8 + +Both knowledge and wisdom extend man's reach. Knowledge led to computers, +wisdom to chopsticks. Unfortunately our association is overinvolved with +the former. The latter will have to wait for a more sublime day. +On what does and will the fame of Turing rest? That he proved a theorem +showing that for a general computing device--later dubbed a "Turing +machine"--there existed functions which it could not compute? I doubt it. +More likely it rests on the model he invented and employed: his formal +mechanism. +This model has captured the imagination and mobilized the thoughts of a +generation of scientists. It has provided a basis for arguments leading to +theories. His model has proved so useful that its generated activity has +been distributed not only in mathematics, but through several technologies +as well. The arguments that have been employed are not always formal and +the consequent creations not all abstract. +Indeed a most fruitful consequence of the Turing machine has been with the +creation, study and computation of functions which are computable, i.e., in +computer programming. This is not surprising since computers can compute so +much more than we yet know how to specify. +I am sure that all will agree that this model has been enormously valuable. +History will forgive me for not devoting any attention in this lecture to +the effect which Turing had on the development of the general-purpose +digital computer, which has further accelerated our involvement with the +theory and practice of computation. +Since the appearance of Turing's model there have, of course, been others +which have concerned and benefited us in computing. I think, however, that +only one has had an effect as great as Turing's: the formal mechanism +called ALGOL Many will immediately disagree, pointing out that too few of +us have understood it or used it. +While such has, unhappily, been the case, it is not the point. The impulse +given by ALGOL to the development of research in computer science is +relevant while the number of adherents is not. ALGOL, too, has mobilized +our thoughts and has provided us with a basis for our arguments. + +--047d7b450b100959e604d85a5320 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
Both knowledge and wisdom extend man's reach. Kno= +wledge led to computers, wisdom to chopsticks. Unfortunately our associatio= +n is overinvolved with the former. The latter will have to wait for a more = +sublime day.=C2=A0
+
On what does and will the fame of Turing rest? That he proved a theore= +m showing that for a general computing device--later dubbed a "Turing = +machine"--there existed functions which it could not compute? I doubt = +it. More likely it rests on the model he invented and employed: his formal = +mechanism.=C2=A0
+
This model has captured the imagination and mobilized the thoughts of = +a generation of scientists. It has provided a basis for arguments leading t= +o theories. His model has proved so useful that its generated activity has = +been distributed not only in mathematics, but through several technologies = +as well. The arguments that have been employed are not always formal and th= +e consequent creations not all abstract.=C2=A0
+
Indeed a most fruitful consequence of the Turing machine has been with= + the creation, study and computation of functions which are computable, i.e= +., in computer programming. This is not surprising since computers can comp= +ute so much more than we yet know how to specify.=C2=A0
+
I am sure that all will agree that this model has been enormously valu= +able. History will forgive me for not devoting any attention in this lectur= +e to the effect which Turing had on the development of the general-purpose = +digital computer, which has further accelerated our involvement with the th= +eory and practice of computation.=C2=A0
+
Since the appearance of Turing's model there have, of course, been= + others which have concerned and benefited us in computing. I think, howeve= +r, that only one has had an effect as great as Turing's: the formal mec= +hanism called ALGOL Many will immediately disagree, pointing out that too f= +ew of us have understood it or used it.=C2=A0
+
While such has, unhappily, been the case, it is not the point. The imp= +ulse given by ALGOL to the development of research in computer science is r= +elevant while the number of adherents is not. ALGOL, too, has mobilized our= + thoughts and has provided us with a basis for our arguments.=C2=A0
+
+ +--047d7b450b100959e604d85a5320-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1967.eml b/backend/demo/src/main/resources/turing/turing_award_1967.eml new file mode 100644 index 0000000..d333fd1 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1967.eml @@ -0,0 +1,35 @@ +MIME-Version: 1.0 +From: "Maurice V. Wilkes" +Date: Wed, 30 Aug 1967 12:00:00 -0400 +Message-ID: +Subject: Computers Then and Now +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7b5d9bdd0d571a04d85aec30 + +--047d7b5d9bdd0d571a04d85aec30 +Content-Type: text/plain; charset=UTF-8 + +I do not imagine that many of the Turing lecturers who will follow me will +be people who were acquainted with Alan Turing. The work on computable +numbers, for which he is famous, was published in 1936 before digital +computers existed. Later he became one of the first of a distinguished +succession of able mathematicians who have made contributions to the +computer field. He was a colorful figure in the early days of digital +computer development in England, and I would find it difficult to speak of +that period without making some references to him. + +--047d7b5d9bdd0d571a04d85aec30 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
I do not imagine that many of the Turing lecturers wh= +o will follow me will be people who were acquainted with Alan Turing. The w= +ork on computable numbers, for which he is famous, was published in 1936 be= +fore digital computers existed. Later he became one of the first of a disti= +nguished succession of able mathematicians who have made contributions to t= +he computer field. He was a colorful figure in the early days of digital co= +mputer development in England, and I would find it difficult to speak of th= +at period without making some references to him.
+
+ +--047d7b5d9bdd0d571a04d85aec30-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1968.eml b/backend/demo/src/main/resources/turing/turing_award_1968.eml new file mode 100644 index 0000000..f8cf0b9 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1968.eml @@ -0,0 +1,40 @@ +MIME-Version: 1.0 +From: Richard Hamming +Date: Tue, 27 Aug 1968 12:00:00 -0400 +Message-ID: +Subject: One Man's View of Computer Science +To: Alan Turing +Content-Type: multipart/alternative; boundary=089e01227b30f6f60004d85af2ae + +--089e01227b30f6f60004d85af2ae +Content-Type: text/plain; charset=UTF-8 + +Let me begin with a few personal words. When one is notified that he has +been elected the ACM Turing lecturer for the year, he is at first +surprised--especially is the nonacademic person surprised by an ACM award. +After a little while the surprise is replaced by a feeling of pleasure. +Still later comes a feeling of "Why me?" With all that has been done and is +being done in computing, why single out me and my work? Well, I suppose +that it has to happen to someone each year, and this +time I am the lucky person. Anyway, let me thank you for the honor you have +given to me and by inference to the Bell Telephone Laboratories where I +work and which has made possible so much of what I have done. + +--089e01227b30f6f60004d85af2ae +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
Let me begin with a few personal words. When one is n= +otified that he has been elected the ACM Turing lecturer for the year, he i= +s at first surprised--especially is the nonacademic person surprised by an = +ACM award. After a little while the surprise is replaced by a feeling of pl= +easure. Still later comes a feeling of "Why me?" With all that ha= +s been done and is being done in computing, why single out me and my work? = +Well, I suppose that it has to happen to someone each year, and this=C2=A0<= +/div> +
time I am the lucky person. Anyway, let me thank you for the honor you= + have given to me and by inference to the Bell Telephone Laboratories where= + I work and which has made possible so much of what I have done.
+ +--089e01227b30f6f60004d85af2ae-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1970.eml b/backend/demo/src/main/resources/turing/turing_award_1970.eml new file mode 100644 index 0000000..d07828c --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1970.eml @@ -0,0 +1,35 @@ +MIME-Version: 1.0 +From: "James H. Wilkinson" +Date: Tue, 01 Sep 1970 12:00:00 -0400 +Message-ID: +Subject: Some Comments from a Numerical Analyst +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7b5d9bdd9697d504d85ac65f + +--047d7b5d9bdd9697d504d85ac65f +Content-Type: text/plain; charset=UTF-8 + +When at last I recovered from the feeling of shocked elation at being +invited to give the 1970 Turing Award Lecture, I became aware that I must +indeed prepare an appropriate lecture. There appears to be a tradition that +a Turing Lecturer should decide for himself what is expected from him, and +probably for this reason previous lectures have differed considerably in +style and content. However, it was made quite clear that I was to give an +after-luncheon speech and that I would not have the benefit of an overhead +projector or a blackboard. + +--047d7b5d9bdd9697d504d85ac65f +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
When at last I recovered from the feeling of shocked = +elation at being invited to give the 1970 Turing Award Lecture, I became aw= +are that I must indeed prepare an appropriate lecture. There appears to be = +a tradition that a Turing Lecturer should decide for himself what is expect= +ed from him, and probably for this reason previous lectures have differed c= +onsiderably in style and content. However, it was made quite clear that I w= +as to give an after-luncheon speech and that I would not have the benefit o= +f an overhead projector or a blackboard.
+
+ +--047d7b5d9bdd9697d504d85ac65f-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1971.eml b/backend/demo/src/main/resources/turing/turing_award_1971.eml new file mode 100644 index 0000000..1f8eee2 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1971.eml @@ -0,0 +1,32 @@ +MIME-Version: 1.0 +From: John McCarthy +Date: Fri, 01 Jan 1971 12:00:00 -0400 +Message-ID: +Subject: Generality in Artificial Intelligence +To: Alan Turing +Content-Type: multipart/alternative; boundary=089e01030106b6942904d85ad870 + +--089e01030106b6942904d85ad870 +Content-Type: text/plain; charset=UTF-8 + +Postscript +My 1971 Turing Award Lecture was entitled "Generality in Artificial +Intelligence." The topic turned out to have been overambitious in that I +discovered that I was unable to put my thoughts on the subject in a +satisfactory written form at that time. It would have been better to have +reviewed previous work rather than attempt something new, but such wasn't +my custom at that time. + +--089e01030106b6942904d85ad870 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
Postscript
My 1971 Turing Award Lecture was= + entitled "Generality in Artificial Intelligence." The topic turn= +ed out to have been overambitious in that I discovered that I was unable to= + put my thoughts on the subject in a satisfactory written form at that time= +. It would have been better to have reviewed previous work rather than atte= +mpt something new, but such wasn't my custom at that time.
+
+ +--089e01030106b6942904d85ad870-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1972.eml b/backend/demo/src/main/resources/turing/turing_award_1972.eml new file mode 100644 index 0000000..2ae547e --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1972.eml @@ -0,0 +1,27 @@ +MIME-Version: 1.0 +From: "Edsger W. Dijkstra" +Date: Mon, 02 Aug 1972 12:00:00 -0500 +Message-ID: +Subject: The Humble Programmer +To: Alan Turing +Content-Type: text/plain; charset=UTF-8; format=flowed + +As a result of a long sequence of coincidences I entered the programming +profession officially on the first spring morning of 1952, and as far as +I have been able to trace, I was the first Dutchman to do so in my +country. In retrospect the most amazing thing is the slowness with which, +at least in my part of the world, the programming profession emerged, a +slowness which is now hard to believe. But I am grateful for two vivid +recollections from that period that establish that slowness beyond any +doubt. + +After having programmed for some three years, I had a discussion with +van Wijngaarden, who was then my boss at the Mathematical Centre in +Amsterdam - a discussion for which I shall remain grateful to him +as long as I live. The point was that I was supposed to study theoretical +physics at the University of Leiden simultaneously, and as I found the +two activities harder and harder to combine, I had to make up my +mind, either to stop programming and become a real, respectable theoretical +physicist, or to carry my study of physics to a formal completion only, +with a minimum of effort, and to become..., yes what? A programmer? +But was that a respectable profession? After all, what was programming? diff --git a/backend/demo/src/main/resources/turing/turing_award_1975.eml b/backend/demo/src/main/resources/turing/turing_award_1975.eml new file mode 100644 index 0000000..662e527 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1975.eml @@ -0,0 +1,30 @@ +MIME-Version: 1.0 +From: Allen Newell +Cc: Herbert Simon +Date: Mon, 20 Oct 1975 12:00:00 -0500 +Message-ID: +Subject: Computer Science as Empirical Inquiry: Symbols and Search +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7b450b1092035304d85abf33 + +--047d7b450b1092035304d85abf33 +Content-Type: text/plain; charset=UTF-8 + +Computer science is the study of the phenomena surrounding computers. The +founders of this society understood this very well when they called +themselves the Association for Computing Machinery. The machine---not just +the hardware, but the programmed, living machine--is the organism we study. + +--047d7b450b1092035304d85abf33 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
Computer science is the study of the phenomena surrounding= + computers. The founders of this society understood this very well when the= +y called themselves the Association for Computing Machinery. The machine---= +not just the hardware, but the programmed, living machine--is the organism = +we study.
+ +
+ +--047d7b450b1092035304d85abf33-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1977.eml b/backend/demo/src/main/resources/turing/turing_award_1977.eml new file mode 100644 index 0000000..2e53001 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1977.eml @@ -0,0 +1,39 @@ +MIME-Version: 1.0 +From: "John W. Backus" +Date: Mon, 17 Oct 1977 12:00:00 -0700 +Message-ID: +Subject: Can Programming Be Liberated from the von Neumann Style? A Functional + Style and Its Algebra of Programs +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7b5d9bdd8a36e804d85ade47 + +--047d7b5d9bdd8a36e804d85ade47 +Content-Type: text/plain; charset=UTF-8 + +Conventional programming languages are growing ever more enormous, but not +stronger. Inherent defects at the most basic level cause them to be both +fat and weak: their primitive word-at-a-time style of programming inherited +from their common ancestor--the von Neumann computer, their close coupling +of semantics to state transitions, their division of programming into a +world of expressions and a world of statements, their inability to +effectively use powerful combining forms for building new programs from +existing ones, and their lack of useful mathematical properties for +reasoning about +programs. + +--047d7b5d9bdd8a36e804d85ade47 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
Conventional programming languages are growing ever m= +ore enormous, but not stronger. Inherent defects at the most basic level ca= +use them to be both fat and weak: their primitive word-at-a-time style of p= +rogramming inherited from their common ancestor--the von Neumann computer, = +their close coupling of semantics to state transitions, their division of p= +rogramming into a world of expressions and a world of statements, their ina= +bility to effectively use powerful combining forms for building new program= +s from existing ones, and their lack of useful mathematical properties for = +reasoning about=C2=A0
+
programs.
+ +--047d7b5d9bdd8a36e804d85ade47-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1978.eml b/backend/demo/src/main/resources/turing/turing_award_1978.eml new file mode 100644 index 0000000..92fd283 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1978.eml @@ -0,0 +1,36 @@ +MIME-Version: 1.0 +From: Robert Floyd +Date: Mon, 04 Dec 1978 12:00:00 -0500 +Message-ID: +Subject: The Paradigms of Programming +To: Alan Turing +Content-Type: multipart/alternative; boundary=089e0118419206e64304d85af860 + +--089e0118419206e64304d85af860 +Content-Type: text/plain; charset=UTF-8 + +Today I want to talk about the paradigms of programming, how they affect +our success as designers of computer programs, how they should be taught, +and how they should be embodied in our programming languages. +A familiar example of a paradigm of programming is the technique of +structured programming, which appears to be the dominant paradigm in most +current treatments of programming methodology. Structured programming, as +formulated by Dijkstra, Wirth, and Parnas, among others, consists of two +phases. + +--089e0118419206e64304d85af860 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
Today I want to talk about the paradigms of programmi= +ng, how they affect our success as designers of computer programs, how they= + should be taught, and how they should be embodied in our programming langu= +ages.=C2=A0
+
A familiar example of a paradigm of programming is the technique of st= +ructured programming, which appears to be the dominant paradigm in most cur= +rent treatments of programming methodology. Structured programming, as form= +ulated by Dijkstra, Wirth, and Parnas, among others, consists of two phases= +.=C2=A0
+
+ +--089e0118419206e64304d85af860-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1979.eml b/backend/demo/src/main/resources/turing/turing_award_1979.eml new file mode 100644 index 0000000..ac82ea9 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1979.eml @@ -0,0 +1,33 @@ +MIME-Version: 1.0 +From: "Kenneth E. Iverson" +Date: Mon, 29 Oct 1979 12:00:00 -0500 +Message-ID: +Subject: Notation as a Tool of Thought +To: Alan Turing +Content-Type: multipart/alternative; boundary=20cf30549cad76254e04d85ae4df + +--20cf30549cad76254e04d85ae4df +Content-Type: text/plain; charset=UTF-8 + +The importance of nomenclature, notation, and language as tools of thought +has long been recognized. In chemistry and in botany, for example, the +establishment of systems of nomenclature by Lavoisier and Linnaeus did much +to stimulate and to channel later investigation. Concerning language, +George Boole in his Laws off Thought asserted "That language is an +instrument of human reason, and not merely a medium for the expression of +thought, is a truth generally admitted." + +--20cf30549cad76254e04d85ae4df +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
The importance of nomenclature, notation, and languag= +e as tools of thought has long been recognized. In chemistry and in botany,= + for example, the establishment of systems of nomenclature by Lavoisier and= + Linnaeus did much to stimulate and to channel later investigation. Concern= +ing language, George Boole in his Laws off Thought asserted "That lang= +uage is an instrument of human reason, and not merely a medium for the expr= +ession of thought, is a truth generally admitted."
+
+ +--20cf30549cad76254e04d85ae4df-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1981.eml b/backend/demo/src/main/resources/turing/turing_award_1981.eml new file mode 100644 index 0000000..8245852 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1981.eml @@ -0,0 +1,51 @@ +MIME-Version: 1.0 +From: "Edgar F. Codd" +Date: Wed, 11 Nov 1981 12:00:00 -0800 +Message-ID: +Subject: Relational Database: A Practical Foundation for Productivity +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7bfd026c782f2404d85ab4b8 + +--047d7bfd026c782f2404d85ab4b8 +Content-Type: text/plain; charset=UTF-8 + +It is well known that the growth in demands from end users for new +applications is outstripping the capability of data processing departments +to implement the corresponding application programs. There are two +complementary approaches to attacking this problem (and both approaches are +needed): one is to put end users into direct touch with the information +stored in computers; the other is to increase the productivity of data +processing professionals in the development of application programs. It is +less well known that a single technology, relational database management, +provides a practical foundation for both approaches. It is explained why +this +is so. +While developing this productivity theme, it is noted that the time has +come to draw a very sharp line between relational and non-relational +database systems, so that the label "relational" will not be used in +misleading ways. +The key to drawing this line is something called a "relational processing +capability." + +--047d7bfd026c782f2404d85ab4b8 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
It is well known that the growth in demands from end = +users for new applications is outstripping the capability of data processin= +g departments to implement the corresponding application programs. There ar= +e two complementary approaches to attacking this problem (and both approach= +es are needed): one is to put end users into direct touch with the informat= +ion stored in computers; the other is to increase the productivity of data = +processing professionals in the development of application programs. It is = +less well known that a single technology, relational database management, p= +rovides a practical foundation for both approaches. It is explained why thi= +s=C2=A0
+
is so.=C2=A0
While developing this productivity theme, = +it is noted that the time has come to draw a very sharp line between relati= +onal and non-relational database systems, so that the label "relationa= +l" will not be used in misleading ways.=C2=A0
+
The key to drawing this line is something called a "relational pr= +ocessing capability."
+ +--047d7bfd026c782f2404d85ab4b8-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1983.eml b/backend/demo/src/main/resources/turing/turing_award_1983.eml new file mode 100644 index 0000000..9bab787 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1983.eml @@ -0,0 +1,46 @@ +MIME-Version: 1.0 +From: Dennis Ritchie +Date: Mon, 24 Oct 1983 12:00:00 -0400 +Message-ID: +Subject: Reflections on Software Research +To: Alan Turing +Content-Type: multipart/alternative; boundary=bcaec54fbb2250035a04d85aabcd + +--bcaec54fbb2250035a04d85aabcd +Content-Type: text/plain; charset=UTF-8 + +The UNIX operating system has suddenly become news, but it is not new. It +began in 1969 when Ken Thompson discovered a little-used PDP-7 computer and +set out to fashion a computing environment that he liked, His work soon +attracted me; I joined in the enterprise, though most of the ideas, and +most of the work for that matter, were his. Before long, others from our +group in the research area of AT&T Bell Laboratories were using the system; +Joe Ossanna, Doug Mcllroy, and +Bob Morris were especially enthusiastic critics and contributors, tn 1971, +we acquired a PDP-11, and by the end of that year we were supporting our +first real users: three typists entering patent applications. In 1973, the +system was rewritten in the C language, and in that year, too, it was first +described publicly at the Operating Systems Principles conference; the +resulting paper appeared in Communications of the ACM the next year. + +--bcaec54fbb2250035a04d85aabcd +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
The UNIX operating system has suddenly become news, b= +ut it is not new. It began in 1969 when Ken Thompson discovered a little-us= +ed PDP-7 computer and set out to fashion a computing environment that he li= +ked, His work soon attracted me; I joined in the enterprise, though most of= + the ideas, and most of the work for that matter, were his. Before long, ot= +hers from our group in the research area of AT&T Bell Laboratories were= + using the system; Joe Ossanna, Doug Mcllroy, and=C2=A0
+
Bob Morris were especially enthusiastic critics and contributors, tn 1= +971, we acquired a PDP-11, and by the end of that year we were supporting o= +ur first real users: three typists entering patent applications. In 1973, t= +he system was rewritten in the C language, and in that year, too, it was fi= +rst described publicly at the Operating Systems Principles conference; the = +resulting paper appeared in Communications of the ACM the next year.=C2=A0<= +/div> +
+ +--bcaec54fbb2250035a04d85aabcd-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1987.eml b/backend/demo/src/main/resources/turing/turing_award_1987.eml new file mode 100644 index 0000000..92fda04 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1987.eml @@ -0,0 +1,42 @@ +MIME-Version: 1.0 +From: John Cocke +Date: Mon, 16 Feb 1987 12:00:00 -0600 +Message-ID: +Subject: The Search for Performance in Scientific Processors +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7bfd079665fb2c04d85ad0bc + +--047d7bfd079665fb2c04d85ad0bc +Content-Type: text/plain; charset=UTF-8 + +I am honored and grateful to have been selected to join the ranks of ACM +Turing Award winners. I probably have spent too much of my life thinking +about computers, but I do not regret it a bit. I was fortunate to enter the +field of computing in its infancy and participate in its explosive growth. +The rapid evolution of the underlying technologies in the past 30 years has +not only provided an exciting environment, but has also presented a +constant stream of intellectual challenges to those of us trying to harness +this power and squeeze it to the last ounce. I hasten to say, especially to +the +younger members of the audience, there is no end in sight. As a matter of +fact, I believe the next thirty years will be even more exciting and rich +with challenges. + +--047d7bfd079665fb2c04d85ad0bc +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
I am honored and grateful to have been selected to jo= +in the ranks of ACM Turing Award winners. I probably have spent too much of= + my life thinking about computers, but I do not regret it a bit. I was fort= +unate to enter the field of computing in its infancy and participate in its= + explosive growth. The rapid evolution of the underlying technologies in th= +e past 30 years has not only provided an exciting environment, but has also= + presented a constant stream of intellectual challenges to those of us tryi= +ng to harness this power and squeeze it to the last ounce. I hasten to say,= + especially to the=C2=A0
+
younger members of the audience, there is no end in sight. As a matter= + of fact, I believe the next thirty years will be even more exciting and ri= +ch with challenges.=C2=A0
+ +--047d7bfd079665fb2c04d85ad0bc-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1991.eml b/backend/demo/src/main/resources/turing/turing_award_1991.eml new file mode 100644 index 0000000..20ad04b --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1991.eml @@ -0,0 +1,44 @@ +MIME-Version: 1.0 +From: Robin Milner +Date: Mon, 18 Nov 1991 12:00:00 -0700 +Message-ID: +Subject: Elements of Interaction +To: Alan Turing +Content-Type: multipart/alternative; boundary=047d7b86e6de64aecb04d85affff + +--047d7b86e6de64aecb04d85affff +Content-Type: text/plain; charset=UTF-8 + +I am greatly honored to receive this award, bearing the name of Alan +Turing. Perhaps Turing would be pleased that it should go to someone +educated at his old college, King's College at Cambridge. While there in +1956 I wrote my first computer program; it was on the EDSAC. Of course +EDSAC made history. But I am ashamed to say it did not lure me into +computing, and I ignored computers for four years. In 1960 I thought that +computers might be more peaceful to handle than schoolchildren--I was then +a teacher--so I applied for a job at Ferranti in London, at the time of +Pegasus. I was asked at the interview whether I would like to devote my +life to computers. This daunting notion had never crossed my mind. Well, +here I am still, and I have had the lucky chance to grow alongside computer +science. + +--047d7b86e6de64aecb04d85affff +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
I am greatly honored to receive this award, bearing t= +he name of Alan Turing. Perhaps Turing would be pleased that it should go t= +o someone educated at his old college, King's College at Cambridge. Whi= +le there in 1956 I wrote my first computer program; it was on the EDSAC. Of= + course EDSAC made history. But I am ashamed to say it did not lure me into= + computing, and I ignored computers for four years. In 1960 I thought that = +computers might be more peaceful to handle than schoolchildren--I was then = +a teacher--so I applied for a job at Ferranti in London, at the time of=C2= +=A0
+
Pegasus. I was asked at the interview whether I would like to devote m= +y life to computers. This daunting notion had never crossed my mind. Well, = +here I am still, and I have had the lucky chance to grow alongside computer= + science.
+
+ +--047d7b86e6de64aecb04d85affff-- diff --git a/backend/demo/src/main/resources/turing/turing_award_1996.eml b/backend/demo/src/main/resources/turing/turing_award_1996.eml new file mode 100644 index 0000000..77a0155 --- /dev/null +++ b/backend/demo/src/main/resources/turing/turing_award_1996.eml @@ -0,0 +1,28 @@ +MIME-Version: 1.0 +From: Amir Pnueli +Date: Thu, 15 Feb 1996 12:00:00 -0500 +Message-ID: +Subject: Verification Engineering: A Future Profession +To: Alan Turing +Content-Type: multipart/alternative; boundary=bcaec54fbb222acf6704d85aa523 + +--bcaec54fbb222acf6704d85aa523 +Content-Type: text/plain; charset=UTF-8 + +It is time that formal verification (of both software and hardware systems) +be demoted from an art practiced by the enlightened few to an activity +routinely and mundanely performed by a cadre of Verification Engineers (a +new profession), as a standard part of the system development process. + +--bcaec54fbb222acf6704d85aa523 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
It is time that formal verification (of both software= + and hardware systems) be demoted from an art practiced by the enlightened = +few to an activity routinely and mundanely performed by a cadre of Verifica= +tion Engineers (a new profession), as a standard part of the system develop= +ment process.
+
+ +--bcaec54fbb222acf6704d85aa523-- diff --git a/backend/imap/build.gradle.kts b/backend/imap/build.gradle.kts new file mode 100644 index 0000000..37cd4ea --- /dev/null +++ b/backend/imap/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.backend.api) + implementation(projects.core.common) + api(projects.core.outcome) + + api(projects.feature.mail.account.api) + + api(projects.mail.protocols.imap) + api(projects.mail.protocols.smtp) + + implementation(projects.feature.mail.folder.api) + + implementation(libs.kotlinx.coroutines.core) + + testImplementation(projects.core.logging.testing) + testImplementation(projects.mail.testing) + testImplementation(projects.backend.testing) + testImplementation(libs.mime4j.dom) +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/BackendIdleRefreshManager.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/BackendIdleRefreshManager.kt new file mode 100644 index 0000000..06f5065 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/BackendIdleRefreshManager.kt @@ -0,0 +1,136 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.store.imap.IdleRefreshManager +import com.fsck.k9.mail.store.imap.IdleRefreshTimer + +private typealias Callback = () -> Unit + +private const val MIN_TIMER_DELTA = 1 * 60 * 1000L +private const val NO_TRIGGER_TIME = 0L + +/** + * Timer mechanism to refresh IMAP IDLE connections. + * + * Triggers timers early if necessary to reduce the number of times the device has to be woken up. + */ +class BackendIdleRefreshManager(private val alarmManager: SystemAlarmManager) : IdleRefreshManager { + private var timers = mutableSetOf() + private var currentTriggerTime = NO_TRIGGER_TIME + private var minTimeout = Long.MAX_VALUE + private var minTimeoutTimestamp = 0L + + @Synchronized + override fun startTimer(timeout: Long, callback: Callback): IdleRefreshTimer { + require(timeout > MIN_TIMER_DELTA) { "Timeout needs to be greater than $MIN_TIMER_DELTA ms" } + + val now = alarmManager.now() + val triggerTime = now + timeout + + updateMinTimeout(timeout, now) + setOrUpdateAlarm(triggerTime) + + return BackendIdleRefreshTimer(triggerTime, callback).also { timer -> + timers.add(timer) + } + } + + override fun resetTimers() { + synchronized(this) { + cancelAlarm() + } + + onTimeout() + } + + private fun updateMinTimeout(timeout: Long, now: Long) { + if (minTimeoutTimestamp + minTimeout * 2 < now) { + minTimeout = Long.MAX_VALUE + } + + if (timeout <= minTimeout) { + minTimeout = timeout + minTimeoutTimestamp = now + } + } + + private fun setOrUpdateAlarm(triggerTime: Long) { + if (currentTriggerTime == NO_TRIGGER_TIME) { + setAlarm(triggerTime) + } else if (currentTriggerTime - triggerTime > MIN_TIMER_DELTA) { + adjustAlarm(triggerTime) + } + } + + private fun setAlarm(triggerTime: Long) { + currentTriggerTime = triggerTime + alarmManager.setAlarm(triggerTime, ::onTimeout) + } + + private fun adjustAlarm(triggerTime: Long) { + currentTriggerTime = triggerTime + alarmManager.cancelAlarm() + alarmManager.setAlarm(triggerTime, ::onTimeout) + } + + private fun cancelAlarm() { + currentTriggerTime = NO_TRIGGER_TIME + alarmManager.cancelAlarm() + } + + private fun onTimeout() { + val triggerTimers = synchronized(this) { + currentTriggerTime = NO_TRIGGER_TIME + + if (timers.isEmpty()) return + + val now = alarmManager.now() + val minNextTriggerTime = now + minTimeout + + val triggerTimers = timers.filter { it.triggerTime < minNextTriggerTime - MIN_TIMER_DELTA } + timers.removeAll(triggerTimers) + + timers.minOfOrNull { it.triggerTime }?.let { nextTriggerTime -> + setAlarm(nextTriggerTime) + } + + triggerTimers + } + + for (timer in triggerTimers) { + timer.onTimeout() + } + } + + @Synchronized + private fun removeTimer(timer: BackendIdleRefreshTimer) { + timers.remove(timer) + + if (timers.isEmpty()) { + cancelAlarm() + } + } + + internal inner class BackendIdleRefreshTimer( + val triggerTime: Long, + val callback: Callback, + ) : IdleRefreshTimer { + override var isWaiting: Boolean = true + private set + + @Synchronized + override fun cancel() { + if (isWaiting) { + isWaiting = false + removeTimer(this) + } + } + + internal fun onTimeout() { + synchronized(this) { + isWaiting = false + } + + callback.invoke() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDelete.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDelete.kt new file mode 100644 index 0000000..0bd2d46 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDelete.kt @@ -0,0 +1,22 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode +import net.thunderbird.core.common.exception.MessagingException + +internal class CommandDelete(private val imapStore: ImapStore) { + + @Throws(MessagingException::class) + fun deleteMessages(folderServerId: String, messageServerIds: List) { + val remoteFolder = imapStore.getFolder(folderServerId) + try { + remoteFolder.open(OpenMode.READ_WRITE) + + val messages = messageServerIds.map { uid -> remoteFolder.getMessage(uid) } + + remoteFolder.deleteMessages(messages) + } finally { + remoteFolder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDeleteAll.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDeleteAll.kt new file mode 100644 index 0000000..fb42e43 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDeleteAll.kt @@ -0,0 +1,20 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode +import net.thunderbird.core.common.exception.MessagingException + +internal class CommandDeleteAll(private val imapStore: ImapStore) { + + @Throws(MessagingException::class) + fun deleteAll(folderServerId: String) { + val remoteFolder = imapStore.getFolder(folderServerId) + try { + remoteFolder.open(OpenMode.READ_WRITE) + + remoteFolder.deleteAllMessages() + } finally { + remoteFolder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDownloadMessage.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDownloadMessage.kt new file mode 100644 index 0000000..d925b3c --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandDownloadMessage.kt @@ -0,0 +1,55 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.FetchProfile.Item.BODY +import com.fsck.k9.mail.FetchProfile.Item.ENVELOPE +import com.fsck.k9.mail.FetchProfile.Item.FLAGS +import com.fsck.k9.mail.FetchProfile.Item.STRUCTURE +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.helper.fetchProfileOf +import com.fsck.k9.mail.store.imap.ImapFolder +import com.fsck.k9.mail.store.imap.ImapMessage +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode + +internal class CommandDownloadMessage(private val backendStorage: BackendStorage, private val imapStore: ImapStore) { + + fun downloadMessageStructure(folderServerId: String, messageServerId: String) { + val folder = imapStore.getFolder(folderServerId) + try { + folder.open(OpenMode.READ_ONLY) + + val message = folder.getMessage(messageServerId) + + // fun fact: ImapFolder.fetch can't handle getting STRUCTURE at same time as headers + fetchMessage(folder, message, fetchProfileOf(FLAGS, ENVELOPE)) + fetchMessage(folder, message, fetchProfileOf(STRUCTURE)) + + val backendFolder = backendStorage.getFolder(folderServerId) + backendFolder.saveMessage(message, MessageDownloadState.ENVELOPE) + } finally { + folder.close() + } + } + + fun downloadCompleteMessage(folderServerId: String, messageServerId: String) { + val folder = imapStore.getFolder(folderServerId) + try { + folder.open(OpenMode.READ_ONLY) + + val message = folder.getMessage(messageServerId) + fetchMessage(folder, message, fetchProfileOf(FLAGS, BODY)) + + val backendFolder = backendStorage.getFolder(folderServerId) + backendFolder.saveMessage(message, MessageDownloadState.FULL) + } finally { + folder.close() + } + } + + private fun fetchMessage(remoteFolder: ImapFolder, message: ImapMessage, fetchProfile: FetchProfile) { + val maxDownloadSize = 0 + remoteFolder.fetch(listOf(message), fetchProfile, null, maxDownloadSize) + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandExpunge.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandExpunge.kt new file mode 100644 index 0000000..ac58bbb --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandExpunge.kt @@ -0,0 +1,34 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode +import net.thunderbird.core.logging.legacy.Log + +internal class CommandExpunge(private val imapStore: ImapStore) { + + fun expunge(folderServerId: String) { + Log.d("processPendingExpunge: folder = %s", folderServerId) + + val remoteFolder = imapStore.getFolder(folderServerId) + try { + remoteFolder.open(OpenMode.READ_WRITE) + + remoteFolder.expunge() + + Log.d("processPendingExpunge: complete for folder = %s", folderServerId) + } finally { + remoteFolder.close() + } + } + + fun expungeMessages(folderServerId: String, messageServerIds: List) { + val remoteFolder = imapStore.getFolder(folderServerId) + try { + remoteFolder.open(OpenMode.READ_WRITE) + + remoteFolder.expungeUids(messageServerIds) + } finally { + remoteFolder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFetchMessage.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFetchMessage.kt new file mode 100644 index 0000000..9feb474 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFetchMessage.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode + +internal class CommandFetchMessage(private val imapStore: ImapStore) { + + fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) { + val folder = imapStore.getFolder(folderServerId) + try { + folder.open(OpenMode.READ_WRITE) + + val message = folder.getMessage(messageServerId) + folder.fetchPart(message, part, bodyFactory, -1) + } finally { + folder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFindByMessageId.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFindByMessageId.kt new file mode 100644 index 0000000..12d1445 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandFindByMessageId.kt @@ -0,0 +1,17 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode + +internal class CommandFindByMessageId(private val imapStore: ImapStore) { + + fun findByMessageId(folderServerId: String, messageId: String): String? { + val folder = imapStore.getFolder(folderServerId) + try { + folder.open(OpenMode.READ_WRITE) + return folder.getUidFromMessageId(messageId) + } finally { + folder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMarkAllAsRead.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMarkAllAsRead.kt new file mode 100644 index 0000000..42c1e35 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMarkAllAsRead.kt @@ -0,0 +1,19 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode + +internal class CommandMarkAllAsRead(private val imapStore: ImapStore) { + + fun markAllAsRead(folderServerId: String) { + val remoteFolder = imapStore.getFolder(folderServerId) + try { + remoteFolder.open(OpenMode.READ_WRITE) + + remoteFolder.setFlagsForAllMessages(setOf(Flag.SEEN), true) + } finally { + remoteFolder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMoveOrCopyMessages.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMoveOrCopyMessages.kt new file mode 100644 index 0000000..892d6a3 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandMoveOrCopyMessages.kt @@ -0,0 +1,66 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.store.imap.ImapFolder +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode +import net.thunderbird.core.logging.legacy.Log + +internal class CommandMoveOrCopyMessages(private val imapStore: ImapStore) { + + fun moveMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, false) + } + + fun copyMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + return moveOrCopyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds, true) + } + + private fun moveOrCopyMessages( + srcFolder: String, + destFolder: String, + uids: Collection, + isCopy: Boolean, + ): Map? { + var remoteSrcFolder: ImapFolder? = null + var remoteDestFolder: ImapFolder? = null + + return try { + remoteSrcFolder = imapStore.getFolder(srcFolder) + + if (uids.isEmpty()) { + Log.i("moveOrCopyMessages: no remote messages to move, skipping") + return null + } + + remoteSrcFolder.open(OpenMode.READ_WRITE) + + val messages = uids.map { uid -> remoteSrcFolder.getMessage(uid) } + + Log.d( + "moveOrCopyMessages: source folder = %s, %d messages, destination folder = %s, isCopy = %s", + srcFolder, + messages.size, + destFolder, + isCopy, + ) + + remoteDestFolder = imapStore.getFolder(destFolder) + if (isCopy) { + remoteSrcFolder.copyMessages(messages, remoteDestFolder) + } else { + remoteSrcFolder.moveMessages(messages, remoteDestFolder) + } + } finally { + remoteSrcFolder?.close() + remoteDestFolder?.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandRefreshFolderList.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandRefreshFolderList.kt new file mode 100644 index 0000000..d6b5521 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandRefreshFolderList.kt @@ -0,0 +1,64 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.updateFolders +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.store.imap.FolderListItem +import com.fsck.k9.mail.store.imap.ImapStore +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter + +private const val TAG = "CommandRefreshFolderList" + +internal class CommandRefreshFolderList( + private val backendStorage: BackendStorage, + private val imapStore: ImapStore, + private val logger: Logger = Log, +) { + + private val LegacyFolderListItem.normalizedServerId: String + get() = imapStore.combinedPrefix?.let { + serverId.removePrefix(prefix = it) + } ?: serverId + + fun refreshFolderList(): FolderPathDelimiter? { + logger.verbose(TAG) { "refreshFolderList() called" } + val folders = imapStore.getFolders() + val folderPathDelimiter = folders.firstOrNull { it.folderPathDelimiter != null }?.folderPathDelimiter + val foldersOnServer = folders.toLegacyFolderList() + val oldFolderServerIds = backendStorage.getFolderServerIds() + + backendStorage.updateFolders { + val foldersToCreate = mutableListOf() + for (folder in foldersOnServer) { + if (folder.normalizedServerId !in oldFolderServerIds) { + foldersToCreate.add(FolderInfo(folder.normalizedServerId, folder.name, folder.type)) + } else { + changeFolder(folder.normalizedServerId, folder.name, folder.type) + } + } + + logger.verbose(TAG) { "refreshFolderList: foldersToCreate = $foldersToCreate" } + createFolders(foldersToCreate) + + val newFolderServerIds = foldersOnServer.map { it.normalizedServerId } + val removedFolderServerIds = oldFolderServerIds - newFolderServerIds + logger.verbose(TAG) { "refreshFolderList: folders to remove = $removedFolderServerIds" } + deleteFolders(removedFolderServerIds) + } + return folderPathDelimiter + } +} + +private fun List.toLegacyFolderList(): List { + return this + .map { LegacyFolderListItem(it.serverId, it.name, it.type) } +} + +private data class LegacyFolderListItem( + val serverId: String, + val name: String, + val type: FolderType, +) diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSearch.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSearch.kt new file mode 100644 index 0000000..e0022fe --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSearch.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode + +internal class CommandSearch(private val imapStore: ImapStore) { + + fun search( + folderServerId: String, + query: String?, + requiredFlags: Set?, + forbiddenFlags: Set?, + performFullTextSearch: Boolean, + ): List { + val folder = imapStore.getFolder(folderServerId) + try { + folder.open(OpenMode.READ_ONLY) + + return folder.search(query, requiredFlags, forbiddenFlags, performFullTextSearch) + .sortedWith(UidReverseComparator()) + .map { it.uid } + } finally { + folder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSetFlag.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSetFlag.kt new file mode 100644 index 0000000..71c3a22 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandSetFlag.kt @@ -0,0 +1,23 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode + +internal class CommandSetFlag(private val imapStore: ImapStore) { + + fun setFlag(folderServerId: String, messageServerIds: List, flag: Flag, newState: Boolean) { + if (messageServerIds.isEmpty()) return + + val remoteFolder = imapStore.getFolder(folderServerId) + try { + remoteFolder.open(OpenMode.READ_WRITE) + + val messages = messageServerIds.map { uid -> remoteFolder.getMessage(uid) } + + remoteFolder.setFlags(messages, setOf(flag), newState) + } finally { + remoteFolder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandUploadMessage.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandUploadMessage.kt new file mode 100644 index 0000000..f7da483 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/CommandUploadMessage.kt @@ -0,0 +1,22 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode + +internal class CommandUploadMessage(private val imapStore: ImapStore) { + + fun uploadMessage(folderServerId: String, message: Message): String? { + val folder = imapStore.getFolder(folderServerId) + try { + folder.open(OpenMode.READ_WRITE) + + val localUid = message.uid + val uidMap = folder.appendMessages(listOf(message)) + + return uidMap?.get(localUid) + } finally { + folder.close() + } + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt new file mode 100644 index 0000000..216d5a0 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackend.kt @@ -0,0 +1,153 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.backend.api.Backend +import com.fsck.k9.backend.api.BackendPusher +import com.fsck.k9.backend.api.BackendPusherCallback +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.power.PowerManager +import com.fsck.k9.mail.store.imap.IdleRefreshManager +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.transport.smtp.SmtpTransport +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter + +class ImapBackend( + private val accountName: String, + backendStorage: BackendStorage, + internal val imapStore: ImapStore, + private val powerManager: PowerManager, + private val idleRefreshManager: IdleRefreshManager, + private val pushConfigProvider: ImapPushConfigProvider, + private val smtpTransport: SmtpTransport, +) : Backend { + private val imapSync = ImapSync(accountName, backendStorage, imapStore) + private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, imapStore) + private val commandSetFlag = CommandSetFlag(imapStore) + private val commandMarkAllAsRead = CommandMarkAllAsRead(imapStore) + private val commandExpunge = CommandExpunge(imapStore) + private val commandMoveOrCopyMessages = CommandMoveOrCopyMessages(imapStore) + private val commandDelete = CommandDelete(imapStore) + private val commandDeleteAll = CommandDeleteAll(imapStore) + private val commandSearch = CommandSearch(imapStore) + private val commandDownloadMessage = CommandDownloadMessage(backendStorage, imapStore) + private val commandFetchMessage = CommandFetchMessage(imapStore) + private val commandFindByMessageId = CommandFindByMessageId(imapStore) + private val commandUploadMessage = CommandUploadMessage(imapStore) + + override val supportsFlags = true + override val supportsExpunge = true + override val supportsMove = true + override val supportsCopy = true + override val supportsUpload = true + override val supportsTrashFolder = true + override val supportsSearchByDate = true + override val supportsFolderSubscriptions = true + override val isPushCapable = true + + override fun refreshFolderList(): FolderPathDelimiter? { + return commandRefreshFolderList.refreshFolderList() + } + + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + imapSync.sync(folderServerId, syncConfig, listener) + } + + override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { + imapSync.downloadMessage(syncConfig, folderServerId, messageServerId) + } + + override fun downloadMessageStructure(folderServerId: String, messageServerId: String) { + commandDownloadMessage.downloadMessageStructure(folderServerId, messageServerId) + } + + override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) { + commandDownloadMessage.downloadCompleteMessage(folderServerId, messageServerId) + } + + override fun setFlag(folderServerId: String, messageServerIds: List, flag: Flag, newState: Boolean) { + commandSetFlag.setFlag(folderServerId, messageServerIds, flag, newState) + } + + override fun markAllAsRead(folderServerId: String) { + commandMarkAllAsRead.markAllAsRead(folderServerId) + } + + override fun expunge(folderServerId: String) { + commandExpunge.expunge(folderServerId) + } + + override fun deleteMessages(folderServerId: String, messageServerIds: List) { + commandDelete.deleteMessages(folderServerId, messageServerIds) + } + + override fun deleteAllMessages(folderServerId: String) { + commandDeleteAll.deleteAll(folderServerId) + } + + override fun moveMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + return commandMoveOrCopyMessages.moveMessages(sourceFolderServerId, targetFolderServerId, messageServerIds) + } + + override fun moveMessagesAndMarkAsRead( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + val uidMapping = commandMoveOrCopyMessages.moveMessages( + sourceFolderServerId, + targetFolderServerId, + messageServerIds, + ) + if (uidMapping != null) { + setFlag(targetFolderServerId, uidMapping.values.toList(), Flag.SEEN, true) + } + return uidMapping + } + + override fun copyMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + return commandMoveOrCopyMessages.copyMessages(sourceFolderServerId, targetFolderServerId, messageServerIds) + } + + override fun search( + folderServerId: String, + query: String?, + requiredFlags: Set?, + forbiddenFlags: Set?, + performFullTextSearch: Boolean, + ): List { + return commandSearch.search(folderServerId, query, requiredFlags, forbiddenFlags, performFullTextSearch) + } + + override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) { + commandFetchMessage.fetchPart(folderServerId, messageServerId, part, bodyFactory) + } + + override fun findByMessageId(folderServerId: String, messageId: String): String? { + return commandFindByMessageId.findByMessageId(folderServerId, messageId) + } + + override fun uploadMessage(folderServerId: String, message: Message): String? { + return commandUploadMessage.uploadMessage(folderServerId, message) + } + + override fun sendMessage(message: Message) { + smtpTransport.sendMessage(message) + } + + override fun createPusher(callback: BackendPusherCallback): BackendPusher { + return ImapBackendPusher(imapStore, powerManager, idleRefreshManager, pushConfigProvider, callback, accountName) + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackendPusher.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackendPusher.kt new file mode 100644 index 0000000..c687ea8 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapBackendPusher.kt @@ -0,0 +1,251 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.backend.api.BackendPusher +import com.fsck.k9.backend.api.BackendPusherCallback +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.power.PowerManager +import com.fsck.k9.mail.store.imap.IdleRefreshManager +import com.fsck.k9.mail.store.imap.IdleRefreshTimeoutProvider +import com.fsck.k9.mail.store.imap.IdleRefreshTimer +import com.fsck.k9.mail.store.imap.ImapStore +import java.io.IOException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.logging.legacy.Log + +private const val IO_ERROR_TIMEOUT = 5 * 60 * 1000L +private const val UNEXPECTED_ERROR_TIMEOUT = 60 * 60 * 1000L + +/** + * Manages [ImapFolderPusher] instances that listen for changes to individual folders. + */ +internal class ImapBackendPusher( + private val imapStore: ImapStore, + private val powerManager: PowerManager, + private val idleRefreshManager: IdleRefreshManager, + private val pushConfigProvider: ImapPushConfigProvider, + private val callback: BackendPusherCallback, + private val accountName: String, + backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : BackendPusher, ImapPusherCallback { + private val coroutineScope = CoroutineScope(backgroundDispatcher) + private val lock = Any() + private val pushFolders = mutableMapOf() + private var currentFolderServerIds: Collection = emptySet() + private val pushFolderSleeping = mutableMapOf() + + private val idleRefreshTimeoutProvider = object : IdleRefreshTimeoutProvider { + override val idleRefreshTimeoutMs + get() = currentIdleRefreshMs + } + + @Volatile + private var currentMaxPushFolders = 0 + + @Volatile + private var currentIdleRefreshMs = 15 * 60 * 1000L + + override fun start() { + coroutineScope.launch { + pushConfigProvider.maxPushFoldersFlow.collect { maxPushFolders -> + currentMaxPushFolders = maxPushFolders + updateFolders() + } + } + + coroutineScope.launch { + pushConfigProvider.idleRefreshMinutesFlow.collect { idleRefreshMinutes -> + currentIdleRefreshMs = idleRefreshMinutes * 60 * 1000L + refreshFolderTimers() + } + } + } + + private fun refreshFolderTimers() { + synchronized(lock) { + for (pushFolder in pushFolders.values) { + pushFolder.refresh() + } + } + } + + override fun updateFolders(folderServerIds: Collection) { + updateFolders(folderServerIds, currentMaxPushFolders) + } + + private fun updateFolders() { + val currentFolderServerIds = synchronized(lock) { currentFolderServerIds } + updateFolders(currentFolderServerIds, currentMaxPushFolders) + } + + private fun updateFolders(folderServerIds: Collection, maxPushFolders: Int) { + Log.v("ImapBackendPusher.updateFolders(): %s", folderServerIds) + + val pushFolderServerIds = if (folderServerIds.size > maxPushFolders) { + folderServerIds.take(maxPushFolders).also { pushFolderServerIds -> + Log.v("..limiting Push to %d folders: %s", maxPushFolders, pushFolderServerIds) + } + } else { + folderServerIds + } + + val stopFolderPushers: List + val startFolderPushers: List + synchronized(lock) { + currentFolderServerIds = folderServerIds + + val oldRunningFolderServerIds = pushFolders.keys + val oldFolderServerIds = oldRunningFolderServerIds + pushFolderSleeping.keys + val removeFolderServerIds = oldFolderServerIds - pushFolderServerIds + stopFolderPushers = removeFolderServerIds + .asSequence() + .onEach { folderServerId -> cancelRetryTimer(folderServerId) } + .map { folderServerId -> pushFolders.remove(folderServerId) } + .filterNotNull() + .toList() + + val startFolderServerIds = pushFolderServerIds - oldRunningFolderServerIds + startFolderPushers = startFolderServerIds + .asSequence() + .filterNot { folderServerId -> isWaitingForRetry(folderServerId) } + .onEach { folderServerId -> pushFolderSleeping.remove(folderServerId) } + .map { folderServerId -> + createImapFolderPusher(folderServerId).also { folderPusher -> + pushFolders[folderServerId] = folderPusher + } + } + .toList() + } + + for (folderPusher in stopFolderPushers) { + folderPusher.stop() + } + + for (folderPusher in startFolderPushers) { + folderPusher.start() + } + } + + override fun stop() { + Log.v("ImapBackendPusher.stop()") + + coroutineScope.cancel() + + synchronized(lock) { + for (pushFolder in pushFolders.values) { + pushFolder.stop() + } + pushFolders.clear() + + for (retryTimer in pushFolderSleeping.values) { + retryTimer.cancel() + } + pushFolderSleeping.clear() + + currentFolderServerIds = emptySet() + } + } + + override fun reconnect() { + Log.v("ImapBackendPusher.reconnect()") + + synchronized(lock) { + for (pushFolder in pushFolders.values) { + pushFolder.stop() + } + pushFolders.clear() + + for (retryTimer in pushFolderSleeping.values) { + retryTimer.cancel() + } + pushFolderSleeping.clear() + } + + imapStore.closeAllConnections() + + updateFolders() + } + + private fun createImapFolderPusher(folderServerId: String): ImapFolderPusher { + return ImapFolderPusher( + imapStore, + powerManager, + idleRefreshManager, + this, + accountName, + folderServerId, + idleRefreshTimeoutProvider, + ) + } + + override fun onPushEvent(folderServerId: String) { + callback.onPushEvent(folderServerId) + idleRefreshManager.resetTimers() + } + + override fun onPushError(folderServerId: String, exception: Exception) { + synchronized(lock) { + pushFolders.remove(folderServerId) + + when (exception) { + is AuthenticationFailedException -> { + Log.v(exception, "Authentication failure when attempting to use IDLE") + // TODO: This could be happening because of too many connections to the host. Ideally we'd want to + // detect this case and use a lower timeout. + + startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT) + } + is IOException -> { + Log.v(exception, "I/O error while trying to use IDLE") + + startRetryTimer(folderServerId, IO_ERROR_TIMEOUT) + } + is MessagingException -> { + Log.v(exception, "MessagingException") + + if (exception.isPermanentFailure) { + startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT) + } else { + startRetryTimer(folderServerId, IO_ERROR_TIMEOUT) + } + } + else -> { + Log.v(exception, "Unexpected error") + startRetryTimer(folderServerId, UNEXPECTED_ERROR_TIMEOUT) + } + } + + if (pushFolders.isEmpty()) { + callback.onPushError(exception) + } + } + } + + override fun onPushNotSupported() { + callback.onPushNotSupported() + } + + private fun startRetryTimer(folderServerId: String, timeout: Long) { + Log.v("ImapBackendPusher for folder %s sleeping for %d ms", folderServerId, timeout) + pushFolderSleeping[folderServerId] = idleRefreshManager.startTimer(timeout, ::restartFolderPushers) + } + + private fun cancelRetryTimer(folderServerId: String) { + Log.v("Canceling ImapBackendPusher retry timer for folder %s", folderServerId) + pushFolderSleeping.remove(folderServerId)?.cancel() + } + + private fun isWaitingForRetry(folderServerId: String): Boolean { + return pushFolderSleeping[folderServerId]?.isWaiting == true + } + + private fun restartFolderPushers() { + Log.v("Refreshing ImapBackendPusher (at least one retry timer has expired)") + + updateFolders() + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapFolderPusher.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapFolderPusher.kt new file mode 100644 index 0000000..14f1820 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapFolderPusher.kt @@ -0,0 +1,101 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.power.PowerManager +import com.fsck.k9.mail.store.imap.IdleRefreshManager +import com.fsck.k9.mail.store.imap.IdleRefreshTimeoutProvider +import com.fsck.k9.mail.store.imap.IdleResult +import com.fsck.k9.mail.store.imap.ImapFolderIdler +import com.fsck.k9.mail.store.imap.ImapStore +import kotlin.concurrent.thread +import net.thunderbird.core.logging.legacy.Log + +/** + * Listens for changes to an IMAP folder in a dedicated thread. + */ +class ImapFolderPusher( + private val imapStore: ImapStore, + private val powerManager: PowerManager, + private val idleRefreshManager: IdleRefreshManager, + private val callback: ImapPusherCallback, + private val accountName: String, + private val folderServerId: String, + private val idleRefreshTimeoutProvider: IdleRefreshTimeoutProvider, +) { + @Volatile + private var folderIdler: ImapFolderIdler? = null + + @Volatile + private var stopPushing = false + + fun start() { + Log.v("Starting ImapFolderPusher for %s / %s", accountName, folderServerId) + + thread(name = "ImapFolderPusher-$accountName-$folderServerId") { + Log.v("Starting ImapFolderPusher thread for %s / %s", accountName, folderServerId) + + runPushLoop() + + Log.v("Exiting ImapFolderPusher thread for %s / %s", accountName, folderServerId) + } + } + + fun refresh() { + Log.v("Refreshing ImapFolderPusher for %s / %s", accountName, folderServerId) + + folderIdler?.refresh() + } + + fun stop() { + Log.v("Stopping ImapFolderPusher for %s / %s", accountName, folderServerId) + + stopPushing = true + folderIdler?.stop() + } + + private fun runPushLoop() { + val wakeLock = powerManager.newWakeLock("ImapFolderPusher-$accountName-$folderServerId") + wakeLock.acquire() + + performInitialSync() + + val folderIdler = ImapFolderIdler.create( + idleRefreshManager, + wakeLock, + imapStore, + folderServerId, + idleRefreshTimeoutProvider, + ).also { + folderIdler = it + } + + try { + while (!stopPushing) { + when (folderIdler.idle()) { + IdleResult.SYNC -> { + callback.onPushEvent(folderServerId) + } + IdleResult.STOPPED -> { + // ImapFolderIdler only stops when we ask it to. + // But it can't hurt to make extra sure we exit the loop. + stopPushing = true + } + IdleResult.NOT_SUPPORTED -> { + stopPushing = true + callback.onPushNotSupported() + } + } + } + } catch (e: Exception) { + Log.v(e, "Exception in ImapFolderPusher") + + this.folderIdler = null + callback.onPushError(folderServerId, e) + } + + wakeLock.release() + } + + private fun performInitialSync() { + callback.onPushEvent(folderServerId) + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPushConfigProvider.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPushConfigProvider.kt new file mode 100644 index 0000000..74cb31e --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPushConfigProvider.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.backend.imap + +import kotlinx.coroutines.flow.Flow + +interface ImapPushConfigProvider { + val maxPushFoldersFlow: Flow + val idleRefreshMinutesFlow: Flow +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPusherCallback.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPusherCallback.kt new file mode 100644 index 0000000..642668e --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapPusherCallback.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.backend.imap + +interface ImapPusherCallback { + fun onPushEvent(folderServerId: String) + fun onPushError(folderServerId: String, exception: Exception) + fun onPushNotSupported() +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt new file mode 100644 index 0000000..7e66874 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/ImapSync.kt @@ -0,0 +1,732 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.backend.api.BackendFolder +import com.fsck.k9.backend.api.BackendFolder.MoreMessages +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.helper.ExceptionHelper +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.DefaultBodyFactory +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.internet.MessageExtractor +import com.fsck.k9.mail.store.imap.FetchListener +import com.fsck.k9.mail.store.imap.ImapFolder +import com.fsck.k9.mail.store.imap.ImapMessage +import com.fsck.k9.mail.store.imap.ImapStore +import com.fsck.k9.mail.store.imap.OpenMode +import java.util.Collections +import java.util.Date +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.max +import net.thunderbird.core.logging.legacy.Log + +internal class ImapSync( + private val accountName: String, + private val backendStorage: BackendStorage, + private val imapStore: ImapStore, +) { + fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) { + synchronizeMailboxSynchronous(folder, syncConfig, listener) + } + + private fun synchronizeMailboxSynchronous(folder: String, syncConfig: SyncConfig, listener: SyncListener) { + Log.i("Synchronizing folder %s:%s", accountName, folder) + + var remoteFolder: ImapFolder? = null + var backendFolder: BackendFolder? = null + var newHighestKnownUid: Long = 0 + try { + Log.v("SYNC: About to get local folder %s", folder) + + backendFolder = backendStorage.getFolder(folder) + + listener.syncStarted(folder) + + Log.v("SYNC: About to get remote folder %s", folder) + remoteFolder = imapStore.getFolder(folder) + + /* + * Synchronization process: + * + Open the folder + Upload any local messages that are marked as PENDING_UPLOAD (Drafts, Sent, Trash) + Get the message count + Get the list of the newest K9.DEFAULT_VISIBLE_LIMIT messages + getMessages(messageCount - K9.DEFAULT_VISIBLE_LIMIT, messageCount) + See if we have each message locally, if not fetch it's flags and envelope + Get and update the unread count for the folder + Update the remote flags of any messages we have locally with an internal date newer than the remote message. + Get the current flags for any messages we have locally but did not just download + Update local flags + For any message we have locally but not remotely, delete the local message to keep cache clean. + Download larger parts of any new messages. + (Optional) Download small attachments in the background. + */ + + /* + * Open the remote folder. This pre-loads certain metadata like message count. + */ + Log.v("SYNC: About to open remote folder %s", folder) + + if (syncConfig.expungePolicy === ExpungePolicy.ON_POLL) { + Log.d("SYNC: Expunging folder %s:%s", accountName, folder) + if (!remoteFolder.isOpen || remoteFolder.mode != OpenMode.READ_WRITE) { + remoteFolder.open(OpenMode.READ_WRITE) + } + remoteFolder.expunge() + } + + remoteFolder.open(OpenMode.READ_ONLY) + + listener.syncAuthenticationSuccess() + + val uidValidity = remoteFolder.getUidValidity() + val oldUidValidity = backendFolder.getFolderExtraNumber(EXTRA_UID_VALIDITY) + if (oldUidValidity == null && uidValidity != null) { + Log.d("SYNC: Saving UIDVALIDITY for %s", folder) + backendFolder.setFolderExtraNumber(EXTRA_UID_VALIDITY, uidValidity) + } else if (oldUidValidity != null && oldUidValidity != uidValidity) { + Log.d("SYNC: UIDVALIDITY for %s changed; clearing local message cache", folder) + backendFolder.clearAllMessages() + backendFolder.setFolderExtraNumber(EXTRA_UID_VALIDITY, uidValidity!!) + backendFolder.setFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID, 0) + } + + /* + * Get the message list from the local store and create an index of + * the uids within the list. + */ + + val highestKnownUid = backendFolder.getFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID) ?: 0 + var localUidMap: Map? = backendFolder.getAllMessagesAndEffectiveDates() + + /* + * Get the remote message count. + */ + val remoteMessageCount = remoteFolder.messageCount + + var visibleLimit = backendFolder.visibleLimit + if (visibleLimit < 0) { + visibleLimit = syncConfig.defaultVisibleLimit + } + + val remoteMessages = mutableListOf() + val remoteUidMap = mutableMapOf() + + Log.v("SYNC: Remote message count for folder %s is %d", folder, remoteMessageCount) + + val earliestDate = syncConfig.earliestPollDate + val earliestTimestamp = earliestDate?.time ?: 0L + + var remoteStart = 1 + if (remoteMessageCount > 0) { + /* Message numbers start at 1. */ + remoteStart = if (visibleLimit > 0) { + max(0, remoteMessageCount - visibleLimit) + 1 + } else { + 1 + } + + Log.v( + "SYNC: About to get messages %d through %d for folder %s", + remoteStart, + remoteMessageCount, + folder, + ) + + val headerProgress = AtomicInteger(0) + listener.syncHeadersStarted(folder) + + val remoteMessageArray = remoteFolder.getMessages(remoteStart, remoteMessageCount, earliestDate, null) + + val messageCount = remoteMessageArray.size + + for (thisMess in remoteMessageArray) { + headerProgress.incrementAndGet() + listener.syncHeadersProgress(folder, headerProgress.get(), messageCount) + + val uid = thisMess.uid.toLong() + if (uid > highestKnownUid && uid > newHighestKnownUid) { + newHighestKnownUid = uid + } + + val localMessageTimestamp = localUidMap!![thisMess.uid] + if (localMessageTimestamp == null || localMessageTimestamp >= earliestTimestamp) { + remoteMessages.add(thisMess) + remoteUidMap[thisMess.uid] = thisMess + } + } + + Log.v("SYNC: Got %d messages for folder %s", remoteUidMap.size, folder) + + listener.syncHeadersFinished(folder, headerProgress.get(), remoteUidMap.size) + } else if (remoteMessageCount < 0) { + throw Exception("Message count $remoteMessageCount for folder $folder") + } + + /* + * Remove any messages that are in the local store but no longer on the remote store or are too old + */ + var moreMessages = backendFolder.getMoreMessages() + if (syncConfig.syncRemoteDeletions) { + val destroyMessageUids = mutableListOf() + for (localMessageUid in localUidMap!!.keys) { + if (remoteUidMap[localMessageUid] == null) { + destroyMessageUids.add(localMessageUid) + } + } + + if (destroyMessageUids.isNotEmpty()) { + moreMessages = MoreMessages.UNKNOWN + backendFolder.destroyMessages(destroyMessageUids) + for (uid in destroyMessageUids) { + listener.syncRemovedMessage(folder, uid) + } + } + } + + @Suppress("UNUSED_VALUE") // free memory early? (better break up the method!) + localUidMap = null + + if (moreMessages === MoreMessages.UNKNOWN) { + updateMoreMessages(remoteFolder, backendFolder, earliestDate, remoteStart) + } + + /* + * Now we download the actual content of messages. + */ + downloadMessages( + syncConfig, + remoteFolder, + backendFolder, + remoteMessages, + highestKnownUid, + listener, + ) + + listener.folderStatusChanged(folder) + + /* Notify listeners that we're finally done. */ + + backendFolder.setLastChecked(System.currentTimeMillis()) + backendFolder.setStatus(null) + + Log.d("Done synchronizing folder %s:%s @ %tc", accountName, folder, System.currentTimeMillis()) + + listener.syncFinished(folder) + + Log.i("Done synchronizing folder %s:%s", accountName, folder) + } catch (e: AuthenticationFailedException) { + listener.syncFailed(folder, "Authentication failure", e) + } catch (e: Exception) { + Log.e(e, "synchronizeMailbox") + // If we don't set the last checked, it can try too often during + // failure conditions + val rootMessage = ExceptionHelper.getRootCauseMessage(e) + if (backendFolder != null) { + try { + backendFolder.setStatus(rootMessage) + backendFolder.setLastChecked(System.currentTimeMillis()) + } catch (e: Exception) { + Log.e(e, "Could not set last checked on folder %s:%s", accountName, folder) + } + } + + listener.syncFailed(folder, rootMessage, e) + + Log.e( + "Failed synchronizing folder %s:%s @ %tc", + accountName, + folder, + System.currentTimeMillis(), + ) + } finally { + if (newHighestKnownUid > 0 && backendFolder != null) { + Log.v("Saving new highest known UID: %d", newHighestKnownUid) + backendFolder.setFolderExtraNumber(EXTRA_HIGHEST_KNOWN_UID, newHighestKnownUid) + } + remoteFolder?.close() + } + } + + fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { + val backendFolder = backendStorage.getFolder(folderServerId) + val remoteFolder = imapStore.getFolder(folderServerId) + try { + remoteFolder.open(OpenMode.READ_ONLY) + val remoteMessage = remoteFolder.getMessage(messageServerId) + + downloadMessages( + syncConfig, + remoteFolder, + backendFolder, + listOf(remoteMessage), + null, + SimpleSyncListener(), + ) + } finally { + remoteFolder.close() + } + } + + /** + * Fetches the messages described by inputMessages from the remote store and writes them to local storage. + * + * @param remoteFolder + * The remote folder to download messages from. + * @param backendFolder + * The [BackendFolder] instance corresponding to the remote folder. + * @param inputMessages + * A list of messages objects that store the UIDs of which messages to download. + */ + private fun downloadMessages( + syncConfig: SyncConfig, + remoteFolder: ImapFolder, + backendFolder: BackendFolder, + inputMessages: List, + highestKnownUid: Long?, + listener: SyncListener, + ) { + val folder = remoteFolder.serverId + + val syncFlagMessages = mutableListOf() + var unsyncedMessages = mutableListOf() + val downloadedMessageCount = AtomicInteger(0) + + val messages = inputMessages.toMutableList() + for (message in messages) { + evaluateMessageForDownload( + message, + backendFolder, + unsyncedMessages, + syncFlagMessages, + ) + } + + val progress = AtomicInteger(0) + val todo = unsyncedMessages.size + syncFlagMessages.size + listener.syncProgress(folder, progress.get(), todo) + + Log.d("SYNC: Have %d unsynced messages", unsyncedMessages.size) + + messages.clear() + val largeMessages = mutableListOf() + val smallMessages = mutableListOf() + if (unsyncedMessages.isNotEmpty()) { + Collections.sort(unsyncedMessages, UidReverseComparator()) + val visibleLimit = backendFolder.visibleLimit + val listSize = unsyncedMessages.size + + if (visibleLimit in 1 until listSize) { + unsyncedMessages = unsyncedMessages.subList(0, visibleLimit) + } + + Log.d("SYNC: About to fetch %d unsynced messages for folder %s", unsyncedMessages.size, folder) + + fetchUnsyncedMessages( + syncConfig, + remoteFolder, + unsyncedMessages, + smallMessages, + largeMessages, + progress, + todo, + listener, + ) + + Log.d("SYNC: Synced unsynced messages for folder %s", folder) + } + + Log.d( + "SYNC: Have %d large messages and %d small messages out of %d unsynced messages", + largeMessages.size, + smallMessages.size, + unsyncedMessages.size, + ) + + unsyncedMessages.clear() + + /* + * Grab the content of the small messages first. This is going to + * be very fast and at very worst will be a single up of a few bytes and a single + * download of 625k. + */ + val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize + // TODO: Only fetch small and large messages if we have some + downloadSmallMessages( + remoteFolder, + backendFolder, + smallMessages, + progress, + downloadedMessageCount, + todo, + highestKnownUid, + listener, + ) + smallMessages.clear() + + /* + * Now do the large messages that require more round trips. + */ + downloadLargeMessages( + remoteFolder, + backendFolder, + largeMessages, + progress, + downloadedMessageCount, + todo, + highestKnownUid, + listener, + maxDownloadSize, + ) + largeMessages.clear() + + /* + * Refresh the flags for any messages in the local store that we didn't just + * download. + */ + refreshLocalMessageFlags(syncConfig, remoteFolder, backendFolder, syncFlagMessages, progress, todo, listener) + + Log.d("SYNC: Synced remote messages for folder %s, %d new messages", folder, downloadedMessageCount.get()) + } + + private fun evaluateMessageForDownload( + message: ImapMessage, + backendFolder: BackendFolder, + unsyncedMessages: MutableList, + syncFlagMessages: MutableList, + ) { + val messageServerId = message.uid + if (message.isSet(Flag.DELETED)) { + Log.v("Message with uid %s is marked as deleted", messageServerId) + syncFlagMessages.add(message) + return + } + + val messagePresentLocally = backendFolder.isMessagePresent(messageServerId) + if (!messagePresentLocally) { + Log.v("Message with uid %s has not yet been downloaded", messageServerId) + unsyncedMessages.add(message) + return + } + + val messageFlags = backendFolder.getMessageFlags(messageServerId) + if (!messageFlags.contains(Flag.DELETED)) { + Log.v("Message with uid %s is present in the local store", messageServerId) + if (!messageFlags.contains(Flag.X_DOWNLOADED_FULL) && !messageFlags.contains(Flag.X_DOWNLOADED_PARTIAL)) { + Log.v("Message with uid %s is not downloaded, even partially; trying again", messageServerId) + unsyncedMessages.add(message) + } else { + syncFlagMessages.add(message) + } + } else { + Log.v("Local copy of message with uid %s is marked as deleted", messageServerId) + } + } + + private fun isOldMessage(messageServerId: String, highestKnownUid: Long?): Boolean { + if (highestKnownUid == null) return false + + try { + val messageUid = messageServerId.toLong() + return messageUid <= highestKnownUid + } catch (e: NumberFormatException) { + Log.w(e, "Couldn't parse UID: %s", messageServerId) + } + + return false + } + + private fun fetchUnsyncedMessages( + syncConfig: SyncConfig, + remoteFolder: ImapFolder, + unsyncedMessages: List, + smallMessages: MutableList, + largeMessages: MutableList, + progress: AtomicInteger, + todo: Int, + listener: SyncListener, + ) { + val folder = remoteFolder.serverId + val fetchProfile = FetchProfile().apply { + add(FetchProfile.Item.FLAGS) + add(FetchProfile.Item.ENVELOPE) + } + + remoteFolder.fetch( + unsyncedMessages, + fetchProfile, + object : FetchListener { + override fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean) { + try { + if (message.isSet(Flag.DELETED)) { + Log.v( + "Newly downloaded message %s:%s:%s was marked deleted on server, skipping", + accountName, + folder, + message.uid, + ) + + if (isFirstResponse) { + progress.incrementAndGet() + } + + // TODO: This might be the source of poll count errors in the UI. Is todo always the same as ofTotal + listener.syncProgress(folder, progress.get(), todo) + + return + } + + if (syncConfig.maximumAutoDownloadMessageSize > 0 && + message.size > syncConfig.maximumAutoDownloadMessageSize + ) { + largeMessages.add(message) + } else { + smallMessages.add(message) + } + } catch (e: Exception) { + Log.e(e, "Error while storing downloaded message.") + } + } + }, + syncConfig.maximumAutoDownloadMessageSize, + ) + } + + private fun downloadSmallMessages( + remoteFolder: ImapFolder, + backendFolder: BackendFolder, + smallMessages: List, + progress: AtomicInteger, + downloadedMessageCount: AtomicInteger, + todo: Int, + highestKnownUid: Long?, + listener: SyncListener, + ) { + val folder = remoteFolder.serverId + val fetchProfile = FetchProfile().apply { + add(FetchProfile.Item.BODY) + } + + Log.d("SYNC: Fetching %d small messages for folder %s", smallMessages.size, folder) + + remoteFolder.fetch( + smallMessages, + fetchProfile, + object : FetchListener { + override fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean) { + try { + // Store the updated message locally + backendFolder.saveMessage(message, MessageDownloadState.FULL) + + if (isFirstResponse) { + progress.incrementAndGet() + downloadedMessageCount.incrementAndGet() + } + + val messageServerId = message.uid + Log.v( + "About to notify listeners that we got a new small message %s:%s:%s", + accountName, + folder, + messageServerId, + ) + + // Update the listener with what we've found + listener.syncProgress(folder, progress.get(), todo) + + val isOldMessage = isOldMessage(messageServerId, highestKnownUid) + listener.syncNewMessage(folder, messageServerId, isOldMessage) + } catch (e: Exception) { + Log.e(e, "SYNC: fetch small messages") + } + } + }, + -1, + ) + + Log.d("SYNC: Done fetching small messages for folder %s", folder) + } + + private fun downloadLargeMessages( + remoteFolder: ImapFolder, + backendFolder: BackendFolder, + largeMessages: List, + progress: AtomicInteger, + downloadedMessageCount: AtomicInteger, + todo: Int, + highestKnownUid: Long?, + listener: SyncListener, + maxDownloadSize: Int, + ) { + val folder = remoteFolder.serverId + val fetchProfile = FetchProfile().apply { + add(FetchProfile.Item.STRUCTURE) + } + + Log.d("SYNC: Fetching large messages for folder %s", folder) + + remoteFolder.fetch(largeMessages, fetchProfile, null, maxDownloadSize) + for (message in largeMessages) { + if (message.body == null) { + downloadSaneBody(remoteFolder, backendFolder, message, maxDownloadSize) + } else { + downloadPartial(remoteFolder, backendFolder, message, maxDownloadSize) + } + + val messageServerId = message.uid + Log.v( + "About to notify listeners that we got a new large message %s:%s:%s", + accountName, + folder, + messageServerId, + ) + + // Update the listener with what we've found + progress.incrementAndGet() + downloadedMessageCount.incrementAndGet() + + listener.syncProgress(folder, progress.get(), todo) + + val isOldMessage = isOldMessage(messageServerId, highestKnownUid) + listener.syncNewMessage(folder, messageServerId, isOldMessage) + } + + Log.d("SYNC: Done fetching large messages for folder %s", folder) + } + + private fun refreshLocalMessageFlags( + syncConfig: SyncConfig, + remoteFolder: ImapFolder, + backendFolder: BackendFolder, + syncFlagMessages: List, + progress: AtomicInteger, + todo: Int, + listener: SyncListener, + ) { + val folder = remoteFolder.serverId + Log.d("SYNC: About to sync flags for %d remote messages for folder %s", syncFlagMessages.size, folder) + + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.FLAGS) + + val undeletedMessages = mutableListOf() + for (message in syncFlagMessages) { + if (!message.isSet(Flag.DELETED)) { + undeletedMessages.add(message) + } + } + + val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize + remoteFolder.fetch(undeletedMessages, fetchProfile, null, maxDownloadSize) + for (remoteMessage in syncFlagMessages) { + val messageChanged = syncFlags(syncConfig, backendFolder, remoteMessage) + if (messageChanged) { + listener.syncFlagChanged(folder, remoteMessage.uid) + } + progress.incrementAndGet() + listener.syncProgress(folder, progress.get(), todo) + } + } + + private fun downloadSaneBody( + remoteFolder: ImapFolder, + backendFolder: BackendFolder, + message: ImapMessage, + maxDownloadSize: Int, + ) { + /* + * The provider was unable to get the structure of the message, so + * we'll download a reasonable portion of the message and mark it as + * incomplete so the entire thing can be downloaded later if the user + * wishes to download it. + */ + val fetchProfile = FetchProfile() + fetchProfile.add(FetchProfile.Item.BODY_SANE) + /* + * TODO a good optimization here would be to make sure that all Stores set + * the proper size after this fetch and compare the before and after size. If + * they equal we can mark this SYNCHRONIZED instead of PARTIALLY_SYNCHRONIZED + */ + remoteFolder.fetch(listOf(message), fetchProfile, null, maxDownloadSize) + + // Store the updated message locally + backendFolder.saveMessage(message, MessageDownloadState.PARTIAL) + } + + private fun downloadPartial( + remoteFolder: ImapFolder, + backendFolder: BackendFolder, + message: ImapMessage, + maxDownloadSize: Int, + ) { + /* + * We have a structure to deal with, from which + * we can pull down the parts we want to actually store. + * Build a list of parts we are interested in. Text parts will be downloaded + * right now, attachments will be left for later. + */ + val viewables = MessageExtractor.collectTextParts(message) + + /* + * Now download the parts we're interested in storing. + */ + val bodyFactory: BodyFactory = DefaultBodyFactory() + for (part in viewables) { + remoteFolder.fetchPart(message, part, bodyFactory, maxDownloadSize) + } + + // Store the updated message locally + backendFolder.saveMessage(message, MessageDownloadState.PARTIAL) + } + + private fun syncFlags(syncConfig: SyncConfig, backendFolder: BackendFolder, remoteMessage: ImapMessage): Boolean { + val messageServerId = remoteMessage.uid + if (!backendFolder.isMessagePresent(messageServerId)) return false + + val localMessageFlags = backendFolder.getMessageFlags(messageServerId) + if (localMessageFlags.contains(Flag.DELETED)) return false + + var messageChanged = false + if (remoteMessage.isSet(Flag.DELETED)) { + if (syncConfig.syncRemoteDeletions) { + backendFolder.setMessageFlag(messageServerId, Flag.DELETED, true) + messageChanged = true + } + } else { + for (flag in syncConfig.syncFlags) { + if (remoteMessage.isSet(flag) != localMessageFlags.contains(flag)) { + backendFolder.setMessageFlag(messageServerId, flag, remoteMessage.isSet(flag)) + messageChanged = true + } + } + } + + return messageChanged + } + + private fun updateMoreMessages( + remoteFolder: ImapFolder, + backendFolder: BackendFolder, + earliestDate: Date?, + remoteStart: Int, + ) { + if (remoteStart == 1) { + backendFolder.setMoreMessages(MoreMessages.FALSE) + } else { + val moreMessagesAvailable = remoteFolder.areMoreMessagesAvailable(remoteStart, earliestDate) + val newMoreMessages = if (moreMessagesAvailable) MoreMessages.TRUE else MoreMessages.FALSE + backendFolder.setMoreMessages(newMoreMessages) + } + } + + companion object { + private const val EXTRA_UID_VALIDITY = "imapUidValidity" + private const val EXTRA_HIGHEST_KNOWN_UID = "imapHighestKnownUid" + } +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/SimpleSyncListener.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/SimpleSyncListener.kt new file mode 100644 index 0000000..0119761 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/SimpleSyncListener.kt @@ -0,0 +1,18 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.backend.api.SyncListener + +class SimpleSyncListener : SyncListener { + override fun syncStarted(folderServerId: String) = Unit + override fun syncAuthenticationSuccess() = Unit + override fun syncHeadersStarted(folderServerId: String) = Unit + override fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int) = Unit + override fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int) = Unit + override fun syncProgress(folderServerId: String, completed: Int, total: Int) = Unit + override fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean) = Unit + override fun syncRemovedMessage(folderServerId: String, messageServerId: String) = Unit + override fun syncFlagChanged(folderServerId: String, messageServerId: String) = Unit + override fun syncFinished(folderServerId: String) = Unit + override fun syncFailed(folderServerId: String, message: String, exception: Exception?) = Unit + override fun folderStatusChanged(folderServerId: String) = Unit +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/SystemAlarmManager.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/SystemAlarmManager.kt new file mode 100644 index 0000000..e2133cf --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/SystemAlarmManager.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.backend.imap + +interface SystemAlarmManager { + fun setAlarm(triggerTime: Long, callback: () -> Unit) + fun cancelAlarm() + fun now(): Long +} diff --git a/backend/imap/src/main/java/com/fsck/k9/backend/imap/UidReverseComparator.kt b/backend/imap/src/main/java/com/fsck/k9/backend/imap/UidReverseComparator.kt new file mode 100644 index 0000000..58d8ae5 --- /dev/null +++ b/backend/imap/src/main/java/com/fsck/k9/backend/imap/UidReverseComparator.kt @@ -0,0 +1,24 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.Message +import java.util.Comparator + +internal class UidReverseComparator : Comparator { + override fun compare(messageLeft: Message, messageRight: Message): Int { + val uidLeft = messageLeft.uidOrNull + val uidRight = messageRight.uidOrNull + if (uidLeft == null && uidRight == null) { + return 0 + } else if (uidLeft == null) { + return 1 + } else if (uidRight == null) { + return -1 + } + + // reverse order + return uidRight.compareTo(uidLeft) + } + + private val Message.uidOrNull + get() = uid?.toLongOrNull() +} diff --git a/backend/imap/src/main/kotlin/net/thunderbird/backend/imap/ImapRemoteFolderCreator.kt b/backend/imap/src/main/kotlin/net/thunderbird/backend/imap/ImapRemoteFolderCreator.kt new file mode 100644 index 0000000..ba7b073 --- /dev/null +++ b/backend/imap/src/main/kotlin/net/thunderbird/backend/imap/ImapRemoteFolderCreator.kt @@ -0,0 +1,75 @@ +package net.thunderbird.backend.imap + +import com.fsck.k9.backend.imap.ImapBackend +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.store.imap.ImapStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.thunderbird.backend.api.BackendFactory +import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome +import net.thunderbird.backend.api.folder.RemoteFolderCreator +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.mail.account.api.BaseAccount + +class ImapRemoteFolderCreator( + private val logger: Logger, + private val imapStore: ImapStore, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : RemoteFolderCreator { + override suspend fun create( + folderServerId: FolderServerId, + mustCreate: Boolean, + folderType: FolderType, + ): Outcome = withContext(ioDispatcher) { + val remoteFolder = imapStore.getFolder(name = folderServerId.serverId) + val outcome = try { + val folderExists = remoteFolder.exists() + when { + folderExists && mustCreate -> Outcome.failure( + RemoteFolderCreationOutcome.Error.AlreadyExists, + ) + + folderExists -> Outcome.success(RemoteFolderCreationOutcome.Success.AlreadyExists) + + !folderExists && remoteFolder.create(folderType = folderType) -> Outcome.success( + RemoteFolderCreationOutcome.Success.Created, + ) + + else -> Outcome.failure( + RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder( + reason = "Failed to create folder on remote server.", + ), + ) + } + } catch (e: MessagingException) { + logger.error(message = { "Failed to create remote folder '${folderServerId.serverId}'" }, throwable = e) + Outcome.failure( + RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder( + reason = e.message ?: "Unhandled exception. Please check the logs.", + ), + ) + } finally { + remoteFolder.close() + } + + outcome + } +} + +class ImapRemoteFolderCreatorFactory( + private val logger: Logger, + private val backendFactory: BackendFactory, +) : RemoteFolderCreator.Factory { + override fun create(account: BaseAccount): RemoteFolderCreator { + val backend = backendFactory.createBackend(account) as ImapBackend + return ImapRemoteFolderCreator( + logger = logger, + imapStore = backend.imapStore, + ioDispatcher = Dispatchers.IO, + ) + } +} diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt new file mode 100644 index 0000000..bcca71c --- /dev/null +++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt @@ -0,0 +1,185 @@ +package com.fsck.k9.backend.imap + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.Test + +private const val START_TIME = 100_000_000L + +class BackendIdleRefreshManagerTest { + val alarmManager = MockSystemAlarmManager(START_TIME) + val idleRefreshManager = BackendIdleRefreshManager(alarmManager) + + @Test + fun `single timer`() { + val timeout = 15 * 60 * 1000L + val callback = RecordingCallback() + + idleRefreshManager.startTimer(timeout, callback::alarm) + alarmManager.advanceTime(timeout) + + assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout)) + assertThat(callback.wasCalled).isTrue() + } + + @Test + fun `starting two timers in quick succession`() { + val timeout = 15 * 60 * 1000L + val callback1 = RecordingCallback() + val callback2 = RecordingCallback() + + idleRefreshManager.startTimer(timeout, callback1::alarm) + // Advance clock less than MIN_TIMER_DELTA + alarmManager.advanceTime(100) + idleRefreshManager.startTimer(timeout, callback2::alarm) + alarmManager.advanceTime(timeout) + + assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout)) + assertThat(callback1.wasCalled).isTrue() + assertThat(callback2.wasCalled).isTrue() + } + + @Test + fun `starting second timer some time after first should trigger both at initial trigger time`() { + val timeout = 15 * 60 * 1000L + val waitTime = 10 * 60 * 1000L + val callback1 = RecordingCallback() + val callback2 = RecordingCallback() + + idleRefreshManager.startTimer(timeout, callback1::alarm) + // Advance clock by more than MIN_TIMER_DELTA but less than 'timeout' + alarmManager.advanceTime(waitTime) + + assertThat(callback1.wasCalled).isFalse() + + idleRefreshManager.startTimer(timeout, callback2::alarm) + alarmManager.advanceTime(timeout - waitTime) + + assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout)) + assertThat(callback1.wasCalled).isTrue() + assertThat(callback2.wasCalled).isTrue() + } + + @Test + fun `second timer with lower timeout should reschedule alarm`() { + val timeout1 = 15 * 60 * 1000L + val timeout2 = 10 * 60 * 1000L + val callback1 = RecordingCallback() + val callback2 = RecordingCallback() + + idleRefreshManager.startTimer(timeout1, callback1::alarm) + + assertThat(alarmManager.triggerTime).isEqualTo(START_TIME + timeout1) + + idleRefreshManager.startTimer(timeout2, callback2::alarm) + alarmManager.advanceTime(timeout2) + + assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout1, START_TIME + timeout2)) + assertThat(callback1.wasCalled).isTrue() + assertThat(callback2.wasCalled).isTrue() + } + + @Test + fun `do not trigger timers earlier than necessary`() { + val timeout1 = 10 * 60 * 1000L + val timeout2 = 23 * 60 * 1000L + val callback1 = RecordingCallback() + val callback2 = RecordingCallback() + val callback3 = RecordingCallback() + + idleRefreshManager.startTimer(timeout1, callback1::alarm) + idleRefreshManager.startTimer(timeout2, callback2::alarm) + + alarmManager.advanceTime(timeout1) + assertThat(callback1.wasCalled).isTrue() + assertThat(callback2.wasCalled).isFalse() + + idleRefreshManager.startTimer(timeout1, callback3::alarm) + + alarmManager.advanceTime(timeout1) + + assertThat(alarmManager.alarmTimes).isEqualTo( + listOf(START_TIME + timeout1, START_TIME + timeout2, START_TIME + timeout1 + timeout1), + ) + assertThat(callback2.wasCalled).isTrue() + assertThat(callback3.wasCalled).isTrue() + } + + @Test + fun `reset timers`() { + val timeout = 10 * 60 * 1000L + val callback = RecordingCallback() + + idleRefreshManager.startTimer(timeout, callback::alarm) + + alarmManager.advanceTime(5 * 60 * 1000L) + assertThat(callback.wasCalled).isFalse() + + idleRefreshManager.resetTimers() + + assertThat(alarmManager.triggerTime).isEqualTo(NO_TRIGGER_TIME) + assertThat(callback.wasCalled).isTrue() + } + + @Test + fun `cancel timer`() { + val timeout = 10 * 60 * 1000L + val callback = RecordingCallback() + + val timer = idleRefreshManager.startTimer(timeout, callback::alarm) + + alarmManager.advanceTime(5 * 60 * 1000L) + timer.cancel() + + assertThat(alarmManager.triggerTime).isEqualTo(NO_TRIGGER_TIME) + assertThat(callback.wasCalled).isFalse() + } +} + +class RecordingCallback { + var wasCalled = false + private set + + fun alarm() { + wasCalled = true + } +} + +typealias Callback = () -> Unit +private const val NO_TRIGGER_TIME = -1L + +class MockSystemAlarmManager(startTime: Long) : SystemAlarmManager { + var now = startTime + var triggerTime = NO_TRIGGER_TIME + var callback: Callback? = null + val alarmTimes = mutableListOf() + + override fun setAlarm(triggerTime: Long, callback: () -> Unit) { + this.triggerTime = triggerTime + this.callback = callback + alarmTimes.add(triggerTime) + } + + override fun cancelAlarm() { + this.triggerTime = NO_TRIGGER_TIME + this.callback = null + } + + override fun now(): Long = now + + fun advanceTime(delta: Long) { + now += delta + if (now >= triggerTime) { + trigger() + } + } + + private fun trigger() { + callback?.invoke().also { + triggerTime = NO_TRIGGER_TIME + callback = null + } + } +} diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt new file mode 100644 index 0000000..a80dc52 --- /dev/null +++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt @@ -0,0 +1,338 @@ +package com.fsck.k9.backend.imap + +import app.k9mail.backend.testing.InMemoryBackendStorage +import assertk.assertThat +import assertk.assertions.containsAtLeast +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEmpty +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.store.imap.FetchListener +import com.fsck.k9.mail.store.imap.ImapMessage +import com.fsck.k9.mail.testing.message.buildMessage +import java.util.Date +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.logging.testing.TestLogger +import org.apache.james.mime4j.dom.field.DateTimeField +import org.apache.james.mime4j.field.DefaultFieldParser +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never + +private const val ACCOUNT_NAME = "Account-1" +private const val FOLDER_SERVER_ID = "FOLDER_ONE" +private const val MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE = 1000 +private const val DEFAULT_VISIBLE_LIMIT = 25 +private const val DEFAULT_MESSAGE_DATE = "Tue, 04 Jan 2022 10:00:00 +0100" + +class ImapSyncTest { + private val backendStorage = createBackendStorage() + private val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + private val imapStore = TestImapStore() + private val imapFolder = imapStore.addFolder(FOLDER_SERVER_ID) + private val imapSync = ImapSync(ACCOUNT_NAME, backendStorage, imapStore) + private val syncListener = mock() + private val defaultSyncConfig = createSyncConfig() + + @Before + fun setUp() { + Log.logger = TestLogger() + } + + @Test + fun `sync of empty folder should notify listener`() { + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + verify(syncListener).syncStarted(FOLDER_SERVER_ID) + verify(syncListener).syncAuthenticationSuccess() + verify(syncListener).syncFinished(FOLDER_SERVER_ID) + verify(syncListener, never()).syncFailed(folderServerId = any(), message = any(), exception = any()) + } + + @Test + fun `sync of folder with negative messageCount should return an error`() { + imapFolder.messageCount = -1 + + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + verify(syncListener).syncFailed( + folderServerId = eq(FOLDER_SERVER_ID), + message = eq("Exception: Message count -1 for folder $FOLDER_SERVER_ID"), + exception = any(), + ) + } + + @Test + fun `successful sync should close folder`() { + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + assertThat(imapFolder.isClosed).isTrue() + } + + @Test + fun `sync with error should close folder`() { + imapFolder.messageCount = -1 + + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + assertThat(imapFolder.isClosed).isTrue() + } + + @Test + fun `sync with ExpungePolicy ON_POLL should expunge remote folder`() { + val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.ON_POLL) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(imapFolder.wasExpunged).isTrue() + } + + @Test + fun `sync with ExpungePolicy MANUALLY should not expunge remote folder`() { + val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.MANUALLY) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(imapFolder.wasExpunged).isFalse() + } + + @Test + fun `sync with ExpungePolicy IMMEDIATELY should not expunge remote folder`() { + val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.IMMEDIATELY) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(imapFolder.wasExpunged).isFalse() + } + + @Test + fun `sync with syncRemoteDeletions=true should remove local messages`() { + addMessageToBackendFolder(uid = 42) + val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = true) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).isEmpty() + verify(syncListener).syncStarted(FOLDER_SERVER_ID) + verify(syncListener).syncFinished(FOLDER_SERVER_ID) + } + + @Test + fun `sync with syncRemoteDeletions=false should not remove local messages`() { + addMessageToBackendFolder(uid = 23) + val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23") + verify(syncListener).syncStarted(FOLDER_SERVER_ID) + verify(syncListener).syncFinished(FOLDER_SERVER_ID) + } + + @Test + fun `sync should remove messages older than earliestPollDate`() { + addMessageToImapAndBackendFolder(uid = 23, date = "Mon, 03 Jan 2022 10:00:00 +0100") + addMessageToImapAndBackendFolder(uid = 42, date = "Wed, 05 Jan 2022 20:00:00 +0100") + val syncConfig = defaultSyncConfig.copy( + syncRemoteDeletions = true, + earliestPollDate = "Tue, 04 Jan 2022 12:00:00 +0100".toDate(), + ) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("42") + } + + @Test + fun `sync with new messages on server should download messages`() { + addMessageToImapFolder(uid = 9) + addMessageToImapFolder(uid = 13) + + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("9", "13") + verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "9", isOldMessage = false) + verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "13", isOldMessage = false) + } + + @Test + fun `sync downloading old messages should notify listener with isOldMessage=true`() { + addMessageToBackendFolder(uid = 42) + addMessageToImapFolder(uid = 23) + addMessageToImapFolder(uid = 42) + + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23", "42") + verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "23", isOldMessage = true) + } + + @Test + fun `determining the highest UID should use numerical ordering`() { + addMessageToBackendFolder(uid = 9) + addMessageToBackendFolder(uid = 100) + // When text ordering is used: "9" > "100" -> highest UID = 9 (when it should be 100) + // With 80 > 9 the message on the server is considered a new message, but it shouldn't be (80 < 100) + addMessageToImapFolder(uid = 80) + + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "80", isOldMessage = true) + } + + @Test + fun `sync should update flags of existing messages`() { + addMessageToBackendFolder(uid = 2) + addMessageToImapFolder(uid = 2, flags = setOf(Flag.SEEN, Flag.ANSWERED)) + + imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener) + + assertThat(backendFolder.getMessageFlags(messageServerId = "2")).containsAtLeast(Flag.SEEN, Flag.ANSWERED) + } + + @Test + fun `sync with UIDVALIDITY change should clear all messages`() { + imapFolder.setUidValidity(1) + addMessageToImapFolder(uid = 300) + addMessageToImapFolder(uid = 301) + val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("300", "301") + + imapFolder.setUidValidity(9000) + imapFolder.removeAllMessages() + addMessageToImapFolder(uid = 1) + + imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("1") + verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "1", isOldMessage = false) + } + + @Test + fun `sync with multiple FETCH responses when downloading small message should report correct progress`() { + val folderServerId = "FOLDER_TWO" + backendStorage.createBackendFolder(folderServerId) + val specialImapFolder = object : TestImapFolder(folderServerId) { + override fun fetch( + messages: List, + fetchProfile: FetchProfile, + listener: FetchListener?, + maxDownloadSize: Int, + ) { + super.fetch(messages, fetchProfile, listener, maxDownloadSize) + + // When fetching the body simulate an additional FETCH response + if (FetchProfile.Item.BODY in fetchProfile) { + val message = messages.first() + listener?.onFetchResponse(message, isFirstResponse = false) + } + } + } + specialImapFolder.addMessage(42) + imapStore.addFolder(specialImapFolder) + + imapSync.sync(folderServerId, defaultSyncConfig, syncListener) + + verify(syncListener, atLeast(1)).syncProgress(folderServerId, completed = 1, total = 1) + verify(syncListener, never()).syncProgress(folderServerId, completed = 2, total = 1) + } + + private fun addMessageToBackendFolder(uid: Long, date: String = DEFAULT_MESSAGE_DATE) { + val messageServerId = uid.toString() + val message = createSimpleMessage(messageServerId, date).apply { + setUid(messageServerId) + } + backendFolder.saveMessage(message, MessageDownloadState.FULL) + + val highestKnownUid = backendFolder.getFolderExtraNumber("imapHighestKnownUid") ?: 0 + if (uid > highestKnownUid) { + backendFolder.setFolderExtraNumber("imapHighestKnownUid", uid) + } + } + + private fun addMessageToImapFolder(uid: Long, flags: Set = emptySet(), date: String = DEFAULT_MESSAGE_DATE) { + imapFolder.addMessage(uid, flags, date) + } + + private fun TestImapFolder.addMessage( + uid: Long, + flags: Set = emptySet(), + date: String = DEFAULT_MESSAGE_DATE, + ) { + val messageServerId = uid.toString() + val message = createSimpleMessage(messageServerId, date) + addMessage(uid, message) + + if (flags.isNotEmpty()) { + val imapMessage = getMessage(messageServerId) + setFlags(listOf(imapMessage), flags, true) + } + } + + private fun addMessageToImapAndBackendFolder(uid: Long, date: String) { + addMessageToBackendFolder(uid, date) + addMessageToImapFolder(uid, date = date) + } + + private fun createBackendStorage(): InMemoryBackendStorage { + return InMemoryBackendStorage().apply { + createBackendFolder(FOLDER_SERVER_ID) + } + } + + private fun InMemoryBackendStorage.createBackendFolder(serverId: String) { + createFolderUpdater().use { updater -> + val folderInfo = FolderInfo( + serverId = serverId, + name = "irrelevant", + type = FolderType.REGULAR, + ) + updater.createFolders(listOf(folderInfo)) + } + } + + private fun createSyncConfig(): SyncConfig { + return SyncConfig( + expungePolicy = ExpungePolicy.MANUALLY, + earliestPollDate = null, + syncRemoteDeletions = true, + maximumAutoDownloadMessageSize = MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE, + defaultVisibleLimit = DEFAULT_VISIBLE_LIMIT, + syncFlags = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED), + ) + } + + private fun createSimpleMessage(uid: String, date: String, text: String = "UID: $uid"): Message { + return buildMessage { + header("Subject", "Test Message") + header("From", "alice@domain.example") + header("To", "Bob ") + header("Date", date) + header("Message-ID", "") + + textBody(text) + } + } +} + +private fun String.toDate(): Date { + val dateTimeField = DefaultFieldParser.parse("Date: $this") as DateTimeField + return dateTimeField.date +} diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt new file mode 100644 index 0000000..670c8c3 --- /dev/null +++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt @@ -0,0 +1,195 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageRetrievalListener +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.store.imap.FetchListener +import com.fsck.k9.mail.store.imap.ImapFolder +import com.fsck.k9.mail.store.imap.ImapMessage +import com.fsck.k9.mail.store.imap.OpenMode +import com.fsck.k9.mail.store.imap.createImapMessage +import java.util.Date + +open class TestImapFolder(override val serverId: String) : ImapFolder { + override var mode: OpenMode? = null + protected set + + override val isOpen: Boolean + get() = mode != null + + override var messageCount: Int = 0 + + var wasExpunged: Boolean = false + private set + + val isClosed: Boolean + get() = mode == null + + private val messages = mutableMapOf() + private val messageFlags = mutableMapOf>() + private var uidValidity: Long? = null + + fun addMessage(uid: Long, message: Message) { + require(!messages.containsKey(uid)) { + "Folder '$serverId' already contains a message with the UID $uid" + } + + messages[uid] = message + messageFlags[uid] = mutableSetOf() + + messageCount = messages.size + } + + fun removeAllMessages() { + messages.clear() + messageFlags.clear() + } + + fun setUidValidity(value: Long) { + uidValidity = value + } + + override fun open(mode: OpenMode) { + this.mode = mode + } + + override fun close() { + mode = null + } + + override fun exists(): Boolean { + throw UnsupportedOperationException("not implemented") + } + + override fun getUidValidity() = uidValidity + + override fun getMessage(uid: String): ImapMessage { + return createImapMessage(uid) + } + + override fun getUidFromMessageId(messageId: String): String? { + throw UnsupportedOperationException("not implemented") + } + + override fun getMessages( + start: Int, + end: Int, + earliestDate: Date?, + listener: MessageRetrievalListener?, + ): List { + require(start > 0) + require(end >= start) + require(end <= messages.size) + + return messages.keys.sortedDescending() + .slice((start - 1) until end) + .map { createImapMessage(uid = it.toString()) } + } + + override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean { + throw UnsupportedOperationException("not implemented") + } + + override fun fetch( + messages: List, + fetchProfile: FetchProfile, + listener: FetchListener?, + maxDownloadSize: Int, + ) { + if (messages.isEmpty()) return + + for (imapMessage in messages) { + val uid = imapMessage.uid.toLong() + + val flags = messageFlags[uid].orEmpty().toSet() + imapMessage.setFlags(flags, true) + + val storedMessage = this.messages[uid] ?: error("Message $uid not found") + for (header in storedMessage.headers) { + imapMessage.addHeader(header.name, header.value) + } + imapMessage.body = storedMessage.body + + listener?.onFetchResponse(imapMessage, isFirstResponse = true) + } + } + + override fun fetchPart( + message: ImapMessage, + part: Part, + bodyFactory: BodyFactory, + maxDownloadSize: Int, + ) { + throw UnsupportedOperationException("not implemented") + } + + override fun search( + queryString: String?, + requiredFlags: Set?, + forbiddenFlags: Set?, + performFullTextSearch: Boolean, + ): List { + throw UnsupportedOperationException("not implemented") + } + + override fun appendMessages(messages: List): Map? { + throw UnsupportedOperationException("not implemented") + } + + override fun setFlagsForAllMessages(flags: Set, value: Boolean) { + if (value) { + for (messageFlagSet in messageFlags.values) { + messageFlagSet.addAll(flags) + } + } else { + for (messageFlagSet in messageFlags.values) { + messageFlagSet.removeAll(flags) + } + } + } + + override fun setFlags(messages: List, flags: Set, value: Boolean) { + for (message in messages) { + val uid = message.uid.toLong() + val messageFlagSet = messageFlags[uid] ?: error("Unknown message with UID $uid") + if (value) { + messageFlagSet.addAll(flags) + } else { + messageFlagSet.removeAll(flags) + } + } + } + + override fun copyMessages(messages: List, folder: ImapFolder): Map? { + throw UnsupportedOperationException("not implemented") + } + + override fun moveMessages(messages: List, folder: ImapFolder): Map? { + throw UnsupportedOperationException("not implemented") + } + + override fun deleteMessages(messages: List) { + setFlags(messages, setOf(Flag.DELETED), true) + } + + override fun deleteAllMessages() { + setFlagsForAllMessages(setOf(Flag.DELETED), true) + } + + override fun expunge() { + mode = OpenMode.READ_WRITE + wasExpunged = true + } + + override fun expungeUids(uids: List) { + throw UnsupportedOperationException("not implemented") + } + + override fun create(folderType: FolderType): Boolean { + throw UnsupportedOperationException("not implemented") + } +} diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt new file mode 100644 index 0000000..d868503 --- /dev/null +++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt @@ -0,0 +1,54 @@ +package com.fsck.k9.backend.imap + +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.store.imap.FolderListItem +import com.fsck.k9.mail.store.imap.ImapFolder +import com.fsck.k9.mail.store.imap.ImapStore + +class TestImapStore : ImapStore { + private val folders = mutableMapOf() + + override val combinedPrefix: String? + get() = throw UnsupportedOperationException("not implemented") + + fun addFolder(serverId: String): TestImapFolder { + require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" } + + return TestImapFolder(serverId).also { folder -> + folders[serverId] = folder + } + } + + fun addFolder(folder: ImapFolder) { + val serverId = folder.serverId + require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" } + + folders[serverId] = folder + } + + override fun getFolder(name: String): ImapFolder { + return folders[name] ?: error("Folder '$name' not found") + } + + override fun getFolders(): List { + return folders.values.map { folder -> + FolderListItem( + serverId = folder.serverId, + name = "irrelevant", + type = FolderType.REGULAR, + ) + } + } + + override fun checkSettings() { + throw UnsupportedOperationException("not implemented") + } + + override fun closeAllConnections() { + throw UnsupportedOperationException("not implemented") + } + + override fun fetchImapPrefix() { + throw UnsupportedOperationException("not implemented") + } +} diff --git a/backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt b/backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt new file mode 100644 index 0000000..c78d12b --- /dev/null +++ b/backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt @@ -0,0 +1,3 @@ +package com.fsck.k9.mail.store.imap + +fun createImapMessage(uid: String) = ImapMessage(uid) diff --git a/backend/imap/src/test/kotlin/net/thunderbird/backend/imap/ImapRemoteFolderCreatorTest.kt b/backend/imap/src/test/kotlin/net/thunderbird/backend/imap/ImapRemoteFolderCreatorTest.kt new file mode 100644 index 0000000..3ea01d4 --- /dev/null +++ b/backend/imap/src/test/kotlin/net/thunderbird/backend/imap/ImapRemoteFolderCreatorTest.kt @@ -0,0 +1,145 @@ +package net.thunderbird.backend.imap + +import assertk.assertAll +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.assertions.prop +import com.fsck.k9.backend.imap.TestImapFolder +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.store.imap.FolderListItem +import com.fsck.k9.mail.store.imap.ImapFolder +import com.fsck.k9.mail.store.imap.ImapStore +import kotlinx.coroutines.test.runTest +import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome +import net.thunderbird.backend.api.folder.RemoteFolderCreationOutcome.Error.FailedToCreateRemoteFolder +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.outcome.Outcome +import org.junit.Test + +class ImapRemoteFolderCreatorTest { + private val logger = TestLogger() + + @Test + fun `when mustCreate true and folder exists, should return Error AlreadyExists`() = runTest { + // Arrange + val folderServerId = FolderServerId("New Folder") + val fakeFolder = object : TestImapFolder(folderServerId.serverId) { + override fun exists(): Boolean = true + } + val imapStore = FakeImapStore(fakeFolder) + val sut = ImapRemoteFolderCreator(logger, imapStore) + + // Act + val outcome = sut.create(folderServerId, mustCreate = true) + + // Assert + assertAll { + assertThat(outcome.isFailure).isTrue() + assertThat(outcome) + .isInstanceOf>() + .prop("error") { it.error } + .isEqualTo(RemoteFolderCreationOutcome.Error.AlreadyExists) + } + } + + @Test + fun `when mustCreate false and folder exists, should return AlreadyExists`() = runTest { + // Arrange + val folderServerId = FolderServerId("New Folder") + val fakeFolder = object : TestImapFolder(folderServerId.serverId) { + override fun exists(): Boolean = true + } + val imapStore = FakeImapStore(fakeFolder) + val sut = ImapRemoteFolderCreator(logger, imapStore) + + // Act + val outcome = sut.create(folderServerId, mustCreate = false) + + // Assert + assertAll { + assertThat(outcome.isSuccess).isTrue() + assertThat(outcome) + .isInstanceOf>() + .prop("data") { it.data } + .isEqualTo(RemoteFolderCreationOutcome.Success.AlreadyExists) + } + } + + @Test + fun `when folder does not exist and creation succeeds, should return Created`() = runTest { + // Arrange + val folderServerId = FolderServerId("New Folder") + val fakeFolder = object : TestImapFolder(folderServerId.serverId) { + override fun exists(): Boolean = false + override fun create(folderType: FolderType): Boolean = true + } + val imapStore = FakeImapStore(fakeFolder) + val sut = ImapRemoteFolderCreator(logger, imapStore) + + // Act + val outcome = sut.create(folderServerId, mustCreate = true) + + // Assert + assertAll { + assertThat(outcome.isSuccess).isTrue() + assertThat(outcome) + .isInstanceOf>() + .prop("data") { it.data } + .isEqualTo(RemoteFolderCreationOutcome.Success.Created) + } + } + + @Test + fun `when folder does not exist and creation fails, should return FailedToCreateRemoteFolder`() = runTest { + // Arrange + val folderServerId = FolderServerId("New Folder") + val fakeFolder = object : TestImapFolder(folderServerId.serverId) { + override fun exists(): Boolean = false + override fun create(folderType: FolderType): Boolean = false + } + val imapStore = FakeImapStore(fakeFolder) + val sut = ImapRemoteFolderCreator(logger, imapStore) + + // Act + val outcome = sut.create(folderServerId, mustCreate = true) + + // Assert + assertAll { + assertThat(outcome.isFailure).isTrue() + assertThat(outcome) + .isInstanceOf>() + .prop("error") { it.error } + .isInstanceOf() + .prop(FailedToCreateRemoteFolder::reason) + .isEqualTo("Failed to create folder on remote server.") + } + } +} + +private class FakeImapStore( + private val folder: TestImapFolder, +) : ImapStore { + override val combinedPrefix: String? + get() = throw NotImplementedError("combinedPrefix not implemented") + + override fun checkSettings() { + throw NotImplementedError("checkSettings not implemented") + } + + override fun getFolder(name: String): ImapFolder = folder + + override fun getFolders(): List { + throw NotImplementedError("getFolders not implemented") + } + + override fun closeAllConnections() { + throw NotImplementedError("closeAllConnections not implemented") + } + + override fun fetchImapPrefix() { + throw NotImplementedError("fetchImapPrefix not implemented") + } +} diff --git a/backend/jmap/build.gradle.kts b/backend/jmap/build.gradle.kts new file mode 100644 index 0000000..de05a60 --- /dev/null +++ b/backend/jmap/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.ksp) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.backend.api) + implementation(projects.core.common) + implementation(projects.feature.mail.folder.api) + + api(libs.okhttp) + implementation(libs.jmap.client) + implementation(libs.moshi) + ksp(libs.moshi.kotlin.codegen) + + testImplementation(projects.core.logging.testing) + testImplementation(projects.mail.testing) + testImplementation(projects.backend.testing) + testImplementation(libs.okhttp.mockwebserver) +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt new file mode 100644 index 0000000..2de5f1d --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandDelete.kt @@ -0,0 +1,71 @@ +package com.fsck.k9.backend.jmap + +import net.thunderbird.core.logging.legacy.Log +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.common.Request.Invocation.ResultReference +import rs.ltt.jmap.common.entity.filter.EmailFilterCondition +import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall +import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall +import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse +import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse + +class CommandDelete( + private val jmapClient: JmapClient, + private val accountId: String, +) { + fun deleteMessages(messageServerIds: List) { + Log.v("Deleting messages %s", messageServerIds) + + val session = jmapClient.session.get() + val maxObjectsInSet = session.maxObjectsInSet + + messageServerIds.chunked(maxObjectsInSet).forEach { emailIds -> + val setEmailCall = jmapClient.call( + SetEmailMethodCall.builder() + .accountId(accountId) + .destroy(emailIds.toTypedArray()) + .build(), + ) + + setEmailCall.getMainResponseBlocking() + } + } + + fun deleteAllMessages(folderServerId: String) { + Log.d("Deleting all messages from %s", folderServerId) + + val session = jmapClient.session.get() + val limit = session.maxObjectsInSet.coerceAtMost(MAX_CHUNK_SIZE).toLong() + + do { + Log.v("Trying to delete up to %d messages from %s", limit, folderServerId) + val multiCall = jmapClient.newMultiCall() + + val queryEmailCall = multiCall.call( + QueryEmailMethodCall.builder() + .accountId(accountId) + .filter(EmailFilterCondition.builder().inMailbox(folderServerId).build()) + .calculateTotal(true) + .limit(limit) + .build(), + ) + + val setEmailCall = multiCall.call( + SetEmailMethodCall.builder() + .accountId(accountId) + .destroyReference(queryEmailCall.createResultReference(ResultReference.Path.IDS)) + .build(), + ) + + multiCall.execute() + + val queryEmailResponse = queryEmailCall.getMainResponseBlocking() + val numberOfReturnedEmails = queryEmailResponse.ids.size + val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'") + + setEmailCall.getMainResponseBlocking() + + Log.v("Deleted %d messages from %s", numberOfReturnedEmails, folderServerId) + } while (totalNumberOfEmails > numberOfReturnedEmails) + } +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt new file mode 100644 index 0000000..24d9336 --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandMove.kt @@ -0,0 +1,56 @@ +package com.fsck.k9.backend.jmap + +import net.thunderbird.core.logging.legacy.Log +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall +import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse +import rs.ltt.jmap.common.util.Patches + +class CommandMove( + private val jmapClient: JmapClient, + private val accountId: String, +) { + fun moveMessages(targetFolderServerId: String, messageServerIds: List) { + Log.v("Moving %d messages to %s", messageServerIds.size, targetFolderServerId) + + val mailboxPatch = Patches.set("mailboxIds", mapOf(targetFolderServerId to true)) + updateEmails(messageServerIds, mailboxPatch) + } + + fun moveMessagesAndMarkAsRead(targetFolderServerId: String, messageServerIds: List) { + Log.v("Moving %d messages to %s and marking them as read", messageServerIds.size, targetFolderServerId) + + val mailboxPatch = Patches.builder() + .set("mailboxIds", mapOf(targetFolderServerId to true)) + .set("keywords/\$seen", true) + .build() + updateEmails(messageServerIds, mailboxPatch) + } + + fun copyMessages(targetFolderServerId: String, messageServerIds: List) { + Log.v("Copying %d messages to %s", messageServerIds.size, targetFolderServerId) + + val mailboxPatch = Patches.set("mailboxIds/$targetFolderServerId", true) + updateEmails(messageServerIds, mailboxPatch) + } + + private fun updateEmails(messageServerIds: List, patch: Map?) { + val session = jmapClient.session.get() + val maxObjectsInSet = session.maxObjectsInSet + + messageServerIds.chunked(maxObjectsInSet).forEach { emailIds -> + val updates = emailIds.map { emailId -> + emailId to patch + }.toMap() + + val setEmailCall = jmapClient.call( + SetEmailMethodCall.builder() + .accountId(accountId) + .update(updates) + .build(), + ) + + setEmailCall.getMainResponseBlocking() + } + } +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt new file mode 100644 index 0000000..adf8daa --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandRefreshFolderList.kt @@ -0,0 +1,169 @@ +package com.fsck.k9.backend.jmap + +import com.fsck.k9.backend.api.BackendFolderUpdater +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.FolderType +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.client.api.ErrorResponseException +import rs.ltt.jmap.client.api.InvalidSessionResourceException +import rs.ltt.jmap.client.api.MethodErrorResponseException +import rs.ltt.jmap.client.api.UnauthorizedException +import rs.ltt.jmap.common.Request.Invocation.ResultReference +import rs.ltt.jmap.common.entity.Mailbox +import rs.ltt.jmap.common.entity.Role +import rs.ltt.jmap.common.method.call.mailbox.ChangesMailboxMethodCall +import rs.ltt.jmap.common.method.call.mailbox.GetMailboxMethodCall +import rs.ltt.jmap.common.method.response.mailbox.ChangesMailboxMethodResponse +import rs.ltt.jmap.common.method.response.mailbox.GetMailboxMethodResponse + +internal class CommandRefreshFolderList( + private val backendStorage: BackendStorage, + private val jmapClient: JmapClient, + private val accountId: String, +) { + @Suppress("ThrowsCount", "TooGenericExceptionCaught") + fun refreshFolderList(): FolderPathDelimiter? { + try { + backendStorage.createFolderUpdater().use { folderUpdater -> + val state = backendStorage.getExtraString(STATE) + if (state == null) { + fetchMailboxes(folderUpdater) + } else { + fetchMailboxUpdates(folderUpdater, state) + } + } + } catch (e: UnauthorizedException) { + throw AuthenticationFailedException("Authentication failed", e) + } catch (e: InvalidSessionResourceException) { + throw MessagingException(e.message, true, e) + } catch (e: ErrorResponseException) { + throw MessagingException(e.message, true, e) + } catch (e: MethodErrorResponseException) { + throw MessagingException(e.message, e.isPermanentError, e) + } catch (e: Exception) { + throw MessagingException(e) + } + return FOLDER_DEFAULT_PATH_DELIMITER + } + + private fun fetchMailboxes(folderUpdater: BackendFolderUpdater) { + val call = jmapClient.call( + GetMailboxMethodCall.builder().accountId(accountId).build(), + ) + val response = call.getMainResponseBlocking() + val foldersOnServer = response.list + + val oldFolderServerIds = backendStorage.getFolderServerIds() + val (foldersToUpdate, foldersToCreate) = foldersOnServer.partition { it.id in oldFolderServerIds } + + for (folder in foldersToUpdate) { + folderUpdater.changeFolder(folder.id, folder.name, folder.type) + } + + val newFolders = foldersToCreate.map { folder -> + FolderInfo(folder.id, folder.name, folder.type) + } + folderUpdater.createFolders(newFolders) + + val newFolderServerIds = foldersOnServer.map { it.id } + val removedFolderServerIds = oldFolderServerIds - newFolderServerIds + folderUpdater.deleteFolders(removedFolderServerIds) + + backendStorage.setExtraString(STATE, response.state) + } + + private fun fetchMailboxUpdates(folderUpdater: BackendFolderUpdater, state: String) { + try { + fetchAllMailboxChanges(folderUpdater, state) + } catch (e: MethodErrorResponseException) { + if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) { + fetchMailboxes(folderUpdater) + } else { + throw e + } + } + } + + private fun fetchAllMailboxChanges(folderUpdater: BackendFolderUpdater, state: String) { + var currentState = state + do { + val (newState, hasMoreChanges) = fetchMailboxChanges(folderUpdater, currentState) + currentState = newState + } while (hasMoreChanges) + } + + private fun fetchMailboxChanges(folderUpdater: BackendFolderUpdater, state: String): UpdateState { + val multiCall = jmapClient.newMultiCall() + val mailboxChangesCall = multiCall.call( + ChangesMailboxMethodCall.builder() + .accountId(accountId) + .sinceState(state) + .build(), + ) + val createdMailboxesCall = multiCall.call( + GetMailboxMethodCall.builder() + .accountId(accountId) + .idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.CREATED)) + .build(), + ) + val changedMailboxesCall = multiCall.call( + GetMailboxMethodCall.builder() + .accountId(accountId) + .idsReference(mailboxChangesCall.createResultReference(ResultReference.Path.UPDATED)) + .build(), + ) + multiCall.execute() + + val mailboxChangesResponse = mailboxChangesCall.getMainResponseBlocking() + val createdMailboxResponse = createdMailboxesCall.getMainResponseBlocking() + val changedMailboxResponse = changedMailboxesCall.getMainResponseBlocking() + + val foldersToCreate = createdMailboxResponse.list.map { folder -> + FolderInfo(folder.id, folder.name, folder.type) + } + folderUpdater.createFolders(foldersToCreate) + + for (folder in changedMailboxResponse.list) { + folderUpdater.changeFolder(folder.id, folder.name, folder.type) + } + + val destroyed = mailboxChangesResponse.destroyed + destroyed?.let { + folderUpdater.deleteFolders(it.toList()) + } + + backendStorage.setExtraString(STATE, mailboxChangesResponse.newState) + + return UpdateState( + state = mailboxChangesResponse.newState, + hasMoreChanges = mailboxChangesResponse.isHasMoreChanges, + ) + } + + private val Mailbox.type: FolderType + get() = when (role) { + Role.INBOX -> FolderType.INBOX + Role.ARCHIVE -> FolderType.ARCHIVE + Role.DRAFTS -> FolderType.DRAFTS + Role.SENT -> FolderType.SENT + Role.TRASH -> FolderType.TRASH + Role.JUNK -> FolderType.SPAM + else -> FolderType.REGULAR + } + + private val MethodErrorResponseException.isPermanentError: Boolean + get() = methodErrorResponse.type != ERROR_SERVER_UNAVAILABLE + + companion object { + private const val STATE = "jmapState" + private const val ERROR_SERVER_UNAVAILABLE = "serverUnavailable" + private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges" + } + + private data class UpdateState(val state: String, val hasMoreChanges: Boolean) +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt new file mode 100644 index 0000000..be42d87 --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSetFlag.kt @@ -0,0 +1,108 @@ +package com.fsck.k9.backend.jmap + +import com.fsck.k9.mail.Flag +import net.thunderbird.core.logging.legacy.Log +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.common.entity.filter.EmailFilterCondition +import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall +import rs.ltt.jmap.common.method.call.email.SetEmailMethodCall +import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse +import rs.ltt.jmap.common.method.response.email.SetEmailMethodResponse +import rs.ltt.jmap.common.util.Patches + +class CommandSetFlag( + private val jmapClient: JmapClient, + private val accountId: String, +) { + fun setFlag(messageServerIds: List, flag: Flag, newState: Boolean) { + if (newState) { + Log.v("Setting flag %s for messages %s", flag, messageServerIds) + } else { + Log.v("Removing flag %s for messages %s", flag, messageServerIds) + } + + val keyword = flag.toKeyword() + val keywordsPatch = if (newState) { + Patches.set("keywords/$keyword", true) + } else { + Patches.remove("keywords/$keyword") + } + + val session = jmapClient.session.get() + val maxObjectsInSet = session.maxObjectsInSet + + messageServerIds.chunked(maxObjectsInSet).forEach { emailIds -> + val updates = emailIds.map { emailId -> + emailId to keywordsPatch + }.toMap() + + val setEmailCall = jmapClient.call( + SetEmailMethodCall.builder() + .accountId(accountId) + .update(updates) + .build(), + ) + + setEmailCall.getMainResponseBlocking() + } + } + + fun markAllAsRead(folderServerId: String) { + Log.d("Marking all messages in %s as read", folderServerId) + + val keywordsPatch = Patches.set("keywords/\$seen", true) + + val session = jmapClient.session.get() + val limit = minOf(MAX_CHUNK_SIZE, session.maxObjectsInSet).toLong() + + do { + Log.v("Trying to mark up to %d messages in %s as read", limit, folderServerId) + + val queryEmailCall = jmapClient.call( + QueryEmailMethodCall.builder() + .accountId(accountId) + .filter( + EmailFilterCondition.builder() + .inMailbox(folderServerId) + .notKeyword("\$seen") + .build(), + ) + .calculateTotal(true) + .limit(limit) + .build(), + ) + + val queryEmailResponse = queryEmailCall.getMainResponseBlocking() + val numberOfReturnedEmails = queryEmailResponse.ids.size + val totalNumberOfEmails = queryEmailResponse.total ?: error("Server didn't return property 'total'") + + if (numberOfReturnedEmails == 0) { + Log.v("There were no messages in %s to mark as read", folderServerId) + } else { + val updates = queryEmailResponse.ids.map { emailId -> + emailId to keywordsPatch + }.toMap() + + val setEmailCall = jmapClient.call( + SetEmailMethodCall.builder() + .accountId(accountId) + .update(updates) + .build(), + ) + + setEmailCall.getMainResponseBlocking() + + Log.v("Marked %d messages in %s as read", numberOfReturnedEmails, folderServerId) + } + } while (totalNumberOfEmails > numberOfReturnedEmails) + } + + private fun Flag.toKeyword(): String = when (this) { + Flag.SEEN -> "\$seen" + Flag.FLAGGED -> "\$flagged" + Flag.DRAFT -> "\$draft" + Flag.ANSWERED -> "\$answered" + Flag.FORWARDED -> "\$forwarded" + else -> error("Unsupported flag: $name") + } +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt new file mode 100644 index 0000000..d5f6a2b --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandSync.kt @@ -0,0 +1,322 @@ +package com.fsck.k9.backend.jmap + +import com.fsck.k9.backend.api.BackendFolder +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.internet.MimeMessage +import java.util.Date +import net.thunderbird.core.logging.legacy.Log +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.client.api.MethodErrorResponseException +import rs.ltt.jmap.client.api.UnauthorizedException +import rs.ltt.jmap.client.http.HttpAuthentication +import rs.ltt.jmap.client.session.Session +import rs.ltt.jmap.common.entity.Email +import rs.ltt.jmap.common.entity.filter.EmailFilterCondition +import rs.ltt.jmap.common.entity.query.EmailQuery +import rs.ltt.jmap.common.method.call.email.GetEmailMethodCall +import rs.ltt.jmap.common.method.call.email.QueryChangesEmailMethodCall +import rs.ltt.jmap.common.method.call.email.QueryEmailMethodCall +import rs.ltt.jmap.common.method.response.email.GetEmailMethodResponse +import rs.ltt.jmap.common.method.response.email.QueryChangesEmailMethodResponse +import rs.ltt.jmap.common.method.response.email.QueryEmailMethodResponse + +class CommandSync( + private val backendStorage: BackendStorage, + private val jmapClient: JmapClient, + private val okHttpClient: OkHttpClient, + private val accountId: String, + private val httpAuthentication: HttpAuthentication, +) { + + fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + try { + val backendFolder = backendStorage.getFolder(folderServerId) + listener.syncStarted(folderServerId) + + val limit = if (backendFolder.visibleLimit > 0) backendFolder.visibleLimit.toLong() else null + + val queryState = backendFolder.getFolderExtraString(EXTRA_QUERY_STATE) + if (queryState == null) { + fullSync(backendFolder, folderServerId, syncConfig, limit, listener) + } else { + deltaSync(backendFolder, folderServerId, syncConfig, limit, queryState, listener) + } + + listener.syncFinished(folderServerId) + } catch (e: UnauthorizedException) { + Log.e(e, "Authentication failure during sync") + + val exception = AuthenticationFailedException(e.message ?: "Authentication failed", e) + listener.syncFailed(folderServerId, "Authentication failed", exception) + } catch (e: Exception) { + Log.e(e, "Unexpected failure during sync") + + listener.syncFailed(folderServerId, "Unexpected failure", e) + } + } + + private fun fullSync( + backendFolder: BackendFolder, + folderServerId: String, + syncConfig: SyncConfig, + limit: Long?, + listener: SyncListener, + ) { + val cachedServerIds: Set = backendFolder.getMessageServerIds() + + if (limit != null) { + Log.d("Fetching %d latest messages in %s (%s)", limit, backendFolder.name, folderServerId) + } else { + Log.d("Fetching all messages in %s (%s)", backendFolder.name, folderServerId) + } + + val queryEmailCall = jmapClient.call( + QueryEmailMethodCall.builder() + .accountId(accountId) + .query(createEmailQuery(folderServerId)) + .limit(limit) + .build(), + ) + val queryEmailResponse = queryEmailCall.getMainResponseBlocking() + val queryState = if (queryEmailResponse.isCanCalculateChanges) queryEmailResponse.queryState else null + val remoteServerIds = queryEmailResponse.ids.toSet() + + val destroyServerIds = (cachedServerIds - remoteServerIds).toList() + val newServerIds = remoteServerIds - cachedServerIds + + handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, queryState, listener) + + val refreshServerIds = cachedServerIds.intersect(remoteServerIds) + refreshMessageFlags(backendFolder, syncConfig, refreshServerIds) + } + + private fun createEmailQuery(folderServerId: String): EmailQuery? { + val filter = EmailFilterCondition.builder() + .inMailbox(folderServerId) + .build() + + // FIXME: Add sort parameter + return EmailQuery.of(filter) + } + + private fun deltaSync( + backendFolder: BackendFolder, + folderServerId: String, + syncConfig: SyncConfig, + limit: Long?, + queryState: String, + listener: SyncListener, + ) { + Log.d("Updating messages in %s (%s)", backendFolder.name, folderServerId) + + val emailQuery = createEmailQuery(folderServerId) + val queryChangesEmailCall = jmapClient.call( + QueryChangesEmailMethodCall.builder() + .accountId(accountId) + .sinceQueryState(queryState) + .query(emailQuery) + .build(), + ) + + val queryChangesEmailResponse = try { + queryChangesEmailCall.getMainResponseBlocking() + } catch (e: MethodErrorResponseException) { + if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) { + Log.d("Server responded with '$ERROR_CANNOT_CALCULATE_CHANGES'; switching to full sync") + + backendFolder.saveQueryState(null) + fullSync(backendFolder, folderServerId, syncConfig, limit, listener) + return + } + + throw e + } + + val cachedServerIds = backendFolder.getMessageServerIds() + + val removedServerIds = queryChangesEmailResponse.removed.toSet() + val addedServerIds = queryChangesEmailResponse.added.map { it.item }.toSet() + val newQueryState = queryChangesEmailResponse.newQueryState + + // An email can appear in both the 'removed' and the 'added' properties, e.g. when its position in the list + // changes. But we don't want to remove a message from the database only to download it again right away. + val retainedServerIds = removedServerIds.intersect(addedServerIds) + val destroyServerIds = (removedServerIds - retainedServerIds).toList() + val newServerIds = addedServerIds - retainedServerIds + + handleFolderUpdates(backendFolder, folderServerId, destroyServerIds, newServerIds, newQueryState, listener) + + val refreshServerIds = cachedServerIds - destroyServerIds + refreshMessageFlags(backendFolder, syncConfig, refreshServerIds) + } + + private fun handleFolderUpdates( + backendFolder: BackendFolder, + folderServerId: String, + destroyServerIds: List, + newServerIds: Set, + newQueryState: String?, + listener: SyncListener, + ) { + if (destroyServerIds.isNotEmpty()) { + Log.d("Removing messages no longer on server: %s", destroyServerIds) + backendFolder.destroyMessages(destroyServerIds) + } + + if (newServerIds.isEmpty()) { + Log.d("No new messages on server") + backendFolder.saveQueryState(newQueryState) + return + } + + Log.d("New messages on server: %s", newServerIds) + val session = jmapClient.session.get() + val maxObjectsInGet = session.maxObjectsInGet + val messageInfoList = fetchMessageInfo(session, maxObjectsInGet, newServerIds) + + val total = messageInfoList.size + messageInfoList.forEachIndexed { index, messageInfo -> + Log.v("Downloading message %s (%s)", messageInfo.serverId, messageInfo.downloadUrl) + val message = downloadMessage(messageInfo.downloadUrl) + if (message != null) { + message.apply { + uid = messageInfo.serverId + setInternalSentDate(messageInfo.receivedAt) + setFlags(messageInfo.flags, true) + } + + backendFolder.saveMessage(message, MessageDownloadState.FULL) + } else { + Log.d("Failed to download message: %s", messageInfo.serverId) + } + + listener.syncProgress(folderServerId, index + 1, total) + } + + backendFolder.saveQueryState(newQueryState) + } + + private fun fetchMessageInfo(session: Session, maxObjectsInGet: Int, emailIds: Set): List { + return emailIds + .chunked(maxObjectsInGet) { emailIdsChunk -> + getEmailPropertiesFromServer(emailIdsChunk, INFO_PROPERTIES) + } + .flatten() + .map { email -> + email.toMessageInfo(session) + } + } + + private fun getEmailPropertiesFromServer(emailIdsChunk: List, properties: Array): List { + val getEmailCall = jmapClient.call( + GetEmailMethodCall.builder() + .accountId(accountId) + .ids(emailIdsChunk.toTypedArray()) + .properties(properties) + .build(), + ) + + val getEmailResponse = getEmailCall.getMainResponseBlocking() + return getEmailResponse.list.toList() + } + + private fun Email.toMessageInfo(session: Session): MessageInfo { + val downloadUrl = session.getDownloadUrl(accountId, blobId, blobId, "application/octet-stream") + return MessageInfo(id, downloadUrl, receivedAt, keywords.toFlags()) + } + + private fun downloadMessage(downloadUrl: HttpUrl): MimeMessage? { + val request = Request.Builder() + .url(downloadUrl) + .apply { + httpAuthentication.authenticate(this) + } + .build() + + return okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val inputStream = response.body!!.byteStream() + MimeMessage.parseMimeMessage(inputStream, false) + } else { + null + } + } + } + + private fun refreshMessageFlags(backendFolder: BackendFolder, syncConfig: SyncConfig, emailIds: Set) { + if (emailIds.isEmpty()) return + + Log.v("Fetching flags for messages: %s", emailIds) + + val session = jmapClient.session.get() + val maxObjectsInGet = session.maxObjectsInGet + + emailIds + .asSequence() + .chunked(maxObjectsInGet) { emailIdsChunk -> + getEmailPropertiesFromServer(emailIdsChunk, FLAG_PROPERTIES) + } + .flatten() + .forEach { email -> + syncFlagsForMessage(backendFolder, syncConfig, email) + } + } + + private fun syncFlagsForMessage(backendFolder: BackendFolder, syncConfig: SyncConfig, email: Email) { + val messageServerId = email.id + val localFlags = backendFolder.getMessageFlags(messageServerId) + val remoteFlags = email.keywords.toFlags() + for (flag in syncConfig.syncFlags) { + val flagSetOnServer = flag in remoteFlags + val flagSetLocally = flag in localFlags + if (flagSetOnServer != flagSetLocally) { + backendFolder.setMessageFlag(messageServerId, flag, flagSetOnServer) + } + } + } + + private fun Map?.toFlags(): Set { + return if (this == null) { + emptySet() + } else { + filterValues { it }.keys + .mapNotNull { keyword -> keyword.toFlag() } + .toSet() + } + } + + private fun String.toFlag(): Flag? = when (this) { + "\$seen" -> Flag.SEEN + "\$flagged" -> Flag.FLAGGED + "\$draft" -> Flag.DRAFT + "\$answered" -> Flag.ANSWERED + "\$forwarded" -> Flag.FORWARDED + else -> null + } + + private fun BackendFolder.saveQueryState(queryState: String?) { + setFolderExtraString(EXTRA_QUERY_STATE, queryState) + } + + companion object { + private const val EXTRA_QUERY_STATE = "jmapQueryState" + private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges" + private val INFO_PROPERTIES = arrayOf("id", "blobId", "size", "receivedAt", "keywords") + private val FLAG_PROPERTIES = arrayOf("id", "keywords") + } +} + +private data class MessageInfo( + val serverId: String, + val downloadUrl: HttpUrl, + val receivedAt: Date, + val flags: Set, +) diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt new file mode 100644 index 0000000..f1d447b --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/CommandUpload.kt @@ -0,0 +1,98 @@ +package com.fsck.k9.backend.jmap + +import com.fsck.k9.mail.Message +import com.squareup.moshi.Moshi +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.logging.legacy.Log +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.client.http.HttpAuthentication +import rs.ltt.jmap.common.entity.EmailImport +import rs.ltt.jmap.common.method.call.email.ImportEmailMethodCall +import rs.ltt.jmap.common.method.response.email.ImportEmailMethodResponse + +class CommandUpload( + private val jmapClient: JmapClient, + private val okHttpClient: OkHttpClient, + private val httpAuthentication: HttpAuthentication, + private val accountId: String, +) { + private val moshi = Moshi.Builder().build() + + fun uploadMessage(folderServerId: String, message: Message): String? { + Log.d("Uploading message to $folderServerId") + + val uploadResponse = uploadMessageAsBlob(message) + return importEmailBlob(uploadResponse, folderServerId) + } + + private fun uploadMessageAsBlob(message: Message): JmapUploadResponse { + val session = jmapClient.session.get() + val uploadUrl = session.getUploadUrl(accountId) + + val request = Request.Builder() + .url(uploadUrl) + .post(MessageRequestBody(message)) + .apply { + httpAuthentication.authenticate(this) + } + .build() + + return okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw MessagingException("Uploading message as blob failed") + } + + response.body!!.source().use { source -> + val adapter = moshi.adapter(JmapUploadResponse::class.java) + val uploadResponse = adapter.fromJson(source) + uploadResponse ?: throw MessagingException("Error reading upload response") + } + } + } + + private fun importEmailBlob(uploadResponse: JmapUploadResponse, folderServerId: String): String? { + val importEmailRequest = ImportEmailMethodCall.builder() + .accountId(accountId) + .email( + LOCAL_EMAIL_ID, + EmailImport.builder() + .blobId(uploadResponse.blobId) + .keywords(mapOf("\$seen" to true)) + .mailboxIds(mapOf(folderServerId to true)) + .build(), + ) + .build() + + val importEmailCall = jmapClient.call(importEmailRequest) + val importEmailResponse = importEmailCall.getMainResponseBlocking() + + return importEmailResponse.serverEmailId + } + + private val ImportEmailMethodResponse.serverEmailId + get() = created?.get(LOCAL_EMAIL_ID)?.id + + companion object { + private const val LOCAL_EMAIL_ID = "t1" + } +} + +private class MessageRequestBody(private val message: Message) : RequestBody() { + override fun contentType(): MediaType? { + return "message/rfc822".toMediaType() + } + + override fun contentLength(): Long { + return message.calculateSize() + } + + override fun writeTo(sink: BufferedSink) { + message.writeTo(sink.outputStream()) + } +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt new file mode 100644 index 0000000..82cccf5 --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapAccountDiscovery.kt @@ -0,0 +1,46 @@ +package com.fsck.k9.backend.jmap + +import java.net.UnknownHostException +import net.thunderbird.core.logging.legacy.Log +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.client.api.EndpointNotFoundException +import rs.ltt.jmap.client.api.UnauthorizedException +import rs.ltt.jmap.common.entity.capability.MailAccountCapability + +class JmapAccountDiscovery { + fun discover(emailAddress: String, password: String): JmapDiscoveryResult { + val jmapClient = JmapClient(emailAddress, password) + val session = try { + jmapClient.session.futureGetOrThrow() + } catch (e: EndpointNotFoundException) { + return JmapDiscoveryResult.EndpointNotFoundFailure + } catch (e: UnknownHostException) { + return JmapDiscoveryResult.EndpointNotFoundFailure + } catch (e: UnauthorizedException) { + return JmapDiscoveryResult.AuthenticationFailure + } catch (e: Exception) { + Log.e(e, "Unable to get JMAP session") + return JmapDiscoveryResult.GenericFailure(e) + } + + val accounts = session.getAccounts(MailAccountCapability::class.java) + val accountId = when { + accounts.isEmpty() -> return JmapDiscoveryResult.NoEmailAccountFoundFailure + accounts.size == 1 -> accounts.keys.first() + else -> session.getPrimaryAccount(MailAccountCapability::class.java) + } + + val account = accounts[accountId]!! + val accountName = account.name ?: emailAddress + return JmapDiscoveryResult.JmapAccount(accountId, accountName) + } +} + +sealed class JmapDiscoveryResult { + class GenericFailure(val cause: Throwable) : JmapDiscoveryResult() + object EndpointNotFoundFailure : JmapDiscoveryResult() + object AuthenticationFailure : JmapDiscoveryResult() + object NoEmailAccountFoundFailure : JmapDiscoveryResult() + + data class JmapAccount(val accountId: String, val name: String) : JmapDiscoveryResult() +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt new file mode 100644 index 0000000..2ebb04e --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapBackend.kt @@ -0,0 +1,153 @@ +package com.fsck.k9.backend.jmap + +import com.fsck.k9.backend.api.Backend +import com.fsck.k9.backend.api.BackendPusher +import com.fsck.k9.backend.api.BackendPusherCallback +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Part +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication +import rs.ltt.jmap.client.http.HttpAuthentication + +class JmapBackend( + backendStorage: BackendStorage, + okHttpClient: OkHttpClient, + config: JmapConfig, +) : Backend { + private val httpAuthentication = config.toHttpAuthentication() + private val jmapClient = createJmapClient(config, httpAuthentication) + private val accountId = config.accountId + private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, jmapClient, accountId) + private val commandSync = CommandSync(backendStorage, jmapClient, okHttpClient, accountId, httpAuthentication) + private val commandSetFlag = CommandSetFlag(jmapClient, accountId) + private val commandDelete = CommandDelete(jmapClient, accountId) + private val commandMove = CommandMove(jmapClient, accountId) + private val commandUpload = CommandUpload(jmapClient, okHttpClient, httpAuthentication, accountId) + override val supportsFlags = true + override val supportsExpunge = false + override val supportsMove = true + override val supportsCopy = true + override val supportsUpload = true + override val supportsTrashFolder = true + override val supportsSearchByDate = true + override val supportsFolderSubscriptions = false // TODO: add support + override val isPushCapable = false // FIXME + + override fun refreshFolderList(): FolderPathDelimiter? { + return commandRefreshFolderList.refreshFolderList() + } + + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + commandSync.sync(folderServerId, syncConfig, listener) + } + + override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun downloadMessageStructure(folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun setFlag(folderServerId: String, messageServerIds: List, flag: Flag, newState: Boolean) { + commandSetFlag.setFlag(messageServerIds, flag, newState) + } + + override fun markAllAsRead(folderServerId: String) { + commandSetFlag.markAllAsRead(folderServerId) + } + + override fun expunge(folderServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun deleteMessages(folderServerId: String, messageServerIds: List) { + commandDelete.deleteMessages(messageServerIds) + } + + override fun deleteAllMessages(folderServerId: String) { + commandDelete.deleteAllMessages(folderServerId) + } + + override fun moveMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + commandMove.moveMessages(targetFolderServerId, messageServerIds) + return messageServerIds.associateWith { it } + } + + override fun moveMessagesAndMarkAsRead( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + commandMove.moveMessagesAndMarkAsRead(targetFolderServerId, messageServerIds) + return messageServerIds.associateWith { it } + } + + override fun copyMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + commandMove.copyMessages(targetFolderServerId, messageServerIds) + return messageServerIds.associateWith { it } + } + + override fun search( + folderServerId: String, + query: String?, + requiredFlags: Set?, + forbiddenFlags: Set?, + performFullTextSearch: Boolean, + ): List { + throw UnsupportedOperationException("not implemented") + } + + override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) { + throw UnsupportedOperationException("not implemented") + } + + override fun findByMessageId(folderServerId: String, messageId: String): String? { + return null + } + + override fun uploadMessage(folderServerId: String, message: Message): String? { + return commandUpload.uploadMessage(folderServerId, message) + } + + override fun sendMessage(message: Message) { + throw UnsupportedOperationException("not implemented") + } + + override fun createPusher(callback: BackendPusherCallback): BackendPusher { + throw UnsupportedOperationException("not implemented") + } + + private fun JmapConfig.toHttpAuthentication(): HttpAuthentication { + return BasicAuthHttpAuthentication(username, password) + } + + private fun createJmapClient(jmapConfig: JmapConfig, httpAuthentication: HttpAuthentication): JmapClient { + return if (jmapConfig.baseUrl == null) { + JmapClient(httpAuthentication) + } else { + val baseHttpUrl = jmapConfig.baseUrl.toHttpUrlOrNull() + JmapClient(httpAuthentication, baseHttpUrl) + } + } +} diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt new file mode 100644 index 0000000..890be0a --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapConfig.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.backend.jmap + +data class JmapConfig( + val username: String, + val password: String, + val baseUrl: String?, + val accountId: String, +) diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt new file mode 100644 index 0000000..0fcc196 --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapExtensions.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.backend.jmap + +import com.google.common.util.concurrent.ListenableFuture +import java.util.concurrent.ExecutionException +import rs.ltt.jmap.client.JmapRequest +import rs.ltt.jmap.client.MethodResponses +import rs.ltt.jmap.client.session.Session +import rs.ltt.jmap.common.entity.capability.CoreCapability +import rs.ltt.jmap.common.method.MethodResponse + +internal const val MAX_CHUNK_SIZE = 5000 + +internal inline fun ListenableFuture.getMainResponseBlocking(): T { + return futureGetOrThrow().getMain(T::class.java) +} + +internal inline fun JmapRequest.Call.getMainResponseBlocking(): T { + return methodResponses.getMainResponseBlocking() +} + +@Suppress("NOTHING_TO_INLINE") +internal inline fun ListenableFuture.futureGetOrThrow(): T { + return try { + get() + } catch (e: ExecutionException) { + throw e.cause ?: e + } +} + +internal val Session.maxObjectsInGet: Int + get() { + val coreCapability = getCapability(CoreCapability::class.java) + return coreCapability.maxObjectsInGet.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + +internal val Session.maxObjectsInSet: Int + get() { + val coreCapability = getCapability(CoreCapability::class.java) + return coreCapability.maxObjectsInSet.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } diff --git a/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt new file mode 100644 index 0000000..bba2edd --- /dev/null +++ b/backend/jmap/src/main/java/com/fsck/k9/backend/jmap/JmapUploadResponse.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.backend.jmap + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class JmapUploadResponse( + val accountId: String, + val blobId: String, + val type: String, + val size: Long, +) diff --git a/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandRefreshFolderListTest.kt b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandRefreshFolderListTest.kt new file mode 100644 index 0000000..3c6dbea --- /dev/null +++ b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandRefreshFolderListTest.kt @@ -0,0 +1,177 @@ +package com.fsck.k9.backend.jmap + +import app.k9mail.backend.testing.InMemoryBackendStorage +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.fail +import com.fsck.k9.backend.api.BackendFolderUpdater +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.updateFolders +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.FolderType +import net.thunderbird.core.common.exception.MessagingException +import okhttp3.HttpUrl +import okhttp3.mockwebserver.MockResponse +import org.junit.Test +import rs.ltt.jmap.client.JmapClient + +class CommandRefreshFolderListTest { + private val backendStorage = InMemoryBackendStorage() + + @Test + fun sessionResourceWithAuthenticationError() { + val command = createCommandRefreshFolderList( + MockResponse().setResponseCode(401), + ) + + assertFailure { + command.refreshFolderList() + }.isInstanceOf() + } + + @Test + fun invalidSessionResource() { + val command = createCommandRefreshFolderList( + MockResponse().setBody("invalid"), + ) + + assertFailure { + command.refreshFolderList() + }.isInstanceOf() + .transform { it.isPermanentFailure }.isTrue() + } + + @Test + fun fetchMailboxes() { + val command = createCommandRefreshFolderList( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json"), + ) + + command.refreshFolderList() + + assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1") + assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX) + assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE) + assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS) + assertFolderPresent("id_sent", "Sent", FolderType.SENT) + assertFolderPresent("id_trash", "Trash", FolderType.TRASH) + assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR) + assertMailboxState("23") + } + + @Test + fun fetchMailboxUpdates() { + val command = createCommandRefreshFolderList( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes.json"), + ) + createFoldersInBackendStorage(state = "23") + + command.refreshFolderList() + + assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2") + assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX) + assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE) + assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS) + assertFolderPresent("id_sent", "Sent", FolderType.SENT) + assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH) + assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR) + assertMailboxState("42") + } + + @Test + fun fetchMailboxUpdates_withHasMoreChanges() { + val command = createCommandRefreshFolderList( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_1.json"), + responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_2.json"), + ) + createFoldersInBackendStorage(state = "23") + + command.refreshFolderList() + + assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2") + assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX) + assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE) + assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS) + assertFolderPresent("id_sent", "Sent", FolderType.SENT) + assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH) + assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR) + assertMailboxState("42") + } + + @Test + fun fetchMailboxUpdates_withCannotCalculateChangesError() { + val command = createCommandRefreshFolderList( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json"), + responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json"), + ) + setMailboxState("unknownToServer") + + command.refreshFolderList() + + assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1") + assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX) + assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE) + assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS) + assertFolderPresent("id_sent", "Sent", FolderType.SENT) + assertFolderPresent("id_trash", "Trash", FolderType.TRASH) + assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR) + assertMailboxState("23") + } + + private fun createCommandRefreshFolderList(vararg mockResponses: MockResponse): CommandRefreshFolderList { + val server = createMockWebServer(*mockResponses) + return createCommandRefreshFolderList(server.url("/jmap/")) + } + + private fun createCommandRefreshFolderList( + baseUrl: HttpUrl, + accountId: String = "test@example.com", + ): CommandRefreshFolderList { + val jmapClient = JmapClient("test", "test", baseUrl) + return CommandRefreshFolderList(backendStorage, jmapClient, accountId) + } + + @Suppress("SameParameterValue") + private fun createFoldersInBackendStorage(state: String) { + backendStorage.updateFolders { + createFolder("id_inbox", "Inbox", FolderType.INBOX) + createFolder("id_archive", "Archive", FolderType.ARCHIVE) + createFolder("id_drafts", "Drafts", FolderType.DRAFTS) + createFolder("id_sent", "Sent", FolderType.SENT) + createFolder("id_trash", "Trash", FolderType.TRASH) + createFolder("id_folder1", "folder1", FolderType.REGULAR) + } + setMailboxState(state) + } + + private fun BackendFolderUpdater.createFolder(serverId: String, name: String, type: FolderType) { + createFolders(listOf(FolderInfo(serverId, name, type))) + } + + private fun setMailboxState(state: String) { + backendStorage.setExtraString("jmapState", state) + } + + private fun assertFolderList(vararg folderServerIds: String) { + assertThat(backendStorage.getFolderServerIds()).containsExactlyInAnyOrder(*folderServerIds) + } + + private fun assertFolderPresent(serverId: String, name: String, type: FolderType) { + val folder = backendStorage.folders[serverId] ?: fail("Expected folder '$serverId' in BackendStorage") + + assertThat(folder.name).isEqualTo(name) + assertThat(folder.type).isEqualTo(type) + } + + private fun assertMailboxState(expected: String) { + assertThat(backendStorage.getExtraString("jmapState")).isEqualTo(expected) + } +} diff --git a/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandSyncTest.kt b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandSyncTest.kt new file mode 100644 index 0000000..5db644c --- /dev/null +++ b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/CommandSyncTest.kt @@ -0,0 +1,280 @@ +package com.fsck.k9.backend.jmap + +import app.k9mail.backend.testing.InMemoryBackendFolder +import app.k9mail.backend.testing.InMemoryBackendStorage +import assertk.assertThat +import assertk.assertions.containsOnly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy +import com.fsck.k9.backend.api.updateFolders +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.internet.BinaryTempFileBody +import java.io.File +import java.util.EnumSet +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.logging.testing.TestLogger +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Before +import org.junit.Test +import rs.ltt.jmap.client.JmapClient +import rs.ltt.jmap.client.http.BasicAuthHttpAuthentication + +class CommandSyncTest { + private val backendStorage = InMemoryBackendStorage() + private val okHttpClient = OkHttpClient.Builder().build() + private val syncListener = LoggingSyncListener() + private val syncConfig = SyncConfig( + expungePolicy = ExpungePolicy.IMMEDIATELY, + earliestPollDate = null, + syncRemoteDeletions = true, + maximumAutoDownloadMessageSize = 1000, + defaultVisibleLimit = 25, + syncFlags = EnumSet.of(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED), + ) + + @Before + fun setUp() { + BinaryTempFileBody.setTempDirectory(File(System.getProperty("java.io.tmpdir"))) + createFolderInBackendStorage() + Log.logger = TestLogger() + } + + @Test + fun sessionResourceWithAuthenticationError() { + val command = createCommandSync( + MockResponse().setResponseCode(401), + ) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(syncListener.getNextEvent()).isEqualTo(SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID)) + val failedEvent = syncListener.getNextEvent() as SyncListenerEvent.SyncFailed + assertThat(failedEvent.exception).isNotNull().isInstanceOf() + } + + @Test + fun fullSyncStartingWithEmptyLocalMailbox() { + val server = createMockWebServer( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/email/email_query_M001_and_M002.json"), + responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"), + responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"), + responseBodyFromResource("/jmap_responses/blob/email/email_2.eml"), + ) + val baseUrl = server.url("/jmap/") + val command = createCommandSync(baseUrl) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + backendFolder.assertMessages( + "M001" to "/jmap_responses/blob/email/email_1.eml", + "M002" to "/jmap_responses/blob/email/email_2.eml", + ) + backendFolder.assertQueryState("50:0") + syncListener.assertSyncEvents( + SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID), + SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 1, total = 2), + SyncListenerEvent.SyncProgress(FOLDER_SERVER_ID, completed = 2, total = 2), + SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID), + ) + server.skipRequests(3) + server.assertRequestUrlPath("/jmap/download/test%40example.com/B001/B001?accept=application%2Foctet-stream") + server.assertRequestUrlPath("/jmap/download/test%40example.com/B002/B002?accept=application%2Foctet-stream") + } + + @Test + fun fullSyncExceedingMaxObjectsInGet() { + val command = createCommandSync( + responseBodyFromResource("/jmap_responses/session/session_with_maxObjectsInGet_2.json"), + responseBodyFromResource("/jmap_responses/email/email_query_M001_to_M005.json"), + responseBodyFromResource("/jmap_responses/email/email_get_ids_M001_and_M002.json"), + responseBodyFromResource("/jmap_responses/email/email_get_ids_M003_and_M004.json"), + responseBodyFromResource("/jmap_responses/email/email_get_ids_M005.json"), + responseBodyFromResource("/jmap_responses/blob/email/email_1.eml"), + responseBodyFromResource("/jmap_responses/blob/email/email_2.eml"), + responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"), + responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"), + responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"), + ) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + assertThat(backendFolder.getMessageServerIds()).containsOnly( + "M001", + "M002", + "M003", + "M004", + "M005", + ) + syncListener.assertSyncSuccess() + } + + @Test + fun fullSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() { + val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + backendFolder.createMessages( + "M001" to "/jmap_responses/blob/email/email_1.eml", + "M002" to "/jmap_responses/blob/email/email_2.eml", + ) + val command = createCommandSync( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"), + responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"), + responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"), + responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"), + ) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + backendFolder.assertMessages( + "M002" to "/jmap_responses/blob/email/email_2.eml", + "M003" to "/jmap_responses/blob/email/email_3.eml", + ) + syncListener.assertSyncSuccess() + } + + @Test + fun fullSyncWithLocalMessagesAndEmptyRemoteMailbox() { + val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + backendFolder.createMessages( + "M001" to "/jmap_responses/blob/email/email_1.eml", + "M002" to "/jmap_responses/blob/email/email_2.eml", + ) + val command = createCommandSync( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/email/email_query_empty_result.json"), + ) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).isEmpty() + syncListener.assertSyncEvents( + SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID), + SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID), + ) + } + + @Test + fun deltaSyncWithoutChanges() { + val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + backendFolder.createMessages( + "M001" to "/jmap_responses/blob/email/email_1.eml", + "M002" to "/jmap_responses/blob/email/email_2.eml", + ) + backendFolder.setQueryState("50:0") + val command = createCommandSync( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/email/email_query_changes_empty_result.json"), + responseBodyFromResource("/jmap_responses/email/email_get_keywords_M001_and_M002.json"), + ) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsOnly("M001", "M002") + assertThat(backendFolder.getMessageFlags("M001")).isEmpty() + assertThat(backendFolder.getMessageFlags("M002")).containsOnly(Flag.SEEN) + backendFolder.assertQueryState("50:0") + syncListener.assertSyncEvents( + SyncListenerEvent.SyncStarted(FOLDER_SERVER_ID), + SyncListenerEvent.SyncFinished(FOLDER_SERVER_ID), + ) + } + + @Test + fun deltaSyncWithLocalMessagesAndDifferentMessagesInRemoteMailbox() { + val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + backendFolder.createMessages( + "M001" to "/jmap_responses/blob/email/email_1.eml", + "M002" to "/jmap_responses/blob/email/email_2.eml", + ) + backendFolder.setQueryState("50:0") + val command = createCommandSync( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json"), + responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"), + responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"), + responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"), + ) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsOnly("M002", "M003") + backendFolder.assertQueryState("51:0") + syncListener.assertSyncSuccess() + } + + @Test + fun deltaSyncCannotCalculateChanges() { + val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID) + backendFolder.createMessages( + "M001" to "/jmap_responses/blob/email/email_1.eml", + "M002" to "/jmap_responses/blob/email/email_2.eml", + ) + backendFolder.setQueryState("10:0") + val command = createCommandSync( + responseBodyFromResource("/jmap_responses/session/valid_session.json"), + responseBodyFromResource("/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json"), + responseBodyFromResource("/jmap_responses/email/email_query_M002_and_M003.json"), + responseBodyFromResource("/jmap_responses/email/email_get_ids_M003.json"), + responseBodyFromResource("/jmap_responses/blob/email/email_3.eml"), + responseBodyFromResource("/jmap_responses/email/email_get_keywords_M002.json"), + ) + + command.sync(FOLDER_SERVER_ID, syncConfig, syncListener) + + assertThat(backendFolder.getMessageServerIds()).containsOnly("M002", "M003") + backendFolder.assertQueryState("50:0") + syncListener.assertSyncSuccess() + } + + private fun createCommandSync(vararg mockResponses: MockResponse): CommandSync { + val server = createMockWebServer(*mockResponses) + return createCommandSync(server.url("/jmap/")) + } + + private fun createCommandSync(baseUrl: HttpUrl): CommandSync { + val httpAuthentication = BasicAuthHttpAuthentication(USERNAME, PASSWORD) + val jmapClient = JmapClient(httpAuthentication, baseUrl) + return CommandSync(backendStorage, jmapClient, okHttpClient, ACCOUNT_ID, httpAuthentication) + } + + private fun createFolderInBackendStorage() { + backendStorage.updateFolders { + createFolders(listOf(FolderInfo(FOLDER_SERVER_ID, "Regular folder", FolderType.REGULAR))) + } + } + + private fun MockWebServer.assertRequestUrlPath(expected: String) { + val request = takeRequest() + val requestUrl = request.requestUrl ?: error("No request URL") + val requestUrlPath = requestUrl.encodedPath + "?" + requestUrl.encodedQuery + assertThat(requestUrlPath).isEqualTo(expected) + } + + private fun InMemoryBackendFolder.assertQueryState(expected: String) { + assertThat(getFolderExtraString("jmapQueryState")).isEqualTo(expected) + } + + private fun InMemoryBackendFolder.setQueryState(queryState: String) { + setFolderExtraString("jmapQueryState", queryState) + } + + companion object { + private const val FOLDER_SERVER_ID = "id_folder" + private const val USERNAME = "username" + private const val PASSWORD = "password" + private const val ACCOUNT_ID = "test@example.com" + } +} diff --git a/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/LoggingSyncListener.kt b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/LoggingSyncListener.kt new file mode 100644 index 0000000..8d0f2d8 --- /dev/null +++ b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/LoggingSyncListener.kt @@ -0,0 +1,94 @@ +package com.fsck.k9.backend.jmap + +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.fail +import com.fsck.k9.backend.api.SyncListener + +class LoggingSyncListener : SyncListener { + private val events = mutableListOf() + + fun assertSyncSuccess() { + events.filterIsInstance().firstOrNull()?.let { syncFailed -> + throw AssertionError("Expected sync success", syncFailed.exception) + } + + if (events.none { it is SyncListenerEvent.SyncFinished }) { + fail("Expected SyncFinished, but only got: $events") + } + } + + fun assertSyncEvents(vararg events: SyncListenerEvent) { + for (event in events) { + assertThat(getNextEvent()).isEqualTo(event) + } + + assertThat(this.events).isEmpty() + } + + fun getNextEvent(): SyncListenerEvent { + require(events.isNotEmpty()) { "No events left" } + return events.removeAt(0) + } + + override fun syncStarted(folderServerId: String) { + events.add(SyncListenerEvent.SyncStarted(folderServerId)) + } + + override fun syncAuthenticationSuccess() { + throw UnsupportedOperationException("not implemented") + } + + override fun syncHeadersStarted(folderServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun syncHeadersProgress(folderServerId: String, completed: Int, total: Int) { + throw UnsupportedOperationException("not implemented") + } + + override fun syncHeadersFinished(folderServerId: String, totalMessagesInMailbox: Int, numNewMessages: Int) { + throw UnsupportedOperationException("not implemented") + } + + override fun syncProgress(folderServerId: String, completed: Int, total: Int) { + events.add(SyncListenerEvent.SyncProgress(folderServerId, completed, total)) + } + + override fun syncNewMessage(folderServerId: String, messageServerId: String, isOldMessage: Boolean) { + throw UnsupportedOperationException("not implemented") + } + + override fun syncRemovedMessage(folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun syncFlagChanged(folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun syncFinished(folderServerId: String) { + events.add(SyncListenerEvent.SyncFinished(folderServerId)) + } + + override fun syncFailed(folderServerId: String, message: String, exception: Exception?) { + events.add(SyncListenerEvent.SyncFailed(folderServerId, message, exception)) + } + + override fun folderStatusChanged(folderServerId: String) { + throw UnsupportedOperationException("not implemented") + } +} + +sealed class SyncListenerEvent { + data class SyncStarted(val folderServerId: String) : SyncListenerEvent() + data class SyncFinished(val folderServerId: String) : SyncListenerEvent() + data class SyncFailed( + val folderServerId: String, + val message: String, + val exception: Exception?, + ) : SyncListenerEvent() + + data class SyncProgress(val folderServerId: String, val completed: Int, val total: Int) : SyncListenerEvent() +} diff --git a/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/MockWebServerHelper.kt b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/MockWebServerHelper.kt new file mode 100644 index 0000000..e4bee42 --- /dev/null +++ b/backend/jmap/src/test/java/com/fsck/k9/backend/jmap/MockWebServerHelper.kt @@ -0,0 +1,35 @@ +package com.fsck.k9.backend.jmap + +import java.io.InputStream +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.buffer +import okio.source + +fun createMockWebServer(vararg mockResponses: MockResponse): MockWebServer { + return MockWebServer().apply { + for (mockResponse in mockResponses) { + enqueue(mockResponse) + } + start() + } +} + +fun responseBodyFromResource(name: String): MockResponse { + return MockResponse().setBody(loadResource(name)) +} + +fun MockWebServer.skipRequests(count: Int) { + repeat(count) { + takeRequest() + } +} + +fun loadResource(name: String): String { + val resourceAsStream = ResourceLoader.getResourceAsStream(name) ?: error("Couldn't load resource: $name") + return resourceAsStream.use { it.source().buffer().readUtf8() } +} + +private object ResourceLoader { + fun getResourceAsStream(name: String): InputStream? = javaClass.getResourceAsStream(name) +} diff --git a/backend/jmap/src/test/resources/jmap_responses/blob/email/email_1.eml b/backend/jmap/src/test/resources/jmap_responses/blob/email/email_1.eml new file mode 100644 index 0000000..803a3e1 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/blob/email/email_1.eml @@ -0,0 +1,14 @@ +From: alice@domain.example +To: bob@domain.example +Message-ID: +Date: Mon, 10 Feb 2020 10:20:30 +0100 +Subject: Hello there +Content-Type: text/plain; charset=UTF-8 +Mime-Version: 1.0 + +Hi Bob, + +this is a message from me to you. + +Cheers, +Alice diff --git a/backend/jmap/src/test/resources/jmap_responses/blob/email/email_2.eml b/backend/jmap/src/test/resources/jmap_responses/blob/email/email_2.eml new file mode 100644 index 0000000..fcff100 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/blob/email/email_2.eml @@ -0,0 +1,16 @@ +From: Bob +To: alice@domain.example +Message-ID: +In-Reply-To: +References: +Date: Mon, 10 Feb 2020 10:20:30 +0100 +Subject: Re: Hello there +Content-Type: text/plain; charset=UTF-8 +Mime-Version: 1.0 + +Hi Alice, + +I've received your message. + +Best, +Bob diff --git a/backend/jmap/src/test/resources/jmap_responses/blob/email/email_3.eml b/backend/jmap/src/test/resources/jmap_responses/blob/email/email_3.eml new file mode 100644 index 0000000..6fa9b27 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/blob/email/email_3.eml @@ -0,0 +1,9 @@ +From: alice@domain.example +To: alice@domain.example +Message-ID: +Date: Mon, 10 Feb 2020 12:20:30 +0100 +Subject: Dummy +Content-Type: text/plain; charset=UTF-8 +Mime-Version: 1.0 + +- diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M001_and_M002.json b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M001_and_M002.json new file mode 100644 index 0000000..3bc14eb --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M001_and_M002.json @@ -0,0 +1,32 @@ +{ + "methodResponses": [ + [ + "Email/get", + { + "state": "50", + "list": [ + { + "id": "M001", + "blobId": "B001", + "keywords": {}, + "size": 280, + "receivedAt": "2020-02-11T11:00:00Z" + }, + { + "id": "M002", + "blobId": "B002", + "keywords": { + "$seen": true + }, + "size": 365, + "receivedAt": "2020-01-11T12:00:00Z" + } + ], + "notFound": [], + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003.json b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003.json new file mode 100644 index 0000000..7eebefe --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003.json @@ -0,0 +1,23 @@ +{ + "methodResponses": [ + [ + "Email/get", + { + "state": "50", + "list": [ + { + "id": "M003", + "blobId": "B003", + "keywords": {}, + "size": 215, + "receivedAt": "2020-02-11T13:00:00Z" + } + ], + "notFound": [], + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003_and_M004.json b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003_and_M004.json new file mode 100644 index 0000000..619ec00 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M003_and_M004.json @@ -0,0 +1,30 @@ +{ + "methodResponses": [ + [ + "Email/get", + { + "state": "50", + "list": [ + { + "id": "M003", + "blobId": "B003", + "keywords": {}, + "size": 215, + "receivedAt": "2020-02-11T13:00:00Z" + }, + { + "id": "M004", + "blobId": "B004", + "keywords": {}, + "size": 215, + "receivedAt": "2020-01-11T13:00:00Z" + } + ], + "notFound": [], + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M005.json b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M005.json new file mode 100644 index 0000000..c28200b --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_get_ids_M005.json @@ -0,0 +1,23 @@ +{ + "methodResponses": [ + [ + "Email/get", + { + "state": "50", + "list": [ + { + "id": "M005", + "blobId": "B005", + "keywords": {}, + "size": 215, + "receivedAt": "2020-01-11T13:00:00Z" + } + ], + "notFound": [], + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M001_and_M002.json b/backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M001_and_M002.json new file mode 100644 index 0000000..f763974 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M001_and_M002.json @@ -0,0 +1,26 @@ +{ + "methodResponses": [ + [ + "Email/get", + { + "state": "50", + "list": [ + { + "id": "M001", + "keywords": {} + }, + { + "id": "M002", + "keywords": { + "$seen": true + } + } + ], + "notFound": [], + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M002.json b/backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M002.json new file mode 100644 index 0000000..2a1f08d --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_get_keywords_M002.json @@ -0,0 +1,20 @@ +{ + "methodResponses": [ + [ + "Email/get", + { + "state": "50", + "list": [ + { + "id": "M002", + "keywords": {} + } + ], + "notFound": [], + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_and_M002.json b/backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_and_M002.json new file mode 100644 index 0000000..d38d8e9 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_and_M002.json @@ -0,0 +1,24 @@ +{ + "methodResponses": [ + [ + "Email/query", + { + "filter": { + "inMailbox": "id_folder" + }, + "queryState": "50:0", + "canCalculateChanges": true, + "position": 0, + "total": 2, + "ids": [ + "M001", + "M002" + ], + "collapseThreads": false, + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_to_M005.json b/backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_to_M005.json new file mode 100644 index 0000000..093470e --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_query_M001_to_M005.json @@ -0,0 +1,27 @@ +{ + "methodResponses": [ + [ + "Email/query", + { + "filter": { + "inMailbox": "id_folder" + }, + "queryState": "50:0", + "canCalculateChanges": true, + "position": 0, + "total": 5, + "ids": [ + "M001", + "M002", + "M003", + "M004", + "M005" + ], + "collapseThreads": false, + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_query_M002_and_M003.json b/backend/jmap/src/test/resources/jmap_responses/email/email_query_M002_and_M003.json new file mode 100644 index 0000000..366f14f --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_query_M002_and_M003.json @@ -0,0 +1,24 @@ +{ + "methodResponses": [ + [ + "Email/query", + { + "filter": { + "inMailbox": "id_folder" + }, + "queryState": "50:0", + "canCalculateChanges": true, + "position": 0, + "total": 2, + "ids": [ + "M002", + "M003" + ], + "collapseThreads": false, + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json b/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json new file mode 100644 index 0000000..165b2e7 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_M001_deleted_M003_added.json @@ -0,0 +1,21 @@ +{ + "methodResponses": [ + [ + "Email/queryChanges", + { + "accountId": "test@example.com", + "oldQueryState": "50:0", + "newQueryState": "51:0", + "removed": ["M001"], + "added": [ + { + "id": "M003", + "index": 1 + } + ] + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json b/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json new file mode 100644 index 0000000..21e9adf --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_cannot_calculate_changes_error.json @@ -0,0 +1,12 @@ +{ + "methodResponses": [ + [ + "error", + { + "type": "cannotCalculateChanges" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_empty_result.json b/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_empty_result.json new file mode 100644 index 0000000..9a71e0a --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_query_changes_empty_result.json @@ -0,0 +1,16 @@ +{ + "methodResponses": [ + [ + "Email/queryChanges", + { + "accountId": "test@example.com", + "oldQueryState": "50:0", + "newQueryState": "50:0", + "removed": [], + "added": [] + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/email/email_query_empty_result.json b/backend/jmap/src/test/resources/jmap_responses/email/email_query_empty_result.json new file mode 100644 index 0000000..44f7c7c --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/email/email_query_empty_result.json @@ -0,0 +1,21 @@ +{ + "methodResponses": [ + [ + "Email/query", + { + "filter": { + "inMailbox": "id_folder" + }, + "queryState": "50:0", + "canCalculateChanges": true, + "position": 0, + "total": 0, + "ids": [], + "collapseThreads": false, + "accountId": "test@example.com" + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes.json b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes.json new file mode 100644 index 0000000..043d3ce --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes.json @@ -0,0 +1,86 @@ +{ + "methodResponses": [ + [ + "Mailbox/changes", + { + "accountId": "test@example.com", + "oldState": "23", + "newState": "42", + "hasMoreChanges": false, + "created": [ "id_folder2" ], + "updated": [ "id_trash" ], + "destroyed": [ "id_folder1" ] + }, + "0" + ], + [ + "Mailbox/get", + { + "accountId": "test@example.com", + "state": "42", + "list": [ + { + "id": "id_folder2", + "name": "folder2", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": null, + "totalEmails": 0, + "unreadEmails": 0, + "totalThreads": 0, + "unreadThreads": 0, + "sortOrder": 10, + "isSubscribed": false + } + ] + }, + "1" + ], + [ + "Mailbox/get", + { + "accountId": "test@example.com", + "state": "42", + "list": [ + { + "id": "id_trash", + "name": "Deleted messages", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": "trash", + "totalEmails": 2, + "unreadEmails": 0, + "totalThreads": 2, + "unreadThreads": 0, + "sortOrder": 7, + "isSubscribed": false + } + ] + }, + "2" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_1.json b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_1.json new file mode 100644 index 0000000..e40a381 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_1.json @@ -0,0 +1,61 @@ +{ + "methodResponses": [ + [ + "Mailbox/changes", + { + "accountId": "test@example.com", + "oldState": "23", + "newState": "27", + "hasMoreChanges": true, + "created": [ "id_folder2" ], + "updated": [], + "destroyed": [] + }, + "0" + ], + [ + "Mailbox/get", + { + "accountId": "test@example.com", + "state": "27", + "list": [ + { + "id": "id_folder2", + "name": "folder2", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": null, + "totalEmails": 0, + "unreadEmails": 0, + "totalThreads": 0, + "unreadThreads": 0, + "sortOrder": 10, + "isSubscribed": false + } + ] + }, + "1" + ], + [ + "Mailbox/get", + { + "accountId": "test@example.com", + "state": "27", + "list": [] + }, + "2" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_2.json b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_2.json new file mode 100644 index 0000000..9ce2329 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_2.json @@ -0,0 +1,61 @@ +{ + "methodResponses": [ + [ + "Mailbox/changes", + { + "accountId": "test@example.com", + "oldState": "27", + "newState": "42", + "hasMoreChanges": false, + "created": [], + "updated": [ "id_trash" ], + "destroyed": [ "id_folder1" ] + }, + "0" + ], + [ + "Mailbox/get", + { + "accountId": "test@example.com", + "state": "42", + "list": [] + }, + "1" + ], + [ + "Mailbox/get", + { + "accountId": "test@example.com", + "state": "42", + "list": [ + { + "id": "id_trash", + "name": "Deleted messages", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": "trash", + "totalEmails": 2, + "unreadEmails": 0, + "totalThreads": 2, + "unreadThreads": 0, + "sortOrder": 7, + "isSubscribed": false + } + ] + }, + "2" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json new file mode 100644 index 0000000..664b9ad --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json @@ -0,0 +1,26 @@ +{ + "methodResponses": [ + [ + "error", + { + "type": "cannotCalculateChanges" + }, + "0" + ], + [ + "error", + { + "type": "resultReference" + }, + "1" + ], + [ + "error", + { + "type": "resultReference" + }, + "2" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_get.json b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_get.json new file mode 100644 index 0000000..930b118 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/mailbox/mailbox_get.json @@ -0,0 +1,159 @@ +{ + "methodResponses": [ + [ + "Mailbox/get", + { + "accountId": "test@example.com", + "state": "23", + "list": [ + { + "id": "id_inbox", + "name": "Inbox", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": false, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": false + }, + "role": "inbox", + "totalEmails": 238, + "unreadEmails": 6, + "totalThreads": 80, + "unreadThreads": 4, + "sortOrder": 1, + "isSubscribed": false + }, + { + "id": "id_archive", + "name": "Archive", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": "archive", + "totalEmails": 295, + "unreadEmails": 36, + "totalThreads": 136, + "unreadThreads": 17, + "sortOrder": 3, + "isSubscribed": false + }, + { + "id": "id_drafts", + "name": "Drafts", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": "drafts", + "totalEmails": 0, + "unreadEmails": 0, + "totalThreads": 0, + "unreadThreads": 0, + "sortOrder": 4, + "isSubscribed": false + }, + { + "id": "id_sent", + "name": "Sent", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": "sent", + "totalEmails": 2, + "unreadEmails": 0, + "totalThreads": 2, + "unreadThreads": 0, + "sortOrder": 5, + "isSubscribed": false + }, + { + "id": "id_trash", + "name": "Trash", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": "trash", + "totalEmails": 2, + "unreadEmails": 0, + "totalThreads": 2, + "unreadThreads": 0, + "sortOrder": 7, + "isSubscribed": false + }, + { + "id": "id_folder1", + "name": "folder1", + "parentId": null, + "myRights": { + "mayReadItems": true, + "mayAddItems": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "mayDelete": true, + "maySubmit": true, + "maySetSeen": true, + "maySetKeywords": true, + "mayAdmin": true, + "mayRename": true + }, + "role": null, + "totalEmails": 0, + "unreadEmails": 0, + "totalThreads": 0, + "unreadThreads": 0, + "sortOrder": 10, + "isSubscribed": false + } + ] + }, + "0" + ] + ], + "sessionState": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/session/session_with_maxObjectsInGet_2.json b/backend/jmap/src/test/resources/jmap_responses/session/session_with_maxObjectsInGet_2.json new file mode 100644 index 0000000..3d18918 --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/session/session_with_maxObjectsInGet_2.json @@ -0,0 +1,64 @@ +{ + "username": "test", + "apiUrl": "/jmap/", + "downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}", + "uploadUrl": "/jmap/upload/{accountId}/", + "eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}", + "accounts": { + "test@example.com": { + "name": "test@example.com", + "isPersonal": true, + "isReadOnly": false, + "accountCapabilities": { + "urn:ietf:params:jmap:core": {}, + "urn:ietf:params:jmap:submission": { + "maxDelayedSend": 44236800, + "submissionExtensions": { + "size": [ + "10240000" + ], + "dsn": [] + } + }, + "urn:ietf:params:jmap:mail": { + "emailQuerySortOptions": [ + "receivedAt", + "sentAt", + "from", + "id", + "emailstate", + "size", + "subject", + "to", + "hasKeyword", + "someInThreadHaveKeyword", + "addedDates", + "threadSize", + "spamScore", + "snoozedUntil" + ], + "maxKeywordsPerEmail": 100, + "maxSizeAttachmentsPerEmail": 10485760, + "maxMailboxesPerEmail": 20, + "mayCreateTopLevelMailbox": true, + "maxSizeMailboxName": 500 + }, + "urn:ietf:params:jmap:vacationresponse": {} + } + } + }, + "capabilities": { + "urn:ietf:params:jmap:core": { + "maxSizeUpload": 1073741824, + "maxConcurrentUpload": 5, + "maxCallsInRequest": 50, + "maxObjectsInGet": 2, + "maxObjectsInSet": 4096, + "collationAlgorithms": [] + }, + "urn:ietf:params:jmap:submission": {}, + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:vacationresponse": {} + }, + "state": "0" +} diff --git a/backend/jmap/src/test/resources/jmap_responses/session/valid_session.json b/backend/jmap/src/test/resources/jmap_responses/session/valid_session.json new file mode 100644 index 0000000..ce3270d --- /dev/null +++ b/backend/jmap/src/test/resources/jmap_responses/session/valid_session.json @@ -0,0 +1,64 @@ +{ + "username": "test", + "apiUrl": "/jmap/", + "downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}", + "uploadUrl": "/jmap/upload/{accountId}/", + "eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}", + "accounts": { + "test@example.com": { + "name": "test@example.com", + "isPersonal": true, + "isReadOnly": false, + "accountCapabilities": { + "urn:ietf:params:jmap:core": {}, + "urn:ietf:params:jmap:submission": { + "maxDelayedSend": 44236800, + "submissionExtensions": { + "size": [ + "10240000" + ], + "dsn": [] + } + }, + "urn:ietf:params:jmap:mail": { + "emailQuerySortOptions": [ + "receivedAt", + "sentAt", + "from", + "id", + "emailstate", + "size", + "subject", + "to", + "hasKeyword", + "someInThreadHaveKeyword", + "addedDates", + "threadSize", + "spamScore", + "snoozedUntil" + ], + "maxKeywordsPerEmail": 100, + "maxSizeAttachmentsPerEmail": 10485760, + "maxMailboxesPerEmail": 20, + "mayCreateTopLevelMailbox": true, + "maxSizeMailboxName": 500 + }, + "urn:ietf:params:jmap:vacationresponse": {} + } + } + }, + "capabilities": { + "urn:ietf:params:jmap:core": { + "maxSizeUpload": 1073741824, + "maxConcurrentUpload": 5, + "maxCallsInRequest": 50, + "maxObjectsInGet": 4096, + "maxObjectsInSet": 4096, + "collationAlgorithms": [] + }, + "urn:ietf:params:jmap:submission": {}, + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:vacationresponse": {} + }, + "state": "0" +} diff --git a/backend/pop3/build.gradle.kts b/backend/pop3/build.gradle.kts new file mode 100644 index 0000000..916ff6d --- /dev/null +++ b/backend/pop3/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.backend.api) + api(projects.mail.protocols.pop3) + api(projects.mail.protocols.smtp) + implementation(projects.core.common) + implementation(projects.feature.mail.folder.api) + + testImplementation(projects.mail.testing) +} diff --git a/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandDownloadMessage.kt b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandDownloadMessage.kt new file mode 100644 index 0000000..d8f950a --- /dev/null +++ b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandDownloadMessage.kt @@ -0,0 +1,26 @@ +package com.fsck.k9.backend.pop3 + +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.mail.FetchProfile.Item.BODY +import com.fsck.k9.mail.FetchProfile.Item.FLAGS +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.helper.fetchProfileOf +import com.fsck.k9.mail.store.pop3.Pop3Store + +internal class CommandDownloadMessage(private val backendStorage: BackendStorage, private val pop3Store: Pop3Store) { + + fun downloadCompleteMessage(folderServerId: String, messageServerId: String) { + val folder = pop3Store.getFolder(folderServerId) + try { + folder.open() + + val message = folder.getMessage(messageServerId) + folder.fetch(listOf(message), fetchProfileOf(FLAGS, BODY), null, 0) + + val backendFolder = backendStorage.getFolder(folderServerId) + backendFolder.saveMessage(message, MessageDownloadState.FULL) + } finally { + folder.close() + } + } +} diff --git a/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandRefreshFolderList.kt b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandRefreshFolderList.kt new file mode 100644 index 0000000..493e22d --- /dev/null +++ b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandRefreshFolderList.kt @@ -0,0 +1,19 @@ +package com.fsck.k9.backend.pop3 + +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.backend.api.updateFolders +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.store.pop3.Pop3Folder + +internal class CommandRefreshFolderList(private val backendStorage: BackendStorage) { + fun refreshFolderList() { + val folderServerIds = backendStorage.getFolderServerIds() + if (Pop3Folder.INBOX !in folderServerIds) { + backendStorage.updateFolders { + val inbox = FolderInfo(Pop3Folder.INBOX, Pop3Folder.INBOX, FolderType.INBOX) + createFolders(listOf(inbox)) + } + } + } +} diff --git a/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandSetFlag.kt b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandSetFlag.kt new file mode 100644 index 0000000..6b29dda --- /dev/null +++ b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/CommandSetFlag.kt @@ -0,0 +1,34 @@ +package com.fsck.k9.backend.pop3 + +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.store.pop3.Pop3Store +import net.thunderbird.core.common.exception.MessagingException + +internal class CommandSetFlag(private val pop3Store: Pop3Store) { + + @Throws(MessagingException::class) + fun setFlag( + folderServerId: String, + messageServerIds: List, + flag: Flag, + newState: Boolean, + ) { + val folder = pop3Store.getFolder(folderServerId) + if (!folder.isFlagSupported(flag)) { + return + } + + try { + folder.open() + + val messages = messageServerIds.map { folder.getMessage(it) } + if (messages.isEmpty()) { + return + } + + folder.setFlags(messages, setOf(flag), newState) + } finally { + folder.close() + } + } +} diff --git a/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt new file mode 100644 index 0000000..463419d --- /dev/null +++ b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Backend.kt @@ -0,0 +1,132 @@ +package com.fsck.k9.backend.pop3 + +import com.fsck.k9.backend.api.Backend +import com.fsck.k9.backend.api.BackendPusher +import com.fsck.k9.backend.api.BackendPusherCallback +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.store.pop3.Pop3Store +import com.fsck.k9.mail.transport.smtp.SmtpTransport +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter + +class Pop3Backend( + accountName: String, + backendStorage: BackendStorage, + private val pop3Store: Pop3Store, + private val smtpTransport: SmtpTransport, +) : Backend { + private val pop3Sync: Pop3Sync = Pop3Sync(accountName, backendStorage, pop3Store) + private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage) + private val commandSetFlag = CommandSetFlag(pop3Store) + private val commandDownloadMessage = CommandDownloadMessage(backendStorage, pop3Store) + + override val supportsFlags = false + override val supportsExpunge = false + override val supportsMove = false + override val supportsCopy = false + override val supportsUpload = false + override val supportsTrashFolder = false + override val supportsSearchByDate = false + override val supportsFolderSubscriptions = false + override val isPushCapable = false + + override fun refreshFolderList(): FolderPathDelimiter? { + commandRefreshFolderList.refreshFolderList() + return null + } + + override fun sync(folderServerId: String, syncConfig: SyncConfig, listener: SyncListener) { + pop3Sync.sync(folderServerId, syncConfig, listener) + } + + override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun downloadMessageStructure(folderServerId: String, messageServerId: String) { + throw UnsupportedOperationException("not implemented") + } + + override fun downloadCompleteMessage(folderServerId: String, messageServerId: String) { + commandDownloadMessage.downloadCompleteMessage(folderServerId, messageServerId) + } + + override fun setFlag(folderServerId: String, messageServerIds: List, flag: Flag, newState: Boolean) { + commandSetFlag.setFlag(folderServerId, messageServerIds, flag, newState) + } + + override fun markAllAsRead(folderServerId: String) { + throw UnsupportedOperationException("not supported") + } + + override fun expunge(folderServerId: String) { + throw UnsupportedOperationException("not supported") + } + + override fun deleteMessages(folderServerId: String, messageServerIds: List) { + commandSetFlag.setFlag(folderServerId, messageServerIds, Flag.DELETED, true) + } + + override fun deleteAllMessages(folderServerId: String) { + throw UnsupportedOperationException("not supported") + } + + override fun moveMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + throw UnsupportedOperationException("not supported") + } + + override fun copyMessages( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + throw UnsupportedOperationException("not supported") + } + + override fun moveMessagesAndMarkAsRead( + sourceFolderServerId: String, + targetFolderServerId: String, + messageServerIds: List, + ): Map? { + throw UnsupportedOperationException("not supported") + } + + override fun search( + folderServerId: String, + query: String?, + requiredFlags: Set?, + forbiddenFlags: Set?, + performFullTextSearch: Boolean, + ): List { + throw UnsupportedOperationException("not supported") + } + + override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) { + throw UnsupportedOperationException("not supported") + } + + override fun findByMessageId(folderServerId: String, messageId: String): String? { + return null + } + + override fun uploadMessage(folderServerId: String, message: Message): String? { + throw UnsupportedOperationException("not supported") + } + + override fun sendMessage(message: Message) { + smtpTransport.sendMessage(message) + } + + override fun createPusher(callback: BackendPusherCallback): BackendPusher { + throw UnsupportedOperationException("not implemented") + } +} diff --git a/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Sync.kt b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Sync.kt new file mode 100644 index 0000000..9b383e6 --- /dev/null +++ b/backend/pop3/src/main/java/com/fsck/k9/backend/pop3/Pop3Sync.kt @@ -0,0 +1,659 @@ +package com.fsck.k9.backend.pop3 + +import com.fsck.k9.backend.api.BackendFolder +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.SyncConfig +import com.fsck.k9.backend.api.SyncListener +import com.fsck.k9.helper.ExceptionHelper +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.MessageRetrievalListener +import com.fsck.k9.mail.store.pop3.Pop3Folder +import com.fsck.k9.mail.store.pop3.Pop3Message +import com.fsck.k9.mail.store.pop3.Pop3Store +import java.lang.Exception +import java.util.ArrayList +import java.util.Date +import java.util.HashMap +import java.util.concurrent.atomic.AtomicInteger +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.logging.legacy.Log + +@Suppress("TooManyFunctions") +internal class Pop3Sync( + private val accountName: String, + private val backendStorage: BackendStorage, + private val remoteStore: Pop3Store, +) { + + fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) { + synchronizeMailboxSynchronous(folder, syncConfig, listener) + } + + @Suppress( + "TooGenericExceptionCaught", + "TooGenericExceptionThrown", + "LongMethod", + "CyclomaticComplexMethod", + "NestedBlockDepth", + ) + fun synchronizeMailboxSynchronous(folder: String, syncConfig: SyncConfig, listener: SyncListener) { + var remoteFolder: Pop3Folder? = null + + Log.i("Synchronizing folder %s:%s", accountName, folder) + + var backendFolder: BackendFolder? = null + try { + Log.d("SYNC: About to process pending commands for account %s", accountName) + + Log.v("SYNC: About to get local folder %s", folder) + backendFolder = backendStorage.getFolder(folder) + + listener.syncStarted(folder) + + /* + * Get the message list from the local store and create an index of + * the uids within the list. + */ + var localUidMap: Map = backendFolder.getAllMessagesAndEffectiveDates() + + Log.v("SYNC: About to get remote folder %s", folder) + remoteFolder = remoteStore.getFolder(folder) + + /* + * Synchronization process: + * + Open the folder + Upload any local messages that are marked as PENDING_UPLOAD (Drafts, Sent, Trash) + Get the message count + Get the list of the newest K9.DEFAULT_VISIBLE_LIMIT messages + getMessages(messageCount - K9.DEFAULT_VISIBLE_LIMIT, messageCount) + See if we have each message locally, if not fetch it's flags and envelope + Get and update the unread count for the folder + Update the remote flags of any messages we have locally with an internal date newer than the remote message. + Get the current flags for any messages we have locally but did not just download + Update local flags + For any message we have locally but not remotely, delete the local message to keep cache clean. + Download larger parts of any new messages. + (Optional) Download small attachments in the background. + */ + + /* + * Open the remote folder. This pre-loads certain metadata like message count. + */ + Log.v("SYNC: About to open remote folder %s", folder) + + remoteFolder.open() + + listener.syncAuthenticationSuccess() + + /* + * Get the remote message count. + */ + val remoteMessageCount = remoteFolder.messageCount + + var visibleLimit = backendFolder.visibleLimit + + if (visibleLimit < 0) { + visibleLimit = syncConfig.defaultVisibleLimit + } + + val remoteMessages: MutableList = ArrayList() + val remoteUidMap: MutableMap = HashMap() + + Log.v("SYNC: Remote message count for folder %s is %d", folder, remoteMessageCount) + + val earliestDate = syncConfig.earliestPollDate + val earliestTimestamp = if (earliestDate != null) earliestDate.time else 0L + + /* Message numbers start at 1. */ + var remoteStart = 1 + if (remoteMessageCount > 0) { + // Adjust the starting message number based on the visible limit + if (visibleLimit > 0) { + remoteStart += (remoteMessageCount - visibleLimit).coerceAtLeast(0) + } + + Log.v( + "SYNC: About to get messages %d through %d for folder %s", + remoteStart, + remoteMessageCount, + folder, + ) + + val headerProgress = AtomicInteger(0) + listener.syncHeadersStarted(folder) + + val remoteMessageArray = remoteFolder.getMessages(remoteStart, remoteMessageCount, null) + + val messageCount = remoteMessageArray.size + + for (thisMess in remoteMessageArray) { + headerProgress.incrementAndGet() + listener.syncHeadersProgress(folder, headerProgress.get(), messageCount) + + val localMessageTimestamp = localUidMap[thisMess.uid] + if (localMessageTimestamp == null || localMessageTimestamp >= earliestTimestamp) { + remoteMessages.add(thisMess) + remoteUidMap.put(thisMess.uid, thisMess) + } + } + + Log.v("SYNC: Got %d messages for folder %s", remoteUidMap.size, folder) + + listener.syncHeadersFinished(folder, headerProgress.get(), remoteUidMap.size) + } else if (remoteMessageCount < 0) { + throw Exception("Message count $remoteMessageCount for folder $folder") + } + + /* + * Remove any messages that are in the local store but no longer on the remote store or are too old + */ + var moreMessages = backendFolder.getMoreMessages() + if (syncConfig.syncRemoteDeletions) { + val destroyMessageUids: MutableList = ArrayList() + for (localMessageUid in localUidMap.keys) { + if (remoteUidMap[localMessageUid] == null) { + destroyMessageUids.add(localMessageUid) + } + } + + if (!destroyMessageUids.isEmpty()) { + moreMessages = BackendFolder.MoreMessages.UNKNOWN + + backendFolder.destroyMessages(destroyMessageUids) + for (uid in destroyMessageUids) { + listener.syncRemovedMessage(folder, uid) + } + } + } + + if (moreMessages == BackendFolder.MoreMessages.UNKNOWN) { + updateMoreMessages(remoteFolder, backendFolder, remoteStart) + } + + /* + * Now we download the actual content of messages. + */ + val newMessages = downloadMessages( + syncConfig, + remoteFolder, + backendFolder, + remoteMessages, + listener, + ) + + listener.folderStatusChanged(folder) + + /* Notify listeners that we're finally done. */ + backendFolder.setLastChecked(System.currentTimeMillis()) + backendFolder.setStatus(null) + + Log.d( + "Done synchronizing folder %s:%s @ %tc with %d new messages", + accountName, + folder, + System.currentTimeMillis(), + newMessages, + ) + + listener.syncFinished(folder) + + Log.i("Done synchronizing folder %s:%s", accountName, folder) + } catch (e: AuthenticationFailedException) { + listener.syncFailed(folder, "Authentication failure", e) + } catch (e: Exception) { + Log.e(e, "synchronizeMailbox") + // If we don't set the last checked, it can try too often during + // failure conditions + val rootMessage = ExceptionHelper.getRootCauseMessage(e) + if (backendFolder != null) { + try { + backendFolder.setStatus(rootMessage) + backendFolder.setLastChecked(System.currentTimeMillis()) + } catch (e1: Exception) { + Log.e(e1, "Could not set last checked on folder %s:%s", accountName, folder) + } + } + + listener.syncFailed(folder, rootMessage, e) + + Log.e( + "Failed synchronizing folder %s:%s @ %tc", + accountName, + folder, + System.currentTimeMillis(), + ) + } finally { + remoteFolder?.close() + } + } + + private fun updateMoreMessages( + remoteFolder: Pop3Folder, + backendFolder: BackendFolder, + remoteStart: Int, + ) { + if (remoteStart == 1) { + backendFolder.setMoreMessages(BackendFolder.MoreMessages.FALSE) + } else { + val moreMessagesAvailable = remoteFolder.areMoreMessagesAvailable(remoteStart) + + val newMoreMessages = + if ((moreMessagesAvailable)) BackendFolder.MoreMessages.TRUE else BackendFolder.MoreMessages.FALSE + backendFolder.setMoreMessages(newMoreMessages) + } + } + + @Suppress("TooGenericExceptionCaught", "LongMethod") + @Throws(MessagingException::class) + private fun downloadMessages( + syncConfig: SyncConfig, + remoteFolder: Pop3Folder, + backendFolder: BackendFolder, + inputMessages: MutableList, + listener: SyncListener, + ): Int { + val earliestDate = syncConfig.earliestPollDate + val downloadStarted = Date() // now + + if (earliestDate != null) { + Log.d("Only syncing messages after %s", earliestDate) + } + val folder = remoteFolder.serverId + + val syncFlagMessages: MutableList = ArrayList() + var unsyncedMessages: MutableList = ArrayList() + val newMessages = AtomicInteger(0) + + val messages: MutableList = ArrayList(inputMessages) + + for (message in messages) { + evaluateMessageForDownload(message, folder, backendFolder, unsyncedMessages, syncFlagMessages, listener) + } + + val progress = AtomicInteger(0) + val todo = unsyncedMessages.size + syncFlagMessages.size + listener.syncProgress(folder, progress.get(), todo) + + Log.d("SYNC: Have %d unsynced messages", unsyncedMessages.size) + + messages.clear() + val largeMessages: MutableList = ArrayList() + val smallMessages: MutableList = ArrayList() + if (!unsyncedMessages.isEmpty()) { + val visibleLimit = backendFolder.visibleLimit + val listSize = unsyncedMessages.size + + if ((visibleLimit > 0) && (listSize > visibleLimit)) { + unsyncedMessages = unsyncedMessages.subList(0, visibleLimit) + } + + val fp = FetchProfile() + fp.add(FetchProfile.Item.ENVELOPE) + + Log.d("SYNC: About to fetch %d unsynced messages for folder %s", unsyncedMessages.size, folder) + + fetchUnsyncedMessages( + syncConfig, remoteFolder, unsyncedMessages, smallMessages, largeMessages, progress, + todo, fp, listener, + ) + + Log.d("SYNC: Synced unsynced messages for folder %s", folder) + } + + Log.d( + "SYNC: Have %d large messages and %d small messages out of %d unsynced messages", + largeMessages.size, + smallMessages.size, + unsyncedMessages.size, + ) + + unsyncedMessages.clear() + /* + * Grab the content of the small messages first. This is going to + * be very fast and at very worst will be a single up of a few bytes and a single + * download of 625k. + */ + var fp = FetchProfile() + // TODO: Only fetch small and large messages if we have some + fp.add(FetchProfile.Item.BODY) + // fp.add(FetchProfile.Item.FLAGS); + // fp.add(FetchProfile.Item.ENVELOPE); + downloadSmallMessages(remoteFolder, backendFolder, smallMessages, progress, newMessages, todo, fp, listener) + smallMessages.clear() + /* + * Now do the large messages that require more round trips. + */ + fp = FetchProfile() + fp.add(FetchProfile.Item.STRUCTURE) + downloadLargeMessages( + syncConfig, + remoteFolder, + backendFolder, + largeMessages, + progress, + newMessages, + todo, + fp, + listener, + ) + largeMessages.clear() + + Log.d("SYNC: Synced remote messages for folder %s, %d new messages", folder, newMessages.get()) + + // If the oldest message seen on this sync is newer than the oldest message seen on the previous sync, then + // we want to move our high-water mark forward. + val oldestMessageTime = backendFolder.getOldestMessageDate() + if (oldestMessageTime != null) { + if (oldestMessageTime.before(downloadStarted) && + oldestMessageTime.after(getLatestOldMessageSeenTime(backendFolder)) + ) { + setLatestOldMessageSeenTime(backendFolder, oldestMessageTime) + } + } + + return newMessages.get() + } + + private fun getLatestOldMessageSeenTime(backendFolder: BackendFolder): Date { + val latestOldMessageSeenTime = backendFolder.getFolderExtraNumber(EXTRA_LATEST_OLD_MESSAGE_SEEN_TIME) + val timestamp = if (latestOldMessageSeenTime != null) latestOldMessageSeenTime else 0L + return Date(timestamp) + } + + private fun setLatestOldMessageSeenTime(backendFolder: BackendFolder, oldestMessageTime: Date) { + backendFolder.setFolderExtraNumber(EXTRA_LATEST_OLD_MESSAGE_SEEN_TIME, oldestMessageTime.time) + } + + private fun evaluateMessageForDownload( + message: Pop3Message, + folder: String, + backendFolder: BackendFolder, + unsyncedMessages: MutableList, + syncFlagMessages: MutableList, + listener: SyncListener, + ) { + val messageServerId = message.uid + if (message.isSet(Flag.DELETED)) { + Log.v("Message with uid %s is marked as deleted", messageServerId) + + syncFlagMessages.add(message) + return + } + + val messagePresentLocally = backendFolder.isMessagePresent(messageServerId) + + if (!messagePresentLocally) { + if (!message.isSet(Flag.X_DOWNLOADED_FULL) && !message.isSet(Flag.X_DOWNLOADED_PARTIAL)) { + Log.v("Message with uid %s has not yet been downloaded", messageServerId) + + unsyncedMessages.add(message) + } else { + Log.v("Message with uid %s is partially or fully downloaded", messageServerId) + + // Store the updated message locally + val completeMessage = message.isSet(Flag.X_DOWNLOADED_FULL) + if (completeMessage) { + backendFolder.saveMessage(message, MessageDownloadState.FULL) + } else { + backendFolder.saveMessage(message, MessageDownloadState.PARTIAL) + } + + val isOldMessage = isOldMessage(backendFolder, message) + listener.syncNewMessage(folder, messageServerId, isOldMessage) + } + return + } + + val messageFlags: Set = backendFolder.getMessageFlags(messageServerId) + if (!messageFlags.contains(Flag.DELETED)) { + Log.v("Message with uid %s is present in the local store", messageServerId) + + if (!messageFlags.contains(Flag.X_DOWNLOADED_FULL) && !messageFlags.contains(Flag.X_DOWNLOADED_PARTIAL)) { + Log.v("Message with uid %s is not downloaded, even partially; trying again", messageServerId) + + unsyncedMessages.add(message) + } else { + syncFlagMessages.add(message) + } + } else { + Log.v("Local copy of message with uid %s is marked as deleted", messageServerId) + } + } + + @Suppress("LongParameterList") + @Throws(MessagingException::class) + private fun fetchUnsyncedMessages( + syncConfig: SyncConfig, + remoteFolder: Pop3Folder, + unsyncedMessages: MutableList?, + smallMessages: MutableList, + largeMessages: MutableList, + progress: AtomicInteger, + todo: Int, + fp: FetchProfile?, + listener: SyncListener, + ) { + val folder = remoteFolder.serverId + + val earliestDate = syncConfig.earliestPollDate + remoteFolder.fetch( + unsyncedMessages, + fp, + object : MessageRetrievalListener { + + @Suppress("TooGenericExceptionCaught") + override fun messageFinished(message: Pop3Message) { + try { + if (message.isSet(Flag.DELETED) || message.olderThan(earliestDate)) { + if (message.isSet(Flag.DELETED)) { + Log.v( + "Newly downloaded message %s:%s:%s was marked deleted on server, " + + "skipping", + accountName, + folder, + message.uid, + ) + } else { + Log.d( + "Newly downloaded message %s is older than %s, skipping", + message.uid, + earliestDate, + ) + } + + progress.incrementAndGet() + + // TODO: This might be the source of poll count errors in the UI. + // Is todo always the same as ofTotal + listener.syncProgress(folder, progress.get(), todo) + return + } + + if (syncConfig.maximumAutoDownloadMessageSize > 0 && + message.size > syncConfig.maximumAutoDownloadMessageSize + ) { + largeMessages.add(message) + } else { + smallMessages.add(message) + } + } catch (e: Exception) { + Log.e(e, "Error while storing downloaded message.") + } + } + }, + syncConfig.maximumAutoDownloadMessageSize, + ) + } + + @Suppress("LongParameterList") + @Throws(MessagingException::class) + private fun downloadSmallMessages( + remoteFolder: Pop3Folder, + backendFolder: BackendFolder, + smallMessages: MutableList, + progress: AtomicInteger, + newMessages: AtomicInteger, + todo: Int, + fp: FetchProfile?, + listener: SyncListener, + ) { + val folder = remoteFolder.serverId + + Log.d("SYNC: Fetching %d small messages for folder %s", smallMessages.size, folder) + + remoteFolder.fetch( + smallMessages, + fp, + object : MessageRetrievalListener { + + @Suppress("TooGenericExceptionCaught") + override fun messageFinished(message: Pop3Message) { + try { + // Store the updated message locally + + backendFolder.saveMessage(message, MessageDownloadState.FULL) + progress.incrementAndGet() + + // Increment the number of "new messages" if the newly downloaded message is + // not marked as read. + if (!message.isSet(Flag.SEEN)) { + newMessages.incrementAndGet() + } + + val messageServerId = message.uid + Log.v( + "About to notify listeners that we got a new small message %s:%s:%s", + accountName, + folder, + messageServerId, + ) + + // Update the listener with what we've found + listener.syncProgress(folder, progress.get(), todo) + + val isOldMessage = isOldMessage(backendFolder, message) + listener.syncNewMessage(folder, messageServerId, isOldMessage) + } catch (e: Exception) { + Log.e(e, "SYNC: fetch small messages") + } + } + }, + -1, + ) + + Log.d("SYNC: Done fetching small messages for folder %s", folder) + } + + private fun isOldMessage(backendFolder: BackendFolder, message: Pop3Message): Boolean { + return message.olderThan(getLatestOldMessageSeenTime(backendFolder)) + } + + @Suppress("LongParameterList") + @Throws(MessagingException::class) + private fun downloadLargeMessages( + syncConfig: SyncConfig, + remoteFolder: Pop3Folder, + backendFolder: BackendFolder, + largeMessages: MutableList, + progress: AtomicInteger, + newMessages: AtomicInteger, + todo: Int, + fp: FetchProfile?, + listener: SyncListener, + ) { + val folder = remoteFolder.serverId + + Log.d("SYNC: Fetching large messages for folder %s", folder) + + val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize + remoteFolder.fetch(largeMessages, fp, null, maxDownloadSize) + for (message in largeMessages) { + downloadSaneBody(syncConfig, remoteFolder, backendFolder, message) + + val messageServerId = message.uid + Log.v( + "About to notify listeners that we got a new large message %s:%s:%s", + accountName, + folder, + messageServerId, + ) + + // Update the listener with what we've found + progress.incrementAndGet() + + // TODO do we need to re-fetch this here? + val flags: Set = backendFolder.getMessageFlags(messageServerId) + // Increment the number of "new messages" if the newly downloaded message is + // not marked as read. + if (!flags.contains(Flag.SEEN)) { + newMessages.incrementAndGet() + } + + listener.syncProgress(folder, progress.get(), todo) + + val isOldMessage = isOldMessage(backendFolder, message) + listener.syncNewMessage(folder, messageServerId, isOldMessage) + } + + Log.d("SYNC: Done fetching large messages for folder %s", folder) + } + + @Throws(MessagingException::class) + private fun downloadSaneBody( + syncConfig: SyncConfig, + remoteFolder: Pop3Folder, + backendFolder: BackendFolder, + message: Pop3Message, + ) { + /* + * The provider was unable to get the structure of the message, so + * we'll download a reasonable portion of the message and mark it as + * incomplete so the entire thing can be downloaded later if the user + * wishes to download it. + */ + val fp = FetchProfile() + fp.add(FetchProfile.Item.BODY_SANE) + + /* + * TODO a good optimization here would be to make sure that all Stores set + * the proper size after this fetch and compare the before and after size. If + * they equal we can mark this SYNCHRONIZED instead of PARTIALLY_SYNCHRONIZED + */ + val maxDownloadSize = syncConfig.maximumAutoDownloadMessageSize + remoteFolder.fetch(mutableListOf(message), fp, null, maxDownloadSize) + + var completeMessage = false + // Certain (POP3) servers give you the whole message even when you ask for only the first x Kb + if (!message.isSet(Flag.X_DOWNLOADED_FULL)) { + /* + * Mark the message as fully downloaded if the message size is smaller than + * the account's autodownload size limit, otherwise mark as only a partial + * download. This will prevent the system from downloading the same message + * twice. + * + * If there is no limit on autodownload size, that's the same as the message + * being smaller than the max size + */ + if (syncConfig.maximumAutoDownloadMessageSize == 0 || + message.size < syncConfig.maximumAutoDownloadMessageSize + ) { + completeMessage = true + } + } + + // Store the updated message locally + if (completeMessage) { + backendFolder.saveMessage(message, MessageDownloadState.FULL) + } else { + backendFolder.saveMessage(message, MessageDownloadState.PARTIAL) + } + } + + companion object { + private const val EXTRA_LATEST_OLD_MESSAGE_SEEN_TIME = "latestOldMessageSeenTime" + } +} diff --git a/backend/testing/build.gradle.kts b/backend/testing/build.gradle.kts new file mode 100644 index 0000000..eb2e245 --- /dev/null +++ b/backend/testing/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.backend.api) + + implementation(libs.okio) + implementation(libs.junit) + implementation(libs.assertk) +} diff --git a/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendFolder.kt b/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendFolder.kt new file mode 100644 index 0000000..6446784 --- /dev/null +++ b/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendFolder.kt @@ -0,0 +1,155 @@ +package app.k9mail.backend.testing + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.backend.api.BackendFolder +import com.fsck.k9.backend.api.BackendFolder.MoreMessages +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.MessageDownloadState +import com.fsck.k9.mail.internet.MimeMessage +import java.util.Date +import okio.Buffer +import okio.buffer +import okio.source + +class InMemoryBackendFolder(override var name: String, var type: FolderType) : BackendFolder { + val extraStrings: MutableMap = mutableMapOf() + val extraNumbers: MutableMap = mutableMapOf() + private val messages = mutableMapOf() + private val messageFlags = mutableMapOf>() + private var moreMessages: MoreMessages = MoreMessages.UNKNOWN + private var status: String? = null + private var lastChecked = 0L + + override var visibleLimit: Int = 25 + + fun assertMessages(vararg messagePairs: Pair) { + for ((messageServerId, resourceName) in messagePairs) { + assertMessageContents(messageServerId, resourceName) + } + val messageServerIds = messagePairs.map { it.first }.toSet() + assertThat(messages.keys).isEqualTo(messageServerIds) + } + + private fun assertMessageContents(messageServerId: String, resourceName: String) { + val message = messages[messageServerId] ?: error("Message $messageServerId not found") + assertThat(getMessageContents(message)).isEqualTo(loadResource(resourceName)) + } + + fun createMessages(vararg messagePairs: Pair) { + for ((messageServerId, resourceName) in messagePairs) { + val inputStream = javaClass.getResourceAsStream(resourceName) + ?: error("Couldn't load resource: $resourceName") + + val message = inputStream.use { + MimeMessage.parseMimeMessage(inputStream, false) + } + + messages[messageServerId] = message + messageFlags[messageServerId] = mutableSetOf() + } + } + + private fun getMessageContents(message: Message): String { + val buffer = Buffer() + buffer.outputStream().use { + message.writeTo(it) + } + return buffer.readUtf8() + } + + override fun getMessageServerIds(): Set { + return messages.keys.toSet() + } + + override fun getAllMessagesAndEffectiveDates(): Map { + return messages + .map { (serverId, message) -> + serverId to message.sentDate.time + } + .toMap() + } + + override fun destroyMessages(messageServerIds: List) { + for (messageServerId in messageServerIds) { + messages.remove(messageServerId) + messageFlags.remove(messageServerId) + } + } + + override fun clearAllMessages() { + destroyMessages(messages.keys.toList()) + } + + override fun getMoreMessages(): MoreMessages = moreMessages + + override fun setMoreMessages(moreMessages: MoreMessages) { + this.moreMessages = moreMessages + } + + override fun setLastChecked(timestamp: Long) { + lastChecked = timestamp + } + + override fun setStatus(status: String?) { + this.status = status + } + + override fun isMessagePresent(messageServerId: String): Boolean { + return messages[messageServerId] != null + } + + override fun getMessageFlags(messageServerId: String): Set { + return messageFlags[messageServerId] ?: error("Message $messageServerId not found") + } + + override fun setMessageFlag(messageServerId: String, flag: Flag, value: Boolean) { + val flags = messageFlags[messageServerId] ?: error("Message $messageServerId not found") + if (value) { + flags.add(flag) + } else { + flags.remove(flag) + } + } + + override fun saveMessage(message: Message, downloadState: MessageDownloadState) { + val messageServerId = checkNotNull(message.uid) + messages[messageServerId] = message + val flags = message.flags.toMutableSet() + + when (downloadState) { + MessageDownloadState.ENVELOPE -> Unit + MessageDownloadState.PARTIAL -> flags.add(Flag.X_DOWNLOADED_PARTIAL) + MessageDownloadState.FULL -> flags.add(Flag.X_DOWNLOADED_FULL) + } + + messageFlags[messageServerId] = flags + } + + override fun getOldestMessageDate(): Date? { + throw UnsupportedOperationException("not implemented") + } + + override fun getFolderExtraString(name: String): String? = extraStrings[name] + + override fun setFolderExtraString(name: String, value: String?) { + if (value != null) { + extraStrings[name] = value + } else { + extraStrings.remove(name) + } + } + + override fun getFolderExtraNumber(name: String): Long? = extraNumbers[name] + + override fun setFolderExtraNumber(name: String, value: Long) { + extraNumbers[name] = value + } + + private fun loadResource(name: String): String { + val resourceAsStream = javaClass.getResourceAsStream(name) ?: error("Couldn't load resource: $name") + return resourceAsStream.use { it.source().buffer().readUtf8() } + } +} diff --git a/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt b/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt new file mode 100644 index 0000000..058f9ef --- /dev/null +++ b/backend/testing/src/main/java/app/k9mail/backend/testing/InMemoryBackendStorage.kt @@ -0,0 +1,69 @@ +package app.k9mail.backend.testing + +import com.fsck.k9.backend.api.BackendFolderUpdater +import com.fsck.k9.backend.api.BackendStorage +import com.fsck.k9.backend.api.FolderInfo +import com.fsck.k9.mail.FolderType + +class InMemoryBackendStorage : BackendStorage { + val folders: MutableMap = mutableMapOf() + val extraStrings: MutableMap = mutableMapOf() + val extraNumbers: MutableMap = mutableMapOf() + + override fun getFolder(folderServerId: String): InMemoryBackendFolder { + return folders[folderServerId] ?: error("Folder $folderServerId not found") + } + + override fun getFolderServerIds(): List { + return folders.keys.toList() + } + + override fun createFolderUpdater(): BackendFolderUpdater { + return InMemoryBackendFolderUpdater() + } + + override fun getExtraString(name: String): String? = extraStrings[name] + + override fun setExtraString(name: String, value: String) { + extraStrings[name] = value + } + + override fun getExtraNumber(name: String): Long? = extraNumbers[name] + + override fun setExtraNumber(name: String, value: Long) { + extraNumbers[name] = value + } + + private inner class InMemoryBackendFolderUpdater : BackendFolderUpdater { + override fun createFolders(folders: List): Set { + var count = this@InMemoryBackendStorage.folders.size.toLong() + return buildSet { + folders.forEach { folder -> + if (this@InMemoryBackendStorage.folders.containsKey(folder.serverId)) { + error("Folder ${folder.serverId} already present") + } + + this@InMemoryBackendStorage.folders[folder.serverId] = InMemoryBackendFolder( + name = folder.name, + type = folder.type, + ) + add(count++) + } + } + } + + override fun deleteFolders(folderServerIds: List) { + for (folderServerId in folderServerIds) { + folders.remove(folderServerId) ?: error("Folder $folderServerId not found") + } + } + + override fun changeFolder(folderServerId: String, name: String, type: FolderType) { + val folder = folders[folderServerId] ?: error("Folder $folderServerId not found") + folder.name = name + folder.type = type + } + + override fun close() = Unit + } +} diff --git a/build-plugin/README.md b/build-plugin/README.md new file mode 100644 index 0000000..1e99fad --- /dev/null +++ b/build-plugin/README.md @@ -0,0 +1,78 @@ +# Build plugins + +The `build-plugin` folder defines Gradle build plugins, used as single source of truth for the project configuration. +This helps to avoid duplicated build script setups and provides a central location for all common build logic. + +## Background + +We use Gradle's +[sharing build logic in a multi-repo setup](https://docs.gradle.org/current/samples/sample_publishing_convention_plugins.html) +to create common configuration. It allows usage of `xyz.gradle.kts` files, that are then automatically converted to +Gradle Plugins. + +The `build-plugin` is used as included build in the root `settings.gradle.kts` and provides all +included `xyz.gradle.kts` as plugins under their `xyz` name to the whole project. + +The plugins should try to accomplish single responsibility and leave one-off configuration to the +module's `build.gradle.kts`. + +## Convention plugins + +- `thunderbird.app.android` - Configures common options for Android apps +- `thunderbird.app.android.compose` - Configures common options for Jetpack Compose, based + on `thunderbird.app.android` +- `thunderbird.library.android` - Configures common options for Android libraries +- `thunderbird.library.android.compose` - Configures common options for Jetpack Compose, based + on `thunderbird.library.android` +- `thunderbird.library.jvm` - Configures common options for JVM libraries + +## Supportive plugins + +- `thunderbird.dependency.check` - [Gradle Versions: Gradle plugin to discover dependency updates](https://github.com/ben-manes/gradle-versions-plugin) + - Use `./gradlew dependencyUpdates` to generate a dependency update report +- `thunderbird.quality.detekt` - [Detekt - Static code analysis for Kotlin](https://detekt.dev/) + - Use `./gradlew detekt` to check for any issue and `./gradlew detektBaseline` in case you can't fix the reported + issue. +- `thunderbird.quality.spotless` - [Spotless - Code formatter](https://github.com/diffplug/spotless) + with [Ktlint - Kotlin linter and formatter](https://pinterest.github.io/ktlint/) + - Use `./gradlew spotlessCheck` to check for any issue and `./gradlew spotlessApply` to format your code +- `thunderbird.quality.badging` - [Android Badging Check Plugin](https://github.com/android/nowinandroid/blob/main/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt) + - Use `./gradlew generate{VariantName}Badging` to generate badging file + - Use `./gradlew check{VariantName}Badging` to validate allowed badging + - Use `./gradlew update{VariantName}Badging` to update allowed badging + +## Add new build plugin + +Create a `thunderbird.xyz.gradle.kts` file, while `xyz` describes the new plugin. + +If you need to access dependencies that are not yet defined in `build-plugin/build.gradle.kts` you have to: + +1. Add the dependency to the version catalog `gradle/libs.versions.toml` +2. Then add it to `build-plugin/build.gradle.kts`. + 1. In case of a plugin dependency use `implementation(plugin(libs.plugins.YOUR_PLUGIN_DEPENDENCY))`. + 2. Otherwise `implementation(libs.YOUR_DEPENDENCY))`. + +When done, add the plugin to `build-plugin/src/main/kotlin/ThunderbirdPlugins.kt` + +Then apply the plugin to any subproject it should be used with: + +``` +plugins { + id(ThunderbirdPlugins.xyz) +} +``` + +If the plugin is meant for the root `build.gradle.kts`, you can't use `ThunderbirdPlugins`, as it's not available to +the `plugins` block. Instead use: + +``` +plugins { + id("thunderbird.xyz") +} +``` + +## Acknowledgments + +- [Herding Elephants | Square Corner Blog](https://developer.squareup.com/blog/herding-elephants/) +- [Idiomatic Gradle: How do I idiomatically structure a large build with Gradle](https://github.com/jjohannes/idiomatic-gradle#idiomatic-build-logic-structure) + diff --git a/build-plugin/build.gradle.kts b/build-plugin/build.gradle.kts new file mode 100644 index 0000000..e3bd34c --- /dev/null +++ b/build-plugin/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) + + implementation(plugin(libs.plugins.kotlin.android)) + implementation(plugin(libs.plugins.kotlin.jvm)) + implementation(plugin(libs.plugins.kotlin.multiplatform)) + implementation(plugin(libs.plugins.kotlin.parcelize)) + implementation(plugin(libs.plugins.kotlin.serialization)) + + implementation(plugin(libs.plugins.android.application)) + implementation(plugin(libs.plugins.android.library)) + + implementation(plugin(libs.plugins.compose)) + + implementation(plugin(libs.plugins.jetbrains.compose)) + + implementation(plugin(libs.plugins.dependency.check)) + implementation(plugin(libs.plugins.detekt)) + implementation(plugin(libs.plugins.spotless)) + + implementation(libs.diff.utils) + compileOnly(libs.android.tools.common) + + // This defines the used Kotlin version for all Plugin dependencies + // and ensures that transitive dependencies are aligned on one version. + implementation(platform(libs.kotlin.gradle.bom)) +} + +fun plugin(provider: Provider) = with(provider.get()) { + "$pluginId:$pluginId.gradle.plugin:$version" +} diff --git a/build-plugin/settings.gradle.kts b/build-plugin/settings.gradle.kts new file mode 100644 index 0000000..d1e82fe --- /dev/null +++ b/build-plugin/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + + repositories { + gradlePluginPortal() + google() + mavenCentral() + } + + versionCatalogs.create("libs") { + from(files("../gradle/libs.versions.toml")) + } +} + +rootProject.name = "build-plugin" diff --git a/build-plugin/src/main/kotlin/AndroidExtension.kt b/build-plugin/src/main/kotlin/AndroidExtension.kt new file mode 100644 index 0000000..cdd6591 --- /dev/null +++ b/build-plugin/src/main/kotlin/AndroidExtension.kt @@ -0,0 +1,72 @@ +import com.android.build.api.dsl.CommonExtension +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Project +import org.gradle.api.artifacts.dsl.DependencyHandler + +internal fun CommonExtension<*, *, *, *, *, *>.configureSharedConfig(project: Project) { + compileSdk = ThunderbirdProjectConfig.Android.sdkCompile + + defaultConfig { + compileSdk = ThunderbirdProjectConfig.Android.sdkCompile + minSdk = ThunderbirdProjectConfig.Android.sdkMin + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + compileOptions { + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + } + + lint { + warningsAsErrors = false + abortOnError = true + checkDependencies = true + lintConfig = project.file("${project.rootProject.projectDir}/config/lint/lint.xml") + baseline = project.file("${project.rootProject.projectDir}/config/lint/android-lint-baseline.xml") + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + packaging { + resources { + excludes += listOf( + "/META-INF/{AL2.0,LGPL2.1}", + "/META-INF/DEPENDENCIES", + "/META-INF/LICENSE", + "/META-INF/LICENSE.txt", + "/META-INF/NOTICE", + "/META-INF/NOTICE.txt", + "/META-INF/README", + "/META-INF/README.md", + "/META-INF/CHANGES", + "/LICENSE.txt", + ) + } + } +} + +internal fun CommonExtension<*, *, *, *, *, *>.configureSharedComposeConfig(libs: LibrariesForLibs) { + buildFeatures { + compose = true + } +} + +internal fun DependencyHandler.configureSharedComposeDependencies(libs: LibrariesForLibs) { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.bundles.shared.jvm.android.compose) + + debugImplementation(libs.bundles.shared.jvm.android.compose.debug) + + testImplementation(libs.bundles.shared.jvm.test.compose) + + androidTestImplementation(libs.bundles.shared.jvm.androidtest.compose) +} diff --git a/build-plugin/src/main/kotlin/DependencyHandlerExtension.kt b/build-plugin/src/main/kotlin/DependencyHandlerExtension.kt new file mode 100644 index 0000000..62cd133 --- /dev/null +++ b/build-plugin/src/main/kotlin/DependencyHandlerExtension.kt @@ -0,0 +1,14 @@ +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.dsl.DependencyHandler + +internal fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? = + add("implementation", dependencyNotation) + +internal fun DependencyHandler.debugImplementation(dependencyNotation: Any): Dependency? = + add("debugImplementation", dependencyNotation) + +internal fun DependencyHandler.testImplementation(dependencyNotation: Any): Dependency? = + add("testImplementation", dependencyNotation) + +internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any): Dependency? = + add("androidTestImplementation", dependencyNotation) diff --git a/build-plugin/src/main/kotlin/KotlinExtension.kt b/build-plugin/src/main/kotlin/KotlinExtension.kt new file mode 100644 index 0000000..8774499 --- /dev/null +++ b/build-plugin/src/main/kotlin/KotlinExtension.kt @@ -0,0 +1,11 @@ +import org.gradle.api.Project +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +fun Project.configureKotlinJavaCompatibility() { + tasks.withType { + compilerOptions { + jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget) + } + } +} diff --git a/build-plugin/src/main/kotlin/ProjectExtension.kt b/build-plugin/src/main/kotlin/ProjectExtension.kt new file mode 100644 index 0000000..ee7dc57 --- /dev/null +++ b/build-plugin/src/main/kotlin/ProjectExtension.kt @@ -0,0 +1,10 @@ +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Project +import org.gradle.kotlin.dsl.DependencyHandlerScope +import org.gradle.kotlin.dsl.getByName + +internal val Project.libs: LibrariesForLibs + get() = extensions.getByName("libs") + +internal fun Project.dependencies(configuration: DependencyHandlerScope.() -> Unit) = + DependencyHandlerScope.of(dependencies).configuration() diff --git a/build-plugin/src/main/kotlin/SigningExtensions.kt b/build-plugin/src/main/kotlin/SigningExtensions.kt new file mode 100644 index 0000000..bb90c54 --- /dev/null +++ b/build-plugin/src/main/kotlin/SigningExtensions.kt @@ -0,0 +1,91 @@ +import com.android.build.api.dsl.ApkSigningConfig +import java.io.FileInputStream +import java.util.Properties +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.Project + +private const val SIGNING_FOLDER = ".signing" +private const val SIGNING_FILE_ENDING = ".signing.properties" +private const val UPLOAD_FILE_ENDING = ".upload.properties" + +private const val PROPERTY_STORE_FILE = "storeFile" +private const val PROPERTY_STORE_PASSWORD = "storePassword" +private const val PROPERTY_KEY_ALIAS = "keyAlias" +private const val PROPERTY_KEY_PASSWORD = "keyPassword" + +/** + * Creates an [ApkSigningConfig] for the given signing type. + * + * The signing properties are read from a file in the `.signing` folder in the project root directory. + * File names are expected to be in the format `$app.$type.signing.properties` or `$app.$type.upload.properties`. + * + * The file should contain the following properties: + * - `$app.$type.storeFile` + * - `$app.$type.storePassword` + * - `$app.$type.keyAlias` + * - `$app.$type.keyPassword` + * + * @param project the project to create the signing config for + * @param signingType the signing type to create the signing config for + * @param isUpload whether the upload or signing config is used + */ +fun NamedDomainObjectContainer.createSigningConfig( + project: Project, + signingType: SigningType, + isUpload: Boolean = true, +) { + val properties = project.readSigningProperties(signingType, isUpload) + + if (properties.hasSigningConfig(signingType)) { + create(signingType.type) { + storeFile = project.file(properties.getSigningProperty(signingType, PROPERTY_STORE_FILE)) + storePassword = properties.getSigningProperty(signingType, PROPERTY_STORE_PASSWORD) + keyAlias = properties.getSigningProperty(signingType, PROPERTY_KEY_ALIAS) + keyPassword = properties.getSigningProperty(signingType, PROPERTY_KEY_PASSWORD) + } + } else { + project.logger.warn("Signing config not created for ${signingType.type}") + } +} + +/** + * Returns the [ApkSigningConfig] for the given signing type. + * + * @param signingType the signing type to get the signing config for + */ +fun NamedDomainObjectContainer.getByType(signingType: SigningType): ApkSigningConfig? { + return findByName(signingType.type) +} + +private fun Project.readSigningProperties(signingType: SigningType, isUpload: Boolean) = Properties().apply { + val signingPropertiesFile = if (isUpload) { + rootProject.file("$SIGNING_FOLDER/${signingType.id}$UPLOAD_FILE_ENDING") + } else { + rootProject.file("$SIGNING_FOLDER/${signingType.id}$SIGNING_FILE_ENDING") + } + + if (signingPropertiesFile.exists()) { + FileInputStream(signingPropertiesFile).use { inputStream -> + load(inputStream) + } + } else { + logger.warn("Signing properties file not found: $signingPropertiesFile") + } +} + +private fun Properties.hasSigningConfig(signingType: SigningType): Boolean { + return isNotEmpty() && + containsKey(signingType, PROPERTY_STORE_FILE) && + containsKey(signingType, PROPERTY_STORE_PASSWORD) && + containsKey(signingType, PROPERTY_KEY_ALIAS) && + containsKey(signingType, PROPERTY_KEY_PASSWORD) +} + +private fun Properties.containsKey(signingType: SigningType, key: String): Boolean { + return containsKey("${signingType.id}.$key") +} + +private fun Properties.getSigningProperty(signingType: SigningType, key: String): String { + return getProperty("${signingType.id}.$key") + ?: throw IllegalArgumentException("Missing property: ${signingType.type}.$key") +} diff --git a/build-plugin/src/main/kotlin/SigningType.kt b/build-plugin/src/main/kotlin/SigningType.kt new file mode 100644 index 0000000..8ae684f --- /dev/null +++ b/build-plugin/src/main/kotlin/SigningType.kt @@ -0,0 +1,10 @@ +enum class SigningType( + val app: String, + val type: String, + val id: String = "$app.$type", +) { + K9_RELEASE(app = "k9", type = "release"), + TB_RELEASE(app = "tb", type = "release"), + TB_BETA(app = "tb", type = "beta"), + TB_DAILY(app = "tb", type = "daily"), +} diff --git a/build-plugin/src/main/kotlin/SpotlessExtension.kt b/build-plugin/src/main/kotlin/SpotlessExtension.kt new file mode 100644 index 0000000..06e6820 --- /dev/null +++ b/build-plugin/src/main/kotlin/SpotlessExtension.kt @@ -0,0 +1,11 @@ + +val kotlinEditorConfigOverride = mapOf( + "ktlint_code_style" to "intellij_idea", + "ktlint_ignore_back_ticked_identifier" to "true", + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + "ktlint_standard_class-signature" to "disabled", + "ktlint_standard_function-expression-body" to "disabled", + "ktlint_standard_function-signature" to "disabled", + "ktlint_standard_parameter-list-spacing" to "disabled", + "ktlint_standard_property-naming" to "disabled", +) diff --git a/build-plugin/src/main/kotlin/ThunderbirdPlugins.kt b/build-plugin/src/main/kotlin/ThunderbirdPlugins.kt new file mode 100644 index 0000000..e2ba60a --- /dev/null +++ b/build-plugin/src/main/kotlin/ThunderbirdPlugins.kt @@ -0,0 +1,20 @@ +object ThunderbirdPlugins { + + object App { + const val android = "thunderbird.app.android" + const val androidCompose = "thunderbird.app.android.compose" + + const val jvm = "thunderbird.app.jvm" + + const val kmpAndroidCompose = "thunderbird.app.kmp.android.compose" + } + object Library { + const val android = "thunderbird.library.android" + const val androidCompose = "thunderbird.library.android.compose" + + const val jvm = "thunderbird.library.jvm" + + const val kmp = "thunderbird.library.kmp" + const val kmpCompose = "thunderbird.library.kmp.compose" + } +} diff --git a/build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt b/build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt new file mode 100644 index 0000000..6739d8c --- /dev/null +++ b/build-plugin/src/main/kotlin/ThunderbirdProjectConfig.kt @@ -0,0 +1,19 @@ +import org.gradle.api.JavaVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +object ThunderbirdProjectConfig { + + object Android { + const val sdkMin = 21 + + // Only needed for application + const val sdkTarget = 35 + const val sdkCompile = 35 + } + + object Compiler { + val javaCompatibility = JavaVersion.VERSION_11 + val jvmTarget = JvmTarget.JVM_11 + val javaVersion = JavaVersion.VERSION_11 + } +} diff --git a/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts new file mode 100644 index 0000000..6503cb6 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.app.android.compose.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("thunderbird.app.android") + id("org.jetbrains.kotlin.plugin.compose") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +android { + configureSharedComposeConfig(libs) + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } +} + +dependencies { + configureSharedComposeDependencies(libs) + + implementation(libs.androidx.activity.compose) +} diff --git a/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts new file mode 100644 index 0000000..5f1a268 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.app.android.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +android { + configureSharedConfig(project) + + defaultConfig { + targetSdk = ThunderbirdProjectConfig.Android.sdkTarget + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } + + kotlinOptions { + jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString() + } + + dependenciesInfo { + includeInApk = false + includeInBundle = false + } +} + +dependencies { + coreLibraryDesugaring(libs.android.desugar.nio) + + implementation(platform(libs.kotlin.bom)) + implementation(platform(libs.koin.bom)) + + implementation(libs.bundles.shared.jvm.android.app) + + testImplementation(libs.bundles.shared.jvm.test) +} diff --git a/build-plugin/src/main/kotlin/thunderbird.app.jvm.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.jvm.gradle.kts new file mode 100644 index 0000000..6fcf67e --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.app.jvm.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("application") + id("org.jetbrains.kotlin.jvm") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +java { + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility +} + +configureKotlinJavaCompatibility() + +dependencies { + implementation(platform(libs.kotlin.bom)) + implementation(platform(libs.koin.bom)) + + implementation(libs.bundles.shared.jvm.main) + testImplementation(libs.bundles.shared.jvm.test) +} diff --git a/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts new file mode 100644 index 0000000..084db7c --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.app.version.info.gradle.kts @@ -0,0 +1,163 @@ +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +androidComponents { + onVariants { variant -> + val variantName = variant.name.capitalized() + val printVersionInfoTaskName = "printVersionInfo$variantName" + tasks.register(printVersionInfoTaskName) { + applicationId.set(variant.applicationId) + applicationLabel.set(getApplicationLabel(variant)) + versionCode.set(getVersionCode(variant)) + versionName.set(getVersionName(variant)) + versionNameSuffix.set(getVersionNameSuffix(variant)) + + // Set outputFile only if provided via -PoutputFile=... + project.findProperty("outputFile")?.toString()?.let { path -> + outputFile.set(File(path)) + } + + // Set the `strings.xml` file for the variant to track changes + findStringsXmlForVariant(variant)?.let { stringsFile -> + stringsXmlFile.set(project.layout.projectDirectory.file(stringsFile.path)) + } + } + } +} + +private fun String.capitalized() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() +} + +abstract class PrintVersionInfo : DefaultTask() { + + @get:Input + abstract val applicationId: Property + + @get:Input + abstract val applicationLabel: Property + + @get:Input + abstract val versionCode: Property + + @get:Input + abstract val versionName: Property + + @get:Input + abstract val versionNameSuffix: Property + + @get:OutputFile + @get:Optional + abstract val outputFile: RegularFileProperty + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val stringsXmlFile: RegularFileProperty + + init { + outputs.upToDateWhen { false } // This forces Gradle to always re-run the task + } + + @TaskAction + fun printVersionInfo() { + val output = """ + APPLICATION_ID=${applicationId.get()} + APPLICATION_LABEL=${applicationLabel.get()} + VERSION_CODE=${versionCode.get()} + VERSION_NAME=${versionName.get()} + VERSION_NAME_SUFFIX=${versionNameSuffix.get()} + FULL_VERSION_NAME=${versionName.get()}${versionNameSuffix.get()} + """.trimIndent() + + println(output) + + if (outputFile.isPresent) { + outputFile.get().asFile.writeText(output + "\n") + } + } +} + +/** + * Finds the correct `strings.xml` for the given variant. + */ +private fun findStringsXmlForVariant(variant: com.android.build.api.variant.Variant): File? { + val targetBuildType = variant.buildType ?: return null + val sourceSets = android.sourceSets + + // Try to find the strings.xml for the specific build type + val buildTypeSource = sourceSets.findByName(targetBuildType)?.res?.srcDirs?.firstOrNull() + val stringsXmlFile = buildTypeSource?.resolve("values/strings.xml") + + if (stringsXmlFile?.exists() == true) { + return stringsXmlFile + } + + // Fallback to the `main` source set + val mainSourceSet = sourceSets.findByName("main")?.res?.srcDirs?.firstOrNull() + return mainSourceSet?.resolve("values/strings.xml")?.takeIf { it.exists() } +} + +/** + * Extracts `APPLICATION_LABEL` from `strings.xml` + */ +private fun getApplicationLabel(variant: com.android.build.api.variant.Variant): Provider { + return project.provider { + findStringsXmlForVariant(variant)?.let { + extractAppName(it) + } ?: "Unknown" + } +} + +/** + * Parses `strings.xml` to extract `...` + */ +private fun extractAppName(stringsXmlFile: File): String { + val xmlDocument = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stringsXmlFile) + val xPath = XPathFactory.newInstance().newXPath() + val expression = "/resources/string[@name='app_name']/text()" + return xPath.evaluate(expression, xmlDocument, XPathConstants.STRING) as String +} + +/** + * Extracts the `VERSION_CODE` from product flavors + */ +private fun getVersionCode(variant: com.android.build.api.variant.Variant): Int { + val flavorNames = variant.productFlavors.map { it.second } + + val androidExtension = + project.extensions.findByType(com.android.build.gradle.internal.dsl.BaseAppModuleExtension::class.java) + val flavor = androidExtension?.productFlavors?.find { it.name in flavorNames } + + return flavor?.versionCode ?: androidExtension?.defaultConfig?.versionCode ?: 0 +} + +/** + * Extracts the `VERSION_NAME` from product flavors + */ +private fun getVersionName(variant: com.android.build.api.variant.Variant): String { + val flavorNames = variant.productFlavors.map { it.second } + + val androidExtension = project.extensions.findByType( + com.android.build.gradle.internal.dsl.BaseAppModuleExtension::class.java, + ) + val flavor = androidExtension?.productFlavors?.find { it.name in flavorNames } + + return flavor?.versionName ?: androidExtension?.defaultConfig?.versionName ?: "unknown" +} + +/** + * Extracts the `VERSION_NAME_SUFFIX` from build types + */ +private fun getVersionNameSuffix(variant: com.android.build.api.variant.Variant): String { + val buildTypeName = variant.buildType ?: return "" + val androidExtension = + project.extensions.findByType(com.android.build.gradle.internal.dsl.BaseAppModuleExtension::class.java) + val buildType = androidExtension?.buildTypes?.find { it.name == buildTypeName } + return buildType?.versionNameSuffix ?: "" +} diff --git a/build-plugin/src/main/kotlin/thunderbird.dependency.check.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.dependency.check.gradle.kts new file mode 100644 index 0000000..4547f5d --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.dependency.check.gradle.kts @@ -0,0 +1,18 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + +plugins { + id("com.github.ben-manes.versions") +} + +tasks.withType { + rejectVersionIf { + isNonStable(candidate.version) && !isNonStable(currentVersion) + } +} + +fun isNonStable(version: String): Boolean { + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } + val regex = "^[\\d,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() +} diff --git a/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts new file mode 100644 index 0000000..c1e6087 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.library.android.compose.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("thunderbird.library.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +android { + configureSharedComposeConfig(libs) +} + +androidComponents { + beforeVariants(selector().withBuildType("release")) { variantBuilder -> + variantBuilder.enableUnitTest = false + variantBuilder.enableAndroidTest = false + } +} + +dependencies { + configureSharedComposeDependencies(libs) +} diff --git a/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts new file mode 100644 index 0000000..75a1077 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.library.android.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +android { + configureSharedConfig(project) + + buildFeatures { + buildConfig = false + } + + kotlinOptions { + jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString() + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +kotlin { + sourceSets.all { + compilerOptions { + freeCompilerArgs.add("-Xwhen-guards") + } + } +} + +dependencies { + implementation(platform(libs.kotlin.bom)) + implementation(platform(libs.koin.bom)) + + implementation(libs.bundles.shared.jvm.main) + implementation(libs.bundles.shared.jvm.android) + + testImplementation(libs.bundles.shared.jvm.test) +} diff --git a/build-plugin/src/main/kotlin/thunderbird.library.jvm.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.jvm.gradle.kts new file mode 100644 index 0000000..5bd544e --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.library.jvm.gradle.kts @@ -0,0 +1,30 @@ +import org.gradle.jvm.tasks.Jar + +plugins { + `java-library` + id("org.jetbrains.kotlin.jvm") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +java { + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility +} + +tasks.withType { + // We want to avoid ending up with multiple JARs having the same name, e.g. "common.jar". + // To do this, we use the modified project path as base name, e.g. ":core:common" -> "core.common". + val projectDotPath = project.path.split(":").filter { it.isNotEmpty() }.joinToString(separator = ".") + archiveBaseName.set(projectDotPath) +} + +configureKotlinJavaCompatibility() + +dependencies { + implementation(platform(libs.kotlin.bom)) + implementation(platform(libs.koin.bom)) + + implementation(libs.bundles.shared.jvm.main) + testImplementation(libs.bundles.shared.jvm.test) +} diff --git a/build-plugin/src/main/kotlin/thunderbird.library.kmp.compose.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.kmp.compose.gradle.kts new file mode 100644 index 0000000..439be5b --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.library.kmp.compose.gradle.kts @@ -0,0 +1,63 @@ +plugins { + id("com.android.library") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +kotlin { + androidTarget { + compilerOptions { + jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget) + } + } + + jvm { + compilerOptions { + jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget) + } + } + + sourceSets { + commonMain.dependencies { + implementation(project.dependencies.platform(libs.kotlin.bom)) + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.bundles.shared.kmp.common) + implementation(libs.bundles.shared.kmp.compose) + + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + } + + commonTest.dependencies { + implementation(libs.bundles.shared.kmp.common.test) + } + + androidMain.dependencies { + implementation(libs.bundles.shared.kmp.android) + implementation(libs.bundles.shared.kmp.compose.android) + implementation(compose.preview) + } + } +} + +android { + compileSdk = ThunderbirdProjectConfig.Android.sdkCompile + + defaultConfig { + minSdk = ThunderbirdProjectConfig.Android.sdkMin + } + + compileOptions { + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + } + + configureSharedComposeConfig(libs) +} diff --git a/build-plugin/src/main/kotlin/thunderbird.library.kmp.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.library.kmp.gradle.kts new file mode 100644 index 0000000..2f693b6 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.library.kmp.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.kotlin.plugin.serialization") + id("thunderbird.quality.detekt.typed") + id("thunderbird.quality.spotless") +} + +kotlin { + androidTarget { + compilerOptions { + jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget) + } + } + + jvm { + compilerOptions { + jvmTarget.set(ThunderbirdProjectConfig.Compiler.jvmTarget) + } + } + + sourceSets { + commonMain.dependencies { + implementation(project.dependencies.platform(libs.kotlin.bom)) + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.bundles.shared.kmp.common) + } + + commonTest.dependencies { + implementation(libs.bundles.shared.kmp.common.test) + } + + androidMain.dependencies { + implementation(libs.bundles.shared.kmp.android) + } + } +} + +android { + compileSdk = ThunderbirdProjectConfig.Android.sdkCompile + + defaultConfig { + minSdk = ThunderbirdProjectConfig.Android.sdkMin + } + + compileOptions { + sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + targetCompatibility = ThunderbirdProjectConfig.Compiler.javaCompatibility + } +} diff --git a/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts new file mode 100644 index 0000000..c134335 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.quality.badging.gradle.kts @@ -0,0 +1,253 @@ +import com.android.SdkConstants +import com.android.build.api.artifact.SingleArtifact +import com.github.difflib.text.DiffRow +import com.github.difflib.text.DiffRowGenerator +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** + * This is a Gradle plugin that adds a task to generate the badging of the APKs and a task to check that the + * generated badging is the same as the golden badging. + * + * This is taken from [nowinandroid](https://github.com/android/nowinandroid) and follows recommendations from + * [Prevent regressions with CI and badging](https://android-developers.googleblog.com/2023/12/increase-your-apps-availability-across-device-types.html). + */ + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +val variantsToCheck = listOf("release", "beta", "daily") + +androidComponents { + onVariants { variant -> + if (variantsToCheck.any { variant.name.contains(it, ignoreCase = true) }) { + val capitalizedVariantName = variant.name.capitalized() + val generateBadgingTaskName = "generate${capitalizedVariantName}Badging" + val generateBadging = tasks.register(generateBadgingTaskName) { + apk.set(variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE)) + aapt2Executable.set( + File( + android.sdkDirectory, + "${SdkConstants.FD_BUILD_TOOLS}/" + + "${android.buildToolsVersion}/" + + SdkConstants.FN_AAPT2, + ), + ) + badging.set( + project.layout.buildDirectory.file( + "outputs/badging/${variant.name}/${variant.name}-badging.txt", + ), + ) + } + + val updateBadgingTaskName = "update${capitalizedVariantName}Badging" + tasks.register(updateBadgingTaskName) { + from(generateBadging.get().badging) + into(project.layout.projectDirectory.dir("badging")) + } + + val checkBadgingTaskName = "check${capitalizedVariantName}Badging" + val goldenBadgingPath = project.layout.projectDirectory.file("badging/${variant.name}-badging.txt") + tasks.register(checkBadgingTaskName) { + if (goldenBadgingPath.asFile.exists()) { + goldenBadging.set(goldenBadgingPath) + } + generatedBadging.set( + generateBadging.get().badging, + ) + this.updateBadgingTaskName.set(updateBadgingTaskName) + + output.set( + project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName"), + ) + } + + tasks.named("build") { + dependsOn(checkBadgingTaskName) + } + } + } +} + +private fun String.capitalized() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() +} + +@CacheableTask +abstract class GenerateBadgingTask : DefaultTask() { + + @get:OutputFile + abstract val badging: RegularFileProperty + + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputFile + abstract val apk: RegularFileProperty + + @get:PathSensitive(PathSensitivity.NONE) + @get:InputFile + abstract val aapt2Executable: RegularFileProperty + + @get:Inject + abstract val execOperations: ExecOperations + + @TaskAction + fun taskAction() { + val outputStream = ByteArrayOutputStream() + execOperations.exec { + commandLine( + aapt2Executable.get().asFile.absolutePath, + "dump", + "badging", + apk.get().asFile.absolutePath, + ) + standardOutput = outputStream + } + + badging.asFile.get().writeText(cleanBadgingContent(outputStream) + "\n") + } + + private fun cleanBadgingContent(outputStream: ByteArrayOutputStream): String { + return ByteArrayInputStream(outputStream.toByteArray()).bufferedReader().use { reader -> + reader.lineSequence().map { line -> + line.cleanBadgingLine() + }.sorted().joinToString("\n") + } + } + + private fun String.cleanBadgingLine(): String { + return if (startsWith("package:")) { + replace(Regex("versionName='[^']*'"), "") + .replace(Regex("versionCode='[^']*'"), "") + .replace(Regex("\\s+"), " ") + .trim() + } else if (trim().startsWith("uses-feature-not-required:")) { + trim() + } else { + this + } + } +} + +@CacheableTask +abstract class CheckBadgingTask : DefaultTask() { + + // In order for the task to be up-to-date when the inputs have not changed, + // the task must declare an output, even if it's not used. Tasks with no + // output are always run regardless of whether the inputs changed + @get:OutputDirectory + abstract val output: DirectoryProperty + + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:Optional + @get:InputFile + abstract val goldenBadging: RegularFileProperty + + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputFile + abstract val generatedBadging: RegularFileProperty + + @get:Input + abstract val updateBadgingTaskName: Property + + override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP + + @TaskAction + fun taskAction() { + if (goldenBadging.isPresent.not()) { + printlnColor( + ANSI_YELLOW, + "Golden badging file does not exist!" + + " If this is the first time running this task," + + " run ./gradlew ${updateBadgingTaskName.get()}", + ) + return + } + + val goldenBadgingContent = goldenBadging.get().asFile.readText() + val generatedBadgingContent = generatedBadging.get().asFile.readText() + if (goldenBadgingContent == generatedBadgingContent) { + printlnColor(ANSI_YELLOW, "Generated badging is the same as golden badging!") + return + } + + val diff = performDiff(goldenBadgingContent, generatedBadgingContent) + printDiff(diff) + + throw GradleException( + """ + Generated badging is different from golden badging! + + If this change is intended, run ./gradlew ${updateBadgingTaskName.get()} + """.trimIndent(), + ) + } + + private fun performDiff(goldenBadgingContent: String, generatedBadgingContent: String): String { + val generator: DiffRowGenerator = DiffRowGenerator.create() + .showInlineDiffs(true) + .mergeOriginalRevised(true) + .inlineDiffByWord(true) + .oldTag { _ -> "" } + .newTag { _ -> "" } + .build() + + return generator.generateDiffRows( + goldenBadgingContent.lines(), + generatedBadgingContent.lines(), + ).filter { row -> row.tag != DiffRow.Tag.EQUAL } + .joinToString("\n") { row -> + @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") + when (row.tag) { + DiffRow.Tag.INSERT -> { + "+ ${row.newLine}" + } + + DiffRow.Tag.DELETE -> { + "- ${row.oldLine}" + } + + DiffRow.Tag.CHANGE -> { + "+ ${row.newLine}" + "- ${row.oldLine}" + } + + DiffRow.Tag.EQUAL -> "" + } + } + } + + private fun printDiff(diff: String) { + printlnColor("", null) + printlnColor(ANSI_YELLOW, "Badging diff:") + + diff.lines().forEach { line -> + val ansiColor = if (line.startsWith("+")) { + ANSI_GREEN + } else if (line.startsWith("-")) { + ANSI_RED + } else { + null + } + printlnColor(line, ansiColor) + } + } + + private fun printlnColor(text: String, ansiColor: String?) { + println( + if (ansiColor != null) { + ansiColor + text + ANSI_RESET + } else { + text + }, + ) + } + + private companion object { + const val ANSI_RESET = "\u001B[0m" + const val ANSI_RED = "\u001B[31m" + const val ANSI_GREEN = "\u001B[32m" + const val ANSI_YELLOW = "\u001B[33m" + } +} diff --git a/build-plugin/src/main/kotlin/thunderbird.quality.detekt.typed.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.quality.detekt.typed.gradle.kts new file mode 100644 index 0000000..73dd76f --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.quality.detekt.typed.gradle.kts @@ -0,0 +1,47 @@ +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask +import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.withType + +plugins { + id("io.gitlab.arturbosch.detekt") +} + +configure { + config.setFrom(project.rootProject.files("config/detekt/detekt.yml")) + val name = project.path.replace(":", "-").replace("/", "-") + baseline = project.rootProject.file("config/detekt/detekt-baseline$name.xml") + + ignoredBuildTypes = listOf("release") +} + +tasks.withType().configureEach { + jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString() + + exclude(defaultExcludes) + + reports { + html.required.set(true) + sarif.required.set(true) + xml.required.set(true) + } +} + +tasks.withType().configureEach { + jvmTarget = ThunderbirdProjectConfig.Compiler.javaCompatibility.toString() + + exclude(defaultExcludes) +} + +dependencies { + detektPlugins(libs.detekt.plugin.compose) +} + +val defaultExcludes = listOf( + "**/.gradle/**", + "**/.idea/**", + "**/build/**", + ".github/**", + "gradle/**", +) diff --git a/build-plugin/src/main/kotlin/thunderbird.quality.spotless.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.quality.spotless.gradle.kts new file mode 100644 index 0000000..e7d0204 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.quality.spotless.gradle.kts @@ -0,0 +1,48 @@ +import com.diffplug.gradle.spotless.SpotlessExtension + +plugins { + id("com.diffplug.spotless") +} + +configure { + kotlin { + target( + "src/*/java/*.kt", + "src/*/kotlin/*.kt", + "src/*/java/**/*.kt", + "src/*/kotlin/**/*.kt", + ) + + ktlint(libs.versions.ktlint.get()) + .setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig") + .editorConfigOverride(kotlinEditorConfigOverride) + } + + kotlinGradle { + target( + "*.gradle.kts", + ) + + ktlint(libs.versions.ktlint.get()) + .setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig") + .editorConfigOverride( + mapOf( + "ktlint_code_style" to "intellij_idea", + "ktlint_standard_function-expression-body" to "disabled", + "ktlint_standard_function-signature" to "disabled", + ), + ) + } + + flexmark { + target( + "*.md", + ) + flexmark() + } + + format("misc") { + target(".gitignore") + trimTrailingWhitespace() + } +} diff --git a/build-plugin/src/main/kotlin/thunderbird.quality.spotless.root.gradle.kts b/build-plugin/src/main/kotlin/thunderbird.quality.spotless.root.gradle.kts new file mode 100644 index 0000000..dde36c7 --- /dev/null +++ b/build-plugin/src/main/kotlin/thunderbird.quality.spotless.root.gradle.kts @@ -0,0 +1,50 @@ +import com.diffplug.gradle.spotless.SpotlessExtension + +plugins { + id("com.diffplug.spotless") +} + +configure { + kotlin { + target( + "build-plugin/src/*/kotlin/*.kt", + "build-plugin/src/*/kotlin/**/*.kt", + ) + ktlint(libs.versions.ktlint.get()) + .setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig") + .editorConfigOverride(kotlinEditorConfigOverride) + } + + kotlinGradle { + target( + "*.gradle.kts", + "build-plugin/*.gradle.kts", + "build-plugin/src/*/kotlin/*.kts", + "build-plugin/src/*/kotlin/**/*.kts", + ) + + ktlint(libs.versions.ktlint.get()) + .setEditorConfigPath("${project.rootProject.projectDir}/.editorconfig") + .editorConfigOverride( + mapOf( + "ktlint_code_style" to "intellij_idea", + "ktlint_standard_function-expression-body" to "disabled", + "ktlint_standard_function-signature" to "disabled", + ), + ) + } + + flexmark { + target( + "*.md", + "docs/*.md", + "docs/**/*.md", + ) + flexmark() + } + + format("misc") { + target(".gitignore") + trimTrailingWhitespace() + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9df7fb6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.lint) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.compose) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.jetbrains.compose) apply false + + id("thunderbird.quality.spotless.root") + id("thunderbird.dependency.check") +} + +val propertyTestCoverage: String? by extra + +allprojects { + extra.apply { + set("testCoverageEnabled", propertyTestCoverage != null) + } + + tasks.withType { + testLogging { + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showCauses = true + showExceptions = true + showStackTraces = true + } + } +} + +tasks.register("testsOnCi") { + val skipTests = setOf("testReleaseUnitTest") + + dependsOn( + subprojects.map { project -> project.tasks.withType(Test::class.java) } + .flatten() + .filterNot { task -> task.name in skipTests }, + ) +} + +tasks.named("wrapper") { + gradleVersion = libs.versions.gradle.get() + distributionType = Wrapper.DistributionType.ALL +} diff --git a/cli/autodiscovery-cli/build.gradle.kts b/cli/autodiscovery-cli/build.gradle.kts new file mode 100644 index 0000000..274e962 --- /dev/null +++ b/cli/autodiscovery-cli/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id(ThunderbirdPlugins.App.jvm) +} + +version = "unspecified" + +application { + mainClass.set("app.k9mail.cli.autodiscovery.MainKt") +} + +dependencies { + implementation(projects.feature.autodiscovery.api) + implementation(projects.feature.autodiscovery.autoconfig) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.clikt) + implementation(libs.kxml2) +} diff --git a/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/AutoDiscoveryCli.kt b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/AutoDiscoveryCli.kt new file mode 100644 index 0000000..36c236a --- /dev/null +++ b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/AutoDiscoveryCli.kt @@ -0,0 +1,70 @@ +package app.k9mail.cli.autodiscovery + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings +import app.k9mail.autodiscovery.autoconfig.AutoconfigUrlConfig +import app.k9mail.autodiscovery.autoconfig.createIspDbAutoconfigDiscovery +import app.k9mail.autodiscovery.autoconfig.createMxLookupAutoconfigDiscovery +import app.k9mail.autodiscovery.autoconfig.createProviderAutoconfigDiscovery +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import kotlin.time.measureTimedValue +import kotlinx.coroutines.runBlocking +import net.thunderbird.core.common.mail.toUserEmailAddress +import okhttp3.OkHttpClient.Builder + +class AutoDiscoveryCli : CliktCommand() { + private val httpsOnly by option(help = "Only perform Autoconfig lookups using HTTPS").flag() + private val includeEmailAddress by option(help = "Include email address in Autoconfig lookups").flag() + + private val emailAddress by argument(name = "email", help = "Email address") + + override fun help(context: Context) = + "Performs the auto-discovery steps used by Thunderbird for Android to find mail server settings" + + override fun run() { + echo("Attempting to find mail server settings for <$emailAddress>…") + echo() + + val config = AutoconfigUrlConfig( + httpsOnly = httpsOnly, + includeEmailAddress = includeEmailAddress, + ) + + val (discoveryResult, duration) = measureTimedValue { + runAutoDiscovery(config) + } + + if (discoveryResult is Settings) { + echo("Found the following mail server settings:") + AutoDiscoveryResultFormatter(::echo).output(discoveryResult) + } else { + echo("Couldn't find any mail server settings.") + } + + echo() + echo("Duration: ${duration.inWholeMilliseconds}") + } + + private fun runAutoDiscovery(config: AutoconfigUrlConfig): AutoDiscoveryResult { + val okHttpClient = Builder().build() + try { + val providerDiscovery = createProviderAutoconfigDiscovery(okHttpClient, config) + val ispDbDiscovery = createIspDbAutoconfigDiscovery(okHttpClient) + val mxDiscovery = createMxLookupAutoconfigDiscovery(okHttpClient, config) + + val runnables = listOf(providerDiscovery, ispDbDiscovery, mxDiscovery) + .flatMap { it.initDiscovery(emailAddress.toUserEmailAddress()) } + val serialRunner = SerialRunner(runnables) + + return runBlocking { + serialRunner.run() + } + } finally { + okHttpClient.dispatcher.executorService.shutdown() + } + } +} diff --git a/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/AutoDiscoveryResultFormatter.kt b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/AutoDiscoveryResultFormatter.kt new file mode 100644 index 0000000..abc82a7 --- /dev/null +++ b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/AutoDiscoveryResultFormatter.kt @@ -0,0 +1,35 @@ +package app.k9mail.cli.autodiscovery + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings + +internal class AutoDiscoveryResultFormatter(private val echo: (String) -> Unit) { + fun output(settings: Settings) { + val incomingServer = requireNotNull(settings.incomingServerSettings as? ImapServerSettings) + val outgoingServer = requireNotNull(settings.outgoingServerSettings as? SmtpServerSettings) + + echo("------------------------------") + echo("Source: ${settings.source}") + echo("") + echo("Incoming server:") + echo(" Hostname: ${incomingServer.hostname.value}") + echo(" Port: ${incomingServer.port.value}") + echo(" Connection security: ${incomingServer.connectionSecurity}") + echo(" Authentication: ${incomingServer.authenticationTypes.joinToString()}") + echo(" Username: ${incomingServer.username}") + echo("") + echo("Outgoing server:") + echo(" Hostname: ${outgoingServer.hostname.value}") + echo(" Port: ${outgoingServer.port.value}") + echo(" Connection security: ${outgoingServer.connectionSecurity}") + echo(" Authentication: ${outgoingServer.authenticationTypes.joinToString()}") + echo(" Username: ${outgoingServer.username}") + echo("------------------------------") + if (settings.isTrusted) { + echo("These settings have been retrieved through trusted channels.") + } else { + echo("At least one UNTRUSTED channel was involved in retrieving these settings.") + } + } +} diff --git a/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/Main.kt b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/Main.kt new file mode 100644 index 0000000..ff47175 --- /dev/null +++ b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/Main.kt @@ -0,0 +1,5 @@ +package app.k9mail.cli.autodiscovery + +import com.github.ajalt.clikt.core.main + +fun main(args: Array) = AutoDiscoveryCli().main(args) diff --git a/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/SerialRunner.kt b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/SerialRunner.kt new file mode 100644 index 0000000..d499f87 --- /dev/null +++ b/cli/autodiscovery-cli/src/main/kotlin/app/k9mail/cli/autodiscovery/SerialRunner.kt @@ -0,0 +1,43 @@ +package app.k9mail.cli.autodiscovery + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NetworkError +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.UnexpectedException +import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable +import net.thunderbird.core.logging.legacy.Log + +/** + * Run a list of [AutoDiscoveryRunnable] one after the other until one returns a [Settings] result. + */ +class SerialRunner(private val runnables: List) { + suspend fun run(): AutoDiscoveryResult { + var networkErrorCount = 0 + var networkError: NetworkError? = null + + for (runnable in runnables) { + when (val discoveryResult = runnable.run()) { + is Settings -> { + return discoveryResult + } + is NetworkError -> { + networkErrorCount++ + if (networkError == null) { + networkError = discoveryResult + } + } + NoUsableSettingsFound -> { } + is UnexpectedException -> { + Log.w(discoveryResult.exception, "Unexpected exception") + } + } + } + + return if (networkError != null && networkErrorCount == runnables.size) { + networkError + } else { + NoUsableSettingsFound + } + } +} diff --git a/cli/html-cleaner-cli/README.md b/cli/html-cleaner-cli/README.md new file mode 100644 index 0000000..2b10cb3 --- /dev/null +++ b/cli/html-cleaner-cli/README.md @@ -0,0 +1,17 @@ +```text +Usage: html-cleaner [OPTIONS] INPUT [OUTPUT] + + A tool that modifies HTML to only keep allowed elements and attributes the + same way that K-9 Mail does. + +Options: + -h, --help Show this message and exit + +Arguments: + INPUT HTML input file (needs to be UTF-8 encoded) + OUTPUT Output file +``` + +You can run this tool using the [html-cleaner](../../html-cleaner) script in the root directory of this repository. +It will compile the application and then run it using the given arguments. This allows you to make modifications to the +[HTML cleaning code](../../app/html-cleaner/src/main/java/app/k9mail/html/cleaner) and test the changes right away. diff --git a/cli/html-cleaner-cli/build.gradle.kts b/cli/html-cleaner-cli/build.gradle.kts new file mode 100644 index 0000000..9f8b4e2 --- /dev/null +++ b/cli/html-cleaner-cli/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id(ThunderbirdPlugins.App.jvm) +} + +version = "unspecified" + +application { + mainClass.set("app.k9mail.cli.html.cleaner.MainKt") +} + +dependencies { + implementation(projects.library.htmlCleaner) + + implementation(libs.clikt) + implementation(libs.okio) +} diff --git a/cli/html-cleaner-cli/src/main/kotlin/app/k9mail/cli/html/cleaner/Main.kt b/cli/html-cleaner-cli/src/main/kotlin/app/k9mail/cli/html/cleaner/Main.kt new file mode 100644 index 0000000..79cf382 --- /dev/null +++ b/cli/html-cleaner-cli/src/main/kotlin/app/k9mail/cli/html/cleaner/Main.kt @@ -0,0 +1,60 @@ +package app.k9mail.cli.html.cleaner + +import app.k9mail.html.cleaner.HtmlHeadProvider +import app.k9mail.html.cleaner.HtmlProcessor +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.optional +import com.github.ajalt.clikt.parameters.types.file +import com.github.ajalt.clikt.parameters.types.inputStream +import java.io.File +import okio.buffer +import okio.sink +import okio.source + +@Suppress("MemberVisibilityCanBePrivate") +class HtmlCleaner : CliktCommand() { + val input by argument(help = "HTML input file (needs to be UTF-8 encoded)") + .inputStream() + + val output by argument(help = "Output file") + .file(mustExist = false, canBeDir = false) + .optional() + + override fun help(context: Context) = + "A tool that modifies HTML to only keep allowed elements and attributes the same way that K-9 Mail does." + + override fun run() { + val html = readInput() + val processedHtml = cleanHtml(html) + writeOutput(processedHtml) + } + + private fun readInput(): String { + return input.source().buffer().use { it.readUtf8() } + } + + private fun cleanHtml(html: String): String { + val htmlProcessor = HtmlProcessor( + object : HtmlHeadProvider { + override val headHtml = """""" + }, + ) + + return htmlProcessor.processForDisplay(html) + } + + private fun writeOutput(data: String) { + output?.writeOutput(data) ?: echo(data) + } + + private fun File.writeOutput(data: String) { + sink().buffer().use { + it.writeUtf8(data) + } + } +} + +fun main(args: Array) = HtmlCleaner().main(args) diff --git a/cli/resource-mover-cli/README.md b/cli/resource-mover-cli/README.md new file mode 100644 index 0000000..0bc6089 --- /dev/null +++ b/cli/resource-mover-cli/README.md @@ -0,0 +1,17 @@ +# Resource Mover CLI + +This is a command line interface that will move resources from one module to another. + +## Usage + +You can run the script with the following command: + +```bash +./scripts/resource-mover --from --to --keys +``` + +The **source-module-path** should be the path to the module that contains the resources you want to move. + +The **target-module-path** should be the path to the module where you want to move the resources. + +The **keys-to-move** should be the keys of the resources you want to move. You can pass multiple keys separated by a comma. diff --git a/cli/resource-mover-cli/build.gradle.kts b/cli/resource-mover-cli/build.gradle.kts new file mode 100644 index 0000000..c410f16 --- /dev/null +++ b/cli/resource-mover-cli/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id(ThunderbirdPlugins.App.jvm) +} + +version = "unspecified" + +application { + mainClass.set("net.thunderbird.cli.resource.mover.MainKt") +} + +dependencies { + implementation(libs.clikt) +} diff --git a/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/Main.kt b/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/Main.kt new file mode 100644 index 0000000..eecdc9e --- /dev/null +++ b/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/Main.kt @@ -0,0 +1,5 @@ +package net.thunderbird.cli.resource.mover + +import com.github.ajalt.clikt.core.main + +fun main(args: Array) = ResourceMoverCli().main(args) diff --git a/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/ResourceMoverCli.kt b/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/ResourceMoverCli.kt new file mode 100644 index 0000000..bc0dd1a --- /dev/null +++ b/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/ResourceMoverCli.kt @@ -0,0 +1,31 @@ +package net.thunderbird.cli.resource.mover + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.options.split + +class ResourceMoverCli( + private val stringResourceMover: StringResourceMover = StringResourceMover(), +) : CliktCommand( + name = "resource-mover", +) { + private val from: String by option( + help = "Source module path", + ).required() + + private val to: String by option( + help = "Target module path", + ).required() + + private val keys: List by option( + help = "Keys to move", + ).split(",").required() + + override fun help(context: Context): String = "Move string resources from one file to another" + + override fun run() { + stringResourceMover.moveKeys(from, to, keys) + } +} diff --git a/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/StringResourceMover.kt b/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/StringResourceMover.kt new file mode 100644 index 0000000..ddfb264 --- /dev/null +++ b/cli/resource-mover-cli/src/main/kotlin/net/thunderbird/cli/resource/mover/StringResourceMover.kt @@ -0,0 +1,186 @@ +package net.thunderbird.cli.resource.mover + +import java.io.File +import kotlin.system.exitProcess + +@Suppress("TooManyFunctions") +class StringResourceMover { + + fun moveKeys(source: String, target: String, keys: List) { + val sourcePath = File(source + RESOURCE_PATH) + val targetPath = File(target + RESOURCE_PATH) + + if (!sourcePath.exists()) { + println("\nSource path does not exist: $sourcePath\n") + return + } + + println("\nMoving keys $keys") + println(" from \"$sourcePath\" -> \"$targetPath\"\n") + for (key in keys) { + moveKey(sourcePath, targetPath, key) + } + } + + private fun moveKey(sourcePath: File, targetPath: File, key: String) { + println("\nMoving key: $key\n") + + sourcePath.walk() + .filter { it.name.startsWith(VALUES_PATH) } + .forEach { sourceDir -> + val sourceFile = sourceDir.resolve(STRING_RESOURCE_FILE_NAME) + if (sourceFile.exists()) { + moveKeyDeclaration(sourceFile, targetPath, key) + } + } + } + + private fun moveKeyDeclaration(sourceFile: File, targetPath: File, key: String) { + if (containsKey(sourceFile, key)) { + println("\nFound key in file: ${sourceFile.path}\n") + + val targetFile = getOrCreateTargetFile(targetPath, sourceFile) + val keyDeclaration = extractKeyDeclaration(sourceFile, key) + + println(" Key declaration: $keyDeclaration") + + copyKeyToTarget(targetFile, keyDeclaration, key) + deleteKeyFromSource(sourceFile, keyDeclaration) + + if (isSourceFileEmpty(sourceFile)) { + println(" Source file is empty: ${sourceFile.path} -> deleting it.") + sourceFile.delete() + } + } + } + + private fun containsKey(sourceFile: File, key: String): Boolean { + val keyPattern = createKeyPattern(key) + val sourceContent = sourceFile.readText() + return keyPattern.containsMatchIn(sourceContent) + } + + private fun extractKeyDeclaration(sourceFile: File, key: String): String { + val keyPattern = createKeyPattern(key) + val declaration = StringBuilder() + var isTagClosed = true + + sourceFile.forEachLine { line -> + if (keyPattern.containsMatchIn(line)) { + declaration.appendLine(line) + isTagClosed = isTagClosed(line) + } else if (!isTagClosed) { + declaration.appendLine(line) + isTagClosed = isTagClosed(line) + } + } + + return declaration.toString() + } + + private fun createKeyPattern(key: String): Regex { + return KEY_PATTERN.replace(KEY_PLACEHOLDER, Regex.escape(key)).toRegex() + } + + private fun isTagClosed(line: String): Boolean { + return line.contains(STRING_CLOSING_TAG) || line.contains(PLURALS_CLOSING_TAG) + } + + private fun copyKeyToTarget(targetFile: File, keyDeclaration: String, key: String) { + println(" Moving key to file: ${targetFile.path}") + + if (containsKey(targetFile, key)) { + println(" Key already exists in target file: ${targetFile.path} replacing it.") + replaceKeyInTarget(targetFile, keyDeclaration, key) + } else { + addKeyToTarget(targetFile, keyDeclaration) + } + } + + private fun addKeyToTarget(targetFile: File, keyDeclaration: String) { + val targetContent = StringBuilder() + + targetFile.forEachLine { line -> + if (line.contains(RESOURCE_CLOSING_TAG)) { + targetContent.appendLine(keyDeclaration.trimEnd()) + targetContent.appendLine(line) + } else { + targetContent.appendLine(line) + } + } + + targetFile.writeText(targetContent.toString()) + } + + private fun replaceKeyInTarget(targetFile: File, keyDeclaration: String, key: String) { + println(" Replacing key in file: ${targetFile.path}") + + val oldKeyDeclaration = extractKeyDeclaration(targetFile, key) + val targetContent = targetFile.readText() + + targetFile.writeText(targetContent.replace(oldKeyDeclaration, keyDeclaration)) + } + + private fun deleteKeyFromSource(sourceFile: File, keyDeclaration: String) { + println(" Deleting key from file: ${sourceFile.path}") + + val sourceContent = sourceFile.readText() + + sourceFile.writeText(sourceContent.replace(keyDeclaration, "")) + } + + private fun isSourceFileEmpty(sourceFile: File): Boolean { + val sourceContent = sourceFile.readText() + return sourceContent.contains(STRING_CLOSING_TAG).not() && sourceContent.contains(PLURALS_CLOSING_TAG).not() + } + + private fun getOrCreateTargetFile(targetPath: File, sourceFile: File): File { + val targetFilePath = targetPath.resolve(sourceFile.parentFile.name) + val targetFile = File(targetFilePath, sourceFile.name) + val targetDirectory = targetFile.parentFile + + if (!targetDirectory.exists()) { + targetDirectory.mkdirs() + println(" Target directory created: ${targetDirectory.path}") + } + + if (!targetFile.exists()) { + createTargetFile(targetFile) + } + + return targetFile + } + + private fun createTargetFile(targetFile: File) { + val isNewFileCreated: Boolean = targetFile.createNewFile() + if (!isNewFileCreated) { + printError("Target file could not be created: ${targetFile.path}") + exitProcess(-1) + } + + targetFile.writeText(TARGET_FILE_CONTENT) + println("Target file ${targetFile.path} created") + } + + private fun printError(message: String) { + System.err.println("\n$message\n") + } + + private companion object { + const val RESOURCE_PATH = "/src/main/res/" + const val KEY_PLACEHOLDER = "{KEY}" + const val KEY_PATTERN = """name="$KEY_PLACEHOLDER"""" + const val VALUES_PATH = "values" + const val STRING_RESOURCE_FILE_NAME = "strings.xml" + const val STRING_CLOSING_TAG = "" + const val PLURALS_CLOSING_TAG = "" + const val RESOURCE_CLOSING_TAG = "" + + val TARGET_FILE_CONTENT = """ + + + + + """.trimIndent() + } +} diff --git a/cli/translation-cli/README.md b/cli/translation-cli/README.md new file mode 100644 index 0000000..0e09800 --- /dev/null +++ b/cli/translation-cli/README.md @@ -0,0 +1,23 @@ +# Translation CLI + +This is a command line interface that will check the [weblate](https://hosted.weblate.org/projects/tb-android/#languages) translation state for all languages and print out the ones that are above a certain threshold. + +## Usage + +To use this script you need to have a weblate token. You can get it by logging in to weblate and going to your profile settings. + +You can run the script with the following command: + +```bash +./scripts/translation --token [--threshold 70] +``` + +It will print out the languages that are above the threshold. The default threshold is 70. You can change it by passing the `--threshold` argument. + +If you want a code example, you can pass the `--print-all` argument. It will print out example code for easier integration into the project. + +```bash +./scripts/translation --token --print-all +``` + +You could use this output to update the `resourceConfigurations` variable in the `app-k9mail/build.gradle.kts` file and the `supported_languages` in the `arrays_general_settings_values.xml` file. diff --git a/cli/translation-cli/build.gradle.kts b/cli/translation-cli/build.gradle.kts new file mode 100644 index 0000000..2df34d7 --- /dev/null +++ b/cli/translation-cli/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id(ThunderbirdPlugins.App.jvm) + alias(libs.plugins.kotlin.serialization) +} + +version = "unspecified" + +application { + mainClass.set("net.thunderbird.cli.translation.MainKt") +} + +dependencies { + implementation(libs.clikt) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.serialization.json) + implementation(libs.logback.classic) +} diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/AndroidLanguageCodeHelper.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/AndroidLanguageCodeHelper.kt new file mode 100644 index 0000000..e485cbb --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/AndroidLanguageCodeHelper.kt @@ -0,0 +1,11 @@ +package net.thunderbird.cli.translation + +object AndroidLanguageCodeHelper { + + /** + * Fix the language code format to match the Android resource format. + */ + fun fixLanguageCodeFormat(languageCode: String): String { + return if (languageCode.contains("-r")) languageCode.replace("-r", "_") else languageCode + } +} diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt new file mode 100644 index 0000000..2469322 --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt @@ -0,0 +1,32 @@ +package net.thunderbird.cli.translation + +import net.thunderbird.cli.translation.net.Language +import net.thunderbird.cli.translation.net.Translation +import net.thunderbird.cli.translation.net.WeblateClient + +class LanguageCodeLoader( + private val client: WeblateClient = WeblateClient(), +) { + fun loadCurrentAndroidLanguageCodes(token: String, threshold: Double): List { + val languages = client.loadLanguages(token) + val translations = client.loadTranslations(token) + val languageCodeLookup = createLanguageCodeLookup(translations) + + return filterAndMapLanguages(languages, threshold, languageCodeLookup) + } + + private fun createLanguageCodeLookup(translations: List): Map { + return translations.associate { it.language.code to it.languageCode } + } + + private fun filterAndMapLanguages( + languages: List, + threshold: Double, + languageCodeLookup: Map, + ): List { + return languages.filter { it.translatedPercent >= threshold } + .map { + languageCodeLookup[it.code] ?: throw IllegalArgumentException("Language code ${it.code} is not mapped") + }.sorted() + } +} diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/Main.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/Main.kt new file mode 100644 index 0000000..2276cdc --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/Main.kt @@ -0,0 +1,5 @@ +package net.thunderbird.cli.translation + +import com.github.ajalt.clikt.core.main + +fun main(args: Array) = TranslationCli().main(args) diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/ResourceConfigurationsFormatter.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/ResourceConfigurationsFormatter.kt new file mode 100644 index 0000000..9c87746 --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/ResourceConfigurationsFormatter.kt @@ -0,0 +1,16 @@ +package net.thunderbird.cli.translation + +class ResourceConfigurationsFormatter { + fun format(languageCodes: List) = buildString { + appendLine("android {") + appendLine(" androidResources {") + appendLine(" // Keep in sync with the resource string array \"supported_languages\"") + appendLine(" localeFilters += listOf(") + languageCodes.forEach { code -> + appendLine(" \"$code\",") + } + appendLine(" )") + appendLine(" }") + appendLine("}") + }.trim() +} diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/SupportedLanguagesFormatter.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/SupportedLanguagesFormatter.kt new file mode 100644 index 0000000..9bbed9a --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/SupportedLanguagesFormatter.kt @@ -0,0 +1,15 @@ +package net.thunderbird.cli.translation + +class SupportedLanguagesFormatter { + fun format(languageCodes: List) = buildString { + appendLine("") + appendLine("") + appendLine(" ") + appendLine(" ") + languageCodes.forEach { + appendLine(" $it") + } + appendLine(" ") + appendLine("") + }.trim() +} diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/TranslationCli.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/TranslationCli.kt new file mode 100644 index 0000000..d85aacd --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/TranslationCli.kt @@ -0,0 +1,59 @@ +package net.thunderbird.cli.translation + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.double + +const val TRANSLATED_THRESHOLD = 70.0 + +class TranslationCli( + private val languageCodeLoader: LanguageCodeLoader = LanguageCodeLoader(), + private val configurationsFormatter: ResourceConfigurationsFormatter = ResourceConfigurationsFormatter(), + private val supportedLanguagesFormatter: SupportedLanguagesFormatter = SupportedLanguagesFormatter(), +) : CliktCommand( + name = "translation", +) { + private val token: String by option( + help = "Weblate API token", + ).required() + + private val threshold: Double by option( + help = "Threshold for translation completion", + ).double().default(TRANSLATED_THRESHOLD) + + private val printAll: Boolean by option( + help = "Print code example", + ).flag() + + override fun help(context: Context): String = "Translation CLI" + + override fun run() { + val languageCodes = languageCodeLoader.loadCurrentAndroidLanguageCodes(token, threshold) + val androidLanguageCodes = languageCodes.map { AndroidLanguageCodeHelper.fixLanguageCodeFormat(it) } + val size = languageCodes.size + + echo("\nLanguages that are translated above the threshold of ($threshold%): $size") + echo("--------------------------------------------------------------") + echo("For androidResources.localeFilters:") + echo(languageCodes.joinToString(", ")) + echo() + echo("For array resource supported_languages:") + echo(androidLanguageCodes.joinToString(", ")) + if (printAll) { + echo() + echo("--------------------------------------------------------------") + echo(configurationsFormatter.format(languageCodes)) + echo("--------------------------------------------------------------") + echo("--------------------------------------------------------------") + echo(supportedLanguagesFormatter.format(androidLanguageCodes)) + echo("--------------------------------------------------------------") + echo("Please read docs/translating.md for more information on how to update language values.") + echo("--------------------------------------------------------------") + } + echo() + } +} diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/Language.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/Language.kt new file mode 100644 index 0000000..6104419 --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/Language.kt @@ -0,0 +1,11 @@ +package net.thunderbird.cli.translation.net + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Language( + val code: String, + @SerialName("translated_percent") + val translatedPercent: Double, +) diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/TranslationResponse.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/TranslationResponse.kt new file mode 100644 index 0000000..ed0559e --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/TranslationResponse.kt @@ -0,0 +1,22 @@ +package net.thunderbird.cli.translation.net + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TranslationResponse( + val next: String?, + val results: List, +) + +@Serializable +data class Translation( + @SerialName("language_code") + val languageCode: String, + val language: TranslationLanguage, +) + +@Serializable +data class TranslationLanguage( + val code: String, +) diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateClient.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateClient.kt new file mode 100644 index 0000000..7121f44 --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateClient.kt @@ -0,0 +1,88 @@ +package net.thunderbird.cli.translation.net + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.DEFAULT +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.get +import io.ktor.http.headers +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json + +class WeblateClient( + private val client: HttpClient = createClient(), + private val config: WeblateConfig = WeblateConfig(), +) { + fun loadLanguages(token: String): List { + val languages: List + + runBlocking { + languages = client.get(config.projectsLanguagesUrl()) { + headers { + config.getDefaultHeaders(token).forEach { (key, value) -> append(key, value) } + } + }.body() + } + + return languages + } + + fun loadTranslations(token: String): List { + val translations = mutableListOf() + var page = 1 + var hasNextPage = true + + while (hasNextPage) { + val translationPage = loadTranslationPage(token, page) + translations.addAll(translationPage.results) + + hasNextPage = translationPage.next != null + page++ + } + + return translations + } + + private fun loadTranslationPage(token: String, page: Int): TranslationResponse { + val translationResponse: TranslationResponse + + runBlocking { + translationResponse = client.get(config.componentsTranslationsUrl(page)) { + headers { + config.getDefaultHeaders(token).forEach { (key, value) -> append(key, value) } + } + }.body() + } + + return translationResponse + } + + private companion object { + fun createClient(): HttpClient { + return HttpClient(CIO) { + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.NONE + } + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + }, + ) + } + } + } + + private fun WeblateConfig.projectsLanguagesUrl() = + "${baseUrl}projects/$projectName/languages/" + + private fun WeblateConfig.componentsTranslationsUrl(page: Int) = + "${baseUrl}components/$projectName/$defaultComponent/translations/?page=$page" + } +} diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateConfig.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateConfig.kt new file mode 100644 index 0000000..8109431 --- /dev/null +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateConfig.kt @@ -0,0 +1,26 @@ +package net.thunderbird.cli.translation.net + +/** + * Configuration for Weblate API + * + * @property baseUrl Base URL of the Weblate API + * @property projectName Name of the Weblate project + * @property defaultComponent Default component to use for translations + */ +data class WeblateConfig( + val baseUrl: String = "https://hosted.weblate.org/api/", + val projectName: String = "tb-android", + val defaultComponent: String = "app-strings", + private val defaultHeaders: Map = mapOf( + "Accept" to "application/json", + "Authorization" to "Token $PLACEHOLDER_TOKEN", + ), +) { + fun getDefaultHeaders(token: String): List> = + defaultHeaders.mapValues { it.value.replace(PLACEHOLDER_TOKEN, token) } + .map { (key, value) -> key to value } + + private companion object { + const val PLACEHOLDER_TOKEN = "{weblate_token}" + } +} diff --git a/config/detekt/detekt-baseline-backend-api.xml b/config/detekt/detekt-baseline-backend-api.xml new file mode 100644 index 0000000..3278a5b --- /dev/null +++ b/config/detekt/detekt-baseline-backend-api.xml @@ -0,0 +1,10 @@ + + + + + ForbiddenComment:BackendFolder.kt$BackendFolder$// FIXME: add documentation + TooManyFunctions:Backend.kt$Backend + TooManyFunctions:BackendFolder.kt$BackendFolder + TooManyFunctions:SyncListener.kt$SyncListener + + diff --git a/config/detekt/detekt-baseline-backend-demo.xml b/config/detekt/detekt-baseline-backend-demo.xml new file mode 100644 index 0000000..389c5ce --- /dev/null +++ b/config/detekt/detekt-baseline-backend-demo.xml @@ -0,0 +1,7 @@ + + + + + TooManyFunctions:DemoBackend.kt$DemoBackend : Backend + + diff --git a/config/detekt/detekt-baseline-backend-imap.xml b/config/detekt/detekt-baseline-backend-imap.xml new file mode 100644 index 0000000..3c996f6 --- /dev/null +++ b/config/detekt/detekt-baseline-backend-imap.xml @@ -0,0 +1,28 @@ + + + + + CyclomaticComplexMethod:ImapSync.kt$ImapSync$private fun synchronizeMailboxSynchronous(folder: String, syncConfig: SyncConfig, listener: SyncListener) + LongMethod:ImapSync.kt$ImapSync$private fun downloadMessages( syncConfig: SyncConfig, remoteFolder: ImapFolder, backendFolder: BackendFolder, inputMessages: List<ImapMessage>, highestKnownUid: Long?, listener: SyncListener, ) + LongMethod:ImapSync.kt$ImapSync$private fun synchronizeMailboxSynchronous(folder: String, syncConfig: SyncConfig, listener: SyncListener) + LongParameterList:ImapSync.kt$ImapSync$( remoteFolder: ImapFolder, backendFolder: BackendFolder, largeMessages: List<ImapMessage>, progress: AtomicInteger, downloadedMessageCount: AtomicInteger, todo: Int, highestKnownUid: Long?, listener: SyncListener, maxDownloadSize: Int, ) + LongParameterList:ImapSync.kt$ImapSync$( remoteFolder: ImapFolder, backendFolder: BackendFolder, smallMessages: List<ImapMessage>, progress: AtomicInteger, downloadedMessageCount: AtomicInteger, todo: Int, highestKnownUid: Long?, listener: SyncListener, ) + LongParameterList:ImapSync.kt$ImapSync$( syncConfig: SyncConfig, remoteFolder: ImapFolder, unsyncedMessages: List<ImapMessage>, smallMessages: MutableList<ImapMessage>, largeMessages: MutableList<ImapMessage>, progress: AtomicInteger, todo: Int, listener: SyncListener, ) + MagicNumber:ImapBackendPusher.kt$ImapBackendPusher$1000L + MagicNumber:ImapBackendPusher.kt$ImapBackendPusher$15 + MagicNumber:ImapBackendPusher.kt$ImapBackendPusher$60 + MaxLineLength:ImapSync.kt$ImapSync.<no name provided>$// TODO: This might be the source of poll count errors in the UI. Is todo always the same as ofTotal + NestedBlockDepth:ImapSync.kt$ImapSync$private fun synchronizeMailboxSynchronous(folder: String, syncConfig: SyncConfig, listener: SyncListener) + ReturnCount:ImapSync.kt$ImapSync$private fun isOldMessage(messageServerId: String, highestKnownUid: Long?): Boolean + ReturnCount:ImapSync.kt$ImapSync$private fun syncFlags(syncConfig: SyncConfig, backendFolder: BackendFolder, remoteMessage: ImapMessage): Boolean + ReturnCount:UidReverseComparator.kt$UidReverseComparator$override fun compare(messageLeft: Message, messageRight: Message): Int + TooGenericExceptionCaught:ImapFolderPusher.kt$ImapFolderPusher$e: Exception + TooGenericExceptionCaught:ImapSync.kt$ImapSync$e: Exception + TooGenericExceptionCaught:ImapSync.kt$ImapSync.<no name provided>$e: Exception + TooGenericExceptionThrown:ImapSync.kt$ImapSync$throw Exception("Message count $remoteMessageCount for folder $folder") + TooManyFunctions:ImapBackend.kt$ImapBackend : Backend + TooManyFunctions:ImapBackendPusher.kt$ImapBackendPusher : BackendPusherImapPusherCallback + TooManyFunctions:ImapSync.kt$ImapSync + TooManyFunctions:SimpleSyncListener.kt$SimpleSyncListener : SyncListener + + diff --git a/config/detekt/detekt-baseline-backend-jmap.xml b/config/detekt/detekt-baseline-backend-jmap.xml new file mode 100644 index 0000000..d346c12 --- /dev/null +++ b/config/detekt/detekt-baseline-backend-jmap.xml @@ -0,0 +1,17 @@ + + + + + ForbiddenComment:CommandSync.kt$CommandSync$// FIXME: Add sort parameter + ReturnCount:JmapAccountDiscovery.kt$JmapAccountDiscovery$fun discover(emailAddress: String, password: String): JmapDiscoveryResult + SwallowedException:JmapAccountDiscovery.kt$JmapAccountDiscovery$e: EndpointNotFoundException + SwallowedException:JmapAccountDiscovery.kt$JmapAccountDiscovery$e: UnauthorizedException + SwallowedException:JmapAccountDiscovery.kt$JmapAccountDiscovery$e: UnknownHostException + ThrowsCount:CommandRefreshFolderList.kt$CommandRefreshFolderList$fun refreshFolderList() + TooGenericExceptionCaught:CommandRefreshFolderList.kt$CommandRefreshFolderList$e: Exception + TooGenericExceptionCaught:CommandSync.kt$CommandSync$e: Exception + TooGenericExceptionCaught:JmapAccountDiscovery.kt$JmapAccountDiscovery$e: Exception + TooManyFunctions:CommandSync.kt$CommandSync + TooManyFunctions:JmapBackend.kt$JmapBackend : Backend + + diff --git a/config/detekt/detekt-baseline-backend-pop3.xml b/config/detekt/detekt-baseline-backend-pop3.xml new file mode 100644 index 0000000..62a8ac4 --- /dev/null +++ b/config/detekt/detekt-baseline-backend-pop3.xml @@ -0,0 +1,7 @@ + + + + + TooManyFunctions:Pop3Backend.kt$Pop3Backend : Backend + + diff --git a/config/detekt/detekt-baseline-backend-testing.xml b/config/detekt/detekt-baseline-backend-testing.xml new file mode 100644 index 0000000..63985b5 --- /dev/null +++ b/config/detekt/detekt-baseline-backend-testing.xml @@ -0,0 +1,8 @@ + + + + + MagicNumber:InMemoryBackendFolder.kt$InMemoryBackendFolder$25 + TooManyFunctions:InMemoryBackendFolder.kt$InMemoryBackendFolder : BackendFolder + + diff --git a/config/detekt/detekt-baseline-cli-html-cleaner-cli.xml b/config/detekt/detekt-baseline-cli-html-cleaner-cli.xml new file mode 100644 index 0000000..24679bb --- /dev/null +++ b/config/detekt/detekt-baseline-cli-html-cleaner-cli.xml @@ -0,0 +1,7 @@ + + + + + MatchingDeclarationName:Main.kt$HtmlCleaner : CliktCommand + + diff --git a/config/detekt/detekt-baseline-feature-account-setup.xml b/config/detekt/detekt-baseline-feature-account-setup.xml new file mode 100644 index 0000000..3e7555d --- /dev/null +++ b/config/detekt/detekt-baseline-feature-account-setup.xml @@ -0,0 +1,8 @@ + + + + + ViewModelForwarding:AccountAutoDiscoveryContent.kt$AccountOAuthView( onOAuthResult = { result -> onEvent(Event.OnOAuthResult(result)) }, viewModel = oAuthViewModel, isEnabled = isAutoDiscoverySettingsTrusted || isConfigurationApproved, ) + ViewModelForwarding:AccountAutoDiscoveryContent.kt$AutoDiscoveryContent( state = state, onEvent = onEvent, oAuthViewModel = oAuthViewModel, ) + + diff --git a/config/detekt/detekt-baseline-feature-autodiscovery-providersxml.xml b/config/detekt/detekt-baseline-feature-autodiscovery-providersxml.xml new file mode 100644 index 0000000..d964bce --- /dev/null +++ b/config/detekt/detekt-baseline-feature-autodiscovery-providersxml.xml @@ -0,0 +1,15 @@ + + + + + MagicNumber:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$143 + MagicNumber:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$465 + MagicNumber:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$587 + MagicNumber:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$993 + NestedBlockDepth:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$private fun parseProviders(xml: XmlResourceParser, domain: String): Provider? + ReturnCount:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$override fun discover(email: String): DiscoveryResults? + ReturnCount:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$private fun Provider.toIncomingServerSettings(email: String): DiscoveredServerSettings? + ReturnCount:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$private fun Provider.toOutgoingServerSettings(email: String): DiscoveredServerSettings? + TooGenericExceptionCaught:ProvidersXmlDiscovery.kt$ProvidersXmlDiscovery$e: Exception + + diff --git a/config/detekt/detekt-baseline-feature-autodiscovery-srvrecords.xml b/config/detekt/detekt-baseline-feature-autodiscovery-srvrecords.xml new file mode 100644 index 0000000..5699da3 --- /dev/null +++ b/config/detekt/detekt-baseline-feature-autodiscovery-srvrecords.xml @@ -0,0 +1,7 @@ + + + + + LongMethod:SrvServiceDiscoveryTest.kt$SrvServiceDiscoveryTest$@Test fun discover_withRequiredServices_shouldCorrectlyPrioritize() + + diff --git a/config/detekt/detekt-baseline-feature-settings-import.xml b/config/detekt/detekt-baseline-feature-settings-import.xml new file mode 100644 index 0000000..5238ba6 --- /dev/null +++ b/config/detekt/detekt-baseline-feature-settings-import.xml @@ -0,0 +1,15 @@ + + + + + CyclomaticComplexMethod:SettingsImportFragment.kt$SettingsImportFragment$private fun ViewHolder.updateUi(model: SettingsImportUiModel) + MagicNumber:SettingsImportListItems.kt$ImportListItem$3 + MagicNumber:SettingsImportListItems.kt$ImportListItem$4 + SwallowedException:AuthViewModel.kt$AuthViewModel$e: ActivityNotFoundException + TooGenericExceptionCaught:AuthViewModel.kt$AuthViewModel$e: Exception + TooGenericExceptionCaught:SettingsImportViewModel.kt$SettingsImportViewModel$e: Exception + TooManyFunctions:SettingsImportFragment.kt$SettingsImportFragment : Fragment + TooManyFunctions:SettingsImportUiModel.kt$SettingsImportUiModel + TooManyFunctions:SettingsImportViewModel.kt$SettingsImportViewModel : ViewModel + + diff --git a/config/detekt/detekt-baseline-legacy-common.xml b/config/detekt/detekt-baseline-legacy-common.xml new file mode 100644 index 0000000..099dbb0 --- /dev/null +++ b/config/detekt/detekt-baseline-legacy-common.xml @@ -0,0 +1,9 @@ + + + + + TooManyFunctions:K9CoreResourceProvider.kt$K9CoreResourceProvider : CoreResourceProvider + TooManyFunctions:K9NotificationActionCreator.kt$K9NotificationActionCreator : NotificationActionCreator + TooManyFunctions:K9NotificationResourceProvider.kt$K9NotificationResourceProvider : NotificationResourceProvider + + diff --git a/config/detekt/detekt-baseline-legacy-core.xml b/config/detekt/detekt-baseline-legacy-core.xml new file mode 100644 index 0000000..cb1e469 --- /dev/null +++ b/config/detekt/detekt-baseline-legacy-core.xml @@ -0,0 +1,100 @@ + + + + + CastToNullableType:SettingsExporter.kt$SettingsExporter$as + CyclomaticComplexMethod:HttpUriParser.kt$HttpUriParser$private fun tryMatchIpv6Address(text: CharSequence, startPos: Int): Int + ForbiddenComment:K9BackendFolderTest.kt$K9BackendFolderTest$// FIXME: This is a hack to get Preferences into a state where it's safe to call newAccount() + ForbiddenComment:K9BackendStorageTest.kt$K9BackendStorageTest$// FIXME: This is a hack to get Preferences into a state where it's safe to call newAccount() + FunctionOnlyReturningConstant:DisplayHtml.kt$DisplayHtml$private fun cssStyleSignature(): String + LongMethod:MessageListRepositoryTest.kt$MessageListRepositoryTest$@Test fun `getThread() should use flag values from the cache`() + LongMethod:TextBodyBuilderTest.kt$TextBodyBuilderTest.Companion$@JvmStatic @Parameterized.Parameters(name = "{index}: {0}") fun data(): Collection<TestData> + LoopWithTooManyJumpStatements:HttpUriParser.kt$HttpUriParser$while + LoopWithTooManyJumpStatements:SettingsExporter.kt$SettingsExporter$for + MagicNumber:AccountPreferenceSerializer.kt$AccountPreferenceSerializer$10 + MagicNumber:AccountPreferenceSerializer.kt$AccountPreferenceSerializer$24 + MagicNumber:AccountPreferenceSerializer.kt$AccountPreferenceSerializer$5 + MagicNumber:CollectionExtensions.kt$0.75F + MagicNumber:CollectionExtensions.kt$3 + MagicNumber:EmailTextToHtml.kt$EmailTextToHtml$3 + MagicNumber:EmailTextToHtml.kt$EmailTextToHtml$4 + MagicNumber:EmailTextToHtml.kt$EmailTextToHtml$5 + MagicNumber:HttpUriParser.kt$HttpUriParser$10 + MagicNumber:HttpUriParser.kt$HttpUriParser$255 + MagicNumber:HttpUriParser.kt$HttpUriParser$4 + MagicNumber:HttpUriParser.kt$HttpUriParser$5 + MagicNumber:HttpUriParser.kt$HttpUriParser$6 + MagicNumber:HttpUriParser.kt$HttpUriParser$65535 + MagicNumber:HttpUriParser.kt$HttpUriParser$7 + MagicNumber:HttpUriParser.kt$HttpUriParser$8 + MagicNumber:MailSyncWorkerManager.kt$MailSyncWorkerManager$1000L + MagicNumber:MailSyncWorkerManager.kt$MailSyncWorkerManager$60L + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0x0000FF + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0x00FF00 + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0x00FFFF + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0x00FFFFFF + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0xFF0000 + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0xFF00FF + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0xFFFF00 + MagicNumber:NotificationLightDecoder.kt$NotificationLightDecoder$0xFFFFFF + MagicNumber:ServerSettingsSerializer.kt$ServerSettingsAdapter$3 + MagicNumber:ServerSettingsSerializer.kt$ServerSettingsAdapter$4 + MagicNumber:ServerSettingsSerializer.kt$ServerSettingsAdapter$5 + MagicNumber:ServerSettingsSerializer.kt$ServerSettingsAdapter$6 + MagicNumber:ServerSettingsSerializer.kt$ServerSettingsAdapter$7 + MagicNumber:SettingsExporter.kt$SettingsExporter$3 + MagicNumber:TimberLogger.kt$TimberLogger$26 + MayBeConst:SummaryNotificationDataCreatorTest.kt$private val TIMESTAMP = 0L + MemberNameEqualsClassName:HtmlModification.kt$HtmlModification.Replace$abstract fun replace(textToHtml: TextToHtml) + NestedBlockDepth:HttpUriParser.kt$HttpUriParser$private fun tryMatchIpv6Address(text: CharSequence, startPos: Int): Int + NestedBlockDepth:SettingsExporter.kt$SettingsExporter$private fun writeIdentity( serializer: XmlSerializer, accountUuid: String, identity: String, prefs: Map<String, Any>, ) + NestedBlockDepth:SingleMessageNotificationCreator.kt$SingleMessageNotificationCreator$private fun NotificationBuilder.setWearActions(notificationData: SingleNotificationData) + ReturnCount:AutocryptDraftStateHeaderParser.kt$AutocryptDraftStateHeaderParser$fun parseAutocryptDraftStateHeader(headerValue: String): AutocryptDraftStateHeader? + ReturnCount:EmailSection.kt$EmailSection$override fun subSequence(startIndex: Int, endIndex: Int): CharSequence + ReturnCount:HtmlSignatureRemover.kt$HtmlSignatureRemover.StripSignatureFilter$override fun head(node: Node, depth: Int): HeadFilterDecision + ReturnCount:HtmlSignatureRemover.kt$HtmlSignatureRemover.StripSignatureFilter$private fun Node.findPrecedingLineBreak(): Node? + ReturnCount:HtmlSignatureRemover.kt$HtmlSignatureRemover.StripSignatureFilter$private fun Node.isFollowedByLineBreak(): Boolean + ReturnCount:HttpUriParser.kt$HttpUriParser$override fun parseUri(text: CharSequence, startPos: Int): UriMatch? + ReturnCount:HttpUriParser.kt$HttpUriParser$private fun tryMatchAuthority(text: CharSequence, startPos: Int): Int + ReturnCount:HttpUriParser.kt$HttpUriParser$private fun tryMatchDomainName(text: CharSequence, startPos: Int): Int + ReturnCount:HttpUriParser.kt$HttpUriParser$private fun tryMatchIpv4Address(text: CharSequence, startPos: Int, portAllowed: Boolean): Int + ReturnCount:HttpUriParser.kt$HttpUriParser$private fun tryMatchIpv6Address(text: CharSequence, startPos: Int): Int + ReturnCount:ListUnsubscribeHelper.kt$ListUnsubscribeHelper$fun getPreferredListUnsubscribeUri(message: Message): UnsubscribeUri? + ReturnCount:ListUnsubscribeHelper.kt$ListUnsubscribeHelper$private fun extractUri(headerValue: String?): Uri? + ReturnCount:MailSyncWorker.kt$MailSyncWorker$override fun doWork(): Result + ReturnCount:MessageHelper.kt$MessageHelper.Companion$@JvmStatic fun toFriendly( address: Address, contactRepository: ContactRepository?, showCorrespondentNames: Boolean, changeContactNameColor: Boolean, contactNameColor: Int, ): CharSequence + ReturnCount:MessageRepository.kt$MessageRepository$private fun List<Header>.parseDate(headerName: String): MessageDate + ReturnCount:PreviewTextExtractor.kt$PreviewTextExtractor$private fun extractUnquotedText(text: String): String + ReturnCount:TextPartFinder.kt$TextPartFinder$private fun findTextPartInMultipart(multipart: Multipart): Part? + ReturnCount:TextPartFinder.kt$TextPartFinder$private fun findTextPartInMultipartAlternative(multipart: Multipart): Part? + SwallowedException:MessageRepository.kt$MessageRepository$e: Exception + SwallowedException:QuoteDateFormatter.kt$QuoteDateFormatter$e: Exception + SwallowedException:SettingsExporter.kt$SettingsExporter$e: InvalidSettingValueException + ThrowingExceptionsWithoutMessageOrCause:TimberLogger.kt$TimberLogger$Throwable() + TooGenericExceptionCaught:BootCompleteReceiver.kt$BootCompleteManager$e: Exception + TooGenericExceptionCaught:K9.kt$K9$e: Exception + TooGenericExceptionCaught:MessageRepository.kt$MessageRepository$e: Exception + TooGenericExceptionCaught:PushServiceManager.kt$PushServiceManager$e: Exception + TooGenericExceptionCaught:QuoteDateFormatter.kt$QuoteDateFormatter$e: Exception + TooGenericExceptionCaught:SettingsExporter.kt$SettingsExporter$e: Exception + TooManyFunctions:CoreResourceProvider.kt$CoreResourceProvider + TooManyFunctions:HttpUriParser.kt$HttpUriParser : UriParser + TooManyFunctions:K9.kt$K9 : KoinComponent + TooManyFunctions:K9BackendFolder.kt$K9BackendFolder : BackendFolder + TooManyFunctions:MessageListCache.kt$MessageListCache + TooManyFunctions:NotificationActionCreator.kt$NotificationActionCreator + TooManyFunctions:NotificationChannelManager.kt$NotificationChannelManager + TooManyFunctions:NotificationController.kt$NotificationController + TooManyFunctions:NotificationResourceProvider.kt$NotificationResourceProvider + TooManyFunctions:NotifierMessageStore.kt$NotifierMessageStore : MessageStore + TooManyFunctions:Preferences.kt$Preferences : AccountManager + TooManyFunctions:PushController.kt$PushController + TooManyFunctions:SettingsExporter.kt$SettingsExporter + TooManyFunctions:SingleMessageNotificationCreator.kt$SingleMessageNotificationCreator + TooManyFunctions:SummaryNotificationCreator.kt$SummaryNotificationCreator + TooManyFunctions:TimberLogger.kt$TimberLogger : Logger + UnusedParameter:Contacts.kt$Contacts$addresses: Array<Address?>? + UnusedPrivateProperty:HttpUriParser.kt$HttpUriParser$i + UseCheckOrError:OutboxStateRepository.kt$OutboxStateRepository$throw IllegalStateException("No outbox_state entry for message with id $messageId") + + diff --git a/config/detekt/detekt-baseline-legacy-storage.xml b/config/detekt/detekt-baseline-legacy-storage.xml new file mode 100644 index 0000000..d286324 --- /dev/null +++ b/config/detekt/detekt-baseline-legacy-storage.xml @@ -0,0 +1,96 @@ + + + + + ComplexCondition:StorageMigrationTo19.kt$StorageMigrationTo19$incomingServerSettings["type"] == "imap" && incomingServerSettings["host"] in setOf("imap.gmail.com", "imap.googlemail.com") && incomingServerSettings["authenticationType"] != "XOAUTH2" || outgoingServerSettings["host"] in setOf("smtp.gmail.com", "smtp.googlemail.com") && outgoingServerSettings["authenticationType"] != "XOAUTH2" + CyclomaticComplexMethod:MessagePartDatabaseHelpers.kt$MessagePartEntry$override fun equals(other: Any?): Boolean + CyclomaticComplexMethod:MessagePartDatabaseHelpers.kt$MessagePartEntry$override fun hashCode(): Int + CyclomaticComplexMethod:Migrations.kt$Migrations$@JvmStatic fun upgradeDatabase(db: SQLiteDatabase, migrationsHelper: MigrationsHelper) + LongMethod:CopyMessageOperations.kt$CopyMessageOperations$private fun readMessageToContentValues(database: SQLiteDatabase, messageId: Long): ContentValues + LongMethod:CopyMessageOperationsTest.kt$CopyMessageOperationsTest$@Test fun `copy message into an existing thread`() + LongMethod:CopyMessageOperationsTest.kt$CopyMessageOperationsTest$@Test fun `copy message that is part of a thread`() + LongMethod:MoveMessageOperationsTest.kt$MoveMessageOperationsTest$@Test fun `move message when destination has empty message entry`() + LongMethod:RetrieveMessageListOperations.kt$RetrieveMessageListOperations$fun <T> getThreadedMessages( selection: String, selectionArgs: Array<String>, sortOrder: String, mapper: MessageMapper<out T?>, ): List<T> + LongMethod:SaveMessageOperationsTest.kt$SaveMessageOperationsTest$@Test fun `save local message`() + LongMethod:SaveMessageOperationsTest.kt$SaveMessageOperationsTest$@Test fun `save message with multipart body`() + LongMethod:SaveMessageOperationsTest.kt$SaveMessageOperationsTest$@Test fun `save message with text_plain body`() + LongMethod:ThreadMessageOperationsTest.kt$ThreadMessageOperationsTest$@Test fun `merge two existing threads`() + LongParameterList:CopyMessageOperations.kt$DatabaseMessagePart$( val id: Long, val type: Int, val root: Long, val parent: Long, val seq: Int, val mimeType: String?, val decodedBodySize: Long?, val displayName: String?, val header: ByteArray?, val encoding: String?, val charset: String?, val dataLocation: Int, val data: ByteArray?, val preamble: ByteArray?, val epilogue: ByteArray?, val boundary: String?, val contentId: String?, val serverExtra: String?, ) + MagicNumber:ChunkedDatabaseOperations.kt$1000 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$10 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$11 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$12 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$13 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$14 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$15 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$16 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$17 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$18 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$19 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$20 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$21 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$3 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$4 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$5 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$6 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$7 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$8 + MagicNumber:CopyMessageOperations.kt$CopyMessageOperations$9 + MagicNumber:MigrationTo76.kt$MigrationTo76$25 + MagicNumber:MigrationTo84.kt$MigrationTo84$3 + MagicNumber:MigrationTo84.kt$MigrationTo84$4 + MagicNumber:MigrationTo84.kt$MigrationTo84$5 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$10 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$11 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$12 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$13 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$14 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$15 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$3 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$4 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$5 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$6 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$7 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$8 + MagicNumber:RetrieveFolderOperations.kt$CursorFolderAccessor$9 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$10 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$11 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$12 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$13 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$14 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$15 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$16 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$17 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$3 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$4 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$5 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$6 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$7 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$8 + MagicNumber:RetrieveMessageListOperations.kt$CursorMessageAccessor$9 + MagicNumber:RetrieveMessageOperations.kt$RetrieveMessageOperations$3 + MagicNumber:RetrieveMessageOperations.kt$RetrieveMessageOperations$4 + MagicNumber:RetrieveMessageOperations.kt$RetrieveMessageOperations$5 + MagicNumber:StorageMigrationTo11.kt$StorageMigrationTo11$3 + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0x0000FF + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0x00FF00 + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0x00FFFF + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0x00FFFFFF + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0xFF0000 + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0xFF00FF + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0xFFFF00 + MagicNumber:StorageMigrationTo17.kt$StorageMigrationTo17$0xFFFFFF + MagicNumber:ThreadMessageOperations.kt$ThreadMessageOperations$3 + MaxLineLength:RetrieveMessageListOperationsTest.kt$RetrieveMessageListOperationsTest$fun + ReturnCount:SaveMessageOperationsTest.kt$SaveMessageOperationsTest$private fun Message.getDownloadState(): MessageDownloadState + ReturnCount:StorageMigrationTo19.kt$StorageMigrationTo19$private fun markIfGmailAccount(accountUuid: String) + SwallowedException:SaveMessageOperations.kt$SaveMessageOperations$e: IOException + TooManyFunctions:DeleteMessageOperations.kt$DeleteMessageOperations + TooManyFunctions:K9MessageStore.kt$K9MessageStore : MessageStore + TooManyFunctions:MigrationTo73.kt$MigrationTo73 + TooManyFunctions:RetrieveFolderOperations.kt$RetrieveFolderOperations + TooManyFunctions:SaveMessageOperations.kt$SaveMessageOperations + TooManyFunctions:ThreadMessageOperations.kt$ThreadMessageOperations + TooManyFunctions:UpdateFolderOperations.kt$UpdateFolderOperations + + diff --git a/config/detekt/detekt-baseline-legacy-ui-base.xml b/config/detekt/detekt-baseline-legacy-ui-base.xml new file mode 100644 index 0000000..2909850 --- /dev/null +++ b/config/detekt/detekt-baseline-legacy-ui-base.xml @@ -0,0 +1,13 @@ + + + + + MagicNumber:AppLanguageManager.kt$AppLanguageManager$3 + MagicNumber:AppLanguageManager.kt$AppLanguageManager$5 + MagicNumber:ConfigurationExtensions.kt$24 + MagicNumber:K9Activity.kt$K9Activity$31 + SpreadOperator:ConfigurationExtensions.kt$(*locales.toTypedArray()) + TooGenericExceptionCaught:LiveDataLoader.kt$e: Exception + TooGenericExceptionCaught:SystemLocaleManager.kt$SystemLocaleManager$e: Exception + + diff --git a/config/detekt/detekt-baseline-legacy-ui-legacy.xml b/config/detekt/detekt-baseline-legacy-ui-legacy.xml new file mode 100644 index 0000000..c2eb617 --- /dev/null +++ b/config/detekt/detekt-baseline-legacy-ui-legacy.xml @@ -0,0 +1,152 @@ + + + + + CastToNullableType:MessageList.kt$MessageList$as + CastToNullableType:MessageViewFragment.kt$MessageViewFragment$as + CastToNullableType:VibrationPreference.kt$VibrationPreference$as + CyclomaticComplexMethod:AccountSettingsDataStore.kt$AccountSettingsDataStore$override fun getBoolean(key: String, defValue: Boolean): Boolean + CyclomaticComplexMethod:AccountSettingsDataStore.kt$AccountSettingsDataStore$override fun getString(key: String, defValue: String?): String? + CyclomaticComplexMethod:AccountSettingsDataStore.kt$AccountSettingsDataStore$override fun putBoolean(key: String, value: Boolean) + CyclomaticComplexMethod:AccountSettingsDataStore.kt$AccountSettingsDataStore$override fun putString(key: String, value: String?) + CyclomaticComplexMethod:GeneralSettingsDataStore.kt$GeneralSettingsDataStore$override fun getBoolean(key: String, defValue: Boolean): Boolean + CyclomaticComplexMethod:GeneralSettingsDataStore.kt$GeneralSettingsDataStore$override fun getString(key: String, defValue: String?): String? + CyclomaticComplexMethod:GeneralSettingsDataStore.kt$GeneralSettingsDataStore$override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? + CyclomaticComplexMethod:GeneralSettingsDataStore.kt$GeneralSettingsDataStore$override fun putBoolean(key: String, value: Boolean) + CyclomaticComplexMethod:GeneralSettingsDataStore.kt$GeneralSettingsDataStore$override fun putString(key: String, value: String?) + CyclomaticComplexMethod:MessageList.kt$MessageList$private fun decodeExtrasToLaunchData(intent: Intent): LaunchData + CyclomaticComplexMethod:MessageList.kt$MessageList$private fun onCustomKeyDown(event: KeyEvent): Boolean + CyclomaticComplexMethod:MessageListAdapter.kt$MessageListAdapter$private fun bindMessageViewHolder(holder: MessageViewHolder, messageListItem: MessageListItem) + CyclomaticComplexMethod:MessageListFragment.kt$MessageListFragment$override fun onOptionsItemSelected(item: MenuItem): Boolean + CyclomaticComplexMethod:MessageViewFragment.kt$MessageViewFragment$override fun onOptionsItemSelected(item: MenuItem): Boolean + CyclomaticComplexMethod:SortTypeToastProvider.kt$SortTypeToastProvider$fun getToast(sortType: SortType, ascending: Boolean): Int + CyclomaticComplexMethod:TouchInterceptView.kt$TouchInterceptView$private fun handleOnInterceptTouchEvent(event: MotionEvent) + ForbiddenComment:MessageDetailsFragment.kt$MessageDetailsFragment$// FIXME: Replace this with a mechanism that survives process death + ForbiddenComment:MessageListFragment.kt$MessageListFragment$// FIXME: Don't save the changes in the UI thread + ImplicitDefaultLocale:MessageListAdapter.kt$MessageListAdapter$String.format("%d", threadCount) + LargeClass:MessageList.kt$MessageList : K9ActivityMessageListFragmentListenerMessageViewFragmentListenerMessageViewContainerListenerOnBackStackChangedListenerOnSwitchCompleteListener + LargeClass:MessageListFragment.kt$MessageListFragment : FragmentConfirmationDialogFragmentListenerMessageListItemActionListener + LongMethod:MessageContainerView.kt$MessageContainerView$private fun createImageMenu(menu: ContextMenu, imageUrl: String?) + LongMethod:MessageList.kt$MessageList$private fun decodeExtrasToLaunchData(intent: Intent): LaunchData + LongMethod:MessageList.kt$MessageList$private fun onCustomKeyDown(event: KeyEvent): Boolean + LongMethod:MessageListAdapter.kt$MessageListAdapter$private fun bindMessageViewHolder(holder: MessageViewHolder, messageListItem: MessageListItem) + LongMethod:MessageViewFragment.kt$MessageViewFragment$override fun onPrepareOptionsMenu(menu: Menu) + LongMethod:RecipientNamesView.kt$RecipientNamesView$override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) + LongParameterList:MessageDetailsViewModel.kt$MessageDetailsViewModel$( private val resources: Resources, private val messageRepository: MessageRepository, private val folderRepository: FolderRepository, private val contactSettingsProvider: ContactSettingsProvider, private val contactRepository: ContactRepository, private val contactPermissionResolver: ContactPermissionResolver, private val clipboardManager: ClipboardManager, private val accountManager: AccountManager, private val participantFormatter: MessageDetailsParticipantFormatter, private val folderNameFormatter: FolderNameFormatter, ) + MagicNumber:AccountItem.kt$AccountItem$200L + MagicNumber:AutocryptSetupTransferLiveEvent.kt$AutocryptSetupTransferLiveEvent$2000 + MagicNumber:ContactLetterBitmapCreator.kt$ContactLetterBitmapCreator$0.65f + MagicNumber:ContactLetterBitmapCreator.kt$ContactLetterBitmapCreator$255 + MagicNumber:MessageContainerView.kt$MessageContainerView$29 + MagicNumber:MessageListItemAnimator.kt$MessageListItemAnimator$120 + MagicNumber:MessageListItemMapper.kt$MessageListItemMapper$52 + MagicNumber:RecipientLayoutCreator.kt$RecipientLayoutCreator$10 + MagicNumber:RecipientMvpView.kt$RecipientMvpView$100.0f + MagicNumber:RecipientMvpView.kt$RecipientMvpView$15 + MagicNumber:RecipientNamesView.kt$RecipientNamesView$8 + MagicNumber:ReplyToView.kt$ReplyToView$15 + MagicNumber:SettingsViewModel.kt$SettingsViewModel$500 + MagicNumber:SimpleHighlightView.kt$SimpleHighlightView$0xFFFFFF + MagicNumber:SimpleHighlightView.kt$SimpleHighlightView$100 + MagicNumber:SimpleHighlightView.kt$SimpleHighlightView$128 + MagicNumber:SimpleHighlightView.kt$SimpleHighlightView$80 + MagicNumber:SizeFormatter.kt$SizeFormatter$1000L + MagicNumber:SizeFormatter.kt$SizeFormatter$1000f + MagicNumber:SizeFormatter.kt$SizeFormatter$1_000_000L + MagicNumber:SizeFormatter.kt$SizeFormatter$999_950L + MagicNumber:SizeFormatter.kt$SizeFormatter$999_950_000L + MemberNameEqualsClassName:ReplyToView.kt$ReplyToView$private val replyToView: RecipientSelectView = activity.findViewById(R.id.reply_to) + NestedBlockDepth:MessageList.kt$MessageList$override fun onBackPressed() + NestedBlockDepth:MessageList.kt$MessageList$override fun onOptionsItemSelected(item: MenuItem): Boolean + NestedBlockDepth:MessageList.kt$MessageList$private fun decodeExtrasToLaunchData(intent: Intent): LaunchData + ReturnCount:ChooseFolderActivity.kt$ChooseFolderActivity$private fun decodeArguments(savedInstanceState: Bundle?): Boolean + ReturnCount:EditIdentity.kt$EditIdentity$override fun onOptionsItemSelected(item: MenuItem): Boolean + ReturnCount:MessageList.kt$MessageList$private fun decodeExtrasToLaunchData(intent: Intent): LaunchData + ReturnCount:MessageList.kt$MessageList$private fun onCustomKeyDown(event: KeyEvent): Boolean + ReturnCount:MessageList.kt$MessageList$public override fun onCreate(savedInstanceState: Bundle?) + ReturnCount:MessageList.kt$MessageList$public override fun onNewIntent(intent: Intent) + ReturnCount:MessageListAdapter.kt$MessageListAdapter$private fun buildStatusHolder(forwarded: Boolean, answered: Boolean): Drawable? + ReturnCount:MessageListAdapter.kt$MessageListAdapter$private fun calculateSelectionCount(): Int + ReturnCount:MessageListFragment.kt$MessageListFragment$override fun onFooterClicked() + ReturnCount:MessageListFragment.kt$MessageListFragment$private fun checkCopyOrMovePossible(messages: List<MessageReference>, operation: FolderOperation): Boolean + ReturnCount:MessageListFragment.kt$MessageListFragment$private fun isSpecialFolder(specialFolderId: Long?): Boolean + ReturnCount:MessageListFragment.kt$MessageListFragment$private fun rememberSortOverride(messageReference: MessageReference?) + ReturnCount:MessageListFragment.kt$MessageListFragment$private fun scrollToMessage(messageReference: MessageReference) + ReturnCount:MessageListSwipeCallback.kt$MessageListSwipeCallback$override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: ViewHolder): Int + ReturnCount:MessageTopView.kt$MessageTopView$private fun shouldShowPicturesFromSender(showPicturesSetting: ShowPictures, message: Message): Boolean + ReturnCount:MessageViewFragment.kt$MessageViewFragment$override fun onOptionsItemSelected(item: MenuItem): Boolean + ReturnCount:RecipientLayoutCreator.kt$RecipientLayoutCreator$fun createRecipientLayout( recipientNames: List<CharSequence>, totalNumberOfRecipients: Int, availableWidth: Int, ): RecipientLayoutData + ReturnCount:RecipientPresenter.kt$RecipientPresenter$fun checkRecipientsOkForSending(): Boolean + ReturnCount:RecipientPresenter.kt$RecipientPresenter$private fun toggleEncryptionState(showGotIt: Boolean) + ReturnCount:ShareIntentBuilder.kt$ShareIntentBuilder$private fun extractBodyText(message: LocalMessage): String + ReturnCount:TouchInterceptView.kt$TouchInterceptView$private fun handleOnInterceptTouchEvent(event: MotionEvent) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*Address.parse(trustId)) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*bccAddresses) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*ccAddresses) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*mailTo.bcc) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*mailTo.cc) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*mailTo.to) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*message.getRecipients(RecipientType.BCC)) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*message.getRecipients(RecipientType.CC)) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*message.getRecipients(RecipientType.TO)) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*replyToAddresses.cc) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*replyToAddresses.to) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter$(*toAddresses) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter.<no name provided>$(*recipientArray) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter.<no name provided>$(context, account.openPgpProvider, *alwaysBccAddresses) + SpreadOperator:RecipientPresenter.kt$RecipientPresenter.<no name provided>$(recipientType, *recipientArray) + SpreadOperator:ReplyToView.kt$ReplyToView$(*recipients) + SwallowedException:AboutFragment.kt$e: ActivityNotFoundException + SwallowedException:ContactPictureLoader.kt$ContactPictureLoader$e: Exception + SwallowedException:MessageContainerView.kt$MessageContainerView$e: ActivityNotFoundException + SwallowedException:MessageDetailsViewModel.kt$MessageDetailsViewModel$e: Exception + SwallowedException:MessageListFragment.kt$MessageListFragment$e: ClassCastException + SwallowedException:MessageListFragment.kt$MessageListFragment$e: MessagingException + SwallowedException:MessageViewContainerFragment.kt$MessageViewContainerFragment$e: ClassCastException + SwallowedException:MessageViewFragment.kt$MessageViewFragment$e: ActivityNotFoundException + SwallowedException:MessageViewFragment.kt$MessageViewFragment$e: ClassCastException + SwallowedException:PushInfoFragment.kt$PushInfoFragment$e: ActivityNotFoundException + SwallowedException:SettingsListFragment.kt$SettingsListFragment$e: ActivityNotFoundException + TooGenericExceptionCaught:AccountRemover.kt$AccountRemover$e: Exception + TooGenericExceptionCaught:AutocryptSetupTransferLiveEvent.kt$AutocryptSetupTransferLiveEvent$e: Exception + TooGenericExceptionCaught:ContactPhotoLoader.kt$ContactPhotoLoader$e: Exception + TooGenericExceptionCaught:ContactPictureLoader.kt$ContactPictureLoader$e: Exception + TooGenericExceptionCaught:GeneralSettingsViewModel.kt$GeneralSettingsViewModel$e: Exception + TooGenericExceptionCaught:MessageDetailsViewModel.kt$MessageDetailsViewModel$e: Exception + TooGenericExceptionCaught:MessageListFragment.kt$MessageListFragment$e: Exception + TooGenericExceptionCaught:MessageListLoader.kt$MessageListLoader$e: Exception + TooGenericExceptionCaught:SettingsExportViewModel.kt$SettingsExportViewModel$e: Exception + TooGenericExceptionThrown:AccountSettingsActivity.kt$AccountSettingsActivity$throw RuntimeException("getSupportActionBar() == null") + TooGenericExceptionThrown:GeneralSettingsActivity.kt$GeneralSettingsActivity$throw RuntimeException("getSupportActionBar() == null") + TooGenericExceptionThrown:MessageListFragment.kt$MessageListFragment$throw RuntimeException("Called showDialog(int) with unknown dialog id.") + TooGenericExceptionThrown:MessageViewFragment.kt$MessageViewFragment$throw RuntimeException("Called showDialog(int) with unknown dialog id.") + TooManyFunctions:AccountSettingsDataStore.kt$AccountSettingsDataStore : PreferenceDataStore + TooManyFunctions:AccountSettingsFragment.kt$AccountSettingsFragment : PreferenceFragmentCompatConfirmationDialogFragmentListener + TooManyFunctions:AutocryptKeyTransferActivity.kt$AutocryptKeyTransferActivity : K9Activity + TooManyFunctions:ChooseFolderActivity.kt$ChooseFolderActivity : K9Activity + TooManyFunctions:FolderSettingsFragment.kt$FolderSettingsFragment : PreferenceFragmentCompatConfirmationDialogFragmentListener + TooManyFunctions:GeneralSettingsDataStore.kt$GeneralSettingsDataStore : PreferenceDataStore + TooManyFunctions:GeneralSettingsFragment.kt$GeneralSettingsFragment : PreferenceFragmentCompat + TooManyFunctions:ManageFoldersFragment.kt$ManageFoldersFragment : Fragment + TooManyFunctions:MessageContainerView.kt$MessageContainerView : LinearLayoutOnCreateContextMenuListenerKoinComponent + TooManyFunctions:MessageDetailsFragment.kt$MessageDetailsFragment : ToolbarBottomSheetDialogFragment + TooManyFunctions:MessageList.kt$MessageList : K9ActivityMessageListFragmentListenerMessageViewFragmentListenerMessageViewContainerListenerOnBackStackChangedListenerOnSwitchCompleteListener + TooManyFunctions:MessageList.kt$MessageList$Companion : KoinComponent + TooManyFunctions:MessageListAdapter.kt$MessageListAdapter : Adapter + TooManyFunctions:MessageListFragment.kt$MessageListFragment : FragmentConfirmationDialogFragmentListenerMessageListItemActionListener + TooManyFunctions:MessageListFragment.kt$MessageListFragment$MessageListActivityListener : SimpleMessagingListener + TooManyFunctions:MessageListSwipeCallback.kt$MessageListSwipeCallback : Callback + TooManyFunctions:MessageTopView.kt$MessageTopView : LinearLayoutKoinComponent + TooManyFunctions:MessageViewContainerFragment.kt$MessageViewContainerFragment : Fragment + TooManyFunctions:MessageViewFragment.kt$MessageViewFragment : FragmentConfirmationDialogFragmentListenerAttachmentViewCallback + TooManyFunctions:RecipientMvpView.kt$RecipientMvpView : OnFocusChangeListenerOnClickListener + TooManyFunctions:RecipientPresenter.kt$RecipientPresenter + TooManyFunctions:ReplyToView.kt$ReplyToView + TooManyFunctions:SettingsExportViewModel.kt$SettingsExportViewModel : ViewModel + TooManyFunctions:SettingsListFragment.kt$SettingsListFragment : FragmentItemTouchCallback + TooManyFunctions:SimpleHighlightView.kt$SimpleHighlightView : FrameLayout + TooManyFunctions:VibrationDialogFragment.kt$VibrationDialogFragment$VibrationPatternAdapter : BaseAdapter + UnusedParameter:MessageViewFragment.kt$MessageViewFragment$requestKey: String + UseCheckOrError:ThemeExtensions.kt$throw IllegalStateException("Couldn't resolve attribute ($attrId)") + + diff --git a/config/detekt/detekt-baseline-legacy-ui-message-list-widget.xml b/config/detekt/detekt-baseline-legacy-ui-message-list-widget.xml new file mode 100644 index 0000000..439a337 --- /dev/null +++ b/config/detekt/detekt-baseline-legacy-ui-message-list-widget.xml @@ -0,0 +1,11 @@ + + + + + ImplicitDefaultLocale:MessageListItemMapper.kt$MessageListItemMapper$String.format("%d %s", dayOfMonth, month) + MagicNumber:MessageListItemMapper.kt$MessageListItemMapper$52 + TooGenericExceptionCaught:MessageListLoader.kt$MessageListLoader$e: Exception + TooGenericExceptionCaught:MessageListWidgetManager.kt$MessageListWidgetManager$e: RuntimeException + TooManyFunctions:MessageListRemoteViewFactory.kt$MessageListRemoteViewFactory : RemoteViewsFactoryKoinComponent + + diff --git a/config/detekt/detekt-baseline-mail-common.xml b/config/detekt/detekt-baseline-mail-common.xml new file mode 100644 index 0000000..2f5d4fe --- /dev/null +++ b/config/detekt/detekt-baseline-mail-common.xml @@ -0,0 +1,81 @@ + + + + + CyclomaticComplexMethod:FlowedMessageUtils.kt$FlowedMessageUtils$@JvmStatic fun deflow(text: String, delSp: Boolean): String + CyclomaticComplexMethod:MimeParameterDecoder.kt$MimeParameterDecoder$private fun convertToParameterSection(parameterName: String, parameterValue: ParameterValue): ParameterSection? + LongMethod:MessageTest.kt$MessageTest$@Test fun toBodyPart() + LongMethod:MessageTest.kt$MessageTest$@Test fun writeTo_withNestedMessage() + MagicNumber:BoundaryGenerator.kt$BoundaryGenerator$36 + MagicNumber:BoundaryGenerator.kt$BoundaryGenerator$4 + MagicNumber:DecoderUtil.kt$DecoderUtil$0x1B + MagicNumber:DecoderUtil.kt$DecoderUtil$0x28 + MagicNumber:DecoderUtil.kt$DecoderUtil$0x42 + MagicNumber:Hex.kt$Hex$4 + MagicNumber:MimeExtensions.kt$126 + MagicNumber:MimeExtensions.kt$33 + MagicNumber:MimeExtensions.kt$39 + MagicNumber:MimeExtensions.kt$42 + MagicNumber:MimeExtensions.kt$90 + MagicNumber:MimeExtensions.kt$91 + MagicNumber:MimeExtensions.kt$93 + MagicNumber:MimeExtensions.kt$94 + MagicNumber:MimeHeader.kt$MimeHeader$1024 + MagicNumber:MimeHeaderChecker.kt$UnstructuredHeaderChecker$1000 + MagicNumber:MimeHeaderChecker.kt$UnstructuredHeaderChecker$998 + MagicNumber:MimeHeaderParser.kt$MimeHeaderParser$10 + MagicNumber:MimeHeaderParser.kt$MimeHeaderParser$4 + MagicNumber:MimeParameterDecoder.kt$MimeParameterDecoder$3 + MagicNumber:MimeParameterEncoder.kt$MimeParameterEncoder$126 + MagicNumber:MimeParameterEncoder.kt$MimeParameterEncoder$3 + MagicNumber:MimeParameterEncoder.kt$MimeParameterEncoder$33 + MagicNumber:MimeParameterEncoder.kt$MimeParameterEncoder$35 + MagicNumber:MimeParameterEncoder.kt$MimeParameterEncoder$91 + MagicNumber:MimeParameterEncoder.kt$MimeParameterEncoder$93 + MagicNumber:Utf8.kt$0x010000 + MagicNumber:Utf8.kt$0x10000 + MagicNumber:Utf8.kt$0x3f + MagicNumber:Utf8.kt$0x80 + MagicNumber:Utf8.kt$0x800 + MagicNumber:Utf8.kt$0xc0 + MagicNumber:Utf8.kt$0xd800 + MagicNumber:Utf8.kt$0xdbff + MagicNumber:Utf8.kt$0xdc00 + MagicNumber:Utf8.kt$0xdfff + MagicNumber:Utf8.kt$0xe0 + MagicNumber:Utf8.kt$0xe000 + MagicNumber:Utf8.kt$0xf0 + MagicNumber:Utf8.kt$10 + MagicNumber:Utf8.kt$12 + MagicNumber:Utf8.kt$18 + MagicNumber:Utf8.kt$3 + MagicNumber:Utf8.kt$4 + MagicNumber:Utf8.kt$6 + ReturnCount:DecoderUtil.kt$DecoderUtil$@JvmStatic fun decodeEncodedWords(body: String, message: Message?): String + ReturnCount:DecoderUtil.kt$DecoderUtil$private fun extractEncodedWord(body: String, begin: Int, end: Int, message: Message?): EncodedWord? + ReturnCount:FormatFlowedHelper.kt$FormatFlowedHelper$@JvmStatic fun checkFormatFlowed(contentTypeHeaderValue: String?): FormatFlowedResult + ReturnCount:MimeParameterDecoder.kt$MimeParameterDecoder$private fun areParameterSectionsValid(parameterSections: MutableList<ParameterSection>): Boolean + ReturnCount:MimeParameterDecoder.kt$MimeParameterDecoder$private fun convertToParameterSection(parameterName: String, parameterValue: ParameterValue): ParameterSection? + SwallowedException:DecoderUtil.kt$DecoderUtil$e: IOException + SwallowedException:DecoderUtil.kt$DecoderUtil$e: MessagingException + SwallowedException:LocalKeyStore.kt$LocalKeyStore$e: FileNotFoundException + SwallowedException:LocalKeyStore.kt$LocalKeyStore$e: KeyStoreException + SwallowedException:MimeParameterDecoder.kt$MimeParameterDecoder$e: IllegalCharsetNameException + SwallowedException:MimeParameterDecoder.kt$MimeParameterDecoder$e: MimeHeaderParserException + SwallowedException:MimeType.kt$MimeType.Companion$e: IllegalArgumentException + ThrowsCount:LocalKeyStore.kt$LocalKeyStore$private fun writeCertificateFile() + ThrowsCount:MimeHeaderChecker.kt$UnstructuredHeaderChecker$fun checkHeaderValue() + TooGenericExceptionCaught:LocalKeyStore.kt$LocalKeyStore$e: Exception + TooManyFunctions:Logger.kt$Logger + TooManyFunctions:MessageIdParser.kt$MessageIdParser + TooManyFunctions:MimeExtensions.kt$com.fsck.k9.mail.internet.MimeExtensions.kt + TooManyFunctions:MimeHeader.kt$MimeHeader + TooManyFunctions:MimeHeaderChecker.kt$UnstructuredHeaderChecker + TooManyFunctions:MimeHeaderParser.kt$MimeHeaderParser + TooManyFunctions:MimeParameterDecoder.kt$MimeParameterDecoder + TooManyFunctions:MimeParameterEncoder.kt$MimeParameterEncoder + TooManyFunctions:NoOpLogger.kt$NoOpLogger : Logger + TooManyFunctions:Timber.kt$Timber + UseRequire:MimeParameterEncoder.kt$MimeParameterEncoder$throw IllegalArgumentException("Unsupported character: $c") + + diff --git a/config/detekt/detekt-baseline-mail-protocols-imap.xml b/config/detekt/detekt-baseline-mail-protocols-imap.xml new file mode 100644 index 0000000..22c0553 --- /dev/null +++ b/config/detekt/detekt-baseline-mail-protocols-imap.xml @@ -0,0 +1,61 @@ + + + + + CastToNullableType:RealImapFolder.kt$RealImapFolder$as + CyclomaticComplexMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) override fun appendMessages(messages: List<Message>): Map<String, String>? + CyclomaticComplexMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) override fun fetch( messages: List<ImapMessage>, fetchProfile: FetchProfile, listener: FetchListener?, maxDownloadSize: Int, ) + CyclomaticComplexMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) private fun handleFetchResponse(message: ImapMessage, fetchList: ImapList): Any? + CyclomaticComplexMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) private fun parseBodyStructure(bs: ImapList, part: Part, id: String) + CyclomaticComplexMethod:RealImapStore.kt$RealImapStore$@Throws(IOException::class, MessagingException::class) private fun listFolders(connection: ImapConnection, subscribedOnly: Boolean): List<FolderListItem> + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format( "%s 1:* %sFLAGS.SILENT (%s)", Commands.UID_STORE, if (value) "+" else "-", combinedFlags, ) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("%s %s", openCommand, escapedFolderName) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("%sFLAGS.SILENT (%s)", if (value) "+" else "-", combinedFlags) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format(";\r\n %s=%s", paramName, encodedValue) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("BODY.PEEK[%s]", partId) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("CREATE %s", escapedFolderName) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("STATUS %s (UIDVALIDITY)", escapedFolderName) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("UID FETCH %s (%s)", commaSeparatedUids, spaceSeparatedFetchFields) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("UID FETCH %s (UID %s)", message.uid, fetch) + ImplicitDefaultLocale:RealImapFolder.kt$RealImapFolder$String.format("UID SEARCH HEADER MESSAGE-ID %s", ImapUtility.encodeString(messageId)) + LargeClass:RealImapConnection.kt$RealImapConnection : ImapConnection + LargeClass:RealImapConnectionTest.kt$RealImapConnectionTest + LargeClass:RealImapFolder.kt$RealImapFolder : ImapFolder + LargeClass:RealImapFolderTest.kt$RealImapFolderTest + LongMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) override fun appendMessages(messages: List<Message>): Map<String, String>? + LongMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) override fun fetch( messages: List<ImapMessage>, fetchProfile: FetchProfile, listener: FetchListener?, maxDownloadSize: Int, ) + LongMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) private fun handleFetchResponse(message: ImapMessage, fetchList: ImapList): Any? + LongMethod:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) private fun parseBodyStructure(bs: ImapList, part: Part, id: String) + LongMethod:RealImapFolderIdler.kt$RealImapFolderIdler$private fun ImapFolder.idle(): IdleResult + LoopWithTooManyJumpStatements:RealImapStore.kt$RealImapStore$for + MagicNumber:RealImapConnection.kt$RealImapConnection$4 + MagicNumber:RealImapFolder.kt$RealImapFolder$3 + MagicNumber:RealImapFolder.kt$RealImapFolder$5 + MagicNumber:RealImapFolder.kt$RealImapFolder$6 + MagicNumber:RealImapFolder.kt$RealImapFolder$8 + MagicNumber:RealImapFolder.kt$RealImapFolder$9 + MagicNumber:UidValidityResponse.kt$UidValidityResponse.Companion$0xFFFFFFFFL + MaxLineLength:RealImapFolder.kt$RealImapFolder$// [MESSAGE, RFC822, [NAME, Fwd: [#HTR-517941]: update plans at 1am Friday - Memory allocation - displayware.eml], NIL, NIL, 7BIT, 5974, NIL, [INLINE, [FILENAME*0, Fwd: [#HTR-517941]: update plans at 1am Friday - Memory all, FILENAME*1, ocation - displayware.eml]], NIL] + MaxLineLength:RealImapStoreTest.kt$RealImapStoreTest$fun + NestedBlockDepth:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) override fun appendMessages(messages: List<Message>): Map<String, String>? + NestedBlockDepth:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) override fun fetch( messages: List<ImapMessage>, fetchProfile: FetchProfile, listener: FetchListener?, maxDownloadSize: Int, ) + NestedBlockDepth:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) override fun fetchPart( message: ImapMessage, part: Part, bodyFactory: BodyFactory, maxDownloadSize: Int, ) + NestedBlockDepth:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) private fun handleFetchResponse(message: ImapMessage, fetchList: ImapList): Any? + NestedBlockDepth:RealImapFolder.kt$RealImapFolder$@Throws(MessagingException::class) private fun parseBodyStructure(bs: ImapList, part: Part, id: String) + ReturnCount:RealImapFolder.kt$RealImapFolder$@Throws(IOException::class, MessagingException::class) override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean + ReturnCount:RealImapFolderIdler.kt$RealImapFolderIdler$private fun ImapFolder.idle(): IdleResult + ReturnCount:RealImapStore.kt$RealImapStore$private fun removePrefixFromFolderName(folderName: String): String? + ReturnCount:UidValidityResponse.kt$UidValidityResponse.Companion$@JvmStatic fun parse(response: ImapResponse): UidValidityResponse? + SwallowedException:RealImapFolder.kt$RealImapFolder$e: NegativeImapResponseException + SwallowedException:RealImapStore.kt$RealImapStore$e: CharacterCodingException + SwallowedException:RealImapStore.kt$RealImapStore$ioe: IOException + ThrowingExceptionsWithoutMessageOrCause:RealImapConnection.kt$RealImapConnection$Exception() + ThrowsCount:RealImapConnection.kt$RealImapConnection$private fun authenticate(): List<ImapResponse> + TooGenericExceptionCaught:RealImapConnection.kt$RealImapConnection$e: Exception + TooManyFunctions:ImapConnection.kt$ImapConnection + TooManyFunctions:ImapFolder.kt$ImapFolder + TooManyFunctions:RealImapConnection.kt$RealImapConnection : ImapConnection + TooManyFunctions:RealImapFolder.kt$RealImapFolder : ImapFolder + TooManyFunctions:RealImapStore.kt$RealImapStore : ImapStoreImapConnectionManagerInternalImapStore + + diff --git a/config/detekt/detekt-baseline-mail-protocols-pop3.xml b/config/detekt/detekt-baseline-mail-protocols-pop3.xml new file mode 100644 index 0000000..0530866 --- /dev/null +++ b/config/detekt/detekt-baseline-mail-protocols-pop3.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/config/detekt/detekt-baseline-mail-protocols-smtp.xml b/config/detekt/detekt-baseline-mail-protocols-smtp.xml new file mode 100644 index 0000000..8266acb --- /dev/null +++ b/config/detekt/detekt-baseline-mail-protocols-smtp.xml @@ -0,0 +1,38 @@ + + + + + CyclomaticComplexMethod:SmtpTransport.kt$SmtpTransport$@VisibleForTesting @Throws(MessagingException::class) internal fun open() + ForbiddenComment:SmtpTransportTest.kt$SmtpTransportTest$// FIXME: Make sure connection was closed + ImplicitDefaultLocale:SmtpTransport.kt$SmtpTransport$String.format("MAIL FROM:<%s> BODY=8BITMIME", fromAddress) + ImplicitDefaultLocale:SmtpTransport.kt$SmtpTransport$String.format("MAIL FROM:<%s>", fromAddress) + ImplicitDefaultLocale:SmtpTransport.kt$SmtpTransport$String.format("RCPT TO:<%s>", address) + LargeClass:SmtpTransportTest.kt$SmtpTransportTest + LongMethod:SmtpResponseParser.kt$SmtpResponseParser$fun readHelloResponse(): SmtpHelloResponse + LongMethod:SmtpTransport.kt$SmtpTransport$@Throws(MessagingException::class) fun sendMessage(message: Message) + LongMethod:SmtpTransport.kt$SmtpTransport$@VisibleForTesting @Throws(MessagingException::class) internal fun open() + MagicNumber:NegativeSmtpReplyException.kt$500 + MagicNumber:NegativeSmtpReplyException.kt$599 + MagicNumber:SmtpResponse.kt$SmtpResponse$400 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$10 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$100 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$126 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$250 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$32 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$33 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$4 + MagicNumber:SmtpResponseParser.kt$SmtpResponseParser$5 + MagicNumber:SmtpTransport.kt$SmtpTransport$1000 + MagicNumber:SmtpTransport.kt$SmtpTransport$1024 + MagicNumber:StatusCodeClass.kt$StatusCodeClass.PERMANENT_FAILURE$5 + MagicNumber:StatusCodeClass.kt$StatusCodeClass.PERSISTENT_TRANSIENT_FAILURE$4 + ReturnCount:SmtpResponseParser.kt$SmtpResponseParser$fun readHelloResponse(): SmtpHelloResponse + SwallowedException:SmtpTransport.kt$SmtpTransport$e: NegativeSmtpReplyException + ThrowingExceptionsWithoutMessageOrCause:SmtpTransport.kt$SmtpTransport$RuntimeException() + ThrowsCount:SmtpTransport.kt$SmtpTransport$@Throws(MessagingException::class) fun sendMessage(message: Message) + ThrowsCount:SmtpTransport.kt$SmtpTransport$@VisibleForTesting @Throws(MessagingException::class) internal fun open() + TooGenericExceptionCaught:SmtpTransport.kt$SmtpTransport$e: Exception + TooManyFunctions:SmtpResponseParser.kt$SmtpResponseParser + TooManyFunctions:SmtpTransport.kt$SmtpTransport + + diff --git a/config/detekt/detekt-baseline-mail-testing.xml b/config/detekt/detekt-baseline-mail-testing.xml new file mode 100644 index 0000000..6b898aa --- /dev/null +++ b/config/detekt/detekt-baseline-mail-testing.xml @@ -0,0 +1,9 @@ + + + + + MagicNumber:MessageBuilderDsl.kt$PartBuilder$1024 + MagicNumber:MessageBuilderDsl.kt$PartBuilder$20 + TooManyFunctions:SystemOutLogger.kt$SystemOutLogger : Logger + + diff --git a/config/detekt/detekt-baseline-ui-utils-ToolbarBottomSheet.xml b/config/detekt/detekt-baseline-ui-utils-ToolbarBottomSheet.xml new file mode 100644 index 0000000..6f5c0c5 --- /dev/null +++ b/config/detekt/detekt-baseline-ui-utils-ToolbarBottomSheet.xml @@ -0,0 +1,8 @@ + + + + + CastToNullableType:ToolbarBottomSheetDialogFragment.kt$ToolbarBottomSheetDialogFragment$as + TooManyFunctions:ToolbarBottomSheetDialog.kt$ToolbarBottomSheetDialog : AppCompatDialog + + diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..0f25af2 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,850 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + UndocumentedPublicFunction: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + threshold: 15 + ComplexCondition: + active: true + threshold: 5 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + ignoreAnnotated: + - 'Preview' + - 'PreviewLightDark' + - 'PreviewLightDarkLandscape' + LongParameterList: + active: true + functionThreshold: 8 + constructorThreshold: 8 + ignoreDefaultParameters: true + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: true + threshold: 3 + ignoreArgumentsMatchingNames: true + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + thresholdInFiles: 11 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: true + SuspendFunWithCoroutineScopeReceiver: + active: true + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + ignoreAnnotated: + - 'Composable' + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: true + CastToNullableType: + active: true + Deprecation: + active: true + DontDowncastCollectionTypes: + active: true + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: true + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: true + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: true + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: true + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: false + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreAnnotated: + - 'Preview' + - 'PreviewLightDark' + - 'PreviewLightDarkLandscape' + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: true + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: true + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: true + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: true + UnnecessaryBracesAroundTrailingLambda: + active: true + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: true + UnusedImports: + active: false + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + ignoreAnnotated: + - 'Preview' + - 'PreviewLightDark' + - 'PreviewLightDarkLandscape' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' + ignoreAnnotated: + - 'Preview' + - 'PreviewLightDark' + - 'PreviewLightDarkLandscape' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: true + UseIfEmptyOrIfBlank: + active: true + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: true + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: true + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' + +Compose: + CompositionLocalAllowlist: + active: true + allowedCompositionLocals: [ + LocalColors, + LocalElevations, + LocalImages, + LocalShapes, + LocalSizes, + LocalSpacings, + LocalThemeColorScheme, + LocalThemeElevations, + LocalThemeImages, + LocalThemeShapes, + LocalThemeSizes, + LocalThemeSpacings, + LocalThemeTypography, + LocalDateTimeConfiguration + ] + ContentEmitterReturningValues: + active: true + ModifierComposable: + active: true + ModifierMissing: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + MutableParams: + active: true + ComposableNaming: + active: true + ComposableParamOrder: + active: true + PreviewAnnotationNaming: + active: true + PreviewPublic: + active: true + RememberMissing: + active: true + UnstableCollections: + active: true + ViewModelForwarding: + active: true + ViewModelInjection: + active: true diff --git a/config/fluidattacks/config.yaml b/config/fluidattacks/config.yaml new file mode 100644 index 0000000..f0530d9 --- /dev/null +++ b/config/fluidattacks/config.yaml @@ -0,0 +1,28 @@ +# Taken from: https://appdefensealliance.dev/casa/tier-2/ast-guide/static-scan +# as that is out of date, updated to the latest version of the scanner, see below +# https://help.fluidattacks.com/portal/en/kb/articles/validate-casa-tier-2-requirements +namespace: thunderbird-android +working_dir: /repo +language: EN +output: + file_path: /repo/fluidscan-results.sarif + format: SARIF +#apk: +# include: +# - ./app-k9mail/build/outputs/apk/foss/release/app-k9mail-foss-release.apk +# - ./app-k9mail/build/outputs/apk/full/release/app-k9mail-full-release.apk +# - ./app-thunderbird/build/outputs/apk/foss/release/app-thunderbird-full-release.apk +# - ./app-thunderbird/build/outputs/apk/foss/release/app-thunderbird-full-release.apk +sast: + include: + - . + exclude: + - glob(**/build/**) + - glob(**/test/**) +sca: + include: + - . + exclude: + - glob(**/test/**) +file_size_limit: false +tracing_opt_out: true diff --git a/config/lint/android-lint-baseline.xml b/config/lint/android-lint-baseline.xml new file mode 100644 index 0000000..56ec64c --- /dev/null +++ b/config/lint/android-lint-baseline.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/config/lint/lint.xml b/config/lint/lint.xml new file mode 100644 index 0000000..b17c618 --- /dev/null +++ b/config/lint/lint.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/android/account/build.gradle.kts b/core/android/account/build.gradle.kts new file mode 100644 index 0000000..89adc8e --- /dev/null +++ b/core/android/account/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id(ThunderbirdPlugins.Library.android) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "net.thunderbird.core.android.account" +} + +dependencies { + api(projects.feature.account.api) + api(projects.feature.account.storage.api) + + implementation(projects.feature.notification.api) + api(projects.mail.common) + + implementation(projects.core.common) + implementation(projects.core.preference.api) + + implementation(projects.feature.mail.account.api) + implementation(projects.feature.mail.folder.api) + + implementation(projects.backend.api) + + testImplementation(projects.feature.account.fake) +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountDefaultsProvider.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountDefaultsProvider.kt new file mode 100644 index 0000000..839fce2 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountDefaultsProvider.kt @@ -0,0 +1,60 @@ +package net.thunderbird.core.android.account + +import net.thunderbird.core.preference.storage.Storage + +interface AccountDefaultsProvider { + /** + * Apply default values to the account. + * + * This method should only be called when creating a new account. + */ + fun applyDefaults(account: LegacyAccount) + + /** + * Apply any additional default values to the account. + * + * This method should be called when updating an existing account. + */ + fun applyOverwrites(account: LegacyAccount, storage: Storage) + + companion object { + const val DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE = 131072 + + @JvmStatic + val DEFAULT_MESSAGE_FORMAT = MessageFormat.HTML + + const val DEFAULT_MESSAGE_FORMAT_AUTO = false + const val DEFAULT_MESSAGE_READ_RECEIPT = false + const val DEFAULT_QUOTED_TEXT_SHOWN = true + const val DEFAULT_QUOTE_PREFIX = ">" + + @JvmStatic + val DEFAULT_QUOTE_STYLE = QuoteStyle.PREFIX + + const val DEFAULT_REMOTE_SEARCH_NUM_RESULTS = 25 + const val DEFAULT_REPLY_AFTER_QUOTE = false + const val DEFAULT_RINGTONE_URI = "content://settings/system/notification_sound" + const val DEFAULT_SORT_ASCENDING = false + + @JvmStatic + val DEFAULT_SORT_TYPE = SortType.SORT_DATE + + const val DEFAULT_STRIP_SIGNATURE = true + + const val DEFAULT_SYNC_INTERVAL = 60 + + /** + * Specifies how many messages will be shown in a folder by default. This number is set + * on each new folder and can be incremented with "Load more messages..." by the + * VISIBLE_LIMIT_INCREMENT + */ + const val DEFAULT_VISIBLE_LIMIT = 25 + + const val NO_OPENPGP_KEY: Long = 0 + + const val UNASSIGNED_ACCOUNT_NUMBER = -1 + + // TODO : Remove once storage is migrated to new format + const val COLOR = 0x0099CC + } +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountManager.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountManager.kt new file mode 100644 index 0000000..a8e0c19 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountManager.kt @@ -0,0 +1,24 @@ +package net.thunderbird.core.android.account + +import kotlinx.coroutines.flow.Flow +import net.thunderbird.feature.mail.account.api.AccountManager + +@Deprecated( + message = "Use net.thunderbird.feature.mail.account.api.AccountManager instead", + replaceWith = ReplaceWith( + expression = "AccountManager", + "net.thunderbird.feature.mail.account.api.AccountManager", + "app.k9mail.legacy.account.LegacyAccount", + ), +) +interface AccountManager : AccountManager { + override fun getAccounts(): List + override fun getAccountsFlow(): Flow> + override fun getAccount(accountUuid: String): LegacyAccount? + override fun getAccountFlow(accountUuid: String): Flow + fun addAccountRemovedListener(listener: AccountRemovedListener) + override fun moveAccount(account: LegacyAccount, newPosition: Int) + fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) + fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) + override fun saveAccount(account: LegacyAccount) +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountRemovedListener.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountRemovedListener.kt new file mode 100644 index 0000000..308c36c --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountRemovedListener.kt @@ -0,0 +1,5 @@ +package net.thunderbird.core.android.account + +fun interface AccountRemovedListener { + fun onAccountRemoved(account: LegacyAccount) +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountsChangeListener.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountsChangeListener.kt new file mode 100644 index 0000000..f1776a7 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/AccountsChangeListener.kt @@ -0,0 +1,5 @@ +package net.thunderbird.core.android.account + +fun interface AccountsChangeListener { + fun onAccountsChanged() +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/DeletePolicy.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/DeletePolicy.kt new file mode 100644 index 0000000..40593cc --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/DeletePolicy.kt @@ -0,0 +1,16 @@ +package net.thunderbird.core.android.account + +@Suppress("MagicNumber") +enum class DeletePolicy(@JvmField val setting: Int) { + NEVER(0), + SEVEN_DAYS(1), + ON_DELETE(2), + MARK_AS_READ(3), + ; + + companion object { + fun fromInt(initialSetting: Int): DeletePolicy { + return entries.find { it.setting == initialSetting } ?: error("DeletePolicy $initialSetting unknown") + } + } +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Expunge.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Expunge.kt new file mode 100644 index 0000000..4fa1e54 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Expunge.kt @@ -0,0 +1,16 @@ +package net.thunderbird.core.android.account + +import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy + +enum class Expunge { + EXPUNGE_IMMEDIATELY, + EXPUNGE_MANUALLY, + EXPUNGE_ON_POLL, + ; + + fun toBackendExpungePolicy(): ExpungePolicy = when (this) { + EXPUNGE_IMMEDIATELY -> ExpungePolicy.IMMEDIATELY + EXPUNGE_MANUALLY -> ExpungePolicy.MANUALLY + EXPUNGE_ON_POLL -> ExpungePolicy.ON_POLL + } +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/FolderMode.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/FolderMode.kt new file mode 100644 index 0000000..90d3168 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/FolderMode.kt @@ -0,0 +1,9 @@ +package net.thunderbird.core.android.account + +enum class FolderMode { + NONE, + ALL, + FIRST_CLASS, + FIRST_AND_SECOND_CLASS, + NOT_SECOND_CLASS, +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Identity.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Identity.kt new file mode 100644 index 0000000..b35fe50 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/Identity.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.android.account + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Identity( + val description: String? = null, + val name: String? = null, + val email: String? = null, + val signature: String? = null, + val signatureUse: Boolean = false, + val replyTo: String? = null, +) : Parcelable { + // TODO remove when callers are converted to Kotlin + fun withName(name: String?) = copy(name = name) + fun withSignature(signature: String?) = copy(signature = signature) + fun withSignatureUse(signatureUse: Boolean) = copy(signatureUse = signatureUse) + fun withEmail(email: String?) = copy(email = email) +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccount.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccount.kt new file mode 100644 index 0000000..d915f89 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccount.kt @@ -0,0 +1,636 @@ +package net.thunderbird.core.android.account + +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.ServerSettings +import java.util.Calendar +import java.util.Date +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY +import net.thunderbird.feature.account.Account +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto +import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection +import net.thunderbird.feature.notification.NotificationSettings + +// This needs to be in sync with K9.DEFAULT_VISIBLE_LIMIT +const val DEFAULT_VISIBLE_LIMIT = 25 + +/** + * Account stores all of the settings for a single account defined by the user. Each account is defined by a UUID. + */ +@Deprecated("Use LegacyAccountWrapper instead") +@Suppress("TooManyFunctions") +open class LegacyAccount( + override val uuid: String, + val isSensitiveDebugLoggingEnabled: () -> Boolean = { false }, +) : Account, BaseAccount { + + // [Account] + override val id: AccountId = AccountIdFactory.of(uuid) + + // [BaseAccount] + @get:Synchronized + @set:Synchronized + override var name: String? = null + set(value) { + field = value?.takeIf { it.isNotEmpty() } + } + + @get:Synchronized + @set:Synchronized + override var email: String + get() = identities[0].email!! + set(email) { + val newIdentity = identities[0].copy(email = email) + identities[0] = newIdentity + } + + // [AccountProfile] + val displayName: String + get() = name ?: email + + @get:Synchronized + @set:Synchronized + var chipColor = 0 + + @get:Synchronized + @set:Synchronized + var avatar: AvatarDto = AvatarDto( + avatarType = AvatarTypeDto.MONOGRAM, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = null, + ) + + // Uncategorized + @get:Synchronized + @set:Synchronized + var deletePolicy = DeletePolicy.NEVER + + @get:Synchronized + @set:Synchronized + private var internalIncomingServerSettings: ServerSettings? = null + + @get:Synchronized + @set:Synchronized + private var internalOutgoingServerSettings: ServerSettings? = null + + var incomingServerSettings: ServerSettings + get() = internalIncomingServerSettings ?: error("Incoming server settings not set yet") + set(value) { + internalIncomingServerSettings = value + } + + var outgoingServerSettings: ServerSettings + get() = internalOutgoingServerSettings ?: error("Outgoing server settings not set yet") + set(value) { + internalOutgoingServerSettings = value + } + + @get:Synchronized + @set:Synchronized + var oAuthState: String? = null + + @get:Synchronized + @set:Synchronized + var alwaysBcc: String? = null + + /** + * -1 for never. + */ + @get:Synchronized + @set:Synchronized + var automaticCheckIntervalMinutes = 0 + + @get:Synchronized + @set:Synchronized + var displayCount = 0 + set(value) { + if (field != value) { + field = value.takeIf { it != -1 } ?: DEFAULT_VISIBLE_LIMIT + isChangedVisibleLimits = true + } + } + + @get:Synchronized + @set:Synchronized + var isNotifyNewMail = false + + @get:Synchronized + @set:Synchronized + var folderNotifyNewMailMode = FolderMode.ALL + + @get:Synchronized + @set:Synchronized + var isNotifySelfNewMail = false + + @get:Synchronized + @set:Synchronized + var isNotifyContactsMailOnly = false + + @get:Synchronized + @set:Synchronized + var isIgnoreChatMessages = false + + @get:Synchronized + @set:Synchronized + var legacyInboxFolder: String? = null + + @get:Synchronized + @set:Synchronized + var importedDraftsFolder: String? = null + + @get:Synchronized + @set:Synchronized + var importedSentFolder: String? = null + + @get:Synchronized + @set:Synchronized + var importedTrashFolder: String? = null + + @get:Synchronized + @set:Synchronized + var importedArchiveFolder: String? = null + + @get:Synchronized + @set:Synchronized + var importedSpamFolder: String? = null + + @get:Synchronized + @set:Synchronized + var inboxFolderId: Long? = null + + @get:Synchronized + @set:Synchronized + var draftsFolderId: Long? = null + + @get:Synchronized + @set:Synchronized + var sentFolderId: Long? = null + + @get:Synchronized + @set:Synchronized + var trashFolderId: Long? = null + + @get:Synchronized + @set:Synchronized + var archiveFolderId: Long? = null + + @get:Synchronized + @set:Synchronized + var spamFolderId: Long? = null + + @get:Synchronized + var draftsFolderSelection = SpecialFolderSelection.AUTOMATIC + + @get:Synchronized + var sentFolderSelection = SpecialFolderSelection.AUTOMATIC + + @get:Synchronized + var trashFolderSelection = SpecialFolderSelection.AUTOMATIC + + @get:Synchronized + var archiveFolderSelection = SpecialFolderSelection.AUTOMATIC + + @get:Synchronized + var spamFolderSelection = SpecialFolderSelection.AUTOMATIC + + @get:Synchronized + @set:Synchronized + var importedAutoExpandFolder: String? = null + + @get:Synchronized + @set:Synchronized + var autoExpandFolderId: Long? = null + + @get:Synchronized + @set:Synchronized + var folderDisplayMode = FolderMode.NOT_SECOND_CLASS + + @get:Synchronized + @set:Synchronized + var folderSyncMode = FolderMode.FIRST_CLASS + + @get:Synchronized + @set:Synchronized + var folderPushMode = FolderMode.NONE + + @get:Synchronized + @set:Synchronized + var accountNumber = 0 + + @get:Synchronized + @set:Synchronized + var isNotifySync = false + + @get:Synchronized + @set:Synchronized + var sortType: SortType = SortType.SORT_DATE + + var sortAscending: MutableMap = mutableMapOf() + + @get:Synchronized + @set:Synchronized + var showPictures = ShowPictures.NEVER + + @get:Synchronized + @set:Synchronized + var isSignatureBeforeQuotedText = false + + @get:Synchronized + @set:Synchronized + var expungePolicy = Expunge.EXPUNGE_IMMEDIATELY + + @get:Synchronized + @set:Synchronized + var maxPushFolders = 0 + + @get:Synchronized + @set:Synchronized + var idleRefreshMinutes = 0 + + @get:JvmName("useCompression") + @get:Synchronized + @set:Synchronized + var useCompression = true + + @get:Synchronized + @set:Synchronized + var isSendClientInfoEnabled = true + + @get:Synchronized + @set:Synchronized + var isSubscribedFoldersOnly = false + + @get:Synchronized + @set:Synchronized + var maximumPolledMessageAge = 0 + + @get:Synchronized + @set:Synchronized + var maximumAutoDownloadMessageSize = 0 + + @get:Synchronized + @set:Synchronized + var messageFormat = MessageFormat.HTML + + @get:Synchronized + @set:Synchronized + var isMessageFormatAuto = false + + @get:Synchronized + @set:Synchronized + var isMessageReadReceipt = false + + @get:Synchronized + @set:Synchronized + var quoteStyle = QuoteStyle.PREFIX + + @get:Synchronized + @set:Synchronized + var quotePrefix: String? = null + + @get:Synchronized + @set:Synchronized + var isDefaultQuotedTextShown = false + + @get:Synchronized + @set:Synchronized + var isReplyAfterQuote = false + + @get:Synchronized + @set:Synchronized + var isStripSignature = false + + @get:Synchronized + @set:Synchronized + var isSyncRemoteDeletions = false + + @get:Synchronized + @set:Synchronized + var openPgpProvider: String? = null + set(value) { + field = value?.takeIf { it.isNotEmpty() } + } + + @get:Synchronized + @set:Synchronized + var openPgpKey: Long = 0 + + @get:Synchronized + @set:Synchronized + var autocryptPreferEncryptMutual = false + + @get:Synchronized + @set:Synchronized + var isOpenPgpHideSignOnly = false + + @get:Synchronized + @set:Synchronized + var isOpenPgpEncryptSubject = false + + @get:Synchronized + @set:Synchronized + var isOpenPgpEncryptAllDrafts = false + + @get:Synchronized + @set:Synchronized + var isMarkMessageAsReadOnView = false + + @get:Synchronized + @set:Synchronized + var isMarkMessageAsReadOnDelete = false + + @get:Synchronized + @set:Synchronized + var isAlwaysShowCcBcc = false + + // Temporarily disabled + @get:Synchronized + @set:Synchronized + var isRemoteSearchFullText = false + get() = false + + @get:Synchronized + @set:Synchronized + var remoteSearchNumResults = 0 + set(value) { + field = value.coerceAtLeast(0) + } + + @get:Synchronized + @set:Synchronized + var isUploadSentMessages = false + + @get:Synchronized + @set:Synchronized + var lastSyncTime: Long = 0 + + @get:Synchronized + @set:Synchronized + var lastFolderListRefreshTime: Long = 0 + + @get:Synchronized + var isFinishedSetup = false + + @get:Synchronized + @set:Synchronized + var messagesNotificationChannelVersion = 0 + + @get:Synchronized + @set:Synchronized + var isChangedVisibleLimits = false + + /** + * Database ID of the folder that was last selected for a copy or move operation. + * + * Note: For now this value isn't persisted. So it will be reset when K-9 Mail is restarted. + */ + @get:Synchronized + var lastSelectedFolderId: Long? = null + + @get:Synchronized + @set:Synchronized + var identities: MutableList = mutableListOf() + set(value) { + field = value.toMutableList() + } + + @get:Synchronized + var notificationSettings = NotificationSettings() + + @get:Synchronized + @set:Synchronized + var senderName: String? + get() = identities[0].name + set(name) { + val newIdentity = identities[0].withName(name) + identities[0] = newIdentity + } + + @get:Synchronized + @set:Synchronized + var signatureUse: Boolean + get() = identities[0].signatureUse + set(signatureUse) { + val newIdentity = identities[0].withSignatureUse(signatureUse) + identities[0] = newIdentity + } + + @get:Synchronized + @set:Synchronized + var signature: String? + get() = identities[0].signature + set(signature) { + val newIdentity = identities[0].withSignature(signature) + identities[0] = newIdentity + } + + @get:JvmName("shouldMigrateToOAuth") + @get:Synchronized + @set:Synchronized + var shouldMigrateToOAuth = false + + @get:JvmName("folderPathDelimiter") + @get:Synchronized + @set:Synchronized + var folderPathDelimiter: FolderPathDelimiter = "/" + + /** + * @param automaticCheckIntervalMinutes or -1 for never. + */ + @Synchronized + fun updateAutomaticCheckIntervalMinutes(automaticCheckIntervalMinutes: Int): Boolean { + val oldInterval = this.automaticCheckIntervalMinutes + this.automaticCheckIntervalMinutes = automaticCheckIntervalMinutes + + return oldInterval != automaticCheckIntervalMinutes + } + + @Synchronized + fun setDraftsFolderId(folderId: Long?, selection: SpecialFolderSelection) { + draftsFolderId = folderId + draftsFolderSelection = selection + } + + @Deprecated("use AccountWrapper instead") + @Synchronized + fun hasDraftsFolder(): Boolean { + return draftsFolderId != null + } + + @Synchronized + fun setSentFolderId(folderId: Long?, selection: SpecialFolderSelection) { + sentFolderId = folderId + sentFolderSelection = selection + } + + @Deprecated("use AccountWrapper instead") + @Synchronized + fun hasSentFolder(): Boolean { + return sentFolderId != null + } + + @Synchronized + fun setTrashFolderId(folderId: Long?, selection: SpecialFolderSelection) { + trashFolderId = folderId + trashFolderSelection = selection + } + + @Deprecated("use AccountWrapper instead") + @Synchronized + fun hasTrashFolder(): Boolean { + return trashFolderId != null + } + + @Synchronized + fun setArchiveFolderId(folderId: Long?, selection: SpecialFolderSelection) { + archiveFolderId = folderId + archiveFolderSelection = selection + } + + @Deprecated("use AccountWrapper instead") + @Synchronized + fun hasArchiveFolder(): Boolean { + return archiveFolderId != null + } + + @Synchronized + fun setSpamFolderId(folderId: Long?, selection: SpecialFolderSelection) { + spamFolderId = folderId + spamFolderSelection = selection + } + + @Deprecated("use AccountWrapper instead") + @Synchronized + fun hasSpamFolder(): Boolean { + return spamFolderId != null + } + + @Synchronized + fun isSortAscending(sortType: SortType): Boolean { + return sortAscending.getOrPut(sortType) { sortType.isDefaultAscending } + } + + @Synchronized + fun setSortAscending(sortType: SortType, sortAscending: Boolean) { + this.sortAscending[sortType] = sortAscending + } + + @Synchronized + fun replaceIdentities(identities: List) { + this.identities = identities.toMutableList() + } + + @Synchronized + fun getIdentity(index: Int): Identity { + if (index !in identities.indices) error("Identity with index $index not found") + + return identities[index] + } + + fun isAnIdentity(addresses: Array
?): Boolean { + if (addresses == null) return false + + return addresses.any { address -> isAnIdentity(address) } + } + + fun isAnIdentity(address: Address): Boolean { + return findIdentity(address) != null + } + + @Synchronized + fun findIdentity(address: Address): Identity? { + return identities.find { identity -> + identity.email.equals(address.address, ignoreCase = true) + } + } + + @Suppress("MagicNumber") + val earliestPollDate: Date? + get() { + val age = maximumPolledMessageAge.takeIf { it >= 0 } ?: return null + + val now = Calendar.getInstance() + now[Calendar.HOUR_OF_DAY] = 0 + now[Calendar.MINUTE] = 0 + now[Calendar.SECOND] = 0 + now[Calendar.MILLISECOND] = 0 + + if (age < 28) { + now.add(Calendar.DATE, age * -1) + } else { + when (age) { + 28 -> now.add(Calendar.MONTH, -1) + 56 -> now.add(Calendar.MONTH, -2) + 84 -> now.add(Calendar.MONTH, -3) + 168 -> now.add(Calendar.MONTH, -6) + 365 -> now.add(Calendar.YEAR, -1) + } + } + + return now.time + } + + @Deprecated("use AccountWrapper instead") + val isOpenPgpProviderConfigured: Boolean + get() = openPgpProvider != null + + @Deprecated("use AccountWrapper instead") + @Synchronized + fun hasOpenPgpKey(): Boolean { + return openPgpKey != NO_OPENPGP_KEY + } + + @Synchronized + fun setLastSelectedFolderId(folderId: Long) { + lastSelectedFolderId = folderId + } + + @Synchronized + fun resetChangeMarkers() { + isChangedVisibleLimits = false + } + + @Synchronized + fun markSetupFinished() { + isFinishedSetup = true + } + + @Synchronized + fun updateNotificationSettings( + block: ( + oldNotificationSettings: NotificationSettings, + ) -> NotificationSettings, + ) { + notificationSettings = block(notificationSettings) + } + + override fun toString(): String { + return if (isSensitiveDebugLoggingEnabled()) displayName else uuid + } + + override fun equals(other: Any?): Boolean { + return if (other is LegacyAccount) { + other.uuid == uuid + } else { + super.equals(other) + } + } + + override fun hashCode(): Int { + return uuid.hashCode() + } + + companion object { + /** + * Fixed name of outbox - not actually displayed. + */ + const val OUTBOX_NAME = "Outbox" + + const val INTERVAL_MINUTES_NEVER = -1 + } +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapper.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapper.kt new file mode 100644 index 0000000..11ee6e6 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapper.kt @@ -0,0 +1,152 @@ +package net.thunderbird.core.android.account + +import com.fsck.k9.mail.ServerSettings +import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY +import net.thunderbird.core.common.mail.Protocols +import net.thunderbird.feature.account.Account +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.storage.profile.ProfileDto +import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection +import net.thunderbird.feature.notification.NotificationSettings + +/** + * A immutable wrapper for the [LegacyAccount] class. + * + * This class is used to store the account data in a way that is safe to pass between threads. + * + * Use LegacyAccountWrapper.from(account) to create a wrapper from an account. + * Use LegacyAccountWrapper.to(wrapper) to create an account from a wrapper. + */ +data class LegacyAccountWrapper( + val isSensitiveDebugLoggingEnabled: () -> Boolean = { false }, + + // [Account] + override val id: AccountId, + + // [BaseAccount] + override val name: String?, + override val email: String, + + // [AccountProfile] + val profile: ProfileDto, + + // Uncategorized + val deletePolicy: DeletePolicy = DeletePolicy.NEVER, + val incomingServerSettings: ServerSettings, + val outgoingServerSettings: ServerSettings, + val oAuthState: String? = null, + val alwaysBcc: String? = null, + val automaticCheckIntervalMinutes: Int = 0, + val displayCount: Int = 0, + val isNotifyNewMail: Boolean = false, + val folderNotifyNewMailMode: FolderMode = FolderMode.ALL, + val isNotifySelfNewMail: Boolean = false, + val isNotifyContactsMailOnly: Boolean = false, + val isIgnoreChatMessages: Boolean = false, + val legacyInboxFolder: String? = null, + val importedDraftsFolder: String? = null, + val importedSentFolder: String? = null, + val importedTrashFolder: String? = null, + val importedArchiveFolder: String? = null, + val importedSpamFolder: String? = null, + val inboxFolderId: Long? = null, + val draftsFolderId: Long? = null, + val sentFolderId: Long? = null, + val trashFolderId: Long? = null, + val archiveFolderId: Long? = null, + val spamFolderId: Long? = null, + val draftsFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC, + val sentFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC, + val trashFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC, + val archiveFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC, + val spamFolderSelection: SpecialFolderSelection = SpecialFolderSelection.AUTOMATIC, + val importedAutoExpandFolder: String? = null, + val autoExpandFolderId: Long? = null, + val folderDisplayMode: FolderMode = FolderMode.NOT_SECOND_CLASS, + val folderSyncMode: FolderMode = FolderMode.FIRST_CLASS, + val folderPushMode: FolderMode = FolderMode.NONE, + val accountNumber: Int = 0, + val isNotifySync: Boolean = false, + val sortType: SortType = SortType.SORT_DATE, + val sortAscending: Map = emptyMap(), + val showPictures: ShowPictures = ShowPictures.NEVER, + val isSignatureBeforeQuotedText: Boolean = false, + val expungePolicy: Expunge = Expunge.EXPUNGE_IMMEDIATELY, + val maxPushFolders: Int = 0, + val idleRefreshMinutes: Int = 0, + val useCompression: Boolean = true, + val isSendClientInfoEnabled: Boolean = true, + val isSubscribedFoldersOnly: Boolean = false, + val maximumPolledMessageAge: Int = 0, + val maximumAutoDownloadMessageSize: Int = 0, + val messageFormat: MessageFormat = MessageFormat.HTML, + val isMessageFormatAuto: Boolean = false, + val isMessageReadReceipt: Boolean = false, + val quoteStyle: QuoteStyle = QuoteStyle.PREFIX, + val quotePrefix: String? = null, + val isDefaultQuotedTextShown: Boolean = false, + val isReplyAfterQuote: Boolean = false, + val isStripSignature: Boolean = false, + val isSyncRemoteDeletions: Boolean = false, + val openPgpProvider: String? = null, + val openPgpKey: Long = 0, + val autocryptPreferEncryptMutual: Boolean = false, + val isOpenPgpHideSignOnly: Boolean = false, + val isOpenPgpEncryptSubject: Boolean = false, + val isOpenPgpEncryptAllDrafts: Boolean = false, + val isMarkMessageAsReadOnView: Boolean = false, + val isMarkMessageAsReadOnDelete: Boolean = false, + val isAlwaysShowCcBcc: Boolean = false, + val isRemoteSearchFullText: Boolean = false, + val remoteSearchNumResults: Int = 0, + val isUploadSentMessages: Boolean = false, + val lastSyncTime: Long = 0, + val lastFolderListRefreshTime: Long = 0, + val isFinishedSetup: Boolean = false, + val messagesNotificationChannelVersion: Int = 0, + val isChangedVisibleLimits: Boolean = false, + val lastSelectedFolderId: Long? = null, + val identities: List, + val notificationSettings: NotificationSettings = NotificationSettings(), + val senderName: String? = identities[0].name, + val signatureUse: Boolean = identities[0].signatureUse, + val signature: String? = identities[0].signature, + val shouldMigrateToOAuth: Boolean = false, + val folderPathDelimiter: FolderPathDelimiter = "/", +) : Account, BaseAccount { + + override val uuid: String = id.asRaw() + + fun hasDraftsFolder(): Boolean { + return draftsFolderId != null + } + + fun hasSentFolder(): Boolean { + return sentFolderId != null + } + + fun hasTrashFolder(): Boolean { + return trashFolderId != null + } + + fun hasArchiveFolder(): Boolean { + return archiveFolderId != null + } + + fun hasSpamFolder(): Boolean { + return spamFolderId != null + } + + fun isOpenPgpProviderConfigured(): Boolean { + return openPgpProvider != null + } + + fun hasOpenPgpKey(): Boolean { + return openPgpKey != NO_OPENPGP_KEY + } + + fun isIncomingServerPop3(): Boolean = + incomingServerSettings.type == Protocols.POP3 +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapperManager.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapperManager.kt new file mode 100644 index 0000000..9ff10d5 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapperManager.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.android.account + +import kotlinx.coroutines.flow.Flow +import net.thunderbird.feature.account.AccountId + +interface LegacyAccountWrapperManager { + fun getAll(): Flow> + + fun getById(id: AccountId): Flow + + suspend fun update(account: LegacyAccountWrapper) +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/MessageFormat.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/MessageFormat.kt new file mode 100644 index 0000000..a3cd561 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/MessageFormat.kt @@ -0,0 +1,7 @@ +package net.thunderbird.core.android.account + +enum class MessageFormat { + TEXT, + HTML, + AUTO, +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/QuoteStyle.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/QuoteStyle.kt new file mode 100644 index 0000000..1afeecd --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/QuoteStyle.kt @@ -0,0 +1,6 @@ +package net.thunderbird.core.android.account + +enum class QuoteStyle { + PREFIX, + HEADER, +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/ShowPictures.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/ShowPictures.kt new file mode 100644 index 0000000..0a95c71 --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/ShowPictures.kt @@ -0,0 +1,7 @@ +package net.thunderbird.core.android.account + +enum class ShowPictures { + NEVER, + ALWAYS, + ONLY_FROM_CONTACTS, +} diff --git a/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/SortType.kt b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/SortType.kt new file mode 100644 index 0000000..22bba8b --- /dev/null +++ b/core/android/account/src/main/kotlin/net/thunderbird/core/android/account/SortType.kt @@ -0,0 +1,11 @@ +package net.thunderbird.core.android.account + +enum class SortType(val isDefaultAscending: Boolean) { + SORT_DATE(false), + SORT_ARRIVAL(false), + SORT_SUBJECT(true), + SORT_SENDER(true), + SORT_UNREAD(true), + SORT_FLAGGED(true), + SORT_ATTACHMENT(true), +} diff --git a/core/android/account/src/test/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapperTest.kt b/core/android/account/src/test/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapperTest.kt new file mode 100644 index 0000000..c4e632f --- /dev/null +++ b/core/android/account/src/test/kotlin/net/thunderbird/core/android/account/LegacyAccountWrapperTest.kt @@ -0,0 +1,195 @@ +package net.thunderbird.core.android.account + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import kotlin.test.Test +import net.thunderbird.account.fake.FakeAccountData.ACCOUNT_ID +import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_COLOR +import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_NAME +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto +import net.thunderbird.feature.account.storage.profile.ProfileDto +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection +import net.thunderbird.feature.notification.NotificationSettings + +class LegacyAccountWrapperTest { + + @Suppress("LongMethod") + @Test + fun `should set defaults`() { + // arrange + val expected = createAccountWrapper() + + // act + val result = LegacyAccountWrapper( + isSensitiveDebugLoggingEnabled = isSensitiveDebugLoggingEnabled, + id = ACCOUNT_ID, + name = PROFILE_NAME, + email = email, + profile = profile, + incomingServerSettings = incomingServerSettings, + outgoingServerSettings = outgoingServerSettings, + identities = identities, + ) + + // assert + assertThat(expected).isEqualTo(result) + } + + private companion object { + val isSensitiveDebugLoggingEnabled = { true } + + const val email = "demo@example.com" + + val avatar = AvatarDto( + avatarType = AvatarTypeDto.MONOGRAM, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = null, + ) + + val profile = ProfileDto( + id = ACCOUNT_ID, + name = PROFILE_NAME, + color = PROFILE_COLOR, + avatar = avatar, + ) + + val incomingServerSettings = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ) + + val outgoingServerSettings = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ) + + val identities = mutableListOf( + Identity( + email = "demo@example.com", + name = "identityName", + signatureUse = true, + signature = "signature", + description = "Demo User", + ), + ) + + val notificationSettings = NotificationSettings() + + @Suppress("LongMethod") + fun createAccountWrapper(): LegacyAccountWrapper { + return LegacyAccountWrapper( + isSensitiveDebugLoggingEnabled = isSensitiveDebugLoggingEnabled, + + // [Account] + id = ACCOUNT_ID, + + // [BaseAccount] + name = PROFILE_NAME, + email = email, + + // [AccountProfile] + profile = profile, + + // Uncategorized + deletePolicy = DeletePolicy.NEVER, + incomingServerSettings = incomingServerSettings, + outgoingServerSettings = outgoingServerSettings, + oAuthState = null, + alwaysBcc = null, + automaticCheckIntervalMinutes = 0, + displayCount = 0, + isNotifyNewMail = false, + folderNotifyNewMailMode = FolderMode.ALL, + isNotifySelfNewMail = false, + isNotifyContactsMailOnly = false, + isIgnoreChatMessages = false, + legacyInboxFolder = null, + importedDraftsFolder = null, + importedSentFolder = null, + importedTrashFolder = null, + importedArchiveFolder = null, + importedSpamFolder = null, + inboxFolderId = null, + draftsFolderId = null, + sentFolderId = null, + trashFolderId = null, + archiveFolderId = null, + spamFolderId = null, + draftsFolderSelection = SpecialFolderSelection.AUTOMATIC, + sentFolderSelection = SpecialFolderSelection.AUTOMATIC, + trashFolderSelection = SpecialFolderSelection.AUTOMATIC, + archiveFolderSelection = SpecialFolderSelection.AUTOMATIC, + spamFolderSelection = SpecialFolderSelection.AUTOMATIC, + importedAutoExpandFolder = null, + autoExpandFolderId = null, + folderDisplayMode = FolderMode.NOT_SECOND_CLASS, + folderSyncMode = FolderMode.FIRST_CLASS, + folderPushMode = FolderMode.NONE, + accountNumber = 0, + isNotifySync = false, + sortType = SortType.SORT_DATE, + sortAscending = emptyMap(), + showPictures = ShowPictures.NEVER, + isSignatureBeforeQuotedText = false, + expungePolicy = Expunge.EXPUNGE_IMMEDIATELY, + maxPushFolders = 0, + idleRefreshMinutes = 0, + useCompression = true, + isSendClientInfoEnabled = true, + isSubscribedFoldersOnly = false, + maximumPolledMessageAge = 0, + maximumAutoDownloadMessageSize = 0, + messageFormat = MessageFormat.HTML, + isMessageFormatAuto = false, + isMessageReadReceipt = false, + quoteStyle = QuoteStyle.PREFIX, + quotePrefix = null, + isDefaultQuotedTextShown = false, + isReplyAfterQuote = false, + isStripSignature = false, + isSyncRemoteDeletions = false, + openPgpProvider = null, + openPgpKey = 0, + autocryptPreferEncryptMutual = false, + isOpenPgpHideSignOnly = false, + isOpenPgpEncryptSubject = false, + isOpenPgpEncryptAllDrafts = false, + isMarkMessageAsReadOnView = false, + isMarkMessageAsReadOnDelete = false, + isAlwaysShowCcBcc = false, + isRemoteSearchFullText = false, + remoteSearchNumResults = 0, + isUploadSentMessages = false, + lastSyncTime = 0, + lastFolderListRefreshTime = 0, + isFinishedSetup = false, + messagesNotificationChannelVersion = 0, + isChangedVisibleLimits = false, + lastSelectedFolderId = null, + identities = identities, + notificationSettings = notificationSettings, + senderName = identities[0].name, + signatureUse = identities[0].signatureUse, + signature = identities[0].signature, + shouldMigrateToOAuth = false, + ) + } + } +} diff --git a/core/android/common/build.gradle.kts b/core/android/common/build.gradle.kts new file mode 100644 index 0000000..b8b6116 --- /dev/null +++ b/core/android/common/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "app.k9mail.core.android.common" +} + +dependencies { + api(projects.core.common) + + implementation(libs.androidx.webkit) + + testImplementation(projects.core.testing) + testImplementation(libs.robolectric) +} diff --git a/core/android/common/src/main/AndroidManifest.xml b/core/android/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bdf29cb --- /dev/null +++ b/core/android/common/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt new file mode 100644 index 0000000..5baf9a3 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModule.kt @@ -0,0 +1,18 @@ +package app.k9mail.core.android.common + +import app.k9mail.core.android.common.camera.cameraModule +import app.k9mail.core.android.common.contact.contactModule +import net.thunderbird.core.android.common.resources.resourcesAndroidModule +import net.thunderbird.core.common.coreCommonModule +import org.koin.core.module.Module +import org.koin.dsl.module + +val coreCommonAndroidModule: Module = module { + includes(resourcesAndroidModule) + + includes(coreCommonModule) + + includes(contactModule) + + includes(cameraModule) +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/activity/ContextExtensions.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/activity/ContextExtensions.kt new file mode 100644 index 0000000..228ee55 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/activity/ContextExtensions.kt @@ -0,0 +1,15 @@ +@file:JvmName("ContextHelper") + +package app.k9mail.core.android.common.activity + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +tailrec fun Context.findActivity(): Activity? { + return if (this is Activity) { + this + } else { + (this as? ContextWrapper)?.baseContext?.findActivity() + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/activity/CreateDocumentResultContract.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/activity/CreateDocumentResultContract.kt new file mode 100644 index 0000000..23b15c8 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/activity/CreateDocumentResultContract.kt @@ -0,0 +1,25 @@ +package app.k9mail.core.android.common.activity + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract + +class CreateDocumentResultContract : ActivityResultContract() { + override fun createIntent(context: Context, input: Input): Intent { + return Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(input.mimeType) + .putExtra(Intent.EXTRA_TITLE, input.title) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return intent.takeIf { resultCode == Activity.RESULT_OK }?.data + } + + data class Input( + val title: String, + val mimeType: String, + ) +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/CameraCaptureHandler.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/CameraCaptureHandler.kt new file mode 100644 index 0000000..257747a --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/CameraCaptureHandler.kt @@ -0,0 +1,57 @@ +package app.k9mail.core.android.common.camera + +import android.Manifest.permission +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityCompat.startActivityForResult +import androidx.core.content.ContextCompat +import app.k9mail.core.android.common.camera.io.CaptureImageFileWriter + +class CameraCaptureHandler( + private val captureImageFileWriter: CaptureImageFileWriter, +) { + + private lateinit var capturedImageUri: Uri + + companion object { + const val REQUEST_IMAGE_CAPTURE: Int = 6 + const val CAMERA_PERMISSION_REQUEST_CODE: Int = 100 + } + + fun getCapturedImageUri(): Uri { + if (::capturedImageUri.isInitialized) { + return capturedImageUri + } else { + throw UninitializedPropertyAccessException("Image Uri not initialized") + } + } + + fun canLaunchCamera(context: Context) = + context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + + fun openCamera(activity: Activity) { + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + capturedImageUri = captureImageFileWriter.getFileUri() + intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri) + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + startActivityForResult(activity, intent, REQUEST_IMAGE_CAPTURE, null) + } + + fun requestCameraPermission(activity: Activity) { + ActivityCompat.requestPermissions( + activity, + arrayOf(permission.CAMERA), + CAMERA_PERMISSION_REQUEST_CODE, + ) + } + + fun hasCameraPermission(context: Context): Boolean { + val hasPermission = ContextCompat.checkSelfPermission(context, permission.CAMERA) + return hasPermission == PackageManager.PERMISSION_GRANTED + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/CameraKoinModule.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/CameraKoinModule.kt new file mode 100644 index 0000000..8d49966 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/CameraKoinModule.kt @@ -0,0 +1,13 @@ +package app.k9mail.core.android.common.camera + +import app.k9mail.core.android.common.camera.io.CaptureImageFileWriter +import org.koin.dsl.module + +internal val cameraModule = module { + single { CaptureImageFileWriter(context = get()) } + single { + CameraCaptureHandler( + captureImageFileWriter = get(), + ) + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/io/CaptureImageFileWriter.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/io/CaptureImageFileWriter.kt new file mode 100644 index 0000000..72ab8fa --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/io/CaptureImageFileWriter.kt @@ -0,0 +1,31 @@ +package app.k9mail.core.android.common.camera.io + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import java.io.File + +class CaptureImageFileWriter(private val context: Context) { + + fun getFileUri(): Uri { + val file = getCaptureImageFile() + return FileProvider.getUriForFile(context, "${context.packageName}.activity", file) + } + + private fun getCaptureImageFile(): File { + val fileName = "IMG_${System.currentTimeMillis()}$FILE_EXT" + return File(getDirectory(), fileName) + } + + private fun getDirectory(): File { + val directory = File(context.cacheDir, DIRECTORY_NAME) + directory.mkdirs() + + return directory + } + + companion object { + private const val FILE_EXT = ".jpg" + private const val DIRECTORY_NAME = "captureImage" + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/provider/CaptureImageFileProvider.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/provider/CaptureImageFileProvider.kt new file mode 100644 index 0000000..06e8e3a --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/camera/provider/CaptureImageFileProvider.kt @@ -0,0 +1,5 @@ +package app.k9mail.core.android.common.camera.provider + +import androidx.core.content.FileProvider + +class CaptureImageFileProvider : FileProvider() diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/compat/BundleCompat.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/compat/BundleCompat.kt new file mode 100644 index 0000000..180a9b3 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/compat/BundleCompat.kt @@ -0,0 +1,22 @@ +package app.k9mail.core.android.common.compat + +import android.os.Build +import android.os.Bundle +import java.io.Serializable + +// This class resolves a deprecation warning and issue with the Bundle.getSerializable method +// Fixes https://issuetracker.google.com/issues/314250395 +// Could be removed once releases in androidx.core.os.BundleCompat +object BundleCompat { + + @JvmStatic + fun getSerializable(bundle: Bundle, key: String?, clazz: Class): T? = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> bundle.getSerializable(key, clazz) + else -> { + @Suppress("DEPRECATION") + val serializable = bundle.getSerializable(key) + @Suppress("UNCHECKED_CAST") + if (clazz.isInstance(serializable)) serializable as T else null + } + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt new file mode 100644 index 0000000..095c400 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/Contact.kt @@ -0,0 +1,12 @@ +package app.k9mail.core.android.common.contact + +import android.net.Uri +import net.thunderbird.core.common.mail.EmailAddress + +data class Contact( + val id: Long, + val name: String?, + val emailAddress: EmailAddress, + val uri: Uri, + val photoUri: Uri?, +) diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt new file mode 100644 index 0000000..82f6495 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactDataSource.kt @@ -0,0 +1,86 @@ +package app.k9mail.core.android.common.contact + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.ContactsContract +import app.k9mail.core.android.common.database.EmptyCursor +import app.k9mail.core.android.common.database.getLongOrThrow +import app.k9mail.core.android.common.database.getStringOrNull +import net.thunderbird.core.common.mail.EmailAddress + +interface ContactDataSource { + + fun getContactFor(emailAddress: EmailAddress): Contact? + + fun hasContactFor(emailAddress: EmailAddress): Boolean +} + +internal class ContentResolverContactDataSource( + private val contentResolver: ContentResolver, + private val contactPermissionResolver: ContactPermissionResolver, +) : ContactDataSource { + + override fun getContactFor(emailAddress: EmailAddress): Contact? { + getCursorFor(emailAddress).use { cursor -> + if (cursor.moveToFirst()) { + val contactId = cursor.getLongOrThrow(ContactsContract.CommonDataKinds.Email._ID) + val lookupKey = cursor.getStringOrNull(ContactsContract.Contacts.LOOKUP_KEY) + val uri = ContactsContract.Contacts.getLookupUri(contactId, lookupKey) + + val name = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME) + + val photoUri = cursor.getStringOrNull(ContactsContract.CommonDataKinds.Photo.PHOTO_URI) + ?.let { photoUriString -> Uri.parse(photoUriString) } + + return Contact( + id = contactId, + name = name, + emailAddress = emailAddress, + uri = uri, + photoUri = photoUri, + ) + } else { + return null + } + } + } + + override fun hasContactFor(emailAddress: EmailAddress): Boolean { + getCursorFor(emailAddress).use { cursor -> + return cursor.count > 0 + } + } + + private fun getCursorFor(emailAddress: EmailAddress): Cursor { + return if (contactPermissionResolver.hasContactPermission()) { + val uri = Uri.withAppendedPath( + ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI, + Uri.encode(emailAddress.address), + ) + + contentResolver.query( + uri, + PROJECTION, + null, + null, + SORT_ORDER, + ) ?: EmptyCursor() + } else { + EmptyCursor() + } + } + + private companion object { + + private const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME + + ", " + ContactsContract.CommonDataKinds.Email._ID + + private val PROJECTION = arrayOf( + ContactsContract.CommonDataKinds.Email._ID, + ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Photo.PHOTO_URI, + ContactsContract.Contacts.LOOKUP_KEY, + ) + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt new file mode 100644 index 0000000..abcf1a0 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactKoinModule.kt @@ -0,0 +1,36 @@ +package app.k9mail.core.android.common.contact + +import android.content.Context +import kotlin.time.ExperimentalTime +import net.thunderbird.core.common.cache.Cache +import net.thunderbird.core.common.cache.ExpiringCache +import net.thunderbird.core.common.cache.SynchronizedCache +import net.thunderbird.core.common.mail.EmailAddress +import org.koin.core.qualifier.named +import org.koin.dsl.module + +internal val contactModule = module { + single>(named(CACHE_NAME)) { + @OptIn(ExperimentalTime::class) + SynchronizedCache( + delegateCache = ExpiringCache(clock = get()), + ) + } + factory { + ContentResolverContactDataSource( + contentResolver = get().contentResolver, + contactPermissionResolver = get(), + ) + } + factory { + CachingContactRepository( + cache = get(named(CACHE_NAME)), + dataSource = get(), + ) + } + factory { + AndroidContactPermissionResolver(context = get()) + } +} + +internal const val CACHE_NAME = "ContactCache" diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt new file mode 100644 index 0000000..d0490c1 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactPermissionResolver.kt @@ -0,0 +1,16 @@ +package app.k9mail.core.android.common.contact + +import android.Manifest.permission.READ_CONTACTS +import android.content.Context +import android.content.pm.PackageManager.PERMISSION_GRANTED +import androidx.core.content.ContextCompat + +interface ContactPermissionResolver { + fun hasContactPermission(): Boolean +} + +internal class AndroidContactPermissionResolver(private val context: Context) : ContactPermissionResolver { + override fun hasContactPermission(): Boolean { + return ContextCompat.checkSelfPermission(context, READ_CONTACTS) == PERMISSION_GRANTED + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt new file mode 100644 index 0000000..ef97d63 --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/contact/ContactRepository.kt @@ -0,0 +1,48 @@ +package app.k9mail.core.android.common.contact + +import net.thunderbird.core.common.cache.Cache +import net.thunderbird.core.common.mail.EmailAddress + +interface ContactRepository { + + fun getContactFor(emailAddress: EmailAddress): Contact? + + fun hasContactFor(emailAddress: EmailAddress): Boolean + + fun hasAnyContactFor(emailAddresses: List): Boolean +} + +interface CachingRepository { + fun clearCache() +} + +internal class CachingContactRepository( + private val cache: Cache, + private val dataSource: ContactDataSource, +) : ContactRepository, CachingRepository { + + override fun getContactFor(emailAddress: EmailAddress): Contact? { + if (cache.hasKey(emailAddress)) { + return cache[emailAddress] + } + + return dataSource.getContactFor(emailAddress).also { + cache[emailAddress] = it + } + } + + override fun hasContactFor(emailAddress: EmailAddress): Boolean { + if (cache.hasKey(emailAddress)) { + return cache[emailAddress] != null + } + + return dataSource.hasContactFor(emailAddress) + } + + override fun hasAnyContactFor(emailAddresses: List): Boolean = + emailAddresses.any { emailAddress -> hasContactFor(emailAddress) } + + override fun clearCache() { + cache.clear() + } +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt new file mode 100644 index 0000000..b6b5e5d --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/CursorExtensions.kt @@ -0,0 +1,37 @@ +package app.k9mail.core.android.common.database + +import android.database.Cursor + +fun Cursor.map(block: (Cursor) -> T): List { + return List(count) { index -> + moveToPosition(index) + block(this) + } +} + +fun Cursor.getStringOrNull(columnName: String): String? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1 || isNull(columnIndex)) null else getString(columnIndex) +} + +fun Cursor.getIntOrNull(columnName: String): Int? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1 || isNull(columnIndex)) null else getInt(columnIndex) +} + +fun Cursor.getLongOrNull(columnName: String): Long? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1 || isNull(columnIndex)) null else getLong(columnIndex) +} + +fun Cursor.getStringOrThrow(columnName: String): String { + return getStringOrNull(columnName) ?: error("Column $columnName must not be null") +} + +fun Cursor.getIntOrThrow(columnName: String): Int { + return getIntOrNull(columnName) ?: error("Column $columnName must not be null") +} + +fun Cursor.getLongOrThrow(columnName: String): Long { + return getLongOrNull(columnName) ?: error("Column $columnName must not be null") +} diff --git a/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt new file mode 100644 index 0000000..a5a4d6f --- /dev/null +++ b/core/android/common/src/main/kotlin/app/k9mail/core/android/common/database/EmptyCursor.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.android.common.database + +import android.database.AbstractCursor + +/** + * A dummy class that provides an empty cursor + */ +class EmptyCursor : AbstractCursor() { + override fun getCount() = 0 + + override fun getColumnNames() = arrayOf() + + override fun getString(column: Int) = null + + override fun getShort(column: Int): Short = 0 + + override fun getInt(column: Int) = 0 + + override fun getLong(column: Int): Long = 0 + + override fun getFloat(column: Int) = 0f + + override fun getDouble(column: Int) = 0.0 + + override fun isNull(column: Int) = true +} diff --git a/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/resources/AndroidResourceManager.kt b/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/resources/AndroidResourceManager.kt new file mode 100644 index 0000000..dd5f520 --- /dev/null +++ b/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/resources/AndroidResourceManager.kt @@ -0,0 +1,21 @@ +package net.thunderbird.core.android.common.resources + +import android.content.Context +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import net.thunderbird.core.common.resources.ResourceManager + +internal class AndroidResourceManager( + private val context: Context, +) : ResourceManager { + override fun stringResource(@StringRes resourceId: Int): String = context.resources.getString(resourceId) + + override fun stringResource(@StringRes resourceId: Int, vararg formatArgs: Any?): String = + context.resources.getString(resourceId, *formatArgs) + + override fun pluralsString( + @PluralsRes resourceId: Int, + quantity: Int, + vararg formatArgs: Any?, + ): String = context.resources.getQuantityString(resourceId, quantity, *formatArgs) +} diff --git a/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/resources/ResourcesAndroidModule.kt b/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/resources/ResourcesAndroidModule.kt new file mode 100644 index 0000000..fbe385c --- /dev/null +++ b/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/resources/ResourcesAndroidModule.kt @@ -0,0 +1,15 @@ +package net.thunderbird.core.android.common.resources + +import net.thunderbird.core.common.resources.PluralsResourceManager +import net.thunderbird.core.common.resources.ResourceManager +import net.thunderbird.core.common.resources.StringsResourceManager +import org.koin.android.ext.koin.androidApplication +import org.koin.core.module.Module +import org.koin.dsl.module + +internal val resourcesAndroidModule: Module = module { + single { AndroidResourceManager(context = androidApplication()) } + single { get() } + single { get() } + single { get() } +} diff --git a/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/view/WebViewExtensions.kt b/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/view/WebViewExtensions.kt new file mode 100644 index 0000000..18f5b65 --- /dev/null +++ b/core/android/common/src/main/kotlin/net/thunderbird/core/android/common/view/WebViewExtensions.kt @@ -0,0 +1,22 @@ +package net.thunderbird.core.android.common.view + +import android.webkit.WebView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature + +fun WebView.showInDarkMode() = setupThemeMode(darkTheme = true) +fun WebView.showInLightMode() = setupThemeMode(darkTheme = false) + +private fun WebView.setupThemeMode(darkTheme: Boolean) { + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed( + this.settings, + darkTheme, + ) + } else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark( + this.settings, + if (darkTheme) WebSettingsCompat.FORCE_DARK_ON else WebSettingsCompat.FORCE_DARK_OFF, + ) + } +} diff --git a/core/android/common/src/main/res/xml/capture_image_file_provider_paths.xml b/core/android/common/src/main/res/xml/capture_image_file_provider_paths.xml new file mode 100644 index 0000000..aa70953 --- /dev/null +++ b/core/android/common/src/main/res/xml/capture_image_file_provider_paths.xml @@ -0,0 +1,6 @@ + + + diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt new file mode 100644 index 0000000..97a34d1 --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/CoreCommonAndroidModuleKtTest.kt @@ -0,0 +1,17 @@ +package app.k9mail.core.android.common + +import android.content.Context +import org.junit.Test +import org.koin.test.verify.verify + +internal class CoreCommonAndroidModuleKtTest { + + @Test + fun `should have a valid di module`() { + coreCommonAndroidModule.verify( + extraTypes = listOf( + Context::class, + ), + ) + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/compat/BundleCompatTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/compat/BundleCompatTest.kt new file mode 100644 index 0000000..cb330cd --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/compat/BundleCompatTest.kt @@ -0,0 +1,55 @@ +package app.k9mail.core.android.common.compat + +import android.os.Bundle +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.Serializable +import kotlin.test.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BundleCompatTest { + + @Test + fun `getSerializable returns Serializable`() { + val bundle = Bundle() + val key = "keySerializable" + val serializable = TestSerializable("value") + val clazz = TestSerializable::class.java + bundle.putSerializable(key, serializable) + + val result = BundleCompat.getSerializable(bundle, key, clazz) + + assertThat(result).isEqualTo(serializable) + } + + @Test + fun `getSerializable returns null when class mismatch`() { + val bundle = Bundle() + val key = "keySerializable" + val serializable = TestSerializable("value") + val clazz = OtherTestSerializable::class.java + bundle.putSerializable(key, serializable) + + val result = BundleCompat.getSerializable(bundle, key, clazz) + + assertThat(result).isEqualTo(null) + } + + internal class TestSerializable( + val value: String, + ) : Serializable { + companion object { + private const val serialVersionUID = 1L + } + } + + internal class OtherTestSerializable( + val value: String, + ) : Serializable { + companion object { + private const val serialVersionUID = 2L + } + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt new file mode 100644 index 0000000..ea0dc67 --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/AndroidContactPermissionResolverTest.kt @@ -0,0 +1,43 @@ +package app.k9mail.core.android.common.contact + +import android.Manifest +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows + +@RunWith(RobolectricTestRunner::class) +class AndroidContactPermissionResolverTest { + private val application = RuntimeEnvironment.getApplication() + private val testSubject = AndroidContactPermissionResolver(context = application) + + @Test + fun `hasPermission() with contact permission`() { + grantContactPermission() + + val result = testSubject.hasContactPermission() + + assertThat(result).isTrue() + } + + @Test + fun `hasPermission() without contact permission`() { + denyContactPermission() + + val result = testSubject.hasContactPermission() + + assertThat(result).isFalse() + } + + private fun grantContactPermission() { + Shadows.shadowOf(application).grantPermissions(Manifest.permission.READ_CONTACTS) + } + + private fun denyContactPermission() { + Shadows.shadowOf(application).denyPermissions(Manifest.permission.READ_CONTACTS) + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt new file mode 100644 index 0000000..a4eed9a --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/CachingContactRepositoryTest.kt @@ -0,0 +1,143 @@ +package app.k9mail.core.android.common.contact + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import kotlin.test.Test +import net.thunderbird.core.common.cache.InMemoryCache +import net.thunderbird.core.common.mail.EmailAddress +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doReturnConsecutively +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class CachingContactRepositoryTest { + + private val dataSource = mock() + private val cache = InMemoryCache() + + private val testSubject = CachingContactRepository(cache = cache, dataSource = dataSource) + + @Before + fun setUp() { + cache.clear() + } + + @Test + fun `getContactFor() returns null if no contact exists`() { + val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isNull() + } + + @Test + fun `getContactFor() returns contact if it exists`() { + dataSource.stub { on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturn CONTACT } + + val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isEqualTo(CONTACT) + } + + @Test + fun `getContactFor() caches contact`() { + dataSource.stub { + on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf( + CONTACT, + CONTACT.copy(id = 567L), + ) + } + + val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result1).isEqualTo(result2) + } + + @Test + fun `getContactFor() caches null`() { + dataSource.stub { + on { getContactFor(CONTACT_EMAIL_ADDRESS) } doReturnConsecutively listOf( + null, + CONTACT, + ) + } + + val result1 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + val result2 = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result1).isEqualTo(result2) + } + + @Test + fun `getContactFor() returns cached contact`() { + cache[CONTACT_EMAIL_ADDRESS] = CONTACT + + val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isEqualTo(CONTACT) + } + + @Test + fun `hasContactFor() returns false if no contact exists`() { + val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isFalse() + } + + @Test + fun `hasContactFor() returns false if cached contact is null`() { + cache[CONTACT_EMAIL_ADDRESS] = null + + val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isFalse() + } + + @Test + fun `hasContactFor() returns true if contact exists`() { + dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true } + + val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isTrue() + } + + @Test + fun `hasAnyContactFor() returns false if no contact exists`() { + val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS)) + + assertThat(result).isFalse() + } + + @Test + fun `hasAnyContactFor() returns false if list is empty`() { + val result = testSubject.hasAnyContactFor(listOf()) + + assertThat(result).isFalse() + } + + @Test + fun `hasAnyContactFor() returns true if contact exists`() { + dataSource.stub { on { hasContactFor(CONTACT_EMAIL_ADDRESS) } doReturn true } + + val result = testSubject.hasAnyContactFor(listOf(CONTACT_EMAIL_ADDRESS)) + + assertThat(result).isTrue() + } + + @Test + fun `clearCache() clears cache`() { + cache[CONTACT_EMAIL_ADDRESS] = CONTACT + + testSubject.clearCache() + + assertThat(cache[CONTACT_EMAIL_ADDRESS]).isNull() + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt new file mode 100644 index 0000000..6e9c2be --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt @@ -0,0 +1,19 @@ +package app.k9mail.core.android.common.contact + +import android.net.Uri +import net.thunderbird.core.common.mail.toEmailAddressOrThrow + +const val CONTACT_ID = 123L +const val CONTACT_NAME = "user name" +const val CONTACT_LOOKUP_KEY = "0r1-4F314D4F2F294F29" +val CONTACT_EMAIL_ADDRESS = "user@example.com".toEmailAddressOrThrow() +val CONTACT_URI: Uri = Uri.parse("content://com.android.contacts/contacts/lookup/$CONTACT_LOOKUP_KEY/$CONTACT_ID") +val CONTACT_PHOTO_URI: Uri = Uri.parse("content://com.android.contacts/display_photo/$CONTACT_ID") + +val CONTACT = Contact( + id = CONTACT_ID, + name = CONTACT_NAME, + emailAddress = CONTACT_EMAIL_ADDRESS, + uri = CONTACT_URI, + photoUri = CONTACT_PHOTO_URI, +) diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt new file mode 100644 index 0000000..8b94429 --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactKoinModuleKtTest.kt @@ -0,0 +1,12 @@ +package app.k9mail.core.android.common.contact + +import org.junit.Test +import org.koin.test.verify.verify + +internal class ContactKoinModuleKtTest { + + @Test + fun `should have a valid di module`() { + contactModule.verify() + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt new file mode 100644 index 0000000..d74ef93 --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContentResolverContactDataSourceTest.kt @@ -0,0 +1,120 @@ +package app.k9mail.core.android.common.contact + +import android.content.ContentResolver +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.ContactsContract +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ContentResolverContactDataSourceTest { + private val contactPermissionResolver = TestContactPermissionResolver(hasPermission = true) + private val contentResolver = mock() + + private val testSubject = ContentResolverContactDataSource( + contentResolver = contentResolver, + contactPermissionResolver = contactPermissionResolver, + ) + + @Test + fun `getContactForEmail() returns null if permission is not granted`() { + contactPermissionResolver.hasContactPermission = false + + val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isNull() + } + + @Test + fun `getContactForEmail() returns null if no contact is found`() { + setupContactProvider(setupEmptyContactCursor()) + + val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isNull() + } + + @Test + fun `getContactForEmail() returns contact if a contact is found`() { + setupContactProvider(setupContactCursor()) + + val result = testSubject.getContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isEqualTo(CONTACT) + } + + @Test + fun `hasContactForEmail() returns false if permission is not granted`() { + contactPermissionResolver.hasContactPermission = false + + val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isFalse() + } + + @Test + fun `hasContactForEmail() returns false if no contact is found`() { + setupContactProvider(setupEmptyContactCursor()) + + val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isFalse() + } + + @Test + fun `hasContactForEmail() returns true if a contact is found`() { + setupContactProvider(setupContactCursor()) + + val result = testSubject.hasContactFor(CONTACT_EMAIL_ADDRESS) + + assertThat(result).isTrue() + } + + private fun setupContactProvider(contactCursor: Cursor) { + val emailUri = Uri.withAppendedPath( + ContactsContract.CommonDataKinds.Email.CONTENT_LOOKUP_URI, + Uri.encode(CONTACT_EMAIL_ADDRESS.address), + ) + + contentResolver.stub { + on { + query(eq(emailUri), eq(PROJECTION), anyOrNull(), anyOrNull(), eq(SORT_ORDER)) + } doReturn contactCursor + } + } + + private fun setupEmptyContactCursor(): Cursor { + return MatrixCursor(PROJECTION) + } + + private fun setupContactCursor(): Cursor { + return MatrixCursor(PROJECTION).apply { + addRow(arrayOf(CONTACT_ID, CONTACT_NAME, CONTACT_PHOTO_URI, CONTACT_LOOKUP_KEY)) + } + } + + private companion object { + val PROJECTION = arrayOf( + ContactsContract.CommonDataKinds.Email._ID, + ContactsContract.CommonDataKinds.Identity.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Photo.PHOTO_URI, + ContactsContract.Contacts.LOOKUP_KEY, + ) + + const val SORT_ORDER = ContactsContract.Contacts.DISPLAY_NAME + + ", " + ContactsContract.CommonDataKinds.Email._ID + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt new file mode 100644 index 0000000..d0b7623 --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/TestContactPermissionResolver.kt @@ -0,0 +1,9 @@ +package app.k9mail.core.android.common.contact + +class TestContactPermissionResolver(hasPermission: Boolean) : ContactPermissionResolver { + var hasContactPermission = hasPermission + + override fun hasContactPermission(): Boolean { + return hasContactPermission + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt new file mode 100644 index 0000000..1eaa52f --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtAccessTest.kt @@ -0,0 +1,100 @@ +package app.k9mail.core.android.common.database + +import android.database.Cursor +import android.database.MatrixCursor +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner + +data class CursorExtensionsAccessTestData( + val name: String, + val value: T, + val access: (Cursor, String) -> T?, + val throwingAccess: (Cursor, String) -> T, +) { + override fun toString(): String = name +} + +@RunWith(ParameterizedRobolectricTestRunner::class) +class CursorExtensionsKtAccessTest(data: CursorExtensionsAccessTestData) { + + private val testValue = data.value + private val testAction = data.access + private val testThrowingAction = data.throwingAccess + + @Test + fun `testAction should return null if column is null`() { + val cursor = MatrixCursor(arrayOf("column")).apply { + addRow(arrayOf(null)) + } + + val result = cursor.map { testAction(it, "column") } + + assertThat(result[0]).isNull() + } + + @Test + fun `testAction should return value if column is not null`() { + val cursor = MatrixCursor(arrayOf("column")).apply { + addRow(arrayOf(testValue)) + } + + val result = cursor.map { testAction(it, "column") } + + assertThat(result[0]).isEqualTo(testValue) + } + + @Test + fun `testThrowingAction should throw if column is null`() { + val cursor = MatrixCursor(arrayOf("column")).apply { + addRow(arrayOf(null)) + } + + assertFailure { + cursor.map { testThrowingAction(it, "column") } + }.hasMessage("Column column must not be null") + } + + @Test + fun `testThrowingAction should return value if column is not null`() { + val cursor = MatrixCursor(arrayOf("column")).apply { + addRow(arrayOf(testValue)) + } + + val result = cursor.map { testThrowingAction(it, "column") } + + assertThat(result[0]).isEqualTo(testValue) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun data(): Collection> { + return listOf( + CursorExtensionsAccessTestData( + name = "getString", + value = "value", + access = { cursor, column -> cursor.getStringOrNull(column) }, + throwingAccess = { cursor, column -> cursor.getStringOrThrow(column) }, + ), + CursorExtensionsAccessTestData( + name = "getInt", + value = Int.MAX_VALUE, + access = { cursor, column -> cursor.getIntOrNull(column) }, + throwingAccess = { cursor, column -> cursor.getIntOrThrow(column) }, + ), + CursorExtensionsAccessTestData( + name = "getLong", + value = Long.MAX_VALUE, + access = { cursor, column -> cursor.getLongOrNull(column) }, + throwingAccess = { cursor, column -> cursor.getLongOrThrow(column) }, + ), + ) + } + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt new file mode 100644 index 0000000..7601712 --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/database/CursorExtensionsKtTest.kt @@ -0,0 +1,33 @@ +package app.k9mail.core.android.common.database + +import android.database.MatrixCursor +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CursorExtensionsKtTest { + + @Test + fun `map should return an empty list if cursor is empty`() { + val cursor = MatrixCursor(arrayOf("column")) + + val result = cursor.map { it.getStringOrNull("column") } + + assertThat(result).isEqualTo(emptyList()) + } + + @Test + fun `map should return a list of mapped values`() { + val cursor = MatrixCursor(arrayOf("column")).apply { + addRow(arrayOf("value1")) + addRow(arrayOf("value2")) + } + + val result = cursor.map { it.getStringOrNull("column") } + + assertThat(result).isEqualTo(listOf("value1", "value2")) + } +} diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/test/GlobalSettingsModule.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/test/GlobalSettingsModule.kt new file mode 100644 index 0000000..bb9800e --- /dev/null +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/test/GlobalSettingsModule.kt @@ -0,0 +1,10 @@ +package app.k9mail.core.android.common.test + +import net.thunderbird.core.common.oauth.OAuthConfigurationFactory +import org.koin.dsl.module + +internal val externalModule = module { + single { + OAuthConfigurationFactory { emptyMap() } + } +} diff --git a/core/android/contact/build.gradle.kts b/core/android/contact/build.gradle.kts new file mode 100644 index 0000000..213edca --- /dev/null +++ b/core/android/contact/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.core.android.contact" +} + +dependencies { + implementation(projects.mail.common) +} diff --git a/core/android/contact/src/main/java/net/thunderbird/core/android/contact/ContactIntentHelper.kt b/core/android/contact/src/main/java/net/thunderbird/core/android/contact/ContactIntentHelper.kt new file mode 100644 index 0000000..7a05869 --- /dev/null +++ b/core/android/contact/src/main/java/net/thunderbird/core/android/contact/ContactIntentHelper.kt @@ -0,0 +1,47 @@ +package net.thunderbird.core.android.contact + +import android.content.Intent +import android.net.Uri +import android.provider.ContactsContract +import com.fsck.k9.mail.Address + +object ContactIntentHelper { + @JvmStatic + fun getContactPickerIntent(): Intent { + return Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI) + } + + /** + * Get Intent to add information to an existing contact or add a new one. + * + * @param address An {@link Address} instance containing the email address + * of the entity you want to add to the contacts. Optionally + * the instance also contains the (display) name of that + * entity. + */ + fun getAddEmailContactIntent(address: Address): Intent { + return Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + data = Uri.fromParts("mailto", address.address, null) + putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION, address.toString()) + + if (address.personal != null) { + putExtra(ContactsContract.Intents.Insert.NAME, address.personal) + } + } + } + + /** + * Get Intent to add a phone number to an existing contact or add a new one. + * + * @param phoneNumber + * The phone number to add to a contact, or to use when creating a new contact. + */ + fun getAddPhoneContactIntent(phoneNumber: String): Intent { + return Intent(Intent.ACTION_INSERT_OR_EDIT).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + type = ContactsContract.Contacts.CONTENT_ITEM_TYPE + putExtra(ContactsContract.Intents.Insert.PHONE, Uri.decode(phoneNumber)) + } + } +} diff --git a/core/android/logging/build.gradle.kts b/core/android/logging/build.gradle.kts new file mode 100644 index 0000000..86a28e0 --- /dev/null +++ b/core/android/logging/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.core.android.logging" +} + +dependencies { + implementation(libs.timber) + implementation(libs.commons.io) +} diff --git a/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/KoinModule.kt b/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/KoinModule.kt new file mode 100644 index 0000000..b25c9f1 --- /dev/null +++ b/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/KoinModule.kt @@ -0,0 +1,13 @@ +package net.thunderbird.core.android.logging + +import org.koin.dsl.module + +val loggingModule = module { + factory { RealProcessExecutor() } + factory { + LogcatLogFileWriter( + contentResolver = get(), + processExecutor = get(), + ) + } +} diff --git a/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/LogFileWriter.kt b/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/LogFileWriter.kt new file mode 100644 index 0000000..d5e8753 --- /dev/null +++ b/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/LogFileWriter.kt @@ -0,0 +1,31 @@ +package net.thunderbird.core.android.logging + +import android.content.ContentResolver +import android.net.Uri +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.io.IOUtils + +interface LogFileWriter { + suspend fun writeLogTo(contentUri: Uri) +} + +class LogcatLogFileWriter( + private val contentResolver: ContentResolver, + private val processExecutor: ProcessExecutor, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : LogFileWriter { + override suspend fun writeLogTo(contentUri: Uri) { + return withContext(coroutineDispatcher) { + val outputStream = contentResolver.openOutputStream(contentUri, "wt") + ?: error("Error opening contentUri for writing") + + outputStream.use { + processExecutor.exec("logcat -d").use { inputStream -> + IOUtils.copy(inputStream, outputStream) + } + } + } + } +} diff --git a/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/ProcessExecutor.kt b/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/ProcessExecutor.kt new file mode 100644 index 0000000..dc437dd --- /dev/null +++ b/core/android/logging/src/main/kotlin/net/thunderbird/core/android/logging/ProcessExecutor.kt @@ -0,0 +1,14 @@ +package net.thunderbird.core.android.logging + +import java.io.InputStream + +interface ProcessExecutor { + fun exec(command: String): InputStream +} + +class RealProcessExecutor : ProcessExecutor { + override fun exec(command: String): InputStream { + val process = Runtime.getRuntime().exec(command) + return process.inputStream + } +} diff --git a/core/android/logging/src/test/kotlin/net/thunderbird/core/android/logging/LogcatLogFileWriterTest.kt b/core/android/logging/src/test/kotlin/net/thunderbird/core/android/logging/LogcatLogFileWriterTest.kt new file mode 100644 index 0000000..7cc43bf --- /dev/null +++ b/core/android/logging/src/test/kotlin/net/thunderbird/core/android/logging/LogcatLogFileWriterTest.kt @@ -0,0 +1,86 @@ +package net.thunderbird.core.android.logging + +import android.content.ContentResolver +import android.net.Uri +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class LogcatLogFileWriterTest { + private val contentUri = mock() + private val outputStream = ByteArrayOutputStream() + + @Test + fun `write log to contentUri`() = runBlocking { + val logData = "a".repeat(10_000) + val logFileWriter = LogcatLogFileWriter( + contentResolver = createContentResolver(), + processExecutor = createProcessExecutor(logData), + coroutineDispatcher = Dispatchers.Unconfined, + ) + + logFileWriter.writeLogTo(contentUri) + + assertThat(outputStream.toByteArray().decodeToString()).isEqualTo(logData) + } + + @Test(expected = FileNotFoundException::class) + fun `contentResolver throws`() = runBlocking { + val logFileWriter = LogcatLogFileWriter( + contentResolver = createThrowingContentResolver(FileNotFoundException()), + processExecutor = createProcessExecutor("irrelevant"), + coroutineDispatcher = Dispatchers.Unconfined, + ) + + logFileWriter.writeLogTo(contentUri) + } + + @Test(expected = IOException::class) + fun `processExecutor throws`() = runBlocking { + val logFileWriter = LogcatLogFileWriter( + contentResolver = createContentResolver(), + processExecutor = ThrowingProcessExecutor(IOException()), + coroutineDispatcher = Dispatchers.Unconfined, + ) + + logFileWriter.writeLogTo(contentUri) + } + + private fun createContentResolver(): ContentResolver { + return mock { + on { openOutputStream(contentUri, "wt") } doReturn outputStream + } + } + + private fun createThrowingContentResolver(exception: Exception): ContentResolver { + return mock { + on { openOutputStream(contentUri, "wt") } doAnswer { throw exception } + } + } + + private fun createProcessExecutor(logData: String): DataProcessExecutor { + return DataProcessExecutor(logData.toByteArray(charset = Charsets.US_ASCII)) + } +} + +private class DataProcessExecutor(val data: ByteArray) : ProcessExecutor { + override fun exec(command: String): InputStream { + return ByteArrayInputStream(data) + } +} + +private class ThrowingProcessExecutor(val exception: Exception) : ProcessExecutor { + override fun exec(command: String): InputStream { + throw exception + } +} diff --git a/core/android/network/build.gradle.kts b/core/android/network/build.gradle.kts new file mode 100644 index 0000000..48467bc --- /dev/null +++ b/core/android/network/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.core.android.network" +} + +dependencies { + api(projects.core.common) + + implementation(projects.core.logging.api) + implementation(projects.core.logging.implLegacy) + + testImplementation(projects.core.testing) + testImplementation(libs.robolectric) +} diff --git a/core/android/network/src/main/AndroidManifest.xml b/core/android/network/src/main/AndroidManifest.xml new file mode 100644 index 0000000..15fea43 --- /dev/null +++ b/core/android/network/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManager.kt b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManager.kt new file mode 100644 index 0000000..72a1b6f --- /dev/null +++ b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManager.kt @@ -0,0 +1,25 @@ +package net.thunderbird.core.android.network + +import android.os.Build +import android.net.ConnectivityManager as SystemConnectivityManager + +interface ConnectivityManager { + fun start() + fun stop() + fun isNetworkAvailable(): Boolean + fun addListener(listener: ConnectivityChangeListener) + fun removeListener(listener: ConnectivityChangeListener) +} + +interface ConnectivityChangeListener { + fun onConnectivityChanged() + fun onConnectivityLost() +} + +internal fun ConnectivityManager(systemConnectivityManager: SystemConnectivityManager): ConnectivityManager { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> ConnectivityManagerApi24(systemConnectivityManager) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> ConnectivityManagerApi23(systemConnectivityManager) + else -> ConnectivityManagerApi21(systemConnectivityManager) + } +} diff --git a/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi21.kt b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi21.kt new file mode 100644 index 0000000..0ffd480 --- /dev/null +++ b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi21.kt @@ -0,0 +1,66 @@ +package net.thunderbird.core.android.network + +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkRequest +import net.thunderbird.core.logging.legacy.Log +import android.net.ConnectivityManager as SystemConnectivityManager + +@Suppress("DEPRECATION") +internal class ConnectivityManagerApi21( + private val systemConnectivityManager: SystemConnectivityManager, +) : ConnectivityManagerBase() { + private var isRunning = false + private var lastNetworkType: Int? = null + private var wasConnected: Boolean? = null + + private val networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + Log.v("Network available: $network") + notifyIfConnectivityHasChanged() + } + + override fun onLost(network: Network) { + Log.v("Network lost: $network") + notifyIfConnectivityHasChanged() + } + + private fun notifyIfConnectivityHasChanged() { + val networkType = systemConnectivityManager.activeNetworkInfo?.type + val isConnected = isNetworkAvailable() + + synchronized(this@ConnectivityManagerApi21) { + if (networkType != lastNetworkType || isConnected != wasConnected) { + lastNetworkType = networkType + wasConnected = isConnected + if (isConnected) { + notifyOnConnectivityChanged() + } else { + notifyOnConnectivityLost() + } + } + } + } + } + + @Synchronized + override fun start() { + if (!isRunning) { + isRunning = true + + val networkRequest = NetworkRequest.Builder().build() + systemConnectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } + } + + @Synchronized + override fun stop() { + if (isRunning) { + isRunning = false + + systemConnectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + override fun isNetworkAvailable(): Boolean = systemConnectivityManager.activeNetworkInfo?.isConnected == true +} diff --git a/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi23.kt b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi23.kt new file mode 100644 index 0000000..74cff1a --- /dev/null +++ b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi23.kt @@ -0,0 +1,73 @@ +package net.thunderbird.core.android.network + +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import androidx.annotation.RequiresApi +import net.thunderbird.core.logging.legacy.Log +import android.net.ConnectivityManager as SystemConnectivityManager + +@RequiresApi(Build.VERSION_CODES.M) +internal class ConnectivityManagerApi23( + private val systemConnectivityManager: SystemConnectivityManager, +) : ConnectivityManagerBase() { + private var isRunning = false + private var lastActiveNetwork: Network? = null + private var wasConnected: Boolean? = null + + private val networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + Log.v("Network available: $network") + notifyIfActiveNetworkOrConnectivityHasChanged() + } + + override fun onLost(network: Network) { + Log.v("Network lost: $network") + notifyIfActiveNetworkOrConnectivityHasChanged() + } + + private fun notifyIfActiveNetworkOrConnectivityHasChanged() { + val activeNetwork = systemConnectivityManager.activeNetwork + val isConnected = isNetworkAvailable() + + synchronized(this@ConnectivityManagerApi23) { + if (activeNetwork != lastActiveNetwork || isConnected != wasConnected) { + lastActiveNetwork = activeNetwork + wasConnected = isConnected + if (isConnected) { + notifyOnConnectivityChanged() + } else { + notifyOnConnectivityLost() + } + } + } + } + } + + @Synchronized + override fun start() { + if (!isRunning) { + isRunning = true + + val networkRequest = NetworkRequest.Builder().build() + systemConnectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } + } + + @Synchronized + override fun stop() { + if (isRunning) { + isRunning = false + + systemConnectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + override fun isNetworkAvailable(): Boolean { + val activeNetwork = systemConnectivityManager.activeNetwork ?: return false + val networkCapabilities = systemConnectivityManager.getNetworkCapabilities(activeNetwork) + return networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } +} diff --git a/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi24.kt b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi24.kt new file mode 100644 index 0000000..ee6c8e4 --- /dev/null +++ b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerApi24.kt @@ -0,0 +1,65 @@ +package net.thunderbird.core.android.network + +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import androidx.annotation.RequiresApi +import net.thunderbird.core.logging.legacy.Log +import android.net.ConnectivityManager as SystemConnectivityManager + +@RequiresApi(Build.VERSION_CODES.N) +internal class ConnectivityManagerApi24( + private val systemConnectivityManager: SystemConnectivityManager, +) : ConnectivityManagerBase() { + private var isRunning = false + private var isNetworkAvailable: Boolean? = null + + private val networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + Log.v("Network available: $network") + synchronized(this@ConnectivityManagerApi24) { + isNetworkAvailable = true + notifyOnConnectivityChanged() + } + } + + override fun onLost(network: Network) { + Log.v("Network lost: $network") + synchronized(this@ConnectivityManagerApi24) { + isNetworkAvailable = false + notifyOnConnectivityLost() + } + } + } + + @Synchronized + override fun start() { + if (!isRunning) { + isRunning = true + + systemConnectivityManager.registerDefaultNetworkCallback(networkCallback) + } + } + + @Synchronized + override fun stop() { + if (isRunning) { + isRunning = false + + systemConnectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + override fun isNetworkAvailable(): Boolean { + return synchronized(this) { isNetworkAvailable } ?: isNetworkAvailableSynchronous() + } + + // Sometimes this will return 'true' even though networkCallback has already received onLost(). + // That's why isNetworkAvailable() prefers the state derived from the callbacks over this method. + private fun isNetworkAvailableSynchronous(): Boolean { + val activeNetwork = systemConnectivityManager.activeNetwork ?: return false + val networkCapabilities = systemConnectivityManager.getNetworkCapabilities(activeNetwork) + return networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } +} diff --git a/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerBase.kt b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerBase.kt new file mode 100644 index 0000000..53df573 --- /dev/null +++ b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/ConnectivityManagerBase.kt @@ -0,0 +1,31 @@ +package net.thunderbird.core.android.network + +import java.util.concurrent.CopyOnWriteArraySet + +internal abstract class ConnectivityManagerBase : ConnectivityManager { + private val listeners = CopyOnWriteArraySet() + + @Synchronized + override fun addListener(listener: ConnectivityChangeListener) { + listeners.add(listener) + } + + @Synchronized + override fun removeListener(listener: ConnectivityChangeListener) { + listeners.remove(listener) + } + + @Synchronized + protected fun notifyOnConnectivityChanged() { + for (listener in listeners) { + listener.onConnectivityChanged() + } + } + + @Synchronized + protected fun notifyOnConnectivityLost() { + for (listener in listeners) { + listener.onConnectivityLost() + } + } +} diff --git a/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/KoinModule.kt b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/KoinModule.kt new file mode 100644 index 0000000..ba5fdc0 --- /dev/null +++ b/core/android/network/src/main/kotlin/net/thunderbird/core/android/network/KoinModule.kt @@ -0,0 +1,10 @@ +package net.thunderbird.core.android.network + +import android.content.Context +import org.koin.dsl.module +import android.net.ConnectivityManager as SystemConnectivityManager + +val coreAndroidNetworkModule = module { + single { get().getSystemService(Context.CONNECTIVITY_SERVICE) as SystemConnectivityManager } + single { ConnectivityManager(systemConnectivityManager = get()) } +} diff --git a/core/android/permissions/build.gradle.kts b/core/android/permissions/build.gradle.kts new file mode 100644 index 0000000..41bb4a2 --- /dev/null +++ b/core/android/permissions/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "app.k9mail.core.android.permissions" +} + +dependencies { + testImplementation(libs.androidx.test.core) + testImplementation(libs.robolectric) + testImplementation(libs.assertk) +} diff --git a/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/AndroidPermissionChecker.kt b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/AndroidPermissionChecker.kt new file mode 100644 index 0000000..371bf2d --- /dev/null +++ b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/AndroidPermissionChecker.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.android.permissions + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat + +/** + * Checks if a [Permission] has been granted to the app. + */ +class AndroidPermissionChecker( + private val context: Context, +) : PermissionChecker { + + override fun checkPermission(permission: Permission): PermissionState { + return when (permission) { + Permission.Contacts -> { + checkSelfPermission(Manifest.permission.READ_CONTACTS) + } + Permission.Notifications -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + } else { + PermissionState.GrantedImplicitly + } + } + } + } + + private fun checkSelfPermission(permission: String): PermissionState { + return if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) { + PermissionState.Granted + } else { + PermissionState.Denied + } + } +} diff --git a/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/AndroidPermissionsModelChecker.kt b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/AndroidPermissionsModelChecker.kt new file mode 100644 index 0000000..905ba8e --- /dev/null +++ b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/AndroidPermissionsModelChecker.kt @@ -0,0 +1,14 @@ +package app.k9mail.core.android.permissions + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast + +/** + * Checks if the Android version the app is running on supports runtime permissions. + */ +internal class AndroidPermissionsModelChecker : PermissionsModelChecker { + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M) + override fun hasRuntimePermissions(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + } +} diff --git a/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/CorePermissionsAndroidModule.kt b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/CorePermissionsAndroidModule.kt new file mode 100644 index 0000000..9e79aa3 --- /dev/null +++ b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/CorePermissionsAndroidModule.kt @@ -0,0 +1,9 @@ +package app.k9mail.core.android.permissions + +import org.koin.core.module.Module +import org.koin.dsl.module + +val corePermissionsAndroidModule: Module = module { + factory { AndroidPermissionChecker(context = get()) } + factory { AndroidPermissionsModelChecker() } +} diff --git a/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/Permission.kt b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/Permission.kt new file mode 100644 index 0000000..4ffaa43 --- /dev/null +++ b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/Permission.kt @@ -0,0 +1,9 @@ +package app.k9mail.core.android.permissions + +/** + * System permissions we ask for during onboarding. + */ +enum class Permission { + Contacts, + Notifications, +} diff --git a/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionChecker.kt b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionChecker.kt new file mode 100644 index 0000000..6713b34 --- /dev/null +++ b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionChecker.kt @@ -0,0 +1,8 @@ +package app.k9mail.core.android.permissions + +/** + * Checks if a [Permission] has been granted to the app. + */ +interface PermissionChecker { + fun checkPermission(permission: Permission): PermissionState +} diff --git a/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionState.kt b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionState.kt new file mode 100644 index 0000000..fc123ae --- /dev/null +++ b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionState.kt @@ -0,0 +1,10 @@ +package app.k9mail.core.android.permissions + +enum class PermissionState { + /** + * The permission is not a runtime permission in the Android version we're running on. + */ + GrantedImplicitly, + Granted, + Denied, +} diff --git a/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionsModelChecker.kt b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionsModelChecker.kt new file mode 100644 index 0000000..9274749 --- /dev/null +++ b/core/android/permissions/src/main/kotlin/app/k9mail/core/android/permissions/PermissionsModelChecker.kt @@ -0,0 +1,8 @@ +package app.k9mail.core.android.permissions + +/** + * Checks what permission model the system is using. + */ +interface PermissionsModelChecker { + fun hasRuntimePermissions(): Boolean +} diff --git a/core/android/permissions/src/test/kotlin/app/k9mail/core/android/permissions/AndroidPermissionCheckerTest.kt b/core/android/permissions/src/test/kotlin/app/k9mail/core/android/permissions/AndroidPermissionCheckerTest.kt new file mode 100644 index 0000000..af2ec34 --- /dev/null +++ b/core/android/permissions/src/test/kotlin/app/k9mail/core/android/permissions/AndroidPermissionCheckerTest.kt @@ -0,0 +1,66 @@ +package app.k9mail.core.android.permissions + +import android.Manifest +import android.app.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(minSdk = Build.VERSION_CODES.TIRAMISU) +class AndroidPermissionCheckerTest { + private val application: Application = ApplicationProvider.getApplicationContext() + private val shadowApplication = Shadows.shadowOf(application) + + private val permissionChecker = AndroidPermissionChecker(application) + + @Test + fun `granted READ_CONTACTS permission`() { + shadowApplication.grantPermissions(Manifest.permission.READ_CONTACTS) + + val result = permissionChecker.checkPermission(Permission.Contacts) + + assertThat(result).isEqualTo(PermissionState.Granted) + } + + @Test + fun `denied READ_CONTACTS permission`() { + shadowApplication.denyPermissions(Manifest.permission.READ_CONTACTS) + + val result = permissionChecker.checkPermission(Permission.Contacts) + + assertThat(result).isEqualTo(PermissionState.Denied) + } + + @Test + fun `granted POST_NOTIFICATIONS permission`() { + shadowApplication.grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + + val result = permissionChecker.checkPermission(Permission.Notifications) + + assertThat(result).isEqualTo(PermissionState.Granted) + } + + @Test + fun `denied POST_NOTIFICATIONS permission`() { + shadowApplication.denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + + val result = permissionChecker.checkPermission(Permission.Notifications) + + assertThat(result).isEqualTo(PermissionState.Denied) + } + + @Test + @Config(minSdk = Build.VERSION_CODES.S_V2, maxSdk = Build.VERSION_CODES.S_V2) + fun `POST_NOTIFICATIONS permission not available`() { + val result = permissionChecker.checkPermission(Permission.Notifications) + + assertThat(result).isEqualTo(PermissionState.GrantedImplicitly) + } +} diff --git a/core/android/testing/build.gradle.kts b/core/android/testing/build.gradle.kts new file mode 100644 index 0000000..37be74a --- /dev/null +++ b/core/android/testing/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.core.android.testing" +} + +dependencies { + api(libs.junit) + api(libs.robolectric) + + implementation(projects.core.logging.api) + implementation(projects.core.preference.api) + implementation(projects.core.preference.impl) + + api(libs.koin.core) + api(libs.mockito.core) + api(libs.mockito.kotlin) +} diff --git a/core/android/testing/src/main/kotlin/net/thunderbird/core/android/preferences/TestStoragePersister.kt b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/preferences/TestStoragePersister.kt new file mode 100644 index 0000000..8c49594 --- /dev/null +++ b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/preferences/TestStoragePersister.kt @@ -0,0 +1,78 @@ +package net.thunderbird.core.android.preferences + +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.storage.InMemoryStorage +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor +import net.thunderbird.core.preference.storage.StoragePersister +import net.thunderbird.core.preference.storage.StorageUpdater + +class TestStoragePersister( + private val logger: Logger, +) : StoragePersister { + private val values = mutableMapOf() + + override fun loadValues(): Storage { + return InMemoryStorage( + values = values.mapValues { (_, value) -> + value?.toString() ?: "" + }, + logger, + ) + } + + override fun createStorageEditor(storageUpdater: StorageUpdater): StorageEditor { + return InMemoryStorageEditor(storageUpdater) + } + + private inner class InMemoryStorageEditor(private val storageUpdater: StorageUpdater) : StorageEditor { + private val removals = mutableSetOf() + private val changes = mutableMapOf() + private var alreadyCommitted = false + + override fun putBoolean(key: String, value: Boolean) = apply { + changes[key] = value.toString() + removals.remove(key) + } + + override fun putInt(key: String, value: Int) = apply { + changes[key] = value.toString() + removals.remove(key) + } + + override fun putLong(key: String, value: Long) = apply { + changes[key] = value.toString() + removals.remove(key) + } + + override fun putString(key: String, value: String?) = apply { + if (value == null) { + remove(key) + } else { + changes[key] = value + removals.remove(key) + } + } + + override fun remove(key: String) = apply { + removals.add(key) + changes.remove(key) + } + + override fun commit(): Boolean { + if (alreadyCommitted) throw AssertionError("StorageEditor.commit() called more than once") + alreadyCommitted = true + + storageUpdater.updateStorage(::writeValues) + + return true + } + + private fun writeValues(currentStorage: Storage): Storage { + val updatedValues = currentStorage.getAll() - removals + changes + values.clear() + values.putAll(updatedValues.mapValues { (_, value) -> value }) + return InMemoryStorage(updatedValues, logger) + } + } +} diff --git a/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/MockHelper.kt b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/MockHelper.kt new file mode 100644 index 0000000..9245e37 --- /dev/null +++ b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/MockHelper.kt @@ -0,0 +1,23 @@ +package net.thunderbird.core.android.testing + +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.kotlin.KStubbing + +object MockHelper { + @JvmStatic + fun mockBuilder(classToMock: Class): T { + return mock(classToMock) { invocation -> + val mock = invocation.mock + if (invocation.method.returnType.isInstance(mock)) { + mock + } else { + Mockito.RETURNS_DEFAULTS.answer(invocation) + } + } + } + + inline fun mockBuilder(stubbing: KStubbing.(T) -> Unit = {}): T { + return mockBuilder(T::class.java).apply { KStubbing(this).stubbing(this) } + } +} diff --git a/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/RobolectricTest.kt b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/RobolectricTest.kt new file mode 100644 index 0000000..aeb3a66 --- /dev/null +++ b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/RobolectricTest.kt @@ -0,0 +1,15 @@ +package net.thunderbird.core.android.testing + +import android.app.Application +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A Robolectric test that does not create an instance of our [Application]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = EmptyApplication::class) +abstract class RobolectricTest + +class EmptyApplication : Application() diff --git a/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/StringExtensions.kt b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/StringExtensions.kt new file mode 100644 index 0000000..b8b3a78 --- /dev/null +++ b/core/android/testing/src/main/kotlin/net/thunderbird/core/android/testing/StringExtensions.kt @@ -0,0 +1,3 @@ +package net.thunderbird.core.android.testing + +fun String.removeNewlines(): String = replace("([\\r\\n])".toRegex(), "") diff --git a/core/architecture/api/build.gradle.kts b/core/architecture/api/build.gradle.kts new file mode 100644 index 0000000..093f7c0 --- /dev/null +++ b/core/architecture/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.architecture" +} diff --git a/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/data/DataMapper.kt b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/data/DataMapper.kt new file mode 100644 index 0000000..fbd44f8 --- /dev/null +++ b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/data/DataMapper.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.architecture.data + +/** + * Mapper definition for converting between domain models and data transfer objects (DTOs). + * + * @param TDomain The domain model type. + * @param TDto The data transfer object type. + */ +interface DataMapper { + fun toDomain(dto: TDto): TDomain + fun toDto(domain: TDomain): TDto +} diff --git a/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/BaseIdFactory.kt b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/BaseIdFactory.kt new file mode 100644 index 0000000..64b5538 --- /dev/null +++ b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/BaseIdFactory.kt @@ -0,0 +1,25 @@ +package net.thunderbird.core.architecture.model + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Abstract base for ID factories. + * + * This class provides a default implementation for creating and generating IDs. + * It uses UUIDs as the underlying representation of the ID. + * + * Example usage: + * + * ```kotlin + * class AccountIdFactory : BaseIdFactory() + * ``` + * + * @param T The type of the ID. + */ +@OptIn(ExperimentalUuidApi::class) +abstract class BaseIdFactory : IdFactory { + override fun of(raw: String): Id = Id(Uuid.parse(raw)) + + override fun create(): Id = Id(Uuid.random()) +} diff --git a/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/Id.kt b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/Id.kt new file mode 100644 index 0000000..4d36117 --- /dev/null +++ b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/Id.kt @@ -0,0 +1,23 @@ +package net.thunderbird.core.architecture.model + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Represents a unique identifier for an entity. + * + * @param T The type of the entity. + * + * @property value The underlying UUID value. + */ +@OptIn(ExperimentalUuidApi::class) +@JvmInline +value class Id(val value: Uuid) { + + /** + * Returns the raw string representation of the ID. + */ + fun asRaw(): String { + return value.toString() + } +} diff --git a/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/IdFactory.kt b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/IdFactory.kt new file mode 100644 index 0000000..16dff3b --- /dev/null +++ b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/IdFactory.kt @@ -0,0 +1,22 @@ +package net.thunderbird.core.architecture.model + +/** + * Factory interface for creating and generating IDs. + */ +interface IdFactory { + + /** + * Creates an [Id] from a raw string representation. + * + * @param raw The raw string representation of the ID. + * @return An instance of [Id] representing the ID. + */ + fun of(raw: String): Id + + /** + * Creates a new [Id]. + * + * @return A new instance of [Id] representing the created ID. + */ + fun create(): Id +} diff --git a/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/Identifiable.kt b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/Identifiable.kt new file mode 100644 index 0000000..23efb4a --- /dev/null +++ b/core/architecture/api/src/commonMain/kotlin/net/thunderbird/core/architecture/model/Identifiable.kt @@ -0,0 +1,8 @@ +package net.thunderbird.core.architecture.model + +/** + * Interface representing an entity with a unique identifier. + */ +interface Identifiable { + val id: Id +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts new file mode 100644 index 0000000..e12b37f --- /dev/null +++ b/core/common/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.common" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core.logging.implLegacy) + implementation(projects.core.logging.api) + implementation(projects.core.logging.implFile) + } + commonTest.dependencies { + implementation(projects.core.testing) + } + jvmMain.dependencies { + implementation(libs.androidx.annotation) + } + } + + compilerOptions { + freeCompilerArgs.addAll( + listOf( + "-Xexpect-actual-classes", + ), + ) + } +} diff --git a/core/common/src/androidMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.android.kt b/core/common/src/androidMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.android.kt new file mode 100644 index 0000000..a2ee7ed --- /dev/null +++ b/core/common/src/androidMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.android.kt @@ -0,0 +1,4 @@ +package net.thunderbird.core.common.resources + +actual typealias StringRes = androidx.annotation.StringRes +actual typealias PluralsRes = androidx.annotation.PluralsRes diff --git a/core/common/src/androidMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.jvm.kt b/core/common/src/androidMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.jvm.kt new file mode 100644 index 0000000..320fad0 --- /dev/null +++ b/core/common/src/androidMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.jvm.kt @@ -0,0 +1,3 @@ +package net.thunderbird.core.common.resources + +actual typealias ResourceNotFoundException = android.content.res.Resources.NotFoundException diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/CoreCommonModule.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/CoreCommonModule.kt new file mode 100644 index 0000000..a85d7f8 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/CoreCommonModule.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.common + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import net.thunderbird.core.common.oauth.InMemoryOAuthConfigurationProvider +import net.thunderbird.core.common.oauth.OAuthConfigurationProvider +import org.koin.core.module.Module +import org.koin.dsl.module + +val coreCommonModule: Module = module { + @OptIn(ExperimentalTime::class) + single { Clock.System } + + single { + InMemoryOAuthConfigurationProvider( + configurationFactory = get(), + ) + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/action/SwipeAction.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/action/SwipeAction.kt new file mode 100644 index 0000000..a8f6fe2 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/action/SwipeAction.kt @@ -0,0 +1,24 @@ +package net.thunderbird.core.common.action + +enum class SwipeAction(val removesItem: Boolean) { + None(removesItem = false), + ToggleSelection(removesItem = false), + ToggleRead(removesItem = false), + ToggleStar(removesItem = false), + Archive(removesItem = true), + ArchiveDisabled(removesItem = false), + ArchiveSetupArchiveFolder(removesItem = false), + Delete(removesItem = true), + Spam(removesItem = true), + Move(removesItem = true), +} + +data class SwipeActions( + val leftAction: SwipeAction, + val rightAction: SwipeAction, +) { + companion object { + const val KEY_SWIPE_ACTION_LEFT = "swipeLeftAction" + const val KEY_SWIPE_ACTION_RIGHT = "swipeRightAction" + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/Cache.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/Cache.kt new file mode 100644 index 0000000..90b752b --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/Cache.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.common.cache + +interface Cache { + + operator fun get(key: KEY): VALUE? + + operator fun set(key: KEY, value: VALUE) + + fun hasKey(key: KEY): Boolean + + fun clear() +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/ExpiringCache.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/ExpiringCache.kt new file mode 100644 index 0000000..38dfd52 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/ExpiringCache.kt @@ -0,0 +1,51 @@ +package net.thunderbird.core.common.cache + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class ExpiringCache +@OptIn(ExperimentalTime::class) +constructor( + private val clock: Clock, + private val delegateCache: Cache = InMemoryCache(), + private var lastClearTime: Instant = clock.now(), + private val cacheTimeValidity: Long = CACHE_TIME_VALIDITY_IN_MILLIS, +) : Cache { + + override fun get(key: KEY): VALUE? { + recycle() + return delegateCache[key] + } + + override fun set(key: KEY, value: VALUE) { + recycle() + delegateCache[key] = value + } + + override fun hasKey(key: KEY): Boolean { + recycle() + return delegateCache.hasKey(key) + } + + override fun clear() { + @OptIn(ExperimentalTime::class) + lastClearTime = clock.now() + delegateCache.clear() + } + + private fun recycle() { + if (isExpired()) { + clear() + } + } + + private fun isExpired(): Boolean { + @OptIn(ExperimentalTime::class) + return (clock.now() - lastClearTime).inWholeMilliseconds >= cacheTimeValidity + } + + private companion object { + const val CACHE_TIME_VALIDITY_IN_MILLIS = 30_000L + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/InMemoryCache.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/InMemoryCache.kt new file mode 100644 index 0000000..5cfb591 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/InMemoryCache.kt @@ -0,0 +1,21 @@ +package net.thunderbird.core.common.cache + +class InMemoryCache( + private val cache: MutableMap = mutableMapOf(), +) : Cache { + override fun get(key: KEY): VALUE? { + return cache[key] + } + + override fun set(key: KEY, value: VALUE) { + cache[key] = value + } + + override fun hasKey(key: KEY): Boolean { + return cache.containsKey(key) + } + + override fun clear() { + cache.clear() + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/SynchronizedCache.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/SynchronizedCache.kt new file mode 100644 index 0000000..ee71675 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/SynchronizedCache.kt @@ -0,0 +1,30 @@ +package net.thunderbird.core.common.cache + +class SynchronizedCache( + private val delegateCache: Cache, +) : Cache { + + override fun get(key: KEY): VALUE? { + synchronized(delegateCache) { + return delegateCache[key] + } + } + + override fun set(key: KEY, value: VALUE) { + synchronized(delegateCache) { + delegateCache[key] = value + } + } + + override fun hasKey(key: KEY): Boolean { + synchronized(delegateCache) { + return delegateCache.hasKey(key) + } + } + + override fun clear() { + synchronized(delegateCache) { + delegateCache.clear() + } + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/TimeLimitedCache.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/TimeLimitedCache.kt new file mode 100644 index 0000000..64b2d2c --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/TimeLimitedCache.kt @@ -0,0 +1,62 @@ +@file:OptIn(ExperimentalTime::class) + +package net.thunderbird.core.common.cache + +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class TimeLimitedCache( + private val clock: Clock = Clock.System, + private val cache: MutableMap> = mutableMapOf(), +) : Cache> { + companion object { + private val DEFAULT_EXPIRATION_TIME = 1.hours + } + + override fun get(key: TKey): Entry? { + recycle(key) + return cache[key] + } + + fun getValue(key: TKey): TValue? = get(key)?.value + + fun set(key: TKey, value: TValue, expiresIn: Duration = DEFAULT_EXPIRATION_TIME) { + set(key, Entry(value, creationTime = clock.now(), expiresIn)) + } + + override fun set(key: TKey, value: Entry) { + cache[key] = value + } + + override fun hasKey(key: TKey): Boolean { + recycle(key) + return key in cache + } + + override fun clear() { + cache.clear() + } + + fun clearExpired() { + cache.entries.removeAll { (_, entry) -> + entry.expiresAt < clock.now() + } + } + + private fun recycle(key: TKey) { + val entry = cache[key] ?: return + if (entry.expiresAt < clock.now()) { + cache.remove(key) + } + } + + data class Entry( + val value: TValue, + val creationTime: Instant, + val expiresIn: Duration, + val expiresAt: Instant = creationTime + expiresIn, + ) +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/domain/usecase/validation/ValidationError.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/domain/usecase/validation/ValidationError.kt new file mode 100644 index 0000000..2cd21ee --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/domain/usecase/validation/ValidationError.kt @@ -0,0 +1,3 @@ +package net.thunderbird.core.common.domain.usecase.validation + +interface ValidationError diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/domain/usecase/validation/ValidationResult.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/domain/usecase/validation/ValidationResult.kt new file mode 100644 index 0000000..8a80cf1 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/domain/usecase/validation/ValidationResult.kt @@ -0,0 +1,7 @@ +package net.thunderbird.core.common.domain.usecase.validation + +sealed interface ValidationResult { + data object Success : ValidationResult + + data class Failure(val error: ValidationError) : ValidationResult +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/ExceptionHandler.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/ExceptionHandler.kt new file mode 100644 index 0000000..8019e37 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/ExceptionHandler.kt @@ -0,0 +1,22 @@ +package net.thunderbird.core.common.exception + +import kotlinx.coroutines.runBlocking +import net.thunderbird.core.logging.file.FileLogSink +import net.thunderbird.core.logging.legacy.Log +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koin.core.qualifier.named + +class ExceptionHandler( + private val defaultHandler: Thread.UncaughtExceptionHandler?, +) : Thread.UncaughtExceptionHandler, KoinComponent { + private val syncDebugFileLogSink: FileLogSink by inject(named("syncDebug")) + + override fun uncaughtException(t: Thread, e: Throwable) { + Log.e("UncaughtException", e.toString(), e) + runBlocking { + syncDebugFileLogSink.flushAndCloseBuffer() + } + defaultHandler?.uncaughtException(t, e) + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/MessagingException.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/MessagingException.kt new file mode 100644 index 0000000..e4dba4b --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/MessagingException.kt @@ -0,0 +1,26 @@ +package net.thunderbird.core.common.exception + +open class MessagingException( + override val message: String?, + val isPermanentFailure: Boolean, + override val cause: Throwable?, +) : Exception(message, cause) { + + constructor(cause: Throwable?) : this(message = null, cause = cause, isPermanentFailure = false) + constructor(message: String?) : this(message = message, cause = null, isPermanentFailure = false) + constructor(message: String?, isPermanentFailure: Boolean) : this( + message = message, + cause = null, + isPermanentFailure = isPermanentFailure, + ) + + constructor(message: String?, cause: Throwable?) : this( + message = message, + cause = cause, + isPermanentFailure = false, + ) + + companion object { + private const val serialVersionUID = -1 + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/ThrowableExtensions.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/ThrowableExtensions.kt new file mode 100644 index 0000000..9f429c6 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/exception/ThrowableExtensions.kt @@ -0,0 +1,28 @@ +@file:JvmName("ThrowableExtensions") + +package net.thunderbird.core.common.exception + +val Throwable.rootCauseMassage: String? + get() { + var rootCause = this + var nextCause: Throwable? = null + do { + nextCause = rootCause.cause?.also { + rootCause = it + } + } while (nextCause != null) + + if (rootCause is MessagingException) { + return rootCause.message + } + + // Remove the namespace on the exception so we have a fighting chance of seeing more + // of the error in the notification. + val simpleName = rootCause::class.simpleName + val message = rootCause.localizedMessage + return if (message.isNullOrBlank()) { + simpleName + } else { + "$simpleName: $message" + } + } diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/inject/KoinMultibindingCollection.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/inject/KoinMultibindingCollection.kt new file mode 100644 index 0000000..4263f31 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/inject/KoinMultibindingCollection.kt @@ -0,0 +1,64 @@ +package net.thunderbird.core.common.inject + +import org.koin.core.definition.Definition +import org.koin.core.module.KoinDslMarker +import org.koin.core.module.Module +import org.koin.core.parameter.parametersOf +import org.koin.core.qualifier.Qualifier +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope + +// This file must be deleted once https://github.com/InsertKoinIO/koin/pull/1951 is merged to +// Koin and released on 4.2.0 + +/** + * Defines a singleton list of elements of type [T]. + * + * This function creates a singleton definition for a mutable list of elements. + * Each element in the list is resolved from the provided [items] definitions. + * + * @param T The type of elements in the list. + * @param items Vararg of [Definition]s that will be resolved and added to the list. + * @param qualifier Optional [Qualifier] to distinguish this list from others of the same type. + * If null, a default qualifier based on the type [T] will be used. + */ +@KoinDslMarker +inline fun Module.singleListOf(vararg items: Definition, qualifier: Qualifier? = null) { + single(qualifier ?: defaultListQualifier(), createdAtStart = true) { + items.map { definition -> definition(this, parametersOf()) } + } +} + +/** + * Resolves a [List] of instances of type [T]. + * This is a helper function for Koin's multibinding feature. + * + * It uses the [defaultListQualifier] if no [qualifier] is provided. + * + * @param T The type of instances in the list. + * @param qualifier An optional [Qualifier] to distinguish between different lists of the same type. + * @return The resolved [MutableList] of instances of type [T]. + */ +inline fun Scope.getList(qualifier: Qualifier? = null) = + get>(qualifier ?: defaultListQualifier()) + +/** + * Creates a qualifier for a set of a specific type. + * + * This is used to differentiate between different sets of the same type when injecting dependencies. + * + * @param T The type of the elements in the set. + * @return A qualifier for the set. + */ +inline fun defaultListQualifier() = + defaultCollectionQualifier, T>() + +/** + * Creates a default [Qualifier] for a collection binding. + * + * @param TCollection The type of the collection (e.g., `List`, `List`). + * @param T The type of the elements in the collection. + * @return A [Qualifier] that can be used to identify the specific collection binding. + */ +inline fun , reified T> defaultCollectionQualifier() = + named("${TCollection::class.qualifiedName}<${T::class.qualifiedName}>") diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/AbstractParser.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/AbstractParser.kt new file mode 100644 index 0000000..182d884 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/AbstractParser.kt @@ -0,0 +1,81 @@ +package net.thunderbird.core.common.mail + +import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedCharacter +import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedEndOfInput + +@Suppress("UnnecessaryAbstractClass") +internal abstract class AbstractParser(val input: String, startIndex: Int = 0, val endIndex: Int = input.length) { + protected var currentIndex = startIndex + + val position: Int + get() = currentIndex + + fun endReached() = currentIndex >= endIndex + + fun peek(): Char { + if (currentIndex >= endIndex) { + parserError(UnexpectedEndOfInput) + } + + return input[currentIndex] + } + + fun read(): Char { + if (currentIndex >= endIndex) { + parserError(UnexpectedEndOfInput) + } + + return input[currentIndex].also { currentIndex++ } + } + + fun expect(character: Char) { + if (!endReached() && peek() == character) { + currentIndex++ + } else { + parserError(UnexpectedCharacter, message = "Expected '$character' (${character.code})") + } + } + + @Suppress("SameParameterValue") + protected inline fun expect(displayInError: String, predicate: (Char) -> Boolean) { + if (!endReached() && predicate(peek())) { + skip() + } else { + parserError(UnexpectedCharacter, message = "Expected $displayInError") + } + } + + @Suppress("NOTHING_TO_INLINE") + protected inline fun skip() { + currentIndex++ + } + + protected inline fun skipWhile(crossinline predicate: (Char) -> Boolean) { + while (!endReached() && predicate(input[currentIndex])) { + currentIndex++ + } + } + + protected inline fun readString(block: () -> Unit): String { + val startIndex = currentIndex + block() + return input.substring(startIndex, currentIndex) + } + + protected inline fun

withParser(parser: P, block: P.() -> T): T { + try { + return block(parser) + } finally { + currentIndex = parser.position + } + } + + @Suppress("NOTHING_TO_INLINE") + protected inline fun parserError( + error: EmailAddressParserError, + position: Int = currentIndex, + message: String = error.message, + ): Nothing { + throw EmailAddressParserException(message, error, input, position) + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddress.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddress.kt new file mode 100644 index 0000000..ac41fc2 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddress.kt @@ -0,0 +1,140 @@ +package net.thunderbird.core.common.mail + +import kotlin.text.iterator + +// See RFC 5321, 4.5.3.1.3. +// The maximum length of 'Path' indirectly limits the length of 'Mailbox'. +internal const val MAXIMUM_EMAIL_ADDRESS_LENGTH = 254 + +// See RFC 5321, 4.5.3.1.1. +internal const val MAXIMUM_LOCAL_PART_LENGTH = 64 + +/** + * Represents an email address. + * + * This class currently doesn't support internationalized domain names (RFC 5891) or non-ASCII local parts (RFC 6532). + */ +class EmailAddress internal constructor( + val localPart: String, + val domain: EmailDomain, +) { + val encodedLocalPart: String = if (localPart.isDotString) localPart else quoteString(localPart) + + val warnings: Set + + init { + warnings = buildSet { + if (localPart.length > MAXIMUM_LOCAL_PART_LENGTH) { + add(Warning.LocalPartExceedsLengthLimit) + } + + if (address.length > MAXIMUM_EMAIL_ADDRESS_LENGTH) { + add(Warning.EmailAddressExceedsLengthLimit) + } + + if (localPart.isEmpty()) { + add(Warning.EmptyLocalPart) + } + + if (!localPart.isDotString) { + add(Warning.QuotedStringInLocalPart) + } + } + } + + val address: String + get() = "$encodedLocalPart@$domain" + + val normalizedAddress: String + get() = "$encodedLocalPart@${domain.normalized}" + + override fun toString(): String { + return address + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as EmailAddress + + if (localPart != other.localPart) return false + return domain == other.domain + } + + override fun hashCode(): Int { + var result = localPart.hashCode() + result = 31 * result + domain.hashCode() + return result + } + + private fun quoteString(input: String): String { + return buildString { + append(DQUOTE) + for (character in input) { + if (!character.isQtext) { + append(BACKSLASH) + } + append(character) + } + append(DQUOTE) + } + } + + enum class Warning { + /** + * The local part exceeds the length limit (see RFC 5321, 4.5.3.1.1.). + */ + LocalPartExceedsLengthLimit, + + /** + * The email address exceeds the length limit (see RFC 5321, 4.5.3.1.3.; The maximum length of 'Path' + * indirectly limits the length of 'Mailbox'). + */ + EmailAddressExceedsLengthLimit, + + /** + * The local part requires using a quoted string. + * + * This is valid, but very uncommon. Using such a local part should be avoided whenever possible. + */ + QuotedStringInLocalPart, + + /** + * The local part is the empty string. + * + * Even if you want to allow quoted strings, you probably don't want to allow this. + */ + EmptyLocalPart, + } + + companion object { + fun parse(address: String, config: EmailAddressParserConfig = EmailAddressParserConfig.RELAXED): EmailAddress { + return EmailAddressParser(address, config).parse() + } + } +} + +/** + * Converts this string to an [EmailAddress] instance using [EmailAddressParserConfig.RELAXED]. + */ +fun String.toEmailAddressOrThrow() = EmailAddress.parse(this, EmailAddressParserConfig.RELAXED) + +/** + * Converts this string to an [EmailAddress] instance using [EmailAddressParserConfig.RELAXED]. + */ +@Suppress("SwallowedException") +fun String.toEmailAddressOrNull(): EmailAddress? { + return try { + EmailAddress.parse(this, EmailAddressParserConfig.RELAXED) + } catch (e: EmailAddressParserException) { + null + } +} + +/** + * Convert this string into an [EmailAddress] instance using [EmailAddressParserConfig.LIMITED]. + * + * Use this when validating the email address a user wants to add to an account/identity. + */ +fun String.toUserEmailAddress() = EmailAddress.parse(this, EmailAddressParserConfig.LIMITED) diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParser.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParser.kt new file mode 100644 index 0000000..a053dbd --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParser.kt @@ -0,0 +1,164 @@ +package net.thunderbird.core.common.mail + +import net.thunderbird.core.common.mail.EmailAddress.Warning +import net.thunderbird.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported +import net.thunderbird.core.common.mail.EmailAddressParserError.EmptyLocalPart +import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDomainPart +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDotString +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidLocalPart +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidQuotedString +import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded +import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString +import net.thunderbird.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart +import net.thunderbird.core.common.mail.EmailAddressParserError.TotalLengthExceeded + +/** + * Parse an email address. + * + * This class currently doesn't support internationalized domain names (RFC 5891) or non-ASCII local parts (RFC 6532). + * + * From RFC 5321: + * ``` + * Mailbox = Local-part "@" ( Domain / address-literal ) + * + * Local-part = Dot-string / Quoted-string + * Dot-string = Atom *("." Atom) + * Quoted-string = DQUOTE *QcontentSMTP DQUOTE + * QcontentSMTP = qtextSMTP / quoted-pairSMTP + * qtextSMTP = %d32-33 / %d35-91 / %d93-126 + * quoted-pairSMTP = %d92 %d32-126 + * + * Domain - see DomainParser + * address-literal - We intentionally don't support address literals + * ``` + */ +internal class EmailAddressParser( + input: String, + private val config: EmailAddressParserConfig, +) : AbstractParser(input) { + + fun parse(): EmailAddress { + val emailAddress = readEmailAddress() + + if (!endReached()) { + parserError(ExpectedEndOfInput) + } + + if ( + config.isEmailAddressLengthCheckEnabled && Warning.EmailAddressExceedsLengthLimit in emailAddress.warnings + ) { + parserError(TotalLengthExceeded) + } + + if (config.isLocalPartLengthCheckEnabled && Warning.LocalPartExceedsLengthLimit in emailAddress.warnings) { + parserError(LocalPartLengthExceeded, position = input.lastIndexOf('@')) + } + + if ( + !config.isLocalPartRequiringQuotedStringAllowed && Warning.QuotedStringInLocalPart in emailAddress.warnings + ) { + parserError(LocalPartRequiresQuotedString, position = 0) + } + + if (!config.isEmptyLocalPartAllowed && Warning.EmptyLocalPart in emailAddress.warnings) { + parserError(EmptyLocalPart, position = 1) + } + + return emailAddress + } + + private fun readEmailAddress(): EmailAddress { + val localPart = readLocalPart() + + expect(AT) + val domain = readDomainPart() + + return EmailAddress(localPart, domain) + } + + private fun readLocalPart(): String { + val character = peek() + val localPart = when { + character.isAtext -> { + readDotString() + } + character == DQUOTE -> { + if (config.isQuotedLocalPartAllowed) { + readQuotedString() + } else { + parserError(QuotedStringInLocalPart) + } + } + else -> { + parserError(InvalidLocalPart) + } + } + + return localPart + } + + private fun readDotString(): String { + return buildString { + appendAtom() + + while (!endReached() && peek() == DOT) { + expect(DOT) + append(DOT) + appendAtom() + } + } + } + + private fun StringBuilder.appendAtom() { + val startIndex = currentIndex + skipWhile { it.isAtext } + + if (startIndex == currentIndex) { + parserError(InvalidDotString) + } + + append(input, startIndex, currentIndex) + } + + private fun readQuotedString(): String { + return buildString { + expect(DQUOTE) + + while (!endReached()) { + val character = peek() + when { + character.isQtext -> append(read()) + character == BACKSLASH -> { + expect(BACKSLASH) + val escapedCharacter = read() + if (!escapedCharacter.isQuotedChar) { + parserError(InvalidQuotedString) + } + append(escapedCharacter) + } + + character == DQUOTE -> break + else -> parserError(InvalidQuotedString) + } + } + + expect(DQUOTE) + } + } + + private fun readDomainPart(): EmailDomain { + val character = peek() + return when { + character.isLetDig -> readDomain() + character == '[' -> parserError(AddressLiteralsNotSupported) + else -> parserError(InvalidDomainPart) + } + } + + private fun readDomain(): EmailDomain { + return withParser(EmailDomainParser(input, currentIndex)) { + readDomain() + } + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserConfig.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserConfig.kt new file mode 100644 index 0000000..223cbdc --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserConfig.kt @@ -0,0 +1,65 @@ +package net.thunderbird.core.common.mail + +/** + * Configuration to control the behavior when parsing an email address into [EmailAddress]. + * + * @param isLocalPartLengthCheckEnabled When this is `true` the length of the local part is checked to make sure it + * doesn't exceed the specified limit (see RFC 5321, 4.5.3.1.1.). + * + * @param isEmailAddressLengthCheckEnabled When this is `true` the length of the whole email address is checked to make + * sure it doesn't exceed the specified limit (see RFC 5321, 4.5.3.1.3.; The maximum length of 'Path' indirectly limits + * the length of 'Mailbox'). + * + * @param isQuotedLocalPartAllowed When this is `true`, the parsing step allows email addresses with a local part + * encoded as quoted string, e.g. `"foo bar"@domain.example`. Otherwise, the parser will throw an + * [EmailAddressParserException] as soon as a quoted string is encountered. + * Quoted strings in local parts are not widely used. It's recommended to disallow them whenever possible. + * + * @param isLocalPartRequiringQuotedStringAllowed Email addresses whose local part requires the use of a quoted string + * are only allowed when this is `true`. This is separate from [isQuotedLocalPartAllowed] because one might want to + * allow email addresses that unnecessarily use a quoted string, e.g. `"test"@domain.example` + * ([isQuotedLocalPartAllowed] = `true`, [isLocalPartRequiringQuotedStringAllowed] = `false`; [EmailAddress] will not + * retain the original form and treat this address exactly like `test@domain.example`). When allowing this, remember to + * use the value of [EmailAddress.address] instead of retaining the original user input. + * + * The value of this property is ignored if [isQuotedLocalPartAllowed] is `false`. + * + * @param isEmptyLocalPartAllowed Email addresses with an empty local part (e.g. `""@domain.example`) are only allowed + * if this value is `true`. + * + * The value of this property is ignored if at least one of [isQuotedLocalPartAllowed] and + * [isLocalPartRequiringQuotedStringAllowed] is `false`. + */ +data class EmailAddressParserConfig( + val isLocalPartLengthCheckEnabled: Boolean, + val isEmailAddressLengthCheckEnabled: Boolean, + val isQuotedLocalPartAllowed: Boolean, + val isLocalPartRequiringQuotedStringAllowed: Boolean, + val isEmptyLocalPartAllowed: Boolean = false, +) { + companion object { + /** + * This allows local parts requiring quoted strings and disables length checks for the local part and the + * whole email address. + */ + val RELAXED = EmailAddressParserConfig( + isLocalPartLengthCheckEnabled = false, + isEmailAddressLengthCheckEnabled = false, + isQuotedLocalPartAllowed = true, + isLocalPartRequiringQuotedStringAllowed = true, + isEmptyLocalPartAllowed = false, + ) + + /** + * This only allows a subset of valid email addresses. Use this when validating the email address a user wants + * to add to an account/identity. + */ + val LIMITED = EmailAddressParserConfig( + isLocalPartLengthCheckEnabled = true, + isEmailAddressLengthCheckEnabled = true, + isQuotedLocalPartAllowed = false, + isLocalPartRequiringQuotedStringAllowed = false, + isEmptyLocalPartAllowed = false, + ) + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserError.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserError.kt new file mode 100644 index 0000000..eb21ab4 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserError.kt @@ -0,0 +1,22 @@ +package net.thunderbird.core.common.mail + +enum class EmailAddressParserError(internal val message: String) { + UnexpectedEndOfInput("End of input reached unexpectedly"), + ExpectedEndOfInput("Expected end of input"), + InvalidLocalPart("Expected 'Dot-string' or 'Quoted-string'"), + InvalidDotString("Expected 'Dot-string'"), + InvalidQuotedString("Expected 'Quoted-string'"), + InvalidDomainPart("Expected 'Domain' or 'address-literal'"), + AddressLiteralsNotSupported("Address literals are not supported"), + + LocalPartLengthExceeded("Local part exceeds maximum length of $MAXIMUM_LOCAL_PART_LENGTH characters"), + DnsLabelLengthExceeded("DNS labels exceeds maximum length of $MAXIMUM_DNS_LABEL_LENGTH characters"), + DomainLengthExceeded("Domain exceeds maximum length of $MAXIMUM_DOMAIN_LENGTH characters"), + TotalLengthExceeded("The email address exceeds the maximum length of $MAXIMUM_EMAIL_ADDRESS_LENGTH characters"), + + QuotedStringInLocalPart("Quoted string in local part is not allowed by config"), + LocalPartRequiresQuotedString("Local part requiring the use of a quoted string is not allowed by config"), + EmptyLocalPart("Empty local part is not allowed by config"), + + UnexpectedCharacter("Caller needs to provide message"), +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserException.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserException.kt new file mode 100644 index 0000000..25a3a60 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailAddressParserException.kt @@ -0,0 +1,8 @@ +package net.thunderbird.core.common.mail + +class EmailAddressParserException internal constructor( + message: String, + val error: EmailAddressParserError, + val input: String, + val position: Int, +) : RuntimeException(message) diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailDomain.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailDomain.kt new file mode 100644 index 0000000..9447a38 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailDomain.kt @@ -0,0 +1,51 @@ +package net.thunderbird.core.common.mail + +import net.thunderbird.core.common.net.Domain + +/** + * The domain part of an email address. + * + * @param value String representation of the email domain with the original capitalization. + */ +class EmailDomain internal constructor(val value: String) { + /** + * The normalized (converted to lower case) string representation of this email domain. + */ + val normalized: String = value.lowercase() + + /** + * Returns this email domain with the original capitalization. + * + * @see value + */ + override fun toString(): String = value + + /** + * Compares the normalized string representations of two [EmailDomain] instances. + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as EmailDomain + + return normalized == other.normalized + } + + override fun hashCode(): Int { + return normalized.hashCode() + } + + companion object { + /** + * Parses the string representation of an email domain. + * + * @throws EmailAddressParserException in case of an error. + */ + fun parse(domain: String): EmailDomain { + return EmailDomainParser(domain).parseDomain() + } + } +} + +fun EmailDomain.toDomain() = Domain(value) diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailDomainParser.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailDomainParser.kt new file mode 100644 index 0000000..bd97e36 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/EmailDomainParser.kt @@ -0,0 +1,92 @@ +package net.thunderbird.core.common.mail + +import net.thunderbird.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded +import net.thunderbird.core.common.mail.EmailAddressParserError.DomainLengthExceeded +import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput + +// See RFC 1035, 2.3.4. +// For the string representation used in emails (labels separated by dots, no final dot allowed), we end up with a +// maximum of 253 characters. +internal const val MAXIMUM_DOMAIN_LENGTH = 253 + +// See RFC 1035, 2.3.4. +internal const val MAXIMUM_DNS_LABEL_LENGTH = 63 + +/** + * Parser for domain names in email addresses. + * + * From RFC 5321: + * ``` + * Domain = sub-domain *("." sub-domain) + * sub-domain = Let-dig [Ldh-str] + * Let-dig = ALPHA / DIGIT + * Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig + * ``` + */ +internal class EmailDomainParser( + input: String, + startIndex: Int = 0, + endIndex: Int = input.length, +) : AbstractParser(input, startIndex, endIndex) { + + fun parseDomain(): EmailDomain { + val domain = readDomain() + + if (!endReached()) { + parserError(ExpectedEndOfInput) + } + + return domain + } + + fun readDomain(): EmailDomain { + val domain = readString { + expectSubDomain() + + while (!endReached() && peek() == DOT) { + expect(DOT) + expectSubDomain() + } + } + + if (domain.length > MAXIMUM_DOMAIN_LENGTH) { + parserError(DomainLengthExceeded) + } + + return EmailDomain(domain) + } + + private fun expectSubDomain() { + val startIndex = currentIndex + + expectLetDig() + + var requireLetDig = false + while (!endReached()) { + val character = peek() + when { + character == HYPHEN -> { + requireLetDig = true + expect(HYPHEN) + } + character.isLetDig -> { + requireLetDig = false + expectLetDig() + } + else -> break + } + } + + if (requireLetDig) { + expectLetDig() + } + + if (currentIndex - startIndex > MAXIMUM_DNS_LABEL_LENGTH) { + parserError(DnsLabelLengthExceeded) + } + } + + private fun expectLetDig() { + expect("'Let-dig'") { it.isLetDig } + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/Protocols.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/Protocols.kt new file mode 100644 index 0000000..849f5a7 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/Protocols.kt @@ -0,0 +1,7 @@ +package net.thunderbird.core.common.mail + +object Protocols { + const val IMAP = "imap" + const val POP3 = "pop3" + const val SMTP = "smtp" +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/Tokens.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/Tokens.kt new file mode 100644 index 0000000..82fe231 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/mail/Tokens.kt @@ -0,0 +1,66 @@ +@file:Suppress("MagicNumber") + +package net.thunderbird.core.common.mail + +internal const val DQUOTE = '"' +internal const val DOT = '.' +internal const val AT = '@' +internal const val BACKSLASH = '\\' +internal const val HYPHEN = '-' + +internal val ATEXT_EXTRA = charArrayOf( + '!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~', +) + +// RFC 5234: ALPHA = %x41-5A / %x61-7A ; A-Z / a-z +internal val Char.isALPHA + get() = this in 'A'..'Z' || this in 'a'..'z' + +// RFC 5234: DIGIT = %x30-39 ; 0-9 +internal val Char.isDIGIT + get() = this in '0'..'9' + +// RFC 5322: +// atext = ALPHA / DIGIT / ; Printable US-ASCII +// "!" / "#" / ; characters not including +// "$" / "%" / ; specials. Used for atoms. +// "&" / "'" / +// "*" / "+" / +// "-" / "/" / +// "=" / "?" / +// "^" / "_" / +// "`" / "{" / +// "|" / "}" / +// "~" +internal val Char.isAtext + get() = isALPHA || isDIGIT || this in ATEXT_EXTRA + +// RFC 5321: qtextSMTP = %d32-33 / %d35-91 / %d93-126 +internal val Char.isQtext + get() = code.let { it in 32..33 || it in 35..91 || it in 93..126 } + +// RFC 5321: second character of quoted-pairSMTP = %d92 %d32-126 +internal val Char.isQuotedChar + get() = code in 32..126 + +// RFC 5321: +// Dot-string = Atom *("." Atom) +// Atom = 1*atext +internal val String.isDotString: Boolean + get() { + if (isEmpty() || this[0] == DOT || this[lastIndex] == DOT) return false + for (i in 0..lastIndex) { + val character = this[i] + when { + character == DOT -> if (this[i - 1] == DOT) return false + character.isAtext -> Unit + else -> return false + } + } + + return true + } + +// RFC 5321: Let-dig = ALPHA / DIGIT +internal val Char.isLetDig + get() = isALPHA || isDIGIT diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Domain.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Domain.kt new file mode 100644 index 0000000..a703adf --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Domain.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.common.net + +@JvmInline +value class Domain(val value: String) { + init { + requireNotNull(HostNameUtils.isLegalHostName(value)) { "Not a valid domain name: '$value'" } + } +} + +fun String.toDomain() = Domain(this) + +@Suppress("SwallowedException") +fun String.toDomainOrNull(): Domain? { + return try { + toDomain() + } catch (e: IllegalArgumentException) { + null + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/HostNameUtils.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/HostNameUtils.kt new file mode 100644 index 0000000..bbcde92 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/HostNameUtils.kt @@ -0,0 +1,225 @@ +package net.thunderbird.core.common.net + +/** + * Code to check the validity of host names and IP addresses. + * + * Based on + * [mailnews/base/src/hostnameUtils.jsm](https://searchfox.org/comm-central/source/mailnews/base/src/hostnameUtils.jsm) + * + * Note: The naming of these functions is inconsistent with the rest of the Android project to match the original + * source. Please use more appropriate names when refactoring this code. + */ +@Suppress("MagicNumber", "ReturnCount") +object HostNameUtils { + /** + * Check if `hostName` is an IP address or a valid hostname. + * + * @return Unobscured host name if `hostName` is valid. + */ + fun isLegalHostNameOrIP(hostName: String): String? { + /* + RFC 1123: + Whenever a user inputs the identity of an Internet host, it SHOULD + be possible to enter either (1) a host domain name or (2) an IP + address in dotted-decimal ("#.#.#.#") form. The host SHOULD check + the string syntactically for a dotted-decimal number before + looking it up in the Domain Name System. + */ + + return isLegalIPAddress(hostName) ?: isLegalHostName(hostName) + } + + /** + * Check if `hostName` is a valid IP address (IPv4 or IPv6). + * + * @return Unobscured canonicalized IPv4 or IPv6 address if it is valid, otherwise `null`. + */ + fun isLegalIPAddress(hostName: String): String? { + return isLegalIPv4Address(hostName) ?: isLegalIPv6Address(hostName) + } + + /** + * Check if `hostName` is a valid IPv4 address. + * + * @return Unobscured canonicalized address if `hostName` is an IPv4 address. Returns `null` if it's not. + */ + fun isLegalIPv4Address(hostName: String): String? { + // Break the IP address down into individual components. + val ipComponentStrings = hostName.split(".") + if (ipComponentStrings.size != 4) { + return null + } + + val ipComponents = ipComponentStrings.map { toIPv4NumericComponent(it) } + + if (ipComponents.any { it == null }) { + return null + } + + // First component of zero is not valid. + if (ipComponents.first() == 0) { + return null + } + + return hostName + } + + /** + * Converts an IPv4 address component to a number if it is valid. Returns `null` otherwise. + */ + private fun toIPv4NumericComponent(value: String): Int? { + return if (IPV4_COMPONENT_PATTERN.matches(value)) { + value.toInt(radix = 10).takeIf { it in 0..255 } + } else { + null + } + } + + /** + * Check if `hostName` is a valid IPv6 address. + * + * @returns Unobscured canonicalized address if `hostName` is an IPv6 address. Returns `null` if it's not. + */ + fun isLegalIPv6Address(hostName: String): String? { + // Break the IP address down into individual components. + val ipComponentStrings = hostName.lowercase().split(":") + + // Make sure there are at least 3 components. + if (ipComponentStrings.size < 3) { + return null + } + + // Take care if the last part is written in decimal using dots as separators. + val lastPart = isLegalIPv4Address(ipComponentStrings.last()) + val ipComponentHexStrings = if (lastPart != null) { + val lastPartComponents = lastPart.split(".").map { it.toInt(radix = 10) } + // Convert it into standard IPv6 components. + val part1 = ((lastPartComponents[0] shl 8) or lastPartComponents[1]).toString(radix = 16) + val part2 = ((lastPartComponents[2] shl 8) or lastPartComponents[3]).toString(radix = 16) + + ipComponentStrings.subList(0, ipComponentStrings.lastIndex) + part1 + part2 + } else { + ipComponentStrings + } + + // Make sure that there is only one empty component. + var emptyIndex = -1 + for (index in 1 until ipComponentHexStrings.lastIndex) { + if (ipComponentHexStrings[index] == "") { + // If we already found an empty component return null. + if (emptyIndex != -1) { + return null + } + + emptyIndex = index + } + } + + // If we found an empty component, extend it. + val fullIpComponentStrings = if (emptyIndex != -1) { + buildList(capacity = 8) { + for (i in 0 until emptyIndex) { + add(ipComponentHexStrings[i]) + } + + repeat(8 - ipComponentHexStrings.size + 1) { + add("0") + } + + for (i in (emptyIndex + 1)..ipComponentHexStrings.lastIndex) { + add(ipComponentHexStrings[i]) + } + } + } else { + ipComponentHexStrings + } + + // Make sure there are 8 components. + if (fullIpComponentStrings.size != 8) { + return null + } + + // Format all components to 4 character hex value. + val ipComponents = fullIpComponentStrings.map { ipComponentString -> + if (ipComponentString == "") { + 0 + } else if (IPV6_COMPONENT_PATTERN.matches(ipComponentString)) { + ipComponentString.toInt(radix = 16) + } else { + return null + } + } + + // Treat 0000:0000:0000:0000:0000:0000:0000:0000 as an invalid IPv6 address. + if (ipComponents.all { it == 0 }) { + return null + } + + // Pad the component with 0:s. + val canonicalIpComponents = ipComponents.map { it.toString(radix = 16).padStart(4, '0') } + + // TODO: support Zone indices in Link-local addresses? Currently they are rejected. + // http://en.wikipedia.org/wiki/IPv6_address#Link-local_addresses_and_zone_indices + + return canonicalIpComponents.joinToString(":") + } + + /** + * Check if `hostName` is a valid hostname. + * + * @returns The host name if it is valid. Returns `null` if it's not. + */ + fun isLegalHostName(hostName: String): String? { + /* + RFC 952: + A "name" (Net, Host, Gateway, or Domain name) is a text string up + to 24 characters drawn from the alphabet (A-Z), digits (0-9), minus + sign (-), and period (.). Note that periods are only allowed when + they serve to delimit components of "domain style names". (See + RFC-921, "Domain Name System Implementation Schedule", for + background). No blank or space characters are permitted as part of a + name. No distinction is made between upper and lower case. The first + character must be an alpha character. The last character must not be + a minus sign or period. + + RFC 1123: + The syntax of a legal Internet host name was specified in RFC-952 + [DNS:4]. One aspect of host name syntax is hereby changed: the + restriction on the first character is relaxed to allow either a + letter or a digit. Host software MUST support this more liberal + syntax. + + Host software MUST handle host names of up to 63 characters and + SHOULD handle host names of up to 255 characters. + + RFC 1034: + Relative names are either taken relative to a well known origin, or to a + list of domains used as a search list. Relative names appear mostly at + the user interface, where their interpretation varies from + implementation to implementation, and in master files, where they are + relative to a single origin domain name. The most common interpretation + uses the root "." as either the single origin or as one of the members + of the search list, so a multi-label relative name is often one where + the trailing dot has been omitted to save typing. + + Since a complete domain name ends with the root label, this leads to + a printed form which ends in a dot. + */ + + return hostName.takeIf { hostName.length <= 255 && HOST_PATTERN.matches(hostName) } + } + + /** + * Clean up the hostname or IP. Usually used to sanitize a value input by the user. + * It is usually applied before we know if the hostname is even valid. + */ + fun cleanUpHostName(hostName: String): String { + return hostName.trim() + } + + private const val LDH_LABEL = "([a-z0-9]|[a-z0-9][a-z0-9\\-]{0,61}[a-z0-9])" + private val HOST_PATTERN = """($LDH_LABEL\.)*$LDH_LABEL\.?""".toRegex(RegexOption.IGNORE_CASE) + + private val IPV4_COMPONENT_PATTERN = "(0|([1-9][0-9]{0,2}))".toRegex() + private val IPV6_COMPONENT_PATTERN = "[0-9a-f]{1,4}".toRegex() +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Hostname.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Hostname.kt new file mode 100644 index 0000000..0182e65 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Hostname.kt @@ -0,0 +1,15 @@ +package net.thunderbird.core.common.net + +/** + * Represents a hostname, IPv4, or IPv6 address. + */ +@JvmInline +value class Hostname(val value: String) { + init { + requireNotNull(HostNameUtils.isLegalHostNameOrIP(value)) { "Not a valid domain or IP: '$value'" } + } +} + +fun String.toHostname() = Hostname(this) + +fun Hostname.isIpAddress(): Boolean = HostNameUtils.isLegalIPAddress(value) != null diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Port.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Port.kt new file mode 100644 index 0000000..78a6fd8 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/net/Port.kt @@ -0,0 +1,11 @@ +package net.thunderbird.core.common.net + +@Suppress("MagicNumber") +@JvmInline +value class Port(val value: Int) { + init { + require(value in 1..65535) { "Not a valid port number: $value" } + } +} + +fun Int.toPort() = Port(this) diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/InMemoryOAuthConfigurationProvider.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/InMemoryOAuthConfigurationProvider.kt new file mode 100644 index 0000000..2a4a333 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/InMemoryOAuthConfigurationProvider.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.common.oauth + +import kotlin.collections.iterator + +internal class InMemoryOAuthConfigurationProvider( + private val configurationFactory: OAuthConfigurationFactory, +) : OAuthConfigurationProvider { + + private val hostnameMapping: Map = buildMap { + for ((hostnames, configuration) in configurationFactory.createConfigurations()) { + for (hostname in hostnames) { + put(hostname.lowercase(), configuration) + } + } + } + + override fun getConfiguration(hostname: String): OAuthConfiguration? { + return hostnameMapping[hostname.lowercase()] + } +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfiguration.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfiguration.kt new file mode 100644 index 0000000..974649c --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfiguration.kt @@ -0,0 +1,9 @@ +package net.thunderbird.core.common.oauth + +data class OAuthConfiguration( + val clientId: String, + val scopes: List, + val authorizationEndpoint: String, + val tokenEndpoint: String, + val redirectUri: String, +) diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfigurationFactory.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfigurationFactory.kt new file mode 100644 index 0000000..19f3808 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfigurationFactory.kt @@ -0,0 +1,5 @@ +package net.thunderbird.core.common.oauth + +fun interface OAuthConfigurationFactory { + fun createConfigurations(): Map, OAuthConfiguration> +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfigurationProvider.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfigurationProvider.kt new file mode 100644 index 0000000..3bf8549 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/oauth/OAuthConfigurationProvider.kt @@ -0,0 +1,5 @@ +package net.thunderbird.core.common.oauth + +fun interface OAuthConfigurationProvider { + fun getConfiguration(hostname: String): OAuthConfiguration? +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/provider/AppNameProvider.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/provider/AppNameProvider.kt new file mode 100644 index 0000000..6e4557d --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/provider/AppNameProvider.kt @@ -0,0 +1,8 @@ +package net.thunderbird.core.common.provider + +/** + * Provides the application name. + */ +interface AppNameProvider { + val appName: String +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/provider/BrandNameProvider.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/provider/BrandNameProvider.kt new file mode 100644 index 0000000..1595f2f --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/provider/BrandNameProvider.kt @@ -0,0 +1,8 @@ +package net.thunderbird.core.common.provider + +/** + * Provides the brand name, e.g. Thunderbird. + */ +interface BrandNameProvider { + val brandName: String +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/PluralsResourceManager.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/PluralsResourceManager.kt new file mode 100644 index 0000000..7ed5f30 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/PluralsResourceManager.kt @@ -0,0 +1,28 @@ +package net.thunderbird.core.common.resources + +// TODO: Add support for Multiplatform resources. See https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources.html +interface PluralsResourceManager { + /** + * Formats the string necessary for grammatically correct pluralization + * of the given resource ID for the given quantity, using the given arguments. + * Note that the string is selected based solely on grammatical necessity, + * and that such rules differ between languages. Do not assume you know which string + * will be returned for a given quantity. See + * String Resources + * for more detail. + * + *

Substitution of format arguments works as if using + * {@link java.util.Formatter} and {@link java.lang.String#format}. + * The resulting string will be stripped of any styled text information. + * + * @param resourceId The desired resource identifier, as generated by the aapt tool. This integer + * encodes the package, type, and resource entry. The value 0 is an invalid identifier. + * @param quantity The number used to get the correct string for the current language's plural rules. + * @param formatArgs The format arguments that will be used for substitution. + * @throws net.thunderbird.core.common.resources.ResourceNotFoundException Throws NotFoundException if the given ID + * does not exist. + * @return String The string data associated with the resource, + * stripped of styled text information. + */ + fun pluralsString(@PluralsRes resourceId: Int, quantity: Int, vararg formatArgs: Any?): String +} diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.kt new file mode 100644 index 0000000..113d2a2 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.kt @@ -0,0 +1,4 @@ +package net.thunderbird.core.common.resources + +expect annotation class StringRes() +expect annotation class PluralsRes() diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceManager.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceManager.kt new file mode 100644 index 0000000..067cd81 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceManager.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.common.resources + +/** + * Represents a comprehensive resource manager that combines string and plural resource handling. + * + * This interface extends both [StringsResourceManager] and [PluralsResourceManager], providing a unified + * interface for accessing different types of string-based resources within the application. + * + * Implementations of this interface should provide concrete implementations for fetching both + * simple strings and pluralized strings based on their respective resource IDs. + */ +interface ResourceManager : StringsResourceManager, PluralsResourceManager diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.kt new file mode 100644 index 0000000..d2be1f6 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.kt @@ -0,0 +1,8 @@ +package net.thunderbird.core.common.resources + +/** + * Exception thrown when a requested resource cannot be found. + * This can occur, for example, when trying to access a file or data + * that does not exist at the specified location. + */ +expect class ResourceNotFoundException diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/StringsResourceManager.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/StringsResourceManager.kt new file mode 100644 index 0000000..d097b79 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/resources/StringsResourceManager.kt @@ -0,0 +1,35 @@ +package net.thunderbird.core.common.resources + +// TODO: Add support for Multiplatform resources. See https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources.html +interface StringsResourceManager { + /** + * Return the string value associated with a particular resource ID. It + * will be stripped of any styled text information. + * + * @param resourceId The desired resource identifier, as generated by the aapt tool. + * This integer encodes the package, type, and resource entry. The value 0 is an + * invalid identifier. + * @throws net.thunderbird.core.common.resources.ResourceNotFoundException Throws NotFoundException if the given ID + * does not exist. + * @return String The string data associated with the resource, stripped of styled + * text information. + */ + fun stringResource(@StringRes resourceId: Int): String + + /** + * Return the string value associated with a particular resource ID, + * substituting the format arguments as defined in {@link java.util.Formatter} + * and {@link java.lang.String#format}. It will be stripped of any styled text + * information. + * + * @param resourceId The desired resource identifier, as generated by the aapt tool. + * This integer encodes the package, type, and resource entry. The value 0 is an invalid + * identifier. + * @param formatArgs The format arguments that will be used for substitution. + * @throws net.thunderbird.core.common.resources.ResourceNotFoundException Throws NotFoundException if the given ID + * does not exist. + * @return String The string data associated with the resource, stripped of styled text + * information. + */ + fun stringResource(@StringRes resourceId: Int, vararg formatArgs: Any?): String +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/CoreCommonModuleKtTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/CoreCommonModuleKtTest.kt new file mode 100644 index 0000000..70cde47 --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/CoreCommonModuleKtTest.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.common + +import net.thunderbird.core.common.oauth.OAuthConfigurationFactory +import org.junit.Test +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.test.verify.verify + +internal class CoreCommonModuleKtTest { + + @OptIn(KoinExperimentalAPI::class) + @Test + fun `should have a valid di module`() { + coreCommonModule.verify( + extraTypes = listOf( + OAuthConfigurationFactory::class, + ), + ) + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/CacheTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/CacheTest.kt new file mode 100644 index 0000000..ff0ea54 --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/CacheTest.kt @@ -0,0 +1,85 @@ +package net.thunderbird.core.common.cache + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import kotlin.test.Test +import kotlin.time.ExperimentalTime +import net.thunderbird.core.testing.TestClock +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +data class CacheTestData( + val name: String, + val createCache: () -> Cache, +) { + override fun toString(): String = name +} + +@RunWith(Parameterized::class) +class CacheTest(data: CacheTestData) { + + private val testSubject = data.createCache() + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> { + return listOf( + CacheTestData("InMemoryCache") { InMemoryCache() }, + CacheTestData("ExpiringCache") { + @OptIn(ExperimentalTime::class) + ExpiringCache(TestClock(), InMemoryCache()) + }, + CacheTestData("SynchronizedCache") { SynchronizedCache(InMemoryCache()) }, + ) + } + + const val KEY = "key" + const val VALUE = "value" + } + + @Test + fun `get should return null with empty cache`() { + assertThat(testSubject[KEY]).isNull() + } + + @Test + fun `set should add entry with empty cache`() { + testSubject[KEY] = VALUE + + assertThat(testSubject[KEY]).isEqualTo(VALUE) + } + + @Test + fun `set should overwrite entry when already present`() { + testSubject[KEY] = VALUE + + testSubject[KEY] = "$VALUE changed" + + assertThat(testSubject[KEY]).isEqualTo("$VALUE changed") + } + + @Test + fun `hasKey should answer no with empty cache`() { + assertThat(testSubject.hasKey(KEY)).isFalse() + } + + @Test + fun `hasKey should answer yes when cache has entry`() { + testSubject[KEY] = VALUE + + assertThat(testSubject.hasKey(KEY)).isTrue() + } + + @Test + fun `clear should empty cache`() { + testSubject[KEY] = VALUE + + testSubject.clear() + + assertThat(testSubject[KEY]).isNull() + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/ExpiringCacheTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/ExpiringCacheTest.kt new file mode 100644 index 0000000..108a544 --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/ExpiringCacheTest.kt @@ -0,0 +1,74 @@ +package net.thunderbird.core.common.cache + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import net.thunderbird.core.common.cache.Cache +import net.thunderbird.core.common.cache.ExpiringCache +import net.thunderbird.core.common.cache.InMemoryCache +import net.thunderbird.core.testing.TestClock + +class ExpiringCacheTest { + + @OptIn(ExperimentalTime::class) + private val clock = TestClock() + + @OptIn(ExperimentalTime::class) + private val testSubject: Cache = ExpiringCache(clock, InMemoryCache()) + + @Test + fun `get should return null when entry present and cache expired`() { + testSubject[KEY] = VALUE + clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION) + + val result = testSubject[KEY] + + assertThat(result).isNull() + } + + @Test + fun `set should clear cache and add new entry when cache expired`() { + testSubject[KEY] = VALUE + clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION) + + testSubject[KEY + 1] = "$VALUE changed" + + assertThat(testSubject[KEY]).isNull() + assertThat(testSubject[KEY + 1]).isEqualTo("$VALUE changed") + } + + @Test + fun `hasKey should answer no when cache has entry and validity expired`() { + testSubject[KEY] = VALUE + clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION) + + assertThat(testSubject.hasKey(KEY)).isFalse() + } + + @Test + fun `should keep cache when time progresses within expiration`() { + testSubject[KEY] = VALUE + clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION.minus(1L.milliseconds)) + + assertThat(testSubject[KEY]).isEqualTo(VALUE) + } + + @Test + fun `should empty cache after time progresses to expiration`() { + testSubject[KEY] = VALUE + + clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION) + + assertThat(testSubject[KEY]).isNull() + } + + private companion object { + const val KEY = "key" + const val VALUE = "value" + val CACHE_TIME_VALIDITY_DURATION = 30_000L.milliseconds + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/TimeLimitedCacheTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/TimeLimitedCacheTest.kt new file mode 100644 index 0000000..15f0c06 --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/TimeLimitedCacheTest.kt @@ -0,0 +1,97 @@ +package net.thunderbird.core.common.cache + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import net.thunderbird.core.testing.TestClock + +@OptIn(ExperimentalTime::class) +class TimeLimitedCacheTest { + + private val clock = TestClock() + private val cache = TimeLimitedCache(clock = clock) + + @Test + fun `getValue should return null when entry present and expired`() { + // Arrange + cache.set(KEY, VALUE, expiresIn = EXPIRES_IN) + clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds) + + // Act + val result = cache.getValue(KEY) + + // Assert + assertThat(result).isNull() + } + + @Test + fun `hasKey should answer false when cache has entry and validity expired`() { + // Arrange + cache.set(KEY, VALUE, expiresIn = EXPIRES_IN) + clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds) + + // Act + val result = cache.hasKey(KEY) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `should keep cache when time progresses within expiration`() { + // Arrange + cache.set(KEY, VALUE, expiresIn = EXPIRES_IN) + clock.advanceTimeBy(EXPIRES_IN - 1.milliseconds) + + // Act + val result = cache.getValue(KEY) + + // Assert + assertThat(result).isEqualTo(VALUE) + } + + @Test + fun `clearExpired should remove only expired entries`() { + // Arrange + cache.set(KEY, VALUE, expiresIn = EXPIRES_IN) + cache.set(KEY_2, VALUE_2, expiresIn = EXPIRES_IN * 2) + clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds) + + // Act + cache.clearExpired() + + // Assert + assertThat(cache.getValue(KEY)).isNull() + assertThat(cache.getValue(KEY_2)).isEqualTo(VALUE_2) + } + + @Test + fun `get should return Entry with correct metadata when not expired`() { + // Arrange + cache.set(KEY, VALUE, expiresIn = EXPIRES_IN) + + // Act + val entry = cache[KEY] + + // Assert + assertThat(entry).isNotNull() + entry!! + assertThat(entry.value).isEqualTo(VALUE) + assertThat(entry.expiresIn).isEqualTo(EXPIRES_IN) + assertThat(entry.expiresAt).isEqualTo(entry.creationTime + EXPIRES_IN) + } + + private companion object { + const val KEY = "key" + const val KEY_2 = "key2" + const val VALUE = "value" + const val VALUE_2 = "value2" + val EXPIRES_IN: Duration = 500.milliseconds + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailAddressParserTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailAddressParserTest.kt new file mode 100644 index 0000000..3fd1236 --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailAddressParserTest.kt @@ -0,0 +1,321 @@ +package net.thunderbird.core.common.mail + +import assertk.all +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import kotlin.test.Test +import net.thunderbird.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported +import net.thunderbird.core.common.mail.EmailAddressParserError.EmptyLocalPart +import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDomainPart +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDotString +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidLocalPart +import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidQuotedString +import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded +import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString +import net.thunderbird.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart +import net.thunderbird.core.common.mail.EmailAddressParserError.TotalLengthExceeded +import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedCharacter + +class EmailAddressParserTest { + @Test + fun `simple address`() { + val emailAddress = parseEmailAddress("alice@domain.example") + + assertThat(emailAddress.localPart).isEqualTo("alice") + assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example")) + } + + @Test + fun `local part containing dot`() { + val emailAddress = parseEmailAddress("alice.lastname@domain.example") + + assertThat(emailAddress.localPart).isEqualTo("alice.lastname") + assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example")) + } + + @Test + fun `quoted local part`() { + val emailAddress = parseEmailAddress( + address = "\"one two\"@domain.example", + isLocalPartRequiringQuotedStringAllowed = true, + ) + + assertThat(emailAddress.localPart).isEqualTo("one two") + assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example")) + } + + @Test + fun `quoted local part not allowed`() { + assertFailure { + parseEmailAddress( + address = "\"one two\"@domain.example", + isQuotedLocalPartAllowed = true, + isLocalPartRequiringQuotedStringAllowed = false, + ) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(LocalPartRequiresQuotedString) + prop(EmailAddressParserException::position).isEqualTo(0) + hasMessage("Local part requiring the use of a quoted string is not allowed by config") + } + } + + @Test + fun `unnecessarily quoted local part`() { + val emailAddress = parseEmailAddress( + address = "\"user\"@domain.example", + isQuotedLocalPartAllowed = true, + isLocalPartRequiringQuotedStringAllowed = false, + ) + + assertThat(emailAddress.localPart).isEqualTo("user") + assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example")) + assertThat(emailAddress.address).isEqualTo("user@domain.example") + } + + @Test + fun `unnecessarily quoted local part not allowed`() { + assertFailure { + parseEmailAddress("\"user\"@domain.example", isQuotedLocalPartAllowed = false) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(QuotedStringInLocalPart) + prop(EmailAddressParserException::position).isEqualTo(0) + hasMessage("Quoted string in local part is not allowed by config") + } + } + + @Test + fun `quoted local part containing double quote character`() { + val emailAddress = parseEmailAddress( + address = """"a\"b"@domain.example""", + isLocalPartRequiringQuotedStringAllowed = true, + ) + + assertThat(emailAddress.localPart).isEqualTo("a\"b") + assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example")) + assertThat(emailAddress.address).isEqualTo(""""a\"b"@domain.example""") + } + + @Test + fun `empty local part`() { + val emailAddress = parseEmailAddress("\"\"@domain.example", isEmptyLocalPartAllowed = true) + + assertThat(emailAddress.localPart).isEqualTo("") + assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example")) + assertThat(emailAddress.address).isEqualTo("\"\"@domain.example") + } + + @Test + fun `empty local part not allowed`() { + assertFailure { + parseEmailAddress( + address = "\"\"@domain.example", + isLocalPartRequiringQuotedStringAllowed = true, + isEmptyLocalPartAllowed = false, + ) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(EmptyLocalPart) + prop(EmailAddressParserException::position).isEqualTo(1) + hasMessage("Empty local part is not allowed by config") + } + } + + @Test + fun `IPv4 address literal`() { + assertFailure { + parseEmailAddress("user@[255.0.100.23]") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported) + prop(EmailAddressParserException::position).isEqualTo(5) + hasMessage("Address literals are not supported") + } + } + + @Test + fun `IPv6 address literal`() { + assertFailure { + parseEmailAddress("user@[IPv6:2001:0db8:0000:0000:0000:ff00:0042:8329]") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported) + prop(EmailAddressParserException::position).isEqualTo(5) + hasMessage("Address literals are not supported") + } + } + + @Test + fun `domain part starts with unsupported value`() { + assertFailure { + parseEmailAddress("user@ä") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(InvalidDomainPart) + prop(EmailAddressParserException::position).isEqualTo(5) + hasMessage("Expected 'Domain' or 'address-literal'") + } + } + + @Test + fun `obsolete syntax`() { + assertFailure { + parseEmailAddress("\"quoted\".atom@domain.example", isLocalPartRequiringQuotedStringAllowed = true) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter) + prop(EmailAddressParserException::position).isEqualTo(8) + hasMessage("Expected '@' (64)") + } + } + + @Test + fun `local part starting with dot`() { + assertFailure { + parseEmailAddress(".invalid@domain.example") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(InvalidLocalPart) + prop(EmailAddressParserException::position).isEqualTo(0) + hasMessage("Expected 'Dot-string' or 'Quoted-string'") + } + } + + @Test + fun `local part ending with dot`() { + assertFailure { + parseEmailAddress("invalid.@domain.example") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(InvalidDotString) + prop(EmailAddressParserException::position).isEqualTo(8) + hasMessage("Expected 'Dot-string'") + } + } + + @Test + fun `quoted local part missing closing double quote`() { + assertFailure { + parseEmailAddress("\"invalid@domain.example", isLocalPartRequiringQuotedStringAllowed = true) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter) + prop(EmailAddressParserException::position).isEqualTo(23) + hasMessage("Expected '\"' (34)") + } + } + + @Test + fun `quoted text containing unsupported character`() { + assertFailure { + parseEmailAddress("\"ä\"@domain.example", isLocalPartRequiringQuotedStringAllowed = true) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString) + prop(EmailAddressParserException::position).isEqualTo(1) + hasMessage("Expected 'Quoted-string'") + } + } + + @Test + fun `quoted text containing unsupported escaped character`() { + assertFailure { + parseEmailAddress(""""\ä"@domain.example""", isLocalPartRequiringQuotedStringAllowed = true) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString) + prop(EmailAddressParserException::position).isEqualTo(3) + hasMessage("Expected 'Quoted-string'") + } + } + + @Test + fun `local part exceeds maximum size with length check enabled`() { + assertFailure { + parseEmailAddress( + address = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345@domain.example", + isLocalPartLengthCheckEnabled = true, + ) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(LocalPartLengthExceeded) + prop(EmailAddressParserException::position).isEqualTo(65) + hasMessage("Local part exceeds maximum length of 64 characters") + } + } + + @Test + fun `local part exceeds maximum size with length check disabled`() { + val input = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345@domain.example" + + val emailAddress = parseEmailAddress(address = input, isLocalPartLengthCheckEnabled = false) + + assertThat(emailAddress.localPart) + .isEqualTo("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345") + assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example")) + assertThat(emailAddress.address).isEqualTo(input) + assertThat(emailAddress.warnings).contains(EmailAddress.Warning.LocalPartExceedsLengthLimit) + } + + @Test + fun `email exceeds maximum size with length check enabled`() { + assertFailure { + parseEmailAddress( + address = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234@" + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12", + isEmailAddressLengthCheckEnabled = true, + ) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(TotalLengthExceeded) + prop(EmailAddressParserException::position).isEqualTo(255) + hasMessage("The email address exceeds the maximum length of 254 characters") + } + } + + @Test + fun `email exceeds maximum size with length check disabled`() { + val input = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234@" + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12" + + val emailAddress = parseEmailAddress(address = input, isEmailAddressLengthCheckEnabled = false) + + assertThat(emailAddress.localPart) + .isEqualTo("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234") + assertThat(emailAddress.domain).isEqualTo( + EmailDomain( + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12", + ), + ) + assertThat(emailAddress.address).isEqualTo(input) + assertThat(emailAddress.warnings).contains(EmailAddress.Warning.EmailAddressExceedsLengthLimit) + } + + @Test + fun `input contains additional character`() { + assertFailure { + parseEmailAddress("test@domain.example#") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput) + prop(EmailAddressParserException::position).isEqualTo(19) + hasMessage("Expected end of input") + } + } + + private fun parseEmailAddress( + address: String, + isLocalPartLengthCheckEnabled: Boolean = false, + isEmailAddressLengthCheckEnabled: Boolean = false, + isEmptyLocalPartAllowed: Boolean = false, + isLocalPartRequiringQuotedStringAllowed: Boolean = isEmptyLocalPartAllowed, + isQuotedLocalPartAllowed: Boolean = isLocalPartRequiringQuotedStringAllowed, + ): EmailAddress { + val config = EmailAddressParserConfig( + isLocalPartLengthCheckEnabled, + isEmailAddressLengthCheckEnabled, + isQuotedLocalPartAllowed, + isLocalPartRequiringQuotedStringAllowed, + isEmptyLocalPartAllowed, + ) + return EmailAddressParser(address, config).parse() + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailAddressTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailAddressTest.kt new file mode 100644 index 0000000..ead241a --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailAddressTest.kt @@ -0,0 +1,60 @@ +package net.thunderbird.core.common.mail + +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isSameInstanceAs +import kotlin.test.Test +import net.thunderbird.core.common.mail.EmailAddress.Warning + +class EmailAddressTest { + @Test + fun `simple email address`() { + val domain = EmailDomain("DOMAIN.example") + val emailAddress = EmailAddress(localPart = "user", domain = domain) + + assertThat(emailAddress.localPart).isEqualTo("user") + assertThat(emailAddress.encodedLocalPart).isEqualTo("user") + assertThat(emailAddress.domain).isSameInstanceAs(domain) + assertThat(emailAddress.address).isEqualTo("user@DOMAIN.example") + assertThat(emailAddress.normalizedAddress).isEqualTo("user@domain.example") + assertThat(emailAddress.toString()).isEqualTo("user@DOMAIN.example") + assertThat(emailAddress.warnings).isEmpty() + } + + @Test + fun `local part that requires use of quoted string`() { + val emailAddress = EmailAddress(localPart = "foo bar", domain = EmailDomain("domain.example")) + + assertThat(emailAddress.localPart).isEqualTo("foo bar") + assertThat(emailAddress.encodedLocalPart).isEqualTo("\"foo bar\"") + assertThat(emailAddress.address).isEqualTo("\"foo bar\"@domain.example") + assertThat(emailAddress.normalizedAddress).isEqualTo("\"foo bar\"@domain.example") + assertThat(emailAddress.toString()).isEqualTo("\"foo bar\"@domain.example") + assertThat(emailAddress.warnings).containsExactlyInAnyOrder(Warning.QuotedStringInLocalPart) + } + + @Test + fun `empty local part`() { + val emailAddress = EmailAddress(localPart = "", domain = EmailDomain("domain.example")) + + assertThat(emailAddress.localPart).isEqualTo("") + assertThat(emailAddress.encodedLocalPart).isEqualTo("\"\"") + assertThat(emailAddress.address).isEqualTo("\"\"@domain.example") + assertThat(emailAddress.normalizedAddress).isEqualTo("\"\"@domain.example") + assertThat(emailAddress.toString()).isEqualTo("\"\"@domain.example") + assertThat(emailAddress.warnings).containsExactlyInAnyOrder( + Warning.QuotedStringInLocalPart, + Warning.EmptyLocalPart, + ) + } + + @Test + fun `equals() does case-insensitive domain comparison`() { + val emailAddress1 = EmailAddress(localPart = "user", domain = EmailDomain("domain.example")) + val emailAddress2 = EmailAddress(localPart = "user", domain = EmailDomain("DOMAIN.example")) + + assertThat(emailAddress2).isEqualTo(emailAddress1) + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailDomainParserTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailDomainParserTest.kt new file mode 100644 index 0000000..801b318 --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailDomainParserTest.kt @@ -0,0 +1,88 @@ +package net.thunderbird.core.common.mail + +import assertk.all +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import kotlin.test.Test +import net.thunderbird.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded +import net.thunderbird.core.common.mail.EmailAddressParserError.DomainLengthExceeded +import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput +import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedCharacter + +class EmailDomainParserTest { + @Test + fun `simple domain`() { + val emailDomain = parseEmailDomain("DOMAIN.example") + + assertThat(emailDomain.value).isEqualTo("DOMAIN.example") + assertThat(emailDomain.normalized).isEqualTo("domain.example") + } + + @Test + fun `label starting with hyphen`() { + assertFailure { + parseEmailDomain("-domain.example") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter) + prop(EmailAddressParserException::position).isEqualTo(0) + hasMessage("Expected 'Let-dig'") + } + } + + @Test + fun `label ending with hyphen`() { + assertFailure { + parseEmailDomain("domain-.example") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter) + prop(EmailAddressParserException::position).isEqualTo(7) + hasMessage("Expected 'Let-dig'") + } + } + + @Test + fun `label exceeds maximum size`() { + assertFailure { + parseEmailDomain("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234.example") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(DnsLabelLengthExceeded) + prop(EmailAddressParserException::position).isEqualTo(64) + hasMessage("DNS labels exceeds maximum length of 63 characters") + } + } + + @Test + fun `domain exceeds maximum size`() { + assertFailure { + parseEmailDomain( + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12", + ) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(DomainLengthExceeded) + prop(EmailAddressParserException::position).isEqualTo(254) + hasMessage("Domain exceeds maximum length of 253 characters") + } + } + + @Test + fun `input contains additional character`() { + assertFailure { + parseEmailDomain("domain.example#") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput) + prop(EmailAddressParserException::position).isEqualTo(14) + hasMessage("Expected end of input") + } + } + + private fun parseEmailDomain(domain: String): EmailDomain { + return EmailDomainParser(domain).parseDomain() + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailDomainTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailDomainTest.kt new file mode 100644 index 0000000..aac6b6b --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/mail/EmailDomainTest.kt @@ -0,0 +1,24 @@ +package net.thunderbird.core.common.mail + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class EmailDomainTest { + @Test + fun `simple domain`() { + val domain = EmailDomain("DOMAIN.example") + + assertThat(domain.value).isEqualTo("DOMAIN.example") + assertThat(domain.normalized).isEqualTo("domain.example") + assertThat(domain.toString()).isEqualTo("DOMAIN.example") + } + + @Test + fun `equals() does case-insensitive comparison`() { + val domain1 = EmailDomain("domain.example") + val domain2 = EmailDomain("DOMAIN.example") + + assertThat(domain2).isEqualTo(domain1) + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/DomainTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/DomainTest.kt new file mode 100644 index 0000000..935481f --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/DomainTest.kt @@ -0,0 +1,25 @@ +package net.thunderbird.core.common.net + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import org.junit.Test + +class DomainTest { + @Test + fun `valid domain`() { + val domain = Domain("domain.example") + + assertThat(domain.value).isEqualTo("domain.example") + } + + @Test + fun `invalid domain should throw`() { + assertFailure { + Domain("invalid domain") + }.isInstanceOf() + .hasMessage("Not a valid domain name: 'invalid domain'") + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/HostNameUtilsTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/HostNameUtilsTest.kt new file mode 100644 index 0000000..dc607eb --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/HostNameUtilsTest.kt @@ -0,0 +1,165 @@ +package net.thunderbird.core.common.net + +import assertk.Assert +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import org.junit.Test + +/** + * Test data copied from `mailnews/base/test/unit/test_hostnameUtils.js` + */ +class HostNameUtilsTest { + @Test + fun `valid host names`() { + assertThat("localhost").isLegalHostName() + assertThat("some-server").isLegalHostName() + assertThat("server.company.invalid").isLegalHostName() + assertThat("server.comp-any.invalid").isLegalHostName() + assertThat("server.123.invalid").isLegalHostName() + assertThat("1server.123.invalid").isLegalHostName() + assertThat("1.2.3.4.5").isLegalHostName() + assertThat("very.log.sub.domain.name.invalid").isLegalHostName() + assertThat("1234567890").isLegalHostName() + assertThat("1234567890.").isLegalHostName() + assertThat("server.company.invalid.").isLegalHostName() + } + + @Test + fun `invalid host names`() { + assertThat("").isNotLegalHostName() + assertThat("server.badcompany!.invalid").isNotLegalHostName() + assertThat("server._badcompany.invalid").isNotLegalHostName() + assertThat("server.bad_company.invalid").isNotLegalHostName() + assertThat("server.badcompany-.invalid").isNotLegalHostName() + assertThat("server.bad company.invalid").isNotLegalHostName() + assertThat("server.b…dcompany.invalid").isNotLegalHostName() + assertThat(".server.badcompany.invalid").isNotLegalHostName() + assertThat("make-this-a-long-host-name-component-that-is-over-63-characters-long.invalid").isNotLegalHostName() + assertThat( + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." + + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." + + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." + + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid", + ).isNotLegalHostName() + } + + @Test + fun `valid IPv4 addresses`() { + assertThat("1.2.3.4").isLegalIPv4Address() + assertThat("123.245.111.222").isLegalIPv4Address() + assertThat("255.255.255.255").isLegalIPv4Address() + assertThat("1.2.0.4").isLegalIPv4Address() + assertThat("1.2.3.4").isLegalIPv4Address() + assertThat("127.1.2.3").isLegalIPv4Address() + assertThat("10.1.2.3").isLegalIPv4Address() + assertThat("192.168.2.3").isLegalIPv4Address() + } + + @Test + fun `invalid IPv4 addresses`() { + assertThat("1.2.3.4.5").isNotLegalIPv4Address() + assertThat("1.2.3").isNotLegalIPv4Address() + assertThat("1.2.3.").isNotLegalIPv4Address() + assertThat(".1.2.3").isNotLegalIPv4Address() + assertThat("1.2.3.256").isNotLegalIPv4Address() + assertThat("1.2.3.12345").isNotLegalIPv4Address() + assertThat("1.2..123").isNotLegalIPv4Address() + assertThat("1").isNotLegalIPv4Address() + assertThat("").isNotLegalIPv4Address() + assertThat("0.1.2.3").isNotLegalIPv4Address() + assertThat("0.0.2.3").isNotLegalIPv4Address() + assertThat("0.0.0.0").isNotLegalIPv4Address() + assertThat("1.2.3.d").isNotLegalIPv4Address() + assertThat("a.b.c.d").isNotLegalIPv4Address() + assertThat("a.b.c.d").isNotLegalIPv4Address() + + // Extended formats of IPv4, hex, octal, decimal up to DWORD + // We intentionally don't support any of these. + assertThat("0xff.0x12.0x45.0x78").isNotLegalIPv4Address() + assertThat("01.0123.056.077").isNotLegalIPv4Address() + assertThat("0xff.2.3.4").isNotLegalIPv4Address() + assertThat("0xff.2.3.077").isNotLegalIPv4Address() + assertThat("0x7f.2.3.077").isNotLegalIPv4Address() + assertThat("0xZZ.1.2.3").isNotLegalIPv4Address() + assertThat("0x00.0123.056.077").isNotLegalIPv4Address() + assertThat("0x11.0123.056.078").isNotLegalIPv4Address() + assertThat("0x11.0123.056.0789").isNotLegalIPv4Address() + assertThat("1234566945").isNotLegalIPv4Address() + assertThat("12345").isNotLegalIPv4Address() + assertThat("123456789123456").isNotLegalIPv4Address() + assertThat("127.1").isNotLegalIPv4Address() + assertThat("0x7f.100").isNotLegalIPv4Address() + assertThat("0x7f.100.1000").isNotLegalIPv4Address() + assertThat("0xff.100.1024").isNotLegalIPv4Address() + assertThat("0xC0.0xA8.0x2A48").isNotLegalIPv4Address() + assertThat("0xC0.0xA82A48").isNotLegalIPv4Address() + assertThat("0xC0A82A48").isNotLegalIPv4Address() + assertThat("0324.062477106").isNotLegalIPv4Address() + assertThat("0.0.1000").isNotLegalIPv4Address() + assertThat("0324.06247710677").isNotLegalIPv4Address() + } + + @Test + fun `valid IPv6 addresses`() { + assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:7334").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334") + assertThat("2001:db8:85a3:0:0:8a2e:370:7334").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334") + assertThat("2001:db8:85a3::8a2e:370:7334").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334") + assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:0000") + assertThat("::ffff:c000:0280").isNormalizedTo("0000:0000:0000:0000:0000:ffff:c000:0280") + assertThat("::ffff:192.0.2.128").isNormalizedTo("0000:0000:0000:0000:0000:ffff:c000:0280") + assertThat("2001:db8::1").isNormalizedTo("2001:0db8:0000:0000:0000:0000:0000:0001") + assertThat("2001:DB8::1").isNormalizedTo("2001:0db8:0000:0000:0000:0000:0000:0001") + assertThat("1:2:3:4:5:6:7:8").isNormalizedTo("0001:0002:0003:0004:0005:0006:0007:0008") + assertThat("::1").isNormalizedTo("0000:0000:0000:0000:0000:0000:0000:0001") + assertThat("::0000:0000:1").isNormalizedTo("0000:0000:0000:0000:0000:0000:0000:0001") + } + + @Test + fun `invalid IPv6 addresses`() { + assertThat("::").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:73346").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:7334:1").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:7334x").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000:8a2e:03707334").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000x8a2e:0370:7334").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000:::1").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000:0000:some:junk").isNotLegalIPv6Address() + assertThat("2001:0db8:85a3:0000:0000:0000::192.0.2.359").isNotLegalIPv6Address() + assertThat("some::junk").isNotLegalIPv6Address() + assertThat("some_junk").isNotLegalIPv6Address() + } + + @Test + fun cleanUpHostName() { + assertThat(HostNameUtils.cleanUpHostName("imap.domain.example ")).isEqualTo("imap.domain.example") + } +} + +private fun Assert.isLegalHostName() = given { actual -> + assertThat(HostNameUtils.isLegalHostName(actual)).isNotNull() + assertThat(HostNameUtils.isLegalHostNameOrIP(actual)).isNotNull() +} + +private fun Assert.isNotLegalHostName() = given { actual -> + assertThat(HostNameUtils.isLegalHostName(actual)).isNull() +} + +private fun Assert.isLegalIPv4Address() = given { actual -> + assertThat(HostNameUtils.isLegalIPv4Address(actual)).isNotNull() + assertThat(HostNameUtils.isLegalIPAddress(actual)).isNotNull() +} + +private fun Assert.isNotLegalIPv4Address() = given { actual -> + assertThat(HostNameUtils.isLegalIPv4Address(actual)).isNull() +} + +private fun Assert.isNormalizedTo(normalized: String) = given { actual -> + assertThat(HostNameUtils.isLegalIPv6Address(actual)).isEqualTo(normalized) + assertThat(HostNameUtils.isLegalHostNameOrIP(actual)).isEqualTo(normalized) +} + +private fun Assert.isNotLegalIPv6Address() = given { actual -> + assertThat(HostNameUtils.isLegalIPv6Address(actual)).isNull() +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/HostnameTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/HostnameTest.kt new file mode 100644 index 0000000..381c32c --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/HostnameTest.kt @@ -0,0 +1,47 @@ +package net.thunderbird.core.common.net + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import org.junit.Test + +class HostnameTest { + @Test + fun `valid domain`() { + val hostname = Hostname("domain.example") + + assertThat(hostname.value).isEqualTo("domain.example") + } + + @Test + fun `valid IPv4`() { + val hostname = Hostname("127.0.0.1") + + assertThat(hostname.value).isEqualTo("127.0.0.1") + } + + @Test + fun `valid IPv6`() { + val hostname = Hostname("fc00::1") + + assertThat(hostname.value).isEqualTo("fc00::1") + } + + @Test + fun `invalid domain should throw`() { + assertFailure { + Hostname("invalid domain") + }.isInstanceOf() + .hasMessage("Not a valid domain or IP: 'invalid domain'") + } + + @Test + fun `invalid IPv6 should throw`() { + assertFailure { + Hostname("fc00:1") + }.isInstanceOf() + .hasMessage("Not a valid domain or IP: 'fc00:1'") + } +} diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/PortTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/PortTest.kt new file mode 100644 index 0000000..7ee29f6 --- /dev/null +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/net/PortTest.kt @@ -0,0 +1,33 @@ +package net.thunderbird.core.common.net + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import org.junit.Test + +class PortTest { + @Test + fun `valid port number`() { + val port = Port(993) + + assertThat(port.value).isEqualTo(993) + } + + @Test + fun `negative port number should throw`() { + assertFailure { + Port(-1) + }.isInstanceOf() + .hasMessage("Not a valid port number: -1") + } + + @Test + fun `port number exceeding valid range should throw`() { + assertFailure { + Port(65536) + }.isInstanceOf() + .hasMessage("Not a valid port number: 65536") + } +} diff --git a/core/common/src/jvmMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.jvm.kt b/core/common/src/jvmMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.jvm.kt new file mode 100644 index 0000000..a2ee7ed --- /dev/null +++ b/core/common/src/jvmMain/kotlin/net/thunderbird/core/common/resources/ResourceAnnotations.jvm.kt @@ -0,0 +1,4 @@ +package net.thunderbird.core.common.resources + +actual typealias StringRes = androidx.annotation.StringRes +actual typealias PluralsRes = androidx.annotation.PluralsRes diff --git a/core/common/src/jvmMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.jvm.kt b/core/common/src/jvmMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.jvm.kt new file mode 100644 index 0000000..eed419e --- /dev/null +++ b/core/common/src/jvmMain/kotlin/net/thunderbird/core/common/resources/ResourceNotFoundException.jvm.kt @@ -0,0 +1,3 @@ +package net.thunderbird.core.common.resources + +actual typealias ResourceNotFoundException = java.lang.Exception diff --git a/core/featureflag/build.gradle.kts b/core/featureflag/build.gradle.kts new file mode 100644 index 0000000..e90508c --- /dev/null +++ b/core/featureflag/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.featureflag" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.androidx.annotation) + } + } +} diff --git a/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlag.kt b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlag.kt new file mode 100644 index 0000000..242b96b --- /dev/null +++ b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlag.kt @@ -0,0 +1,6 @@ +package net.thunderbird.core.featureflag + +data class FeatureFlag( + val key: FeatureFlagKey, + val enabled: Boolean = false, +) diff --git a/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagFactory.kt b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagFactory.kt new file mode 100644 index 0000000..e8b2b14 --- /dev/null +++ b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagFactory.kt @@ -0,0 +1,5 @@ +package net.thunderbird.core.featureflag + +fun interface FeatureFlagFactory { + fun createFeatureCatalog(): List +} diff --git a/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagKey.kt b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagKey.kt new file mode 100644 index 0000000..b0857dc --- /dev/null +++ b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagKey.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.featureflag + +@JvmInline +value class FeatureFlagKey(val key: String) { + companion object Keys { + val DisplayInAppNotifications = "display_in_app_notifications".toFeatureFlagKey() + val UseNotificationSenderForSystemNotifications = + "use_notification_sender_for_system_notifications".toFeatureFlagKey() + } +} + +fun String.toFeatureFlagKey(): FeatureFlagKey = FeatureFlagKey(this) diff --git a/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagProvider.kt b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagProvider.kt new file mode 100644 index 0000000..81e3101 --- /dev/null +++ b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagProvider.kt @@ -0,0 +1,5 @@ +package net.thunderbird.core.featureflag + +fun interface FeatureFlagProvider { + fun provide(key: FeatureFlagKey): FeatureFlagResult +} diff --git a/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagResult.kt b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagResult.kt new file mode 100644 index 0000000..8250989 --- /dev/null +++ b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/FeatureFlagResult.kt @@ -0,0 +1,47 @@ +package net.thunderbird.core.featureflag + +sealed interface FeatureFlagResult { + data object Enabled : FeatureFlagResult + data object Disabled : FeatureFlagResult + data object Unavailable : FeatureFlagResult + + fun whenEnabledOrNot( + onEnabled: () -> T, + onDisabledOrUnavailable: () -> T, + ): T = when (this) { + is Enabled -> onEnabled() + is Disabled, Unavailable -> onDisabledOrUnavailable() + } + + fun onEnabled(action: () -> Unit): FeatureFlagResult { + if (this is Enabled) { + action() + } + + return this + } + + fun onDisabled(action: () -> Unit): FeatureFlagResult { + if (this is Disabled) { + action() + } + + return this + } + + fun onUnavailable(action: () -> Unit): FeatureFlagResult { + if (this is Unavailable) { + action() + } + + return this + } + + fun onDisabledOrUnavailable(action: () -> Unit): FeatureFlagResult { + if (this is Disabled || this is Unavailable) { + action() + } + + return this + } +} diff --git a/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/InMemoryFeatureFlagProvider.kt b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/InMemoryFeatureFlagProvider.kt new file mode 100644 index 0000000..c378e5c --- /dev/null +++ b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/InMemoryFeatureFlagProvider.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.featureflag + +class InMemoryFeatureFlagProvider( + featureFlagFactory: FeatureFlagFactory, +) : FeatureFlagProvider { + + private val features: Map = + featureFlagFactory.createFeatureCatalog().associateBy { it.key } + + override fun provide(key: FeatureFlagKey): FeatureFlagResult { + return when (features[key]?.enabled) { + null -> FeatureFlagResult.Unavailable + true -> FeatureFlagResult.Enabled + false -> FeatureFlagResult.Disabled + } + } +} diff --git a/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/compat/FeatureFlagProviderCompat.kt b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/compat/FeatureFlagProviderCompat.kt new file mode 100644 index 0000000..f030fbd --- /dev/null +++ b/core/featureflag/src/commonMain/kotlin/net/thunderbird/core/featureflag/compat/FeatureFlagProviderCompat.kt @@ -0,0 +1,29 @@ +@file:JvmName("FeatureFlagProviderCompat") + +package net.thunderbird.core.featureflag.compat + +import androidx.annotation.Discouraged +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.core.featureflag.toFeatureFlagKey + +/** + * Provides a feature flag result based on a string key, primarily for Java compatibility. + * + * This function acts as a bridge for Java code to access the Kotlin-idiomatic `provide` + * function that expects a [FeatureFlagKey], as value classes are not compatible with Java + * code. + * + * **Note:** This function is discouraged for use in Kotlin code. Prefer using the + * [FeatureFlagProvider.provide(key: FeatureFlagKey)][FeatureFlagProvider.provide] + * function directly in Kotlin. + * + * @receiver The [FeatureFlagProvider] instance to query. + * @param key The string representation of the feature flag key. + * @return The [FeatureFlagResult] corresponding to the given key. + */ +@Discouraged(message = "This function should be only used within Java files.") +fun FeatureFlagProvider.provide(key: String): FeatureFlagResult { + return provide(key.toFeatureFlagKey()) +} diff --git a/core/featureflag/src/commonTest/kotlin/net/thunderbird/core/featureflag/FeatureFlagResultTest.kt b/core/featureflag/src/commonTest/kotlin/net/thunderbird/core/featureflag/FeatureFlagResultTest.kt new file mode 100644 index 0000000..949b4a0 --- /dev/null +++ b/core/featureflag/src/commonTest/kotlin/net/thunderbird/core/featureflag/FeatureFlagResultTest.kt @@ -0,0 +1,142 @@ +package net.thunderbird.core.featureflag + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class FeatureFlagResultTest { + + @Test + fun `should only call onEnabled when enabled`() { + val testSubject = FeatureFlagResult.Enabled + + var resultEnabled = "" + var resultDisabled = "" + var resultUnavailable = "" + + testSubject.onEnabled { + resultEnabled = "enabled" + }.onDisabled { + resultDisabled = "disabled" + }.onUnavailable { + resultUnavailable = "unavailable" + } + + assertThat(resultEnabled).isEqualTo("enabled") + assertThat(resultDisabled).isEqualTo("") + assertThat(resultUnavailable).isEqualTo("") + } + + @Test + fun `should only call onDisabled when disabled`() { + val testSubject = FeatureFlagResult.Disabled + + var resultEnabled = "" + var resultDisabled = "" + var resultUnavailable = "" + + testSubject.onEnabled { + resultEnabled = "enabled" + }.onDisabled { + resultDisabled = "disabled" + }.onUnavailable { + resultUnavailable = "unavailable" + } + + assertThat(resultEnabled).isEqualTo("") + assertThat(resultDisabled).isEqualTo("disabled") + assertThat(resultUnavailable).isEqualTo("") + } + + @Test + fun `should only call onUnavailable when unavailable`() { + val testSubject = FeatureFlagResult.Unavailable + + var resultEnabled = "" + var resultDisabled = "" + var resultUnavailable = "" + + testSubject.onEnabled { + resultEnabled = "enabled" + }.onDisabled { + resultDisabled = "disabled" + }.onUnavailable { + resultUnavailable = "unavailable" + } + + assertThat(resultEnabled).isEqualTo("") + assertThat(resultDisabled).isEqualTo("") + assertThat(resultUnavailable).isEqualTo("unavailable") + } + + @Test + fun `should call onDisabledOrUnavailable when disabled`() { + val testSubject = FeatureFlagResult.Disabled + + var resultEnabled = "" + var resultDisabled = "" + var resultUnavailable = "" + var resultDisabledOrUnavailable = "" + + testSubject.onEnabled { + resultEnabled = "enabled" + }.onDisabled { + resultDisabled = "disabled" + }.onUnavailable { + resultUnavailable = "unavailable" + }.onDisabledOrUnavailable { + resultDisabledOrUnavailable = "disabled or unavailable" + } + + assertThat(resultEnabled).isEqualTo("") + assertThat(resultDisabled).isEqualTo("disabled") + assertThat(resultUnavailable).isEqualTo("") + assertThat(resultDisabledOrUnavailable).isEqualTo("disabled or unavailable") + } + + @Test + fun `should call onDisabledOrUnavailable when unavailable`() { + val testSubject = FeatureFlagResult.Unavailable + + var resultEnabled = "" + var resultDisabled = "" + var resultUnavailable = "" + var resultDisabledOrUnavailable = "" + + testSubject.onEnabled { + resultEnabled = "enabled" + }.onDisabled { + resultDisabled = "disabled" + }.onUnavailable { + resultUnavailable = "unavailable" + }.onDisabledOrUnavailable { + resultDisabledOrUnavailable = "disabled or unavailable" + } + + assertThat(resultEnabled).isEqualTo("") + assertThat(resultDisabled).isEqualTo("") + assertThat(resultUnavailable).isEqualTo("unavailable") + assertThat(resultDisabledOrUnavailable).isEqualTo("disabled or unavailable") + } + + @Test + fun `whenEnabledOrNot should return correct value based on state`() { + val enabledResult = FeatureFlagResult.Enabled.whenEnabledOrNot( + onEnabled = { "Feature is ON" }, + onDisabledOrUnavailable = { "Feature is OFF" }, + ) + assertThat(enabledResult).isEqualTo("Feature is ON") + + val disabledResult = FeatureFlagResult.Disabled.whenEnabledOrNot( + onEnabled = { "Feature is ON" }, + onDisabledOrUnavailable = { "Feature is OFF" }, + ) + assertThat(disabledResult).isEqualTo("Feature is OFF") + + val unavailableResult = FeatureFlagResult.Unavailable.whenEnabledOrNot( + onEnabled = { "Feature is ON" }, + onDisabledOrUnavailable = { "Feature is OFF" }, + ) + assertThat(unavailableResult).isEqualTo("Feature is OFF") + } +} diff --git a/core/featureflag/src/commonTest/kotlin/net/thunderbird/core/featureflag/InMemoryFeatureFlagProviderTest.kt b/core/featureflag/src/commonTest/kotlin/net/thunderbird/core/featureflag/InMemoryFeatureFlagProviderTest.kt new file mode 100644 index 0000000..2c74ce5 --- /dev/null +++ b/core/featureflag/src/commonTest/kotlin/net/thunderbird/core/featureflag/InMemoryFeatureFlagProviderTest.kt @@ -0,0 +1,57 @@ +package net.thunderbird.core.featureflag + +import assertk.assertThat +import assertk.assertions.isInstanceOf +import org.junit.Test + +class InMemoryFeatureFlagProviderTest { + + @Test + fun `should return FeatureFlagResult#Enabled when feature is enabled`() { + val feature1Key = FeatureFlagKey("feature1") + val featureFlagProvider = InMemoryFeatureFlagProvider( + featureFlagFactory = { + listOf( + FeatureFlag(key = feature1Key, enabled = true), + ) + }, + ) + + val result = featureFlagProvider.provide(feature1Key) + + assertThat(result).isInstanceOf() + } + + @Test + fun `should return FeatureFlagResult#Disabled when feature is disabled`() { + val feature1Key = FeatureFlagKey("feature1") + val featureFlagProvider = InMemoryFeatureFlagProvider( + featureFlagFactory = { + listOf( + FeatureFlag(key = feature1Key, enabled = false), + ) + }, + ) + + val result = featureFlagProvider.provide(feature1Key) + + assertThat(result).isInstanceOf() + } + + @Test + fun `should return FeatureFlagResult#Unavailable when feature is not found`() { + val feature1Key = FeatureFlagKey("feature1") + val feature2Key = FeatureFlagKey("feature2") + val featureFlagProvider = InMemoryFeatureFlagProvider( + featureFlagFactory = { + listOf( + FeatureFlag(key = feature1Key, enabled = false), + ) + }, + ) + + val result = featureFlagProvider.provide(feature2Key) + + assertThat(result).isInstanceOf() + } +} diff --git a/core/logging/README.md b/core/logging/README.md new file mode 100644 index 0000000..8dbd447 --- /dev/null +++ b/core/logging/README.md @@ -0,0 +1,205 @@ +# Thunderbird Core Logging Module + +This module provides a flexible and extensible logging system for Thunderbird for Android. + +## Architecture + +The logging system is organized into several modules: + +- **api**: Core interfaces and classes +- **impl-console**: Console logging implementation +- **impl-composite**: Composite logging (multiple sinks) +- **impl-legacy**: Legacy logging system compatibility +- **testing**: Testing utilities + +### Core Components + +```mermaid +classDiagram + class Logger { + +verbose(tag, throwable, message: () -> LogMessage) + +debug(tag, throwable, message: () -> LogMessage) + +info(tag, throwable, message: () -> LogMessage) + +warn(tag, throwable, message: () -> LogMessage) + +error(tag, throwable, message: () -> LogMessage) + } + + class DefaultLogger { + -sink: LogSink + -clock: Clock + } + + class LogSink { + +level: LogLevel + +canLog(level): boolean + +log(event: LogEvent) + } + + class LogEvent { + +level: LogLevel + +tag: LogTag? + +message: LogMessage + +throwable: Throwable? + +timestamp: Long + } + + class LogLevel { + VERBOSE + DEBUG + INFO + WARN + ERROR + } + + Logger <|-- DefaultLogger + DefaultLogger --> LogSink + LogSink --> LogEvent + LogSink --> LogLevel + LogEvent --> LogLevel +``` + +### Implementation Modules + +```mermaid +classDiagram + class LogSink { + +level: LogLevel + +canLog(level): boolean + +log(event: LogEvent) + } + + class ConsoleLogSink { + +level: LogLevel + } + + class CompositeLogSink { + +level: LogLevel + -manager: LogSinkManager + } + + LogSink <|-- ConsoleLogSink + LogSink <|-- CompositeLogSink + + CompositeLogSink --> LogSinkManager + + class LogSinkManager { + +getAll(): List + +add(sink: LogSink) + +addAll(sinks: List) + +remove(sink: LogSink) + +removeAll() + } + + class DefaultLogSinkManager { + -sinks: MutableList + } + + LogSinkManager <|-- DefaultLogSinkManager +``` + +## Getting Started + +### Basic Setup + +To start using the logging system, you need to: + +1. Add the necessary dependencies to your module's build.gradle.kts file +2. Create a LogSink +3. Create a Logger +4. Start logging! + +### Basic Logging + +```kotlin +// Create a log sink +val sink = ConsoleLogSink(LogLevel.DEBUG) + +// Create a logger +val logger = DefaultLogger(sink) + +// Log messages +logger.debug(tag = "MyTag") { "Debug message" } +logger.info { "Info message" } +logger.warn { "Warning message" } +logger.error(throwable = exception) { "Error message with exception" } +``` + +Note that the message parameter is a lambda that returns a String. This allows for lazy evaluation of the message, which can improve performance when the log level is set to filter out certain messages. + +### Composite Logging (Multiple Sinks) + +If you want to send logs to multiple destinations, use the CompositeLogSink: + +```kotlin +// Create log sinks +val consoleSink = ConsoleLogSink(LogLevel.INFO) +val otherSink = YourCustomLogSink(LogLevel.DEBUG) + +// Create a composite sink +val compositeSink = CompositeLogSink( + level = LogLevel.DEBUG, + sinks = listOf( + consoleSink, + otherSink + ) +) + +// Create a logger +val logger = DefaultLogger(compositeSink) + +// Log messages (will go to both sinks if level is appropriate) +logger.debug { "This goes only to otherSink if its level is DEBUG or lower" } +logger.info { "This goes to both sinks if their levels are INFO or lower" } +``` + +## Creating Custom Log Sinks + +You can create your own log sink by implementing the LogSink interface: + +```kotlin +class MyCustomLogSink( + override val level: LogLevel, + // Add any other parameters you need +) : LogSink { + override fun log(event: LogEvent) { + // Implement your custom logging logic here + // For example, send logs to a remote server, write to a database, etc. + val formattedMessage = "${event.timestamp} [${event.level}] ${event.tag ?: ""}: ${event.message}" + + // Handle the throwable if present + event.throwable?.let { + // Process the throwable + } + + // Send or store the log message + } +} +``` + +## Best Practices + +### Log Levels + +Use appropriate log levels for different types of messages: + +- **VERBOSE**: Detailed information, typically useful only for debugging +- **DEBUG**: Debugging information, useful during development +- **INFO**: General information about application operation +- **WARN**: Potential issues that aren't errors but might need attention +- **ERROR**: Errors and exceptions that should be investigated + +## Troubleshooting + +### Common Issues + +1. **No logs appearing**: + - Check that the log level of your sink is appropriate for the messages you're logging + - Verify that your logger is properly initialized + +### Debugging the Logging System + +To debug issues with the logging system itself: + +1. Create a simple ConsoleLogSink with VERBOSE level +2. Log test messages at different levels +3. Check if messages appear as expected diff --git a/core/logging/api/build.gradle.kts b/core/logging/api/build.gradle.kts new file mode 100644 index 0000000..c3b18a6 --- /dev/null +++ b/core/logging/api/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.logging" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.datetime) + } + + commonTest.dependencies { + implementation(projects.core.testing) + } + } +} diff --git a/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/DefaultLogger.kt b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/DefaultLogger.kt new file mode 100644 index 0000000..2a7f1eb --- /dev/null +++ b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/DefaultLogger.kt @@ -0,0 +1,106 @@ +package net.thunderbird.core.logging + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Default implementation of [Logger] that logs messages to a [LogSink]. + * + * @param sink The [LogSink] to which log events will be sent. + * @param clock The [Clock] used to get the current time for log events. Defaults to the system clock. + */ +class DefaultLogger +@OptIn(ExperimentalTime::class) +constructor( + private val sink: LogSink, + private val clock: Clock = Clock.System, +) : Logger { + + private fun log( + level: LogLevel, + tag: LogTag? = null, + throwable: Throwable? = null, + message: () -> LogMessage, + ) { + sink.let { currentSink -> + if (currentSink.canLog(level)) { + @OptIn(ExperimentalTime::class) + val timestamp = clock.now().toEpochMilliseconds() + currentSink.log( + event = LogEvent( + level = level, + tag = tag, + message = message(), + throwable = throwable, + timestamp = timestamp, + ), + ) + } + } + } + + override fun verbose( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + log( + level = LogLevel.VERBOSE, + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun debug( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + log( + level = LogLevel.DEBUG, + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun info( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + log( + level = LogLevel.INFO, + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun warn( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + log( + level = LogLevel.WARN, + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun error( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + log( + level = LogLevel.ERROR, + tag = tag, + throwable = throwable, + message = message, + ) + } +} diff --git a/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogEvent.kt b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogEvent.kt new file mode 100644 index 0000000..d52ceee --- /dev/null +++ b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogEvent.kt @@ -0,0 +1,21 @@ +package net.thunderbird.core.logging + +typealias LogTag = String +typealias LogMessage = String + +/** + * Represents a single log event + * + * @property level The [LogLevel] of the log event. + * @property tag An optional [LogTag] to categorize the log event. + * @property message The [LogMessage] associated with the log event. + * @property throwable An optional [Throwable] associated with the log event. + * @property timestamp The timestamp of the log event in milliseconds. + */ +data class LogEvent( + val level: LogLevel, + val tag: LogTag? = null, + val message: LogMessage, + val throwable: Throwable? = null, + val timestamp: Long, +) diff --git a/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevel.kt b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevel.kt new file mode 100644 index 0000000..b342672 --- /dev/null +++ b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevel.kt @@ -0,0 +1,44 @@ +package net.thunderbird.core.logging + +/** + * Represents the different levels of logging used to filter log messages. + * + * The log levels are ordered by priority, where a lower number indicates a more verbose level. + * - [VERBOSE]: Most detailed log level, including all messages. + * - [DEBUG]: Detailed information, typically useful for diagnosing problems. + * - [INFO]: General information about the application state. + * - [WARN]: Indicates something unexpected but not necessarily an error. + * - [ERROR]: Indicates a failure or critical issue. + * + * Each log level has a priority, the higher the priority, the more important the log message is. + * + * @param priority The priority of the log level, where a lower priority indicates a more verbose level. + */ +enum class LogLevel( + val priority: Int, +) { + /** + * Verbose log level — most detailed log level, including all messages. + */ + VERBOSE(1), + + /** + * Debug log level — detailed information, typically useful for diagnosing problems. + */ + DEBUG(2), + + /** + * Informational log level — general information about the application state. + */ + INFO(3), + + /** + * Warning log level — indicates something unexpected but not necessarily an error. + */ + WARN(4), + + /** + * Error log level — indicates a failure or critical issue. + */ + ERROR(5), +} diff --git a/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevelManager.kt b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevelManager.kt new file mode 100644 index 0000000..1391f43 --- /dev/null +++ b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevelManager.kt @@ -0,0 +1,28 @@ +package net.thunderbird.core.logging + +/** + * Manages the log level for the application. + * + * This interface provides a way to update the log level dynamically. + * Implementations of this interface are responsible for persisting the log level + * and notifying listeners of changes. + */ +interface LogLevelManager : LogLevelProvider { + /** + * Overrides the current log level. + * + * This function allows for a temporary change in the log level, + * typically for debugging or specific operational needs. + * The original log level can be restored by calling [restoreDefault] function + * + * @param level The new log level to set + */ + fun override(level: LogLevel) + + /** + * Restores the log level to its default value. + * + * The default log level is defined by the specific implementation of this interface. + */ + fun restoreDefault() +} diff --git a/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevelProvider.kt b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevelProvider.kt new file mode 100644 index 0000000..34e46be --- /dev/null +++ b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogLevelProvider.kt @@ -0,0 +1,15 @@ +package net.thunderbird.core.logging + +/** + * Provides the current [LogLevel]. + * + * This can be used to dynamically change the log level during runtime. + */ +fun interface LogLevelProvider { + /** + * Gets the current log level. + * + * @return The current log level. + */ + fun current(): LogLevel +} diff --git a/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogSink.kt b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogSink.kt new file mode 100644 index 0000000..c8fa0f7 --- /dev/null +++ b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/LogSink.kt @@ -0,0 +1,35 @@ +package net.thunderbird.core.logging + +/** + * A sink that receives and processes log events. + * + * A `LogSink` determines whether to handle a log event based on its log level. + * Log events with a level lower than the sink's configured level will be ignored. + */ +interface LogSink { + + /** + * The minimum log level this sink will process. + * Log events with a lower priority than this level will be ignored. + */ + val level: LogLevel + + /** + * Checks whether the sink is enabled for the given log level. + * + * @param level The log level to check. + * @return `true` if this sink will process log events at this level or higher. + */ + fun canLog(level: LogLevel): Boolean { + return this.level <= level + } + + /** + * Logs a [LogEvent]. + * + * @param event The [LogEvent] to log. + */ + fun log( + event: LogEvent, + ) +} diff --git a/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/Logger.kt b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/Logger.kt new file mode 100644 index 0000000..90ee096 --- /dev/null +++ b/core/logging/api/src/commonMain/kotlin/net/thunderbird/core/logging/Logger.kt @@ -0,0 +1,71 @@ +package net.thunderbird.core.logging + +/** + * A logging interface that provides methods for logging messages at specific log levels. + */ +interface Logger { + /** + * Logs a message at the verbose log level. + * + * @param tag An optional [LogTag] to categorize the log message. + * @param throwable An optional throwable to log. + * @param message Lambda that returns the [LogMessage] to log. + */ + fun verbose( + tag: LogTag? = null, + throwable: Throwable? = null, + message: () -> LogMessage, + ) + + /** + * Logs a message at the debug log level. + * + * @param tag An optional [LogTag] to categorize the log message. + * @param throwable An optional throwable to log. + * @param message Lambda that returns the [LogMessage] to log. + */ + fun debug( + tag: LogTag? = null, + throwable: Throwable? = null, + message: () -> LogMessage, + ) + + /** + * Logs a message at the info log level. + * + * @param tag An optional [LogTag] to categorize the log message. + * @param throwable An optional throwable to log. + * @param message Lambda that returns the [LogMessage] to log. + */ + fun info( + tag: LogTag? = null, + throwable: Throwable? = null, + message: () -> LogMessage, + ) + + /** + * Logs a message at the warn log level. + * + * @param tag An optional [LogTag] to categorize the log message. + * @param throwable An optional throwable to log. + * @param message Lambda that returns the [LogMessage] to log. + */ + fun warn( + tag: LogTag? = null, + throwable: Throwable? = null, + message: () -> LogMessage, + ) + + /** + * Logs a message at the error log level. + * + * @param tag An optional [LogTag] to categorize the log message. + * @param throwable An optional throwable to log. + * @param message Lambda that returns the [LogMessage] to log. + */ + fun error( + tag: LogTag? = null, + throwable: Throwable? = null, + message: () -> LogMessage, + ) +} diff --git a/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/DefaultLoggerTest.kt b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/DefaultLoggerTest.kt new file mode 100644 index 0000000..2f8d44f --- /dev/null +++ b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/DefaultLoggerTest.kt @@ -0,0 +1,167 @@ +package net.thunderbird.core.logging + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import net.thunderbird.core.testing.TestClock + +@OptIn(ExperimentalTime::class) +class DefaultLoggerTest { + + @Test + fun `log should add all event to the sink`() { + // Arrange + val sink = FakeLogSink(LogLevel.VERBOSE) + val exceptionVerbose = Exception("Verbose exception") + val exceptionDebug = Exception("Debug exception") + val exceptionInfo = Exception("Info exception") + val exceptionWarn = Exception("Warn exception") + val exceptionError = Exception("Error exception") + + val clock = TestClock( + currentTime = Instant.fromEpochMilliseconds(0L), + ) + val testSubject = DefaultLogger( + sink = sink, + clock = clock, + ) + + // Act + testSubject.verbose( + tag = "Verbose tag", + throwable = exceptionVerbose, + message = { "Verbose message" }, + ) + + clock.advanceTimeBy(1000.milliseconds) + testSubject.debug( + tag = "Debug tag", + throwable = exceptionDebug, + message = { "Debug message" }, + ) + + clock.advanceTimeBy(1000.milliseconds) + testSubject.info( + tag = "Info tag", + throwable = exceptionInfo, + message = { "Info message" }, + ) + + clock.advanceTimeBy(1000.milliseconds) + testSubject.warn( + tag = "Warn tag", + throwable = exceptionWarn, + message = { "Warn message" }, + ) + + clock.advanceTimeBy(1000.milliseconds) + testSubject.error( + tag = "Error tag", + throwable = exceptionError, + message = { "Error message" }, + ) + + // Assert + val events = sink.events + assertThat(events).hasSize(5) + assertThat(events[0]).isEqualTo( + LogEvent( + level = LogLevel.VERBOSE, + tag = "Verbose tag", + message = "Verbose message", + throwable = exceptionVerbose, + timestamp = 0, + ), + ) + assertThat(events[1]).isEqualTo( + LogEvent( + level = LogLevel.DEBUG, + tag = "Debug tag", + message = "Debug message", + throwable = exceptionDebug, + timestamp = 1000, + ), + ) + assertThat(events[2]).isEqualTo( + LogEvent( + level = LogLevel.INFO, + tag = "Info tag", + message = "Info message", + throwable = exceptionInfo, + timestamp = 2000, + ), + ) + assertThat(events[3]).isEqualTo( + LogEvent( + level = LogLevel.WARN, + tag = "Warn tag", + message = "Warn message", + throwable = exceptionWarn, + timestamp = 3000, + ), + ) + assertThat(events[4]).isEqualTo( + LogEvent( + level = LogLevel.ERROR, + tag = "Error tag", + message = "Error message", + throwable = exceptionError, + timestamp = 4000, + ), + ) + } + + @Test + fun `log should not add event to the sink if the level is not allowed for the sink`() { + // Arrange + val sink = FakeLogSink(LogLevel.INFO) + val exceptionVerbose = Exception("Verbose exception") + val exceptionDebug = Exception("Debug exception") + val exceptionInfo = Exception("Info exception") + + val clock = TestClock( + currentTime = Instant.fromEpochMilliseconds(0L), + ) + val testSubject = DefaultLogger( + sink = sink, + clock = clock, + ) + + // Act + testSubject.verbose( + tag = "Verbose tag", + throwable = exceptionVerbose, + message = { "Verbose message" }, + ) + + clock.advanceTimeBy(1000.milliseconds) + testSubject.debug( + tag = "Debug tag", + throwable = exceptionDebug, + message = { "Debug message" }, + ) + + clock.advanceTimeBy(1000.milliseconds) + testSubject.info( + tag = "Info tag", + throwable = exceptionInfo, + message = { "Info message" }, + ) + + // Assert + assertThat(sink.events).hasSize(1) + assertThat(sink.events[0]).isEqualTo( + LogEvent( + level = LogLevel.INFO, + tag = "Info tag", + message = "Info message", + throwable = exceptionInfo, + timestamp = 2000, + ), + ) + } +} diff --git a/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/FakeLogSink.kt b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/FakeLogSink.kt new file mode 100644 index 0000000..b4aa160 --- /dev/null +++ b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/FakeLogSink.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.logging + +class FakeLogSink( + override val level: LogLevel = LogLevel.VERBOSE, +) : LogSink { + + val events = mutableListOf() + + override fun log(event: LogEvent) { + events.add(event) + } +} diff --git a/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/FakeLogger.kt b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/FakeLogger.kt new file mode 100644 index 0000000..f8c4e43 --- /dev/null +++ b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/FakeLogger.kt @@ -0,0 +1,49 @@ +package net.thunderbird.core.logging + +class FakeLogger : Logger { + val events = mutableListOf() + + override fun verbose( + tag: String?, + throwable: Throwable?, + message: () -> String, + ) { + events.add(LogEvent(LogLevel.VERBOSE, tag, message(), throwable, TIMESTAMP)) + } + + override fun debug( + tag: String?, + throwable: Throwable?, + message: () -> String, + ) { + events.add(LogEvent(LogLevel.DEBUG, tag, message(), throwable, TIMESTAMP)) + } + + override fun info( + tag: String?, + throwable: Throwable?, + message: () -> String, + ) { + events.add(LogEvent(LogLevel.INFO, tag, message(), throwable, TIMESTAMP)) + } + + override fun warn( + tag: String?, + throwable: Throwable?, + message: () -> String, + ) { + events.add(LogEvent(LogLevel.WARN, tag, message(), throwable, TIMESTAMP)) + } + + override fun error( + tag: String?, + throwable: Throwable?, + message: () -> String, + ) { + events.add(LogEvent(LogLevel.ERROR, tag, message(), throwable, TIMESTAMP)) + } + + private companion object { + const val TIMESTAMP = 0L + } +} diff --git a/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/LogSinkTest.kt b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/LogSinkTest.kt new file mode 100644 index 0000000..87de129 --- /dev/null +++ b/core/logging/api/src/commonTest/kotlin/net/thunderbird/core/logging/LogSinkTest.kt @@ -0,0 +1,41 @@ +package net.thunderbird.core.logging + +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test + +class LogSinkTest { + + @Test + fun `canLog should return true for same level`() { + // Arrange + val testSubject = TestLogSink(LogLevel.INFO) + + // Act && Assert + assertTrue { testSubject.canLog(LogLevel.INFO) } + } + + @Test + fun `canLog should return false for level below sink level`() { + // Arrange + val testSubject = TestLogSink(LogLevel.INFO) + + // Act && Assert + assertFalse { testSubject.canLog(LogLevel.DEBUG) } + } + + @Test + fun `canLog should return true for level above sink level`() { + // Arrange + val testSubject = TestLogSink(LogLevel.INFO) + + // Act && Assert + assertTrue { testSubject.canLog(LogLevel.WARN) } + } + + private class TestLogSink( + override val level: LogLevel, + ) : LogSink { + override fun log(event: LogEvent) = Unit + } +} diff --git a/core/logging/impl-composite/build.gradle.kts b/core/logging/impl-composite/build.gradle.kts new file mode 100644 index 0000000..bdbbbb2 --- /dev/null +++ b/core/logging/impl-composite/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.logging.composite" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core.logging.api) + } + } +} diff --git a/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/CompositeLogSink.kt b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/CompositeLogSink.kt new file mode 100644 index 0000000..77b981d --- /dev/null +++ b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/CompositeLogSink.kt @@ -0,0 +1,37 @@ +package net.thunderbird.core.logging.composite + +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogLevelProvider +import net.thunderbird.core.logging.LogSink + +/** + * A [LogSink] that aggregates multiple [LogSink] and forwards log events to them. + * + * This [CompositeLogSink] is useful when you want to log messages to multiple destinations + * (e.g., console, file, etc.) without having to manage each [LogSink] individually. + * + * It checks the log level of each event against its own level and forwards the event + * to all managed sinks that can handle the event's level. + * + * @param level The minimum log level this sink will process. Log events with a lower priority will be ignored. + * @param manager The [CompositeLogSinkManager] that manages the collection of sinks. + */ +interface CompositeLogSink : LogSink { + val manager: CompositeLogSinkManager +} + +/** + * Creates a [CompositeLogSink] with the specified log level and manager. + * + * @param logLevelProvider The minimum [LogLevel] for messages to be logged. + * @param manager The [CompositeLogSinkManager] that manages the collection of sinks. + * @param sinks A list of [LogSink] instances to be managed by this composite sink. + * @return A new instance of [CompositeLogSink]. + */ +fun CompositeLogSink( + logLevelProvider: LogLevelProvider, + manager: CompositeLogSinkManager = DefaultLogSinkManager(), + sinks: List = emptyList(), +): CompositeLogSink { + return DefaultCompositeLogSink(logLevelProvider, manager, sinks) +} diff --git a/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/CompositeLogSinkManager.kt b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/CompositeLogSinkManager.kt new file mode 100644 index 0000000..950e361 --- /dev/null +++ b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/CompositeLogSinkManager.kt @@ -0,0 +1,42 @@ +package net.thunderbird.core.logging.composite + +import net.thunderbird.core.logging.LogSink + +/** + * CompositeLogSinkManager is responsible for managing a collection of [LogSink] instances. + */ +interface CompositeLogSinkManager { + + /** + * Retrieves all [LogSink] instances managed by this manager. + * + * @return A list of all sinks. + */ + fun getAll(): List + + /** + * Adds a [LogSink] to the manager. + * + * @param sink The [LogSink] to add. + */ + fun add(sink: LogSink) + + /** + * Adds multiple [LogSink] instances to the manager. + * + * @param sinks The list of [LogSink] to add. + */ + fun addAll(sinks: List) + + /** + * Removes a [LogSink] from the manager. + * + * @param sink The [LogSink] to remove. + */ + fun remove(sink: LogSink) + + /** + * Removes all [LogSink] instances from the manager. + */ + fun removeAll() +} diff --git a/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/DefaultCompositeLogSink.kt b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/DefaultCompositeLogSink.kt new file mode 100644 index 0000000..5f398ae --- /dev/null +++ b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/DefaultCompositeLogSink.kt @@ -0,0 +1,28 @@ +package net.thunderbird.core.logging.composite + +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogLevelProvider +import net.thunderbird.core.logging.LogSink + +internal class DefaultCompositeLogSink( + private val logLevelProvider: LogLevelProvider, + override val manager: CompositeLogSinkManager = DefaultLogSinkManager(), + sinks: List = emptyList(), +) : CompositeLogSink { + override val level: LogLevel get() = logLevelProvider.current() + + init { + manager.addAll(sinks) + } + + override fun log(event: LogEvent) { + if (canLog(event.level)) { + manager.getAll().forEach { sink -> + if (sink.canLog(event.level)) { + sink.log(event) + } + } + } + } +} diff --git a/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/DefaultLogSinkManager.kt b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/DefaultLogSinkManager.kt new file mode 100644 index 0000000..a61b979 --- /dev/null +++ b/core/logging/impl-composite/src/commonMain/kotlin/net/thunderbird/core/logging/composite/DefaultLogSinkManager.kt @@ -0,0 +1,36 @@ +package net.thunderbird.core.logging.composite + +import net.thunderbird.core.logging.LogSink + +/** + * Default implementation of [CompositeLogSinkManager] that manages a collection of [LogSink] instances. + */ +internal class DefaultLogSinkManager : CompositeLogSinkManager { + private val sinks: MutableList = mutableListOf() + + override fun getAll(): List { + return sinks.toList() + } + + override fun addAll(sinks: List) { + sinks.forEach { + add(it) + } + } + + override fun add(sink: LogSink) { + if (sink !in sinks) { + sinks.add(sink) + } + } + + override fun remove(sink: LogSink) { + if (sink in sinks) { + sinks.remove(sink) + } + } + + override fun removeAll() { + sinks.clear() + } +} diff --git a/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/DefaultCompositeLogSinkTest.kt b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/DefaultCompositeLogSinkTest.kt new file mode 100644 index 0000000..7b2f8aa --- /dev/null +++ b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/DefaultCompositeLogSinkTest.kt @@ -0,0 +1,106 @@ +package net.thunderbird.core.logging.composite + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import org.junit.Test + +class DefaultCompositeLogSinkTest { + + @Test + fun `init should set initial sinks`() { + // Arrange + val sink1 = FakeLogSink(LogLevel.INFO) + val sink2 = FakeLogSink(LogLevel.INFO) + val sinkManager = FakeCompositeLogSinkManager() + + // Act + DefaultCompositeLogSink( + logLevelProvider = { LogLevel.INFO }, + manager = sinkManager, + sinks = listOf(sink1, sink2), + ) + + // Assert + assertThat(sinkManager.sinks).hasSize(2) + assertThat(sinkManager.sinks[0]).isEqualTo(sink1) + assertThat(sinkManager.sinks[1]).isEqualTo(sink2) + } + + @Test + fun `log should log to all sinks`() { + // Arrange + val sink1 = FakeLogSink(LogLevel.INFO) + val sink2 = FakeLogSink(LogLevel.INFO) + val sinkManager = FakeCompositeLogSinkManager(mutableListOf(sink1, sink2)) + + val testSubject = DefaultCompositeLogSink( + logLevelProvider = { LogLevel.INFO }, + manager = sinkManager, + ) + + // Act + testSubject.log(LOG_EVENT) + + // Assert + assertThat(sink1.events).hasSize(1) + assertThat(sink2.events).hasSize(1) + assertThat(sink1.events[0]).isEqualTo(LOG_EVENT) + assertThat(sink2.events[0]).isEqualTo(LOG_EVENT) + } + + @Test + fun `log should not log if level is below threshold`() { + // Arrange + val sink1 = FakeLogSink(LogLevel.INFO) + val sink2 = FakeLogSink(LogLevel.INFO) + val sinkManager = FakeCompositeLogSinkManager(mutableListOf(sink1, sink2)) + + val testSubject = DefaultCompositeLogSink( + logLevelProvider = { LogLevel.WARN }, + manager = sinkManager, + ) + + // Act + testSubject.log(LOG_EVENT) + + // Assert + assertThat(sink1.events).isEmpty() + assertThat(sink2.events).isEmpty() + } + + @Test + fun `log should not log if sink level is below threshold`() { + // Arrange + val sink1 = FakeLogSink(LogLevel.WARN) + val sink2 = FakeLogSink(LogLevel.INFO) + val sinkManager = FakeCompositeLogSinkManager(mutableListOf(sink1, sink2)) + + val testSubject = DefaultCompositeLogSink( + logLevelProvider = { LogLevel.INFO }, + manager = sinkManager, + ) + + // Act + testSubject.log(LOG_EVENT) + + // Assert + assertThat(sink1.events).isEmpty() + assertThat(sink2.events).hasSize(1) + assertThat(sink2.events[0]).isEqualTo(LOG_EVENT) + } + + private companion object Companion { + const val TIMESTAMP = 0L + + val LOG_EVENT = LogEvent( + level = LogLevel.INFO, + tag = "TestTag", + message = "Test message", + timestamp = TIMESTAMP, + ) + } +} diff --git a/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/DefaultLogSinkManagerTest.kt b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/DefaultLogSinkManagerTest.kt new file mode 100644 index 0000000..4fadb5c --- /dev/null +++ b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/DefaultLogSinkManagerTest.kt @@ -0,0 +1,115 @@ +package net.thunderbird.core.logging.composite + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import kotlin.test.Test +import net.thunderbird.core.logging.LogLevel + +class DefaultLogSinkManagerTest { + + @Test + fun `should have no sinks initially`() { + // Arrange + val sinkManager = DefaultLogSinkManager() + + // Act + val sinks = sinkManager.getAll() + + // Assert + assertThat(sinks).isEmpty() + } + + @Test + fun `should add and retrieve sinks`() { + // Arrange + val sinkManager = DefaultLogSinkManager() + val sink = FakeLogSink(LogLevel.INFO) + sinkManager.add(sink) + + // Act + val sinks = sinkManager.getAll() + + // Assert + assertThat(sinks.contains(sink)) + } + + @Test + fun `should add multiple sinks`() { + // Arrange + val sinkManager = DefaultLogSinkManager() + val sink1 = FakeLogSink(LogLevel.INFO) + val sink2 = FakeLogSink(LogLevel.DEBUG) + sinkManager.addAll(listOf(sink1, sink2)) + + // Act + val sinks = sinkManager.getAll() + + // Assert + assertThat(sinks).hasSize(2) + assertThat(sinks).contains(sink1) + assertThat(sinks).contains(sink2) + } + + @Test + fun `should remove sink`() { + // Arrange + val sinkManager = DefaultLogSinkManager() + val sink = FakeLogSink(LogLevel.INFO) + sinkManager.add(sink) + + // Act + sinkManager.remove(sink) + val sinks = sinkManager.getAll() + + // Assert + assertThat(sinks).isEmpty() + } + + @Test + fun `should clear all sinks`() { + // Arrange + val sinkManager = DefaultLogSinkManager() + val sink1 = FakeLogSink(LogLevel.INFO) + val sink2 = FakeLogSink(LogLevel.DEBUG) + sinkManager.add(sink1) + sinkManager.add(sink2) + + // Act + sinkManager.removeAll() + val sinks = sinkManager.getAll() + + // Assert + assertThat(sinks).isEmpty() + } + + @Test + fun `should not add duplicate sinks`() { + // Arrange + val sinkManager = DefaultLogSinkManager() + val sink = FakeLogSink(LogLevel.INFO) + sinkManager.add(sink) + + // Act + sinkManager.add(sink) + val sinks = sinkManager.getAll() + + // Assert + assertThat(sinks).hasSize(1) + } + + @Test + fun `should not remove non-existent sinks`() { + // Arrange + val sinkManager = DefaultLogSinkManager() + val sink = FakeLogSink(LogLevel.INFO) + + // Act + sinkManager.remove(sink) + val sinks = sinkManager.getAll() + + // Assert + assertThat(sinks).isEmpty() + } +} diff --git a/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/FakeCompositeLogSinkManager.kt b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/FakeCompositeLogSinkManager.kt new file mode 100644 index 0000000..a9d871a --- /dev/null +++ b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/FakeCompositeLogSinkManager.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.logging.composite + +import net.thunderbird.core.logging.LogSink + +class FakeCompositeLogSinkManager( + val sinks: MutableList = mutableListOf(), +) : CompositeLogSinkManager { + + override fun getAll(): List = sinks + + override fun add(sink: LogSink) = Unit + + override fun addAll(sinks: List) { + this.sinks.addAll(sinks) + } + + override fun remove(sink: LogSink) = Unit + + override fun removeAll() = Unit +} diff --git a/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/FakeLogSink.kt b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/FakeLogSink.kt new file mode 100644 index 0000000..3d48bc4 --- /dev/null +++ b/core/logging/impl-composite/src/commonTest/kotlin/net/thunderbird/core/logging/composite/FakeLogSink.kt @@ -0,0 +1,14 @@ +package net.thunderbird.core.logging.composite + +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogSink + +class FakeLogSink(override val level: LogLevel) : LogSink { + + val events = mutableListOf() + + override fun log(event: LogEvent) { + events.add(event) + } +} diff --git a/core/logging/impl-console/build.gradle.kts b/core/logging/impl-console/build.gradle.kts new file mode 100644 index 0000000..04122b6 --- /dev/null +++ b/core/logging/impl-console/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.logging.console" +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + applyDefaultHierarchyTemplate { + common { + group("commonJvm") { + withAndroidTarget() + withJvm() + } + } + } + sourceSets { + val commonJvmMain by getting + + commonMain.dependencies { + implementation(projects.core.logging.api) + } + + androidMain.dependencies { + implementation(libs.timber) + } + } +} diff --git a/core/logging/impl-console/src/androidMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.android.kt b/core/logging/impl-console/src/androidMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.android.kt new file mode 100644 index 0000000..7ccc50d --- /dev/null +++ b/core/logging/impl-console/src/androidMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.android.kt @@ -0,0 +1,37 @@ +package net.thunderbird.core.logging.console + +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import timber.log.Timber + +actual fun ConsoleLogSink(level: LogLevel): ConsoleLogSink = AndroidConsoleLogSink(level) + +private class AndroidConsoleLogSink( + override val level: LogLevel, +) : ConsoleLogSink { + + override fun log(event: LogEvent) { + val timber = event.tag + ?.let { Timber.tag(it) } + ?: Timber.tag(event.composeTag(ignoredClasses = IGNORE_CLASSES) ?: this::class.java.name) + + when (event.level) { + LogLevel.VERBOSE -> timber.v(event.throwable, event.message) + LogLevel.DEBUG -> timber.d(event.throwable, event.message) + LogLevel.INFO -> timber.i(event.throwable, event.message) + LogLevel.WARN -> timber.w(event.throwable, event.message) + LogLevel.ERROR -> timber.e(event.throwable, event.message) + } + } + + companion object { + private val IGNORE_CLASSES = setOf( + Timber::class.java.name, + Timber.Forest::class.java.name, + Timber.Tree::class.java.name, + Timber.DebugTree::class.java.name, + AndroidConsoleLogSink::class.java.name, + // Add other classes to ignore if needed + ) + } +} diff --git a/core/logging/impl-console/src/androidUnitTest/kotlin/net/thunderbird/core/logging/console/ConsoleLogSinkTest.android.kt b/core/logging/impl-console/src/androidUnitTest/kotlin/net/thunderbird/core/logging/console/ConsoleLogSinkTest.android.kt new file mode 100644 index 0000000..52c8150 --- /dev/null +++ b/core/logging/impl-console/src/androidUnitTest/kotlin/net/thunderbird/core/logging/console/ConsoleLogSinkTest.android.kt @@ -0,0 +1,101 @@ +package net.thunderbird.core.logging.console + +import android.util.Log +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import timber.log.Timber + +class ConsoleLogSinkTest { + + @Test + fun shouldHaveCorrectLogLevel() { + // Arrange + val testSubject = ConsoleLogSink(LogLevel.INFO) + + // Act & Assert + assertThat(testSubject.level).isEqualTo(LogLevel.INFO) + } + + @Test + fun shouldLogMessages() { + // Arrange + val testTree = TestTree() + Timber.plant(testTree) + val eventVerbose = LogEvent( + level = LogLevel.VERBOSE, + tag = "TestTag", + message = "This is a verbose message", + throwable = null, + timestamp = 0L, + ) + val eventDebug = LogEvent( + level = LogLevel.DEBUG, + tag = "TestTag", + message = "This is a debug message", + throwable = null, + timestamp = 0L, + ) + val eventInfo = LogEvent( + level = LogLevel.INFO, + tag = "TestTag", + message = "This is a info message", + throwable = null, + timestamp = 0L, + ) + val eventWarn = LogEvent( + level = LogLevel.WARN, + tag = "TestTag", + message = "This is a warning message", + throwable = null, + timestamp = 0L, + ) + val eventError = LogEvent( + level = LogLevel.ERROR, + tag = "TestTag", + message = "This is an error message", + throwable = null, + timestamp = 0L, + ) + + val testSubject = ConsoleLogSink(LogLevel.VERBOSE) + + // Act + testSubject.log(eventVerbose) + testSubject.log(eventDebug) + testSubject.log(eventInfo) + testSubject.log(eventWarn) + testSubject.log(eventError) + + // Assert + assertThat(testTree.events).hasSize(5) + assertThat(testTree.events[0]).isEqualTo(eventVerbose) + assertThat(testTree.events[1]).isEqualTo(eventDebug) + assertThat(testTree.events[2]).isEqualTo(eventInfo) + assertThat(testTree.events[3]).isEqualTo(eventWarn) + assertThat(testTree.events[4]).isEqualTo(eventError) + } + + class TestTree : Timber.DebugTree() { + + val events = mutableListOf() + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + events.add(LogEvent(mapPriorityToLogLevel(priority), tag, message, t, 0L)) + } + + private fun mapPriorityToLogLevel(priority: Int): LogLevel { + return when (priority) { + Log.VERBOSE -> LogLevel.VERBOSE + Log.DEBUG -> LogLevel.DEBUG + Log.INFO -> LogLevel.INFO + Log.WARN -> LogLevel.WARN + Log.ERROR -> LogLevel.ERROR + else -> throw IllegalArgumentException("Unknown log priority: $priority") + } + } + } +} diff --git a/core/logging/impl-console/src/commonJvmMain/kotlin/net/thunderbird/core/logging/console/ComposeLogTag.kt b/core/logging/impl-console/src/commonJvmMain/kotlin/net/thunderbird/core/logging/console/ComposeLogTag.kt new file mode 100644 index 0000000..fead12a --- /dev/null +++ b/core/logging/impl-console/src/commonJvmMain/kotlin/net/thunderbird/core/logging/console/ComposeLogTag.kt @@ -0,0 +1,67 @@ +package net.thunderbird.core.logging.console + +import net.thunderbird.core.logging.DefaultLogger +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.Logger + +/** + * Composes a tag for the given [LogEvent]. + * + * If the event has a tag, it is used; otherwise, a tag is extracted from the stack trace. + * The tag is processed using the [processTag] method before being returned. + * + * @receiver The [LogEvent] to compose a tag for. + * @param ignoredClasses The set of Class full name to be ignored. + * @param processTag Processes a tag before it is used for logging. + * @return The composed tag, or null if no tag could be determined. + */ +internal fun LogEvent.composeTag( + ignoredClasses: Set, + processTag: (String) -> String? = { it }, +): String? { + // If a tag is provided, use it; otherwise, extract it from the stack trace + val rawTag = tag ?: extractTagFromStackTrace(ignoredClasses) + // Process the tag before returning it + return rawTag?.let { processTag(it) } +} + +/** + * Extracts a tag from the stack trace. + * + * @return The extracted tag, or null if no suitable tag could be found. + */ +private fun extractTagFromStackTrace(ignoredClasses: Set): String? { + // Some classes are not available to this module, and we don't want + // to add the dependency just for class filtering. + val ignoredClasses = ignoredClasses + setOf( + "net.thunderbird.core.logging.console.ComposeLogTagKt", + "net.thunderbird.core.logging.composite.DefaultCompositeLogSink", + "net.thunderbird.core.logging.legacy.Log", + Logger::class.java.name, + DefaultLogger::class.java.name, + ) + + @Suppress("ThrowingExceptionsWithoutMessageOrCause") + val stackTrace = Throwable().stackTrace + + return stackTrace + .firstOrNull { element -> + ignoredClasses.none { element.className.startsWith(it) } + } + ?.let(::createStackElementTag) +} + +/** + * Creates a tag from a stack trace element. + * + * @param element The stack trace element to create a tag from. + * @return The created tag. + */ +private fun createStackElementTag(element: StackTraceElement): String { + var tag = element.className.substringAfterLast('.') + val regex = "(\\$\\d+)+$".toRegex() + if (regex.containsMatchIn(input = tag)) { + tag = regex.replace(input = tag, replacement = "") + } + return tag +} diff --git a/core/logging/impl-console/src/commonMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.kt b/core/logging/impl-console/src/commonMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.kt new file mode 100644 index 0000000..1a2038a --- /dev/null +++ b/core/logging/impl-console/src/commonMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.kt @@ -0,0 +1,23 @@ +package net.thunderbird.core.logging.console + +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogSink + +/** + * A [LogSink] implementation that logs messages to the console. + * + * This sink uses the platform-specific implementations to handle logging. + * + * @param level The minimum [LogLevel] for messages to be logged. + */ +interface ConsoleLogSink : LogSink + +/** + * Creates a [ConsoleLogSink] with the specified log level. + * + * @param level The minimum [LogLevel] for messages to be logged. + * @return A new instance of [ConsoleLogSink]. + */ +expect fun ConsoleLogSink( + level: LogLevel = LogLevel.INFO, +): ConsoleLogSink diff --git a/core/logging/impl-console/src/jvmMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.jvm.kt b/core/logging/impl-console/src/jvmMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.jvm.kt new file mode 100644 index 0000000..6690e19 --- /dev/null +++ b/core/logging/impl-console/src/jvmMain/kotlin/net/thunderbird/core/logging/console/ConsoleLogSink.jvm.kt @@ -0,0 +1,32 @@ +package net.thunderbird.core.logging.console + +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel + +actual fun ConsoleLogSink(level: LogLevel): ConsoleLogSink = JvmConsoleLogSink(level) + +private class JvmConsoleLogSink( + override val level: LogLevel, +) : ConsoleLogSink { + + override fun log(event: LogEvent) { + println("[$level] ${composeMessage(event)}") + event.throwable?.printStackTrace() + } + + private fun composeMessage(event: LogEvent): String { + val tag = event.tag ?: event.composeTag(ignoredClasses = IGNORE_CLASSES) + return if (tag != null) { + "[$tag] ${event.message}" + } else { + event.message + } + } + + companion object { + private val IGNORE_CLASSES = setOf( + JvmConsoleLogSink::class.java.name, + // Add other classes to ignore if needed + ) + } +} diff --git a/core/logging/impl-console/src/jvmTest/kotlin/net/thunderbird/core/logging/console/ConsoleLogSinkTest.jvm.kt b/core/logging/impl-console/src/jvmTest/kotlin/net/thunderbird/core/logging/console/ConsoleLogSinkTest.jvm.kt new file mode 100644 index 0000000..b5f5c3c --- /dev/null +++ b/core/logging/impl-console/src/jvmTest/kotlin/net/thunderbird/core/logging/console/ConsoleLogSinkTest.jvm.kt @@ -0,0 +1,56 @@ +package net.thunderbird.core.logging.console + +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import kotlin.test.Test +import kotlin.test.assertEquals +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel + +class ConsoleLogSinkTest { + + @Test + fun shouldHaveCorrectLogLevel() { + // Arrange + val testSubject = ConsoleLogSink(LogLevel.INFO) + + // Act & Assert + assertEquals(LogLevel.INFO, testSubject.level) + } + + @Test + fun shouldLogMessages() { + // Arrange + val originalOut = System.out + val outContent = ByteArrayOutputStream() + System.setOut(PrintStream(outContent)) + + try { + val eventInfo = LogEvent( + level = LogLevel.INFO, + tag = "TestTag", + message = "This is an info message", + throwable = null, + timestamp = 0L, + ) + + val testSubject = ConsoleLogSink(LogLevel.VERBOSE) + + // Act + testSubject.log(eventInfo) + + // Assert + val output = outContent.toString().trim() + println("[DEBUG_LOG] Actual output: '$output'") + + // The expected format is: [VERBOSE] [TestTag] This is an info message + // Note: The log level in the output is the sink's level (VERBOSE), not the event's level (INFO) + val expectedOutput = "[VERBOSE] [TestTag] This is an info message" + println("[DEBUG_LOG] Expected output: '$expectedOutput'") + + assertEquals(expectedOutput, output) + } finally { + System.setOut(originalOut) + } + } +} diff --git a/core/logging/impl-file/build.gradle.kts b/core/logging/impl-file/build.gradle.kts new file mode 100644 index 0000000..cc2a55f --- /dev/null +++ b/core/logging/impl-file/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.logging.file" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.io.core) + implementation(projects.core.logging.api) + } + } +} diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt new file mode 100644 index 0000000..df56422 --- /dev/null +++ b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSink.kt @@ -0,0 +1,140 @@ +package net.thunderbird.core.logging.file + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import kotlin.coroutines.CoroutineContext +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlinx.io.Buffer +import kotlinx.io.RawSink +import kotlinx.io.asSink +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel + +private const val BUFFER_SIZE = 8192 // 8KB buffer size +private const val LOG_BUFFER_COUNT = 4 + +open class AndroidFileLogSink( + override val level: LogLevel, + fileName: String, + fileLocation: String, + private val fileSystemManager: FileSystemManager, + coroutineContext: CoroutineContext = Dispatchers.IO, +) : FileLogSink { + + private val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + private val logFile = File(fileLocation, "$fileName.txt") + private val accumulatedLogs = ArrayList() + private val mutex: Mutex = Mutex() + + // Make sure the directory exists + init { + val directory = File(fileLocation) + if (!directory.exists()) { + directory.mkdirs() + } + logFile.createNewFile() + } + + override fun log(event: LogEvent) { + coroutineScope.launch { + mutex.withLock { + accumulatedLogs.add( + "${convertLongToTime(event.timestamp)} priority = ${event.level}, ${event.message}", + ) + } + if (accumulatedLogs.size > LOG_BUFFER_COUNT) { + writeToLogFile() + } + } + } + + @OptIn(ExperimentalTime::class) + private fun convertLongToTime(long: Long): String { + val instant = Instant.fromEpochMilliseconds(long) + val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + return LocalDateTime.Formats.ISO.format(dateTime) + } + + private suspend fun writeToLogFile() { + val outputStream = FileOutputStream(logFile, true) + val sink = outputStream.asSink() + var content: String + try { + mutex.withLock { + content = accumulatedLogs.joinToString("\n", postfix = "\n") + accumulatedLogs.clear() + } + val buffer = Buffer() + val contentBytes = content.toByteArray(Charsets.UTF_8) + buffer.write(contentBytes) + sink.write(buffer, buffer.size) + + sink.flush() + } finally { + sink.close() + outputStream.close() + } + } + + override suspend fun flushAndCloseBuffer() { + if (accumulatedLogs.isNotEmpty()) { + writeToLogFile() + } + } + + override suspend fun export(uriString: String) { + if (accumulatedLogs.isNotEmpty()) { + writeToLogFile() + } + val sink = fileSystemManager.openSink(uriString, "wt") + ?: error("Error opening contentUri for writing") + + copyInternalFileToExternal(sink) + + // Clear the log file after export + val outputStream = FileOutputStream(logFile) + val clearSink = outputStream.asSink() + + try { + // Write empty string to clear the file + val buffer = Buffer() + clearSink.write(buffer, 0) + clearSink.flush() + } finally { + clearSink.close() + outputStream.close() + } + } + + private fun copyInternalFileToExternal(sink: RawSink) { + val inputStream = FileInputStream(logFile) + + try { + val buffer = Buffer() + val byteArray = ByteArray(BUFFER_SIZE) + var bytesRead: Int + + while (inputStream.read(byteArray).also { bytesRead = it } != -1) { + buffer.write(byteArray, 0, bytesRead) + sink.write(buffer, buffer.size) + buffer.clear() + } + + sink.flush() + } finally { + inputStream.close() + sink.close() + } + } +} diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt new file mode 100644 index 0000000..fe61e2e --- /dev/null +++ b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/AndroidFileSystemManager.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.logging.file + +import android.content.ContentResolver +import android.net.Uri +import androidx.core.net.toUri +import kotlinx.io.RawSink +import kotlinx.io.asSink + +/** + * Android implementation of [FileSystemManager] that uses [ContentResolver] to perform file operations. + */ +class AndroidFileSystemManager( + private val contentResolver: ContentResolver, +) : FileSystemManager { + override fun openSink(uriString: String, mode: String): RawSink? { + val uri: Uri = uriString.toUri() + return contentResolver.openOutputStream(uri, mode)?.asSink() + } +} diff --git a/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt new file mode 100644 index 0000000..dd13139 --- /dev/null +++ b/core/logging/impl-file/src/androidMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.android.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.logging.file + +import net.thunderbird.core.logging.LogLevel + +actual fun FileLogSink( + level: LogLevel, + fileName: String, + fileLocation: String, + fileSystemManager: FileSystemManager, +): FileLogSink { + return AndroidFileLogSink(level, fileName, fileLocation, fileSystemManager) +} diff --git a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSinkTest.android.kt b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSinkTest.android.kt new file mode 100644 index 0000000..6067458 --- /dev/null +++ b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/AndroidFileLogSinkTest.android.kt @@ -0,0 +1,195 @@ +package net.thunderbird.core.logging.file + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import java.io.File +import kotlin.test.Test +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import org.junit.Before +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidFileLogSinkTest { + + @JvmField + @Rule + val folder = TemporaryFolder() + + private val initialTimestamp = 1234567890L + private lateinit var logFile: File + private lateinit var fileLocation: String + private lateinit var fileManager: FakeFileSystemManager + private lateinit var testSubject: AndroidFileLogSink + + @Before + fun setUp() { + fileLocation = folder.newFolder().absolutePath + logFile = File(fileLocation, "test_log.txt") + fileManager = FakeFileSystemManager() + testSubject = AndroidFileLogSink( + level = LogLevel.INFO, + fileName = "test_log", + fileLocation = fileLocation, + fileSystemManager = fileManager, + coroutineContext = UnconfinedTestDispatcher(), + ) + } + + @OptIn(ExperimentalTime::class) + fun timeSetup(timeStamp: Long): String { + val instant = Instant.fromEpochMilliseconds(timeStamp) + val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + return LocalDateTime.Formats.ISO.format(dateTime) + } + + @Test + fun shouldHaveCorrectLogLevel() { + assertThat(testSubject.level).isEqualTo(LogLevel.INFO) + } + + @Test + fun shouldLogMessageToFile() { + // Arrange + val message = "Test log message" + val event = LogEvent( + timestamp = initialTimestamp, + level = LogLevel.INFO, + tag = "TestTag", + message = message, + throwable = null, + ) + + // Act + testSubject.log(event) + runBlocking { + testSubject.flushAndCloseBuffer() + } + + // Arrange + val logFile = File(fileLocation, "test_log.txt") + assertThat(logFile.exists()).isEqualTo(true) + assertThat(logFile.readText()) + .isEqualTo("${timeSetup(initialTimestamp)} priority = INFO, Test log message\n") + } + + @Test + fun shouldLogMultipleMessagesToFile() { + // Arrange + val message = "Test log message" + var fiveLogString: String = "" + for (num in 0..3) { + val event = LogEvent( + timestamp = initialTimestamp + num, + level = LogLevel.INFO, + tag = "TestTag", + message = message + num, + throwable = null, + ) + testSubject.log(event) + fiveLogString = fiveLogString + "${timeSetup(event.timestamp)} priority = INFO, ${event.message}\n" + } + + val logFile = File(fileLocation, "test_log.txt") + assertThat(logFile.exists()).isEqualTo(true) + assertThat(logFile.readText()) + .isEqualTo("") + + val eventTippingBuffer = LogEvent( + timestamp = initialTimestamp + 6, + level = LogLevel.INFO, + tag = "TestTag", + message = message + "buffered", + throwable = null, + ) + fiveLogString = + fiveLogString + + "${timeSetup(eventTippingBuffer.timestamp)} priority = INFO, ${eventTippingBuffer.message}\n" + testSubject.log(eventTippingBuffer) + + // Arrange + assertThat(logFile.exists()).isEqualTo(true) + assertThat(logFile.readText()) + .isEqualTo(fiveLogString) + } + + @Test + fun shouldExportLogFile() { + // Arrange + val event = LogEvent( + timestamp = initialTimestamp, + level = LogLevel.INFO, + tag = "TestTag", + message = "Test log message for export", + throwable = null, + ) + testSubject.log(event) + runBlocking { + // Act + testSubject.flushAndCloseBuffer() + val exportUri = "content://test/export.txt" + testSubject.export(exportUri) + } + + // Arrange + val exportedContent = fileManager.exportedContent + assertThat(exportedContent).isNotNull() + assertThat(exportedContent!!) + .isEqualTo("${timeSetup(initialTimestamp)} priority = INFO, Test log message for export\n") + } + + @Test + fun shouldClearBufferAndExportToFile() { + // Arrange + val message = "Test log message" + var logString1: String = "" + + for (num in 0..4) { + val event = LogEvent( + timestamp = initialTimestamp + num, + level = LogLevel.INFO, + tag = "TestTag", + message = message + num, + throwable = null, + ) + testSubject.log(event) + logString1 = logString1 + "${timeSetup(event.timestamp)} priority = INFO, ${event.message}\n" + } + val event = LogEvent( + timestamp = initialTimestamp + 5, + level = LogLevel.INFO, + tag = "TestTag", + message = message + 5, + throwable = null, + ) + testSubject.log(event) + + var logString2: String = logString1 + "${timeSetup(event.timestamp)} priority = INFO, ${event.message}\n" + + // Arrange + val logFile = File(fileLocation, "test_log.txt") + assertThat(logFile.exists()).isEqualTo(true) + assertThat(logFile.readText()) + .isEqualTo(logString1) + runBlocking { + val exportUri = "content://test/export.txt" + testSubject.export(exportUri) + } + + // Arrange + val exportedContent = fileManager.exportedContent + assertThat(exportedContent).isNotNull() + assertThat(exportedContent!!) + .isEqualTo(logString2) + } +} diff --git a/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt new file mode 100644 index 0000000..cdd9e18 --- /dev/null +++ b/core/logging/impl-file/src/androidUnitTest/kotlin/net/thunderbird/core/logging/file/FakeFileSystemManager.kt @@ -0,0 +1,32 @@ +package net.thunderbird.core.logging.file + +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import kotlinx.io.Buffer +import kotlinx.io.RawSink + +class FakeFileSystemManager : FileSystemManager { + + var exportedContent: String? = null + private val outputStream = ByteArrayOutputStream() + + override fun openSink(uriString: String, mode: String): RawSink? { + return object : RawSink { + override fun write(source: Buffer, byteCount: Long) { + val bytes = ByteArray(byteCount.toInt()) + + for (i in 0 until byteCount.toInt()) { + bytes[i] = source.readByte() + } + + outputStream.write(bytes) + + exportedContent = String(outputStream.toByteArray(), StandardCharsets.UTF_8) + } + + override fun flush() = Unit + + override fun close() = Unit + } + } +} diff --git a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt new file mode 100644 index 0000000..1a368dd --- /dev/null +++ b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.kt @@ -0,0 +1,36 @@ +package net.thunderbird.core.logging.file + +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogSink + +interface FileLogSink : LogSink { + /** + * Exports from the logging method to the requested external file + * @param uriString The [String] for the URI to export the log to + * + **/ + suspend fun export(uriString: String) + + /** + * On a crash or close, flushes buffer to file fo avoid log loss + * + **/ + suspend fun flushAndCloseBuffer() +} + +/** + * A [LogSink] implementation that logs messages to a specified internal file. + * + * This sink uses the platform-specific implementations to handle logging. + * + * @param level The minimum [LogLevel] for messages to be logged. + * @param fileName The [String] fileName to log to + * @param fileLocation The [String] fileLocation for the log file + * @param fileSystemManager The [FileSystemManager] abstraction for opening the file stream + */ +expect fun FileLogSink( + level: LogLevel, + fileName: String, + fileLocation: String, + fileSystemManager: FileSystemManager, +): FileLogSink diff --git a/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt new file mode 100644 index 0000000..aaca95a --- /dev/null +++ b/core/logging/impl-file/src/commonMain/kotlin/net/thunderbird/core/logging/file/FileSystemManager.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.logging.file + +import kotlinx.io.RawSink + +/** + * An interface for file system operations that are platform-specific. + */ +interface FileSystemManager { + /** + * Opens a sink for writing to a URI. + * + * @param uriString The URI string to open a sink for + * @param mode The mode to open the sink in (e.g., "wt" for write text) + * @return A sink for writing to the URI, or null if the URI couldn't be opened + */ + fun openSink(uriString: String, mode: String): RawSink? +} diff --git a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt new file mode 100644 index 0000000..2ceb504 --- /dev/null +++ b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/FileLogSink.jvm.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.logging.file + +import net.thunderbird.core.logging.LogLevel + +/** + * A [LogSink] implementation that logs messages to a specified internal file. + * + * This sink uses the platform-specific implementations to handle logging. + * + * @param level The minimum [LogLevel] for messages to be logged. + * @param fileName The [String] fileName to log to + */ +actual fun FileLogSink( + level: LogLevel, + fileName: String, + fileLocation: String, + fileSystemManager: FileSystemManager, +): FileLogSink { + return JvmFileLogSink(level, fileName, fileLocation) +} diff --git a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileLogSink.kt b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileLogSink.kt new file mode 100644 index 0000000..49f27e7 --- /dev/null +++ b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileLogSink.kt @@ -0,0 +1,32 @@ +package net.thunderbird.core.logging.file + +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel + +internal class JvmFileLogSink( + override val level: LogLevel, + fileName: String, + fileLocation: String, +) : FileLogSink { + + override fun log(event: LogEvent) { + println("[$level] ${composeMessage(event)}") + event.throwable?.printStackTrace() + } + + override suspend fun export(uriString: String) { + // TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9435 + } + + override suspend fun flushAndCloseBuffer() { + TODO("Not yet implemented") + } + + private fun composeMessage(event: LogEvent): String { + return if (event.tag != null) { + "[${event.tag}] ${event.message}" + } else { + event.message + } + } +} diff --git a/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileSystemManager.kt b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileSystemManager.kt new file mode 100644 index 0000000..fb3179f --- /dev/null +++ b/core/logging/impl-file/src/jvmMain/kotlin/net/thunderbird/core/logging/file/JvmFileSystemManager.kt @@ -0,0 +1,13 @@ +package net.thunderbird.core.logging.file + +import kotlinx.io.RawSink + +/** + * Android implementation of [FileSystemManager] that uses [ContentResolver] to perform file operations. + */ +class JvmFileSystemManager() : FileSystemManager { + override fun openSink(uriString: String, mode: String): RawSink? { + // TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9435 + return TODO("Provide the return value") + } +} diff --git a/core/logging/impl-legacy/build.gradle.kts b/core/logging/impl-legacy/build.gradle.kts new file mode 100644 index 0000000..c1c9070 --- /dev/null +++ b/core/logging/impl-legacy/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.logging.legacy" +} + +kotlin { + sourceSets { + androidMain.dependencies { + implementation(libs.timber) + implementation(projects.core.logging.implComposite) + implementation(projects.core.logging.implFile) + } + + commonMain.dependencies { + api(projects.core.logging.api) + api(libs.androidx.annotation) + } + + commonTest.dependencies { + implementation(projects.core.logging.testing) + } + } +} diff --git a/core/logging/impl-legacy/src/androidMain/kotlin/net/thunderbird/core/logging/legacy/DebugLogConfigurator.kt b/core/logging/impl-legacy/src/androidMain/kotlin/net/thunderbird/core/logging/legacy/DebugLogConfigurator.kt new file mode 100644 index 0000000..2e8c63d --- /dev/null +++ b/core/logging/impl-legacy/src/androidMain/kotlin/net/thunderbird/core/logging/legacy/DebugLogConfigurator.kt @@ -0,0 +1,27 @@ +package net.thunderbird.core.logging.legacy + +import net.thunderbird.core.logging.composite.CompositeLogSink +import net.thunderbird.core.logging.file.FileLogSink +import timber.log.Timber +import timber.log.Timber.DebugTree + +// TODO: Implementation https://github.com/thunderbird/thunderbird-android/issues/9573 +class DebugLogConfigurator( + private val syncDebugCompositeSink: CompositeLogSink, + private val syncDebugFileLogSink: FileLogSink, +) { + fun updateLoggingStatus(isDebugLoggingEnabled: Boolean) { + Timber.uprootAll() + if (isDebugLoggingEnabled) { + Timber.plant(DebugTree()) + } + } + + fun updateSyncLogging(isSyncLoggingEnabled: Boolean) { + if (isSyncLoggingEnabled) { + syncDebugCompositeSink.manager.add(syncDebugFileLogSink) + } else { + syncDebugCompositeSink.manager.remove(syncDebugFileLogSink) + } + } +} diff --git a/core/logging/impl-legacy/src/commonMain/kotlin/net/thunderbird/core/logging/legacy/Log.kt b/core/logging/impl-legacy/src/commonMain/kotlin/net/thunderbird/core/logging/legacy/Log.kt new file mode 100644 index 0000000..09fb946 --- /dev/null +++ b/core/logging/impl-legacy/src/commonMain/kotlin/net/thunderbird/core/logging/legacy/Log.kt @@ -0,0 +1,159 @@ +package net.thunderbird.core.logging.legacy + +import androidx.annotation.Discouraged +import net.thunderbird.core.logging.LogMessage +import net.thunderbird.core.logging.LogTag +import net.thunderbird.core.logging.Logger + +/** + * A static logging utility that implements [net.thunderbird.core.logging.Logger] and delegates to a [net.thunderbird.core.logging.Logger] implementation. + * + * You can initialize it in your application startup code, for example: + * + * ```kotlin + * import net.thunderbird.core.logging.Log + * import net.thunderbird.core.logging.DefaultLogger // or any other Logger implementation + * fun main() { + * val sink: LogSink = // Your LogSink implementation + * val logger: Logger = DefaultLogger(sink) + * + * Log.logger = logger + * Log.i("Application started") + * // Your application code here + * } + * ``` + */ +@Discouraged( + message = "Use a net.thunderbird.core.logging.Logger instance via dependency injection instead. " + + "This class will be removed in a future release.", +) +object Log : Logger { + + lateinit var logger: Logger + + override fun verbose( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + logger.verbose( + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun debug( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + logger.debug( + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun info( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + logger.info( + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun warn( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + logger.warn( + tag = tag, + throwable = throwable, + message = message, + ) + } + + override fun error( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + logger.error( + tag = tag, + throwable = throwable, + message = message, + ) + } + + // Legacy Logger implementation + + @JvmStatic + fun v(message: String?, vararg args: Any?) { + logger.verbose(message = { formatMessage(message, args) }) + } + + @JvmStatic + fun v(t: Throwable?, message: String?, vararg args: Any?) { + logger.verbose(message = { formatMessage(message, args) }, throwable = t) + } + + @JvmStatic + fun d(message: String?, vararg args: Any?) { + logger.debug(message = { formatMessage(message, args) }) + } + + @JvmStatic + fun d(t: Throwable?, message: String?, vararg args: Any?) { + logger.debug(message = { formatMessage(message, args) }, throwable = t) + } + + @JvmStatic + fun i(message: String?, vararg args: Any?) { + logger.info(message = { formatMessage(message, args) }) + } + + @JvmStatic + fun i(t: Throwable?, message: String?, vararg args: Any?) { + logger.info(message = { formatMessage(message, args) }, throwable = t) + } + + @JvmStatic + fun w(message: String?, vararg args: Any?) { + logger.warn(message = { formatMessage(message, args) }) + } + + @JvmStatic + fun w(t: Throwable?, message: String?, vararg args: Any?) { + logger.warn(message = { formatMessage(message, args) }, throwable = t) + } + + @JvmStatic + fun e(message: String?, vararg args: Any?) { + logger.error(message = { formatMessage(message, args) }) + } + + @JvmStatic + fun e(t: Throwable?, message: String?, vararg args: Any?) { + logger.error(message = { formatMessage(message, args) }, throwable = t) + } + + private fun formatMessage(message: String?, args: Array): String { + return if (message == null) { + "" + } else if (args.isEmpty()) { + message + } else { + try { + String.format(message, *args) + } catch (e: Exception) { + "$message (Error formatting message: $e, args: ${args.joinToString()})" + } + } + } +} diff --git a/core/logging/impl-legacy/src/commonTest/kotlin/net/thunderbird/core/logging/legacy/LogTest.kt b/core/logging/impl-legacy/src/commonTest/kotlin/net/thunderbird/core/logging/legacy/LogTest.kt new file mode 100644 index 0000000..3b2ad5d --- /dev/null +++ b/core/logging/impl-legacy/src/commonTest/kotlin/net/thunderbird/core/logging/legacy/LogTest.kt @@ -0,0 +1,261 @@ +package net.thunderbird.core.logging.legacy + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.logging.testing.TestLogger.Companion.TIMESTAMP + +class LogTest { + + @Test + fun `init should set logger`() { + // Arrange + val logger = TestLogger() + + // Act + Log.logger = logger + Log.info( + tag = "Test tag", + message = { "Test message" }, + ) + + // Assert + assertThat(logger.events).hasSize(1) + assertThat(logger.events[0]).isEqualTo( + LogEvent( + level = LogLevel.INFO, + tag = "Test tag", + message = "Test message", + throwable = null, + timestamp = TIMESTAMP, + ), + ) + } + + @Test + fun `log should add all event to the logger`() { + // Arrange + val logger = TestLogger() + val exceptionVerbose = Exception("Verbose exception") + val exceptionDebug = Exception("Debug exception") + val exceptionInfo = Exception("Info exception") + val exceptionWarn = Exception("Warn exception") + val exceptionError = Exception("Error exception") + + Log.logger = logger + + // Act + Log.verbose( + tag = "Verbose tag", + throwable = exceptionVerbose, + message = { "Verbose message" }, + ) + Log.debug( + tag = "Debug tag", + throwable = exceptionDebug, + message = { "Debug message" }, + ) + Log.info( + tag = "Info tag", + throwable = exceptionInfo, + message = { "Info message" }, + ) + Log.warn( + tag = "Warn tag", + throwable = exceptionWarn, + message = { "Warn message" }, + ) + Log.error( + tag = "Error tag", + throwable = exceptionError, + message = { "Error message" }, + ) + + // Assert + val events = logger.events + assertThat(events).hasSize(5) + assertThat(events[0]).isEqualTo( + LogEvent( + level = LogLevel.VERBOSE, + tag = "Verbose tag", + message = "Verbose message", + throwable = exceptionVerbose, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[1]).isEqualTo( + LogEvent( + level = LogLevel.DEBUG, + tag = "Debug tag", + message = "Debug message", + throwable = exceptionDebug, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[2]).isEqualTo( + LogEvent( + level = LogLevel.INFO, + tag = "Info tag", + message = "Info message", + throwable = exceptionInfo, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[3]).isEqualTo( + LogEvent( + level = LogLevel.WARN, + tag = "Warn tag", + message = "Warn message", + throwable = exceptionWarn, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[4]).isEqualTo( + LogEvent( + level = LogLevel.ERROR, + tag = "Error tag", + message = "Error message", + throwable = exceptionError, + timestamp = TIMESTAMP, + ), + ) + } + + @Test + fun `legacy methods should log correctly`() { + // Arrange + val logger = TestLogger() + val exception = Exception("Test exception") + Log.logger = logger + + // Act - Test all legacy method signatures for each log level + + // Verbose methods + Log.v("Verbose message %s", "arg1") + Log.v(exception, "Verbose message with exception %s", "arg1") + + // Debug methods + Log.d("Debug message %s", "arg1") + Log.d(exception, "Debug message with exception %s", "arg1") + + // Info methods + Log.i("Info message %s", "arg1") + Log.i(exception, "Info message with exception %s", "arg1") + + // Warn methods + Log.w("Warn message %s", "arg1") + Log.w(exception, "Warn message with exception %s", "arg1") + + // Error methods + Log.e("Error message %s", "arg1") + Log.e(exception, "Error message with exception %s", "arg1") + + // Assert + val events = logger.events + assertThat(events).hasSize(10) + + // Verify verbose events + assertThat(events[0]).isEqualTo( + LogEvent( + level = LogLevel.VERBOSE, + tag = null, + message = "Verbose message arg1", + throwable = null, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[1]).isEqualTo( + LogEvent( + level = LogLevel.VERBOSE, + tag = null, + message = "Verbose message with exception arg1", + throwable = exception, + timestamp = TIMESTAMP, + ), + ) + + // Verify debug events + assertThat(events[2]).isEqualTo( + LogEvent( + level = LogLevel.DEBUG, + tag = null, + message = "Debug message arg1", + throwable = null, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[3]).isEqualTo( + LogEvent( + level = LogLevel.DEBUG, + tag = null, + message = "Debug message with exception arg1", + throwable = exception, + timestamp = TIMESTAMP, + ), + ) + + // Verify info events + assertThat(events[4]).isEqualTo( + LogEvent( + level = LogLevel.INFO, + tag = null, + message = "Info message arg1", + throwable = null, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[5]).isEqualTo( + LogEvent( + level = LogLevel.INFO, + tag = null, + message = "Info message with exception arg1", + throwable = exception, + timestamp = TIMESTAMP, + ), + ) + + // Verify warn events + assertThat(events[6]).isEqualTo( + LogEvent( + level = LogLevel.WARN, + tag = null, + message = "Warn message arg1", + throwable = null, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[7]).isEqualTo( + LogEvent( + level = LogLevel.WARN, + tag = null, + message = "Warn message with exception arg1", + throwable = exception, + timestamp = TIMESTAMP, + ), + ) + + // Verify error events + assertThat(events[8]).isEqualTo( + LogEvent( + level = LogLevel.ERROR, + tag = null, + message = "Error message arg1", + throwable = null, + timestamp = TIMESTAMP, + ), + ) + assertThat(events[9]).isEqualTo( + LogEvent( + level = LogLevel.ERROR, + tag = null, + message = "Error message with exception arg1", + throwable = exception, + timestamp = TIMESTAMP, + ), + ) + } +} diff --git a/core/logging/testing/build.gradle.kts b/core/logging/testing/build.gradle.kts new file mode 100644 index 0000000..d9b5263 --- /dev/null +++ b/core/logging/testing/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.logging.testing" +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(projects.core.logging.api) + } + } +} diff --git a/core/logging/testing/src/commonMain/kotlin/net/thunderbird/core/logging/testing/TestLogLevelManager.kt b/core/logging/testing/src/commonMain/kotlin/net/thunderbird/core/logging/testing/TestLogLevelManager.kt new file mode 100644 index 0000000..97b7333 --- /dev/null +++ b/core/logging/testing/src/commonMain/kotlin/net/thunderbird/core/logging/testing/TestLogLevelManager.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.logging.testing + +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogLevelManager + +class TestLogLevelManager : LogLevelManager { + var logLevel = LogLevel.VERBOSE + override fun override(level: LogLevel) { + logLevel = level + } + + override fun restoreDefault() { + logLevel = LogLevel.VERBOSE + } + + override fun current(): LogLevel = logLevel +} diff --git a/core/logging/testing/src/commonMain/kotlin/net/thunderbird/core/logging/testing/TestLogger.kt b/core/logging/testing/src/commonMain/kotlin/net/thunderbird/core/logging/testing/TestLogger.kt new file mode 100644 index 0000000..255f568 --- /dev/null +++ b/core/logging/testing/src/commonMain/kotlin/net/thunderbird/core/logging/testing/TestLogger.kt @@ -0,0 +1,99 @@ +package net.thunderbird.core.logging.testing + +import net.thunderbird.core.logging.LogEvent +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogMessage +import net.thunderbird.core.logging.LogTag +import net.thunderbird.core.logging.Logger + +/** + * A test logger that captures all log events in a list. + */ +class TestLogger() : Logger { + + val events: MutableList = mutableListOf() + + override fun verbose( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + events.add( + LogEvent( + level = LogLevel.VERBOSE, + tag = tag, + message = message(), + throwable = throwable, + timestamp = TIMESTAMP, + ), + ) + } + + override fun debug( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + events.add( + LogEvent( + level = LogLevel.DEBUG, + tag = tag, + message = message(), + throwable = throwable, + timestamp = TIMESTAMP, + ), + ) + } + + override fun info( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + events.add( + LogEvent( + level = LogLevel.INFO, + tag = tag, + message = message(), + throwable = throwable, + timestamp = TIMESTAMP, + ), + ) + } + + override fun warn( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + events.add( + LogEvent( + level = LogLevel.WARN, + tag = tag, + message = message(), + throwable = throwable, + timestamp = TIMESTAMP, + ), + ) + } + + override fun error( + tag: LogTag?, + throwable: Throwable?, + message: () -> LogMessage, + ) { + events.add( + LogEvent( + level = LogLevel.ERROR, + tag = tag, + message = message(), + throwable = throwable, + timestamp = TIMESTAMP, + ), + ) + } + + companion object { + const val TIMESTAMP = 1000L + } +} diff --git a/core/mail/mailserver/build.gradle.kts b/core/mail/mailserver/build.gradle.kts new file mode 100644 index 0000000..53abed5 --- /dev/null +++ b/core/mail/mailserver/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} diff --git a/core/mail/mailserver/src/main/kotlin/net/thunderbird/core/mail/mailserver/MailServerDirection.kt b/core/mail/mailserver/src/main/kotlin/net/thunderbird/core/mail/mailserver/MailServerDirection.kt new file mode 100644 index 0000000..acf1b7d --- /dev/null +++ b/core/mail/mailserver/src/main/kotlin/net/thunderbird/core/mail/mailserver/MailServerDirection.kt @@ -0,0 +1,6 @@ +package net.thunderbird.core.mail.mailserver + +enum class MailServerDirection { + INCOMING, + OUTGOING, +} diff --git a/core/outcome/build.gradle.kts b/core/outcome/build.gradle.kts new file mode 100644 index 0000000..0d75d37 --- /dev/null +++ b/core/outcome/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.outcome" +} diff --git a/core/outcome/src/commonMain/kotlin/net/thunderbird/core/outcome/Outcome.kt b/core/outcome/src/commonMain/kotlin/net/thunderbird/core/outcome/Outcome.kt new file mode 100644 index 0000000..656da23 --- /dev/null +++ b/core/outcome/src/commonMain/kotlin/net/thunderbird/core/outcome/Outcome.kt @@ -0,0 +1,108 @@ +package net.thunderbird.core.outcome + +sealed interface Outcome { + data class Success(val data: SUCCESS) : Outcome + data class Failure( + val error: FAILURE, + val cause: Any? = null, + ) : Outcome + + val isSuccess: Boolean + get() = this is Success + + val isFailure: Boolean + get() = this is Failure + + companion object { + fun success(data: SUCCESS): Outcome = Success(data) + fun failure(error: FAILURE): Outcome = Failure(error) + } +} + +/** + * Map the value and error of an [Outcome] to a new value. + * + * @param transformSuccess The function to transform the value of a [Success] to a new value. + * @param transformFailure The function to transform the value of a [Failure] to a new value. + */ +inline fun Outcome.map( + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return when (this) { + is Outcome.Success -> Outcome.Success(transformSuccess(data)) + is Outcome.Failure -> Outcome.Failure(transformFailure(error, cause)) + } +} + +/** + * Map the value of a [Outcome] to a new value. + * + * @param transformSuccess The function to transform the value of a [Success] to a new value. + */ +inline fun Outcome.mapSuccess( + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, +): Outcome { + return when (this) { + is Outcome.Success -> Outcome.Success(transformSuccess(data)) + is Outcome.Failure -> this + } +} + +/** + * Flat map the value and error of an [Outcome] to a new [Outcome]. + */ +inline fun Outcome.flatMapSuccess( + transformSuccess: (IN_SUCCESS) -> Outcome, +): Outcome { + return when (this) { + is Outcome.Success -> transformSuccess(data) + is Outcome.Failure -> this + } +} + +/** + * Map the error of a [Outcome] to a new value. + * + * @param transformFailure The function to transform the value of a [Failure] to a new value. + */ +inline fun Outcome.mapFailure( + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return when (this) { + is Outcome.Success -> this + is Outcome.Failure -> Outcome.Failure(transformFailure(error, cause)) + } +} + +/** + * Handle the value of an [Outcome] and execute the given function. + * + * @param onSuccess The function to execute if the outcome is a [Success]. + * @param onFailure The function to execute if the outcome is a [Failure]. + */ +fun Outcome.handle( + onSuccess: (SUCCESS) -> Unit, + onFailure: (FAILURE) -> Unit, +) { + when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} + +/** + * Handle the value of an [Outcome] and execute the given function. + * + * @param onSuccess The function to execute if the outcome is a [Success]. + * @param onFailure The function to execute if the outcome is a [Failure]. + */ +suspend fun Outcome.handleAsync( + onSuccess: suspend (SUCCESS) -> Unit, + onFailure: suspend (FAILURE) -> Unit, +) { + when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} diff --git a/core/preference/api/build.gradle.kts b/core/preference/api/build.gradle.kts new file mode 100644 index 0000000..60d40f1 --- /dev/null +++ b/core/preference/api/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.preference" + buildFeatures { + buildConfig = true + } +} diff --git a/core/preference/api/src/androidMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt b/core/preference/api/src/androidMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt new file mode 100644 index 0000000..07be4d5 --- /dev/null +++ b/core/preference/api/src/androidMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt @@ -0,0 +1,5 @@ +package net.thunderbird.core.preference.debugging + +import net.thunderbird.core.preference.BuildConfig + +actual val isDebug: Boolean = BuildConfig.DEBUG diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/GeneralSettings.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/GeneralSettings.kt new file mode 100644 index 0000000..f9fe6b9 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/GeneralSettings.kt @@ -0,0 +1,56 @@ +package net.thunderbird.core.preference + +import net.thunderbird.core.preference.debugging.DebuggingSettings +import net.thunderbird.core.preference.display.DisplaySettings +import net.thunderbird.core.preference.network.NetworkSettings +import net.thunderbird.core.preference.notification.NotificationPreference +import net.thunderbird.core.preference.privacy.PrivacySettings + +/** + * Stores a snapshot of the app's general settings. + * + * When adding a setting here, make sure to also add it in these places: + * - [GeneralSettingsManager] (write function) + * - [GeneralSettingsDescriptions] + */ +// TODO: Move over settings from K9 +data class GeneralSettings( + val network: NetworkSettings = NetworkSettings(), + val notification: NotificationPreference = NotificationPreference(), + val display: DisplaySettings = DisplaySettings(), + val privacy: PrivacySettings = PrivacySettings(), + val debugging: DebuggingSettings = DebuggingSettings(), +) + +enum class BackgroundSync { + ALWAYS, + NEVER, + FOLLOW_SYSTEM_AUTO_SYNC, +} + +enum class AppTheme { + LIGHT, + DARK, + FOLLOW_SYSTEM, +} + +enum class SubTheme { + LIGHT, + DARK, + USE_GLOBAL, +} + +enum class BackgroundOps { + ALWAYS, + NEVER, + WHEN_CHECKED_AUTO_SYNC, +} + +/** + * Controls when to use the message list split view. + */ +enum class SplitViewMode { + ALWAYS, + NEVER, + WHEN_IN_LANDSCAPE, +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/GeneralSettingsManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/GeneralSettingsManager.kt new file mode 100644 index 0000000..47a4592 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/GeneralSettingsManager.kt @@ -0,0 +1,25 @@ +package net.thunderbird.core.preference + +import kotlinx.coroutines.flow.Flow + +/** + * Retrieve and modify general settings. + * + */ +interface GeneralSettingsManager : PreferenceManager { + @Deprecated( + message = "Use PreferenceManager.getConfig() instead", + replaceWith = ReplaceWith( + expression = "getConfig()", + ), + ) + fun getSettings(): GeneralSettings + + @Deprecated( + message = "Use PreferenceManager.getConfigFlow() instead", + replaceWith = ReplaceWith( + expression = "getConfigFlow()", + ), + ) + fun getSettingsFlow(): Flow +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangeBroker.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangeBroker.kt new file mode 100644 index 0000000..f68406d --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangeBroker.kt @@ -0,0 +1,22 @@ +package net.thunderbird.core.preference + +/** + * Broker to manage subscribers and notify them about changes in the preferences, when the + * [PreferenceChangePublisher] publishes a change. + */ +interface PreferenceChangeBroker { + + /** + * Subscribe to preference changes. + * + * @param subscriber The subscriber to be notified about preference changes. + */ + fun subscribe(subscriber: PreferenceChangeSubscriber) + + /** + * Unsubscribe from preference changes. + * + * @param subscriber The subscriber that no longer wants to be notified about preference changes. + */ + fun unsubscribe(subscriber: PreferenceChangeSubscriber) +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangePublisher.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangePublisher.kt new file mode 100644 index 0000000..66061e4 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangePublisher.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.preference + +/** + * Publishes changes of preferences to all subscribers. + */ +interface PreferenceChangePublisher { + + /** + * Publish a change in the preferences. + */ + fun publish() +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangeSubscriber.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangeSubscriber.kt new file mode 100644 index 0000000..bd7feb1 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceChangeSubscriber.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.preference + +/** + * Subscribe to be notified about changes in the preferences. + */ +fun interface PreferenceChangeSubscriber { + + /** + * Called when preferences change. + */ + fun receive() +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceManager.kt new file mode 100644 index 0000000..3056134 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/PreferenceManager.kt @@ -0,0 +1,54 @@ +package net.thunderbird.core.preference + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for managing preferences of a specific type. + * + * This interface provides methods to save, retrieve, and observe changes to a configuration object. + * + * @param T The type of the configuration object being managed. + */ +interface PreferenceManager { + /** + * Saves the given configuration. + * + * @param config The configuration of type [T] to be saved. + */ + fun save(config: T) + + /** + * Retrieves a snapshot of the current configuration. + * + * **Note:** This function provides a one-time snapshot of the configuration. + * For observing configuration changes reactively, it is recommended to use [getConfigFlow] instead. + * + * @return The current configuration of type [T]. + */ + fun getConfig(): T + + /** + * Returns a [Flow] that emits the configuration whenever it changes. + * + * This allows observing changes to the configuration in a reactive way. + * + * @return A [Flow] of [T] representing the configuration. + */ + fun getConfigFlow(): Flow +} + +/** + * Updates the configuration by applying the given [updater] function to the current configuration. + * + * This function is an inline extension function for [PreferenceManager]. + * It retrieves the current configuration, applies the [updater] function to it, + * and then saves the updated configuration. + * + * @param T The type of the configuration. + * @param updater A lambda function that takes the current configuration of type [T] + * and returns the updated configuration of type [T]. + */ +inline fun PreferenceManager.update(updater: (T) -> T) { + val config = getConfig() + save(updater(config)) +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt new file mode 100644 index 0000000..8d59874 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt @@ -0,0 +1,3 @@ +package net.thunderbird.core.preference.debugging + +expect val isDebug: Boolean diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DebuggingSettings.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DebuggingSettings.kt new file mode 100644 index 0000000..4b9f132 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DebuggingSettings.kt @@ -0,0 +1,9 @@ +package net.thunderbird.core.preference.debugging + +const val DEBUGGING_SETTINGS_DEFAULT_IS_SYNC_LOGGING_ENABLED = false +val DEBUGGING_SETTINGS_DEFAULT_IS_DEBUGGING_LOGGING_ENABLED = isDebug + +data class DebuggingSettings( + val isDebugLoggingEnabled: Boolean = DEBUGGING_SETTINGS_DEFAULT_IS_DEBUGGING_LOGGING_ENABLED, + val isSyncLoggingEnabled: Boolean = DEBUGGING_SETTINGS_DEFAULT_IS_SYNC_LOGGING_ENABLED, +) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DebuggingSettingsPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DebuggingSettingsPreferenceManager.kt new file mode 100644 index 0000000..ee25480 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DebuggingSettingsPreferenceManager.kt @@ -0,0 +1,8 @@ +package net.thunderbird.core.preference.debugging + +import net.thunderbird.core.preference.PreferenceManager + +const val KEY_ENABLE_DEBUG_LOGGING = "enableDebugLogging" +const val KEY_ENABLE_SYNC_DEBUG_LOGGING = "enableSyncDebugLogging" + +interface DebuggingSettingsPreferenceManager : PreferenceManager diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/DisplaySettings.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/DisplaySettings.kt new file mode 100644 index 0000000..4dd6fca --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/DisplaySettings.kt @@ -0,0 +1,32 @@ +package net.thunderbird.core.preference.display + +import net.thunderbird.core.preference.display.coreSettings.DisplayCoreSettings +import net.thunderbird.core.preference.display.inboxSettings.DisplayInboxSettings + +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_ANIMATION = true +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CORRESPONDENT_NAMES = true +const val DISPLAY_SETTINGS_DEFAULT_SHOW_RECENT_CHANGES = true +const val DISPLAY_SETTINGS_DEFAULT_SHOULD_SHOW_SETUP_ARCHIVE_FOLDER_DIALOG = true +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CONTACT_NAME = false +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CONTACT_PICTURE = true +const val DISPLAY_SETTINGS_DEFAULT_IS_CHANGE_CONTACT_NAME_COLOR = true +const val DISPLAY_SETTINGS_DEFAULT_IS_COLORIZE_MISSING_CONTACT_PICTURE = false +const val DISPLAY_SETTINGS_DEFAULT_IS_USE_BACKGROUND_AS_INDICATOR = false +const val DISPLAY_SETTINGS_DEFAULT_IS_USE_MESSAGE_VIEW_FIXED_WIDTH_FONT = false +const val DISPLAY_SETTINGS_DEFAULT_IS_AUTO_FIT_WIDTH = true + +data class DisplaySettings( + val coreSettings: DisplayCoreSettings = DisplayCoreSettings(), + val inboxSettings: DisplayInboxSettings = DisplayInboxSettings(), + val isShowAnimations: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_ANIMATION, + val isShowCorrespondentNames: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CORRESPONDENT_NAMES, + val showRecentChanges: Boolean = DISPLAY_SETTINGS_DEFAULT_SHOW_RECENT_CHANGES, + val shouldShowSetupArchiveFolderDialog: Boolean = DISPLAY_SETTINGS_DEFAULT_SHOULD_SHOW_SETUP_ARCHIVE_FOLDER_DIALOG, + val isShowContactName: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CONTACT_NAME, + val isShowContactPicture: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CONTACT_PICTURE, + val isChangeContactNameColor: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_CHANGE_CONTACT_NAME_COLOR, + val isColorizeMissingContactPictures: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_COLORIZE_MISSING_CONTACT_PICTURE, + val isUseBackgroundAsUnreadIndicator: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_USE_BACKGROUND_AS_INDICATOR, + val isUseMessageViewFixedWidthFont: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_USE_MESSAGE_VIEW_FIXED_WIDTH_FONT, + val isAutoFitWidth: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_AUTO_FIT_WIDTH, +) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/DisplaySettingsPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/DisplaySettingsPreferenceManager.kt new file mode 100644 index 0000000..cc25cf9 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/DisplaySettingsPreferenceManager.kt @@ -0,0 +1,18 @@ +package net.thunderbird.core.preference.display + +import net.thunderbird.core.preference.PreferenceManager + +const val KEY_SHOW_CONTACT_NAME = "showContactName" +const val KEY_SHOW_CORRESPONDENT_NAMES = "showCorrespondentNames" +const val KEY_SHOW_RECENT_CHANGES = "showRecentChanges" +const val KEY_THEME = "theme" +const val KEY_ANIMATION = "animations" +const val KEY_SHOULD_SHOW_SETUP_ARCHIVE_FOLDER_DIALOG = "shouldShowSetupArchiveFolderDialog" +const val KEY_CHANGE_REGISTERED_NAME_COLOR = "changeRegisteredNameColor" +const val KEY_COLORIZE_MISSING_CONTACT_PICTURE = "colorizeMissingContactPictures" +const val KEY_USE_BACKGROUND_AS_UNREAD_INDICATOR = "isUseBackgroundAsUnreadIndicator" +const val KEY_MESSAGE_VIEW_FIXED_WIDTH_FONT = "messageViewFixedWidthFont" +const val KEY_AUTO_FIT_WIDTH = "autofitWidth" +const val KEY_SHOW_CONTACT_PICTURE = "showContactPicture" + +interface DisplaySettingsPreferenceManager : PreferenceManager diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DisplayCoreSettings.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DisplayCoreSettings.kt new file mode 100644 index 0000000..9729c64 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DisplayCoreSettings.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.preference.display.coreSettings + +import net.thunderbird.core.preference.AppTheme +import net.thunderbird.core.preference.SplitViewMode +import net.thunderbird.core.preference.SubTheme + +const val DISPLAY_SETTINGS_DEFAULT_APP_LANGUAGE = "" +const val DISPLAY_SETTINGS_DEFAULT_FIXED_MESSAGE_VIEW_THEME = true +val DISPLAY_SETTINGS_DEFAULT_APP_THEME = AppTheme.FOLLOW_SYSTEM +val DISPLAY_SETTINGS_DEFAULT_MESSAGE_COMPOSE_THEME = SubTheme.USE_GLOBAL +val DISPLAY_SETTINGS_DEFAULT_SPLIT_VIEW_MODE = SplitViewMode.NEVER +val DISPLAY_SETTINGS_DEFAULT_MESSAGE_VIEW_THEME = SubTheme.USE_GLOBAL +data class DisplayCoreSettings( + val fixedMessageViewTheme: Boolean = DISPLAY_SETTINGS_DEFAULT_FIXED_MESSAGE_VIEW_THEME, + val appTheme: AppTheme = DISPLAY_SETTINGS_DEFAULT_APP_THEME, + val messageViewTheme: SubTheme = DISPLAY_SETTINGS_DEFAULT_MESSAGE_VIEW_THEME, + val messageComposeTheme: SubTheme = DISPLAY_SETTINGS_DEFAULT_MESSAGE_COMPOSE_THEME, + val appLanguage: String = DISPLAY_SETTINGS_DEFAULT_APP_LANGUAGE, + val splitViewMode: SplitViewMode = DISPLAY_SETTINGS_DEFAULT_SPLIT_VIEW_MODE, +) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DisplayCoreSettingsPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DisplayCoreSettingsPreferenceManager.kt new file mode 100644 index 0000000..b0d5c77 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DisplayCoreSettingsPreferenceManager.kt @@ -0,0 +1,11 @@ +package net.thunderbird.core.preference.display.coreSettings + +import net.thunderbird.core.preference.PreferenceManager + +const val KEY_FIXED_MESSAGE_VIEW_THEME = "fixedMessageViewTheme" +const val KEY_MESSAGE_VIEW_THEME = "messageViewTheme" +const val KEY_MESSAGE_COMPOSE_THEME = "messageComposeTheme" +const val KEY_APP_LANGUAGE = "language" +const val KEY_SPLIT_VIEW_MODE = "splitViewMode" + +interface DisplayCoreSettingsPreferenceManager : PreferenceManager diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DisplayInboxSettings.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DisplayInboxSettings.kt new file mode 100644 index 0000000..6b96b56 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DisplayInboxSettings.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.preference.display.inboxSettings + +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_UNIFIED_INBOX = false +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_STAR_COUNT = false +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_MESSAGE_LIST_STAR = true +const val DISPLAY_SETTINGS_DEFAULT_IS_MESSAGE_LIST_SENDER_ABOVE_SUBJECT = false +const val DISPLAY_SETTINGS_DEFAULT_IS_SHOW_COMPOSE_BUTTON_ON_MESSAGE_LIST = true +const val DISPLAY_SETTINGS_DEFAULT_IS_THREAD_VIEW_ENABLED = true + +data class DisplayInboxSettings( + val isShowUnifiedInbox: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_UNIFIED_INBOX, + val isShowStarredCount: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_STAR_COUNT, + val isShowMessageListStars: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_MESSAGE_LIST_STAR, + val isMessageListSenderAboveSubject: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_MESSAGE_LIST_SENDER_ABOVE_SUBJECT, + val isShowComposeButtonOnMessageList: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_SHOW_COMPOSE_BUTTON_ON_MESSAGE_LIST, + val isThreadedViewEnabled: Boolean = DISPLAY_SETTINGS_DEFAULT_IS_THREAD_VIEW_ENABLED, +) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DisplayInboxSettingsPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DisplayInboxSettingsPreferenceManager.kt new file mode 100644 index 0000000..e38647f --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DisplayInboxSettingsPreferenceManager.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.preference.display.inboxSettings + +import net.thunderbird.core.preference.PreferenceManager + +const val KEY_MESSAGE_LIST_SENDER_ABOVE_SUBJECT = "messageListSenderAboveSubject" +const val KEY_SHOW_COMPOSE_BUTTON_ON_MESSAGE_LIST = "showComposeButtonOnMessageList" +const val KEY_SHOW_MESSAGE_LIST_STARS = "messageListStars" +const val KEY_SHOW_STAR_COUNT = "showStarredCount" +const val KEY_SHOW_UNIFIED_INBOX = "showUnifiedInbox" +const val KEY_THREAD_VIEW_ENABLED = "isThreadedViewEnabled" + +interface DisplayInboxSettingsPreferenceManager : PreferenceManager diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/network/NetworkSettings.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/network/NetworkSettings.kt new file mode 100644 index 0000000..92debbe --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/network/NetworkSettings.kt @@ -0,0 +1,9 @@ +package net.thunderbird.core.preference.network + +import net.thunderbird.core.preference.BackgroundOps + +val NETWORK_SETTINGS_DEFAULT_BACKGROUND_OPS = BackgroundOps.ALWAYS + +data class NetworkSettings( + val backgroundOps: BackgroundOps = NETWORK_SETTINGS_DEFAULT_BACKGROUND_OPS, +) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/network/NetworkSettingsPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/network/NetworkSettingsPreferenceManager.kt new file mode 100644 index 0000000..bd97a95 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/network/NetworkSettingsPreferenceManager.kt @@ -0,0 +1,7 @@ +package net.thunderbird.core.preference.network + +import net.thunderbird.core.preference.PreferenceManager + +const val KEY_BG_OPS = "backgroundOperations" + +interface NetworkSettingsPreferenceManager : PreferenceManager diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt new file mode 100644 index 0000000..43cc2a6 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreference.kt @@ -0,0 +1,11 @@ +package net.thunderbird.core.preference.notification + +const val NOTIFICATION_PREFERENCE_DEFAULT_IS_QUIET_TIME_ENABLED = false +const val NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_STARTS = "21:00" +const val NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_END = "7:00" + +data class NotificationPreference( + val isQuietTimeEnabled: Boolean = NOTIFICATION_PREFERENCE_DEFAULT_IS_QUIET_TIME_ENABLED, + val quietTimeStarts: String = NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_STARTS, + val quietTimeEnds: String = NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_END, +) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt new file mode 100644 index 0000000..e9a402a --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/notification/NotificationPreferenceManager.kt @@ -0,0 +1,9 @@ +package net.thunderbird.core.preference.notification + +import net.thunderbird.core.preference.PreferenceManager + +const val KEY_QUIET_TIME_ENDS = "quietTimeEnds" +const val KEY_QUIET_TIME_STARTS = "quietTimeStarts" +const val KEY_QUIET_TIME_ENABLED = "quietTimeEnabled" + +interface NotificationPreferenceManager : PreferenceManager diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/PrivacySettings.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/PrivacySettings.kt new file mode 100644 index 0000000..0f74b24 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/PrivacySettings.kt @@ -0,0 +1,9 @@ +package net.thunderbird.core.preference.privacy + +const val PRIVACY_SETTINGS_DEFAULT_HIDE_TIME_ZONE = false +const val PRIVACY_SETTINGS_DEFAULT_HIDE_USER_AGENT = false + +data class PrivacySettings( + val isHideTimeZone: Boolean = PRIVACY_SETTINGS_DEFAULT_HIDE_TIME_ZONE, + val isHideUserAgent: Boolean = PRIVACY_SETTINGS_DEFAULT_HIDE_USER_AGENT, +) diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/PrivacySettingsPreferenceManager.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/PrivacySettingsPreferenceManager.kt new file mode 100644 index 0000000..60e3faf --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/PrivacySettingsPreferenceManager.kt @@ -0,0 +1,8 @@ +package net.thunderbird.core.preference.privacy + +import net.thunderbird.core.preference.PreferenceManager + +const val KEY_HIDE_TIME_ZONE = "hideTimeZone" +const val KEY_HIDE_USER_AGENT = "hideUserAgent" + +interface PrivacySettingsPreferenceManager : PreferenceManager diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/Storage.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/Storage.kt new file mode 100644 index 0000000..1d9b9e6 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/Storage.kt @@ -0,0 +1,108 @@ +package net.thunderbird.core.preference.storage + +interface Storage { + /** + * Checks if the storage is empty. + * + * @return true if the storage is empty, false otherwise. + */ + fun isEmpty(): Boolean + + /** + * Checks if the storage contains a value for the given key. + * + * @param key The key to check. + * @return true if the storage contains a value for the given key, false otherwise. + */ + fun contains(key: String): Boolean + + /** + * Returns a map of all key-value pairs in the storage. + * + * @return A map of all key-value pairs. + */ + fun getAll(): Map + + /** + * Returns the boolean value for the given key. + * + * @param key The key to look up. + * @param defValue The default value to return if the key is not found. + * @return The boolean value for the given key, or the default value if the key is not found. + */ + fun getBoolean(key: String, defValue: Boolean): Boolean + + /** + * Returns the integer value for the given key. + * + * @param key The key to look up. + * @param defValue The default value to return if the key is not found. + * @return The integer value for the given key, or the default value if the key is not found. + */ + fun getInt(key: String, defValue: Int): Int + + /** + * Returns the long value for the given key. + * + * @param key The key to look up. + * @param defValue The default value to return if the key is not found. + * @return The long value for the given key, or the default value if the key is not found. + */ + fun getLong(key: String, defValue: Long): Long + + /** + * Returns the string value for the given key. + * + * @param key The key to look up. + * @return The string value for the given key. + * @throws NoSuchElementException if the key is not found. + */ + @Throws(NoSuchElementException::class) + fun getString(key: String): String + + /** + * Returns the string value for the given key. + * + * @param key The key to look up. + * @param defValue The default value to return if the key is not found. + * @return The string value for the given key, or the default value if the key is not found. + */ + fun getStringOrDefault(key: String, defValue: String): String + + /** + * Returns the string value for the given key, or null if the key is not found. + * + * @param key The key to look up. + * @return The string value for the given key, or null if the key is not found. + */ + fun getStringOrNull(key: String): String? +} + +/** + * Returns the enum value for the given key, or the default value if the key is not found or the stored value + * is not a valid enum constant. + * + * @param T The enum type. + * @param key The key to look up. + * @param default The default enum value to return if the key is not found or the value is invalid. + * @return The enum value for the given key, or the default value. + * @throws IllegalArgumentException if the stored string value does not match any of the enum constants. + */ +@Throws(IllegalArgumentException::class) +inline fun > Storage.getEnumOrDefault(key: String, default: T): T = + getStringOrNull(key) + ?.let { value -> + try { + enumValueOf(value) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException( + buildString { + append("Unable to convert stored key [$key] value [$value] ") + appendLine("to enum of type ${T::class.qualifiedName}.") + append("Valid values: ${enumValues().joinToString()}") + }, + e, + ) + } + } + ?: default diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageEditor.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageEditor.kt new file mode 100644 index 0000000..8987a09 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageEditor.kt @@ -0,0 +1,61 @@ +package net.thunderbird.core.preference.storage + +/** + * Interface for editing the storage. + * + * This interface provides methods to put various types of values into the storage, + * remove values, and commit the changes. + */ +interface StorageEditor { + + /** + * Puts a boolean value into the storage. + * + * @param key The key for the value. + * @param value The boolean value to put. + * @return The StorageEditor instance for chaining. + */ + fun putBoolean(key: String, value: Boolean): StorageEditor + + /** + * Puts an integer value into the storage. + * + * @param key The key for the value. + * @param value The integer value to put. + * @return The StorageEditor instance for chaining. + */ + fun putInt(key: String, value: Int): StorageEditor + + /** + * Puts a long value into the storage. + * + * @param key The key for the value. + * @param value The long value to put. + * @return The StorageEditor instance for chaining. + */ + fun putLong(key: String, value: Long): StorageEditor + + /** + * Puts a string value into the storage. + * + * @param key The key for the value. + * @param value The string value to put. If null, the key will be removed. + * @return The StorageEditor instance for chaining. + */ + fun putString(key: String, value: String?): StorageEditor + + /** + * Removes a value from the storage. + * + * @param key The key for the value to remove. + * @return The StorageEditor instance for chaining. + */ + fun remove(key: String): StorageEditor + + /** + * Commits the changes made to the storage. + * + * @return true if the commit was successful, false otherwise. + */ + fun commit(): Boolean +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageEditorExtensions.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageEditorExtensions.kt new file mode 100644 index 0000000..231a241 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageEditorExtensions.kt @@ -0,0 +1,12 @@ +package net.thunderbird.core.preference.storage + +/** + * Extension functions for the [StorageEditor] interface to simplify putting enum values. + * + * @param T The type of the enum. + * @param key The key under which the enum value will be stored. + * @param value The enum value to be stored. + */ +inline fun > StorageEditor.putEnum(key: String, value: T) { + putString(key, value.name) +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StoragePersister.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StoragePersister.kt new file mode 100644 index 0000000..c4b4c0d --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StoragePersister.kt @@ -0,0 +1,26 @@ +package net.thunderbird.core.preference.storage + +/** + * Represents a mechanism for persisting storage data. + * + * This interface provides methods to: + * - Load the current storage values. + * - Create a storage editor for applying updates to the storage. + */ +interface StoragePersister { + + /** + * Loads the storage values. + * + * @return The loaded storage. + */ + fun loadValues(): Storage + + /** + * Creates a storage editor for updating the storage. + * + * @param storageUpdater The updater to apply changes to the storage. + * @return A new instance of [StorageEditor]. + */ + fun createStorageEditor(storageUpdater: StorageUpdater): StorageEditor +} diff --git a/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageUpdater.kt b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageUpdater.kt new file mode 100644 index 0000000..5959ea2 --- /dev/null +++ b/core/preference/api/src/commonMain/kotlin/net/thunderbird/core/preference/storage/StorageUpdater.kt @@ -0,0 +1,14 @@ +package net.thunderbird.core.preference.storage + +/** + * Interface for updating the storage. + */ +fun interface StorageUpdater { + + /** + * Updates the storage using the provided updater function. + * + * @param updater A function that takes the current storage and returns the updated storage. + */ + fun updateStorage(updater: (currentStorage: Storage) -> Storage) +} diff --git a/core/preference/api/src/jvmMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt b/core/preference/api/src/jvmMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt new file mode 100644 index 0000000..c9e6990 --- /dev/null +++ b/core/preference/api/src/jvmMain/kotlin/net/thunderbird/core/preference/debugging/BuildInfo.kt @@ -0,0 +1,3 @@ +package net.thunderbird.core.preference.debugging + +actual val isDebug: Boolean = false diff --git a/core/preference/impl/build.gradle.kts b/core/preference/impl/build.gradle.kts new file mode 100644 index 0000000..1da2d0e --- /dev/null +++ b/core/preference/impl/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.preference.impl" +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(projects.core.preference.api) + + implementation(projects.core.logging.api) + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/DefaultPreferenceChangeBroker.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/DefaultPreferenceChangeBroker.kt new file mode 100644 index 0000000..75ddb97 --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/DefaultPreferenceChangeBroker.kt @@ -0,0 +1,28 @@ +package net.thunderbird.core.preference + +class DefaultPreferenceChangeBroker( + private val subscribers: MutableSet = mutableSetOf(), +) : PreferenceChangeBroker, PreferenceChangePublisher { + + private val lock = Any() + + override fun subscribe(subscriber: PreferenceChangeSubscriber) { + synchronized(lock) { + subscribers.add(subscriber) + } + } + + override fun unsubscribe(subscriber: PreferenceChangeSubscriber) { + synchronized(lock) { + subscribers.remove(subscriber) + } + } + + override fun publish() { + val currentSubscribers = synchronized(lock) { HashSet(subscribers) } + + for (subscriber in currentSubscribers) { + subscriber.receive() + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DefaultDebuggingSettingsPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DefaultDebuggingSettingsPreferenceManager.kt new file mode 100644 index 0000000..77cb628 --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/debugging/DefaultDebuggingSettingsPreferenceManager.kt @@ -0,0 +1,72 @@ +package net.thunderbird.core.preference.debugging + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.LogLevelManager +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor + +private const val TAG = "DefaultDebuggingSettingsPreferenceManager" + +class DefaultDebuggingSettingsPreferenceManager( + private val logger: Logger, + private val storage: Storage, + private val storageEditor: StorageEditor, + private val logLevelManager: LogLevelManager, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var scope: CoroutineScope = CoroutineScope(SupervisorJob()), +) : DebuggingSettingsPreferenceManager { + private val configState: MutableStateFlow = MutableStateFlow(value = loadConfig()) + private val mutex = Mutex() + + override fun getConfig(): DebuggingSettings = configState.value + override fun getConfigFlow(): Flow = configState + + override fun save(config: DebuggingSettings) { + logger.debug(TAG) { "save() called with: config = $config" } + writeConfig(config) + configState.update { config.also(::updateDebugLogLevel) } + } + + private fun loadConfig(): DebuggingSettings = DebuggingSettings( + isDebugLoggingEnabled = storage.getBoolean( + KEY_ENABLE_DEBUG_LOGGING, + DEBUGGING_SETTINGS_DEFAULT_IS_DEBUGGING_LOGGING_ENABLED, + ), + isSyncLoggingEnabled = storage.getBoolean( + KEY_ENABLE_SYNC_DEBUG_LOGGING, + DEBUGGING_SETTINGS_DEFAULT_IS_SYNC_LOGGING_ENABLED, + ), + ).also(::updateDebugLogLevel) + + private fun writeConfig(config: DebuggingSettings) { + logger.debug(TAG) { "writeConfig() called with: config = $config" } + scope.launch(ioDispatcher) { + mutex.withLock { + storageEditor.putBoolean(KEY_ENABLE_DEBUG_LOGGING, config.isDebugLoggingEnabled) + storageEditor.putBoolean(KEY_ENABLE_SYNC_DEBUG_LOGGING, config.isSyncLoggingEnabled) + storageEditor.commit().also { commited -> + logger.verbose(TAG) { "writeConfig: storageEditor.commit() resulted in: $commited" } + } + } + } + } + + private fun updateDebugLogLevel(config: DebuggingSettings) { + if (config.isDebugLoggingEnabled) { + logLevelManager.override(LogLevel.DEBUG) + } else { + logLevelManager.restoreDefault() + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/DefaultDisplaySettingsPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/DefaultDisplaySettingsPreferenceManager.kt new file mode 100644 index 0000000..93b2f4c --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/DefaultDisplaySettingsPreferenceManager.kt @@ -0,0 +1,131 @@ +package net.thunderbird.core.preference.display + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.display.coreSettings.DisplayCoreSettingsPreferenceManager +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor + +private const val TAG = "DefaultDisplaySettingsPreferenceManager" + +class DefaultDisplaySettingsPreferenceManager( + private val logger: Logger, + private val storage: Storage, + private val storageEditor: StorageEditor, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var scope: CoroutineScope = CoroutineScope(SupervisorJob()), + private val coreSettingsPreferenceManager: DisplayCoreSettingsPreferenceManager, +) : DisplaySettingsPreferenceManager { + private val configState: MutableStateFlow = MutableStateFlow(value = loadConfig()) + private val mutex = Mutex() + + override fun getConfig(): DisplaySettings = configState.value + override fun getConfigFlow(): Flow = configState + + override fun save(config: DisplaySettings) { + logger.debug(TAG) { "save() called with: config = $config" } + coreSettingsPreferenceManager.save(config.coreSettings) + writeConfig(config) + configState.update { config } + } + + private fun loadConfig(): DisplaySettings = DisplaySettings( + coreSettings = coreSettingsPreferenceManager.getConfig(), + showRecentChanges = storage.getBoolean( + KEY_SHOW_RECENT_CHANGES, + DISPLAY_SETTINGS_DEFAULT_SHOW_RECENT_CHANGES, + ), + shouldShowSetupArchiveFolderDialog = storage.getBoolean( + KEY_SHOULD_SHOW_SETUP_ARCHIVE_FOLDER_DIALOG, + DISPLAY_SETTINGS_DEFAULT_SHOULD_SHOW_SETUP_ARCHIVE_FOLDER_DIALOG, + ), + isColorizeMissingContactPictures = storage.getBoolean( + KEY_COLORIZE_MISSING_CONTACT_PICTURE, + DISPLAY_SETTINGS_DEFAULT_IS_COLORIZE_MISSING_CONTACT_PICTURE, + ), + isChangeContactNameColor = storage.getBoolean( + KEY_CHANGE_REGISTERED_NAME_COLOR, + DISPLAY_SETTINGS_DEFAULT_IS_CHANGE_CONTACT_NAME_COLOR, + ), + isUseBackgroundAsUnreadIndicator = storage.getBoolean( + KEY_USE_BACKGROUND_AS_UNREAD_INDICATOR, + DISPLAY_SETTINGS_DEFAULT_IS_USE_BACKGROUND_AS_INDICATOR, + ), + isUseMessageViewFixedWidthFont = storage.getBoolean( + KEY_MESSAGE_VIEW_FIXED_WIDTH_FONT, + DISPLAY_SETTINGS_DEFAULT_IS_USE_MESSAGE_VIEW_FIXED_WIDTH_FONT, + ), + isAutoFitWidth = storage.getBoolean( + KEY_AUTO_FIT_WIDTH, + DISPLAY_SETTINGS_DEFAULT_IS_AUTO_FIT_WIDTH, + ), + isShowAnimations = storage.getBoolean( + KEY_ANIMATION, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_ANIMATION, + ), + isShowCorrespondentNames = storage.getBoolean( + KEY_SHOW_CORRESPONDENT_NAMES, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CORRESPONDENT_NAMES, + ), + isShowContactName = storage.getBoolean( + KEY_SHOW_CONTACT_NAME, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CONTACT_NAME, + ), + isShowContactPicture = storage.getBoolean( + KEY_SHOW_CONTACT_PICTURE, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_CONTACT_PICTURE, + ), + ) + + private fun writeConfig(config: DisplaySettings) { + logger.debug(TAG) { "writeConfig() called with: config = $config" } + scope.launch(ioDispatcher) { + mutex.withLock { + storageEditor.putBoolean( + KEY_CHANGE_REGISTERED_NAME_COLOR, + config.isChangeContactNameColor, + ) + storageEditor.putBoolean( + KEY_COLORIZE_MISSING_CONTACT_PICTURE, + config.isColorizeMissingContactPictures, + ) + storageEditor.putBoolean( + KEY_SHOULD_SHOW_SETUP_ARCHIVE_FOLDER_DIALOG, + config.shouldShowSetupArchiveFolderDialog, + ) + storageEditor.putBoolean(KEY_SHOW_CONTACT_NAME, config.isShowContactName) + storageEditor.putBoolean( + KEY_SHOW_CORRESPONDENT_NAMES, + config.isShowCorrespondentNames, + ) + storageEditor.putBoolean(KEY_SHOW_RECENT_CHANGES, config.showRecentChanges) + storageEditor.putBoolean(KEY_ANIMATION, config.isShowAnimations) + storageEditor.putBoolean( + KEY_SHOW_CONTACT_PICTURE, + config.isShowContactPicture, + ) + storageEditor.putBoolean( + KEY_USE_BACKGROUND_AS_UNREAD_INDICATOR, + config.isUseBackgroundAsUnreadIndicator, + ) + storageEditor.putBoolean( + KEY_MESSAGE_VIEW_FIXED_WIDTH_FONT, + config.isUseMessageViewFixedWidthFont, + ) + storageEditor.putBoolean(KEY_AUTO_FIT_WIDTH, config.isAutoFitWidth) + storageEditor.commit().also { commited -> + logger.verbose(TAG) { "writeConfig: storageEditor.commit() resulted in: $commited" } + } + } + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DefaultDisplayCoreSettingsPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DefaultDisplayCoreSettingsPreferenceManager.kt new file mode 100644 index 0000000..eb93c0b --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/coreSettings/DefaultDisplayCoreSettingsPreferenceManager.kt @@ -0,0 +1,89 @@ +package net.thunderbird.core.preference.display.coreSettings + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.display.KEY_THEME +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor +import net.thunderbird.core.preference.storage.getEnumOrDefault +import net.thunderbird.core.preference.storage.putEnum + +private const val TAG = "DefaultDisplayCoreSettingsPreferenceManager" + +class DefaultDisplayCoreSettingsPreferenceManager( + private val logger: Logger, + private val storage: Storage, + private val storageEditor: StorageEditor, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var scope: CoroutineScope = CoroutineScope(SupervisorJob()), +) : DisplayCoreSettingsPreferenceManager { + + private val configState: MutableStateFlow = MutableStateFlow(value = loadConfig()) + private val mutex = Mutex() + + override fun getConfig(): DisplayCoreSettings = configState.value + + override fun getConfigFlow(): Flow = configState + + override fun save(config: DisplayCoreSettings) { + logger.debug(TAG) { "save() called with: config = $config" } + writeConfig(config) + configState.update { config } + } + + private fun loadConfig(): DisplayCoreSettings = DisplayCoreSettings( + fixedMessageViewTheme = storage.getBoolean( + KEY_FIXED_MESSAGE_VIEW_THEME, + DISPLAY_SETTINGS_DEFAULT_FIXED_MESSAGE_VIEW_THEME, + ), + appTheme = storage.getEnumOrDefault(KEY_THEME, DISPLAY_SETTINGS_DEFAULT_APP_THEME), + messageViewTheme = storage.getEnumOrDefault( + KEY_MESSAGE_VIEW_THEME, + DISPLAY_SETTINGS_DEFAULT_MESSAGE_VIEW_THEME, + ), + messageComposeTheme = storage.getEnumOrDefault( + KEY_MESSAGE_COMPOSE_THEME, + DISPLAY_SETTINGS_DEFAULT_MESSAGE_COMPOSE_THEME, + ), + appLanguage = storage.getStringOrDefault( + KEY_APP_LANGUAGE, + DISPLAY_SETTINGS_DEFAULT_APP_LANGUAGE, + ), + splitViewMode = storage.getEnumOrDefault( + KEY_SPLIT_VIEW_MODE, + DISPLAY_SETTINGS_DEFAULT_SPLIT_VIEW_MODE, + ), + ) + + private fun writeConfig(config: DisplayCoreSettings) { + logger.debug(TAG) { "writeConfig() called with: config = $config" } + scope.launch(ioDispatcher) { + mutex.withLock { + storageEditor.putEnum(KEY_THEME, config.appTheme) + storageEditor.putEnum(KEY_MESSAGE_VIEW_THEME, config.messageViewTheme) + storageEditor.putEnum( + KEY_MESSAGE_COMPOSE_THEME, + config.messageComposeTheme, + ) + storageEditor.putBoolean( + KEY_FIXED_MESSAGE_VIEW_THEME, + config.fixedMessageViewTheme, + ) + storageEditor.putString(KEY_APP_LANGUAGE, config.appLanguage) + storageEditor.putEnum(KEY_SPLIT_VIEW_MODE, config.splitViewMode) + storageEditor.commit().also { commited -> + logger.verbose(TAG) { "writeConfig: storageEditor.commit() resulted in: $commited" } + } + } + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DefaultDisplayInboxSettingsPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DefaultDisplayInboxSettingsPreferenceManager.kt new file mode 100644 index 0000000..525a1e0 --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/display/inboxSettings/DefaultDisplayInboxSettingsPreferenceManager.kt @@ -0,0 +1,95 @@ +package net.thunderbird.core.preference.display.inboxSettings + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor + +private const val TAG = "DefaultDisplayInboxSettingsPreferenceManager" + +class DefaultDisplayInboxSettingsPreferenceManager( + private val logger: Logger, + private val storage: Storage, + private val storageEditor: StorageEditor, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var scope: CoroutineScope = CoroutineScope(SupervisorJob()), +) : DisplayInboxSettingsPreferenceManager { + + private val configState: MutableStateFlow = MutableStateFlow(value = loadConfig()) + private val mutex = Mutex() + + override fun getConfig(): DisplayInboxSettings = configState.value + + override fun getConfigFlow(): Flow = configState + + override fun save(config: DisplayInboxSettings) { + logger.debug(TAG) { "save() called with: config = $config" } + writeConfig(config) + configState.update { config } + } + + private fun loadConfig(): DisplayInboxSettings = DisplayInboxSettings( + isShowUnifiedInbox = storage.getBoolean( + KEY_SHOW_UNIFIED_INBOX, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_UNIFIED_INBOX, + ), + isShowComposeButtonOnMessageList = storage.getBoolean( + KEY_SHOW_COMPOSE_BUTTON_ON_MESSAGE_LIST, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_COMPOSE_BUTTON_ON_MESSAGE_LIST, + ), + isThreadedViewEnabled = storage.getBoolean( + KEY_THREAD_VIEW_ENABLED, + DISPLAY_SETTINGS_DEFAULT_IS_THREAD_VIEW_ENABLED, + ), + isShowStarredCount = storage.getBoolean( + KEY_SHOW_STAR_COUNT, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_STAR_COUNT, + ), + isShowMessageListStars = storage.getBoolean( + KEY_SHOW_MESSAGE_LIST_STARS, + DISPLAY_SETTINGS_DEFAULT_IS_SHOW_MESSAGE_LIST_STAR, + ), + isMessageListSenderAboveSubject = storage.getBoolean( + KEY_MESSAGE_LIST_SENDER_ABOVE_SUBJECT, + DISPLAY_SETTINGS_DEFAULT_IS_MESSAGE_LIST_SENDER_ABOVE_SUBJECT, + ), + ) + + private fun writeConfig(config: DisplayInboxSettings) { + logger.debug(TAG) { "writeConfig() called with: config = $config" } + scope.launch(ioDispatcher) { + mutex.withLock { + storageEditor.putBoolean( + KEY_MESSAGE_LIST_SENDER_ABOVE_SUBJECT, + config.isMessageListSenderAboveSubject, + ) + storageEditor.putBoolean( + KEY_SHOW_MESSAGE_LIST_STARS, + config.isShowMessageListStars, + ) + storageEditor.putBoolean( + KEY_SHOW_COMPOSE_BUTTON_ON_MESSAGE_LIST, + config.isShowComposeButtonOnMessageList, + ) + storageEditor.putBoolean( + KEY_THREAD_VIEW_ENABLED, + config.isThreadedViewEnabled, + ) + storageEditor.putBoolean(KEY_SHOW_UNIFIED_INBOX, config.isShowUnifiedInbox) + storageEditor.putBoolean(KEY_SHOW_STAR_COUNT, config.isShowStarredCount) + storageEditor.commit().also { commited -> + logger.verbose(TAG) { "writeConfig: storageEditor.commit() resulted in: $commited" } + } + } + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/network/DefaultNetworkSettingsPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/network/DefaultNetworkSettingsPreferenceManager.kt new file mode 100644 index 0000000..40984b1 --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/network/DefaultNetworkSettingsPreferenceManager.kt @@ -0,0 +1,55 @@ +package net.thunderbird.core.preference.network + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor +import net.thunderbird.core.preference.storage.getEnumOrDefault +import net.thunderbird.core.preference.storage.putEnum + +private const val TAG = "DefaultNetworkSettingsPreferenceManager" + +class DefaultNetworkSettingsPreferenceManager( + private val logger: Logger, + private val storage: Storage, + private val storageEditor: StorageEditor, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var scope: CoroutineScope = CoroutineScope(SupervisorJob()), +) : NetworkSettingsPreferenceManager { + private val configState: MutableStateFlow = MutableStateFlow(value = loadConfig()) + private val mutex = Mutex() + + override fun getConfig(): NetworkSettings = configState.value + override fun getConfigFlow(): Flow = configState + + override fun save(config: NetworkSettings) { + logger.debug(TAG) { "save() called with: config = $config" } + writeConfig(config) + configState.update { config } + } + + private fun loadConfig(): NetworkSettings = NetworkSettings( + backgroundOps = storage.getEnumOrDefault(KEY_BG_OPS, NETWORK_SETTINGS_DEFAULT_BACKGROUND_OPS), + ) + + private fun writeConfig(config: NetworkSettings) { + logger.debug(TAG) { "writeConfig() called with: config = $config" } + scope.launch(ioDispatcher) { + mutex.withLock { + storageEditor.putEnum(KEY_BG_OPS, config.backgroundOps) + storageEditor.commit().also { commited -> + logger.verbose(TAG) { "writeConfig: storageEditor.commit() resulted in: $commited" } + } + } + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt new file mode 100644 index 0000000..9549c3a --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/notification/DefaultNotificationPreferenceManager.kt @@ -0,0 +1,64 @@ +package net.thunderbird.core.preference.notification + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor + +private const val TAG = "DefaultNotificationPreferenceManager" + +class DefaultNotificationPreferenceManager( + private val logger: Logger, + storage: Storage, + private val storageEditor: StorageEditor, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var scope: CoroutineScope = CoroutineScope(SupervisorJob()), +) : NotificationPreferenceManager { + private val mutex = Mutex() + private val configState = MutableStateFlow( + value = NotificationPreference( + isQuietTimeEnabled = storage.getBoolean( + key = KEY_QUIET_TIME_ENABLED, + defValue = NOTIFICATION_PREFERENCE_DEFAULT_IS_QUIET_TIME_ENABLED, + ), + quietTimeStarts = storage.getStringOrDefault( + key = KEY_QUIET_TIME_STARTS, + defValue = NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_STARTS, + ), + quietTimeEnds = storage.getStringOrDefault( + key = KEY_QUIET_TIME_ENDS, + defValue = NOTIFICATION_PREFERENCE_DEFAULT_QUIET_TIME_END, + ), + ), + ) + + override fun getConfig(): NotificationPreference = configState.value + override fun getConfigFlow(): Flow = configState + + override fun save(config: NotificationPreference) { + logger.debug(TAG) { "writeConfig() called with: config = $config" } + scope.launch(ioDispatcher) { + mutex.withLock { + storageEditor.putString(KEY_QUIET_TIME_ENDS, config.quietTimeEnds) + storageEditor.putString(KEY_QUIET_TIME_STARTS, config.quietTimeStarts) + storageEditor.putBoolean( + KEY_QUIET_TIME_ENABLED, + config.isQuietTimeEnabled, + ) + storageEditor.commit().also { commited -> + logger.verbose(TAG) { "writeConfig: storageEditor.commit() resulted in: $commited" } + } + } + configState.update { config } + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/DefaultPrivacySettingsPreferenceManager.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/DefaultPrivacySettingsPreferenceManager.kt new file mode 100644 index 0000000..689adf1 --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/privacy/DefaultPrivacySettingsPreferenceManager.kt @@ -0,0 +1,61 @@ +package net.thunderbird.core.preference.privacy + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor + +private const val TAG = "DefaultPrivacySettingsPreferenceManager" + +class DefaultPrivacySettingsPreferenceManager( + private val logger: Logger, + private val storage: Storage, + private val storageEditor: StorageEditor, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var scope: CoroutineScope = CoroutineScope(SupervisorJob()), +) : PrivacySettingsPreferenceManager { + private val configState: MutableStateFlow = MutableStateFlow(value = loadConfig()) + private val mutex = Mutex() + + override fun getConfig(): PrivacySettings = configState.value + override fun getConfigFlow(): Flow = configState + + override fun save(config: PrivacySettings) { + logger.debug(TAG) { "save() called with: config = $config" } + writeConfig(config) + configState.update { config } + } + + private fun loadConfig(): PrivacySettings = PrivacySettings( + isHideTimeZone = storage.getBoolean( + key = KEY_HIDE_TIME_ZONE, + defValue = PRIVACY_SETTINGS_DEFAULT_HIDE_TIME_ZONE, + ), + isHideUserAgent = storage.getBoolean( + key = KEY_HIDE_USER_AGENT, + defValue = PRIVACY_SETTINGS_DEFAULT_HIDE_USER_AGENT, + ), + ) + + private fun writeConfig(config: PrivacySettings) { + logger.debug(TAG) { "writeConfig() called with: config = $config" } + scope.launch(ioDispatcher) { + mutex.withLock { + storageEditor.putBoolean(KEY_HIDE_TIME_ZONE, config.isHideTimeZone) + storageEditor.putBoolean(KEY_HIDE_USER_AGENT, config.isHideUserAgent) + storageEditor.commit().also { commited -> + logger.verbose(TAG) { "writeConfig: storageEditor.commit() resulted in: $commited" } + } + } + } + } +} diff --git a/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/storage/InMemoryStorage.kt b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/storage/InMemoryStorage.kt new file mode 100644 index 0000000..8176a1a --- /dev/null +++ b/core/preference/impl/src/commonMain/kotlin/net/thunderbird/core/preference/storage/InMemoryStorage.kt @@ -0,0 +1,56 @@ +package net.thunderbird.core.preference.storage + +import net.thunderbird.core.logging.Logger + +class InMemoryStorage( + private val values: Map, + private val logger: Logger, +) : Storage { + + override fun isEmpty(): Boolean = values.isEmpty() + + override fun contains(key: String): Boolean = values.contains(key) + + override fun getAll(): Map = values + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + values[key] + ?.toBoolean() + ?: defValue + + override fun getInt(key: String, defValue: Int): Int { + val value = values[key] ?: return defValue + return try { + value.toInt() + } catch (e: NumberFormatException) { + logger.error( + message = { "Could not parse int" }, + throwable = e, + ) + defValue + } + } + + override fun getLong(key: String, defValue: Long): Long { + val value = values[key] ?: return defValue + return try { + value.toLong() + } catch (e: NumberFormatException) { + logger.error( + message = { "Could not parse long" }, + throwable = e, + ) + defValue + } + } + + @Throws(NoSuchElementException::class) + override fun getString(key: String): String = + values.getValue(key) + + override fun getStringOrDefault(key: String, defValue: String): String = + getStringOrNull(key) ?: defValue + + override fun getStringOrNull(key: String): String? = + values[key] +} diff --git a/core/preference/impl/src/commonTest/kotlin/net/thunderbird/core/preference/DefaultPreferenceChangeBrokerTest.kt b/core/preference/impl/src/commonTest/kotlin/net/thunderbird/core/preference/DefaultPreferenceChangeBrokerTest.kt new file mode 100644 index 0000000..e75a156 --- /dev/null +++ b/core/preference/impl/src/commonTest/kotlin/net/thunderbird/core/preference/DefaultPreferenceChangeBrokerTest.kt @@ -0,0 +1,49 @@ +package net.thunderbird.core.preference + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class DefaultPreferenceChangeBrokerTest { + + @Test + fun `subscribe should add subscriber`() { + val subscriber = PreferenceChangeSubscriber { } + val subscribers = mutableSetOf() + val broker = DefaultPreferenceChangeBroker(subscribers) + + broker.subscribe(subscriber) + + assertThat(subscribers.size).isEqualTo(1) + assertThat(subscribers).contains(subscriber) + } + + @Test + fun `unsubscribe should remove subscriber`() { + val subscriber = PreferenceChangeSubscriber { } + val subscribers = mutableSetOf(subscriber) + val broker = DefaultPreferenceChangeBroker(subscribers) + + broker.unsubscribe(subscriber) + + assertThat(subscribers.size).isEqualTo(0) + assertThat(subscribers).doesNotContain(subscriber) + } + + @Test + fun `publish should notify subscribers`() { + var received = false + val subscriber = PreferenceChangeSubscriber { received = true } + var receivedOther = false + val otherSubscriber = PreferenceChangeSubscriber { receivedOther = true } + val subscribers = mutableSetOf(subscriber, otherSubscriber) + val broker = DefaultPreferenceChangeBroker(subscribers) + + broker.publish() + + assertThat(received).isEqualTo(true) + assertThat(receivedOther).isEqualTo(true) + } +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts new file mode 100644 index 0000000..bf86410 --- /dev/null +++ b/core/testing/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.core.testing" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlin.test.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.assertk) + implementation(libs.turbine) + } + } +} diff --git a/core/testing/src/commonMain/kotlin/assertk/assertions/ListExtensions.kt b/core/testing/src/commonMain/kotlin/assertk/assertions/ListExtensions.kt new file mode 100644 index 0000000..2ab42b2 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/assertk/assertions/ListExtensions.kt @@ -0,0 +1,13 @@ +package assertk.assertions + +import assertk.Assert +import assertk.assertions.support.expected +import assertk.assertions.support.show + +fun Assert>.containsNoDuplicates() = given { actual -> + val seen: MutableSet = mutableSetOf() + val duplicates = actual.filter { !seen.add(it) } + if (duplicates.isNotEmpty()) { + expected("to contain no duplicates but found: ${show(duplicates)}") + } +} diff --git a/core/testing/src/commonMain/kotlin/net/thunderbird/core/testing/TestClock.kt b/core/testing/src/commonMain/kotlin/net/thunderbird/core/testing/TestClock.kt new file mode 100644 index 0000000..6ddb19c --- /dev/null +++ b/core/testing/src/commonMain/kotlin/net/thunderbird/core/testing/TestClock.kt @@ -0,0 +1,21 @@ +package net.thunderbird.core.testing + +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +class TestClock( + private var currentTime: Instant = Clock.System.now(), +) : Clock { + override fun now(): Instant = currentTime + + fun changeTimeTo(time: Instant) { + currentTime = time + } + + fun advanceTimeBy(duration: Duration) { + currentTime += duration + } +} diff --git a/core/testing/src/commonMain/kotlin/net/thunderbird/core/testing/coroutines/MainDispatcherRule.kt b/core/testing/src/commonMain/kotlin/net/thunderbird/core/testing/coroutines/MainDispatcherRule.kt new file mode 100644 index 0000000..b3db36c --- /dev/null +++ b/core/testing/src/commonMain/kotlin/net/thunderbird/core/testing/coroutines/MainDispatcherRule.kt @@ -0,0 +1,34 @@ +package net.thunderbird.core.testing.coroutines + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * A JUnit rule that swaps the [kotlinx.coroutines.Dispatchers.Main] dispatcher with a [kotlinx.coroutines.test.TestDispatcher] for the duration of the test. + * + * Use this rule to ensure that coroutines running on the main dispatcher are executed in a controlled manner during tests. + * + * This uses [kotlinx.coroutines.test.UnconfinedTestDispatcher] by default, but you can provide a different [kotlinx.coroutines.test.TestDispatcher] if needed. + * Especially when testing view models use the [kotlinx.coroutines.test.StandardTestDispatcher], this allows you to + * control the execution of coroutines in a more predictable way. + * + * @param testDispatcher The [kotlinx.coroutines.test.TestDispatcher] to use as the main dispatcher during tests. Defaults to [kotlinx.coroutines.test.UnconfinedTestDispatcher]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/core/testing/src/commonTest/kotlin/assertk/assertions/ListExtensionsKtTest.kt b/core/testing/src/commonTest/kotlin/assertk/assertions/ListExtensionsKtTest.kt new file mode 100644 index 0000000..e362613 --- /dev/null +++ b/core/testing/src/commonTest/kotlin/assertk/assertions/ListExtensionsKtTest.kt @@ -0,0 +1,24 @@ +package assertk.assertions + +import assertk.assertFailure +import assertk.assertThat +import kotlin.test.Test + +class ListExtensionsKtTest { + + @Test + fun `containsNoDuplicates() should succeed with no duplicates`() { + val list = listOf("a", "b", "c") + + assertThat(list).containsNoDuplicates() + } + + @Test + fun `containsNoDuplicates() should fail with duplicates`() { + val list = listOf("a", "b", "c", "a", "a") + + assertFailure { + assertThat(list).containsNoDuplicates() + }.hasMessage("""expected to contain no duplicates but found: <["a", "a"]>""") + } +} diff --git a/core/testing/src/commonTest/kotlin/net/thunderbird/core/testing/TestClockTest.kt b/core/testing/src/commonTest/kotlin/net/thunderbird/core/testing/TestClockTest.kt new file mode 100644 index 0000000..651ea47 --- /dev/null +++ b/core/testing/src/commonTest/kotlin/net/thunderbird/core/testing/TestClockTest.kt @@ -0,0 +1,41 @@ +package net.thunderbird.core.testing + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +internal class TestClockTest { + + @Test + fun `should return the current time`() { + val testClock = TestClock(Instant.DISTANT_PAST) + + val currentTime = testClock.now() + + assertThat(currentTime).isEqualTo(Instant.DISTANT_PAST) + } + + @Test + fun `should return the changed time`() { + val testClock = TestClock(Instant.DISTANT_PAST) + testClock.changeTimeTo(Instant.DISTANT_FUTURE) + + val currentTime = testClock.now() + + assertThat(currentTime).isEqualTo(Instant.DISTANT_FUTURE) + } + + @Test + fun `should advance time by duration`() { + val testClock = TestClock(Instant.DISTANT_PAST) + testClock.advanceTimeBy(1L.milliseconds) + + val currentTime = testClock.now() + + assertThat(currentTime).isEqualTo(Instant.DISTANT_PAST + 1L.milliseconds) + } +} diff --git a/core/ui/account/build.gradle.kts b/core/ui/account/build.gradle.kts new file mode 100644 index 0000000..c5bd05d --- /dev/null +++ b/core/ui/account/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.core.ui.account" +} + +dependencies { + implementation(projects.core.ui.legacy.designsystem) + + implementation(libs.glide) +} diff --git a/core/ui/account/src/main/java/net/thunderbird/core/ui/account/AccountFallbackImageProvider.kt b/core/ui/account/src/main/java/net/thunderbird/core/ui/account/AccountFallbackImageProvider.kt new file mode 100644 index 0000000..0001b6f --- /dev/null +++ b/core/ui/account/src/main/java/net/thunderbird/core/ui/account/AccountFallbackImageProvider.kt @@ -0,0 +1,35 @@ +package net.thunderbird.core.ui.account + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +import android.util.TypedValue +import androidx.core.content.ContextCompat +import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons + +private const val PADDING_DP = 8f + +/** + * Provides a [Drawable] for the account using the account's color as background color. + */ +class AccountFallbackImageProvider(private val context: Context) { + fun getDrawable(color: Int): Drawable { + val drawable = ContextCompat.getDrawable(context, Icons.Outlined.Person) + ?: error("Error loading drawable") + + val inset = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + PADDING_DP, + context.resources.displayMetrics, + ).toInt() + + return LayerDrawable( + arrayOf( + ColorDrawable(color), + InsetDrawable(drawable, inset), + ), + ) + } +} diff --git a/core/ui/account/src/main/java/net/thunderbird/core/ui/account/AccountImage.kt b/core/ui/account/src/main/java/net/thunderbird/core/ui/account/AccountImage.kt new file mode 100644 index 0000000..39c6d7e --- /dev/null +++ b/core/ui/account/src/main/java/net/thunderbird/core/ui/account/AccountImage.kt @@ -0,0 +1,10 @@ +package net.thunderbird.core.ui.account + +import com.bumptech.glide.load.Key +import java.security.MessageDigest + +data class AccountImage(val email: String, val color: Int) : Key { + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(toString().toByteArray(Key.CHARSET)) + } +} diff --git a/core/ui/compose/common/README.md b/core/ui/compose/common/README.md new file mode 100644 index 0000000..0500065 --- /dev/null +++ b/core/ui/compose/common/README.md @@ -0,0 +1,3 @@ +## Core - UI - Compose - Common + +This module contains common code for the compose UI. diff --git a/core/ui/compose/common/build.gradle.kts b/core/ui/compose/common/build.gradle.kts new file mode 100644 index 0000000..c64b049 --- /dev/null +++ b/core/ui/compose/common/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.core.ui.compose.common" + resourcePrefix = "core_ui_common_" +} + +dependencies { + testImplementation(projects.core.ui.compose.testing) + testImplementation(projects.core.ui.compose.designsystem) +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/annotation/PreviewDevices.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/annotation/PreviewDevices.kt new file mode 100644 index 0000000..5cf9511 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/annotation/PreviewDevices.kt @@ -0,0 +1,16 @@ +package app.k9mail.core.ui.compose.common.annotation + +import androidx.compose.ui.tooling.preview.Preview + +/** + * A marker annotation for device previews. + * + * It's used to provide previews for a set of different devices and form factors. + */ +@Preview(name = "Small phone", device = "spec:width=360dp,height=640dp,dpi=160") +@Preview(name = "Phone", device = "spec:width=411dp,height=891dp,dpi=420") +@Preview(name = "Phone landscape", device = "spec:width=891dp,height=411dp,dpi=420") +@Preview(name = "Foldable", device = "spec:width=673dp,height=841dp,dpi=420") +@Preview(name = "Tablet", device = "spec:width=1280dp,height=800dp,dpi=240") +@Preview(name = "Desktop", device = "spec:width=1920dp,height=1080dp,dpi=160") +annotation class PreviewDevices diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/annotation/PreviewDevicesWithBackground.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/annotation/PreviewDevicesWithBackground.kt new file mode 100644 index 0000000..6fbb35e --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/annotation/PreviewDevicesWithBackground.kt @@ -0,0 +1,16 @@ +package app.k9mail.core.ui.compose.common.annotation + +import androidx.compose.ui.tooling.preview.Preview + +/** + * A marker annotation for device previews with background. + * + * It's used to provide previews for a set of different devices and form factors. + */ +@Preview(name = "Small phone", device = "spec:width=360dp,height=640dp,dpi=160", showBackground = true) +@Preview(name = "Phone", device = "spec:width=411dp,height=891dp,dpi=420", showBackground = true) +@Preview(name = "Phone landscape", device = "spec:width=891dp,height=411dp,dpi=420", showBackground = true) +@Preview(name = "Foldable", device = "spec:width=673dp,height=841dp,dpi=420", showBackground = true) +@Preview(name = "Tablet", device = "spec:width=1280dp,height=800dp,dpi=240", showBackground = true) +@Preview(name = "Desktop", device = "spec:width=1920dp,height=1080dp,dpi=160", showBackground = true) +annotation class PreviewDevicesWithBackground diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/baseline/BaselineModifier.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/baseline/BaselineModifier.kt new file mode 100644 index 0000000..c6a1cd8 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/baseline/BaselineModifier.kt @@ -0,0 +1,31 @@ +package app.k9mail.core.ui.compose.common.baseline + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp + +/** + * Adds a baseline to a Composable that typically doesn't have one. + * + * This can be used to align e.g. an icon to the baseline of some text next to it. See e.g. [RowScope.alignByBaseline]. + * + * @param baseline The number of device-independent pixels (dp) the baseline is from the top of the Composable. + */ +fun Modifier.withBaseline(baseline: Dp) = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val baselineInPx = baseline.roundToPx() + + layout( + width = placeable.width, + height = placeable.height, + alignmentLines = mapOf( + FirstBaseline to baselineInPx, + LastBaseline to baselineInPx, + ), + ) { + placeable.placeRelative(x = 0, y = 0) + } +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/image/ImageWithBaseline.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/image/ImageWithBaseline.kt new file mode 100644 index 0000000..123ed6f --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/image/ImageWithBaseline.kt @@ -0,0 +1,11 @@ +package app.k9mail.core.ui.compose.common.image + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp + +@Immutable +data class ImageWithBaseline( + val image: ImageVector, + val baseline: Dp, +) diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/image/ImageWithOverlayCoordinate.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/image/ImageWithOverlayCoordinate.kt new file mode 100644 index 0000000..e64bf2b --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/image/ImageWithOverlayCoordinate.kt @@ -0,0 +1,18 @@ +package app.k9mail.core.ui.compose.common.image + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp + +/** + * An image with a coordinate to draw a (smaller) overlay icon on top of it. + * + * Example: An icon representing an Android permission with an overlay icon to indicate whether the permission has been + * granted. + */ +@Immutable +data class ImageWithOverlayCoordinate( + val image: ImageVector, + val overlayOffsetX: Dp, + val overlayOffsetY: Dp, +) diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/koin/KoinPreview.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/koin/KoinPreview.kt new file mode 100644 index 0000000..7519873 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/koin/KoinPreview.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.common.koin + +import androidx.compose.runtime.Composable +import org.koin.compose.KoinContext +import org.koin.core.Koin +import org.koin.dsl.ModuleDeclaration +import org.koin.dsl.module + +/** + * Helper to make Compose previews work when some dependencies are injected via Koin. + */ +fun koinPreview(moduleDeclaration: ModuleDeclaration): KoinPreview { + val koin = Koin().apply { + loadModules(listOf(module(moduleDeclaration = moduleDeclaration))) + } + + return KoinPreview(koin) +} + +class KoinPreview internal constructor(private val koin: Koin) { + @Composable + infix fun WithContent(content: @Composable () -> Unit) { + KoinContext(context = koin) { + content() + } + } +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModel.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModel.kt new file mode 100644 index 0000000..89efa47 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModel.kt @@ -0,0 +1,75 @@ +package app.k9mail.core.ui.compose.common.mvi + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * An abstract base ViewModel that implements [UnidirectionalViewModel] and provides basic + * functionality for managing state and effects. + * + * @param STATE The type that represents the state of the ViewModel. For example, the UI state of a screen can be + * represented as a state. + * @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. For + * example, a button click can be represented as an event. + * @param EFFECT The type that represents side-effects that can occur in response to the state changes. For example, + * a navigation event can be represented as an effect. + * + * @param initialState The initial [STATE] of the ViewModel. + */ +abstract class BaseViewModel( + initialState: STATE, +) : ViewModel(), + UnidirectionalViewModel { + + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + override val effect: SharedFlow = _effect.asSharedFlow() + + private val handledOneTimeEvents = mutableSetOf() + + /** + * Updates the [STATE] of the ViewModel. + * + * @param update A function that takes the current [STATE] and produces a new [STATE]. + */ + protected fun updateState(update: (STATE) -> STATE) { + _state.update(update) + } + + /** + * Emits a side effect. + * + * @param effect The [EFFECT] to emit. + */ + protected fun emitEffect(effect: EFFECT) { + viewModelScope.launch { + _effect.emit(effect) + } + } + + /** + * Ensures that one-time events are only handled once. + * + * When you can't ensure that an event is only sent once, but you want the event to only be handled once, call this + * method. It will ensure [block] is only executed the first time this function is called. Subsequent calls with an + * [event] argument equal to that of a previous invocation will not execute [block]. + * + * Multiple one-time events are supported. + */ + protected fun handleOneTimeEvent(event: EVENT, block: () -> Unit) { + if (event !in handledOneTimeEvents) { + handledOneTimeEvents.add(event) + block() + } + } +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt new file mode 100644 index 0000000..73e8093 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt @@ -0,0 +1,147 @@ +package app.k9mail.core.ui.compose.common.mvi + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface for a unidirectional view model with side-effects ([EFFECT]). It has a [STATE] and can handle [EVENT]'s. + * + * @param STATE The type that represents the state of the ViewModel. For example, the UI state of a screen can be + * represented as a state. + * @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. For + * example, a button click can be represented as an event. + * @param EFFECT The type that represents side-effects that can occur in response to the state changes. For example, + * a navigation event can be represented as an effect. + */ +interface UnidirectionalViewModel { + /** + * The current [STATE] of the view model. + */ + val state: StateFlow + + /** + * The side-effects ([EFFECT]) produced by the view model. + */ + val effect: SharedFlow + + /** + * Handles an [EVENT] and updates the [STATE] of the view model. + * + * @param event The [EVENT] to handle. + */ + fun event(event: EVENT) +} + +/** + * Data class representing a state and a dispatch function, used for destructuring in [observe]. + */ +data class StateDispatch( + val state: State, + val dispatch: (EVENT) -> Unit, +) + +/** + * Composable function that observes a UnidirectionalViewModel and handles its side effects. + * + * Example usage: + * ``` + * @Composable + * fun MyScreen( + * onNavigateNext: () -> Unit, + * onNavigateBack: () -> Unit, + * viewModel: MyUnidirectionalViewModel, + * ) { + * val (state, dispatch) = viewModel.observe { effect -> + * when (effect) { + * MyEffect.OnBackPressed -> onNavigateBack() + * MyEffect.OnNextPressed -> onNavigateNext() + * } + * } + * + * MyContent( + * onNextClick = { + * dispatch(MyEvent.OnNext) + * }, + * onBackClick = { + * dispatch(MyEvent.OnBack) + * }, + * state = state.value, + * ) + * } + * ``` + * + * @param STATE The type that represents the state of the ViewModel. + * @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. + * @param EFFECT The type that represents side-effects that can occur in response to the state changes. + * + * @param handleEffect A function to handle side effects ([EFFECT]). + * + * @return A [StateDispatch] containing the state and a dispatch function. + */ +@Composable +inline fun UnidirectionalViewModel.observe( + crossinline handleEffect: (EFFECT) -> Unit, +): StateDispatch { + val collectedState = state.collectAsStateWithLifecycle() + + val dispatch: (EVENT) -> Unit = { event(it) } + + LaunchedEffect(key1 = effect) { + effect.collect { + handleEffect(it) + } + } + + return StateDispatch( + state = collectedState, + dispatch = dispatch, + ) +} + +/** + * Composable function that observes a UnidirectionalViewModel without handling side effects. + * + * Example usage: + * ``` + * @Composable + * fun MyScreen( + * viewModel: MyUnidirectionalViewModel, + * onNavigateNext: () -> Unit, + * onNavigateBack: () -> Unit, + * ) { + * val (state, dispatch) = viewModel.observeWithoutEffect() + * + * MyContent( + * onNextClick = { + * dispatch(MyEvent.OnNext) + * }, + * onBackClick = { + * dispatch(MyEvent.OnBack) + * }, + * state = state.value, + * ) + * } + * ``` + * + * @param STATE The type that represents the state of the ViewModel. + * @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. + * + * @return A [StateDispatch] containing the state and a dispatch function. + */ +@Suppress("MaxLineLength") +@Composable +inline fun UnidirectionalViewModel.observeWithoutEffect( + // no effect handler +): StateDispatch { + val collectedState = state.collectAsStateWithLifecycle() + val dispatch: (EVENT) -> Unit = { event(it) } + + return StateDispatch( + state = collectedState, + dispatch = dispatch, + ) +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/padding/CalculateResponsivePadding.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/padding/CalculateResponsivePadding.kt new file mode 100644 index 0000000..9a1c3df --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/padding/CalculateResponsivePadding.kt @@ -0,0 +1,20 @@ +package app.k9mail.core.ui.compose.common.padding + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.common.window.WindowSizeClass +import app.k9mail.core.ui.compose.common.window.getWindowSizeInfo + +@Composable +fun calculateResponsiveWidthPadding(): PaddingValues { + val windowSizeInfo = getWindowSizeInfo() + val horizontalPadding = when (windowSizeInfo.screenWidthSizeClass) { + WindowSizeClass.Compact -> 0.dp + + WindowSizeClass.Medium -> (windowSizeInfo.screenWidth - WindowSizeClass.COMPACT_MAX_WIDTH.dp) / 2 + + WindowSizeClass.Expanded -> (windowSizeInfo.screenWidth - WindowSizeClass.MEDIUM_MAX_WIDTH.dp) / 2 + } + return PaddingValues(horizontal = horizontalPadding) +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/resources/StringResources.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/resources/StringResources.kt new file mode 100644 index 0000000..96a2bc4 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/resources/StringResources.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.ui.compose.common.resources + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString + +private const val PLACE_HOLDER = "{placeHolder}" + +/** + * Loads a string resource with a single string parameter that will be replaced with the given [AnnotatedString]. + */ +@Composable +@ReadOnlyComposable +fun annotatedStringResource(@StringRes id: Int, argument: AnnotatedString): AnnotatedString { + val stringWithPlaceHolder = stringResource(id, PLACE_HOLDER) + return buildAnnotatedString { + // In Android Studio previews loading string resources with formatting is not working + if (LocalInspectionMode.current) { + append(stringWithPlaceHolder) + return@buildAnnotatedString + } + + val placeHolderStartIndex = stringWithPlaceHolder.indexOf(PLACE_HOLDER) + require(placeHolderStartIndex != -1) + + append(text = stringWithPlaceHolder, start = 0, end = placeHolderStartIndex) + append(argument) + append( + text = stringWithPlaceHolder, + start = placeHolderStartIndex + PLACE_HOLDER.length, + end = stringWithPlaceHolder.length, + ) + } +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/text/AnnotatedStrings.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/text/AnnotatedStrings.kt new file mode 100644 index 0000000..68bca34 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/text/AnnotatedStrings.kt @@ -0,0 +1,18 @@ +package app.k9mail.core.ui.compose.common.text + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle + +/** + * Converts a [String] into an [AnnotatedString] with all of the text being bold. + */ +fun String.bold(): AnnotatedString { + return buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(this@bold) + } + } +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/visibility/VisibilityModifiers.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/visibility/VisibilityModifiers.kt new file mode 100644 index 0000000..64300cc --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/visibility/VisibilityModifiers.kt @@ -0,0 +1,16 @@ +package app.k9mail.core.ui.compose.common.visibility + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.semantics.clearAndSetSemantics + +/** + * Sets a composable to be fully transparent when it should be hidden (but still present for layout purposes). + */ +fun Modifier.hide(hide: Boolean): Modifier { + return if (hide) { + alpha(0f).clearAndSetSemantics {} + } else { + alpha(1f) + } +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClass.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClass.kt new file mode 100644 index 0000000..7ef49cf --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClass.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.ui.compose.common.window + +/** + * WindowSizeClass as defined by supporting different screen sizes. + * + * See: https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes#window_size_classes + */ +enum class WindowSizeClass { + Compact, + Medium, + Expanded, + ; + + companion object { + const val COMPACT_MAX_WIDTH = 600 + const val COMPACT_MAX_HEIGHT = 480 + + const val MEDIUM_MAX_WIDTH = 840 + + const val MEDIUM_MAX_HEIGHT = 900 + + fun fromWidth(width: Int): WindowSizeClass { + return when { + width < COMPACT_MAX_WIDTH -> Compact + width < MEDIUM_MAX_WIDTH -> Medium + else -> Expanded + } + } + + fun fromHeight(height: Int): WindowSizeClass { + return when { + height < COMPACT_MAX_HEIGHT -> Compact + height < MEDIUM_MAX_HEIGHT -> Medium + else -> Expanded + } + } + } +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeInfo.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeInfo.kt new file mode 100644 index 0000000..6fd7d8c --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeInfo.kt @@ -0,0 +1,30 @@ +package app.k9mail.core.ui.compose.common.window + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Returns the current window size info based on current Configuration. + */ +@Composable +fun getWindowSizeInfo(): WindowSizeInfo { + val configuration = LocalConfiguration.current + + return WindowSizeInfo( + screenWidthSizeClass = WindowSizeClass.fromWidth(configuration.screenWidthDp), + screenHeightSizeClass = WindowSizeClass.fromHeight(configuration.screenHeightDp), + screenWidth = configuration.screenWidthDp.dp, + screenHeight = configuration.screenHeightDp.dp, + ) +} + +@Immutable +data class WindowSizeInfo( + val screenWidthSizeClass: WindowSizeClass, + val screenHeightSizeClass: WindowSizeClass, + val screenWidth: Dp, + val screenHeight: Dp, +) diff --git a/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/date/DateTimeConfiguration.kt b/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/date/DateTimeConfiguration.kt new file mode 100644 index 0000000..c638b26 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/date/DateTimeConfiguration.kt @@ -0,0 +1,28 @@ +package net.thunderbird.core.ui.compose.common.date + +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.datetime.format.DayOfWeekNames +import kotlinx.datetime.format.MonthNames + +/** + * Configuration for date and time formatting. + * + * @property monthNames The names of the months. + * @property dayOfWeekNames The names of the days of the week. + */ +data class DateTimeConfiguration( + val monthNames: MonthNames, + val dayOfWeekNames: DayOfWeekNames, +) + +/** + * CompositionLocal that provides the default [DateTimeConfiguration] for date and time formatting. + * This configuration uses abbreviated English month and day of the week names by default. + * It can be overridden at a lower level in the composition tree to customize date and time formatting. + */ +val LocalDateTimeConfiguration = staticCompositionLocalOf { + DateTimeConfiguration( + monthNames = MonthNames.ENGLISH_ABBREVIATED, + dayOfWeekNames = DayOfWeekNames.ENGLISH_ABBREVIATED, + ) +} diff --git a/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/modifier/ModifierExtensions.kt b/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/modifier/ModifierExtensions.kt new file mode 100644 index 0000000..1522458 --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/modifier/ModifierExtensions.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.ui.compose.common.modifier + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +/** + * Adds a test tag to the element with testTagsAsResourceId set to true. + * This allows the element to be found by its test tag during UI testing. + * + * @param tag The test tag to be assigned to the element. + * @return A [Modifier] with the test tag applied. + */ +fun Modifier.testTagAsResourceId(tag: String): Modifier = this + .semantics { testTagsAsResourceId = true } + .testTag(tag) diff --git a/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/koin/KoinPreviewTest.kt b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/koin/KoinPreviewTest.kt new file mode 100644 index 0000000..01d3ccc --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/koin/KoinPreviewTest.kt @@ -0,0 +1,33 @@ +package app.k9mail.core.ui.compose.common.koin + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.onNodeWithText +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import kotlin.test.Test +import org.koin.compose.koinInject + +class KoinPreviewTest : ComposeTest() { + @Test + fun `koinPreview should make dependencies available in WithContent block`() = runComposeTest { + val injectString = "Test" + + setContentWithTheme { + koinPreview { + factory { injectString } + } WithContent { + TestComposable() + } + } + + onNodeWithText(injectString).assertExists() + } +} + +@Composable +private fun TestComposable( + injected: String = koinInject(), +) { + TextBodyLarge(text = injected) +} diff --git a/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModelTest.kt b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModelTest.kt new file mode 100644 index 0000000..366658f --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModelTest.kt @@ -0,0 +1,109 @@ +package app.k9mail.core.ui.compose.common.mvi + +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class BaseViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should emit initial state`() = runTest { + val viewModel = TestBaseViewModel() + assertThat(viewModel.state.value).isEqualTo("Initial state") + } + + @Test + fun `should update state`() = runTest { + val viewModel = TestBaseViewModel() + + viewModel.event("Test event") + + assertThat(viewModel.state.value).isEqualTo("Test event") + + viewModel.event("Another test event") + + assertThat(viewModel.state.value).isEqualTo("Another test event") + } + + @Test + fun `should emit effects`() = runTest { + val viewModel = TestBaseViewModel() + + viewModel.effect.test { + viewModel.event("Test effect") + + assertThat(awaitItem()).isEqualTo("Test effect") + + viewModel.event("Another test effect") + + assertThat(awaitItem()).isEqualTo("Another test effect") + } + } + + @Test + fun `handleOneTimeEvent() should execute block`() = runTest { + val viewModel = TestBaseViewModel() + var eventHandled = false + + viewModel.callHandleOneTimeEvent(event = "event") { + eventHandled = true + } + + assertThat(eventHandled).isTrue() + } + + @Test + fun `handleOneTimeEvent() should execute block only once`() = runTest { + val viewModel = TestBaseViewModel() + var eventHandledCount = 0 + + repeat(2) { + viewModel.callHandleOneTimeEvent(event = "event") { + eventHandledCount++ + } + } + + assertThat(eventHandledCount).isEqualTo(1) + } + + @Test + fun `handleOneTimeEvent() should support multiple one-time events`() = runTest { + val viewModel = TestBaseViewModel() + var eventOneHandled = false + var eventTwoHandled = false + + viewModel.callHandleOneTimeEvent(event = "eventOne") { + eventOneHandled = true + } + + assertThat(eventOneHandled).isTrue() + assertThat(eventTwoHandled).isFalse() + + viewModel.callHandleOneTimeEvent(event = "eventTwo") { + eventTwoHandled = true + } + + assertThat(eventOneHandled).isTrue() + assertThat(eventTwoHandled).isTrue() + } + + private class TestBaseViewModel : BaseViewModel("Initial state") { + override fun event(event: String) { + updateState { event } + emitEffect(event) + } + + fun callHandleOneTimeEvent(event: String, block: () -> Unit) { + handleOneTimeEvent(event, block) + } + } +} diff --git a/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModelKtTest.kt b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModelKtTest.kt new file mode 100644 index 0000000..523a3bd --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModelKtTest.kt @@ -0,0 +1,78 @@ +package app.k9mail.core.ui.compose.common.mvi + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContent +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class UnidirectionalViewModelKtTest : ComposeTest() { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `observe should emit state changes, allow event dispatch and expose effects`() = runTest { + val viewModel = TestViewModel() + val effects = mutableListOf() + lateinit var stateDispatch: StateDispatch + + setContent { + stateDispatch = viewModel.observe { effect -> + effects.add(effect) + } + } + + val (state, dispatch) = stateDispatch + + // Initial state + assertThat(state.value.data).isEqualTo("TestState: Initial") + + // Dispatch an event + dispatch(TestEvent("Event 1")) + + assertThat(state.value.data).isEqualTo("TestState: Event 1") + assertThat(effects.last().result).isEqualTo("TestEffect: Event 1") + + // Dispatch another event + dispatch(TestEvent("Event 2")) + + assertThat(state.value.data).isEqualTo("TestState: Event 2") + assertThat(effects.last().result).isEqualTo("TestEffect: Event 2") + } + + private data class TestState(val data: String) + private data class TestEvent(val action: String) + private data class TestEffect(val result: String) + + private class TestViewModel( + initialState: TestState = TestState("TestState: Initial"), + ) : ViewModel(), UnidirectionalViewModel { + + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + override val effect: SharedFlow = _effect.asSharedFlow() + + override fun event(event: TestEvent) { + _state.update { it.copy(data = "TestState: ${event.action}") } + viewModelScope.launch { + _effect.emit(TestEffect(result = "TestEffect: ${event.action}")) + } + } + } +} diff --git a/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/resources/StringResourcesTest.kt b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/resources/StringResourcesTest.kt new file mode 100644 index 0000000..d7c2a03 --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/resources/StringResourcesTest.kt @@ -0,0 +1,28 @@ +package app.k9mail.core.ui.compose.common.resources + +import androidx.compose.ui.text.buildAnnotatedString +import app.k9mail.core.ui.compose.common.test.R +import app.k9mail.core.ui.compose.common.text.bold +import app.k9mail.core.ui.compose.testing.ComposeTest +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class StringResourcesTest : ComposeTest() { + @Test + fun `annotatedStringResource() with bold text`() = runComposeTest { + val argument = "text".bold() + + setContent { + val result = annotatedStringResource(id = R.string.StringResourcesTest, argument) + + assertThat(result).isEqualTo( + buildAnnotatedString { + append("prefix ") + append(argument) + append(" suffix") + }, + ) + } + } +} diff --git a/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/text/AnnotatedStringsTest.kt b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/text/AnnotatedStringsTest.kt new file mode 100644 index 0000000..00ec6f9 --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/text/AnnotatedStringsTest.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.common.text + +import androidx.compose.ui.text.AnnotatedString.Range +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class AnnotatedStringsTest { + @Test + fun bold() { + val input = "text" + + val result = input.bold() + + assertThat(result.toString()).isEqualTo(input) + assertThat(result.spanStyles).containsExactly( + Range( + item = SpanStyle(fontWeight = FontWeight.Bold), + start = 0, + end = input.length, + ), + ) + } +} diff --git a/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClassTest.kt b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClassTest.kt new file mode 100644 index 0000000..ea67a76 --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/window/WindowSizeClassTest.kt @@ -0,0 +1,80 @@ +package app.k9mail.core.ui.compose.common.window + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class WindowSizeClassTest { + + @Test + fun `should return compact when width is less than 600`() { + val width = 599 + + val windowSizeClass = WindowSizeClass.fromWidth(width) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Compact) + } + + @Test + fun `should return medium when width is 600`() { + val width = 600 + + val windowSizeClass = WindowSizeClass.fromWidth(width) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium) + } + + @Test + fun `should return medium when width is less than 840`() { + val width = 839 + + val windowSizeClass = WindowSizeClass.fromWidth(width) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium) + } + + @Test + fun `should return expanded when width is 840`() { + val width = 840 + + val windowSizeClass = WindowSizeClass.fromWidth(width) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Expanded) + } + + @Test + fun `should return compact when height is less than 480`() { + val height = 479 + + val windowSizeClass = WindowSizeClass.fromHeight(height) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Compact) + } + + @Test + fun `should return medium when height is 480`() { + val height = 480 + + val windowSizeClass = WindowSizeClass.fromHeight(height) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium) + } + + @Test + fun `should return medium when height is less than 900`() { + val height = 899 + + val windowSizeClass = WindowSizeClass.fromHeight(height) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium) + } + + @Test + fun `should return expanded when height is 900`() { + val height = 900 + + val windowSizeClass = WindowSizeClass.fromHeight(height) + + assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Expanded) + } +} diff --git a/core/ui/compose/common/src/test/res/values/test_strings.xml b/core/ui/compose/common/src/test/res/values/test_strings.xml new file mode 100644 index 0000000..c7cdef2 --- /dev/null +++ b/core/ui/compose/common/src/test/res/values/test_strings.xml @@ -0,0 +1,4 @@ + + + prefix %s suffix + diff --git a/core/ui/compose/designsystem/README.md b/core/ui/compose/designsystem/README.md new file mode 100644 index 0000000..9596293 --- /dev/null +++ b/core/ui/compose/designsystem/README.md @@ -0,0 +1,39 @@ +## Core - UI - Compose - Design system + +Uses [`:core:ui:compose:theme`](../theme/README.md) + +## Background + +[Jetpack Compose](https://developer.android.com/jetpack/compose) is a declarative UI toolkit for Android that provides a modern and efficient way to build UIs for Android apps. In this context, design systems and atomic design can help designers and developers create more scalable, maintainable, and reusable UIs. + +### Design system + +A design system is a collection of guidelines, principles, and tools that help teams create consistent and cohesive visual designs and user experiences. +It typically includes a set of reusable components, such as icons, typography, color palettes, and layouts, that can be combined and customized to create new designs. + +The design system also provides documentation and resources for designers and developers to ensure that the designs are implemented consistently and efficiently across all platforms and devices. +The goal of a design system is to streamline the design process, improve design quality, and maintain brand consistency. + +An example is Google's [Material Design](https://m3.material.io/) that is used to develop cohesive apps. + +### Atomic Design + +![Atomic design](assets/images/atomic_design.svg) + +Atomic design is a methodology for creating user interfaces (UI) in a design system by breaking them down into smaller, reusable components. +These components are classified into five categories based on their level of abstraction: **atoms**, **molecules**, **organisms**, **templates**, and **pages**. + +- **Atoms** are the smallest building blocks, such as buttons, labels, and input fields and could be combined to create more complex components. +- **Molecules** are groups of atoms that work together, like search bars, forms or menus +- **Organisms** are more complex components that combine molecules and atoms, such as headers or cards. +- **Templates** are pages with placeholders for components +- **Pages** are the final UI + +By using atomic design, designers and developers can create more consistent and reusable UIs. +This can save time and improve the overall quality, as well as facilitate collaboration between team members. + +## Acknowledgement + +- [Atomic Design Methodology | Atomic Design by Brad Frost](https://atomicdesign.bradfrost.com/chapter-2/) +- [Atomic Design: Getting Started | Blog | We Are Mobile First](https://www.wearemobilefirst.com/blog/atomic-design) + diff --git a/core/ui/compose/designsystem/assets/images/atomic_design.svg b/core/ui/compose/designsystem/assets/images/atomic_design.svg new file mode 100644 index 0000000..80b8ca7 --- /dev/null +++ b/core/ui/compose/designsystem/assets/images/atomic_design.svg @@ -0,0 +1,4 @@ + + + +
ATOMS
ATOMS
MOLECULES
MOLECUL...
ORGANISM
ORGANISM
TEMPLATES
TEMPLAT...
PAGES
PAGES
Text is not SVG - cannot display
\ No newline at end of file diff --git a/core/ui/compose/designsystem/build.gradle.kts b/core/ui/compose/designsystem/build.gradle.kts new file mode 100644 index 0000000..664748c --- /dev/null +++ b/core/ui/compose/designsystem/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "app.k9mail.core.ui.compose.designsystem" + resourcePrefix = "designsystem_" +} + +dependencies { + api(projects.core.ui.compose.theme2.common) + + debugApi(projects.core.ui.compose.theme2.k9mail) + debugApi(projects.core.ui.compose.theme2.thunderbird) + + implementation(libs.androidx.autofill) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.adaptive.layout) + implementation(libs.androidx.compose.material3.adaptive.navigation) + implementation(libs.androidx.compose.material.icons.extended) + + // Landscapist imports a lot of dependencies that we don't need. We exclude them here. + implementation(libs.lanscapist.coil) { + exclude(group = "io.coil-kt", module = "coil-gif") + exclude(group = "io.coil-kt", module = "coil-video") + exclude(group = "io.coil-kt.coil3", module = "coil-network-ktor3") + exclude(group = "io.ktor") + } + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewLightDarkLandscape.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewLightDarkLandscape.kt new file mode 100644 index 0000000..0c952ef --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewLightDarkLandscape.kt @@ -0,0 +1,13 @@ +package app.k9mail.core.ui.compose.designsystem + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.content.res.Configuration.UI_MODE_TYPE_NORMAL +import androidx.compose.ui.tooling.preview.Preview + +@Preview(name = "Light", device = "spec:width=673dp,height=841dp,orientation=landscape") +@Preview( + name = "Dark", + uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL, + device = "spec:width=673dp,height=841dp,orientation=landscape", +) +annotation class PreviewLightDarkLandscape diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewWithThemeLightDark.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewWithThemeLightDark.kt new file mode 100644 index 0000000..64dd02f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewWithThemeLightDark.kt @@ -0,0 +1,108 @@ +package app.k9mail.core.ui.compose.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.theme2.MainTheme + +/** + * A Composable function that displays a preview of the content in both Thunderbird and K-9 Mail themes. + * + * It uses the current system theme (light or dark) for both previews. + * + * @param modifier The modifier to be applied to the layout. + * @param useRow Whether to display the previews in a row or column. Defaults to `false` (column). + * @param useScrim Whether to display a scrim behind the content. Defaults to `false`. + * @param scrimAlpha The alpha value for the scrim. Defaults to `0.8f`. + * @param scrimPadding The padding for the scrim. Defaults to `MainTheme.spacings.triple`. + * @param arrangement The arrangement for the previews. Defaults to `Arrangement.spacedBy(MainTheme.spacings.triple)`. + * @param content The content to be displayed in the previews. + * + * @see app.k9mail.core.ui.compose.theme2.default.defaultThemeSpacings for MainTheme.spacings + */ +@Composable +fun PreviewWithThemesLightDark( + modifier: Modifier = Modifier, + useRow: Boolean = false, + useScrim: Boolean = false, + scrimAlpha: Float = 0.8f, + scrimPadding: PaddingValues = PaddingValues(24.dp), + arrangement: Arrangement.HorizontalOrVertical = Arrangement.spacedBy(24.dp), + content: @Composable () -> Unit, +) { + val movableContent = remember { + movableContentOf { + PreviewWithThemeLightDark( + themeType = PreviewThemeType.THUNDERBIRD, + useScrim = useScrim, + scrimAlpha = scrimAlpha, + scrimPadding = scrimPadding, + content = content, + ) + PreviewWithThemeLightDark( + themeType = PreviewThemeType.K9MAIL, + useScrim = useScrim, + scrimAlpha = scrimAlpha, + scrimPadding = scrimPadding, + content = content, + ) + } + } + + if (useRow) { + Row( + horizontalArrangement = arrangement, + modifier = modifier, + ) { + movableContent() + } + } else { + Column( + verticalArrangement = arrangement, + modifier = modifier, + ) { + movableContent() + } + } +} + +@Suppress("ModifierMissing") +@Composable +fun PreviewWithThemeLightDark( + themeType: PreviewThemeType = PreviewThemeType.THUNDERBIRD, + useScrim: Boolean = false, + scrimAlpha: Float = 0f, + scrimPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable (() -> Unit), +) { + val movableContent = remember { movableContentOf { content() } } + PreviewWithTheme( + themeType = themeType, + isDarkTheme = isSystemInDarkTheme(), + ) { + PreviewSurface { + if (useScrim) { + Box( + modifier = Modifier + .background(MainTheme.colors.scrim.copy(alpha = scrimAlpha)) + .padding(scrimPadding), + ) { + movableContent() + } + } else { + movableContent() + } + PreviewHeader(themeName = themeType.name) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewWithThemes.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewWithThemes.kt new file mode 100644 index 0000000..3e99326 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/PreviewWithThemes.kt @@ -0,0 +1,127 @@ +package app.k9mail.core.ui.compose.designsystem + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.sp +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.core.ui.compose.theme2.k9mail.K9MailTheme2 +import app.k9mail.core.ui.compose.theme2.thunderbird.ThunderbirdTheme2 + +@Composable +fun PreviewWithThemes( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier, + ) { + K9MailTheme2 { + PreviewSurface { + Column { + PreviewHeader(themeName = "K9Theme Light") + content() + } + } + } + K9MailTheme2(darkTheme = true) { + PreviewSurface { + Column { + PreviewHeader(themeName = "K9Theme Dark") + content() + } + } + } + ThunderbirdTheme2 { + PreviewSurface { + Column { + PreviewHeader(themeName = "ThunderbirdTheme Light") + content() + } + } + } + ThunderbirdTheme2(darkTheme = true) { + PreviewSurface { + Column { + PreviewHeader(themeName = "ThunderbirdTheme Dark") + content() + } + } + } + } +} + +enum class PreviewThemeType { + K9MAIL, + THUNDERBIRD, +} + +@Composable +fun PreviewWithTheme( + themeType: PreviewThemeType = PreviewThemeType.THUNDERBIRD, + isDarkTheme: Boolean = false, + content: @Composable () -> Unit, +) { + when (themeType) { + PreviewThemeType.K9MAIL -> { + PreviewWithK9MailTheme(isDarkTheme, content) + } + + PreviewThemeType.THUNDERBIRD -> { + PreviewWithThunderbirdTheme(isDarkTheme, content) + } + } +} + +@Composable +private fun PreviewWithK9MailTheme( + isDarkTheme: Boolean, + content: @Composable () -> Unit, +) { + K9MailTheme2( + darkTheme = isDarkTheme, + content = content, + ) +} + +@Composable +private fun PreviewWithThunderbirdTheme( + isDarkTheme: Boolean, + content: @Composable () -> Unit, +) { + ThunderbirdTheme2( + darkTheme = isDarkTheme, + content = content, + ) +} + +@Composable +internal fun PreviewHeader( + themeName: String, +) { + Surface( + color = MainTheme.colors.primary, + ) { + Text( + text = themeName, + fontSize = 4.sp, + modifier = Modifier.padding( + start = MainTheme.spacings.half, + end = MainTheme.spacings.half, + ), + ) + } +} + +@Composable +internal fun PreviewSurface( + content: @Composable () -> Unit, +) { + Surface( + color = MainTheme.colors.surface, + content = content, + ) +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CheckboxPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CheckboxPreview.kt new file mode 100644 index 0000000..d4fc1ef --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CheckboxPreview.kt @@ -0,0 +1,28 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun CheckboxPreview() { + PreviewWithThemes { + Checkbox( + checked = true, + onCheckedChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun CheckboxDisabledPreview() { + PreviewWithThemes { + Checkbox( + checked = true, + onCheckedChange = {}, + enabled = false, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CircularProgressIndicatorPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CircularProgressIndicatorPreview.kt new file mode 100644 index 0000000..77a5a8e --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CircularProgressIndicatorPreview.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun CircularProgressIndicatorPreview() { + PreviewWithThemes { + CircularProgressIndicator( + progress = { 0.75f }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun CircularProgressIndicatorColoredPreview() { + PreviewWithThemes { + CircularProgressIndicator( + progress = { 0.75f }, + color = MainTheme.colors.secondary, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerHorizontalPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerHorizontalPreview.kt new file mode 100644 index 0000000..ac3ed1b --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerHorizontalPreview.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun DividerHorizontalPreview() { + PreviewWithThemes { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(MainTheme.spacings.double), + ) { + DividerHorizontal( + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerVerticalPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerVerticalPreview.kt new file mode 100644 index 0000000..79a071f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerVerticalPreview.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun DividerVerticalPreview() { + PreviewWithThemes { + Row( + modifier = Modifier + .fillMaxHeight() + .padding(MainTheme.spacings.double), + ) { + DividerVertical( + modifier = Modifier.fillMaxHeight(), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/RadioGroupPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/RadioGroupPreview.kt new file mode 100644 index 0000000..36ed403 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/RadioGroupPreview.kt @@ -0,0 +1,48 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.collections.immutable.persistentListOf + +val choice = persistentListOf( + Pair("1", "Native Android"), + Pair("2", "Native iOS"), + Pair("3", "KMM"), + Pair("4", "Flutter"), +) + +@Composable +@Preview(showBackground = true) +internal fun RadioGroupSelectedPreview() { + PreviewWithThemes { + var selectedOption by remember { mutableStateOf(choice[0]) } + RadioGroup( + onClick = { selectedOption = it }, + options = choice, + optionTitle = { it.second }, + selectedOption = selectedOption, + modifier = Modifier.padding(MainTheme.spacings.default), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun RadioGroupUnSelectedPreview() { + PreviewWithThemes { + RadioGroup( + onClick = {}, + options = choice, + optionTitle = { it.second }, + modifier = Modifier.padding(MainTheme.spacings.default), + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/SurfacePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/SurfacePreview.kt new file mode 100644 index 0000000..df84302 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/SurfacePreview.kt @@ -0,0 +1,37 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun SurfacePreview() { + PreviewWithThemes { + Surface( + modifier = Modifier + .requiredHeight(MainTheme.sizes.larger) + .requiredWidth(MainTheme.sizes.larger), + content = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SurfaceWithShapePreview() { + PreviewWithThemes { + Surface( + modifier = Modifier + .requiredHeight(MainTheme.sizes.larger) + .requiredWidth(MainTheme.sizes.larger), + shape = MainTheme.shapes.small, + color = MainTheme.colors.primary, + content = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/SwitchPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/SwitchPreview.kt new file mode 100644 index 0000000..ec1b687 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/SwitchPreview.kt @@ -0,0 +1,28 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun SwitchPreview() { + PreviewWithThemes { + Switch( + checked = true, + onCheckedChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SwitchDisabledPreview() { + PreviewWithThemes { + Switch( + checked = true, + onCheckedChange = {}, + enabled = false, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonElevatedPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonElevatedPreview.kt new file mode 100644 index 0000000..1f1f6e8 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonElevatedPreview.kt @@ -0,0 +1,39 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun ButtonElevatedPreview() { + PreviewWithThemes { + ButtonElevated( + text = "Button Elevated", + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonElevatedDisabledPreview() { + PreviewWithThemes { + ButtonElevated( + text = "Button Elevated Disabled", + onClick = {}, + enabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonElevatedMultiLinePreview() { + PreviewWithThemes { + ButtonElevated( + text = "First\nSecond line", + onClick = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledPreview.kt new file mode 100644 index 0000000..aa88f4d --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledPreview.kt @@ -0,0 +1,39 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun ButtonFilledPreview() { + PreviewWithThemes { + ButtonFilled( + text = "Button Filled", + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonFilledDisabledPreview() { + PreviewWithThemes { + ButtonFilled( + text = "Button Filled Disabled", + onClick = {}, + enabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonFilledMultiLinePreview() { + PreviewWithThemes { + ButtonFilled( + text = "First\nSecond line", + onClick = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledTonalPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledTonalPreview.kt new file mode 100644 index 0000000..07156b4 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledTonalPreview.kt @@ -0,0 +1,39 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun ButtonFilledTonalPreview() { + PreviewWithThemes { + ButtonFilledTonal( + text = "Button Filled Tonal", + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonFilledTonalDisabledPreview() { + PreviewWithThemes { + ButtonFilledTonal( + text = "Button Filled Tonal Disabled", + onClick = {}, + enabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonFilledTonalMultiLinePreview() { + PreviewWithThemes { + ButtonFilledTonal( + text = "First\nSecond line", + onClick = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonIconPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonIconPreview.kt new file mode 100644 index 0000000..552bb03 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonIconPreview.kt @@ -0,0 +1,28 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Composable +@Preview(showBackground = true) +internal fun ButtonIconPreview() { + PreviewWithThemes { + ButtonIcon( + onClick = { }, + imageVector = Icons.Outlined.Info, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonIconFilledPreview() { + PreviewWithThemes { + ButtonIcon( + onClick = { }, + imageVector = Icons.Filled.Cancel, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonOutlinedPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonOutlinedPreview.kt new file mode 100644 index 0000000..dfa147e --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonOutlinedPreview.kt @@ -0,0 +1,39 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun ButtonOutlinedPreview() { + PreviewWithThemes { + ButtonOutlined( + text = "Button Outlined", + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonOutlinedDisabledPreview() { + PreviewWithThemes { + ButtonOutlined( + text = "Button Outlined Disabled", + onClick = {}, + enabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonOutlinedMultiLinePreview() { + PreviewWithThemes { + ButtonOutlined( + text = "First\nSecond line", + onClick = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonSegmentedSingleChoicePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonSegmentedSingleChoicePreview.kt new file mode 100644 index 0000000..1190c31 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonSegmentedSingleChoicePreview.kt @@ -0,0 +1,55 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import kotlinx.collections.immutable.persistentListOf + +private val options = persistentListOf( + "Option 1", + "Option 2", + "Option 3", +) + +@Composable +@Preview(showBackground = true) +internal fun ButtonSegmentedSingleChoicePreview() { + PreviewWithThemes { + ButtonSegmentedSingleChoice( + modifier = Modifier, + onClick = {}, + options = options, + optionTitle = { it }, + selectedOption = null, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonSegmentedSingleChoiceWithSelectionPreview() { + PreviewWithThemes { + ButtonSegmentedSingleChoice( + modifier = Modifier, + onClick = {}, + options = options, + optionTitle = { it }, + selectedOption = options[1], + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonSegmentedSingleChoiceEmptyPreview() { + PreviewWithThemes { + ButtonSegmentedSingleChoice( + modifier = Modifier, + onClick = {}, + options = persistentListOf(), + optionTitle = { it }, + selectedOption = null, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonTextPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonTextPreview.kt new file mode 100644 index 0000000..91cc6b3 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonTextPreview.kt @@ -0,0 +1,52 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun ButtonTextPreview() { + PreviewWithThemes { + ButtonText( + text = "Button Text", + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonTextColoredPreview() { + PreviewWithThemes { + ButtonText( + text = "Button Text Colored", + onClick = {}, + color = Color.Magenta, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonTextDisabledPreview() { + PreviewWithThemes { + ButtonText( + text = "Button Text Disabled", + onClick = {}, + enabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ButtonTextMultiLinePreview() { + PreviewWithThemes { + ButtonText( + text = "First\nSecond line", + onClick = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevatedPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevatedPreview.kt new file mode 100644 index 0000000..cc918ad --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevatedPreview.kt @@ -0,0 +1,22 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun CardElevatedPreview() { + PreviewWithThemes { + CardElevated { + Box(modifier = Modifier.padding(MainTheme.spacings.double)) { + TextBodyMedium("Text in card") + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardFilledPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardFilledPreview.kt new file mode 100644 index 0000000..e17a9d2 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardFilledPreview.kt @@ -0,0 +1,22 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun CardFilledPreview() { + PreviewWithThemes { + CardFilled { + Box(modifier = Modifier.padding(MainTheme.spacings.double)) { + TextBodyMedium("Text in card") + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardOutlinedPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardOutlinedPreview.kt new file mode 100644 index 0000000..92a8ce5 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardOutlinedPreview.kt @@ -0,0 +1,25 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun CardOutlinedPreview() { + PreviewWithThemesLightDark { + Surface(modifier = Modifier.padding(MainTheme.spacings.quadruple)) { + CardOutlined { + Box(modifier = Modifier.padding(MainTheme.spacings.double)) { + TextBodyMedium("Text in card") + } + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconPreview.kt new file mode 100644 index 0000000..78f7325 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconPreview.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Preview(showBackground = true) +@Composable +internal fun IconPreview() { + PreviewWithThemes { + Icon( + imageVector = Icons.Outlined.Info, + ) + } +} + +@Preview(showBackground = true) +@Composable +internal fun IconTintedPreview() { + PreviewWithThemes { + Icon( + imageVector = Icons.Outlined.Info, + tint = Color.Magenta, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/FixedScaleImagePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/FixedScaleImagePreview.kt new file mode 100644 index 0000000..8088eec --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/FixedScaleImagePreview.kt @@ -0,0 +1,76 @@ +package app.k9mail.core.ui.compose.designsystem.atom.image + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun FixedScaleImageBottomCenterPreview() { + PreviewWithTheme { + Box( + modifier = Modifier + .width(MainTheme.sizes.huge) + .height(MainTheme.sizes.huge), + ) { + FixedScaleImage( + id = MainTheme.images.logo, + alignment = Alignment.BottomCenter, + ) + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun FixedScaleImageCroppedPreview() { + PreviewWithTheme { + Box( + modifier = Modifier + .width(MainTheme.sizes.medium) + .height(MainTheme.sizes.medium), + ) { + FixedScaleImage( + id = MainTheme.images.logo, + ) + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun FixedScaleImageHorizontallyCroppedPreview() { + PreviewWithTheme { + Box( + modifier = Modifier + .width(MainTheme.sizes.huge) + .height(MainTheme.sizes.medium), + ) { + FixedScaleImage( + id = MainTheme.images.logo, + ) + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun FixedScaleImageVerticallyCroppedPreview() { + PreviewWithTheme { + Box( + modifier = Modifier + .width(MainTheme.sizes.medium) + .height(MainTheme.sizes.huge), + ) { + FixedScaleImage( + id = MainTheme.images.logo, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/RemoteImagePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/RemoteImagePreview.kt new file mode 100644 index 0000000..1a8331f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/RemoteImagePreview.kt @@ -0,0 +1,23 @@ +package app.k9mail.core.ui.compose.designsystem.atom.image + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +fun RemoteImagePreview() { + PreviewWithTheme { + val painter = rememberVectorPainter(Icons.Outlined.AccountCircle) + RemoteImage( + url = "", + modifier = Modifier.size(MainTheme.sizes.large), + previewPlaceholder = painter, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyLargePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyLargePreview.kt new file mode 100644 index 0000000..986ba1f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyLargePreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextBodyLargePreview() { + PreviewWithThemes { + TextBodyLarge( + text = "Text Body Large", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodyLargeWithAnnotatedStringPreview() { + PreviewWithThemes { + TextBodyLarge( + text = buildAnnotatedString { + append("Text Body Large ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodyLargeWithColorPreview() { + PreviewWithThemes { + TextBodyLarge( + text = "Text Body Large with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodyLargeWithTextAlignPreview() { + PreviewWithThemes { + TextBodyLarge( + text = "Text Body Large with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyMediumPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyMediumPreview.kt new file mode 100644 index 0000000..56b5f86 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyMediumPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextBodyMediumPreview() { + PreviewWithThemes { + TextBodyMedium( + text = "Text Body Medium", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodyMediumWithAnnotatedStringPreview() { + PreviewWithThemes { + TextBodyMedium( + text = buildAnnotatedString { + append("Text Body Medium ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodyMediumWithColorPreview() { + PreviewWithThemes { + TextBodyMedium( + text = "Text Body Medium with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodyMediumWithTextAlignPreview() { + PreviewWithThemes { + TextBodyMedium( + text = "Text Body Medium with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodySmallPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodySmallPreview.kt new file mode 100644 index 0000000..a58d626 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodySmallPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextBodySmallPreview() { + PreviewWithThemes { + TextBodySmall( + text = "Text Body Small", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodySmallWithAnnotatedStringPreview() { + PreviewWithThemes { + TextBodySmall( + text = buildAnnotatedString { + append("Text Body Small ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodySmallWithColorPreview() { + PreviewWithThemes { + TextBodySmall( + text = "Text Body Small with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextBodySmallWithTextAlignPreview() { + PreviewWithThemes { + TextBodySmall( + text = "Text Body Small with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayLargePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayLargePreview.kt new file mode 100644 index 0000000..1d09ba9 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayLargePreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayLargePreview() { + PreviewWithThemes { + TextDisplayLarge( + text = "Text Display Large", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayLargeWithAnnotatedStringPreview() { + PreviewWithThemes { + TextDisplayLarge( + text = buildAnnotatedString { + append("Text Display Large ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayLargeWithColorPreview() { + PreviewWithThemes { + TextDisplayLarge( + text = "Text Display Large with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayLargeWithTextAlignPreview() { + PreviewWithThemes { + TextDisplayLarge( + text = "Text Display Large with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMediumPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMediumPreview.kt new file mode 100644 index 0000000..672d42d --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMediumPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayMediumPreview() { + PreviewWithThemes { + TextDisplayMedium( + text = "Text Display Medium", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayMediumWithAnnotatedStringPreview() { + PreviewWithThemes { + TextDisplayMedium( + text = buildAnnotatedString { + append("Text Display Medium ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayMediumWithColorPreview() { + PreviewWithThemes { + TextDisplayMedium( + text = "Text Display Medium with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplayMediumWithTextAlignPreview() { + PreviewWithThemes { + TextDisplayMedium( + text = "Text Display Medium with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplaySmallPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplaySmallPreview.kt new file mode 100644 index 0000000..5aedbb2 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplaySmallPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextDisplaySmallPreview() { + PreviewWithThemes { + TextDisplaySmall( + text = "Text Display Small", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplaySmallWithAnnotatedStringPreview() { + PreviewWithThemes { + TextDisplaySmall( + text = buildAnnotatedString { + append("Text Display Small ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplaySmallWithColorPreview() { + PreviewWithThemes { + TextDisplaySmall( + text = "Text Display Small with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextDisplaySmallWithTextAlignPreview() { + PreviewWithThemes { + TextDisplaySmall( + text = "Text Display Small with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineLargePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineLargePreview.kt new file mode 100644 index 0000000..e1ba785 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineLargePreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineLargePreview() { + PreviewWithThemes { + TextHeadlineLarge( + text = "Text Headline Large", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineLargeWithAnnotatedStringPreview() { + PreviewWithThemes { + TextHeadlineLarge( + text = buildAnnotatedString { + append("Text Headline Large ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineLargeWithColorPreview() { + PreviewWithThemes { + TextHeadlineLarge( + text = "Text Headline Large with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineLargeWithTextAlignPreview() { + PreviewWithThemes { + TextHeadlineLarge( + text = "Text Headline Large with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineMediumPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineMediumPreview.kt new file mode 100644 index 0000000..8c07493 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineMediumPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineMediumPreview() { + PreviewWithThemes { + TextHeadlineMedium( + text = "Text Headline Medium", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineMediumWithAnnotatedStringPreview() { + PreviewWithThemes { + TextHeadlineMedium( + text = buildAnnotatedString { + append("Text Headline Medium ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineMediumWithColorPreview() { + PreviewWithThemes { + TextHeadlineMedium( + text = "Text Headline Medium with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineMediumWithTextAlignPreview() { + PreviewWithThemes { + TextHeadlineMedium( + text = "Text Headline Medium with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineSmallPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineSmallPreview.kt new file mode 100644 index 0000000..5b77ed4 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineSmallPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineSmallPreview() { + PreviewWithThemes { + TextHeadlineSmall( + text = "Text Headline Small", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineSmallWithAnnotatedStringPreview() { + PreviewWithThemes { + TextHeadlineSmall( + text = buildAnnotatedString { + append("Text Headline Small ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineSmallWithColorPreview() { + PreviewWithThemes { + TextHeadlineSmall( + text = "Text Headline Small with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextHeadlineSmallWithTextAlignPreview() { + PreviewWithThemes { + TextHeadlineSmall( + text = "Text Headline Small with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelLargePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelLargePreview.kt new file mode 100644 index 0000000..2dfc1cb --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelLargePreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextLabelLargePreview() { + PreviewWithThemes { + TextLabelLarge( + text = "Text Label Large", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelLargeWithAnnotatedStringPreview() { + PreviewWithThemes { + TextLabelLarge( + text = buildAnnotatedString { + append("Text Label Large ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelLargeWithColorPreview() { + PreviewWithThemes { + TextLabelLarge( + text = "Text Label Large with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelLargeWithTextAlignPreview() { + PreviewWithThemes { + TextLabelLarge( + text = "Text Label Large with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelMediumPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelMediumPreview.kt new file mode 100644 index 0000000..8036b59 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelMediumPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextLabelMediumPreview() { + PreviewWithThemes { + TextLabelMedium( + text = "Text Label Medium", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelMediumWithAnnotatedStringPreview() { + PreviewWithThemes { + TextLabelMedium( + text = buildAnnotatedString { + append("Text Label Medium ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelMediumWithColorPreview() { + PreviewWithThemes { + TextLabelMedium( + text = "Text Label Medium with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelMediumWithTextAlignPreview() { + PreviewWithThemes { + TextLabelMedium( + text = "Text Label Medium with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelSmallPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelSmallPreview.kt new file mode 100644 index 0000000..4b5932b --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelSmallPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextLabelSmallPreview() { + PreviewWithThemes { + TextLabelSmall( + text = "Text Label Small", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelSmallWithAnnotatedStringPreview() { + PreviewWithThemes { + TextLabelSmall( + text = buildAnnotatedString { + append("Text Label Small ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelSmallWithColorPreview() { + PreviewWithThemes { + TextLabelSmall( + text = "Text Label Small with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextLabelSmallWithTextAlignPreview() { + PreviewWithThemes { + TextLabelSmall( + text = "Text Label Small with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleLargePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleLargePreview.kt new file mode 100644 index 0000000..aa8a6d9 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleLargePreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextTitleLargePreview() { + PreviewWithThemes { + TextTitleLarge( + text = "Text Title Large", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleLargeWithAnnotatedStringPreview() { + PreviewWithThemes { + TextTitleLarge( + text = buildAnnotatedString { + append("Text Title Large ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleLargeWithColorPreview() { + PreviewWithThemes { + TextTitleLarge( + text = "Text Title Large with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleLargeWithTextAlignPreview() { + PreviewWithThemes { + TextTitleLarge( + text = "Text Title Large with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleMediumPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleMediumPreview.kt new file mode 100644 index 0000000..cd6d306 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleMediumPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextTitleMediumPreview() { + PreviewWithThemes { + TextTitleMedium( + text = "Text Title Medium", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleMediumWithAnnotatedStringPreview() { + PreviewWithThemes { + TextTitleMedium( + text = buildAnnotatedString { + append("Text Title Medium ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleMediumWithColorPreview() { + PreviewWithThemes { + TextTitleMedium( + text = "Text Title Medium with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleMediumWithTextAlignPreview() { + PreviewWithThemes { + TextTitleMedium( + text = "Text Title Medium with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleSmallPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleSmallPreview.kt new file mode 100644 index 0000000..5436c99 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleSmallPreview.kt @@ -0,0 +1,58 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun TextTitleSmallPreview() { + PreviewWithThemes { + TextTitleSmall( + text = "Text Title Small", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleSmallWithAnnotatedStringPreview() { + PreviewWithThemes { + TextTitleSmall( + text = buildAnnotatedString { + append("Text Title Small ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleSmallWithColorPreview() { + PreviewWithThemes { + TextTitleSmall( + text = "Text Title Small with color", + color = MainTheme.colors.primary, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextTitleSmallWithTextAlignPreview() { + PreviewWithThemes { + TextTitleSmall( + text = "Text Title Small with TextAlign End", + textAlign = TextAlign.End, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldLabelPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldLabelPreview.kt new file mode 100644 index 0000000..a4b9a56 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldLabelPreview.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun TextFieldLabelPreview() { + PreviewWithThemes { + TextFieldLabel( + label = "Label", + isRequired = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldLabelRequiredPreview() { + PreviewWithThemes { + TextFieldLabel( + label = "Label", + isRequired = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldLabelRequiredEmptyLabelPreview() { + PreviewWithThemes { + TextFieldLabel( + label = "", + isRequired = true, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddressPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddressPreview.kt new file mode 100644 index 0000000..abb4049 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddressPreview.kt @@ -0,0 +1,52 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedEmailAddressPreview() { + PreviewWithThemes { + TextFieldOutlinedEmailAddress( + value = "Input text", + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedEmailAddressWithLabelPreview() { + PreviewWithThemes { + TextFieldOutlinedEmailAddress( + value = "Input text", + label = "Label", + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedEmailDisabledPreview() { + PreviewWithThemes { + TextFieldOutlinedEmailAddress( + value = "Input text", + onValueChange = {}, + isEnabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedEmailErrorPreview() { + PreviewWithThemes { + TextFieldOutlinedEmailAddress( + value = "Input text", + onValueChange = {}, + hasError = true, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedFakeSelectPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedFakeSelectPreview.kt new file mode 100644 index 0000000..56996e0 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedFakeSelectPreview.kt @@ -0,0 +1,28 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedFakeSelectPreview() { + PreviewWithThemes { + TextFieldOutlinedFakeSelect( + text = "Current value", + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedFakeSelectPreviewWithLabel() { + PreviewWithThemes { + TextFieldOutlinedFakeSelect( + text = "Current value", + onClick = {}, + label = "Label", + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumberPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumberPreview.kt new file mode 100644 index 0000000..cba70ea --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumberPreview.kt @@ -0,0 +1,52 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedNumberPreview() { + PreviewWithThemes { + TextFieldOutlinedNumber( + value = 123L, + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedNumberWithLabelPreview() { + PreviewWithThemes { + TextFieldOutlinedNumber( + value = 123L, + label = "Label", + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedNumberDisabledPreview() { + PreviewWithThemes { + TextFieldOutlinedNumber( + value = 123L, + onValueChange = {}, + isEnabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedNumberErrorPreview() { + PreviewWithThemes { + TextFieldOutlinedNumber( + value = 123L, + onValueChange = {}, + hasError = true, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordPreview.kt new file mode 100644 index 0000000..425375f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordPreview.kt @@ -0,0 +1,52 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedPasswordPreview() { + PreviewWithThemes { + TextFieldOutlinedPassword( + value = "Input text", + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedPasswordWithLabelPreview() { + PreviewWithThemes { + TextFieldOutlinedPassword( + value = "Input text", + label = "Label", + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedPasswordDisabledPreview() { + PreviewWithThemes { + TextFieldOutlinedPassword( + value = "Input text", + onValueChange = {}, + isEnabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedPasswordErrorPreview() { + PreviewWithThemes { + TextFieldOutlinedPassword( + value = "Input text", + onValueChange = {}, + hasError = true, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPreview.kt new file mode 100644 index 0000000..22bce4c --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPreview.kt @@ -0,0 +1,80 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedPreview() { + PreviewWithThemes { + TextFieldOutlined( + value = "Input text", + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedWithLabelPreview() { + PreviewWithThemes { + TextFieldOutlined( + value = "Input text", + onValueChange = {}, + label = "Label", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedDisabledPreview() { + PreviewWithThemes { + TextFieldOutlined( + value = "Input text", + onValueChange = {}, + isEnabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedErrorPreview() { + PreviewWithThemes { + TextFieldOutlined( + value = "Input text", + onValueChange = {}, + hasError = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedRequiredPreview() { + PreviewWithThemes { + TextFieldOutlined( + value = "", + onValueChange = {}, + label = "Label", + isRequired = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedWithTrailingIconPreview() { + PreviewWithThemes { + TextFieldOutlined( + value = "", + onValueChange = {}, + trailingIcon = { Icon(imageVector = Icons.Outlined.AccountCircle) }, + isRequired = true, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelectPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelectPreview.kt new file mode 100644 index 0000000..ff66ee3 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelectPreview.kt @@ -0,0 +1,31 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import kotlinx.collections.immutable.persistentListOf + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedSelectPreview() { + PreviewWithThemes { + TextFieldOutlinedSelect( + options = persistentListOf("Option 1", "Option 2", "Option 3"), + selectedOption = "Option 1", + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextFieldOutlinedSelectPreviewWithLabel() { + PreviewWithThemes { + TextFieldOutlinedSelect( + options = persistentListOf("Option 1", "Option 2", "Option 3"), + selectedOption = "Option 1", + onValueChange = {}, + label = "Label", + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingErrorViewPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingErrorViewPreview.kt new file mode 100644 index 0000000..5759aea --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingErrorViewPreview.kt @@ -0,0 +1,91 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium + +@Composable +@Preview(showBackground = true) +internal fun ContentLoadingErrorViewContentPreview() { + PreviewWithThemes { + DefaultContentLoadingErrorView( + state = ContentLoadingErrorState.Content, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContentLoadingErrorViewLoadingPreview() { + PreviewWithThemes { + DefaultContentLoadingErrorView( + state = ContentLoadingErrorState.Loading, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContentLoadingErrorViewErrorPreview() { + PreviewWithThemes { + DefaultContentLoadingErrorView( + state = ContentLoadingErrorState.Error, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContentLoadingErrorViewInteractivePreview() { + PreviewWithThemes { + val state = remember { + mutableStateOf(ContentLoadingErrorState.Loading) + } + + DefaultContentLoadingErrorView( + state = state.value, + modifier = Modifier + .clickable { + when (state.value) { + ContentLoadingErrorState.Loading -> { + state.value = ContentLoadingErrorState.Content + } + + ContentLoadingErrorState.Content -> { + state.value = ContentLoadingErrorState.Error + } + + ContentLoadingErrorState.Error -> { + state.value = ContentLoadingErrorState.Loading + } + } + }, + ) + } +} + +@Composable +private fun DefaultContentLoadingErrorView( + state: ContentLoadingErrorState, + modifier: Modifier = Modifier, +) { + ContentLoadingErrorView( + state = state, + error = { + TextTitleMedium(text = "Error") + }, + loading = { + TextTitleMedium(text = "Loading...") + }, + content = { + TextTitleMedium(text = "Content") + }, + modifier = modifier.fillMaxSize(), + ) +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingViewPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingViewPreview.kt new file mode 100644 index 0000000..6b3f8a6 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingViewPreview.kt @@ -0,0 +1,79 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium + +@Composable +@Preview(showBackground = true) +fun ContentLoadingViewPreview() { + PreviewWithThemes { + DefaultContentLoadingView( + state = ContentLoadingState.Content, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContentLoadingViewLoadingPreview() { + PreviewWithThemes { + DefaultContentLoadingView( + state = ContentLoadingState.Loading, + ) + } +} + +@Composable +private fun DefaultContentLoadingView( + state: ContentLoadingState, + modifier: Modifier = Modifier, +) { + ContentLoadingView( + state = state, + loading = { + TextTitleMedium(text = "Loading...") + }, + content = { + TextTitleMedium(text = "Content") + }, + modifier = modifier.fillMaxSize(), + ) +} + +@Composable +@Preview(showBackground = true) +internal fun ContentLoadingViewInteractivePreview() { + PreviewWithThemes { + val state = remember { + mutableStateOf(State(isLoading = true, content = "Hello world")) + } + + ContentLoadingView( + state = state.value, + loading = { + TextTitleMedium(text = "Loading...") + }, + content = { targetState -> + TextTitleMedium(text = targetState.content) + }, + modifier = Modifier + .clickable { + val currentValue = state.value + state.value = currentValue.copy(isLoading = currentValue.isLoading.not()) + } + .fillMaxSize(), + ) + } +} + +private data class State( + override val isLoading: Boolean, + val content: String, +) : LoadingState diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ErrorViewPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ErrorViewPreview.kt new file mode 100644 index 0000000..e4dc54e --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ErrorViewPreview.kt @@ -0,0 +1,49 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun ErrorViewPreview() { + PreviewWithThemes { + ErrorView( + title = "Error", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ErrorViewWithMessagePreview() { + PreviewWithThemes { + ErrorView( + title = "Error", + message = "Something went wrong.", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ErrorViewWithRetryPreview() { + PreviewWithThemes { + ErrorView( + title = "Error", + onRetry = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ErrorViewWithRetryAndMessagePreview() { + PreviewWithThemes { + ErrorView( + title = "Error", + message = "Something went wrong.", + onRetry = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/LoadingViewPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/LoadingViewPreview.kt new file mode 100644 index 0000000..5ec602b --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/LoadingViewPreview.kt @@ -0,0 +1,23 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun LoadingViewPreview() { + PreviewWithThemes { + LoadingView() + } +} + +@Composable +@Preview(showBackground = true) +internal fun LoadingViewWithMessagePreview() { + PreviewWithThemes { + LoadingView( + message = "Loading ...", + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/PullToRefreshBoxPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/PullToRefreshBoxPreview.kt new file mode 100644 index 0000000..20da9ce --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/PullToRefreshBoxPreview.kt @@ -0,0 +1,45 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun PullToRefreshBoxPreview() { + PreviewWithThemes { + PullToRefreshBox( + isRefreshing = false, + onRefresh = {}, + modifier = Modifier.fillMaxWidth() + .height(MainTheme.sizes.medium), + ) { + Surface { + TextBodyLarge("Pull to refresh") + } + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun PullToRefreshBoxRefreshingPreview() { + PreviewWithThemes { + PullToRefreshBox( + isRefreshing = true, + onRefresh = {}, + modifier = Modifier.fillMaxWidth() + .height(MainTheme.sizes.medium), + ) { + Surface { + TextBodyLarge("Refreshing ...") + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/AdvancedTextInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/AdvancedTextInputPreview.kt new file mode 100644 index 0000000..7fa0eb3 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/AdvancedTextInputPreview.kt @@ -0,0 +1,87 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun AdvancedTextInputPreview() { + PreviewWithThemes { + AdvancedTextInput( + onTextChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AdvancedTextInputIsRequiredPreview() { + PreviewWithThemes { + AdvancedTextInput( + onTextChange = {}, + label = "Text input is required", + isRequired = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AdvancedTextInputWithErrorPreview() { + PreviewWithThemes { + AdvancedTextInput( + onTextChange = {}, + errorMessage = "Text input error", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AdvancedTextInputWithAnnotatedStringPreview() { + PreviewWithThemes { + AdvancedTextInput( + onTextChange = {}, + text = TextFieldValue( + annotatedString = buildAnnotatedString { + append("Text input with ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Annotated") + } + }, + ), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AdvancedTextInputWithSelectionPreview() { + PreviewWithThemes { + AdvancedTextInput( + onTextChange = {}, + text = TextFieldValue("Text input with selection", selection = TextRange(0, 4)), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AdvancedTextInputWithCompositionPreview() { + PreviewWithThemes { + AdvancedTextInput( + onTextChange = {}, + text = TextFieldValue( + text = "Text input with composition", + composition = TextRange(0, 4), + ), + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/CheckboxInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/CheckboxInputPreview.kt new file mode 100644 index 0000000..8e0282a --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/CheckboxInputPreview.kt @@ -0,0 +1,42 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun CheckboxInputPreview() { + PreviewWithThemes { + CheckboxInput( + text = "CheckboxInput", + checked = false, + onCheckedChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun CheckboxInputWithErrorPreview() { + PreviewWithThemes { + CheckboxInput( + text = "CheckboxInput", + checked = false, + onCheckedChange = {}, + errorMessage = "Error message", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun CheckboxInputCheckedPreview() { + PreviewWithThemes { + CheckboxInput( + text = "CheckboxInput", + checked = true, + onCheckedChange = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/EmailAddressInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/EmailAddressInputPreview.kt new file mode 100644 index 0000000..76c503e --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/EmailAddressInputPreview.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun EmailAddressInputPreview() { + PreviewWithThemes { + EmailAddressInput( + onEmailAddressChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun EmailAddressInputWithErrorPreview() { + PreviewWithThemes { + EmailAddressInput( + onEmailAddressChange = {}, + errorMessage = "Email address error", + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayoutPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayoutPreview.kt new file mode 100644 index 0000000..5f960be --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayoutPreview.kt @@ -0,0 +1,40 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlined + +@Composable +@Preview(showBackground = true) +internal fun InputLayoutPreview() { + PreviewWithThemes { + InputLayout { + TextFieldOutlined(value = "InputLayout", onValueChange = {}) + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun InputLayoutWithErrorPreview() { + PreviewWithThemes { + InputLayout( + errorMessage = "Error message", + ) { + TextFieldOutlined(value = "InputLayout", onValueChange = {}) + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun InputLayoutWithWarningPreview() { + PreviewWithThemes { + InputLayout( + warningMessage = "Warning message", + ) { + TextFieldOutlined(value = "InputLayout", onValueChange = {}) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/NumberInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/NumberInputPreview.kt new file mode 100644 index 0000000..e47c72f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/NumberInputPreview.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun NumberInputPreview() { + PreviewWithThemes { + NumberInput( + onValueChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun NumberInputIsRequiredPreview() { + PreviewWithThemes { + NumberInput( + onValueChange = {}, + label = "Text input is required", + isRequired = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun NumberInputWithErrorPreview() { + PreviewWithThemes { + NumberInput( + onValueChange = {}, + errorMessage = "Text input error", + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/PasswordInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/PasswordInputPreview.kt new file mode 100644 index 0000000..3ce177f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/PasswordInputPreview.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun PasswordInputPreview() { + PreviewWithThemes { + PasswordInput( + onPasswordChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun PasswordInputWithErrorPreview() { + PreviewWithThemes { + PasswordInput( + onPasswordChange = {}, + errorMessage = "Password error", + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SelectInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SelectInputPreview.kt new file mode 100644 index 0000000..99ff4ac --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SelectInputPreview.kt @@ -0,0 +1,18 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import kotlinx.collections.immutable.persistentListOf + +@Composable +@Preview(showBackground = true) +internal fun SelectInputPreview() { + PreviewWithThemes { + SelectInput( + options = persistentListOf("Option 1", "Option 2", "Option 3"), + selectedOption = "Option 1", + onOptionChange = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SwitchInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SwitchInputPreview.kt new file mode 100644 index 0000000..6af9ffc --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SwitchInputPreview.kt @@ -0,0 +1,42 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun SwitchInputPreview() { + PreviewWithThemes { + SwitchInput( + text = "SwitchInput", + checked = false, + onCheckedChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SwitchInputWithErrorPreview() { + PreviewWithThemes { + SwitchInput( + text = "SwitchInput", + checked = false, + onCheckedChange = {}, + errorMessage = "Error message", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SwitchInputCheckedPreview() { + PreviewWithThemes { + SwitchInput( + text = "SwitchInput", + checked = true, + onCheckedChange = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/TextInputPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/TextInputPreview.kt new file mode 100644 index 0000000..687255f --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/TextInputPreview.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun TextInputPreview() { + PreviewWithThemes { + TextInput( + onTextChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextInputIsRequiredPreview() { + PreviewWithThemes { + TextInput( + onTextChange = {}, + label = "Text input is required", + isRequired = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TextInputWithErrorPreview() { + PreviewWithThemes { + TextInput( + onTextChange = {}, + errorMessage = "Text input error", + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/notification/NotificationActionButtonPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/notification/NotificationActionButtonPreview.kt new file mode 100644 index 0000000..ec58d61 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/notification/NotificationActionButtonPreview.kt @@ -0,0 +1,28 @@ + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun NotificationActionButtonPreview() { + PreviewWithThemesLightDark { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + NotificationActionButton( + onClick = {}, + text = "Sign in", + ) + NotificationActionButton( + onClick = {}, + text = "View support article", + isExternalLink = true, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialogPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialogPreview.kt new file mode 100644 index 0000000..a87e471 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialogPreview.kt @@ -0,0 +1,80 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun AlertDialogPreview() { + PreviewWithTheme { + AlertDialog( + title = "Title", + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + confirmText = "Accept", + onConfirmClick = {}, + onDismissRequest = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AlertDialogWithIconPreview() { + PreviewWithTheme { + AlertDialog( + icon = Icons.Outlined.Info, + title = "Title", + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + confirmText = "Accept", + onConfirmClick = {}, + onDismissRequest = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AlertDialogWithCancelPreview() { + PreviewWithTheme { + AlertDialog( + icon = Icons.Outlined.Info, + title = "Title", + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + confirmText = "Accept", + dismissText = "Cancel", + onConfirmClick = {}, + onDismissRequest = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AlertDialogWithCustomContentPreview() { + PreviewWithTheme { + AlertDialog( + icon = Icons.Outlined.Info, + title = "Title", + confirmText = "Accept", + dismissText = "Cancel", + onConfirmClick = {}, + onDismissRequest = {}, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + ) { + TextBodyMedium("Lorem ipsum dolor sit amet, consectetur adipiscing elit.") + TextBodyMedium("Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/BasicDialogPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/BasicDialogPreview.kt new file mode 100644 index 0000000..7dcbabc --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/BasicDialogPreview.kt @@ -0,0 +1,147 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.PreviewLightDarkLandscape +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDarkLandscape +@Composable +private fun BasicDialogPreview() { + PreviewWithThemesLightDark( + useRow = true, + useScrim = true, + scrimPadding = PaddingValues(32.dp), + arrangement = Arrangement.spacedBy(24.dp), + ) { + BasicDialogContent( + headline = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(imageVector = Icons.Default.Refresh, contentDescription = null) + TextHeadlineSmall(text = "Reset settings?") + } + }, + supportingText = { + TextBodyMedium( + text = "This will reset your app preferences back to their default settings. " + + "The following accounts will also be signed out:", + color = MainTheme.colors.onSurfaceVariant, + ) + }, + content = { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + modifier = Modifier + .fillMaxWidth() + .padding( + start = MainTheme.spacings.triple, + end = MainTheme.spacings.triple, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + ) { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background(color = MainTheme.colors.primary, shape = CircleShape), + ) + Text(text = "Account 1") + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + ) { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background(color = MainTheme.colors.primary, shape = CircleShape), + ) + Text(text = "Account 2") + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + ) { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background(color = MainTheme.colors.primary, shape = CircleShape), + ) + Text(text = "Account 3") + } + } + }, + buttons = { + TextButton(onClick = {}) { + Text(text = "Cancel") + } + TextButton(onClick = {}) { + Text(text = "Accept") + } + }, + showDividers = true, + modifier = Modifier.width(300.dp), + ) + } +} + +@PreviewLightDarkLandscape +@Composable +private fun PreviewOnlySupportingText() { + PreviewWithThemesLightDark( + useRow = true, + useScrim = true, + scrimPadding = PaddingValues(32.dp), + arrangement = Arrangement.spacedBy(24.dp), + ) { + BasicDialogContent( + headline = { + TextHeadlineSmall(text = "Email can not be archived") + }, + supportingText = { + TextBodyMedium( + text = "Configure archive folder now", + color = MainTheme.colors.onSurfaceVariant, + ) + }, + content = null, + buttons = { + TextButton(onClick = {}) { + Text(text = "Skip for now") + } + TextButton(onClick = {}) { + Text(text = "Set archive folder") + } + }, + showDividers = false, + modifier = Modifier.width(300.dp), + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/SubtitleTopAppBarPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/SubtitleTopAppBarPreview.kt new file mode 100644 index 0000000..7e785af --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/SubtitleTopAppBarPreview.kt @@ -0,0 +1,79 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Composable +@Preview(showBackground = true) +internal fun SubtitleTopAppBarPreview() { + PreviewWithThemes { + SubtitleTopAppBar( + title = "Title", + subtitle = "Subtitle", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SubtitleTopAppBarWithLongSubtitlePreview() { + PreviewWithThemes { + SubtitleTopAppBar( + title = "Title", + subtitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SubtitleTopAppBarWithActionsPreview() { + PreviewWithThemes { + SubtitleTopAppBar( + title = "Title", + subtitle = "Subtitle", + actions = { + ButtonIcon( + onClick = {}, + imageVector = Icons.Outlined.Info, + ) + ButtonIcon( + onClick = {}, + imageVector = Icons.Outlined.Check, + ) + ButtonIcon( + onClick = {}, + imageVector = Icons.Outlined.Visibility, + ) + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SubtitleTopAppBarWithMenuButtonPreview() { + PreviewWithThemes { + SubtitleTopAppBarWithMenuButton( + title = "Title", + subtitle = "Subtitle", + onMenuClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SubtitleTopAppBarWithBackButtonPreview() { + PreviewWithThemes { + SubtitleTopAppBarWithBackButton( + title = "Title", + subtitle = "Subtitle", + onBackClick = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/TopAppBarPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/TopAppBarPreview.kt new file mode 100644 index 0000000..22aad93 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/TopAppBarPreview.kt @@ -0,0 +1,63 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Composable +@Preview(showBackground = true) +internal fun TopAppBarPreview() { + PreviewWithThemes { + TopAppBar( + title = "Title", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TopAppBarWithActionsPreview() { + PreviewWithThemes { + TopAppBar( + title = "Title", + actions = { + ButtonIcon( + onClick = {}, + imageVector = Icons.Outlined.Info, + ) + ButtonIcon( + onClick = {}, + imageVector = Icons.Outlined.Check, + ) + ButtonIcon( + onClick = {}, + imageVector = Icons.Outlined.Visibility, + ) + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TopAppBarWithMenuButtonPreview() { + PreviewWithThemes { + TopAppBarWithMenuButton( + title = "Title", + onMenuClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun TopAppBarWithBackButtonPreview() { + PreviewWithThemes { + TopAppBarWithBackButton( + title = "Title", + onBackClick = {}, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/BannerGlobalNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/BannerGlobalNotificationCardPreview.kt new file mode 100644 index 0000000..1d8bc4b --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/BannerGlobalNotificationCardPreview.kt @@ -0,0 +1,68 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.Warning +import app.k9mail.core.ui.compose.designsystem.organism.banner.global.BannerGlobalNotificationCard +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun BannerGlobalNotificationCardStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + BannerGlobalNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Warning) }, + text = "Offline. No internet connection found.", + action = { + ButtonText( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun BannerGlobalNotificationCardAnnotatedStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + BannerGlobalNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Warning) }, + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Black)) { + append("Offline. ") + } + append("No internet connection found.") + }, + action = { + ButtonText( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/ErrorBannerGlobalNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/ErrorBannerGlobalNotificationCardPreview.kt new file mode 100644 index 0000000..02c0d1d --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/ErrorBannerGlobalNotificationCardPreview.kt @@ -0,0 +1,63 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton +import app.k9mail.core.ui.compose.designsystem.organism.banner.global.ErrorBannerGlobalNotificationCard +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun ErrorBannerGlobalNotificationCardStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + ErrorBannerGlobalNotificationCard( + text = "Offline. No internet connection found.", + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ErrorBannerGlobalNotificationCardAnnotatedStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + ErrorBannerGlobalNotificationCard( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Black)) { + append("Offline. ") + } + append("No internet connection found.") + }, + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/InfoBannerGlobalNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/InfoBannerGlobalNotificationCardPreview.kt new file mode 100644 index 0000000..50bddf0 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/InfoBannerGlobalNotificationCardPreview.kt @@ -0,0 +1,120 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton +import app.k9mail.core.ui.compose.designsystem.organism.banner.global.InfoBannerGlobalNotificationCard +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun InfoBannerGlobalNotificationCardStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + InfoBannerGlobalNotificationCard( + text = "Offline. No internet connection found.", + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun InfoBannerGlobalNotificationCardNoActionPreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + InfoBannerGlobalNotificationCard( + text = "Offline. No internet connection found.", + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun InfoBannerGlobalNotificationCardLongTextPreview( + @PreviewParameter(LoremIpsum::class) text: String, +) { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + InfoBannerGlobalNotificationCard( + text = text, + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun InfoBannerGlobalNotificationCardLongNoActionTextPreview( + @PreviewParameter(LoremIpsum::class) text: String, +) { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + InfoBannerGlobalNotificationCard( + text = text, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun InfoBannerGlobalNotificationCardAnnotatedStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + InfoBannerGlobalNotificationCard( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Black)) { + append("Offline. ") + } + append("No internet connection found.") + }, + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/SuccessBannerGlobalNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/SuccessBannerGlobalNotificationCardPreview.kt new file mode 100644 index 0000000..9a97373 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/SuccessBannerGlobalNotificationCardPreview.kt @@ -0,0 +1,120 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton +import app.k9mail.core.ui.compose.designsystem.organism.banner.global.SuccessBannerGlobalNotificationCard +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun SuccessBannerGlobalNotificationCardStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + SuccessBannerGlobalNotificationCard( + text = "What an awesome notification, isn't it?", + action = { + NotificationActionButton( + text = "Action", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun SuccessBannerGlobalNotificationCardNoActionPreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + SuccessBannerGlobalNotificationCard( + text = "What an awesome notification, isn't it?", + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun SuccessBannerGlobalNotificationCardLongTextPreview( + @PreviewParameter(LoremIpsum::class) text: String, +) { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + SuccessBannerGlobalNotificationCard( + text = text, + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun SuccessBannerGlobalNotificationCardLongNoActionTextPreview( + @PreviewParameter(LoremIpsum::class) text: String, +) { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + SuccessBannerGlobalNotificationCard( + text = text, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun SuccessBannerGlobalNotificationCardAnnotatedStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + SuccessBannerGlobalNotificationCard( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Black)) { + append("Offline. ") + } + append("No internet connection found.") + }, + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/WarningBannerGlobalNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/WarningBannerGlobalNotificationCardPreview.kt new file mode 100644 index 0000000..c859bdd --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/WarningBannerGlobalNotificationCardPreview.kt @@ -0,0 +1,120 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton +import app.k9mail.core.ui.compose.designsystem.organism.banner.global.WarningBannerGlobalNotificationCard +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun WarningBannerGlobalNotificationCardStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + WarningBannerGlobalNotificationCard( + text = "Offline. No internet connection found.", + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun WarningBannerGlobalNotificationCardNoActionPreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + WarningBannerGlobalNotificationCard( + text = "Offline. No internet connection found.", + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun WarningBannerGlobalNotificationCardLongTextPreview( + @PreviewParameter(LoremIpsum::class) text: String, +) { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + WarningBannerGlobalNotificationCard( + text = text, + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun WarningBannerGlobalNotificationCardLongNoActionTextPreview( + @PreviewParameter(LoremIpsum::class) text: String, +) { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + WarningBannerGlobalNotificationCard( + text = text, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun WarningBannerGlobalNotificationCardAnnotatedStringTitlePreview() { + PreviewWithThemesLightDark { + Surface( + modifier = Modifier.fillMaxWidth(), + ) { + WarningBannerGlobalNotificationCard( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Black)) { + append("Offline. ") + } + append("No internet connection found.") + }, + action = { + NotificationActionButton( + text = "Retry", + onClick = {}, + ) + }, + modifier = Modifier.padding(top = MainTheme.spacings.quadruple), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCardPreview.kt new file mode 100644 index 0000000..2ac35ce --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCardPreview.kt @@ -0,0 +1,151 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun BannerInlineNotificationCardCustomTitleAndDescriptionPreview() { + PreviewWithThemesLightDark { + Surface(modifier = Modifier.padding(MainTheme.spacings.triple)) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Report) }, + title = { + TextTitleMedium(text = "Authentication required") + }, + supportingText = { + TextBodyMedium(text = "Sign in to authenticate username@domain3.example") + }, + actions = { + NotificationActionButton(text = "Support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Sign in", onClick = {}) + }, + modifier = Modifier.padding( + vertical = MainTheme.spacings.quadruple, + horizontal = MainTheme.spacings.default, + ), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun BannerInlineNotificationCardTextPreview() { + PreviewWithThemesLightDark { + Surface(modifier = Modifier.padding(MainTheme.spacings.triple)) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Report) }, + title = "Missing encryption key", + supportingText = "To dismiss this error, disable encryption for this account or ensure " + + "encryption key is available in openKeychain app.", + actions = { + NotificationActionButton(text = "Support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Disable encryption", onClick = {}) + }, + modifier = Modifier.padding( + vertical = MainTheme.spacings.quadruple, + horizontal = MainTheme.spacings.default, + ), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun BannerInlineNotificationCardAnnotatedStringPreview() { + PreviewWithThemesLightDark { + Surface(modifier = Modifier.padding(MainTheme.spacings.triple)) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Report) }, + title = buildAnnotatedString { + withStyle(style = SpanStyle(color = MainTheme.colors.tertiaryContainer)) { + append("Missing encryption key") + } + }, + supportingText = buildAnnotatedString { + append("To dismiss this error, ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Black)) { + append("disable encryption for this account or ensure encryption key is available") + } + append("in openKeychain app.") + }, + actions = { + NotificationActionButton(text = "Support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Disable encryption", onClick = {}) + }, + modifier = Modifier.padding( + vertical = MainTheme.spacings.quadruple, + horizontal = MainTheme.spacings.default, + ), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun BannerInlineNotificationClippedCardTextPreview() { + PreviewWithThemesLightDark { + Surface(modifier = Modifier.padding(MainTheme.spacings.triple)) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Report) }, + title = "Vestibulum tempor sed massa eget fermentum. Vivamus ut vitae aliquam e augue. " + + "Sed nec tincidunt arcu", + supportingText = "scelerisque fermentum. In lobortis pellentesque aliquet. Curabitur quam " + + "felis, sodales in leo ac, sodales rutrum quam. Quisque et odio id ex varius porta. " + + "Vestibulum tortor nibh, porta venenatis velit", + actions = { + NotificationActionButton(text = "Support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Disable encryption", onClick = {}) + }, + modifier = Modifier.padding( + vertical = MainTheme.spacings.quadruple, + horizontal = MainTheme.spacings.default, + ), + behaviour = BannerInlineNotificationCardBehaviour.Clipped, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun BannerInlineNotificationExpandedCardTextPreview() { + PreviewWithThemesLightDark { + Surface(modifier = Modifier.padding(MainTheme.spacings.triple)) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Report) }, + title = "Vestibulum tempor sed massa eget fermentum. Vivamus ut vitae aliquam e augue. " + + "Sed nec tincidunt arcu", + supportingText = "scelerisque fermentum. In lobortis pellentesque aliquet. Curabitur quam " + + "felis, sodales in leo ac, sodales rutrum quam. Quisque et odio id ex varius porta. " + + "Vestibulum tortor nibh, porta venenatis velit", + actions = { + NotificationActionButton(text = "Support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Disable encryption", onClick = {}) + }, + modifier = Modifier.padding( + vertical = MainTheme.spacings.quadruple, + horizontal = MainTheme.spacings.default, + ), + behaviour = BannerInlineNotificationCardBehaviour.Expanded, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/ErrorBannerInlineNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/ErrorBannerInlineNotificationCardPreview.kt new file mode 100644 index 0000000..5350cdf --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/ErrorBannerInlineNotificationCardPreview.kt @@ -0,0 +1,59 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton + +@PreviewLightDark +@Composable +private fun ErrorBannerInlineNotificationCardPreviewPreview() { + PreviewWithThemesLightDark { + ErrorBannerInlineNotificationCard( + title = "Notification title", + supportingText = "Supporting text", + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun ErrorBannerInlineNotificationCardLongTextClippedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + ErrorBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Clipped, + ) + } +} + +@PreviewLightDark +@Composable +private fun ErrorBannerInlineNotificationCardLongTextExpandedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + ErrorBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Expanded, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/InfoBannerInlineNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/InfoBannerInlineNotificationCardPreview.kt new file mode 100644 index 0000000..d3c9fe4 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/InfoBannerInlineNotificationCardPreview.kt @@ -0,0 +1,59 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton + +@PreviewLightDark +@Composable +private fun InfoBannerInlineNotificationCardPreviewPreview() { + PreviewWithThemesLightDark { + InfoBannerInlineNotificationCard( + title = "Notification title", + supportingText = "Supporting text", + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun InfoBannerInlineNotificationCardLongTextClippedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + InfoBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Clipped, + ) + } +} + +@PreviewLightDark +@Composable +private fun InfoBannerInlineNotificationCardLongTextExpandedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + InfoBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Expanded, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/SuccessBannerInlineNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/SuccessBannerInlineNotificationCardPreview.kt new file mode 100644 index 0000000..7c7d960 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/SuccessBannerInlineNotificationCardPreview.kt @@ -0,0 +1,59 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton + +@PreviewLightDark +@Composable +private fun SuccessBannerInlineNotificationCardPreviewPreview() { + PreviewWithThemesLightDark { + SuccessBannerInlineNotificationCard( + title = "Notification title", + supportingText = "Supporting text", + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun SuccessBannerInlineNotificationCardLongTextClippedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + SuccessBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Clipped, + ) + } +} + +@PreviewLightDark +@Composable +private fun SuccessBannerInlineNotificationCardLongTextExpandedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + SuccessBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Expanded, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/WarningBannerInlineNotificationCardPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/WarningBannerInlineNotificationCardPreview.kt new file mode 100644 index 0000000..38c16f5 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/WarningBannerInlineNotificationCardPreview.kt @@ -0,0 +1,59 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton + +@PreviewLightDark +@Composable +private fun WarningBannerInlineNotificationCardPreviewPreview() { + PreviewWithThemesLightDark { + WarningBannerInlineNotificationCard( + title = "Notification title", + supportingText = "Supporting text", + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun WarningBannerInlineNotificationCardLongTextClippedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + WarningBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Clipped, + ) + } +} + +@PreviewLightDark +@Composable +private fun WarningBannerInlineNotificationCardLongTextExpandedPreviewPreview() { + val title = remember { LoremIpsum(words = 20).values.joinToString(" ") } + val supportingText = remember { LoremIpsum(words = 60).values.joinToString(" ") } + PreviewWithThemesLightDark { + WarningBannerInlineNotificationCard( + title = title, + supportingText = supportingText, + actions = { + NotificationActionButton(text = "View support article", onClick = {}, isExternalLink = true) + NotificationActionButton(text = "Action 1", onClick = {}) + }, + behaviour = BannerInlineNotificationCardBehaviour.Expanded, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemBadgePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemBadgePreview.kt new file mode 100644 index 0000000..071ce35 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemBadgePreview.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Composable +@Preview(showBackground = true) +internal fun NavigationDrawerItemBadgePreview() { + PreviewWithThemes { + NavigationDrawerItemBadge( + label = "99+", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun NavigationDrawerItemBadgeWithIconPreview() { + PreviewWithThemes { + NavigationDrawerItemBadge( + label = "99+", + imageVector = Icons.Outlined.Info, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemPreview.kt new file mode 100644 index 0000000..7a7d8d1 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemPreview.kt @@ -0,0 +1,67 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBox +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge + +@Composable +@Preview(showBackground = true) +internal fun NavigationDrawerItemSelectedPreview() { + PreviewWithThemes { + NavigationDrawerItem( + label = "DrawerItem", + selected = true, + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun NavigationDrawerItemUnselectedPreview() { + PreviewWithThemes { + NavigationDrawerItem( + label = "DrawerItem", + selected = false, + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun NavigationDrawerItemWithIconPreview() { + PreviewWithThemes { + NavigationDrawerItem( + label = "DrawerItem", + selected = false, + onClick = {}, + icon = { + Icon( + imageVector = Icons.Outlined.AccountBox, + ) + }, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun NavigationDrawerItemWithLabelBadgePreview() { + PreviewWithThemes { + NavigationDrawerItem( + label = "DrawerItem", + selected = false, + onClick = {}, + badge = { + TextLabelLarge( + text = "100+", + ) + }, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/LazyColumnWithHeaderFooterPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/LazyColumnWithHeaderFooterPreview.kt new file mode 100644 index 0000000..050f776 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/LazyColumnWithHeaderFooterPreview.kt @@ -0,0 +1,32 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@Preview(showBackground = true) +internal fun LazyColumnWithHeaderFooterPreview() { + PreviewWithTheme { + Surface { + LazyColumnWithHeaderFooter( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double, Alignment.CenterVertically), + header = { TextTitleMedium(text = "Header") }, + footer = { TextTitleMedium(text = "Footer") }, + ) { + items(10) { + TextBodyLarge(text = "Item $it") + } + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ListDetailPanePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ListDetailPanePreview.kt new file mode 100644 index 0000000..b60664b --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ListDetailPanePreview.kt @@ -0,0 +1,102 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import android.os.Parcelable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@Composable +@PreviewDevices +internal fun ListDetailPanePreview() { + PreviewWithTheme { + val navigationController = rememberListDetailNavigationController() + val coroutineScope = rememberCoroutineScope() + + ListDetailPane( + navigationController = navigationController, + listPane = { + Surface( + color = Color.Yellow, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn { + itemsIndexed(createItems()) { index, item -> + ListItem( + item = item, + onClick = { + coroutineScope.launch { + navigationController.value.navigateToDetail(item) + } + }, + ) + } + } + } + }, + detailPane = { item -> + Surface( + color = Color.Red, + modifier = Modifier.fillMaxSize(), + ) { + ListItem( + item = item, + onClick = { + coroutineScope.launch { + navigationController.value.navigateBack() + } + }, + ) + } + }, + ) + } +} + +@Composable +private fun ListItem( + item: ListItem, + onClick: () -> Unit, +) { + Column( + modifier = Modifier.clickable(onClick = onClick), + ) { + TextTitleMedium(item.id) + TextBodyMedium(item.title) + } +} + +@Parcelize +internal data class ListItem( + val id: String, + val title: String, +) : Parcelable + +private fun createItems(): List { + return listOf( + ListItem( + id = "1", + title = "Item 1", + ), + ListItem( + id = "2", + title = "Item 2", + ), + ListItem( + id = "3", + title = "Item 3", + ), + ) +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentPreview.kt new file mode 100644 index 0000000..376ceaf --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentPreview.kt @@ -0,0 +1,25 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@PreviewDevices +internal fun ResponsiveContentPreview() { + PreviewWithTheme { + Surface { + ResponsiveContent { contentPadding -> + Surface( + color = MainTheme.colors.info, + modifier = Modifier.fillMaxSize().padding(contentPadding), + ) {} + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentWithSurfacePreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentWithSurfacePreview.kt new file mode 100644 index 0000000..177a20a --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentWithSurfacePreview.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@PreviewDevices +internal fun ResponsiveContentWithBackgroundPreview() { + PreviewWithTheme { + ResponsiveContentWithSurface { + Surface( + color = MainTheme.colors.info, + modifier = Modifier + .fillMaxSize() + .padding(MainTheme.spacings.double), + content = {}, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveWidthContainerPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveWidthContainerPreview.kt new file mode 100644 index 0000000..01111c5 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveWidthContainerPreview.kt @@ -0,0 +1,30 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@PreviewDevices +internal fun ResponsiveWidthContainerPreview() { + PreviewWithTheme { + Surface { + ResponsiveWidthContainer { contentPadding -> + Surface( + color = MainTheme.colors.error, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + TextBodyLarge("Hello, World!") + } + } + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ScaffoldPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ScaffoldPreview.kt new file mode 100644 index 0000000..f1d4880 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/app/k9mail/core/ui/compose/designsystem/template/ScaffoldPreview.kt @@ -0,0 +1,85 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +@PreviewDevices +internal fun ScaffoldPreview() { + PreviewWithTheme { + Scaffold( + topBar = { + Surface( + color = MainTheme.colors.error, + modifier = Modifier + .fillMaxWidth() + .height(MainTheme.sizes.topBarHeight), + ) {} + }, + bottomBar = { + Surface( + color = MainTheme.colors.warning, + modifier = Modifier + .fillMaxWidth() + .height(MainTheme.sizes.bottomBarHeight), + ) {} + }, + ) { contentPadding -> + Surface( + color = MainTheme.colors.info, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) {} + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun ScaffoldWitFabPreview() { + PreviewWithTheme { + Scaffold( + topBar = { + Surface( + color = MainTheme.colors.error, + modifier = Modifier + .fillMaxWidth() + .height(MainTheme.sizes.topBarHeight), + ) {} + }, + bottomBar = { + Surface( + color = MainTheme.colors.warning, + modifier = Modifier + .fillMaxWidth() + .height(MainTheme.sizes.bottomBarHeight), + ) {} + }, + floatingActionButton = { + ButtonIcon( + onClick = { }, + imageVector = Icons.Outlined.Check, + ) + }, + ) { contentPadding -> + Surface( + color = MainTheme.colors.surface, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) {} + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/button/FavouriteButtonIconPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/button/FavouriteButtonIconPreview.kt new file mode 100644 index 0000000..e2022e3 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/button/FavouriteButtonIconPreview.kt @@ -0,0 +1,33 @@ +package net.thunderbird.core.ui.compose.designsystem.atom.button + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.PreviewLightDarkLandscape +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDarkLandscape +@Composable +private fun FavouriteButtonIconPreview() { + PreviewWithThemesLightDark(useRow = true) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + modifier = Modifier.padding( + vertical = MainTheme.spacings.quadruple, + horizontal = MainTheme.spacings.default, + ), + ) { + TextLabelLarge(text = "Favourite = false") + FavouriteButtonIcon(favourite = false, onFavouriteChange = {}) + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + TextLabelLarge(text = "Favourite = true") + FavouriteButtonIcon(favourite = true, onFavouriteChange = {}) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/message/MessageItemSenderTextPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/message/MessageItemSenderTextPreview.kt new file mode 100644 index 0000000..ad8f007 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/message/MessageItemSenderTextPreview.kt @@ -0,0 +1,93 @@ +package net.thunderbird.core.ui.compose.designsystem.molecule.message + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.DividerHorizontal +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelSmall +import app.k9mail.core.ui.compose.theme2.MainTheme + +private data class MessageItemSenderTextPreviewParams( + val sender: String, + val subject: String, + val swapSenderWithSubject: Boolean, + val threadCount: Int, +) + +private class MessageItemSenderTextPreviewCol : CollectionPreviewParameterProvider( + listOf( + MessageItemSenderTextPreviewParams( + sender = "Sender", + subject = "Subject", + swapSenderWithSubject = false, + threadCount = 0, + ), + MessageItemSenderTextPreviewParams( + sender = "Sender", + subject = "Subject", + swapSenderWithSubject = true, + threadCount = 0, + ), + MessageItemSenderTextPreviewParams( + sender = "Sender", + subject = "Subject", + swapSenderWithSubject = false, + threadCount = 10, + ), + MessageItemSenderTextPreviewParams( + sender = "Sender", + subject = "Subject", + swapSenderWithSubject = true, + threadCount = 10, + ), + MessageItemSenderTextPreviewParams( + sender = LoremIpsum(words = 10).values.joinToString(" "), + subject = "Subject", + swapSenderWithSubject = false, + threadCount = 10, + ), + MessageItemSenderTextPreviewParams( + sender = "Sender", + subject = LoremIpsum(words = 10).values.joinToString(" "), + swapSenderWithSubject = true, + threadCount = 10, + ), + ), +) + +@Preview +@Composable +private fun MessageItemSenderTextPreview( + @PreviewParameter(MessageItemSenderTextPreviewCol::class) params: MessageItemSenderTextPreviewParams, +) { + PreviewWithThemes { + Column(verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default)) { + DividerHorizontal(modifier = Modifier.padding(vertical = MainTheme.spacings.default)) + TextBodySmall(text = "Params: $params") + DividerHorizontal(modifier = Modifier.padding(vertical = MainTheme.spacings.default)) + TextLabelSmall(text = "MessageItemSenderTitleSmall:") + MessageItemSenderTitleSmall( + subject = params.subject, + sender = params.sender, + swapSenderWithSubject = params.swapSenderWithSubject, + threadCount = params.threadCount, + ) + DividerHorizontal(modifier = Modifier.padding(vertical = MainTheme.spacings.default)) + TextLabelSmall(text = "MessageItemSenderBodyMedium:") + MessageItemSenderBodyMedium( + subject = params.subject, + sender = params.sender, + swapSenderWithSubject = params.swapSenderWithSubject, + threadCount = params.threadCount, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ActiveMessageItemPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ActiveMessageItemPreview.kt new file mode 100644 index 0000000..fdcef01 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ActiveMessageItemPreview.kt @@ -0,0 +1,194 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private class ActiveMessageItemPrevParamCol : CollectionPreviewParameterProvider( + collection = listOf( + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = true, + ), + ), +) + +@Preview +@Composable +private fun PreviewDefault( + @PreviewParameter(ActiveMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + ActiveMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background( + color = MainTheme.colors.primaryContainer.copy(alpha = 0.15f), + shape = CircleShape, + ) + .border(width = 1.dp, color = MainTheme.colors.primary, shape = CircleShape), + ) { + TextTitleSmall(text = "SN", modifier = Modifier.align(Alignment.Center)) + } + }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + ) + } +} + +@Preview +@Composable +private fun PreviewCompact( + @PreviewParameter(ActiveMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + ActiveMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.compactContentPadding, + ) + } +} + +@Preview +@Composable +private fun PreviewRelaxed( + @PreviewParameter(ActiveMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + ActiveMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.relaxedContentPadding, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/JunkMessageItemPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/JunkMessageItemPreview.kt new file mode 100644 index 0000000..87afc40 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/JunkMessageItemPreview.kt @@ -0,0 +1,183 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private class JunkMessageItemPrevParamCol : CollectionPreviewParameterProvider( + collection = listOf( + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + threadCount = 100, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + threadCount = 0, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + threadCount = 1, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + threadCount = 10, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + threadCount = 100, + swapSenderWithSubject = true, + ), + ), +) + +@Preview +@Composable +private fun PreviewDefault( + @PreviewParameter(JunkMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + JunkMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background( + color = MainTheme.colors.primaryContainer.copy(alpha = 0.15f), + shape = CircleShape, + ) + .border(width = 1.dp, color = MainTheme.colors.primary, shape = CircleShape), + ) { + TextTitleSmall(text = "SN", modifier = Modifier.align(Alignment.Center)) + } + }, + onClick = { }, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + ) + } +} + +@Preview +@Composable +private fun PreviewCompact( + @PreviewParameter(JunkMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + JunkMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.compactContentPadding, + ) + } +} + +@Preview +@Composable +private fun PreviewRelaxed( + @PreviewParameter(JunkMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + JunkMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.relaxedContentPadding, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemPrevParams.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemPrevParams.kt new file mode 100644 index 0000000..c987e48 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemPrevParams.kt @@ -0,0 +1,21 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +internal data class MessageItemPrevParams( + val sender: String, + val subject: String, + val preview: String, + val hasAttachments: Boolean, + val selected: Boolean, + val favourite: Boolean = false, + val threadCount: Int = 0, + val swapSenderWithSubject: Boolean = false, + val receivedAt: LocalDateTime = @OptIn(ExperimentalTime::class) Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), +) diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemPreview.kt new file mode 100644 index 0000000..cf1f943 --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemPreview.kt @@ -0,0 +1,196 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import net.thunderbird.core.ui.compose.designsystem.atom.icon.filled.Star + +private class MessageItemPrevParamCol : CollectionPreviewParameterProvider( + collection = listOf( + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System + .now() + .minus(1.minutes) + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System + .now() + .minus(1.days) + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System + .now() + .minus(31.days) + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System + .now() + .minus(365.days) + .toLocalDateTime(TimeZone.currentSystemDefault()), + ), + ), +) + +@Preview +@Composable +private fun PreviewDefault( + @PreviewParameter(MessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + MessageItem( + leading = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .padding(MainTheme.spacings.half) + .background(color = MainTheme.colors.primary, shape = CircleShape), + ) + }, + sender = { TextTitleSmall(text = params.sender) }, + subject = { TextLabelLarge(text = params.subject) }, + preview = params.preview, + action = { + IconButton( + onClick = { }, + modifier = Modifier.size(MainTheme.sizes.iconLarge), + ) { + Image(imageVector = Icons.Filled.Star, contentDescription = null) + } + }, + receivedAt = params.receivedAt, + onClick = { }, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + colors = MessageItemDefaults.newMessageItemColors(), + ) + } +} + +@Preview +@Composable +private fun PreviewCompact( + @PreviewParameter(MessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + MessageItem( + leading = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .padding(MainTheme.spacings.half) + .background(color = MainTheme.colors.primary, shape = CircleShape), + ) + }, + sender = { TextTitleSmall(text = params.sender) }, + subject = { TextLabelLarge(text = params.subject) }, + preview = params.preview, + action = { + IconButton( + onClick = { }, + modifier = Modifier.size(MainTheme.sizes.iconLarge), + ) { + Image(imageVector = Icons.Filled.Star, contentDescription = null) + } + }, + receivedAt = params.receivedAt, + onClick = { }, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + contentPadding = MessageItemDefaults.compactContentPadding, + colors = MessageItemDefaults.unreadMessageItemColors(), + ) + } +} + +@Preview +@Composable +private fun PreviewRelaxed( + @PreviewParameter(MessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + MessageItem( + leading = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .padding(MainTheme.spacings.half) + .background(color = MainTheme.colors.primary, shape = CircleShape), + ) + }, + sender = { TextTitleSmall(text = params.sender) }, + subject = { TextLabelLarge(text = params.subject) }, + preview = params.preview, + action = { + IconButton( + onClick = { }, + modifier = Modifier.size(MainTheme.sizes.iconLarge), + ) { + Image(imageVector = Icons.Filled.Star, contentDescription = null) + } + }, + receivedAt = params.receivedAt, + onClick = { }, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + contentPadding = MessageItemDefaults.relaxedContentPadding, + colors = MessageItemDefaults.readMessageItemColors(), + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/NewMessageItemPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/NewMessageItemPreview.kt new file mode 100644 index 0000000..f76d5fe --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/NewMessageItemPreview.kt @@ -0,0 +1,194 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private class NewMessageItemPrevParamCol : CollectionPreviewParameterProvider( + collection = listOf( + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = true, + ), + ), +) + +@Preview +@Composable +private fun PreviewDefault( + @PreviewParameter(NewMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + NewMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background( + color = MainTheme.colors.primaryContainer.copy(alpha = 0.15f), + shape = CircleShape, + ) + .border(width = 1.dp, color = MainTheme.colors.primary, shape = CircleShape), + ) { + TextTitleSmall(text = "SN", modifier = Modifier.align(Alignment.Center)) + } + }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + ) + } +} + +@Preview +@Composable +private fun PreviewCompact( + @PreviewParameter(NewMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + NewMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + contentPadding = MessageItemDefaults.compactContentPadding, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + ) + } +} + +@Preview +@Composable +private fun PreviewRelaxed( + @PreviewParameter(NewMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + NewMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + contentPadding = MessageItemDefaults.relaxedContentPadding, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ReadMessageItemPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ReadMessageItemPreview.kt new file mode 100644 index 0000000..885e09d --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ReadMessageItemPreview.kt @@ -0,0 +1,194 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private class ReadMessageItemPrevParamCol : CollectionPreviewParameterProvider( + collection = listOf( + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = true, + ), + ), +) + +@Preview +@Composable +private fun PreviewDefault( + @PreviewParameter(ReadMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + ReadMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background( + color = MainTheme.colors.primaryContainer.copy(alpha = 0.15f), + shape = CircleShape, + ) + .border(width = 1.dp, color = MainTheme.colors.primary, shape = CircleShape), + ) { + TextTitleSmall(text = "SN", modifier = Modifier.align(Alignment.Center)) + } + }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + ) + } +} + +@Preview +@Composable +private fun PreviewCompact( + @PreviewParameter(ReadMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + ReadMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.compactContentPadding, + ) + } +} + +@Preview +@Composable +private fun PreviewRelaxed( + @PreviewParameter(ReadMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + ReadMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.relaxedContentPadding, + ) + } +} diff --git a/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/UnreadMessageItemPreview.kt b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/UnreadMessageItemPreview.kt new file mode 100644 index 0000000..a19cf3c --- /dev/null +++ b/core/ui/compose/designsystem/src/debug/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/UnreadMessageItemPreview.kt @@ -0,0 +1,194 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +private class UnreadMessageItemPrevParamCol : CollectionPreviewParameterProvider( + collection = listOf( + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = false, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 3).values.joinToString(), + hasAttachments = false, + selected = false, + favourite = false, + threadCount = 0, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = LoremIpsum(words = 100).values.joinToString(), + subject = LoremIpsum(words = 100).values.joinToString(), + preview = LoremIpsum(words = 5).values.joinToString(), + hasAttachments = true, + selected = false, + favourite = false, + threadCount = 1, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 10).values.joinToString(), + hasAttachments = false, + selected = true, + favourite = true, + threadCount = 10, + swapSenderWithSubject = true, + ), + MessageItemPrevParams( + sender = "Sender Name", + subject = "The subject", + preview = LoremIpsum(words = 20).values.joinToString(), + hasAttachments = true, + selected = true, + favourite = true, + threadCount = 100, + swapSenderWithSubject = true, + ), + ), +) + +@Preview +@Composable +private fun PreviewDefault( + @PreviewParameter(UnreadMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + UnreadMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { + Box( + modifier = Modifier + .size(MainTheme.sizes.iconAvatar) + .background( + color = MainTheme.colors.primaryContainer.copy(alpha = 0.15f), + shape = CircleShape, + ) + .border(width = 1.dp, color = MainTheme.colors.primary, shape = CircleShape), + ) { + TextTitleSmall(text = "SN", modifier = Modifier.align(Alignment.Center)) + } + }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + ) + } +} + +@Preview +@Composable +private fun PreviewCompact( + @PreviewParameter(UnreadMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + UnreadMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.compactContentPadding, + ) + } +} + +@Preview +@Composable +private fun PreviewRelaxed( + @PreviewParameter(UnreadMessageItemPrevParamCol::class) params: MessageItemPrevParams, +) { + PreviewWithThemes { + UnreadMessageItem( + sender = params.sender, + subject = params.subject, + preview = params.preview, + receivedAt = @OptIn(ExperimentalTime::class) Clock.System.now().toLocalDateTime(TimeZone.UTC), + avatar = { }, + onClick = { }, + onFavouriteChange = {}, + modifier = Modifier.padding(MainTheme.spacings.double), + hasAttachments = params.hasAttachments, + selected = params.selected, + favourite = params.favourite, + threadCount = params.threadCount, + swapSenderWithSubject = params.swapSenderWithSubject, + contentPadding = MessageItemDefaults.relaxedContentPadding, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Checkbox.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Checkbox.kt new file mode 100644 index 0000000..382c6ec --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Checkbox.kt @@ -0,0 +1,20 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.material3.Checkbox as Material3Checkbox + +@Composable +fun Checkbox( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Material3Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CircularProgressIndicator.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CircularProgressIndicator.kt new file mode 100644 index 0000000..5ee82ad --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/CircularProgressIndicator.kt @@ -0,0 +1,31 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.material3.CircularProgressIndicator as Material3CircularProgressIndicator + +@Composable +fun CircularProgressIndicator( + progress: () -> Float, + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.circularColor, +) { + Material3CircularProgressIndicator( + progress = progress, + modifier = modifier, + color = color, + ) +} + +@Composable +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.circularColor, +) { + Material3CircularProgressIndicator( + modifier = modifier, + color = color, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DelayedCircularProgressIndicator.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DelayedCircularProgressIndicator.kt new file mode 100644 index 0000000..665e23d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DelayedCircularProgressIndicator.kt @@ -0,0 +1,43 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.common.visibility.hide +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val LOADING_INDICATOR_DELAY = 500L + +/** + * Only show a [CircularProgressIndicator] after [LOADING_INDICATOR_DELAY] ms. + * + * Use this to avoid flashing a loading indicator for loads that are usually very fast. + */ +@Composable +fun DelayedCircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.circularColor, +) { + var progressIndicatorVisible by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = Unit) { + launch { + delay(LOADING_INDICATOR_DELAY) + progressIndicatorVisible = true + } + } + + CircularProgressIndicator( + modifier = Modifier + .hide(!progressIndicatorVisible) + .then(modifier), + color = color, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerHorizontal.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerHorizontal.kt new file mode 100644 index 0000000..d0d6016 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerHorizontal.kt @@ -0,0 +1,21 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.material3.DividerDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.material3.HorizontalDivider as Material3HorizontalDivider + +@Composable +fun DividerHorizontal( + modifier: Modifier = Modifier, + thickness: Dp = DividerDefaults.Thickness, + color: Color = DividerDefaults.color, +) { + Material3HorizontalDivider( + modifier = modifier, + thickness = thickness, + color = color, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerVertical.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerVertical.kt new file mode 100644 index 0000000..c88bd58 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/DividerVertical.kt @@ -0,0 +1,21 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.material3.DividerDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.material3.VerticalDivider as Material3VerticalDivider + +@Composable +fun DividerVertical( + modifier: Modifier = Modifier, + thickness: Dp = DividerDefaults.Thickness, + color: Color = DividerDefaults.color, +) { + Material3VerticalDivider( + modifier = modifier, + thickness = thickness, + color = color, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/RadioGroup.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/RadioGroup.kt new file mode 100644 index 0000000..61aa245 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/RadioGroup.kt @@ -0,0 +1,30 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.button.RadioButton +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun RadioGroup( + onClick: (T) -> Unit, + options: ImmutableList, + optionTitle: (T) -> String, + modifier: Modifier = Modifier, + selectedOption: T? = null, +) { + if (options.isEmpty()) { + return + } + + Column(modifier = modifier) { + options.forEach { option -> + RadioButton( + label = optionTitle(option), + onClick = { onClick(option) }, + selected = option == selectedOption, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Surface.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Surface.kt new file mode 100644 index 0000000..4f79927 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Surface.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Surface as Material3Surface + +@Composable +fun Surface( + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + color: Color = MainTheme.colors.surface, + tonalElevation: Dp = MainTheme.elevations.level0, + content: @Composable () -> Unit, +) { + Material3Surface( + modifier = modifier, + shape = shape, + content = content, + tonalElevation = tonalElevation, + color = color, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Switch.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Switch.kt new file mode 100644 index 0000000..1c86ddb --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/Switch.kt @@ -0,0 +1,20 @@ +package app.k9mail.core.ui.compose.designsystem.atom + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.material3.Switch as Material3Switch + +@Composable +fun Switch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Material3Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonElevated.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonElevated.kt new file mode 100644 index 0000000..8f1b7fa --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonElevated.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.material3.ElevatedButton as Material3ElevatedButton +import androidx.compose.material3.Text as Material3Text + +@Composable +fun ButtonElevated( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Material3ElevatedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) { + Material3Text( + text = text, + textAlign = TextAlign.Center, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilled.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilled.kt new file mode 100644 index 0000000..87b99bd --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilled.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.material3.Button as Material3Button +import androidx.compose.material3.Text as Material3Text + +@Composable +fun ButtonFilled( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Material3Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) { + Material3Text( + text = text, + textAlign = TextAlign.Center, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledTonalButton.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledTonalButton.kt new file mode 100644 index 0000000..a670b18 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonFilledTonalButton.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.material3.FilledTonalButton as Material3FilledTonalButton +import androidx.compose.material3.Text as Material3Text + +@Composable +fun ButtonFilledTonal( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Material3FilledTonalButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) { + Material3Text( + text = text, + textAlign = TextAlign.Center, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonIcon.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonIcon.kt new file mode 100644 index 0000000..1d388bf --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonIcon.kt @@ -0,0 +1,123 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Icon as Material3Icon +import androidx.compose.material3.IconButton as Material3IconButton +import androidx.compose.material3.IconButtonColors as Material3IconButtonColors +import androidx.compose.material3.IconButtonDefaults as Material3IconButtonDefaults + +@Composable +fun ButtonIcon( + onClick: () -> Unit, + imageVector: ImageVector, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentDescription: String? = null, + colors: ButtonIconColors = ButtonIconDefaults.buttonIconColors(), +) { + Material3IconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = colors.toMaterial3Colors(), + ) { + Material3Icon( + modifier = Modifier.size(MainTheme.sizes.icon), + imageVector = imageVector, + contentDescription = contentDescription, + ) + } +} + +object ButtonIconDefaults { + private const val DISABLED_ICON_OPACITY = 0.38f + + @Composable + fun buttonIconColors(): ButtonIconColors = Material3IconButtonDefaults.iconButtonColors().toButtonIconColors() + + /** + * Creates a [ButtonIconColors] that represents the default colors used in a [ButtonIcon]. + * + * @param containerColor the container color of this icon button when enabled. + * @param contentColor the content color of this icon button when enabled. + * @param disabledContainerColor the container color of this icon button when not enabled. + * @param disabledContentColor the content color of this icon button when not enabled. + */ + @Composable + fun buttonIconColors( + containerColor: Color = Color.Unspecified, + contentColor: Color = LocalContentColor.current, + disabledContainerColor: Color = Color.Unspecified, + disabledContentColor: Color = contentColor.copy(alpha = DISABLED_ICON_OPACITY), + ): ButtonIconColors = Material3IconButtonDefaults.iconButtonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ).toButtonIconColors() + + /** + * Creates a [ButtonIconColors] that represents the default colors used in a [ButtonIcon]. + */ + @Composable + fun buttonIconFilledColors(): ButtonIconColors = + Material3IconButtonDefaults.filledIconButtonColors().toButtonIconColors() + + /** + * Creates a [ButtonIconColors] that represents the default colors used in a [ButtonIcon]. + * + * @param containerColor the container color of this icon button when enabled. + * @param contentColor the content color of this icon button when enabled. + * @param disabledContainerColor the container color of this icon button when not enabled. + * @param disabledContentColor the content color of this icon button when not enabled. + */ + @Composable + fun buttonIconFilledColors( + containerColor: Color = Color.Unspecified, + contentColor: Color = contentColorFor(containerColor), + disabledContainerColor: Color = Color.Unspecified, + disabledContentColor: Color = Color.Unspecified, + ): ButtonIconColors = Material3IconButtonDefaults.filledIconButtonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ).toButtonIconColors() +} + +/** + * Represents the container and content colors used in an icon button in different states. + * + * @param containerColor the container color of this icon button when enabled. + * @param contentColor the content color of this icon button when enabled. + * @param disabledContainerColor the container color of this icon button when not enabled. + * @param disabledContentColor the content color of this icon button when not enabled. + * @constructor create an instance with arbitrary colors. + */ +data class ButtonIconColors( + val containerColor: Color, + val contentColor: Color, + val disabledContainerColor: Color, + val disabledContentColor: Color, +) + +internal fun ButtonIconColors.toMaterial3Colors(): Material3IconButtonColors = Material3IconButtonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, +) + +internal fun Material3IconButtonColors.toButtonIconColors(): ButtonIconColors = ButtonIconColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, +) diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonOutlined.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonOutlined.kt new file mode 100644 index 0000000..1ca3222 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonOutlined.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.material3.OutlinedButton as Material3OutlinedButton +import androidx.compose.material3.Text as Material3Text + +@Composable +fun ButtonOutlined( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Material3OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) { + Material3Text( + text = text, + textAlign = TextAlign.Center, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonSegmentedSingleChoice.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonSegmentedSingleChoice.kt new file mode 100644 index 0000000..017c9f2 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonSegmentedSingleChoice.kt @@ -0,0 +1,53 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import kotlinx.collections.immutable.ImmutableList + +/** + * A segmented button group that allows the user to select a single option from a list of options. + * + * @param onClick The callback to be invoked when an option is clicked. + * @param options The list of options to be displayed. + * @param optionTitle A function that returns the title of an option. + * @param modifier The [Modifier] to be applied to the segmented button group. + * @param selectedOption The currently selected option. If null, no option is selected. + */ +@Composable +fun ButtonSegmentedSingleChoice( + onClick: (T) -> Unit, + options: ImmutableList, + optionTitle: (T) -> String, + modifier: Modifier = Modifier, + selectedOption: T? = null, +) { + if (options.isEmpty()) { + return + } + + SingleChoiceSegmentedButtonRow( + modifier = modifier, + ) { + options.forEachIndexed { index, option -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = options.size, + ), + onClick = { + onClick(option) + }, + selected = option == selectedOption, + label = { + TextLabelLarge( + text = optionTitle(option), + ) + }, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonText.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonText.kt new file mode 100644 index 0000000..2ada79c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/ButtonText.kt @@ -0,0 +1,35 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text +import androidx.compose.material3.TextButton as Material3TextButton + +@Composable +fun ButtonText( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + color: Color? = null, + leadingIcon: (@Composable () -> Unit)? = null, +) { + Material3TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.textButtonColors( + contentColor = color ?: MainTheme.colors.primary, + ), + ) { + leadingIcon?.invoke() + Material3Text( + text = text, + textAlign = TextAlign.Center, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/RadioButton.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/RadioButton.kt new file mode 100644 index 0000000..a31c075 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/button/RadioButton.kt @@ -0,0 +1,53 @@ +package app.k9mail.core.ui.compose.designsystem.atom.button + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.RadioButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge + +@Composable +fun RadioButton( + selected: Boolean, + label: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .selectable( + selected = selected, + role = Role.RadioButton, + onClick = onClick, + ), + ) { + RadioButton( + selected = selected, + onClick = onClick, + enabled = enabled, + ) + label() + } +} + +@Composable +fun RadioButton( + selected: Boolean, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + RadioButton( + selected = selected, + label = { TextLabelLarge(label) }, + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardColors.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardColors.kt new file mode 100644 index 0000000..c99b814 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardColors.kt @@ -0,0 +1,43 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.ui.graphics.Color +import androidx.compose.material3.CardColors as Material3CardColors + +/** + * Represents the colors used by a card. + * + * See [CardDefaults.cardColors], [CardDefaults.outlinedCardColors], and [CardDefaults.elevatedCardColors]. + * + * @property containerColor The color used for the background of this card. + * @property contentColor The preferred color for content inside this card. + * @property disabledContainerColor The color used for the background of this card when it is not enabled. + * @property disabledContentColor The preferred color for content inside this card when it is not enabled. + */ +data class CardColors( + val containerColor: Color, + val contentColor: Color, + val disabledContainerColor: Color, + val disabledContentColor: Color, +) + +/** + * Converts a [Material3CardColors] to a [CardColors]. + */ +internal fun Material3CardColors.toCardColors(): CardColors = + CardColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ) + +/** + * Converts a [CardColors] to a Material 3 [Material3CardColors]. + */ +internal fun CardColors.toMaterial3CardColors(): Material3CardColors = + Material3CardColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ) diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardDefaults.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardDefaults.kt new file mode 100644 index 0000000..312ff5d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardDefaults.kt @@ -0,0 +1,213 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.material3.Card +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.CardDefaults as Material3CardDefaults + +/** + * Contains the default values used by all card types. + */ +object CardDefaults { + // shape Defaults + /** Default shape for a card. */ + val shape: Shape + @Composable get() = Material3CardDefaults.shape + + /** Default shape for an elevated card. */ + val elevatedShape: Shape + @Composable get() = Material3CardDefaults.elevatedShape + + /** Default shape for an outlined card. */ + val outlinedShape: Shape + @Composable get() = Material3CardDefaults.outlinedShape + + internal const val DISABLED_ALPHA = 0.38f + + /** + * Creates a [CardElevation] that will animate between the provided values according to the + * Material specification for a [Card]. + * + * @param defaultElevation the elevation used when the [Card] is has no other [Interaction]s. + * @param pressedElevation the elevation used when the [Card] is pressed. + * @param focusedElevation the elevation used when the [Card] is focused. + * @param hoveredElevation the elevation used when the [Card] is hovered. + * @param draggedElevation the elevation used when the [Card] is dragged. + */ + @Composable + fun cardElevation( + defaultElevation: Dp = MainTheme.elevations.level0, + pressedElevation: Dp = MainTheme.elevations.level0, + focusedElevation: Dp = MainTheme.elevations.level0, + hoveredElevation: Dp = MainTheme.elevations.level1, + draggedElevation: Dp = MainTheme.elevations.level3, + disabledElevation: Dp = MainTheme.elevations.level0, + ): CardElevation = CardElevation.FilledCardElevation( + defaultElevation = defaultElevation, + pressedElevation = pressedElevation, + focusedElevation = focusedElevation, + hoveredElevation = hoveredElevation, + draggedElevation = draggedElevation, + disabledElevation = disabledElevation, + ) + + /** + * Creates a [CardElevation] that will animate between the provided values according to the + * Material specification for an [ElevatedCard]. + * + * @param defaultElevation the elevation used when the [ElevatedCard] is has no other + * [Interaction]s. + * @param pressedElevation the elevation used when the [ElevatedCard] is pressed. + * @param focusedElevation the elevation used when the [ElevatedCard] is focused. + * @param hoveredElevation the elevation used when the [ElevatedCard] is hovered. + * @param draggedElevation the elevation used when the [ElevatedCard] is dragged. + */ + @Composable + fun elevatedCardElevation( + defaultElevation: Dp = MainTheme.elevations.level1, + pressedElevation: Dp = MainTheme.elevations.level1, + focusedElevation: Dp = MainTheme.elevations.level1, + hoveredElevation: Dp = MainTheme.elevations.level2, + draggedElevation: Dp = MainTheme.elevations.level4, + disabledElevation: Dp = MainTheme.elevations.level1, + ): CardElevation = CardElevation.ElevatedCardElevation( + defaultElevation = defaultElevation, + pressedElevation = pressedElevation, + focusedElevation = focusedElevation, + hoveredElevation = hoveredElevation, + draggedElevation = draggedElevation, + disabledElevation = disabledElevation, + ) + + /** + * Creates a [CardElevation] that will animate between the provided values according to the + * Material specification for an [OutlinedCard]. + * + * @param defaultElevation the elevation used when the [OutlinedCard] is has no other + * [Interaction]s. + * @param pressedElevation the elevation used when the [OutlinedCard] is pressed. + * @param focusedElevation the elevation used when the [OutlinedCard] is focused. + * @param hoveredElevation the elevation used when the [OutlinedCard] is hovered. + * @param draggedElevation the elevation used when the [OutlinedCard] is dragged. + */ + @Composable + fun outlinedCardElevation( + defaultElevation: Dp = MainTheme.elevations.level0, + pressedElevation: Dp = defaultElevation, + focusedElevation: Dp = defaultElevation, + hoveredElevation: Dp = defaultElevation, + draggedElevation: Dp = MainTheme.elevations.level3, + disabledElevation: Dp = MainTheme.elevations.level0, + ): CardElevation = CardElevation.OutlinedCardElevation( + defaultElevation = defaultElevation, + pressedElevation = pressedElevation, + focusedElevation = focusedElevation, + hoveredElevation = hoveredElevation, + draggedElevation = draggedElevation, + disabledElevation = disabledElevation, + ) + + /** + * Creates a [CardColors] that represents the default container and content colors used in a + * [Card]. + */ + @Composable + fun cardColors(): CardColors = Material3CardDefaults.cardColors().toCardColors() + + /** + * Creates a [CardColors] that represents the default container and content colors used in a + * [Card]. + * + * @param containerColor the container color of this [Card] when enabled. + * @param contentColor the content color of this [Card] when enabled. + * @param disabledContainerColor the container color of this [Card] when not enabled. + * @param disabledContentColor the content color of this [Card] when not enabled. + */ + @Composable + fun cardColors( + containerColor: Color = Color.Unspecified, + contentColor: Color = contentColorFor(backgroundColor = containerColor), + disabledContainerColor: Color = Color.Unspecified, + disabledContentColor: Color = contentColor.copy(alpha = DISABLED_ALPHA), + ): CardColors = Material3CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ).toCardColors() + + /** + * Creates a [CardColors] that represents the default container and content colors used in an + * [ElevatedCard]. + */ + @Composable + fun elevatedCardColors(): CardColors = Material3CardDefaults.elevatedCardColors().toCardColors() + + /** + * Creates a [CardColors] that represents the default container and content colors used in an + * [ElevatedCard]. + * + * @param containerColor the container color of this [ElevatedCard] when enabled. + * @param contentColor the content color of this [ElevatedCard] when enabled. + * @param disabledContainerColor the container color of this [ElevatedCard] when not enabled. + * @param disabledContentColor the content color of this [ElevatedCard] when not enabled. + */ + @Composable + fun elevatedCardColors( + containerColor: Color = Color.Unspecified, + contentColor: Color = contentColorFor(backgroundColor = containerColor), + disabledContainerColor: Color = Color.Unspecified, + disabledContentColor: Color = contentColor.copy(alpha = DISABLED_ALPHA), + ): CardColors = Material3CardDefaults.elevatedCardColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ).toCardColors() + + /** + * Creates a [CardColors] that represents the default container and content colors used in an + * [OutlinedCard]. + */ + @Composable + fun outlinedCardColors(): CardColors = Material3CardDefaults.outlinedCardColors().toCardColors() + + /** + * Creates a [CardColors] that represents the default container and content colors used in an + * [OutlinedCard]. + * + * @param containerColor the container color of this [OutlinedCard] when enabled. + * @param contentColor the content color of this [OutlinedCard] when enabled. + * @param disabledContainerColor the container color of this [OutlinedCard] when not enabled. + * @param disabledContentColor the content color of this [OutlinedCard] when not enabled. + */ + @Composable + fun outlinedCardColors( + containerColor: Color = Color.Unspecified, + contentColor: Color = contentColorFor(backgroundColor = containerColor), + disabledContainerColor: Color = Color.Unspecified, + disabledContentColor: Color = contentColorFor(backgroundColor = containerColor).copy(alpha = DISABLED_ALPHA), + ): CardColors = Material3CardDefaults.outlinedCardColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + ).toCardColors() + + /** + * Creates a [BorderStroke] that represents the default border used in [OutlinedCard]. + * + * @param enabled whether the card is enabled + */ + @Composable + fun outlinedCardBorder(enabled: Boolean = true): BorderStroke = + Material3CardDefaults.outlinedCardBorder(enabled) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevated.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevated.kt new file mode 100644 index 0000000..c2e44aa --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevated.kt @@ -0,0 +1,36 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.material3.ElevatedCard as Material3ElevatedCard + +@Composable +fun CardElevated( + modifier: Modifier = Modifier, + shape: Shape = CardDefaults.elevatedShape, + colors: CardColors = CardDefaults.elevatedCardColors(), + elevation: CardElevation = CardDefaults.elevatedCardElevation(), + onClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + if (onClick != null) { + Material3ElevatedCard( + onClick = onClick, + modifier = modifier, + shape = shape, + colors = colors.toMaterial3CardColors(), + elevation = elevation.toMaterial3CardElevation(), + content = content, + ) + } else { + Material3ElevatedCard( + modifier = modifier, + shape = shape, + colors = colors.toMaterial3CardColors(), + elevation = elevation.toMaterial3CardElevation(), + content = content, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevation.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevation.kt new file mode 100644 index 0000000..7b47d91 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardElevation.kt @@ -0,0 +1,103 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.material3.CardDefaults as Material3CardDefaults +import androidx.compose.material3.CardElevation as Material3CardElevation + +/** + * Represents the elevation for a card in different states. + * + * This sealed interface defines the elevation properties for various interaction states of a card component. + * It also provides a composable function to convert these elevations into a [Material3CardElevation] object. + * + * Different card types (Filled, Elevated, Outlined) will have their own specific implementations of this interface. + * + * - See [CardDefaults.cardElevation] for the default elevation used in a [CardFilled]. + * - See [CardDefaults.elevatedCardElevation] for the default elevation used in an [CardElevated]. + * - See [CardDefaults.outlinedCardElevation] for the default elevation used in an [CardOutlined]. + * + * @property defaultElevation The elevation used by default. + * @property pressedElevation The elevation used when the card is pressed. + * @property focusedElevation The elevation used when the card is focused. + * @property hoveredElevation The elevation used when the card is hovered. + * @property draggedElevation The elevation used when the card is dragged. + * @property disabledElevation The elevation used when the card is disabled. + */ +sealed interface CardElevation { + val defaultElevation: Dp + val pressedElevation: Dp + val focusedElevation: Dp + val hoveredElevation: Dp + val draggedElevation: Dp + val disabledElevation: Dp + + /** + * Converts this [CardElevation] to a Material 3 [Material3CardElevation]. + */ + @Composable + fun toMaterial3CardElevation(): Material3CardElevation + + @ConsistentCopyVisibility + data class FilledCardElevation internal constructor( + override val defaultElevation: Dp, + override val pressedElevation: Dp, + override val focusedElevation: Dp, + override val hoveredElevation: Dp, + override val draggedElevation: Dp, + override val disabledElevation: Dp, + ) : CardElevation { + @Composable + override fun toMaterial3CardElevation(): Material3CardElevation = + Material3CardDefaults.cardElevation( + defaultElevation = defaultElevation, + pressedElevation = pressedElevation, + focusedElevation = focusedElevation, + hoveredElevation = hoveredElevation, + draggedElevation = draggedElevation, + disabledElevation = disabledElevation, + ) + } + + @ConsistentCopyVisibility + data class ElevatedCardElevation internal constructor( + override val defaultElevation: Dp, + override val pressedElevation: Dp, + override val focusedElevation: Dp, + override val hoveredElevation: Dp, + override val draggedElevation: Dp, + override val disabledElevation: Dp, + ) : CardElevation { + @Composable + override fun toMaterial3CardElevation(): Material3CardElevation = + Material3CardDefaults.elevatedCardElevation( + defaultElevation = defaultElevation, + pressedElevation = pressedElevation, + focusedElevation = focusedElevation, + hoveredElevation = hoveredElevation, + draggedElevation = draggedElevation, + disabledElevation = disabledElevation, + ) + } + + @ConsistentCopyVisibility + data class OutlinedCardElevation internal constructor( + override val defaultElevation: Dp, + override val pressedElevation: Dp, + override val focusedElevation: Dp, + override val hoveredElevation: Dp, + override val draggedElevation: Dp, + override val disabledElevation: Dp, + ) : CardElevation { + @Composable + override fun toMaterial3CardElevation(): Material3CardElevation = + Material3CardDefaults.outlinedCardElevation( + defaultElevation = defaultElevation, + pressedElevation = pressedElevation, + focusedElevation = focusedElevation, + hoveredElevation = hoveredElevation, + draggedElevation = draggedElevation, + disabledElevation = disabledElevation, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardFilled.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardFilled.kt new file mode 100644 index 0000000..a37a102 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardFilled.kt @@ -0,0 +1,36 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.material3.Card as Material3Card + +@Composable +fun CardFilled( + modifier: Modifier = Modifier, + shape: Shape = CardDefaults.shape, + colors: CardColors = CardDefaults.cardColors(), + elevation: CardElevation = CardDefaults.cardElevation(), + onClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + if (onClick != null) { + Material3Card( + onClick = onClick, + modifier = modifier, + shape = shape, + colors = colors.toMaterial3CardColors(), + elevation = elevation.toMaterial3CardElevation(), + content = content, + ) + } else { + Material3Card( + modifier = modifier, + shape = shape, + colors = colors.toMaterial3CardColors(), + elevation = elevation.toMaterial3CardElevation(), + content = content, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardOutlined.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardOutlined.kt new file mode 100644 index 0000000..6833e1a --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/card/CardOutlined.kt @@ -0,0 +1,40 @@ +package app.k9mail.core.ui.compose.designsystem.atom.card + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.OutlinedCard +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape + +@Composable +fun CardOutlined( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + shape: Shape = CardDefaults.outlinedShape, + colors: CardColors = CardDefaults.outlinedCardColors(), + elevation: CardElevation = CardDefaults.outlinedCardElevation(), + border: BorderStroke = CardDefaults.outlinedCardBorder(), + content: @Composable ColumnScope.() -> Unit, +) { + if (onClick != null) { + OutlinedCard( + onClick = onClick, + modifier = modifier, + shape = shape, + colors = colors.toMaterial3CardColors(), + elevation = elevation.toMaterial3CardElevation(), + border = border, + content = content, + ) + } else { + OutlinedCard( + modifier = modifier, + shape = shape, + colors = colors.toMaterial3CardColors(), + elevation = elevation.toMaterial3CardElevation(), + border = border, + content = content, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icon.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icon.kt new file mode 100644 index 0000000..e08bded --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icon.kt @@ -0,0 +1,23 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.Icon as Material3Icon +import androidx.compose.material3.LocalContentColor as Material3LocalContentColor + +@Composable +fun Icon( + imageVector: ImageVector, + modifier: Modifier = Modifier, + contentDescription: String? = null, + tint: Color? = null, +) { + Material3Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = modifier, + tint = tint ?: Material3LocalContentColor.current, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt new file mode 100644 index 0000000..4717174 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/Icons.kt @@ -0,0 +1,147 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon + +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Outbox +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.AllInbox +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ChevronLeft +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Drafts +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.ui.graphics.vector.ImageVector +import app.k9mail.core.ui.compose.designsystem.atom.icon.filled.Dot +import app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.FolderManaged +import androidx.compose.material.icons.Icons as MaterialIcons + +// We're using getters so not all icons are loaded into memory as soon as one of the nested objects is accessed. +object Icons { + object Filled { + val Cancel: ImageVector + get() = MaterialIcons.Filled.Cancel + + val CheckCircle: ImageVector + get() = MaterialIcons.Filled.CheckCircle + + val Dot: ImageVector + get() = MaterialIcons.Filled.Dot + } + + object Outlined { + val AccountCircle: ImageVector + get() = MaterialIcons.Outlined.AccountCircle + + val Add: ImageVector + get() = MaterialIcons.Outlined.Add + + val AllInbox: ImageVector + get() = MaterialIcons.Outlined.AllInbox + + val Archive: ImageVector + get() = MaterialIcons.Outlined.Archive + + val Attachment: ImageVector + get() = MaterialIcons.Outlined.Attachment + + val ArrowBack: ImageVector + get() = MaterialIcons.AutoMirrored.Outlined.ArrowBack + + val KeyboardArrowDown: ImageVector + get() = MaterialIcons.Outlined.KeyboardArrowDown + + val KeyboardArrowUp: ImageVector + get() = MaterialIcons.Outlined.KeyboardArrowUp + + val Check: ImageVector + get() = MaterialIcons.Outlined.Check + + val CheckCircle: ImageVector + get() = MaterialIcons.Outlined.CheckCircle + + val ChevronLeft: ImageVector + get() = MaterialIcons.Outlined.ChevronLeft + + val ChevronRight: ImageVector + get() = MaterialIcons.Outlined.ChevronRight + + val Close: ImageVector + get() = MaterialIcons.Outlined.Close + + val Delete: ImageVector + get() = MaterialIcons.Outlined.Delete + + val Drafts: ImageVector + get() = MaterialIcons.Outlined.Drafts + + val ErrorOutline: ImageVector + get() = MaterialIcons.Outlined.ErrorOutline + + val ExpandMore: ImageVector + get() = MaterialIcons.Outlined.ExpandMore + + val ExpandLess: ImageVector + get() = MaterialIcons.Outlined.ExpandLess + + val Folder: ImageVector + get() = MaterialIcons.Outlined.Folder + + val Inbox: ImageVector + get() = MaterialIcons.Outlined.Inbox + + val Info: ImageVector + get() = MaterialIcons.Outlined.Info + + val FolderManaged: ImageVector + get() = MaterialIcons.Outlined.FolderManaged + + val Menu: ImageVector + get() = MaterialIcons.Outlined.Menu + + val Outbox: ImageVector + get() = MaterialIcons.Filled.Outbox + + val Security: ImageVector + get() = MaterialIcons.Outlined.Security + + val Send: ImageVector + get() = MaterialIcons.AutoMirrored.Outlined.Send + + val Settings: ImageVector + get() = MaterialIcons.Outlined.Settings + + val Sync: ImageVector + get() = MaterialIcons.Outlined.Sync + + val Report: ImageVector + get() = MaterialIcons.Outlined.Report + + val Visibility: ImageVector + get() = MaterialIcons.Outlined.Visibility + + val VisibilityOff: ImageVector + get() = MaterialIcons.Filled.VisibilityOff + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconsWithBaseline.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconsWithBaseline.kt new file mode 100644 index 0000000..f7fed16 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconsWithBaseline.kt @@ -0,0 +1,16 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon + +import androidx.compose.material.icons.filled.Warning +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.common.image.ImageWithBaseline +import androidx.compose.material.icons.Icons as MaterialIcons + +// We're using "by lazy" so not all icons are loaded into memory as soon as a nested object is accessed. But once a +// property is accessed we want to retain the `ImageWithBaseline` instance. +object IconsWithBaseline { + object Filled { + val warning: ImageWithBaseline by lazy { + ImageWithBaseline(image = MaterialIcons.Filled.Warning, baseline = 21.dp) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconsWithBottomRightOverlay.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconsWithBottomRightOverlay.kt new file mode 100644 index 0000000..af0b487 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/IconsWithBottomRightOverlay.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon + +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.common.image.ImageWithOverlayCoordinate +import androidx.compose.material.icons.Icons as MaterialIcons + +// We're using "by lazy" so not all icons are loaded into memory as soon as the object is accessed. But once a property +// is accessed we want to retain the `ImageWithOverlayCoordinate` instance. +object IconsWithBottomRightOverlay { + val person: ImageWithOverlayCoordinate by lazy { + ImageWithOverlayCoordinate( + image = MaterialIcons.Filled.Person, + overlayOffsetX = 24.dp, + overlayOffsetY = 20.dp, + ) + } + + val notification: ImageWithOverlayCoordinate by lazy { + ImageWithOverlayCoordinate( + image = MaterialIcons.Filled.Notifications, + overlayOffsetX = 23.dp, + overlayOffsetY = 19.dp, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/filled/Dot.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/filled/Dot.kt new file mode 100644 index 0000000..cc85e7b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/filled/Dot.kt @@ -0,0 +1,27 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon.filled + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +@Suppress("MagicNumber") +val Icons.Filled.Dot: ImageVector + get() { + if (instance != null) { + return instance!! + } + instance = materialIcon(name = "Filled.Dot") { + materialPath { + moveTo(12.0f, 6.0f) + curveToRelative(-3.31f, 0.0f, -6.0f, 2.69f, -6.0f, 6.0f) + reflectiveCurveToRelative(2.69f, 6.0f, 6.0f, 6.0f) + reflectiveCurveToRelative(6.0f, -2.69f, 6.0f, -6.0f) + reflectiveCurveToRelative(-2.69f, -6.0f, -6.0f, -6.0f) + close() + } + } + return instance!! + } + +private var instance: ImageVector? = null diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/FolderManaged.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/FolderManaged.kt new file mode 100644 index 0000000..ce930e7 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/FolderManaged.kt @@ -0,0 +1,134 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon.outlined + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +@Suppress("MagicNumber") +val Icons.Outlined.FolderManaged: ImageVector + get() { + if (instance != null) { + return instance!! + } + instance = ImageVector.Builder( + name = "Outlined.FolderManaged", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(680f, 880f) + lineTo(668f, 820f) + quadTo(656f, 815f, 645.5f, 809.5f) + quadTo(635f, 804f, 624f, 796f) + lineTo(566f, 814f) + lineTo(526f, 746f) + lineTo(572f, 706f) + quadTo(570f, 694f, 570f, 680f) + quadTo(570f, 666f, 572f, 654f) + lineTo(526f, 614f) + lineTo(566f, 546f) + lineTo(624f, 564f) + quadTo(635f, 556f, 645.5f, 550.5f) + quadTo(656f, 545f, 668f, 540f) + lineTo(680f, 480f) + lineTo(760f, 480f) + lineTo(772f, 540f) + quadTo(784f, 545f, 794.5f, 550.5f) + quadTo(805f, 556f, 816f, 564f) + lineTo(874f, 546f) + lineTo(914f, 614f) + lineTo(868f, 654f) + quadTo(870f, 666f, 870f, 680f) + quadTo(870f, 694f, 868f, 706f) + lineTo(914f, 746f) + lineTo(874f, 814f) + lineTo(816f, 796f) + quadTo(805f, 804f, 794.5f, 809.5f) + quadTo(784f, 815f, 772f, 820f) + lineTo(760f, 880f) + lineTo(680f, 880f) + close() + moveTo(720f, 760f) + quadTo(753f, 760f, 776.5f, 736.5f) + quadTo(800f, 713f, 800f, 680f) + quadTo(800f, 647f, 776.5f, 623.5f) + quadTo(753f, 600f, 720f, 600f) + quadTo(687f, 600f, 663.5f, 623.5f) + quadTo(640f, 647f, 640f, 680f) + quadTo(640f, 713f, 663.5f, 736.5f) + quadTo(687f, 760f, 720f, 760f) + close() + moveTo(160f, 720f) + lineTo(160f, 720f) + quadTo(160f, 720f, 160f, 720f) + quadTo(160f, 720f, 160f, 720f) + lineTo(160f, 240f) + quadTo(160f, 240f, 160f, 240f) + quadTo(160f, 240f, 160f, 240f) + lineTo(160f, 240f) + lineTo(160f, 320f) + lineTo(160f, 320f) + quadTo(160f, 320f, 160f, 320f) + quadTo(160f, 320f, 160f, 320f) + lineTo(160f, 412f) + quadTo(160f, 406f, 160f, 403f) + quadTo(160f, 400f, 160f, 400f) + quadTo(160f, 400f, 160f, 482.5f) + quadTo(160f, 565f, 160f, 679f) + quadTo(160f, 690f, 160f, 699.5f) + quadTo(160f, 709f, 160f, 720f) + close() + moveTo(160f, 800f) + quadTo(127f, 800f, 103.5f, 776.5f) + quadTo(80f, 753f, 80f, 720f) + lineTo(80f, 240f) + quadTo(80f, 207f, 103.5f, 183.5f) + quadTo(127f, 160f, 160f, 160f) + lineTo(400f, 160f) + lineTo(480f, 240f) + lineTo(800f, 240f) + quadTo(833f, 240f, 856.5f, 263.5f) + quadTo(880f, 287f, 880f, 320f) + lineTo(880f, 451f) + quadTo(862f, 438f, 842f, 428.5f) + quadTo(822f, 419f, 800f, 412f) + lineTo(800f, 320f) + quadTo(800f, 320f, 800f, 320f) + quadTo(800f, 320f, 800f, 320f) + lineTo(447f, 320f) + lineTo(367f, 240f) + lineTo(160f, 240f) + quadTo(160f, 240f, 160f, 240f) + quadTo(160f, 240f, 160f, 240f) + lineTo(160f, 720f) + quadTo(160f, 720f, 160f, 720f) + quadTo(160f, 720f, 160f, 720f) + lineTo(443f, 720f) + quadTo(446f, 741f, 452.5f, 761f) + quadTo(459f, 781f, 468f, 800f) + lineTo(160f, 800f) + close() + } + }.build() + return instance!! + } + +private var instance: ImageVector? = null diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/OpenInNew.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/OpenInNew.kt new file mode 100644 index 0000000..da6283c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/OpenInNew.kt @@ -0,0 +1,60 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon.outlined + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Suppress("MagicNumber", "UnusedReceiverParameter") +internal val Icons.Outlined.OpenInNew: ImageVector + get() { + val current = _openInNew + if (current != null) return current + + return ImageVector.Builder( + name = "app.k9mail.core.ui.compose.theme2.MainTheme.OpenInNew", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), + ) { + moveTo(x = 5.0f, y = 21.0f) + curveTo(x1 = 4.45f, y1 = 21.0f, x2 = 3.97917f, y2 = 20.8042f, x3 = 3.5875f, y3 = 20.4125f) + curveTo(x1 = 3.19583f, y1 = 20.0208f, x2 = 3.0f, y2 = 19.55f, x3 = 3.0f, y3 = 19.0f) + verticalLineTo(y = 5.0f) + curveTo(x1 = 3.0f, y1 = 4.45f, x2 = 3.19583f, y2 = 3.97917f, x3 = 3.5875f, y3 = 3.5875f) + curveTo(x1 = 3.97917f, y1 = 3.19583f, x2 = 4.45f, y2 = 3.0f, x3 = 5.0f, y3 = 3.0f) + horizontalLineTo(x = 12.0f) + verticalLineTo(y = 5.0f) + horizontalLineTo(x = 5.0f) + verticalLineTo(y = 19.0f) + horizontalLineTo(x = 19.0f) + verticalLineTo(y = 12.0f) + horizontalLineTo(x = 21.0f) + verticalLineTo(y = 19.0f) + curveTo(x1 = 21.0f, y1 = 19.55f, x2 = 20.8042f, y2 = 20.0208f, x3 = 20.4125f, y3 = 20.4125f) + curveTo(x1 = 20.0208f, y1 = 20.8042f, x2 = 19.55f, y2 = 21.0f, x3 = 19.0f, y3 = 21.0f) + horizontalLineTo(x = 5.0f) + close() + moveTo(x = 9.7f, y = 15.7f) + lineTo(x = 8.3f, y = 14.3f) + lineTo(x = 17.6f, y = 5.0f) + horizontalLineTo(x = 14.0f) + verticalLineTo(y = 3.0f) + horizontalLineTo(x = 21.0f) + verticalLineTo(y = 10.0f) + horizontalLineTo(x = 19.0f) + verticalLineTo(y = 6.4f) + lineTo(x = 9.7f, y = 15.7f) + close() + } + }.build().also { _openInNew = it } + } + +@Suppress("ObjectPropertyName") +private var _openInNew: ImageVector? = null diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/Warning.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/Warning.kt new file mode 100644 index 0000000..83e3e71 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/icon/outlined/Warning.kt @@ -0,0 +1,62 @@ +package app.k9mail.core.ui.compose.designsystem.atom.icon.outlined + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Suppress("MagicNumber", "UnusedReceiverParameter") +val Icons.Outlined.Warning: ImageVector + get() { + val current = _warning + if (current != null) return current + + return ImageVector.Builder( + name = "app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.Warning", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 960.0f, + viewportHeight = 960.0f, + ).apply { + path( + fill = SolidColor(Color(0xFFFFFFFF)), + ) { + moveTo(x = 40.0f, y = 840.0f) + lineTo(x = 480.0f, y = 80.0f) + lineTo(x = 920.0f, y = 840.0f) + lineTo(x = 40.0f, y = 840.0f) + close() + moveTo(x = 178.0f, y = 760.0f) + lineTo(x = 782.0f, y = 760.0f) + lineTo(x = 480.0f, y = 240.0f) + lineTo(x = 178.0f, y = 760.0f) + close() + moveTo(x = 480.0f, y = 720.0f) + quadTo(x1 = 497.0f, y1 = 720.0f, x2 = 508.5f, y2 = 708.5f) + quadTo(x1 = 520.0f, y1 = 697.0f, x2 = 520.0f, y2 = 680.0f) + quadTo(x1 = 520.0f, y1 = 663.0f, x2 = 508.5f, y2 = 651.5f) + quadTo(x1 = 497.0f, y1 = 640.0f, x2 = 480.0f, y2 = 640.0f) + quadTo(x1 = 463.0f, y1 = 640.0f, x2 = 451.5f, y2 = 651.5f) + quadTo(x1 = 440.0f, y1 = 663.0f, x2 = 440.0f, y2 = 680.0f) + quadTo(x1 = 440.0f, y1 = 697.0f, x2 = 451.5f, y2 = 708.5f) + quadTo(x1 = 463.0f, y1 = 720.0f, x2 = 480.0f, y2 = 720.0f) + close() + moveTo(x = 440.0f, y = 600.0f) + lineTo(x = 520.0f, y = 600.0f) + lineTo(x = 520.0f, y = 400.0f) + lineTo(x = 440.0f, y = 400.0f) + lineTo(x = 440.0f, y = 600.0f) + close() + moveTo(x = 480.0f, y = 500.0f) + lineTo(x = 480.0f, y = 500.0f) + lineTo(x = 480.0f, y = 500.0f) + lineTo(x = 480.0f, y = 500.0f) + close() + } + }.build().also { _warning = it } + } + +@Suppress("ObjectPropertyName") +private var _warning: ImageVector? = null diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/FixedScaleImage.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/FixedScaleImage.kt new file mode 100644 index 0000000..5325cd7 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/FixedScaleImage.kt @@ -0,0 +1,62 @@ +package app.k9mail.core.ui.compose.designsystem.atom.image + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.FixedScale +import androidx.compose.ui.res.painterResource + +/** + * An image that has a fixed size and does not scale with the available space. It could be cropped, if the size of the + * container is smaller than the image. Use allowOverflow to control this behavior. + * The [alignment] allows to control the position of the image in the container. + */ +@Composable +fun FixedScaleImage( + @DrawableRes id: Int, + modifier: Modifier = Modifier, + scale: Float = 1f, + alignment: Alignment = Alignment.Center, + allowOverflow: Boolean = false, + contentDescription: String? = null, +) { + Image( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(align = alignment, unbounded = allowOverflow) + .then(modifier), + painter = painterResource(id), + contentDescription = contentDescription, + contentScale = FixedScale(scale), + ) +} + +/** + * An image that has a fixed size and does not scale with the available space. It could be cropped, if the size of the + * container is smaller than the image. Use allowOverflow to control this behavior. + * The [alignment] allows to control the position of the image in the container. + */ +@Composable +fun FixedScaleImage( + imageVector: ImageVector, + modifier: Modifier = Modifier, + scale: Float = 1f, + alignment: Alignment = Alignment.Center, + allowOverflow: Boolean = false, + contentDescription: String? = null, +) { + Image( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(align = alignment, unbounded = allowOverflow) + .then(modifier), + imageVector = imageVector, + contentDescription = contentDescription, + contentScale = FixedScale(scale), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/RemoteImage.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/RemoteImage.kt new file mode 100644 index 0000000..03a96fa --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/image/RemoteImage.kt @@ -0,0 +1,37 @@ +package app.k9mail.core.ui.compose.designsystem.atom.image + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil3.CoilImage + +@Composable +fun RemoteImage( + url: String, + modifier: Modifier = Modifier, + placeholder: @Composable (() -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Crop, + previewPlaceholder: Painter? = null, +) { + CoilImage( + imageModel = { url }, + imageOptions = ImageOptions( + alignment = alignment, + contentDescription = contentDescription, + contentScale = contentScale, + ), + failure = { + placeholder?.invoke() + }, + loading = { + placeholder?.invoke() + }, + modifier = modifier, + previewPlaceholder = previewPlaceholder, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyLarge.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyLarge.kt new file mode 100644 index 0000000..1f60b25 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyLarge.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextBodyLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.bodyLarge, + ) +} + +@Composable +fun TextBodyLarge( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.bodyLarge, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyMedium.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyMedium.kt new file mode 100644 index 0000000..e7bbc1b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodyMedium.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextBodyMedium( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.bodyMedium, + ) +} + +@Composable +fun TextBodyMedium( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.bodyMedium, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodySmall.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodySmall.kt new file mode 100644 index 0000000..9dda4cb --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextBodySmall.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextBodySmall( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.bodySmall, + ) +} + +@Composable +fun TextBodySmall( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.bodySmall, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayLarge.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayLarge.kt new file mode 100644 index 0000000..1f226ba --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayLarge.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextDisplayLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.displayLarge, + ) +} + +@Composable +fun TextDisplayLarge( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.displayLarge, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMedium.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMedium.kt new file mode 100644 index 0000000..9071111 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMedium.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextDisplayMedium( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.displayMedium, + ) +} + +@Composable +fun TextDisplayMedium( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.displayMedium, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMediumAutoResize.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMediumAutoResize.kt new file mode 100644 index 0000000..7d53b5c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplayMediumAutoResize.kt @@ -0,0 +1,48 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextDisplayMediumAutoResize( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, +) { + val style: TextStyle = MainTheme.typography.displayMedium + var shouldDraw by remember { mutableStateOf(false) } + var resizedTextStyle by remember { mutableStateOf(style) } + + Material3Text( + text = text, + modifier = modifier.drawWithContent { + if (shouldDraw) { + drawContent() + } + }, + color = color, + textAlign = textAlign, + softWrap = false, + style = resizedTextStyle, + onTextLayout = { result -> + if (result.didOverflowWidth) { + resizedTextStyle = resizedTextStyle.copy( + fontSize = resizedTextStyle.fontSize * 0.95, + ) + } else { + shouldDraw = true + } + }, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplaySmall.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplaySmall.kt new file mode 100644 index 0000000..5b7ab14 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextDisplaySmall.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextDisplaySmall( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.displaySmall, + ) +} + +@Composable +fun TextDisplaySmall( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.displaySmall, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineLarge.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineLarge.kt new file mode 100644 index 0000000..72d8a37 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineLarge.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextHeadlineLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.headlineLarge, + ) +} + +@Composable +fun TextHeadlineLarge( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.headlineLarge, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineMedium.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineMedium.kt new file mode 100644 index 0000000..fbf2a5f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineMedium.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextHeadlineMedium( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.headlineMedium, + ) +} + +@Composable +fun TextHeadlineMedium( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.headlineMedium, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineSmall.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineSmall.kt new file mode 100644 index 0000000..53c35c9 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextHeadlineSmall.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextHeadlineSmall( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.headlineSmall, + ) +} + +@Composable +fun TextHeadlineSmall( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.headlineSmall, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelLarge.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelLarge.kt new file mode 100644 index 0000000..8b039b9 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelLarge.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextLabelLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.labelLarge, + ) +} + +@Composable +fun TextLabelLarge( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.labelLarge, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelMedium.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelMedium.kt new file mode 100644 index 0000000..4fbd545 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelMedium.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextLabelMedium( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.labelMedium, + ) +} + +@Composable +fun TextLabelMedium( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.labelMedium, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelSmall.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelSmall.kt new file mode 100644 index 0000000..7476fee --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextLabelSmall.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextLabelSmall( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.labelSmall, + ) +} + +@Composable +fun TextLabelSmall( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.labelSmall, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleLarge.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleLarge.kt new file mode 100644 index 0000000..7dd2d02 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleLarge.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextTitleLarge( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.titleLarge, + ) +} + +@Composable +fun TextTitleLarge( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.titleLarge, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleMedium.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleMedium.kt new file mode 100644 index 0000000..3a0613b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleMedium.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextTitleMedium( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.titleMedium, + ) +} + +@Composable +fun TextTitleMedium( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.titleMedium, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleSmall.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleSmall.kt new file mode 100644 index 0000000..a522430 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/text/TextTitleSmall.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextTitleSmall( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.titleSmall, + ) +} + +@Composable +fun TextTitleSmall( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, +) { + Material3Text( + text = text, + modifier = modifier, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + style = MainTheme.typography.titleSmall, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldCommon.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldCommon.kt new file mode 100644 index 0000000..e41e8c7 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldCommon.kt @@ -0,0 +1,29 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.TextFieldValue + +private val LINE_BREAK = "[\\r\\n]".toRegex() + +internal fun stripLineBreaks(onValueChange: (String) -> Unit): (String) -> Unit = { value -> + onValueChange(value.replace(LINE_BREAK, replacement = "")) +} + +internal fun stripTextFieldValueLineBreaks(onValueChange: (TextFieldValue) -> Unit): (TextFieldValue) -> Unit { + return { value -> + onValueChange(value.copy(text = value.text.replace(LINE_BREAK, replacement = ""))) + } +} + +internal fun selectLabel( + label: String?, + isRequired: Boolean, +): @Composable (() -> Unit)? { + return if (label != null || isRequired) { + { + TextFieldLabel(label.orEmpty(), isRequired) + } + } else { + null + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldLabel.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldLabel.kt new file mode 100644 index 0000000..fff213c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldLabel.kt @@ -0,0 +1,20 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.material3.Text as Material3Text + +private const val ASTERISK = "*" + +@Composable +internal fun TextFieldLabel( + label: String, + isRequired: Boolean, +) { + Material3Text( + text = if (isRequired) { + "$label$ASTERISK" + } else { + label + }, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlined.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlined.kt new file mode 100644 index 0000000..6b879df --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlined.kt @@ -0,0 +1,68 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.material3.OutlinedTextField as Material3OutlinedTextField + +@Suppress("LongParameterList") +@Composable +fun TextFieldOutlined( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + trailingIcon: @Composable (() -> Unit)? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, + isSingleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + Material3OutlinedTextField( + value = value, + onValueChange = if (isSingleLine) stripLineBreaks(onValueChange) else onValueChange, + modifier = modifier, + enabled = isEnabled, + label = selectLabel(label, isRequired), + trailingIcon = trailingIcon, + readOnly = isReadOnly, + isError = hasError, + singleLine = isSingleLine, + keyboardOptions = keyboardOptions, + ) +} + +/** + * Overload of [TextFieldOutlined] that accepts a [TextFieldValue] instead of a [String]. + */ +@Suppress("LongParameterList") +@Composable +fun TextFieldOutlined( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + trailingIcon: @Composable (() -> Unit)? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, + isSingleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + Material3OutlinedTextField( + value = value, + onValueChange = if (isSingleLine) stripTextFieldValueLineBreaks(onValueChange) else onValueChange, + modifier = modifier, + enabled = isEnabled, + label = selectLabel(label, isRequired), + trailingIcon = trailingIcon, + readOnly = isReadOnly, + isError = hasError, + singleLine = isSingleLine, + keyboardOptions = keyboardOptions, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddress.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddress.kt new file mode 100644 index 0000000..83adf42 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddress.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.material3.OutlinedTextField as Material3OutlinedTextField + +@Suppress("LongParameterList") +@Composable +fun TextFieldOutlinedEmailAddress( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + Material3OutlinedTextField( + value = value, + onValueChange = stripLineBreaks(onValueChange), + modifier = modifier.semantics { contentType = ContentType.EmailAddress }, + enabled = isEnabled, + label = selectLabel(label, isRequired), + readOnly = isReadOnly, + isError = hasError, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Email, + ), + singleLine = true, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedFakeSelect.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedFakeSelect.kt new file mode 100644 index 0000000..8e816bf --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedFakeSelect.kt @@ -0,0 +1,45 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import androidx.compose.material3.OutlinedTextField as Material3OutlinedTextField +import androidx.compose.material3.Text as Material3Text + +@Composable +fun TextFieldOutlinedFakeSelect( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + label: String? = null, +) { + Material3OutlinedTextField( + value = text, + onValueChange = { }, + modifier = Modifier + .fillMaxWidth() + .then(modifier), + readOnly = true, + label = optionalLabel(label), + trailingIcon = { Icon(Icons.Outlined.ExpandMore) }, + singleLine = true, + interactionSource = remember { MutableInteractionSource() } + .also { interactionSource -> + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { + if (it is PressInteraction.Release) { + onClick() + } + } + } + }, + ) +} + +private fun optionalLabel(label: String?): @Composable (() -> Unit)? = label?.let { { Material3Text(label) } } diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumber.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumber.kt new file mode 100644 index 0000000..03fa029 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumber.kt @@ -0,0 +1,38 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.material3.OutlinedTextField as Material3OutlinedTextField + +@Suppress("LongParameterList") +@Composable +fun TextFieldOutlinedNumber( + value: Long?, + onValueChange: (Long?) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + Material3OutlinedTextField( + value = value?.toString() ?: "", + onValueChange = { + onValueChange( + it.takeIf { it.isNotBlank() }?.toLongOrNull(), + ) + }, + modifier = modifier, + enabled = isEnabled, + label = selectLabel(label, isRequired), + readOnly = isReadOnly, + isError = hasError, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + ), + singleLine = true, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPassword.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPassword.kt new file mode 100644 index 0000000..2773589 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPassword.kt @@ -0,0 +1,149 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import android.os.Build +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import app.k9mail.core.ui.compose.designsystem.R +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import androidx.compose.material3.Icon as Material3Icon +import androidx.compose.material3.IconButton as Material3IconButton +import androidx.compose.material3.OutlinedTextField as Material3OutlinedTextField + +@Suppress("LongParameterList") +@Composable +fun TextFieldOutlinedPassword( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + var passwordVisibilityState by rememberSaveable { mutableStateOf(false) } + + Material3OutlinedTextField( + value = value, + onValueChange = stripLineBreaks(onValueChange), + modifier = modifier.applyLegacyPasswordSemantics().semantics { contentType = ContentType.Password }, + enabled = isEnabled, + label = selectLabel(label, isRequired), + trailingIcon = selectTrailingIcon( + isEnabled = isEnabled, + isPasswordVisible = passwordVisibilityState, + onClick = { passwordVisibilityState = !passwordVisibilityState }, + ), + readOnly = isReadOnly, + isError = hasError, + visualTransformation = selectVisualTransformation( + isEnabled = isEnabled, + isPasswordVisible = passwordVisibilityState, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) +} + +@Composable +fun TextFieldOutlinedPassword( + value: String, + onValueChange: (String) -> Unit, + isPasswordVisible: Boolean, + onPasswordVisibilityToggleClicked: () -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + Material3OutlinedTextField( + value = value, + onValueChange = stripLineBreaks(onValueChange), + modifier = modifier.applyLegacyPasswordSemantics().semantics { contentType = ContentType.Password }, + enabled = isEnabled, + label = selectLabel(label, isRequired), + trailingIcon = selectTrailingIcon( + isEnabled = isEnabled, + isPasswordVisible = isPasswordVisible, + onClick = onPasswordVisibilityToggleClicked, + ), + readOnly = isReadOnly, + isError = hasError, + visualTransformation = selectVisualTransformation( + isEnabled = isEnabled, + isPasswordVisible = isPasswordVisible, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) +} + +private fun selectTrailingIcon( + isEnabled: Boolean, + isPasswordVisible: Boolean, + onClick: () -> Unit, + hasTrailingIcon: Boolean = true, +): @Composable (() -> Unit)? { + return if (hasTrailingIcon) { + { + val image = if (isShowPasswordAllowed(isEnabled, isPasswordVisible)) { + Icons.Outlined.Visibility + } else { + Icons.Outlined.VisibilityOff + } + + val description = if (isShowPasswordAllowed(isEnabled, isPasswordVisible)) { + stringResource(id = R.string.designsystem_atom_password_textfield_hide_password) + } else { + stringResource(id = R.string.designsystem_atom_password_textfield_show_password) + } + + Material3IconButton(onClick = onClick) { + Material3Icon(imageVector = image, contentDescription = description) + } + } + } else { + null + } +} + +private fun selectVisualTransformation( + isEnabled: Boolean, + isPasswordVisible: Boolean, +): VisualTransformation { + return if (isShowPasswordAllowed(isEnabled, isPasswordVisible)) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + } +} + +private fun isShowPasswordAllowed(isEnabled: Boolean, isPasswordVisible: Boolean) = isEnabled && isPasswordVisible + +private fun Modifier.applyLegacyPasswordSemantics(): Modifier { + /* + * Workaround for a crash that can occur when the password visibility state changes + * while an accessibility service is enabled on devices running Android API level 25 or below. + * This approach mitigates the issue by applying password semantics only on affected versions. + */ + return this.semantics { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1) { + password() + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelect.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelect.kt new file mode 100644 index 0000000..6f7f6f6 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelect.kt @@ -0,0 +1,119 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import kotlinx.collections.immutable.ImmutableList +import androidx.compose.material3.DropdownMenu as Material3DropdownMenu +import androidx.compose.material3.DropdownMenuItem as Material3DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox as Material3ExposedDropdownMenuBox +import androidx.compose.material3.OutlinedTextField as Material3OutlinedTextField +import androidx.compose.material3.Text as Material3Text + +// TODO replace Material3 DropdownMenu with Material3 ExposedDropdownMenu once it's size issue is fixed +// see: https://issuetracker.google.com/issues/205589613 +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongParameterList", "LongMethod") +@Composable +fun TextFieldOutlinedSelect( + options: ImmutableList, + selectedOption: T, + onValueChange: (T) -> Unit, + modifier: Modifier = Modifier, + optionToStringTransformation: (T) -> String = { it.toString() }, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + var isExpanded by remember { + mutableStateOf(false) + } + + val isReadOnlyOrDisabled = isReadOnly || !isEnabled + + Material3ExposedDropdownMenuBox( + expanded = isExpanded, + onExpandedChange = { + isExpanded = if (isReadOnlyOrDisabled) { + false + } else { + isExpanded.not() + } + }, + ) { + Material3OutlinedTextField( + value = optionToStringTransformation(selectedOption), + onValueChange = { }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + .then(modifier), + enabled = isEnabled, + readOnly = true, + label = selectLabel(label, isRequired), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) }, + isError = hasError, + singleLine = true, + interactionSource = remember { + MutableInteractionSource() + }, + ) + + if (isReadOnlyOrDisabled.not()) { + Material3DropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + modifier = Modifier.exposedDropdownSize(), + ) { + options.forEach { option -> + Material3DropdownMenuItem( + text = { + Material3Text( + text = transformOptionWithSelectionHighlight( + option, + optionToStringTransformation(option), + selectedOption, + ), + ) + }, + onClick = { + onValueChange(option) + isExpanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } +} + +private fun transformOptionWithSelectionHighlight( + option: T, + optionString: String, + selectedOption: T, +): AnnotatedString { + return buildAnnotatedString { + if (option == selectedOption) { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(optionString) + } + } else { + append(optionString) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingErrorView.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingErrorView.kt new file mode 100644 index 0000000..a3d1424 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingErrorView.kt @@ -0,0 +1,83 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * A container view that can animate between a loading view, an error view, and a content view. + * + * @param ERROR The type describing the error. + * @param STATE The type of the state being passed to this view. + * + * @param state The state relevant for displaying the content inside this view. + * @param loading When `state.isLoading` is `true`, this composable function is displayed. + * @param error When `state.isLoading` is `false` and `state.error` is not `null`, this composable function is displayed + * with `state.error` being passed as the argument. + * @param content When `state.isLoading` is `false` and `state.error` is `null`, this composable function is displayed + * with [state] being passed as the argument. + * + * **IMPORTANT**: This is a delicate API whose usage should be carefully reviewed. It is using [AnimatedContent] and + * inherits its caveats. + * + * The [loading], [error] and [content] composable functions should only use the state being passed to them (if any). + * If you disregard this advice, make sure to read the documentation of [AnimatedContent] to learn when the composable + * functions are invoked and what that means for the external state a function fetches. + */ +@Composable +fun > ContentLoadingErrorView( + state: STATE, + loading: @Composable () -> Unit, + error: @Composable (ERROR) -> Unit, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + content: @Composable (STATE) -> Unit, +) { + Box( + modifier = modifier, + contentAlignment = contentAlignment, + ) { + AnimatedContent( + targetState = state, + label = "ContentLoadingErrorView", + contentKey = { targetState -> + ContentKey(isLoading = targetState.isLoading, error = targetState.error) + }, + ) { targetState -> + val errorValue = targetState.error + when { + targetState.isLoading -> loading() + errorValue != null -> error(errorValue) + else -> content(targetState) + } + } + } +} + +/** + * Signals [ContentLoadingErrorView] which of its composable function parameters to execute/display. + */ +interface LoadingErrorState { + val isLoading: Boolean + val error: ERROR? +} + +private data class ContentKey( + override val isLoading: Boolean, + override val error: ERROR?, +) : LoadingErrorState + +/** + * Helper that can be use as `state` argument for [ContentLoadingErrorView] when none of the composable function + * parameters need access to any state. + */ +sealed class ContentLoadingErrorState private constructor( + override val isLoading: Boolean, + override val error: Unit?, +) : LoadingErrorState { + data object Loading : ContentLoadingErrorState(isLoading = true, error = null) + data object Content : ContentLoadingErrorState(isLoading = false, error = null) + data object Error : ContentLoadingErrorState(isLoading = false, error = Unit) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingView.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingView.kt new file mode 100644 index 0000000..f86c8cb --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ContentLoadingView.kt @@ -0,0 +1,68 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * A container view that can animate between a loading view and a content view. + * + * @param STATE The type of the state being passed to this view. + * + * @param state The state relevant for displaying the content inside this view. + * @param loading When `state.isLoading` is `true`, this composable function is displayed. + * @param content When `state.isLoading` is `false`, this composable function is displayed with [state] being passed as + * the argument. + * + * **IMPORTANT**: This is a delicate API whose usage should be carefully reviewed. It is using [AnimatedContent] and + * inherits its caveats. + * + * The [loading] and [content] composable functions should only use the state being passed to them (if any). If you + * disregard this advice, make sure to read the documentation of [AnimatedContent] to learn when the composable + * functions are invoked and what that means for the external state a function fetches. + */ +@Composable +fun ContentLoadingView( + state: STATE, + loading: @Composable () -> Unit, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + content: @Composable (STATE) -> Unit, +) { + Box( + modifier = modifier, + contentAlignment = contentAlignment, + ) { + AnimatedContent( + targetState = state, + label = "ContentLoadingView", + contentKey = { targetState -> targetState.isLoading }, + ) { targetState -> + if (targetState.isLoading) { + loading() + } else { + content(targetState) + } + } + } +} + +/** + * Signals [ContentLoadingView] which of its composable function parameters to execute/display. + */ +interface LoadingState { + val isLoading: Boolean +} + +/** + * Helper that can be use as `state` argument for [ContentLoadingView] when none of the composable function parameters + * need access to any state. + */ +sealed class ContentLoadingState private constructor( + override val isLoading: Boolean, +) : LoadingState { + data object Loading : ContentLoadingState(isLoading = true) + data object Content : ContentLoadingState(isLoading = false) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ErrorView.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ErrorView.kt new file mode 100644 index 0000000..3ddbe6f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/ErrorView.kt @@ -0,0 +1,79 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import app.k9mail.core.ui.compose.designsystem.R +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun ErrorView( + title: String, + modifier: Modifier = Modifier, + message: String? = null, + onRetry: (() -> Unit)? = null, + contentAlignment: Alignment = Alignment.Center, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + contentAlignment = contentAlignment, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MainTheme.spacings.triple), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + tint = MainTheme.colors.error, + ) + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + TextHeadlineSmall( + text = title, + textAlign = TextAlign.Center, + ) + } + + if (message != null) { + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + TextBodyMedium( + text = message, + modifier = Modifier.fillMaxWidth(), + ) + } + if (onRetry != null) { + Spacer(modifier = Modifier.height(MainTheme.spacings.triple)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + ButtonText( + text = stringResource(id = R.string.designsystem_molecule_error_view_button_retry), + onClick = onRetry, + ) + } + } + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/LoadingView.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/LoadingView.kt new file mode 100644 index 0000000..1330897 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/LoadingView.kt @@ -0,0 +1,51 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import app.k9mail.core.ui.compose.designsystem.atom.CircularProgressIndicator +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun LoadingView( + modifier: Modifier = Modifier, + message: String? = null, + contentAlignment: Alignment = Alignment.Center, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + contentAlignment = contentAlignment, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MainTheme.spacings.triple), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (message != null) { + TextTitleMedium( + text = message, + textAlign = TextAlign.Center, + ) + } + Row( + modifier = Modifier.height(MainTheme.sizes.larger), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + } + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/PullToRefreshBox.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/PullToRefreshBox.kt new file mode 100644 index 0000000..0f12679 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/PullToRefreshBox.kt @@ -0,0 +1,41 @@ +package app.k9mail.core.ui.compose.designsystem.molecule + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import androidx.compose.material3.pulltorefresh.PullToRefreshBox as Material3PullToRefreshBox + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PullToRefreshBox( + isRefreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + content: @Composable BoxScope.() -> Unit, +) { + val state = rememberPullToRefreshState() + + Material3PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = modifier + .testTagAsResourceId("PullToRefreshBox"), + state = state, + contentAlignment = contentAlignment, + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter) + .testTagAsResourceId("PullToRefreshIndicator"), + isRefreshing = isRefreshing, + state = state, + ) + }, + content = content, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/AdvancedTextInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/AdvancedTextInput.kt new file mode 100644 index 0000000..45efb93 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/AdvancedTextInput.kt @@ -0,0 +1,47 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlined + +/** + * A text input field that uses [TextFieldValue] to support text selection and composition. + * + * It supports annotated strings, which can be used to display rich text or formatted text. + */ +@Suppress("LongParameterList") +@Composable +fun AdvancedTextInput( + onTextChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + text: TextFieldValue = TextFieldValue(""), + label: String? = null, + isRequired: Boolean = false, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), + isSingleLine: Boolean = true, + isEnabled: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, +) { + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + ) { + TextFieldOutlined( + value = text, + onValueChange = onTextChange, + label = label, + isEnabled = isEnabled, + isRequired = isRequired, + hasError = errorMessage != null, + isSingleLine = isSingleLine, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = keyboardOptions, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/CheckboxInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/CheckboxInput.kt new file mode 100644 index 0000000..9345503 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/CheckboxInput.kt @@ -0,0 +1,48 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.toggleable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import app.k9mail.core.ui.compose.designsystem.atom.Checkbox +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun CheckboxInput( + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = checked, + role = Role.Checkbox, + onValueChange = { onCheckedChange(!checked) }, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + ) + TextBodyLarge(text = text) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/EmailAddressInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/EmailAddressInput.kt new file mode 100644 index 0000000..7654667 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/EmailAddressInput.kt @@ -0,0 +1,34 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.R +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedEmailAddress + +@Composable +fun EmailAddressInput( + onEmailAddressChange: (String) -> Unit, + modifier: Modifier = Modifier, + emailAddress: String = "", + errorMessage: String? = null, + isEnabled: Boolean = true, + contentPadding: PaddingValues = inputContentPadding(), +) { + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + ) { + TextFieldOutlinedEmailAddress( + value = emailAddress, + onValueChange = onEmailAddressChange, + label = stringResource(id = R.string.designsystem_molecule_email_address_input_label), + isEnabled = isEnabled, + hasError = errorMessage != null, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputDefaults.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputDefaults.kt new file mode 100644 index 0000000..d07e8a1 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputDefaults.kt @@ -0,0 +1,28 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun inputContentPadding( + start: Dp = MainTheme.spacings.double, + top: Dp = MainTheme.spacings.default, + end: Dp = MainTheme.spacings.double, + bottom: Dp = MainTheme.spacings.default, +): PaddingValues = PaddingValues( + start = start, + top = top, + end = end, + bottom = bottom, +) + +@Composable +fun inputContentPadding( + horizontal: Dp = MainTheme.spacings.double, + vertical: Dp = MainTheme.spacings.default, +): PaddingValues = PaddingValues( + horizontal = horizontal, + vertical = vertical, +) diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayout.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayout.kt new file mode 100644 index 0000000..516ae3e --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/InputLayout.kt @@ -0,0 +1,45 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun InputLayout( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = inputContentPadding(), + errorMessage: String? = null, + warningMessage: String? = null, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth() + .then(modifier), + ) { + content() + + AnimatedVisibility(visible = errorMessage != null) { + TextBodySmall( + text = errorMessage ?: "", + modifier = Modifier.padding(start = MainTheme.spacings.double, top = MainTheme.spacings.half), + color = MainTheme.colors.error, + ) + } + + AnimatedVisibility(visible = warningMessage != null) { + TextBodySmall( + text = warningMessage ?: "", + modifier = Modifier.padding(start = MainTheme.spacings.double, top = MainTheme.spacings.half), + color = MainTheme.colors.warning, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/NumberInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/NumberInput.kt new file mode 100644 index 0000000..96822aa --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/NumberInput.kt @@ -0,0 +1,34 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedNumber + +@Suppress("LongParameterList") +@Composable +fun NumberInput( + onValueChange: (Long?) -> Unit, + modifier: Modifier = Modifier, + value: Long? = null, + label: String? = null, + isRequired: Boolean = false, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + ) { + TextFieldOutlinedNumber( + value = value, + onValueChange = onValueChange, + label = label, + isRequired = isRequired, + hasError = errorMessage != null, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/PasswordInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/PasswordInput.kt new file mode 100644 index 0000000..8cd269c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/PasswordInput.kt @@ -0,0 +1,34 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.R +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedPassword + +@Composable +fun PasswordInput( + onPasswordChange: (String) -> Unit, + modifier: Modifier = Modifier, + password: String = "", + isRequired: Boolean = false, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + ) { + TextFieldOutlinedPassword( + value = password, + onValueChange = onPasswordChange, + label = stringResource(id = R.string.designsystem_molecule_password_input_label), + isRequired = isRequired, + hasError = errorMessage != null, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SelectInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SelectInput.kt new file mode 100644 index 0000000..5facae1 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SelectInput.kt @@ -0,0 +1,37 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedSelect +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SelectInput( + options: ImmutableList, + selectedOption: T, + onOptionChange: (T) -> Unit, + modifier: Modifier = Modifier, + optionToStringTransformation: (T) -> String = { it.toString() }, + label: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth() + .then(modifier), + ) { + TextFieldOutlinedSelect( + options = options, + selectedOption = selectedOption, + onValueChange = onOptionChange, + modifier = Modifier.fillMaxWidth(), + optionToStringTransformation = optionToStringTransformation, + label = label, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SwitchInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SwitchInput.kt new file mode 100644 index 0000000..dacf9e3 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/SwitchInput.kt @@ -0,0 +1,43 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.Switch +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun SwitchInput( + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + TextBodyLarge(text = text) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/TextInput.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/TextInput.kt new file mode 100644 index 0000000..f1220eb --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/input/TextInput.kt @@ -0,0 +1,51 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.input + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlined + +@Suppress("LongParameterList") +@Composable +fun TextInput( + onTextChange: (String) -> Unit, + modifier: Modifier = Modifier, + text: String = "", + label: String? = null, + isRequired: Boolean = false, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), + isSingleLine: Boolean = true, + isEnabled: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + contentType: ContentType? = null, +) { + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + ) { + val textFieldModifier = if (contentType != null) { + Modifier.semantics { this.contentType = contentType } + } else { + Modifier + } + + TextFieldOutlined( + value = text, + onValueChange = onTextChange, + label = label, + isEnabled = isEnabled, + isRequired = isRequired, + hasError = errorMessage != null, + isSingleLine = isSingleLine, + modifier = textFieldModifier.fillMaxWidth(), + keyboardOptions = keyboardOptions, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/notification/NotificationActionButton.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/notification/NotificationActionButton.kt new file mode 100644 index 0000000..3b4cee0 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/molecule/notification/NotificationActionButton.kt @@ -0,0 +1,44 @@ +package app.k9mail.core.ui.compose.designsystem.molecule.notification + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.OpenInNew +import app.k9mail.core.ui.compose.theme2.LocalContentColor +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun NotificationActionButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isExternalLink: Boolean = false, +) { + val leadingIcon = remember(isExternalLink) { + if (isExternalLink) { + movableContentOf { + Icon( + imageVector = Icons.Outlined.OpenInNew, + modifier = Modifier.size(MainTheme.sizes.iconSmall), + ) + Spacer(modifier = Modifier.width(MainTheme.spacings.default)) + } + } else { + null + } + } + ButtonText( + text = text, + onClick = onClick, + leadingIcon = leadingIcon, + modifier = modifier, + color = LocalContentColor.current, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialog.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialog.kt new file mode 100644 index 0000000..a634989 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialog.kt @@ -0,0 +1,81 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall +import androidx.compose.material3.AlertDialog as MaterialAlertDialog + +@Composable +fun AlertDialog( + title: String, + text: String, + confirmText: String, + onConfirmClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + dismissText: String? = null, + onDismissClick: () -> Unit = {}, +) { + AlertDialog( + title = title, + icon = icon, + confirmText = confirmText, + onConfirmClick = onConfirmClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + dismissText = dismissText, + onDismissClick = onDismissClick, + ) { + TextBodyMedium(text = text) + } +} + +@Composable +fun AlertDialog( + title: String, + confirmText: String, + onConfirmClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + dismissText: String? = null, + onDismissClick: () -> Unit = {}, + content: @Composable () -> Unit, +) { + MaterialAlertDialog( + title = { + TextHeadlineSmall( + text = title, + textAlign = if (icon == null) TextAlign.Start else TextAlign.Center, + ) + }, + icon = icon?.let { + { + Icon(imageVector = it) + } + }, + text = { content() }, + confirmButton = { + ButtonText( + text = confirmText, + onClick = onConfirmClick, + ) + }, + dismissButton = dismissText?.let { + { + ButtonText( + text = it, + onClick = onDismissClick, + ) + } + }, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/BasicDialog.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/BasicDialog.kt new file mode 100644 index 0000000..b187f3b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/BasicDialog.kt @@ -0,0 +1,164 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.window.DialogProperties +import app.k9mail.core.ui.compose.designsystem.atom.DividerHorizontal +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import androidx.compose.ui.window.Dialog as MaterialDialog + +@Composable +fun BasicDialog( + onDismissRequest: () -> Unit, + content: (@Composable () -> Unit)?, + buttons: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + headline: (@Composable ColumnScope.() -> Unit)? = null, + supportingText: (@Composable ColumnScope.() -> Unit)? = null, + contentPadding: PaddingValues = BasicDialogDefaults.contentPadding, + showDividers: Boolean = BasicDialogDefaults.showDividers, + dividerColor: Color = BasicDialogDefaults.dividerColor, + properties: DialogProperties = DialogProperties(), +) { + MaterialDialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + BasicDialogContent( + content = content, + buttons = buttons, + modifier = modifier, + headline = headline, + supportingText = supportingText, + contentPadding = contentPadding, + showDividers = showDividers, + dividerColor = dividerColor, + ) + } +} + +@Composable +fun BasicDialog( + headlineText: String, + onDismissRequest: () -> Unit, + content: (@Composable () -> Unit)?, + buttons: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + supportingText: String? = null, + contentPadding: PaddingValues = BasicDialogDefaults.contentPadding, + showDividers: Boolean = BasicDialogDefaults.showDividers, + dividerColor: Color = BasicDialogDefaults.dividerColor, + properties: DialogProperties = DialogProperties(), +) { + BasicDialog( + onDismissRequest = onDismissRequest, + content = content, + buttons = buttons, + modifier = modifier, + headline = { TextHeadlineSmall(text = headlineText) }, + supportingText = supportingText?.let { + @Composable { + TextBodyMedium( + text = supportingText, + color = MainTheme.colors.onSurfaceVariant, + ) + } + }, + contentPadding = contentPadding, + showDividers = showDividers, + dividerColor = dividerColor, + properties = properties, + ) +} + +@Composable +internal fun BasicDialogContent( + content: (@Composable () -> Unit)?, + buttons: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + headline: (@Composable ColumnScope.() -> Unit)? = null, + supportingText: (@Composable ColumnScope.() -> Unit)? = null, + contentPadding: PaddingValues = BasicDialogDefaults.contentPadding, + showDividers: Boolean = BasicDialogDefaults.showDividers, + dividerColor: Color = BasicDialogDefaults.dividerColor, +) { + Surface( + modifier = modifier, + shape = MainTheme.shapes.extraLarge, + ) { + Column { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + modifier = Modifier + .padding( + start = MainTheme.spacings.triple, + end = MainTheme.spacings.triple, + top = MainTheme.spacings.triple, + bottom = MainTheme.spacings.double, + ), + ) { + headline?.invoke(this) + supportingText?.invoke(this) + } + if (showDividers && (headline != null || supportingText != null)) { + DividerHorizontal( + color = dividerColor, + modifier = Modifier.wrapContentSize(), + ) + } + content?.let { content -> + Box( + modifier = Modifier + .weight(weight = 1f, fill = false) + .padding(contentPadding), + ) { + content() + } + } + if (showDividers && content != null) { + DividerHorizontal( + color = dividerColor, + modifier = Modifier.wrapContentSize(), + ) + } + Box( + modifier = Modifier + .align(Alignment.End) + .padding( + start = MainTheme.spacings.triple, + end = MainTheme.spacings.triple, + bottom = MainTheme.spacings.triple, + ), + ) { + Row { buttons() } + } + } + } +} + +object BasicDialogDefaults { + val showDividers: Boolean get() = false + val dividerColor: Color + @Composable + get() = MainTheme.colors.outlineVariant + val contentPadding: PaddingValues + @Composable + get() = PaddingValues( + top = MainTheme.spacings.oneHalf, + bottom = MainTheme.spacings.double, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/SubtitleTopAppBar.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/SubtitleTopAppBar.kt new file mode 100644 index 0000000..bc3e047 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/SubtitleTopAppBar.kt @@ -0,0 +1,107 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import androidx.compose.material3.TopAppBar as Material3TopAppBar + +/** + * A top app bar with a title, subtitle, navigation icon, and actions. + * + * @param title The title of the top app bar. + * @param subtitle The subtitle of the top app bar (optional). + * @param navigationIcon The icon to use for the navigation icon. + * @param actions The actions to display in the top app bar. + * @param modifier The modifier to apply to the top app bar. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubtitleTopAppBar( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable (() -> Unit) = {}, + actions: @Composable RowScope.() -> Unit = {}, +) { + Material3TopAppBar( + title = { + Column( + modifier = Modifier.padding(end = MainTheme.spacings.double), + ) { + TextTitleMedium(text = title) + TextBodyMedium( + text = subtitle, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + }, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + colors = topAppBarColors( + containerColor = MainTheme.colors.surfaceContainer, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubtitleTopAppBarWithMenuButton( + title: String, + subtitle: String, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + SubtitleTopAppBar( + title = title, + subtitle = subtitle, + modifier = modifier, + navigationIcon = { + ButtonIcon( + onClick = onMenuClick, + imageVector = Icons.Outlined.Menu, + modifier = Modifier.testTagAsResourceId("SubtitleTopAppBarMenuButton"), + ) + }, + actions = actions, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubtitleTopAppBarWithBackButton( + title: String, + subtitle: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + SubtitleTopAppBar( + title = title, + subtitle = subtitle, + modifier = modifier, + navigationIcon = { + ButtonIcon( + onClick = onBackClick, + imageVector = Icons.Outlined.ArrowBack, + modifier = Modifier.testTagAsResourceId("SubtitleTopAppBarBackButton"), + contentDescription = stringResource(androidx.appcompat.R.string.abc_action_bar_up_description), + ) + }, + actions = actions, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/TopAppBar.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/TopAppBar.kt new file mode 100644 index 0000000..6bd44e9 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/TopAppBar.kt @@ -0,0 +1,82 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import androidx.compose.material3.TopAppBar as Material3TopAppBar + +/** + * A top app bar with a title, subtitle, navigation icon, and actions. + * + * @param title The title of the top app bar. + * @param navigationIcon The icon to use for the navigation icon. + * @param actions The actions to display in the top app bar. + * @param modifier The modifier to apply to the top app bar. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBar( + title: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable (() -> Unit) = {}, + actions: @Composable RowScope.() -> Unit = {}, +) { + Material3TopAppBar( + title = { TextTitleLarge(text = title) }, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + colors = topAppBarColors( + containerColor = MainTheme.colors.surfaceContainer, + ), + ) +} + +@Composable +fun TopAppBarWithMenuButton( + title: String, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + title = title, + modifier = modifier, + navigationIcon = { + ButtonIcon( + onClick = onMenuClick, + imageVector = Icons.Outlined.Menu, + modifier = Modifier.testTagAsResourceId("TopAppBarMenuButton"), + ) + }, + actions = actions, + ) +} + +@Composable +fun TopAppBarWithBackButton( + title: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + title = title, + modifier = modifier, + navigationIcon = { + ButtonIcon( + onClick = onBackClick, + imageVector = Icons.Outlined.ArrowBack, + modifier = Modifier.testTagAsResourceId("TopAppBarBackButton"), + ) + }, + actions = actions, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/BannerNotificationCardDefaults.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/BannerNotificationCardDefaults.kt new file mode 100644 index 0000000..dccdec3 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/BannerNotificationCardDefaults.kt @@ -0,0 +1,141 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.card.CardColors +import app.k9mail.core.ui.compose.designsystem.atom.card.CardDefaults +import app.k9mail.core.ui.compose.designsystem.organism.banner.global.BannerGlobalNotificationCard +import app.k9mail.core.ui.compose.designsystem.organism.banner.inline.BannerInlineNotificationCard +import app.k9mail.core.ui.compose.designsystem.organism.banner.inline.BannerInlineNotificationCardBehaviour +import app.k9mail.core.ui.compose.theme2.MainTheme + +/** + * Contains the default values used by [BannerInlineNotificationCard] and [BannerGlobalNotificationCard] types + */ +object BannerNotificationCardDefaults { + /** The default shape of the [BannerGlobalNotificationCard] */ + val bannerGlobalShape: Shape = RectangleShape + + /** The default shape of the [BannerInlineNotificationCard] */ + val bannerInlineShape: Shape + @ReadOnlyComposable + @Composable + get() = RoundedCornerShape(size = 12.dp) + + /** + * The default behaviour for the [BannerInlineNotificationCard] + */ + val bannerInlineBehaviour = BannerInlineNotificationCardBehaviour.Expanded + + /** + * Creates a [CardColors] for an error banner inline notification card. + * + * @param containerColor The color used for the background of this card. + * @param contentColor The preferred color for content inside this card. + * + * @return A [CardColors] with the specified colors. + */ + @Composable + fun errorCardColors( + containerColor: Color = MainTheme.colors.errorContainer, + contentColor: Color = MainTheme.colors.onErrorContainer, + ): CardColors = CardDefaults.outlinedCardColors( + containerColor = containerColor, + contentColor = contentColor, + ) + + /** + * Creates a [CardColors] for an information banner inline notification card. + * + * @param containerColor The container color of the card. + * @param contentColor The content color of the card. + * + * @return A [CardColors] with the specified colors. + */ + @Composable + fun infoCardColors( + containerColor: Color = MainTheme.colors.infoContainer, + contentColor: Color = MainTheme.colors.onInfoContainer, + ): CardColors = CardDefaults.outlinedCardColors( + containerColor = containerColor, + contentColor = contentColor, + ) + + /** + * Creates a [CardColors] for a warning banner inline notification card. + * + * @param containerColor The container color of the card. + * @param contentColor The content color of the card. + * + * @return A [CardColors] with the specified colors. + */ + @Composable + fun warningCardColors( + containerColor: Color = MainTheme.colors.warningContainer, + contentColor: Color = MainTheme.colors.onWarningContainer, + ): CardColors = CardDefaults.outlinedCardColors( + containerColor = containerColor, + contentColor = contentColor, + ) + + /** + * Creates a [CardColors] for a success banner inline notification card. + * + * @param containerColor The container color of the card. + * @param contentColor The content color of the card. + * + * @return A [CardColors] with the specified colors. + */ + @Composable + fun successCardColors( + containerColor: Color = MainTheme.colors.successContainer, + contentColor: Color = MainTheme.colors.onSuccessContainer, + ): CardColors = CardDefaults.outlinedCardColors( + containerColor = containerColor, + contentColor = contentColor, + ) + + /** + * Creates a [BorderStroke] for the error banner inline notification card. + * + * @return The [BorderStroke] for the error banner inline notification card. + */ + @Composable + fun errorCardBorder(): BorderStroke = defaultCardBorder(color = MainTheme.colors.onErrorContainer) + + /** + * Creates a [BorderStroke] for the info banner inline notification card. + * + * @return The [BorderStroke] for the info banner inline notification card. + */ + @Composable + fun infoCardBorder(): BorderStroke = defaultCardBorder(color = MainTheme.colors.onInfoContainer) + + /** + * Creates a [BorderStroke] for the warning banner inline notification card. + * + * @return The [BorderStroke] for the warning banner inline notification card. + */ + @Composable + fun warningCardBorder(): BorderStroke = defaultCardBorder(color = MainTheme.colors.onWarningContainer) + + /** + * Creates a [BorderStroke] for the success banner inline notification card. + * + * @return The [BorderStroke] for the success banner inline notification card. + */ + @Composable + fun successCardBorder(): BorderStroke = defaultCardBorder(color = MainTheme.colors.onSuccessContainer) + + private fun defaultCardBorder(color: Color): BorderStroke = BorderStroke( + width = 1.dp, + brush = SolidColor(color), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/BannerGlobalNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/BannerGlobalNotificationCard.kt new file mode 100644 index 0000000..794fbaf --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/BannerGlobalNotificationCard.kt @@ -0,0 +1,118 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.card.CardColors +import app.k9mail.core.ui.compose.designsystem.atom.card.CardFilled +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults +import app.k9mail.core.ui.compose.theme2.LocalContentColor +import app.k9mail.core.ui.compose.theme2.MainTheme + +/** + * Used to maintain the user’s awareness of a persistent irregular state of the application, + * without disrupting other elements of the flow. + * + * @param icon The icon to display on the left side of the card. + * @param text The text to display in the card. + * @param modifier The modifier to apply to this layout node. + * @param colors The colors to use for the card. + * @param shape The shape of the card. + * @param action The action to display on the right side of the card. + */ +@Composable +internal fun BannerGlobalNotificationCard( + icon: @Composable () -> Unit, + text: CharSequence, + modifier: Modifier = Modifier, + colors: CardColors = BannerNotificationCardDefaults.warningCardColors(), + shape: Shape = BannerNotificationCardDefaults.bannerGlobalShape, + action: (@Composable () -> Unit)? = null, +) { + BannerGlobalNotificationCard( + icon = icon, + text = { + when (text) { + is String -> TextTitleSmall( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + is AnnotatedString -> TextTitleSmall( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + else -> TextTitleSmall( + text = text.toString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + colors = colors, + shape = shape, + modifier = modifier, + action = action, + ) +} + +/** + * Displays a header notification card. + * + * @param icon The icon to display on the left side of the card. + * @param text The text to display in the card. + * @param modifier The modifier to apply to this layout node. + * @param colors The colors to use for the card. + * @param shape The shape of the card. + * @param action The action to display on the right side of the card. + * + * @see BannerGlobalNotificationCard + */ +@Composable +private fun BannerGlobalNotificationCard( + icon: @Composable () -> Unit, + text: @Composable () -> Unit, + colors: CardColors, + shape: Shape, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, +) { + CardFilled( + modifier = modifier, + shape = shape, + colors = colors, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MainTheme.sizes.bannerGlobalHeight) + .padding(horizontal = MainTheme.spacings.default), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + icon() + Box( + modifier = Modifier.weight(1f), + ) { + text() + } + CompositionLocalProvider(LocalContentColor provides colors.contentColor) { + action?.invoke() + } + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/ErrorBannerGlobalNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/ErrorBannerGlobalNotificationCard.kt new file mode 100644 index 0000000..f02c44b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/ErrorBannerGlobalNotificationCard.kt @@ -0,0 +1,29 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays an error banner global notification card. + * + * @param text The text to display in the notification card. + * @param action The composable function to display as the action button. + * @param modifier The modifier to apply to this layout node. + */ +@Composable +fun ErrorBannerGlobalNotificationCard( + text: CharSequence, + action: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + BannerGlobalNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Report) }, + text = text, + action = action, + modifier = modifier, + colors = BannerNotificationCardDefaults.errorCardColors(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/InfoBannerGlobalNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/InfoBannerGlobalNotificationCard.kt new file mode 100644 index 0000000..2323d84 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/InfoBannerGlobalNotificationCard.kt @@ -0,0 +1,29 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays an info banner global notification card. + * + * @param text The text to display in the notification card. + * @param action The composable function to display as the action button. + * @param modifier The modifier to apply to this layout node. + */ +@Composable +fun InfoBannerGlobalNotificationCard( + text: CharSequence, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, +) { + BannerGlobalNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Info) }, + text = text, + action = action, + modifier = modifier, + colors = BannerNotificationCardDefaults.infoCardColors(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/SuccessBannerGlobalNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/SuccessBannerGlobalNotificationCard.kt new file mode 100644 index 0000000..d97f7b8 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/SuccessBannerGlobalNotificationCard.kt @@ -0,0 +1,29 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays a success banner global notification card. + * + * @param text The text to display in the notification card. + * @param action The composable function to display as the action button. + * @param modifier The modifier to apply to this layout node. + */ +@Composable +fun SuccessBannerGlobalNotificationCard( + text: CharSequence, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, +) { + BannerGlobalNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.CheckCircle) }, + text = text, + action = action, + modifier = modifier, + colors = BannerNotificationCardDefaults.successCardColors(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/WarningBannerGlobalNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/WarningBannerGlobalNotificationCard.kt new file mode 100644 index 0000000..2142938 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/global/WarningBannerGlobalNotificationCard.kt @@ -0,0 +1,30 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.global + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.Warning +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays a warning banner global notification card. + * + * @param text The text to display in the notification card. + * @param action The composable function to display as the action button. + * @param modifier The modifier to apply to this layout node. + */ +@Composable +fun WarningBannerGlobalNotificationCard( + text: CharSequence, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, +) { + BannerGlobalNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Warning) }, + text = text, + action = action, + modifier = modifier, + colors = BannerNotificationCardDefaults.warningCardColors(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCard.kt new file mode 100644 index 0000000..c5f5f11 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCard.kt @@ -0,0 +1,221 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.card.CardColors +import app.k9mail.core.ui.compose.designsystem.atom.card.CardOutlined +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults +import app.k9mail.core.ui.compose.theme2.LocalContentColor +import app.k9mail.core.ui.compose.theme2.MainTheme + +private const val MAX_TITLE_LENGTH = 100 +private const val MAX_SUPPORTING_TEXT_LENGTH = 200 + +/** + * Used to inform the user that something needs their attention before interacting with + * the main content on the screen. + * + * @param icon The icon to display on the left side of the notification card. + * @param title The title of the notification card. + * @param supportingText The supporting text of the notification card. + * @param actions The actions to display at the bottom of the notification card. + * @param modifier The modifier to apply to the notification card. + * @param colors The colors to use for the notification card. + * @param border The border to use for the notification card. + * @param shape The shape to use for the notification card. + * @param behaviour The behaviour to use for the notification card. + * @see BannerNotificationCardDefaults + */ +@Composable +internal fun BannerInlineNotificationCard( + icon: @Composable () -> Unit, + title: CharSequence, + supportingText: CharSequence, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + colors: CardColors = BannerNotificationCardDefaults.errorCardColors(), + border: BorderStroke = BannerNotificationCardDefaults.errorCardBorder(), + shape: Shape = BannerNotificationCardDefaults.bannerInlineShape, + behaviour: BannerInlineNotificationCardBehaviour = BannerNotificationCardDefaults.bannerInlineBehaviour, +) { + val maxLines = when (behaviour) { + BannerInlineNotificationCardBehaviour.Clipped -> 2 + BannerInlineNotificationCardBehaviour.Expanded -> Int.MAX_VALUE + } + + BannerInlineNotificationCard( + icon = icon, + title = { + BannerInlineNotificationTitle( + title = title, + behaviour = behaviour, + maxLines = maxLines, + ) + }, + supportingText = { + BannerInlineNotificationSupportingText( + supportingText = supportingText, + behaviour = behaviour, + maxLines = maxLines, + ) + }, + actions = actions, + modifier = modifier, + colors = colors, + border = border, + shape = shape, + ) +} + +/** + * Displays an inline notification card. + * + * @param icon The icon to display on the left side of the notification card. + * @param title The title of the notification card. + * @param supportingText The supporting text of the notification card. + * @param actions The actions to display at the bottom of the notification card. + * @param modifier The modifier to apply to the notification card. + * + * @see BannerInlineNotificationCard + */ +@Composable +internal fun BannerInlineNotificationCard( + icon: @Composable () -> Unit, + title: @Composable () -> Unit, + supportingText: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + colors: CardColors = BannerNotificationCardDefaults.errorCardColors(), + border: BorderStroke = BannerNotificationCardDefaults.errorCardBorder(), + shape: Shape = BannerNotificationCardDefaults.bannerInlineShape, +) { + CardOutlined( + modifier = modifier, + colors = colors, + border = border, + shape = shape, + ) { + Column( + modifier = Modifier.padding(MainTheme.spacings.oneHalf), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + CompositionLocalProvider(LocalContentColor provides colors.contentColor) { + icon() + Column(verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.quarter)) { + title() + supportingText() + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy( + space = MainTheme.spacings.default, + alignment = Alignment.End, + ), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + CompositionLocalProvider(LocalContentColor provides colors.contentColor) { + actions() + } + } + } + } +} + +@Composable +private fun BannerInlineNotificationTitle( + title: CharSequence, + behaviour: BannerInlineNotificationCardBehaviour, + maxLines: Int, + modifier: Modifier = Modifier, +) { + val clippedTitle = remember(title, behaviour) { + when (behaviour) { + BannerInlineNotificationCardBehaviour.Clipped if title.length > MAX_TITLE_LENGTH -> + title.subSequence(startIndex = 0, endIndex = MAX_TITLE_LENGTH) + + else -> title + } + } + + when (clippedTitle) { + is String -> TextTitleSmall( + text = clippedTitle, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + + is AnnotatedString -> TextTitleSmall( + text = clippedTitle, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + + else -> TextTitleSmall( + text = clippedTitle.toString(), + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } +} + +@Composable +fun BannerInlineNotificationSupportingText( + supportingText: CharSequence, + behaviour: BannerInlineNotificationCardBehaviour, + maxLines: Int, + modifier: Modifier = Modifier, +) { + val clippedSupportingText = remember(supportingText, behaviour) { + when (behaviour) { + BannerInlineNotificationCardBehaviour.Clipped if supportingText.length > MAX_SUPPORTING_TEXT_LENGTH -> + supportingText.subSequence(startIndex = 0, endIndex = MAX_SUPPORTING_TEXT_LENGTH) + + else -> supportingText + } + } + when (clippedSupportingText) { + is String -> TextBodyMedium( + text = clippedSupportingText, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + + is AnnotatedString -> TextBodyMedium( + text = clippedSupportingText, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + + else -> TextBodyMedium( + text = clippedSupportingText.toString(), + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCardBehaviour.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCardBehaviour.kt new file mode 100644 index 0000000..74d0b94 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/BannerInlineNotificationCardBehaviour.kt @@ -0,0 +1,6 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +enum class BannerInlineNotificationCardBehaviour { + Clipped, + Expanded, +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/ErrorBannerInlineNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/ErrorBannerInlineNotificationCard.kt new file mode 100644 index 0000000..25bb7da --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/ErrorBannerInlineNotificationCard.kt @@ -0,0 +1,39 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays an error banner inline notification card. + * + * @param title The main text or heading of the notification. + * @param supportingText Additional details or context for the notification. + * @param actions A composable lambda that defines the actions available in the notification, + * typically buttons, laid out in a [RowScope]. + * @param modifier Optional [Modifier] to be applied to the composable. + * @param behaviour Optional [BannerInlineNotificationCardBehaviour] to customize the appearance + * and behavior of the notification card. Defaults to [BannerNotificationCardDefaults.bannerInlineBehaviour]. + */ +@Composable +fun ErrorBannerInlineNotificationCard( + title: CharSequence, + supportingText: CharSequence, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + behaviour: BannerInlineNotificationCardBehaviour = BannerNotificationCardDefaults.bannerInlineBehaviour, +) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Report) }, + title = title, + supportingText = supportingText, + actions = actions, + modifier = modifier, + behaviour = behaviour, + colors = BannerNotificationCardDefaults.errorCardColors(), + border = BannerNotificationCardDefaults.errorCardBorder(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/InfoBannerInlineNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/InfoBannerInlineNotificationCard.kt new file mode 100644 index 0000000..e835d7d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/InfoBannerInlineNotificationCard.kt @@ -0,0 +1,39 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays an info banner inline notification card. + * + * @param title The main text or heading of the notification. + * @param supportingText Additional details or context for the notification. + * @param actions A composable lambda that defines the actions available in the notification, + * typically buttons, laid out in a [RowScope]. + * @param modifier Optional [Modifier] to be applied to the composable. + * @param behaviour Optional [BannerInlineNotificationCardBehaviour] to customize the appearance + * and behavior of the notification card. Defaults to [BannerNotificationCardDefaults.bannerInlineBehaviour]. + */ +@Composable +fun InfoBannerInlineNotificationCard( + title: CharSequence, + supportingText: CharSequence, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + behaviour: BannerInlineNotificationCardBehaviour = BannerNotificationCardDefaults.bannerInlineBehaviour, +) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Info) }, + title = title, + supportingText = supportingText, + actions = actions, + modifier = modifier, + behaviour = behaviour, + colors = BannerNotificationCardDefaults.infoCardColors(), + border = BannerNotificationCardDefaults.infoCardBorder(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/SuccessBannerInlineNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/SuccessBannerInlineNotificationCard.kt new file mode 100644 index 0000000..102c7c2 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/SuccessBannerInlineNotificationCard.kt @@ -0,0 +1,39 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays a success banner inline notification card. + * + * @param title The main text or heading of the notification. + * @param supportingText Additional details or context for the notification. + * @param actions A composable lambda that defines the actions available in the notification, + * typically buttons, laid out in a [RowScope]. + * @param modifier Optional [Modifier] to be applied to the composable. + * @param behaviour Optional [BannerInlineNotificationCardBehaviour] to customize the appearance + * and behavior of the notification card. Defaults to [BannerNotificationCardDefaults.bannerInlineBehaviour]. + */ +@Composable +fun SuccessBannerInlineNotificationCard( + title: CharSequence, + supportingText: CharSequence, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + behaviour: BannerInlineNotificationCardBehaviour = BannerNotificationCardDefaults.bannerInlineBehaviour, +) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.CheckCircle) }, + title = title, + supportingText = supportingText, + actions = actions, + modifier = modifier, + behaviour = behaviour, + colors = BannerNotificationCardDefaults.successCardColors(), + border = BannerNotificationCardDefaults.successCardBorder(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/WarningBannerInlineNotificationCard.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/WarningBannerInlineNotificationCard.kt new file mode 100644 index 0000000..24cded2 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/banner/inline/WarningBannerInlineNotificationCard.kt @@ -0,0 +1,40 @@ +package app.k9mail.core.ui.compose.designsystem.organism.banner.inline + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.Warning +import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults + +/** + * Displays a warning banner inline notification card. + * + * @param title The main text or heading of the notification. + * @param supportingText Additional details or context for the notification. + * @param actions A composable lambda that defines the actions available in the notification, + * typically buttons, laid out in a [RowScope]. + * @param modifier Optional [Modifier] to be applied to the composable. + * @param behaviour Optional [BannerInlineNotificationCardBehaviour] to customize the appearance + * and behavior of the notification card. Defaults to [BannerNotificationCardDefaults.bannerInlineBehaviour]. + */ +@Composable +fun WarningBannerInlineNotificationCard( + title: CharSequence, + supportingText: CharSequence, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + behaviour: BannerInlineNotificationCardBehaviour = BannerNotificationCardDefaults.bannerInlineBehaviour, +) { + BannerInlineNotificationCard( + icon = { Icon(imageVector = Icons.Outlined.Warning) }, + title = title, + supportingText = supportingText, + actions = actions, + modifier = modifier, + behaviour = behaviour, + colors = BannerNotificationCardDefaults.warningCardColors(), + border = BannerNotificationCardDefaults.warningCardBorder(), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/ModalDrawerSheet.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/ModalDrawerSheet.kt new file mode 100644 index 0000000..fc53eb4 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/ModalDrawerSheet.kt @@ -0,0 +1,17 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.material3.ModalDrawerSheet as Material3ModalDrawerSheet + +@Composable +fun ModalDrawerSheet( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Material3ModalDrawerSheet( + modifier = modifier, + content = content, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/ModalNavigationDrawer.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/ModalNavigationDrawer.kt new file mode 100644 index 0000000..df10567 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/ModalNavigationDrawer.kt @@ -0,0 +1,43 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.material3.ModalNavigationDrawer as Material3ModalNavigationDrawer + +@Composable +fun ModalNavigationDrawer( + drawerContent: @Composable (closeDrawer: () -> Unit) -> Unit, + modifier: Modifier = Modifier, + gesturesEnabled: Boolean = true, + content: @Composable (openDrawer: () -> Unit) -> Unit, +) { + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() + + val openDrawer: () -> Unit = { scope.launch { drawerState.open() } } + val closeDrawer: () -> Unit = { + scope.launch { + delay(DRAWER_CLOSE_DELAY) + drawerState.close() + } + } + + Material3ModalNavigationDrawer( + drawerContent = { drawerContent(closeDrawer) }, + modifier = modifier, + drawerState = drawerState, + gesturesEnabled = gesturesEnabled, + content = { content(openDrawer) }, + ) +} + +/** + * Delay before closing the drawer to avoid the drawer being closed immediately and give time + * for the ripple effect to finish. + */ +private const val DRAWER_CLOSE_DELAY = 250L diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerDivider.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerDivider.kt new file mode 100644 index 0000000..16ce042 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerDivider.kt @@ -0,0 +1,20 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun NavigationDrawerDivider( + modifier: Modifier = Modifier, +) { + NavigationDrawerItemLayout( + modifier = modifier, + ) { paddingValues -> + HorizontalDivider( + modifier = Modifier + .padding(paddingValues), + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerHeadline.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerHeadline.kt new file mode 100644 index 0000000..d8ab671 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerHeadline.kt @@ -0,0 +1,30 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun NavigationDrawerHeadline( + title: String, + modifier: Modifier = Modifier, +) { + NavigationDrawerItemLayout( + modifier = modifier, + ) { + TextTitleSmall( + text = title, + color = MainTheme.colors.primary, + modifier = Modifier + .padding(NavigationDrawerItemDefaults.ItemPadding) + .padding( + top = MainTheme.spacings.triple, + bottom = MainTheme.spacings.double, + ) + .then(modifier), + ) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItem.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItem.kt new file mode 100644 index 0000000..0b8247f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItem.kt @@ -0,0 +1,111 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import androidx.compose.material3.NavigationDrawerItem as Material3NavigationDrawerItem + +/** + * A navigation drawer item that can be used in a navigation drawer. + * + * @param label The content of the label to be displayed in the item as a String. + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is clicked. + * @param modifier The [Modifier] to be applied to this item. + * @param icon An optional composable that represents an icon for this item. + * @param badge An optional composable that represents a badge for this item. + */ +@Composable +fun NavigationDrawerItem( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + badge: (@Composable () -> Unit)? = null, +) { + NavigationDrawerItem( + label = { + TextLabelLarge( + text = label, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + }, + selected = selected, + onClick = onClick, + modifier = modifier, + icon = icon, + badge = badge, + ) +} + +/** + * A navigation drawer item that can be used in a navigation drawer. + * + * @param label The content of the label to be displayed in the item as AnnotatedString. + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is clicked. + * @param modifier The [Modifier] to be applied to this item. + * @param icon An optional composable that represents an icon for this item. + * @param badge An optional composable that represents a badge for this item. + */ +@Composable +fun NavigationDrawerItem( + label: AnnotatedString, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + badge: (@Composable () -> Unit)? = null, +) { + NavigationDrawerItem( + label = { + TextLabelLarge( + text = label, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + }, + selected = selected, + onClick = onClick, + modifier = modifier, + icon = icon, + badge = badge, + ) +} + +/** + * A navigation drawer item that can be used in a navigation drawer. + * + * @param label The content of the label to be displayed in the item. + * @param selected Whether this item is selected. + * @param onClick The callback to be invoked when this item is clicked. + * @param modifier The [Modifier] to be applied to this item. + * @param icon An optional composable that represents an icon for this item. + * @param badge An optional composable that represents a badge for this item. + */ +@Composable +fun NavigationDrawerItem( + label: @Composable () -> Unit, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + badge: (@Composable () -> Unit)? = null, +) { + Material3NavigationDrawerItem( + label = label, + selected = selected, + onClick = onClick, + modifier = Modifier + .padding(NavigationDrawerItemDefaults.ItemPadding) + .then(modifier), + icon = icon, + badge = badge, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemBadge.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemBadge.kt new file mode 100644 index 0000000..4978505 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemBadge.kt @@ -0,0 +1,42 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +/** + * A badge for a navigation drawer item with an optional icon. + * + * @param label The label to display. + * @param modifier The modifier to apply. + * @param imageVector The image vector to display (optional). + */ +@Composable +fun NavigationDrawerItemBadge( + label: String, + modifier: Modifier = Modifier, + imageVector: ImageVector? = null, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + TextLabelLarge( + text = label, + ) + if (imageVector != null) { + Icon( + imageVector = imageVector, + modifier = Modifier.size(MainTheme.sizes.iconSmall) + .padding(start = MainTheme.spacings.quarter), + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemLayout.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemLayout.kt new file mode 100644 index 0000000..573452b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/drawer/NavigationDrawerItemLayout.kt @@ -0,0 +1,32 @@ +package app.k9mail.core.ui.compose.designsystem.organism.drawer + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun NavigationDrawerItemLayout( + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit, +) { + Row( + modifier = Modifier + .then(modifier) + .padding( + start = MainTheme.spacings.double, + end = MainTheme.spacings.triple, + ), + ) { + content(defaultInnerPaddingValues()) + } +} + +@Composable +private fun defaultInnerPaddingValues(): PaddingValues { + return PaddingValues( + horizontal = MainTheme.spacings.oneHalf, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarDuration.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarDuration.kt new file mode 100644 index 0000000..65998ea --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarDuration.kt @@ -0,0 +1,21 @@ +package app.k9mail.core.ui.compose.designsystem.organism.snackbar + +import androidx.compose.material3.SnackbarDuration as Material3SnackbarDuration + +/** Possible durations of the [Snackbar] in [SnackbarHost] */ +enum class SnackbarDuration { + /** Show the Snackbar for a short period of time */ + Short, + + /** Show the Snackbar for a long period of time */ + Long, + + /** Show the Snackbar indefinitely until explicitly dismissed or action is clicked */ + Indefinite, +} + +internal fun SnackbarDuration.toMaterial3SnackbarDuration(): Material3SnackbarDuration = when (this) { + SnackbarDuration.Short -> Material3SnackbarDuration.Short + SnackbarDuration.Long -> Material3SnackbarDuration.Long + SnackbarDuration.Indefinite -> Material3SnackbarDuration.Indefinite +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarHost.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarHost.kt new file mode 100644 index 0000000..61cb0b3 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarHost.kt @@ -0,0 +1,34 @@ +package app.k9mail.core.ui.compose.designsystem.organism.snackbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.material3.SnackbarHost as Material3SnackbarHost +import androidx.compose.material3.SnackbarHostState as Material3SnackbarHostState + +/** + * Snackbars provide brief messages about app processes at the bottom of the screen. + * + * It uses the Material 3 [SnackbarHost] implementation under the hood. + * + * @param hostState state of this component to manage Snackbar show/dismiss timings. + * @param modifier the [Modifier] to be applied to this component. + */ +@Composable +fun SnackbarHost( + hostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + Material3SnackbarHost( + hostState = (hostState as Material3BackedSnackbarHostState).m3State, + modifier = modifier, + ) +} + +/** + * Creates a [SnackbarHostState] that is remembered across compositions. + */ +@Composable +fun rememberSnackbarHostState(): SnackbarHostState { + return remember { Material3BackedSnackbarHostState(m3State = Material3SnackbarHostState()) } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarHostState.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarHostState.kt new file mode 100644 index 0000000..82f0966 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarHostState.kt @@ -0,0 +1,67 @@ +package app.k9mail.core.ui.compose.designsystem.organism.snackbar + +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.runtime.Stable +import androidx.compose.material3.SnackbarHostState as Material3SnackbarHostState + +/** + * State of the [SnackbarHost], which controls the queue and the current [Snackbar] being shown + * inside the [SnackbarHost]. + * + * This state is usually [remembered][rememberSnackbarHostState] and used to provide a [SnackbarHost] to a [Scaffold]. + * + * @see rememberSnackbarHostState + */ +@Stable +sealed interface SnackbarHostState { + /** + * Shows or queues to be shown a [Snackbar] at the bottom of the [Scaffold] to which this state + * is attached and suspends until the snackbar has disappeared. + * + * [SnackbarHostState] guarantees to show at most one snackbar at a time. If this function is + * called while another snackbar is already visible, it will be suspended until this snackbar is + * shown and subsequently addressed. If the caller is cancelled, the snackbar will be removed + * from display and/or the queue to be displayed. + * + * To change the Snackbar appearance, change it in 'snackbarHost' on the [Scaffold]. + * + * @param message text to be shown in the Snackbar + * @param actionLabel optional action label to show as button in the Snackbar + * @param withDismissAction a boolean to show a dismiss action in the Snackbar. This is + * recommended to be set to true for better accessibility when a Snackbar is set with a + * [SnackbarDuration.Indefinite] + * @param duration duration to control how long snackbar will be shown in [SnackbarHost], either + * [SnackbarDuration.Short], [SnackbarDuration.Long] or [SnackbarDuration.Indefinite]. + * @return [SnackbarResult.ActionPerformed] if option action has been clicked or + * [SnackbarResult.Dismissed] if snackbar has been dismissed via timeout or by the user + */ + suspend fun showSnackbar( + message: String, + actionLabel: String? = null, + withDismissAction: Boolean = false, + duration: SnackbarDuration = + if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite, + ): SnackbarResult +} + +@Stable +internal data class Material3BackedSnackbarHostState( + val m3State: Material3SnackbarHostState, +) : SnackbarHostState { + override suspend fun showSnackbar( + message: String, + actionLabel: String?, + withDismissAction: Boolean, + duration: SnackbarDuration, + ): SnackbarResult { + val m3Result = m3State.showSnackbar( + message = message, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + duration = duration.toMaterial3SnackbarDuration(), + ) + + return m3Result.toSnackbarResult() + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarResult.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarResult.kt new file mode 100644 index 0000000..3c4c4d3 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/organism/snackbar/SnackbarResult.kt @@ -0,0 +1,17 @@ +package app.k9mail.core.ui.compose.designsystem.organism.snackbar + +import androidx.compose.material3.SnackbarResult as Material3SnackbarResult + +/** Possible results of the [SnackbarHostState.showSnackbar] call */ +enum class SnackbarResult { + /** [Snackbar] that is shown has been dismissed either by timeout of by user */ + Dismissed, + + /** Action on the [Snackbar] has been clicked before the time out passed */ + ActionPerformed, +} + +internal fun Material3SnackbarResult.toSnackbarResult(): SnackbarResult = when (this) { + Material3SnackbarResult.Dismissed -> SnackbarResult.Dismissed + Material3SnackbarResult.ActionPerformed -> SnackbarResult.ActionPerformed +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/LazyColumnWithHeaderFooter.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/LazyColumnWithHeaderFooter.kt new file mode 100644 index 0000000..a1ed873 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/LazyColumnWithHeaderFooter.kt @@ -0,0 +1,77 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp + +/** + * The [LazyColumnWithHeaderFooter] composable creates a [LazyColumn] with header and footer items. + * + * @param modifier The modifier to be applied to the layout. + * @param verticalArrangement The vertical arrangement of the children. + * @param horizontalAlignment The horizontal alignment of the children. + * @param header The header to be displayed at the top of the [LazyColumn]. + * @param footer The footer to be displayed at the bottom of the [LazyColumn]. + * @param content The content of the [LazyColumn]. + */ +@Composable +fun LazyColumnWithHeaderFooter( + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + header: @Composable () -> Unit = {}, + footer: @Composable () -> Unit = {}, + content: LazyListScope.() -> Unit, +) { + LazyColumn( + modifier = modifier, + state = state, + contentPadding = contentPadding, + verticalArrangement = verticalArrangementWithHeaderFooter(verticalArrangement), + horizontalAlignment = horizontalAlignment, + ) { + item { header() } + content() + item { footer() } + } +} + +@Composable +private fun verticalArrangementWithHeaderFooter(verticalArrangement: Arrangement.Vertical) = remember { + object : Arrangement.Vertical { + override fun Density.arrange( + totalSize: Int, + sizes: IntArray, + outPositions: IntArray, + ) { + val headerSize = sizes.first() + val footerSize = sizes.last() + val innerTotalSize = totalSize - (headerSize + footerSize) + val innerSizes = sizes.copyOfRange(1, sizes.lastIndex) + val innerOutPositions = outPositions.copyOfRange(1, outPositions.lastIndex) + + with(verticalArrangement) { + arrange( + totalSize = innerTotalSize, + sizes = innerSizes, + outPositions = innerOutPositions, + ) + } + + innerOutPositions.forEachIndexed { index, position -> outPositions[index + 1] = position + headerSize } + outPositions[0] = 0 + outPositions[outPositions.lastIndex] = totalSize - footerSize + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ListDetailPane.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ListDetailPane.kt new file mode 100644 index 0000000..7e5ef06 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ListDetailPane.kt @@ -0,0 +1,120 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import kotlinx.coroutines.launch + +/** + * A list and detail pane layout that can be used to display a list of items and a detail view. + * + * @param navigationController A [ListDetailNavigationController] that can be used to navigate between list + * and detail panes. + * @param listPane A composable that displays the list of items. + * @param detailPane A composable that displays the detail view of an item. + * @param modifier The modifier to apply to this layout. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun ListDetailPane( + navigationController: MutableState>, + listPane: @Composable () -> Unit, + detailPane: @Composable (T) -> Unit, + modifier: Modifier = Modifier, +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(navigator) { + navigationController.value = DefaultListDetailNavigationController( + navigator = navigator, + ) + } + + BackHandler(navigator.canNavigateBack()) { + coroutineScope.launch { + navigator.navigateBack() + } + } + + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + listPane = { + AnimatedPane { + listPane() + } + }, + detailPane = { + navigator.currentDestination?.contentKey?.let { item -> + AnimatedPane { + detailPane(item) + } + } + }, + modifier = modifier, + ) +} + +/** + * Creates a [ListDetailNavigationController] that can be used to navigate between list and + * detail panes in a [ListDetailPane]. + */ +@Composable +fun rememberListDetailNavigationController(): MutableState> { + val defaultController = remember { NoOpListDetailNavigationController() } + return remember { mutableStateOf(defaultController) } +} + +/** + * A controller that can be used to navigate between list and detail panes in a [ListDetailPane]. + * + * It is recommended to use [rememberListDetailNavigationController] to create an instance of this controller. + * + * @see rememberListDetailNavigationController + */ +interface ListDetailNavigationController { + fun canNavigateBack(): Boolean + suspend fun navigateBack(): Boolean + suspend fun navigateToDetail(item: T) + + fun paneCount(): Int +} + +/** + * A [ListDetailNavigationController] that does nothing. + */ +internal class NoOpListDetailNavigationController : ListDetailNavigationController { + override fun canNavigateBack() = false + override suspend fun navigateBack() = false + override suspend fun navigateToDetail(item: T) = Unit + + override fun paneCount() = 1 +} + +/** + * A [ListDetailNavigationController] that wrappes a [ThreePaneScaffoldNavigator] to navigate + * between list and detail panes. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +internal class DefaultListDetailNavigationController( + private val navigator: ThreePaneScaffoldNavigator, +) : ListDetailNavigationController { + override fun canNavigateBack() = navigator.canNavigateBack() + override suspend fun navigateBack() = navigator.navigateBack() + override suspend fun navigateToDetail(item: T) = navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, item) + + override fun paneCount(): Int = navigator.scaffoldDirective.maxHorizontalPartitions +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContent.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContent.kt new file mode 100644 index 0000000..cadb96b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContent.kt @@ -0,0 +1,108 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.common.padding.calculateResponsiveWidthPadding +import app.k9mail.core.ui.compose.common.window.WindowSizeClass +import app.k9mail.core.ui.compose.common.window.getWindowSizeInfo +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.theme2.MainTheme + +/** + * The [ResponsiveContent] composable automatically adapts its child content to different screen sizes and resolutions, + * providing a responsive layout for a better user experience. + * + * It uses the [WindowSizeClass] (Compact, Medium, or Expanded) to make appropriate layout adjustments. + * + * @param modifier The modifier to be applied to the layout. + * @param content The content to be displayed. + */ +@Composable +fun ResponsiveContent( + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit, +) { + val windowSizeClass = getWindowSizeInfo() + + when (windowSizeClass.screenWidthSizeClass) { + WindowSizeClass.Compact -> CompactContent(modifier = modifier, content = content) + WindowSizeClass.Medium -> MediumContent(modifier = modifier, content = content) + WindowSizeClass.Expanded -> ExpandedContent(modifier = modifier, content = content) + } +} + +@Composable +private fun CompactContent( + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .then(modifier), + ) { + content(calculateResponsiveWidthPadding()) + } +} + +@Composable +private fun MediumContent( + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .then(modifier), + contentAlignment = Alignment.TopCenter, + ) { + content(calculateResponsiveWidthPadding()) + } +} + +@Composable +private fun ExpandedContent( + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit, +) { + when (getWindowSizeInfo().screenHeightSizeClass) { + WindowSizeClass.Compact -> MediumContent(modifier, content) + WindowSizeClass.Medium -> { + Box( + modifier = Modifier + .fillMaxSize() + .then(modifier), + contentAlignment = Alignment.TopCenter, + ) { + Surface( + tonalElevation = MainTheme.elevations.level1, + ) { + content(calculateResponsiveWidthPadding()) + } + } + } + + WindowSizeClass.Expanded -> { + Box( + modifier = Modifier + .fillMaxSize() + .then(modifier), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier + .requiredHeight(WindowSizeClass.MEDIUM_MAX_HEIGHT.dp), + tonalElevation = MainTheme.elevations.level1, + ) { + content(calculateResponsiveWidthPadding()) + } + } + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentWithSurface.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentWithSurface.kt new file mode 100644 index 0000000..1924a8d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveContentWithSurface.kt @@ -0,0 +1,28 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.theme2.MainTheme + +/** + * The [ResponsiveContentWithSurface] composable embeds its content in [ResponsiveContent] with [Surface]. + * + * @param modifier The modifier to be applied to the layout. + * @param content The content to be displayed. + */ +@Composable +fun ResponsiveContentWithSurface( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + ResponsiveContent { contentPadding -> + Surface( + modifier = modifier.padding(contentPadding), + color = MainTheme.colors.surface, + ) { + content() + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveWidthContainer.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveWidthContainer.kt new file mode 100644 index 0000000..22ed11f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/ResponsiveWidthContainer.kt @@ -0,0 +1,43 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.padding.calculateResponsiveWidthPadding + +/** + * A container that adjusts its width depending on the screen size. + * + * This composable function acts as a wrapper for its content, applying a modifier that changes + * the width of the content based on the current screen size. It uses the `getWindowSizeInfo` + * function to determine the screen size, and then applies the appropriate modifier. + * + * @param modifier Any modifier that should be applied to the outer container. This can be used + * to add padding, background colors, click events, etc. + * @param content The content to be placed inside this container. The content is expected to be + * a composable function. + * + * Example usage: + * ``` + * ResponsiveWidthContainer { + * Text("Hello, World!") + * } + * ``` + */ +@Composable +fun ResponsiveWidthContainer( + modifier: Modifier = Modifier, + content: @Composable (paddingValues: PaddingValues) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + contentAlignment = Alignment.TopCenter, + ) { + content(calculateResponsiveWidthPadding()) + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/Scaffold.kt b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/Scaffold.kt new file mode 100644 index 0000000..47fbdd8 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/app/k9mail/core/ui/compose/designsystem/template/Scaffold.kt @@ -0,0 +1,45 @@ +package app.k9mail.core.ui.compose.designsystem.template + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.material3.FabPosition as Material3FabPosition +import androidx.compose.material3.Scaffold as Material3Scaffold + +@Suppress("LongParameterList") +@Composable +fun Scaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: ScaffoldFabPosition = ScaffoldFabPosition.End, + content: @Composable (PaddingValues) -> Unit, +) { + Material3Scaffold( + modifier = modifier, + topBar = topBar, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition.toMaterialFabPosition(), + content = content, + ) +} + +enum class ScaffoldFabPosition { + Start, + Center, + End, + EndOverlay, +} + +private fun ScaffoldFabPosition.toMaterialFabPosition(): Material3FabPosition { + return when (this) { + ScaffoldFabPosition.Start -> Material3FabPosition.Start + ScaffoldFabPosition.Center -> Material3FabPosition.Center + ScaffoldFabPosition.End -> Material3FabPosition.End + ScaffoldFabPosition.EndOverlay -> Material3FabPosition.EndOverlay + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/button/FavouriteButtonIcon.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/button/FavouriteButtonIcon.kt new file mode 100644 index 0000000..83209d2 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/button/FavouriteButtonIcon.kt @@ -0,0 +1,30 @@ +package net.thunderbird.core.ui.compose.designsystem.atom.button + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIconDefaults +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.designsystem.atom.icon.filled.Star +import net.thunderbird.core.ui.compose.designsystem.atom.icon.outlined.Star + +@Composable +fun FavouriteButtonIcon( + favourite: Boolean, + onFavouriteChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + size: Dp = MainTheme.sizes.icon, +) { + ButtonIcon( + onClick = { onFavouriteChange(!favourite) }, + imageVector = if (favourite) Icons.Filled.Star else Icons.Outlined.Star, + colors = ButtonIconDefaults.buttonIconColors( + contentColor = if (favourite) Color(color = 0xFFFF8C00) else MainTheme.colors.onSurface, + ), + modifier = modifier.size(size), + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/NewMailBadge.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/NewMailBadge.kt new file mode 100644 index 0000000..176c1ab --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/NewMailBadge.kt @@ -0,0 +1,63 @@ +package net.thunderbird.core.ui.compose.designsystem.atom.icon.filled + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Suppress("MagicNumber", "UnusedReceiverParameter") +val Icons.Filled.NewMailBadge: ImageVector + get() { + val current = _newMailBadge + if (current != null) return current + + return ImageVector.Builder( + name = "net.thunderbird.core.ui.compose.theme2.MainTheme.NewMailBadge", + defaultWidth = 12.0.dp, + defaultHeight = 12.0.dp, + viewportWidth = 12.0f, + viewportHeight = 12.0f, + ).apply { + path( + fill = SolidColor(Color(0xFFF4C430)), + stroke = SolidColor(Color(0xFFFF8C00)), + ) { + moveTo(x = 6.0f, y = 0.5f) + curveTo(x1 = 6.264f, y1 = 0.5f, x2 = 6.52061f, y2 = 0.547953f, x3 = 6.7666f, y3 = 0.643555f) + curveTo(x1 = 7.02686f, y1 = 0.744765f, x2 = 7.25861f, y2 = 0.90322f, x3 = 7.46191f, y3 = 1.10645f) + lineTo(x = 10.8936f, y = 4.53809f) + curveTo(x1 = 11.0968f, y1 = 4.74139f, x2 = 11.2552f, y2 = 4.97314f, x3 = 11.3564f, y3 = 5.2334f) + curveTo(x1 = 11.452f, y1 = 5.47939f, x2 = 11.5f, y2 = 5.736f, x3 = 11.5f, y3 = 6.0f) + curveTo(x1 = 11.5f, y1 = 6.264f, x2 = 11.452f, y2 = 6.52061f, x3 = 11.3564f, y3 = 6.7666f) + curveTo(x1 = 11.2552f, y1 = 7.02686f, x2 = 11.0968f, y2 = 7.25861f, x3 = 10.8936f, y3 = 7.46191f) + lineTo(x = 7.46191f, y = 10.8936f) + curveTo(x1 = 7.25861f, y1 = 11.0968f, x2 = 7.02686f, y2 = 11.2552f, x3 = 6.7666f, y3 = 11.3564f) + curveTo(x1 = 6.52061f, y1 = 11.452f, x2 = 6.264f, y2 = 11.5f, x3 = 6.0f, y3 = 11.5f) + curveTo(x1 = 5.736f, y1 = 11.5f, x2 = 5.47939f, y2 = 11.452f, x3 = 5.2334f, y3 = 11.3564f) + curveTo(x1 = 4.97314f, y1 = 11.2552f, x2 = 4.74139f, y2 = 11.0968f, x3 = 4.53809f, y3 = 10.8936f) + lineTo(x = 1.10645f, y = 7.46191f) + curveTo(x1 = 0.90322f, y1 = 7.25861f, x2 = 0.744765f, y2 = 7.02686f, x3 = 0.643555f, y3 = 6.7666f) + curveTo(x1 = 0.547953f, y1 = 6.52061f, x2 = 0.5f, y2 = 6.264f, x3 = 0.5f, y3 = 6.0f) + curveTo(x1 = 0.5f, y1 = 5.736f, x2 = 0.547953f, y2 = 5.47939f, x3 = 0.643555f, y3 = 5.2334f) + curveTo(x1 = 0.744766f, y1 = 4.97314f, x2 = 0.90322f, y2 = 4.74139f, x3 = 1.10645f, y3 = 4.53809f) + lineTo(x = 4.53809f, y = 1.10645f) + curveTo(x1 = 4.74139f, y1 = 0.90322f, x2 = 4.97314f, y2 = 0.744766f, x3 = 5.2334f, y3 = 0.643555f) + curveTo(x1 = 5.47939f, y1 = 0.547953f, x2 = 5.736f, y2 = 0.5f, x3 = 6.0f, y3 = 0.5f) + close() + } + }.build().also { _newMailBadge = it } + } + +@Suppress("ObjectPropertyName") +private var _newMailBadge: ImageVector? = null + +@Preview +@Composable +private fun Preview() { + Image(imageVector = Icons.Filled.NewMailBadge, contentDescription = null) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/Star.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/Star.kt new file mode 100644 index 0000000..0ee9060 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/Star.kt @@ -0,0 +1,52 @@ +package net.thunderbird.core.ui.compose.designsystem.atom.icon.filled + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Suppress("MagicNumber", "UnusedReceiverParameter") +val Icons.Filled.Star: ImageVector + get() { + val current = _star + if (current != null) return current + + return ImageVector.Builder( + name = "net.thunderbird.core.ui.compose.theme2.MainTheme.Star", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + path( + fill = SolidColor(Color(0xFFFF8C00)), + ) { + moveTo(x = 12.0f, y = 17.77f) + lineTo(x = 18.18f, y = 21.5f) + lineTo(x = 16.54f, y = 14.47f) + lineTo(x = 22.0f, y = 9.74f) + lineTo(x = 14.81f, y = 9.13f) + lineTo(x = 12.0f, y = 2.5f) + lineTo(x = 9.19f, y = 9.13f) + lineTo(x = 2.0f, y = 9.74f) + lineTo(x = 7.46f, y = 14.47f) + lineTo(x = 5.82f, y = 21.5f) + lineTo(x = 12.0f, y = 17.77f) + close() + } + }.build().also { _star = it } + } + +@Suppress("ObjectPropertyName") +private var _star: ImageVector? = null + +@Preview +@Composable +private fun Preview() { + Image(imageVector = Icons.Filled.Star, contentDescription = null) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/UnreadMailBadge.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/UnreadMailBadge.kt new file mode 100644 index 0000000..c7cb8c3 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/filled/UnreadMailBadge.kt @@ -0,0 +1,51 @@ +package net.thunderbird.core.ui.compose.designsystem.atom.icon.filled + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Suppress("MagicNumber", "UnusedReceiverParameter") +val Icons.Filled.UnreadMailBadge: ImageVector + get() { + val current = _unreadMailBadge + if (current != null) return current + + return ImageVector.Builder( + name = "net.thunderbird.core.ui.compose.theme2.MainTheme.UnreadMailBadge", + defaultWidth = 12.0.dp, + defaultHeight = 12.0.dp, + viewportWidth = 12.0f, + viewportHeight = 12.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF34C759)), + stroke = SolidColor(Color(0xFF1D783B)), + ) { + moveTo(x = 5.99854f, y = 1.00024f) + curveTo(x1 = 7.38181f, y1 = 1.00025f, x2 = 8.57321f, y2 = 1.48747f, x3 = 9.54248f, y3 = 2.45532f) + curveTo(x1 = 10.512f, y1 = 3.42345f, x2 = 11.0005f, y2 = 4.6147f, x3 = 11.0005f, y3 = 5.99829f) + curveTo(x1 = 11.0005f, y1 = 7.38164f, x2 = 10.5124f, y2 = 8.57294f, x3 = 9.54443f, y3 = 9.54224f) + curveTo(x1 = 8.5764f, y1 = 10.5115f, x2 = 7.38583f, y2 = 11.0002f, x3 = 6.00244f, y3 = 11.0002f) + curveTo(x1 = 4.61908f, y1 = 11.0002f, x2 = 3.4278f, y2 = 10.5121f, x3 = 2.4585f, y3 = 9.54419f) + curveTo(x1 = 1.48911f, y1 = 8.57613f, x2 = 1.00055f, y2 = 7.38566f, x3 = 1.00049f, y3 = 6.0022f) + curveTo(x1 = 1.00049f, y1 = 4.61899f, x2 = 1.48781f, y2 = 3.4275f, x3 = 2.45557f, y3 = 2.45825f) + curveTo(x1 = 3.4237f, y1 = 1.48877f, x2 = 4.61494f, y2 = 1.00024f, x3 = 5.99854f, y3 = 1.00024f) + close() + } + }.build().also { _unreadMailBadge = it } + } + +@Suppress("ObjectPropertyName") +private var _unreadMailBadge: ImageVector? = null + +@Preview +@Composable +private fun Preview() { + Image(imageVector = Icons.Filled.UnreadMailBadge, contentDescription = null) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/outlined/Star.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/outlined/Star.kt new file mode 100644 index 0000000..8c255f3 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/atom/icon/outlined/Star.kt @@ -0,0 +1,64 @@ +package net.thunderbird.core.ui.compose.designsystem.atom.icon.outlined + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons + +@Suppress("MagicNumber", "UnusedReceiverParameter") +val Icons.Outlined.Star: ImageVector + get() { + val current = _star + if (current != null) return current + + return ImageVector.Builder( + name = "net.thunderbird.core.ui.compose.theme2.MainTheme.Star", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF1C1B1B)), + ) { + moveTo(x = 22.0f, y = 9.74f) + lineTo(x = 14.81f, y = 9.12f) + lineTo(x = 12.0f, y = 2.5f) + lineTo(x = 9.19f, y = 9.13f) + lineTo(x = 2.0f, y = 9.74f) + lineTo(x = 7.46f, y = 14.47f) + lineTo(x = 5.82f, y = 21.5f) + lineTo(x = 12.0f, y = 17.77f) + lineTo(x = 18.18f, y = 21.5f) + lineTo(x = 16.55f, y = 14.47f) + lineTo(x = 22.0f, y = 9.74f) + close() + moveTo(x = 12.0f, y = 15.9f) + lineTo(x = 8.24f, y = 18.17f) + lineTo(x = 9.24f, y = 13.89f) + lineTo(x = 5.92f, y = 11.01f) + lineTo(x = 10.3f, y = 10.63f) + lineTo(x = 12.0f, y = 6.6f) + lineTo(x = 13.71f, y = 10.64f) + lineTo(x = 18.09f, y = 11.02f) + lineTo(x = 14.77f, y = 13.9f) + lineTo(x = 15.77f, y = 18.18f) + lineTo(x = 12.0f, y = 15.9f) + close() + } + }.build().also { _star = it } + } + +@Suppress("ObjectPropertyName") +private var _star: ImageVector? = null + +@Preview(showBackground = true) +@Composable +private fun Preview() { + Image(imageVector = Icons.Outlined.Star, contentDescription = null) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/message/MessageItemSenderText.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/message/MessageItemSenderText.kt new file mode 100644 index 0000000..bc4e639 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/message/MessageItemSenderText.kt @@ -0,0 +1,95 @@ +package net.thunderbird.core.ui.compose.designsystem.molecule.message + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelSmall +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +internal fun MessageItemSenderTitleSmall( + sender: String, + subject: String, + swapSenderWithSubject: Boolean, + threadCount: Int, + modifier: Modifier = Modifier, + color: Color = MainTheme.colors.onSurface, +) { + MessageItemSenderText( + sender = sender, + subject = subject, + swapSenderWithSubject = swapSenderWithSubject, + text = { text -> + TextTitleSmall( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + color = color, + ) + }, + threadCount = threadCount, + modifier = modifier, + color = color, + ) +} + +@Composable +internal fun MessageItemSenderBodyMedium( + sender: String, + subject: String, + swapSenderWithSubject: Boolean, + threadCount: Int, + modifier: Modifier = Modifier, + color: Color = MainTheme.colors.onSurface, +) { + MessageItemSenderText( + sender = sender, + subject = subject, + swapSenderWithSubject = swapSenderWithSubject, + text = { text -> + TextBodyMedium( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + color = color, + ) + }, + threadCount = threadCount, + modifier = modifier, + color = color, + ) +} + +@Composable +private fun MessageItemSenderText( + sender: String, + subject: String, + swapSenderWithSubject: Boolean, + text: @Composable RowScope.(text: String) -> Unit, + threadCount: Int, + modifier: Modifier = Modifier, + color: Color = MainTheme.colors.onSurface, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + modifier = modifier, + ) { + text(if (swapSenderWithSubject) subject else sender) + if (threadCount > 0) { + TextLabelSmall( + text = threadCount.toString(), + color = color, + ) + } + } +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ActiveMessageItem.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ActiveMessageItem.kt new file mode 100644 index 0000000..4e05044 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ActiveMessageItem.kt @@ -0,0 +1,86 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.datetime.LocalDateTime +import net.thunderbird.core.ui.compose.designsystem.atom.button.FavouriteButtonIcon +import net.thunderbird.core.ui.compose.designsystem.molecule.message.MessageItemSenderBodyMedium + +/** + * Represents a message item in its Active state. + * + * @param sender The name of the sender. + * @param subject The subject of the message. + * @param preview A short preview of the message content. + * @param receivedAt The date and time the message was received. + * @param favourite Whether the message is marked as favourite. + * @param avatar A composable function to display the sender's avatar. + * @param onClick A lambda function to be invoked when the message item is clicked. + * @param onFavouriteChange A lambda function to be invoked when the favourite button is clicked. + * @param modifier A [Modifier] to be applied to the message item. + * @param hasAttachments Whether the message has attachments. Defaults to `false`. + * @param threadCount The number of messages in the thread. Defaults to `0`. If greater than 0, + * it will be displayed next to the sender. + * @param selected Whether the message item is currently selected. Defaults to `false`. + * @param maxPreviewLines The maximum number of lines to display for the preview. Defaults to `2`. + * @param contentPadding The padding to apply to the content of the message item. Defaults to + * [MessageItemDefaults.defaultContentPadding]. + * @param swapSenderWithSubject If `true`, the sender and subject will be swapped in their display positions. + * Defaults to `false`. + */ +@Composable +fun ActiveMessageItem( + sender: String, + subject: String, + preview: String, + receivedAt: LocalDateTime, + avatar: @Composable () -> Unit, + onClick: () -> Unit, + onFavouriteChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + favourite: Boolean = false, + hasAttachments: Boolean = false, + threadCount: Int = 0, + selected: Boolean = false, + maxPreviewLines: Int = 2, + contentPadding: PaddingValues = MessageItemDefaults.defaultContentPadding, + swapSenderWithSubject: Boolean = false, +) { + MessageItem( + leading = avatar, + sender = { + MessageItemSenderBodyMedium( + sender = sender, + subject = subject, + swapSenderWithSubject = swapSenderWithSubject, + threadCount = threadCount, + color = MainTheme.colors.onSurfaceVariant, + ) + }, + subject = { + TextBodyMedium( + text = if (swapSenderWithSubject) sender else subject, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + preview = preview, + action = { FavouriteButtonIcon(favourite = favourite, onFavouriteChange = onFavouriteChange) }, + receivedAt = receivedAt, + onClick = onClick, + colors = if (selected) { + MessageItemDefaults.selectedMessageItemColors() + } else { + MessageItemDefaults.activeMessageItemColors() + }, + modifier = modifier, + hasAttachments = hasAttachments, + selected = selected, + maxPreviewLines = maxPreviewLines, + contentPadding = contentPadding, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/JunkMessageItem.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/JunkMessageItem.kt new file mode 100644 index 0000000..70b760e --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/JunkMessageItem.kt @@ -0,0 +1,70 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.datetime.LocalDateTime +import net.thunderbird.core.ui.compose.designsystem.molecule.message.MessageItemSenderBodyMedium + +@Composable +fun JunkMessageItem( + sender: String, + subject: String, + preview: String, + receivedAt: LocalDateTime, + avatar: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + hasAttachments: Boolean = false, + threadCount: Int = 0, + selected: Boolean = false, + maxPreviewLines: Int = 2, + contentPadding: PaddingValues = MessageItemDefaults.defaultContentPadding, + swapSenderWithSubject: Boolean = false, +) { + MessageItem( + leading = avatar, + sender = { + MessageItemSenderBodyMedium( + sender = sender, + subject = subject, + swapSenderWithSubject = swapSenderWithSubject, + threadCount = threadCount, + color = MainTheme.colors.onSurfaceVariant, + ) + }, + subject = { + TextBodyMedium( + text = if (swapSenderWithSubject) sender else subject, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + preview = preview, + action = { + Icon( + imageVector = Icons.Outlined.Report, + tint = MainTheme.colors.onErrorContainer, + ) + }, + receivedAt = receivedAt, + onClick = onClick, + colors = if (selected) { + MessageItemDefaults.selectedMessageItemColors( + containerColor = MainTheme.colors.errorContainer, + ) + } else { + MessageItemDefaults.junkMessageItemColors() + }, + modifier = modifier, + hasAttachments = hasAttachments, + selected = selected, + maxPreviewLines = maxPreviewLines, + contentPadding = contentPadding, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItem.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItem.kt new file mode 100644 index 0000000..218c109 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItem.kt @@ -0,0 +1,273 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIconDefaults +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelSmall +import app.k9mail.core.ui.compose.theme2.LocalContentColor +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.Padding +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime +import net.thunderbird.core.ui.compose.common.date.LocalDateTimeConfiguration + +private const val WEEK_DAYS = 7 + +/** + * Displays a single message item. + * + * This composable function is responsible for rendering a single message item within a list. It includes + * information such as the sender, subject, preview, received time, and actions. + * + * @param leading A composable function to display the leading content (e.g., avatar). + * @param sender A composable function to display the sender's information. + * @param subject A composable function to display the message subject. + * @param preview The message preview text. + * @param action A composable function to display actions related to the message (e.g., star). + * @param receivedAt The date and time the message was received. + * @param onClick A callback function to be invoked when the message item is clicked. + * @param colors The colors to be used for the message item. See [MessageItemDefaults]. + * @param modifier The modifier to be applied to the message item. + * @param hasAttachments A boolean indicating whether the message has attachments. + * Defaults to `false`. + * @param selected A boolean indicating whether the message item is selected. + * Defaults to `false`. + * @param maxPreviewLines The maximum number of lines to display for the message preview. + * Defaults to `2`. + * @param contentPadding The padding to be applied to the content of the message item. + * Defaults to [MessageItemDefaults.defaultContentPadding]. + * @see MessageItemDefaults + */ +@Composable +internal fun MessageItem( + leading: @Composable () -> Unit, + sender: @Composable () -> Unit, + subject: @Composable () -> Unit, + preview: CharSequence, + action: @Composable () -> Unit, + receivedAt: LocalDateTime, + onClick: () -> Unit, + modifier: Modifier = Modifier, + colors: MessageItemColors = MessageItemDefaults.readMessageItemColors(), + hasAttachments: Boolean = false, + selected: Boolean = false, + maxPreviewLines: Int = 2, + contentPadding: PaddingValues = MessageItemDefaults.defaultContentPadding, +) { + val outlineVariant = MainTheme.colors.outlineVariant + var contentStart by remember { mutableFloatStateOf(0f) } + val layoutDirection = LocalLayoutDirection.current + Surface( + onClick = onClick, + modifier = modifier + .drawWithCache { + onDrawWithContent { + drawContent() + val x = contentStart + contentPadding.calculateStartPadding(layoutDirection).toPx() + drawOutline( + outline = Outline.Rectangle( + rect = Rect( + offset = Offset(x = x, y = size.height - 1.dp.toPx()), + size = Size(width = size.width - x, height = 1.dp.toPx()), + ), + ), + color = outlineVariant, + ) + } + }, + color = colors.containerColor, + contentColor = colors.contentColor, + ) { + Row( + modifier = Modifier + .padding(contentPadding) + .height(intrinsicSize = IntrinsicSize.Min), + ) { + LeadingElements(selected, onClick, leading) + Spacer(modifier = Modifier.width(MainTheme.spacings.default)) + Column( + modifier = Modifier + .weight(1f) + .onPlaced { contentStart = it.positionInParent().x }, + ) { + sender() + CompositionLocalProvider(LocalContentColor provides colors.subjectColor) { + subject() + } + Spacer(modifier = Modifier.height(MainTheme.spacings.half)) + PreviewText(preview = preview, maxLines = maxPreviewLines) + } + Spacer(modifier = Modifier.width(MainTheme.spacings.double)) + TrailingElements( + receivedAt = receivedAt, + action = action, + hasAttachments = hasAttachments, + modifier = Modifier.heightIn(min = MainTheme.sizes.large), + ) + } + } +} + +@Composable +private fun PreviewText( + preview: CharSequence, + maxLines: Int, + modifier: Modifier = Modifier, +) { + when (preview) { + is AnnotatedString -> TextBodySmall( + text = preview, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + + else -> TextBodySmall( + text = preview.toString(), + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } +} + +@Composable +private fun LeadingElements( + selected: Boolean, + onClick: () -> Unit, + trailing: @Composable (() -> Unit), + modifier: Modifier = Modifier, +) { + AnimatedContent( + targetState = selected, + modifier = modifier, + ) { selected -> + if (selected) { + SelectedIcon(onClick = onClick) + } else { + trailing() + } + } +} + +@Composable +private fun SelectedIcon( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ButtonIcon( + onClick = onClick, + imageVector = Icons.Outlined.Check, + colors = ButtonIconDefaults.buttonIconFilledColors( + containerColor = MainTheme.colors.secondaryContainer, + contentColor = contentColorFor(backgroundColor = MainTheme.colors.secondaryContainer), + ), + modifier = modifier, + ) +} + +@Composable +private fun TrailingElements( + receivedAt: LocalDateTime, + action: @Composable (() -> Unit), + hasAttachments: Boolean, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + modifier = modifier, + ) { + MessageItemDate(receivedAt = receivedAt) + action() + if (hasAttachments) { + Icon( + imageVector = Icons.Outlined.Attachment, + modifier = Modifier.size(MainTheme.sizes.icon), + ) + } + } +} + +@Composable +private fun MessageItemDate( + receivedAt: LocalDateTime, + modifier: Modifier = Modifier, +) { + val dateTimeConfiguration = LocalDateTimeConfiguration.current + val formatter = LocalDateTime.Format { + @OptIn(ExperimentalTime::class) + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + when { + now.date == receivedAt.date -> { + hour() + char(':') + minute() + } + + now.year != receivedAt.year -> { + year() + char('/') + monthNumber() + char('/') + day() + } + + now.month == receivedAt.month && now.day - receivedAt.date.day < WEEK_DAYS -> { + dayOfWeek(dateTimeConfiguration.dayOfWeekNames) + } + + else -> { + monthName(dateTimeConfiguration.monthNames) + char(' ') + day(padding = Padding.ZERO) + } + } + } + val formatted = remember(receivedAt) { + receivedAt.format(formatter) + } + TextLabelSmall(text = formatted, modifier = modifier) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemDefaults.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemDefaults.kt new file mode 100644 index 0000000..fd80c7c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/MessageItemDefaults.kt @@ -0,0 +1,186 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.theme2.MainTheme + +/** + * Contains the default values used by all [MessageItem] types. + */ +object MessageItemDefaults { + /** + * The default content padding. + */ + val defaultContentPadding: PaddingValues + @Composable + @ReadOnlyComposable + get() = PaddingValues( + top = MainTheme.spacings.oneHalf, + bottom = MainTheme.spacings.oneHalf, + start = MainTheme.spacings.double, + end = MainTheme.spacings.triple, + ) + + /** + * The compact mode content padding. This provides a smaller content padding for the [MessageItem], + * suitable for users who prefer less spacing. + */ + val compactContentPadding: PaddingValues + @Composable + @ReadOnlyComposable + get() = PaddingValues( + top = MainTheme.spacings.default, + bottom = MainTheme.spacings.default, + start = MainTheme.spacings.oneHalf, + end = MainTheme.spacings.double, + ) + + /** + * The relaxed mode content padding. This provides a larger content padding for the [MessageItem], + * suitable for users who prefer more spacing. + */ + val relaxedContentPadding: PaddingValues + @Composable + @ReadOnlyComposable + get() = PaddingValues( + top = MainTheme.spacings.double, + bottom = MainTheme.spacings.double, + start = MainTheme.spacings.triple, + end = MainTheme.spacings.quadruple, + ) + + /** + * Creates a [MessageItemColors] that represent a new message item. + * + * This is typically used to highlight a message that has just arrived. + * + * @param containerColor The container color of this [MessageItem]. + * @param contentColor The content color of this [MessageItem]. + * @param subjectColor The subject color of this [MessageItem]. + */ + @Composable + fun newMessageItemColors( + containerColor: Color = MainTheme.colors.surfaceContainerLowest, + contentColor: Color = MainTheme.colors.onSurface, + subjectColor: Color = MainTheme.colors.primary, + ): MessageItemColors = MessageItemColors( + containerColor = containerColor, + contentColor = contentColor, + subjectColor = subjectColor, + ) + + /** + * Creates a [MessageItemColors] that represent an unread message item. + * + * This is typically used to highlight a message that is unread. + * + * @param containerColor The container color of this [MessageItem]. + * @param contentColor The content color of this [MessageItem]. + * @param subjectColor The subject color of this [MessageItem]. + * Defaults to [contentColor] if not specified. + */ + @Composable + fun unreadMessageItemColors( + containerColor: Color = MainTheme.colors.surfaceContainerLowest, + contentColor: Color = MainTheme.colors.onSurface, + subjectColor: Color = contentColor, + ): MessageItemColors = MessageItemColors( + containerColor = containerColor, + contentColor = contentColor, + subjectColor = subjectColor, + ) + + /** + * Creates a [MessageItemColors] that represent a read message item. + * + * This is typically used to highlight a message that is read. + * + * @param containerColor The container color of this [MessageItem]. + * @param contentColor The content color of this [MessageItem]. + * @param subjectColor The subject color of this [MessageItem]. + * Defaults to [contentColor] if not specified. + */ + @Composable + fun readMessageItemColors( + containerColor: Color = MainTheme.colors.surfaceContainerLow, + contentColor: Color = MainTheme.colors.onSurfaceVariant, + subjectColor: Color = contentColor, + ): MessageItemColors = MessageItemColors( + containerColor = containerColor, + contentColor = contentColor, + subjectColor = subjectColor, + ) + + /** + * Creates a [MessageItemColors] that represent a message item that was selected. + * + * This is typically used to highlight a selected message. + * + * @param containerColor The container color of this [MessageItem]. + * @param contentColor The content color of this [MessageItem]. + * @param subjectColor The subject color of this [MessageItem]. + */ + @Composable + fun selectedMessageItemColors( + containerColor: Color = MainTheme.colors.infoContainer, + contentColor: Color = MainTheme.colors.onSurface, + subjectColor: Color = MainTheme.colors.onSurfaceVariant, + ): MessageItemColors = MessageItemColors( + containerColor = containerColor, + contentColor = contentColor, + subjectColor = subjectColor, + ) + + /** + * Creates a [MessageItemColors] that represent a message item that is currently active. + * + * This is typically used to highlight the currently displayed message in a split view. + * + * @param containerColor The container color of this [MessageItem]. + * @param contentColor The content color of this [MessageItem]. + * @param subjectColor The subject color of this [MessageItem]. + */ + @Composable + fun activeMessageItemColors( + containerColor: Color = MainTheme.colors.infoContainer, + contentColor: Color = MainTheme.colors.onSurface, + subjectColor: Color = MainTheme.colors.onSurfaceVariant, + ): MessageItemColors = MessageItemColors( + containerColor = containerColor, + contentColor = contentColor, + subjectColor = subjectColor, + ) + + /** + * Creates a [MessageItemColors] that represent a message item that is currently a junk message. + * + * @param containerColor The container color of this [MessageItem]. + * @param contentColor The content color of this [MessageItem]. + * @param subjectColor The subject color of this [MessageItem]. + */ + @Composable + fun junkMessageItemColors( + containerColor: Color = MainTheme.colors.surfaceContainerLow, + contentColor: Color = MainTheme.colors.onSurface, + subjectColor: Color = MainTheme.colors.onSurfaceVariant, + ): MessageItemColors = MessageItemColors( + containerColor = containerColor, + contentColor = contentColor, + subjectColor = subjectColor, + ) +} + +/** + * Represents the colors used by a [MessageItem]. + * + * @param containerColor The color used for the background of this message item. + * @param contentColor The preferred color for content inside this message item. + * @param subjectColor The preferred color for the subject inside this message item. + */ +data class MessageItemColors( + val containerColor: Color, + val contentColor: Color, + val subjectColor: Color, +) diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/NewMessageItem.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/NewMessageItem.kt new file mode 100644 index 0000000..07e6c54 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/NewMessageItem.kt @@ -0,0 +1,102 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.datetime.LocalDateTime +import net.thunderbird.core.ui.compose.designsystem.atom.button.FavouriteButtonIcon +import net.thunderbird.core.ui.compose.designsystem.atom.icon.filled.NewMailBadge +import net.thunderbird.core.ui.compose.designsystem.molecule.message.MessageItemSenderTitleSmall + +/** + * Represents a message item in its New Message state. + * + * @param sender The name of the sender. + * @param subject The subject of the message. + * @param preview A short preview of the message content. + * @param receivedAt The date and time the message was received. + * @param favourite Whether the message is marked as favourite. + * @param avatar A composable function to display the sender's avatar. + * @param onClick A lambda function to be invoked when the message item is clicked. + * @param onFavouriteChange A lambda function to be invoked when the favourite button is clicked. + * @param modifier A [Modifier] to be applied to the message item. + * @param hasAttachments Whether the message has attachments. Defaults to `false`. + * @param threadCount The number of messages in the thread. Defaults to `0`. If greater than 0, + * it will be displayed next to the sender. + * @param selected Whether the message item is currently selected. Defaults to `false`. + * @param maxPreviewLines The maximum number of lines to display for the preview. Defaults to `2`. + * @param contentPadding The padding to apply to the content of the message item. Defaults to + * [MessageItemDefaults.defaultContentPadding]. + * @param swapSenderWithSubject If `true`, the sender and subject will be swapped in their display positions. + * Defaults to `false`. + */ +@Composable +fun NewMessageItem( + sender: String, + subject: String, + preview: String, + receivedAt: LocalDateTime, + avatar: @Composable () -> Unit, + onClick: () -> Unit, + onFavouriteChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + favourite: Boolean = false, + hasAttachments: Boolean = false, + threadCount: Int = 0, + selected: Boolean = false, + maxPreviewLines: Int = 2, + contentPadding: PaddingValues = MessageItemDefaults.defaultContentPadding, + swapSenderWithSubject: Boolean = false, +) { + MessageItem( + leading = { + Box { + avatar() + Image( + imageVector = Icons.Filled.NewMailBadge, + contentDescription = null, + modifier = Modifier.padding(start = MainTheme.spacings.half, top = MainTheme.spacings.half), + ) + } + }, + sender = { + MessageItemSenderTitleSmall( + sender = sender, + subject = subject, + swapSenderWithSubject = swapSenderWithSubject, + threadCount = threadCount, + color = if (swapSenderWithSubject) MainTheme.colors.primary else MainTheme.colors.onSurface, + ) + }, + subject = { + TextLabelLarge( + text = if (swapSenderWithSubject) sender else subject, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + preview = preview, + action = { FavouriteButtonIcon(favourite = favourite, onFavouriteChange = onFavouriteChange) }, + receivedAt = receivedAt, + onClick = onClick, + colors = if (selected) { + MessageItemDefaults.selectedMessageItemColors() + } else { + MessageItemDefaults.newMessageItemColors( + subjectColor = if (swapSenderWithSubject) MainTheme.colors.onSurface else MainTheme.colors.primary, + ) + }, + modifier = modifier, + hasAttachments = hasAttachments, + selected = selected, + maxPreviewLines = maxPreviewLines, + contentPadding = contentPadding, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ReadMessageItem.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ReadMessageItem.kt new file mode 100644 index 0000000..8ee82fc --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/ReadMessageItem.kt @@ -0,0 +1,86 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import kotlinx.datetime.LocalDateTime +import net.thunderbird.core.ui.compose.designsystem.atom.button.FavouriteButtonIcon +import net.thunderbird.core.ui.compose.designsystem.molecule.message.MessageItemSenderBodyMedium + +/** + * Represents a message item in its Read state. + * + * @param sender The name of the sender. + * @param subject The subject of the message. + * @param preview A short preview of the message content. + * @param receivedAt The date and time the message was received. + * @param favourite Whether the message is marked as favourite. + * @param avatar A composable function to display the sender's avatar. + * @param onClick A lambda function to be invoked when the message item is clicked. + * @param onFavouriteChange A lambda function to be invoked when the favourite button is clicked. + * @param modifier A [Modifier] to be applied to the message item. + * @param hasAttachments Whether the message has attachments. Defaults to `false`. + * @param threadCount The number of messages in the thread. Defaults to `0`. If greater than 0, + * it will be displayed next to the sender. + * @param selected Whether the message item is currently selected. Defaults to `false`. + * @param maxPreviewLines The maximum number of lines to display for the preview. Defaults to `2`. + * @param contentPadding The padding to apply to the content of the message item. Defaults to + * [MessageItemDefaults.defaultContentPadding]. + * @param swapSenderWithSubject If `true`, the sender and subject will be swapped in their display positions. + * Defaults to `false`. + */ +@Composable +fun ReadMessageItem( + sender: String, + subject: String, + preview: String, + receivedAt: LocalDateTime, + avatar: @Composable () -> Unit, + onClick: () -> Unit, + onFavouriteChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + favourite: Boolean = false, + hasAttachments: Boolean = false, + threadCount: Int = 0, + selected: Boolean = false, + maxPreviewLines: Int = 2, + contentPadding: PaddingValues = MessageItemDefaults.defaultContentPadding, + swapSenderWithSubject: Boolean = false, +) { + MessageItem( + leading = { + avatar() + }, + sender = { + MessageItemSenderBodyMedium( + sender = sender, + subject = subject, + swapSenderWithSubject = swapSenderWithSubject, + threadCount = threadCount, + ) + }, + subject = { + TextBodyMedium( + text = if (swapSenderWithSubject) sender else subject, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + preview = preview, + action = { FavouriteButtonIcon(favourite = favourite, onFavouriteChange = onFavouriteChange) }, + receivedAt = receivedAt, + onClick = onClick, + colors = if (selected) { + MessageItemDefaults.selectedMessageItemColors() + } else { + MessageItemDefaults.readMessageItemColors() + }, + modifier = modifier, + hasAttachments = hasAttachments, + selected = selected, + maxPreviewLines = maxPreviewLines, + contentPadding = contentPadding, + ) +} diff --git a/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/UnreadMessageItem.kt b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/UnreadMessageItem.kt new file mode 100644 index 0000000..3622098 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/organism/message/UnreadMessageItem.kt @@ -0,0 +1,99 @@ +package net.thunderbird.core.ui.compose.designsystem.organism.message + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.datetime.LocalDateTime +import net.thunderbird.core.ui.compose.designsystem.atom.button.FavouriteButtonIcon +import net.thunderbird.core.ui.compose.designsystem.atom.icon.filled.UnreadMailBadge +import net.thunderbird.core.ui.compose.designsystem.molecule.message.MessageItemSenderTitleSmall + +/** + * Represents a message item in its Unread state. + * + * @param sender The name of the sender. + * @param subject The subject of the message. + * @param preview A short preview of the message content. + * @param receivedAt The date and time the message was received. + * @param favourite Whether the message is marked as favourite. + * @param avatar A composable function to display the sender's avatar. + * @param onClick A lambda function to be invoked when the message item is clicked. + * @param onFavouriteChange A lambda function to be invoked when the favourite button is clicked. + * @param modifier A [Modifier] to be applied to the message item. + * @param hasAttachments Whether the message has attachments. Defaults to `false`. + * @param threadCount The number of messages in the thread. Defaults to `0`. If greater than 0, + * it will be displayed next to the sender. + * @param selected Whether the message item is currently selected. Defaults to `false`. + * @param maxPreviewLines The maximum number of lines to display for the preview. Defaults to `2`. + * @param contentPadding The padding to apply to the content of the message item. Defaults to + * [MessageItemDefaults.defaultContentPadding]. + * @param swapSenderWithSubject If `true`, the sender and subject will be swapped in their display positions. + * Defaults to `false`. + */ +@Composable +fun UnreadMessageItem( + sender: String, + subject: String, + preview: String, + receivedAt: LocalDateTime, + avatar: @Composable () -> Unit, + onClick: () -> Unit, + onFavouriteChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + favourite: Boolean = false, + hasAttachments: Boolean = false, + threadCount: Int = 0, + selected: Boolean = false, + maxPreviewLines: Int = 2, + contentPadding: PaddingValues = MessageItemDefaults.defaultContentPadding, + swapSenderWithSubject: Boolean = false, +) { + MessageItem( + leading = { + Box { + avatar() + Image( + imageVector = Icons.Filled.UnreadMailBadge, + contentDescription = null, + modifier = Modifier.padding(start = MainTheme.spacings.half, top = MainTheme.spacings.half), + ) + } + }, + sender = { + MessageItemSenderTitleSmall( + sender = sender, + subject = subject, + swapSenderWithSubject = swapSenderWithSubject, + threadCount = threadCount, + ) + }, + subject = { + TextLabelLarge( + text = if (swapSenderWithSubject) sender else subject, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + preview = preview, + action = { FavouriteButtonIcon(favourite = favourite, onFavouriteChange = onFavouriteChange) }, + receivedAt = receivedAt, + onClick = onClick, + colors = if (selected) { + MessageItemDefaults.selectedMessageItemColors() + } else { + MessageItemDefaults.unreadMessageItemColors() + }, + modifier = modifier, + hasAttachments = hasAttachments, + selected = selected, + maxPreviewLines = maxPreviewLines, + contentPadding = contentPadding, + ) +} diff --git a/core/ui/compose/designsystem/src/main/res/values-am/strings.xml b/core/ui/compose/designsystem/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ar/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..5e42b99 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ar/strings.xml @@ -0,0 +1,8 @@ + + + إخفاء كلمة المرور + عرض كلمة المرور + عنوان البريد الإلكتروني + كلمة المرور + إعادة المحاولة + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ast/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-az/strings.xml b/core/ui/compose/designsystem/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-be/strings.xml b/core/ui/compose/designsystem/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..508ee55 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-be/strings.xml @@ -0,0 +1,8 @@ + + + Пароль + Схаваць пароль + Паказаць пароль + Паўтарыць + Адрас электроннай пошты + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-bg/strings.xml b/core/ui/compose/designsystem/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..ef2b140 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-bg/strings.xml @@ -0,0 +1,8 @@ + + + Скриване на паролата + Имейл адрес + Показване на паролата + Повторен опит + Парола + diff --git a/core/ui/compose/designsystem/src/main/res/values-bn/strings.xml b/core/ui/compose/designsystem/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..41c9f5c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-bn/strings.xml @@ -0,0 +1,8 @@ + + + পুনরায় চেষ্টা + পাসওয়ার্ড লুকাও + পাসওয়ার্ড দেখাও + পাসওয়ার্ড + ইমেইল ঠিকানা + diff --git a/core/ui/compose/designsystem/src/main/res/values-br/strings.xml b/core/ui/compose/designsystem/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..2eb5322 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-br/strings.xml @@ -0,0 +1,6 @@ + + + Kuzhat ger-tremen + Diskouez ger-tremen + Chomlec\'h postel + diff --git a/core/ui/compose/designsystem/src/main/res/values-bs/strings.xml b/core/ui/compose/designsystem/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..cf39e96 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-bs/strings.xml @@ -0,0 +1,8 @@ + + + Sakrij lozinku + Prikaži lozinku + Mejl-adresa + Lozinka + Pokušajte ponovo + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ca/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..213e62a --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ca/strings.xml @@ -0,0 +1,8 @@ + + + Amaga la contrasenya + Adreça electrònica + Mostra la contrasenya + Reintenta + Contrasenya + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-co/strings.xml b/core/ui/compose/designsystem/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..9a29ee0 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-co/strings.xml @@ -0,0 +1,8 @@ + + + Piattà a parolla d’intesa + Affissa a parolla d’intesa + Indirizzu elettronicu + Parolla d’intesa + Pruvà torna + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-cs/strings.xml b/core/ui/compose/designsystem/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..e827c7f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-cs/strings.xml @@ -0,0 +1,8 @@ + + + Skrýt heslo + E-mailová adresa + Zobrazit heslo + Zkusit znovu + Heslo + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-cy/strings.xml b/core/ui/compose/designsystem/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..ef635ce --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-cy/strings.xml @@ -0,0 +1,8 @@ + + + Cuddio cyfrinair + Dangos cyfrinair + Cyfeiriad e-bost + Cyfrinair + Ceisio eto + diff --git a/core/ui/compose/designsystem/src/main/res/values-da/strings.xml b/core/ui/compose/designsystem/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..2646799 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-da/strings.xml @@ -0,0 +1,8 @@ + + + Skjul kodeord + Email adresse + Vis kodeord + Prøv igen + Kodeord + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-de/strings.xml b/core/ui/compose/designsystem/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..d8bca8c --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-de/strings.xml @@ -0,0 +1,8 @@ + + + Passwort ausblenden + E-Mail-Adresse + Passwort anzeigen + Wiederholen + Passwort + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-el/strings.xml b/core/ui/compose/designsystem/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..08132a9 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-el/strings.xml @@ -0,0 +1,8 @@ + + + Απόκρυψη κωδικού πρόσβασης + Διεύθυνση email + Εμφάνιση κωδικού πρόσβασης + Επανάληψη + Κωδικός πρόσβασης + diff --git a/core/ui/compose/designsystem/src/main/res/values-en-rGB/strings.xml b/core/ui/compose/designsystem/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..f2fb0c7 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,8 @@ + + + Hide password + Email address + Show password + Retry + Password + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-enm/strings.xml b/core/ui/compose/designsystem/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-eo/strings.xml b/core/ui/compose/designsystem/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..846052a --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-eo/strings.xml @@ -0,0 +1,8 @@ + + + Kaŝi pasvorton + Montri pasvorton + Retpoŝtadreso + Pasvorto + Reprovi + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-es/strings.xml b/core/ui/compose/designsystem/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..e0e046d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-es/strings.xml @@ -0,0 +1,8 @@ + + + Ocultar contraseña + Dirección de correo + Mostrar contraseña + Reintentar + Contraseña + diff --git a/core/ui/compose/designsystem/src/main/res/values-et/strings.xml b/core/ui/compose/designsystem/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..4a236ba --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-et/strings.xml @@ -0,0 +1,8 @@ + + + Peida salasõna + E-posti aadress + Näita salasõna + Proovi uuesti + Salasõna + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-eu/strings.xml b/core/ui/compose/designsystem/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..0503962 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-eu/strings.xml @@ -0,0 +1,8 @@ + + + Pasahitza ezkutatu + E-mail helbidea + Pasahitza erakutsi + Saiatu berriro + Pasahitza + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-fa/strings.xml b/core/ui/compose/designsystem/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..73e5779 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-fa/strings.xml @@ -0,0 +1,8 @@ + + + مخفی کردن رمز عبور + نشانی رایانامه + نشان دادن رمز عبور + تلاش دوباره + رمز عبور + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-fi/strings.xml b/core/ui/compose/designsystem/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..0b3ab5b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-fi/strings.xml @@ -0,0 +1,8 @@ + + + Piilota salasana + Sähköpostiosoite + Näytä salasana + Yritä uudelleen + Salasana + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-fr/strings.xml b/core/ui/compose/designsystem/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..8205211 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-fr/strings.xml @@ -0,0 +1,8 @@ + + + Cacher le mot de passe + Adresse courriel + Afficher le mot de passe + Réessayer + Mot de passe + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-fy/strings.xml b/core/ui/compose/designsystem/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..4b9ed32 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-fy/strings.xml @@ -0,0 +1,8 @@ + + + Wachtwurd ferstopje + E-mailadres + Wachtwurd toane + Opnij probearje + Wachtwurd + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ga/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..3963f99 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ga/strings.xml @@ -0,0 +1,8 @@ + + + Bain triail eile as + Folaigh pasfhocal + Seoladh ríomhphoist + Pasfhocal + Taispeáin pasfhocal + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-gd/strings.xml b/core/ui/compose/designsystem/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..6d8d911 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-gd/strings.xml @@ -0,0 +1,8 @@ + + + Seall am facal-faire + Falaich am facal-faire + Seòladh puist-d + Facal-faire + Feuch ris a-rithist + diff --git a/core/ui/compose/designsystem/src/main/res/values-gl/strings.xml b/core/ui/compose/designsystem/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-gl/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-gu/strings.xml b/core/ui/compose/designsystem/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-hi/strings.xml b/core/ui/compose/designsystem/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..2731557 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-hi/strings.xml @@ -0,0 +1,8 @@ + + + पासवर्ड छुपाएं + ईमेल पता + पासवर्ड दिखाएं + वापस कोशिश करें + पासवर्ड + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-hr/strings.xml b/core/ui/compose/designsystem/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..671c04d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-hr/strings.xml @@ -0,0 +1,8 @@ + + + Sakrij lozinku + Prikaži lozinku + Adresa e-pošte + Lozinka + Pokušaj ponovo + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-hu/strings.xml b/core/ui/compose/designsystem/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..2e802d8 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-hu/strings.xml @@ -0,0 +1,8 @@ + + + Jelszó elrejtése + E-mail-cím + Jelszó megjelenítése + Újra + Jelszó + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-hy/strings.xml b/core/ui/compose/designsystem/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-in/strings.xml b/core/ui/compose/designsystem/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..5a9ba2d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-in/strings.xml @@ -0,0 +1,8 @@ + + + Sembunyikan kata sandi + Tampilkan kata sandi + Alamat surel + Kata sandi + Coba lagi + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-is/strings.xml b/core/ui/compose/designsystem/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..f6f4fa9 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-is/strings.xml @@ -0,0 +1,8 @@ + + + Fela lykilorð + Tölvupóstfang + Birta lykilorð + Reyna aftur + Lykilorð + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-it/strings.xml b/core/ui/compose/designsystem/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..c390ce9 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-it/strings.xml @@ -0,0 +1,8 @@ + + + Nascondi password + Indirizzo email + Mostra password + Riprova + Password + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-iw/strings.xml b/core/ui/compose/designsystem/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..d146d66 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-iw/strings.xml @@ -0,0 +1,8 @@ + + + הצג סיסמה + כתובת דוא\"ל + סיסמה + הסתר סיסמה + נסה שוב + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ja/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..ea2136f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ja/strings.xml @@ -0,0 +1,8 @@ + + + パスワードを非表示 + メールアドレス + パスワードを表示 + 再試行 + パスワード + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ka/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-kab/strings.xml b/core/ui/compose/designsystem/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-kab/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-kk/strings.xml b/core/ui/compose/designsystem/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..6818dd9 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-kk/strings.xml @@ -0,0 +1,7 @@ + + + Парольді жасыру + Парольді көрсету + Электронды пошта + Пароль + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ko/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..a9f5097 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ko/strings.xml @@ -0,0 +1,8 @@ + + + 비밀번호 숨기기 + 비밀번호 표시 + 이메일 주소 + 비밀번호 + 재시도 + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-lt/strings.xml b/core/ui/compose/designsystem/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..f6f27cb --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-lt/strings.xml @@ -0,0 +1,8 @@ + + + Nerodyti slaptažodžio + Rodyti slaptažodį + El. pašto adresas + Slaptažodis + Kartoti bandymą + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-lv/strings.xml b/core/ui/compose/designsystem/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ml/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-nb-rNO/strings.xml b/core/ui/compose/designsystem/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..97e90b4 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,8 @@ + + + Skjul passord + E-postadresse + Vis passord + Prøv igjen + Passord + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-nl/strings.xml b/core/ui/compose/designsystem/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..16bdcca --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-nl/strings.xml @@ -0,0 +1,8 @@ + + + Wachtwoord verbergen + E-mailadres + Wachtwoord tonen + Opnieuw proberen + Wachtwoord + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-nn/strings.xml b/core/ui/compose/designsystem/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..71a523f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-nn/strings.xml @@ -0,0 +1,8 @@ + + + Skjul passord + Vis passord + E-postadresse + Passord + Prøv igjen + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-pl/strings.xml b/core/ui/compose/designsystem/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..60c57cb --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-pl/strings.xml @@ -0,0 +1,8 @@ + + + Ukryj hasło + Adres e-mail + Pokaż hasło + Spróbuj ponownie + Hasło + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-pt-rBR/strings.xml b/core/ui/compose/designsystem/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..b537f3f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,8 @@ + + + Ocultar senha + Endereço de email + Mostrar senha + Tentar novamente + Senha + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-pt-rPT/strings.xml b/core/ui/compose/designsystem/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..64fcde4 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,8 @@ + + + Esconder palavra-passe + Endereço de e-mail + Mostrar palavra-passe + Tentar novamente + Palavra-passe + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-pt/strings.xml b/core/ui/compose/designsystem/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ro/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..dc03fe4 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ro/strings.xml @@ -0,0 +1,8 @@ + + + Ascunde parola + Adresa e-mail + Arată parola + Reîncearcă + Parola + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ru/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..2faa6fd --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ru/strings.xml @@ -0,0 +1,8 @@ + + + Скрыть пароль + Показать пароль + Адрес электронной почты + Пароль + Повторить + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-sk/strings.xml b/core/ui/compose/designsystem/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..401ef8a --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-sk/strings.xml @@ -0,0 +1,8 @@ + + + Skryť heslo + E-mailová adresa + Zobraziť heslo + Heslo + Skúsiť znova + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-sl/strings.xml b/core/ui/compose/designsystem/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..23cc55b --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-sl/strings.xml @@ -0,0 +1,8 @@ + + + Skrij geslo + Pokaži geslo + Elektronski naslov + Geslo + Poskusi znova + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-sq/strings.xml b/core/ui/compose/designsystem/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..cfcc9e6 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-sq/strings.xml @@ -0,0 +1,8 @@ + + + Fshihe fjalëkalimin + Adresë email + Shfaqe fjalëkalimin + Riprovoni + Fjalëkalim + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-sr/strings.xml b/core/ui/compose/designsystem/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..1f11050 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-sr/strings.xml @@ -0,0 +1,8 @@ + + + Покушај поново + Сакриј лозинку + Прикажи лозинку + Имејл адреса + Лозинка + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-sv/strings.xml b/core/ui/compose/designsystem/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..42b3154 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-sv/strings.xml @@ -0,0 +1,8 @@ + + + Dölj lösenord + E-postadress + Visa lösenord + Lösenord + Försök igen + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-sw/strings.xml b/core/ui/compose/designsystem/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-ta/strings.xml b/core/ui/compose/designsystem/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..a5c2311 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-ta/strings.xml @@ -0,0 +1,8 @@ + + + கடவுச்சொல்லை மறைக்கவும் + கடவுச்சொல்லைக் காட்டு + மின்னஞ்சல் முகவரி + கடவுச்சொல் + மீண்டும் முயற்சிக்கவும் + diff --git a/core/ui/compose/designsystem/src/main/res/values-th/strings.xml b/core/ui/compose/designsystem/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..501548d --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-th/strings.xml @@ -0,0 +1,8 @@ + + + ซ่อนรหัสผ่าน + แสดงรหัสผ่าน + ที่อยู่อีเมล์ + รหัสผ่าน + ลองใหม่ + diff --git a/core/ui/compose/designsystem/src/main/res/values-tr/strings.xml b/core/ui/compose/designsystem/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..81835e4 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-tr/strings.xml @@ -0,0 +1,8 @@ + + + Parolayı gizle + E-posta adresi + Parolayı göster + Yeniden dene + Parola + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-uk/strings.xml b/core/ui/compose/designsystem/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..2e798fb --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-uk/strings.xml @@ -0,0 +1,8 @@ + + + Показати пароль + Сховати пароль + Адреса е-пошти + Пароль + Повторити спробу + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-vi/strings.xml b/core/ui/compose/designsystem/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..d9c4a6f --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-vi/strings.xml @@ -0,0 +1,8 @@ + + + Ẩn mật khẩu + Địa chỉ email + Hiện mật khẩu + Thử lại + Mật khẩu + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-zh-rCN/strings.xml b/core/ui/compose/designsystem/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..a7f68e5 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,8 @@ + + + 隐藏密码 + 电子邮件地址 + 显示密码 + 重试 + 密码 + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values-zh-rTW/strings.xml b/core/ui/compose/designsystem/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..8bdd264 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,8 @@ + + + 隱藏密碼 + 電子郵件地址 + 顯示密碼 + 再試一次 + 密碼 + \ No newline at end of file diff --git a/core/ui/compose/designsystem/src/main/res/values/strings.xml b/core/ui/compose/designsystem/src/main/res/values/strings.xml new file mode 100644 index 0000000..d23ac90 --- /dev/null +++ b/core/ui/compose/designsystem/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Hide password + Show password + Email address + Password + Retry + diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/CommonTextFieldTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/CommonTextFieldTest.kt new file mode 100644 index 0000000..ddac843 --- /dev/null +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/CommonTextFieldTest.kt @@ -0,0 +1,284 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import app.k9mail.core.ui.compose.testing.ComposeTest +import assertk.assertFailure +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner + +private const val LABEL = "Label" + +data class TextFieldConfig( + val label: String?, + val isEnabled: Boolean?, + val isReadOnly: Boolean, + val isRequired: Boolean, +) + +data class CommonTextFieldTestData( + val name: String, + val content: @Composable ( + modifier: Modifier, + textFieldConfig: TextFieldConfig, + ) -> Unit, +) + +@RunWith(ParameterizedRobolectricTestRunner::class) +class CommonTextFieldTest( + data: CommonTextFieldTestData, +) : ComposeTest() { + + private val testSubjectName = data.name + private val testSubject = data.content + + @Test + fun `should be enabled by default`() = runComposeTest { + setContent { + testSubject( + Modifier.testTagAsResourceId(testSubjectName), + TextFieldConfig( + label = null, + isEnabled = null, + isReadOnly = false, + isRequired = false, + ), + ) + } + + onNodeWithTag(testSubjectName).assertIsEnabled() + } + + @Test + fun `should be disabled when enabled is false`() = runComposeTest { + setContent { + testSubject( + Modifier.testTagAsResourceId(testSubjectName), + TextFieldConfig( + label = null, + isEnabled = false, + isReadOnly = false, + isRequired = false, + ), + ) + } + + onNodeWithTag(testSubjectName).assertIsNotEnabled() + } + + @Test + fun `should show label when label is not null`() = runComposeTest { + setContent { + testSubject( + Modifier.testTagAsResourceId(testSubjectName), + TextFieldConfig( + label = LABEL, + isEnabled = null, + isReadOnly = false, + isRequired = false, + ), + ) + } + + onNodeWithText(LABEL).assertIsDisplayed() + } + + @Test + fun `should show asterisk when isRequired is true`() = runComposeTest { + setContent { + testSubject( + Modifier.testTagAsResourceId(testSubjectName), + TextFieldConfig( + label = LABEL, + isEnabled = null, + isReadOnly = false, + isRequired = true, + ), + ) + } + + onNodeWithText("$LABEL*").assertIsDisplayed() + } + + @Test + fun `should not show asterisk when isRequired is false`() = runComposeTest { + setContent { + testSubject( + Modifier.testTagAsResourceId(testSubjectName), + TextFieldConfig( + label = LABEL, + isEnabled = null, + isReadOnly = false, + isRequired = false, + ), + ) + } + + onNodeWithText("$LABEL*").assertDoesNotExist() + } + + @Test + fun `should not allow editing when isReadOnly is true`() = runComposeTest { + setContent { + testSubject( + Modifier.testTagAsResourceId(testSubjectName), + TextFieldConfig( + label = LABEL, + isEnabled = null, + isReadOnly = true, + isRequired = false, + ), + ) + } + + onNodeWithTag(testSubjectName).performClick() + assertFailure { + onNodeWithText(testSubjectName).performTextInput(" + added text") + } + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + @Suppress("LongMethod") + fun data(): List = listOf( + CommonTextFieldTestData( + name = "TextFieldOutlined", + content = { modifier, config -> + if (config.isEnabled != null) { + TextFieldOutlined( + value = "", + onValueChange = {}, + modifier = modifier, + label = config.label, + isEnabled = config.isEnabled, + isReadOnly = config.isReadOnly, + isRequired = config.isRequired, + ) + } else { + TextFieldOutlined( + value = "", + onValueChange = {}, + modifier = modifier, + label = config.label, + isRequired = config.isRequired, + isReadOnly = config.isReadOnly, + ) + } + }, + ), + CommonTextFieldTestData( + name = "TextFieldOutlinedPassword", + content = { modifier, config -> + if (config.isEnabled != null) { + TextFieldOutlinedPassword( + value = "", + onValueChange = {}, + modifier = modifier, + label = config.label, + isEnabled = config.isEnabled, + isReadOnly = config.isReadOnly, + isRequired = config.isRequired, + ) + } else { + TextFieldOutlinedPassword( + value = "", + onValueChange = {}, + label = config.label, + modifier = modifier, + isRequired = config.isRequired, + isReadOnly = config.isReadOnly, + ) + } + }, + ), + CommonTextFieldTestData( + name = "TextFieldOutlinedEmail", + content = { modifier, config -> + if (config.isEnabled != null) { + TextFieldOutlinedEmailAddress( + value = "", + onValueChange = {}, + modifier = modifier, + label = config.label, + isEnabled = config.isEnabled, + isReadOnly = config.isReadOnly, + isRequired = config.isRequired, + ) + } else { + TextFieldOutlinedEmailAddress( + value = "", + onValueChange = {}, + modifier = modifier, + label = config.label, + isRequired = config.isRequired, + isReadOnly = config.isReadOnly, + ) + } + }, + ), + CommonTextFieldTestData( + name = "TextFieldOutlinedNumber", + content = { modifier, config -> + if (config.isEnabled != null) { + TextFieldOutlinedNumber( + value = 123L, + onValueChange = {}, + modifier = modifier, + label = config.label, + isEnabled = config.isEnabled, + isReadOnly = config.isReadOnly, + isRequired = config.isRequired, + ) + } else { + TextFieldOutlinedNumber( + value = 123L, + onValueChange = {}, + modifier = modifier, + label = config.label, + isRequired = config.isRequired, + isReadOnly = config.isReadOnly, + ) + } + }, + ), + CommonTextFieldTestData( + name = "TextFieldOutlinedSelect", + content = { modifier, config -> + if (config.isEnabled != null) { + TextFieldOutlinedSelect( + options = persistentListOf("option1", "option2"), + selectedOption = "option1", + onValueChange = {}, + modifier = modifier, + label = config.label, + isEnabled = config.isEnabled, + isReadOnly = config.isReadOnly, + isRequired = config.isRequired, + ) + } else { + TextFieldOutlinedSelect( + options = persistentListOf("option1", "option2"), + selectedOption = "option1", + onValueChange = {}, + modifier = modifier, + label = config.label, + isRequired = config.isRequired, + isReadOnly = config.isReadOnly, + ) + } + }, + ), + ) + } +} diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddressKtTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddressKtTest.kt new file mode 100644 index 0000000..cd6d369 --- /dev/null +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedEmailAddressKtTest.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import app.k9mail.core.ui.compose.testing.ComposeTest +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import org.junit.Test + +private const val TEST_TAG = "TextFieldOutlinedEmailAddress" + +class TextFieldOutlinedEmailAddressKtTest : ComposeTest() { + + @Test + fun `should call onValueChange when value changes`() = runComposeTest { + var value = "initial" + setContent { + TextFieldOutlinedEmailAddress( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput(" + added text") + + assertThat(value).isEqualTo("initial + added text") + } + + @Test + fun `should strip line breaks before onValueChange is called`() = runComposeTest { + var value = "" + setContent { + TextFieldOutlinedEmailAddress( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput("one\n two") + + assertThat(value).isEqualTo("one two") + } +} diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedKtTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedKtTest.kt new file mode 100644 index 0000000..b16e6fb --- /dev/null +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedKtTest.kt @@ -0,0 +1,88 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import app.k9mail.core.ui.compose.testing.ComposeTest +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import org.junit.Test + +private const val TEST_TAG = "TextFieldOutlined" + +class TextFieldOutlinedKtTest : ComposeTest() { + + @Test + fun `should call onValueChange when value changes with isSingleLine = false`() = runComposeTest { + var value = "initial" + setContent { + TextFieldOutlined( + value = value, + onValueChange = { value = it }, + isSingleLine = false, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput(" + added text") + + assertThat(value).isEqualTo("initial + added text") + } + + @Test + fun `should call onValueChange when value changes with isSingleLine = true`() = runComposeTest { + var value = "initial" + setContent { + TextFieldOutlined( + value = value, + onValueChange = { value = it }, + isSingleLine = true, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput(" + added text") + + assertThat(value).isEqualTo("initial + added text") + } + + @Test + fun `should allow line breaks when isSingleLine = false`() = runComposeTest { + var value = "" + setContent { + TextFieldOutlined( + value = value, + onValueChange = { value = it }, + isSingleLine = false, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput("one\ntwo") + + assertThat(value).isEqualTo("one\ntwo") + } + + @Test + fun `should strip line breaks before onValueChange is called when isSingleLine = true`() = runComposeTest { + var value = "" + setContent { + TextFieldOutlined( + value = value, + onValueChange = { value = it }, + isSingleLine = true, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput("one\n two") + + assertThat(value).isEqualTo("one two") + } +} diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumberKtTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumberKtTest.kt new file mode 100644 index 0000000..4509cb4 --- /dev/null +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedNumberKtTest.kt @@ -0,0 +1,85 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import app.k9mail.core.ui.compose.testing.ComposeTest +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import org.junit.Test + +private const val TEST_TAG = "TextFieldOutlinedNumber" + +class TextFieldOutlinedNumberKtTest : ComposeTest() { + + @Test + fun `should call onValueChange with null when input is empty`() = runComposeTest { + var value: Long? = 1L + setContent { + TextFieldOutlinedNumber( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextClearance() + + assertThat(value).isEqualTo(null) + } + + @Test + fun `should call onValueChange when value changes`() = runComposeTest { + var value: Long? = 123L + setContent { + TextFieldOutlinedNumber( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput("456") + + assertThat(value).isEqualTo(123456L) + } + + @Test + fun `should return null when no number`() = runComposeTest { + var value: Long? = 123L + setContent { + TextFieldOutlinedNumber( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput(",") + + assertThat(value).isEqualTo(null) + } + + @Test + fun `should return null when input exceeds max long`() = runComposeTest { + var value: Long? = null + setContent { + TextFieldOutlinedNumber( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput("9223372036854775808") + + assertThat(value).isEqualTo(null) + } +} diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordKtTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordKtTest.kt new file mode 100644 index 0000000..15bacbc --- /dev/null +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedPasswordKtTest.kt @@ -0,0 +1,227 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import app.k9mail.core.ui.compose.designsystem.R +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.onNodeWithText +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import org.junit.Test + +private const val PASSWORD = "Password input" +private const val PASSWORD_MASKED = "••••••••••••••" +private const val TEST_TAG = "TextFieldOutlinedPassword" + +class TextFieldOutlinedPasswordKtTest : ComposeTest() { + + @Test + fun `should not display password by default`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + ) + } + + onNodeWithText(PASSWORD_MASKED).assertIsDisplayed() + } + + @Test + fun `should display password when show password is clicked`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + ) + } + + onShowPasswordNode().performClick() + + onNodeWithText(PASSWORD_MASKED).assertIsNotDisplayed() + onNodeWithText(PASSWORD).assertIsDisplayed() + } + + @Test + fun `should not display password when hide password is clicked`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + ) + } + onShowPasswordNode().performClick() + + onHidePasswordNode().performClick() + + onNodeWithText(PASSWORD_MASKED).assertIsDisplayed() + } + + @Test + fun `should display hide password icon when show password is clicked`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + ) + } + + onShowPasswordNode().performClick() + + onHidePasswordNode().assertIsDisplayed() + } + + @Test + fun `should display show password icon when hide password icon is clicked`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + ) + } + onShowPasswordNode().performClick() + + onHidePasswordNode().performClick() + + onShowPasswordNode().assertIsDisplayed() + } + + @Test + fun `should call callback when password visibility toggle icon is clicked`() = runComposeTest { + var clicked = false + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + isPasswordVisible = false, + onPasswordVisibilityToggleClicked = { clicked = true }, + ) + } + + onShowPasswordNode().performClick() + + assertThat(clicked).isTrue() + } + + @Test + fun `should display password when isPasswordVisible = true`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + isPasswordVisible = true, + onPasswordVisibilityToggleClicked = {}, + ) + } + + onNodeWithText(PASSWORD_MASKED).assertIsNotDisplayed() + onNodeWithText(PASSWORD).assertIsDisplayed() + } + + @Test + fun `should not display password when isPasswordVisible = false`() = runComposeTest { + setContent { + TextFieldOutlinedPassword( + value = PASSWORD, + onValueChange = {}, + isPasswordVisible = false, + onPasswordVisibilityToggleClicked = {}, + ) + } + + onNodeWithText(PASSWORD_MASKED).assertIsDisplayed() + } + + @Test + fun `variant 1 should call onValueChange when value changes`() = runComposeTest { + var value = "initial" + setContent { + TextFieldOutlinedPassword( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput(" + added text") + + assertThat(value).isEqualTo("initial + added text") + } + + @Test + fun `variant 2 should call onValueChange when value changes`() = runComposeTest { + var value = "initial" + setContent { + TextFieldOutlinedPassword( + value = value, + onValueChange = { value = it }, + isPasswordVisible = false, + onPasswordVisibilityToggleClicked = {}, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput(" + added text") + + assertThat(value).isEqualTo("initial + added text") + } + + @Test + fun `variant 1 should strip line breaks before onValueChange is called`() = runComposeTest { + var value = "" + setContent { + TextFieldOutlinedPassword( + value = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput("one\n two") + + assertThat(value).isEqualTo("one two") + } + + @Test + fun `variant 2 should strip line breaks before onValueChange is called`() = runComposeTest { + var value = "" + setContent { + TextFieldOutlinedPassword( + value = value, + onValueChange = { value = it }, + isPasswordVisible = false, + onPasswordVisibilityToggleClicked = {}, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + onNodeWithTag(TEST_TAG).performTextInput("one\n two") + + assertThat(value).isEqualTo("one two") + } + + private fun SemanticsNodeInteractionsProvider.onShowPasswordNode(): SemanticsNodeInteraction { + return onNodeWithContentDescription( + getString(R.string.designsystem_atom_password_textfield_show_password), + ) + } + + private fun SemanticsNodeInteractionsProvider.onHidePasswordNode(): SemanticsNodeInteraction { + return onNodeWithContentDescription( + getString(R.string.designsystem_atom_password_textfield_hide_password), + ) + } +} diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelectTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelectTest.kt new file mode 100644 index 0000000..9fe2814 --- /dev/null +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/atom/textfield/TextFieldOutlinedSelectTest.kt @@ -0,0 +1,68 @@ +package app.k9mail.core.ui.compose.designsystem.atom.textfield + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.k9mail.core.ui.compose.testing.ComposeTest +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +private const val TEST_TAG = "TextFieldOutlinedSelect" + +class TextFieldOutlinedSelectTest : ComposeTest() { + @Test + fun `should call onValueChange when value changes`() = runComposeTest { + var value = "option1" + setContent { + TextFieldOutlinedSelect( + options = persistentListOf("option1", "option2"), + selectedOption = value, + onValueChange = { value = it }, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + ) + } + + onNodeWithTag(TEST_TAG).performClick() + + onNodeWithText("option2").performClick() + assertThat(value).isEqualTo("option2") + } + + @Test + fun `should not show dropdown when not enabled`() = runComposeTest { + setContent { + TextFieldOutlinedSelect( + options = persistentListOf("option1", "option2"), + selectedOption = "option1", + onValueChange = {}, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + isEnabled = false, + ) + } + + onNodeWithTag(TEST_TAG).performClick() + + onNodeWithText("option2").assertDoesNotExist() + } + + @Test + fun `should not show dropdown when read-only`() = runComposeTest { + setContent { + TextFieldOutlinedSelect( + options = persistentListOf("option1", "option2"), + selectedOption = "option1", + onValueChange = {}, + modifier = Modifier.testTagAsResourceId(TEST_TAG), + isReadOnly = true, + ) + } + + onNodeWithTag(TEST_TAG).performClick() + + onNodeWithText("option2").assertDoesNotExist() + } +} diff --git a/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialogKtTest.kt b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialogKtTest.kt new file mode 100644 index 0000000..0b208dc --- /dev/null +++ b/core/ui/compose/designsystem/src/test/kotlin/app/k9mail/core/ui/compose/designsystem/organism/AlertDialogKtTest.kt @@ -0,0 +1,113 @@ +package app.k9mail.core.ui.compose.designsystem.organism + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.performClick +import androidx.test.espresso.Espresso +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.onNodeWithText +import app.k9mail.core.ui.compose.testing.onNodeWithTextIgnoreCase +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import assertk.assertThat +import assertk.assertions.isTrue +import kotlin.test.Test + +class AlertDialogKtTest : ComposeTest() { + + @Test + fun `should display title, text and confirm button`() = runComposeTest { + setContentWithTheme { + AlertDialog( + title = "Title", + text = "Text", + confirmText = "Confirm", + onConfirmClick = {}, + onDismissRequest = {}, + ) + } + + onNodeWithText("Title").assertExists() + onNodeWithText("Text").assertExists() + onNodeWithTextIgnoreCase(text = "Confirm").assertExists() + } + + @Test + fun `should call onConfirmClick when confirm button is clicked`() = runComposeTest { + var clicked = false + setContentWithTheme { + AlertDialog( + title = "Title", + text = "Text", + confirmText = "Confirm", + onConfirmClick = { clicked = true }, + onDismissRequest = {}, + ) + } + + onNodeWithTextIgnoreCase(text = "Confirm").performClick() + + assertThat(clicked).isTrue() + } + + @Test + fun `should display dismiss button and call onDismissClick when clicked`() = runComposeTest { + var clicked = false + setContentWithTheme { + AlertDialog( + title = "Title", + text = "Text", + confirmText = "Confirm", + onConfirmClick = {}, + onDismissClick = { + clicked = true + }, + onDismissRequest = {}, + dismissText = "Dismiss", + ) + } + + onNodeWithTextIgnoreCase(text = "Dismiss").assertExists() + onNodeWithTextIgnoreCase(text = "Dismiss").performClick() + + assertThat(clicked).isTrue() + } + + @Test + fun `should call onDismissRequest when dialog is dismissed`() = runComposeTest { + var dismissed = false + setContentWithTheme { + Column { + TextTitleMedium("Other") + AlertDialog( + title = "Title", + text = "Text", + confirmText = "Confirm", + onConfirmClick = {}, + onDismissRequest = { dismissed = true }, + ) + } + } + + Espresso.pressBack() + + assertThat(dismissed).isTrue() + } + + @Test + fun `should contain custom content`() = runComposeTest { + setContentWithTheme { + AlertDialog( + title = "Title", + confirmText = "Confirm", + onConfirmClick = {}, + onDismissRequest = {}, + ) { + Column { + TextTitleMedium("Custom") + } + } + } + + onNodeWithText("Custom").assertExists() + } +} diff --git a/core/ui/compose/navigation/build.gradle.kts b/core/ui/compose/navigation/build.gradle.kts new file mode 100644 index 0000000..9cdd201 --- /dev/null +++ b/core/ui/compose/navigation/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.core.ui.compose.navigation" + resourcePrefix = "core_ui_navigation_" +} diff --git a/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/Navigation.kt b/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/Navigation.kt new file mode 100644 index 0000000..1c6c712 --- /dev/null +++ b/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/Navigation.kt @@ -0,0 +1,24 @@ +package app.k9mail.core.ui.compose.navigation + +import androidx.navigation.NavGraphBuilder + +/** + * A Navigation is responsible for registering routes with the navigation graph. + * + * @param T the type of route + */ +interface Navigation { + + /** + * Register all routes for this navigation. + * + * @param navGraphBuilder the navigation graph builder + * @param onBack the action to perform when the back button is pressed + * @param onFinish the action to perform when a route is finished + */ + fun registerRoutes( + navGraphBuilder: NavGraphBuilder, + onBack: () -> Unit, + onFinish: (T) -> Unit, + ) +} diff --git a/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/NavigationExtension.kt b/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/NavigationExtension.kt new file mode 100644 index 0000000..001efea --- /dev/null +++ b/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/NavigationExtension.kt @@ -0,0 +1,22 @@ +package app.k9mail.core.ui.compose.navigation + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink + +inline fun NavGraphBuilder.deepLinkComposable( + basePath: String, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + composable( + deepLinks = listOf( + navDeepLink( + basePath = basePath, + ), + ), + content = content, + ) +} diff --git a/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/Route.kt b/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/Route.kt new file mode 100644 index 0000000..fb141d9 --- /dev/null +++ b/core/ui/compose/navigation/src/main/kotlin/app/k9mail/core/ui/compose/navigation/Route.kt @@ -0,0 +1,18 @@ +package app.k9mail.core.ui.compose.navigation + +/** + * A Route represents a destination in the app. + * + * It is used to navigate to a specific screen using type-safe composable navigation + * and deep links. + * + * @see Navigation + */ +interface Route { + val basePath: String + + /** + * The route to navigate to this screen. + */ + fun route(): String +} diff --git a/core/ui/compose/preference/build.gradle.kts b/core/ui/compose/preference/build.gradle.kts new file mode 100644 index 0000000..aa67f1f --- /dev/null +++ b/core/ui/compose/preference/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "net.thunderbird.core.ui.compose.preference" + resourcePrefix = "core_ui_preference_" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceViewPreview.kt new file mode 100644 index 0000000..6d73eb8 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceViewPreview.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.ui.compose.preference.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevicesWithBackground +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@PreviewDevicesWithBackground +fun PreferenceViewPreview() { + PreviewWithTheme { + PreferenceView( + title = "Title", + subtitle = "Subtitle", + preferences = FakePreferenceData.preferences, + onPreferenceChange = {}, + onBack = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/PreferenceTopBarPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/PreferenceTopBarPreview.kt new file mode 100644 index 0000000..ed0fa8d --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/PreferenceTopBarPreview.kt @@ -0,0 +1,29 @@ +package net.thunderbird.core.ui.compose.preference.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun PreferenceTopBarPreview() { + PreviewWithThemes { + PreferenceTopBar( + title = "Title", + subtitle = null, + onBack = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun PreferenceTopBarWithSubtitlePreview() { + PreviewWithThemes { + PreferenceTopBar( + title = "Title", + subtitle = "Subtitle", + onBack = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/common/ColoreViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/common/ColoreViewPreview.kt new file mode 100644 index 0000000..deffd85 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/common/ColoreViewPreview.kt @@ -0,0 +1,28 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun ColorViewPreview() { + PreviewWithThemes { + ColorView( + color = 0xFFFF0000.toInt(), + onClick = null, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ColorViewWithSelectionPreview() { + PreviewWithThemes { + ColorView( + color = 0xFFFF0000.toInt(), + onClick = null, + isSelected = true, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogColorViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogColorViewPreview.kt new file mode 100644 index 0000000..0370259 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogColorViewPreview.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun PreferenceDialogColorViewPreview() { + PreviewWithTheme { + PreferenceDialogColorView( + preference = FakePreferenceData.colorPreference, + onConfirmClick = {}, + onDismissClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogLayoutPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogLayoutPreview.kt new file mode 100644 index 0000000..db30a52 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogLayoutPreview.kt @@ -0,0 +1,22 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium + +@Composable +@Preview(showBackground = true) +internal fun PreferenceDialogLayoutPreview() { + PreviewWithTheme { + PreferenceDialogLayout( + title = "Dialog", + icon = null, + onConfirmClick = {}, + onDismissClick = {}, + onDismissRequest = {}, + ) { + TextBodyMedium("PreferenceDialogLayoutContent") + } + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogSingleChoiceCompactViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogSingleChoiceCompactViewPreview.kt new file mode 100644 index 0000000..ddd2bf9 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogSingleChoiceCompactViewPreview.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun PreferenceDialogSingleChoiceCompactViewPreview() { + PreviewWithTheme { + PreferenceDialogSingleChoiceCompactView( + preference = FakePreferenceData.singleChoiceCompactPreference, + onConfirmClick = {}, + onDismissClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogTextViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogTextViewPreview.kt new file mode 100644 index 0000000..4f73d47 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogTextViewPreview.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun PreferenceDialogTextViewPreview() { + PreviewWithTheme { + PreferenceDialogTextView( + preference = FakePreferenceData.textPreference, + onConfirmClick = {}, + onDismissClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemColorViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemColorViewPreview.kt new file mode 100644 index 0000000..ee50ff3 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemColorViewPreview.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun PreferenceItemColorViewPreview() { + PreviewWithThemes { + PreferenceItemColorView( + preference = FakePreferenceData.colorPreference, + onClick = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemLayoutPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemLayoutPreview.kt new file mode 100644 index 0000000..4df4e9a --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemLayoutPreview.kt @@ -0,0 +1,37 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge + +@Composable +@Preview(showBackground = true) +internal fun PreferenceItemLayoutPreview() { + PreviewWithThemes { + PreferenceItemLayout( + onClick = {}, + icon = null, + modifier = Modifier.fillMaxWidth(), + ) { + TextTitleLarge(text = "PreferenceItemLayoutContent") + } + } +} + +@Composable +@Preview(showBackground = true) +internal fun PreferenceItemLayoutWithIconPreview() { + PreviewWithThemes { + PreferenceItemLayout( + onClick = {}, + icon = Icons.Outlined.Info, + modifier = Modifier.fillMaxWidth(), + ) { + TextTitleLarge(text = "PreferenceItemLayoutContent") + } + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceCompactViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceCompactViewPreview.kt new file mode 100644 index 0000000..c0a5b2a --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceCompactViewPreview.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun PreferenceItemSingleChoiceCompactViewPreview() { + PreviewWithThemes { + PreferenceItemSingleChoiceCompactView( + preference = FakePreferenceData.singleChoiceCompactPreference, + onClick = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceViewPreview.kt new file mode 100644 index 0000000..c3868d2 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceViewPreview.kt @@ -0,0 +1,28 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun PreferenceItemSingleChoiceViewPreview() { + PreviewWithThemes { + PreferenceItemSingleChoiceView( + preference = FakePreferenceData.singleChoicePreference.copy(description = { null }), + onPreferenceChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun PreferenceItemSingleChoiceViewWithDescriptionPreview() { + PreviewWithThemes { + PreferenceItemSingleChoiceView( + preference = FakePreferenceData.singleChoicePreference, + onPreferenceChange = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSwitchViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSwitchViewPreview.kt new file mode 100644 index 0000000..a540d38 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSwitchViewPreview.kt @@ -0,0 +1,50 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun Preview_Switch_On_Enabled() { + PreviewWithThemes { + PreferenceItemSwitchView( + preference = FakePreferenceData.switchPreference, + onPreferenceChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun Preview_Switch_Off_Enabled() { + PreviewWithThemes { + PreferenceItemSwitchView( + preference = FakePreferenceData.switchPreference.copy(value = false), + onPreferenceChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun Preview_Switch_On_Disabled() { + PreviewWithThemes { + PreferenceItemSwitchView( + preference = FakePreferenceData.switchPreference.copy(enabled = false), + onPreferenceChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun Preview_Switch_Off_Disabled() { + PreviewWithThemes { + PreferenceItemSwitchView( + preference = FakePreferenceData.switchPreference.copy(value = false, enabled = false), + onPreferenceChange = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemTextViewPreview.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemTextViewPreview.kt new file mode 100644 index 0000000..d76833e --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemTextViewPreview.kt @@ -0,0 +1,17 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import net.thunderbird.core.ui.compose.preference.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun PreferenceItemTextViewPreview() { + PreviewWithThemes { + PreferenceItemTextView( + preference = FakePreferenceData.textPreference, + onClick = {}, + ) + } +} diff --git a/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/fake/FakePreferenceData.kt b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/fake/FakePreferenceData.kt new file mode 100644 index 0000000..bd6f172 --- /dev/null +++ b/core/ui/compose/preference/src/debug/kotlin/net/thunderbird/core/ui/compose/preference/ui/fake/FakePreferenceData.kt @@ -0,0 +1,78 @@ +package net.thunderbird.core.ui.compose.preference.ui.fake + +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting.SingleChoice.Choice +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting.SingleChoiceCompact.CompactChoice + +internal object FakePreferenceData { + + val textPreference = PreferenceSetting.Text( + id = "text", + icon = { Icons.Outlined.Delete }, + title = { "Title" }, + description = { "Description" }, + value = "Value", + ) + + val colorPreference = PreferenceSetting.Color( + id = "color", + icon = { Icons.Outlined.Delete }, + title = { "Title" }, + description = { "Description" }, + value = 0xFFFF0000.toInt(), + colors = persistentListOf( + 0xFFFF0000.toInt(), + 0xFF00FF00.toInt(), + 0xFF0000FF.toInt(), + ), + ) + + private val choices = persistentListOf( + Choice("1") { "Choice 1" }, + Choice("2") { "Choice 2" }, + Choice("3") { "Choice 3" }, + ) + + val singleChoicePreference = PreferenceSetting.SingleChoice( + id = "single_choice", + title = { "Title" }, + description = { "Description" }, + value = choices[1], + options = choices, + ) + + private val compactChoices = persistentListOf( + CompactChoice("1") { "Compact Choice 1" }, + CompactChoice("2") { "Compact Choice 2" }, + CompactChoice("3") { "Compact Choice 3" }, + CompactChoice("1") { "Compact Choice 4" }, + CompactChoice("2") { "Compact Choice 5" }, + CompactChoice("3") { "Compact Choice 6" }, + ) + + val singleChoiceCompactPreference = PreferenceSetting.SingleChoiceCompact( + id = "single_choice_compact", + title = { "Title" }, + icon = { Icons.Outlined.Info }, + description = { "Description" }, + value = compactChoices[1], + options = compactChoices, + ) + + val switchPreference = PreferenceSetting.Switch( + id = "switch", + title = { "Title" }, + description = { "Description" }, + enabled = true, + value = true, + ) + + val preferences = persistentListOf( + textPreference, + colorPreference, + switchPreference, + singleChoicePreference, + ) +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/api/Preference.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/api/Preference.kt new file mode 100644 index 0000000..f4e4d11 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/api/Preference.kt @@ -0,0 +1,125 @@ +package net.thunderbird.core.ui.compose.preference.api + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.collections.immutable.ImmutableList +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting.SingleChoice.Choice +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting.SingleChoiceCompact.CompactChoice + +/** + * A preference that can be displayed in a preference screen. + */ +sealed interface Preference : Parcelable { + val id: String +} + +/** + * A preference that holds a value of type [T]. + */ +sealed interface PreferenceSetting : Preference { + val value: T + val requiresEditView: Boolean + + @Parcelize + data class Text( + override val id: String, + val title: () -> String, + val description: () -> String? = { null }, + val icon: () -> ImageVector? = { null }, + override val value: String, + ) : PreferenceSetting { + @IgnoredOnParcel + override val requiresEditView: Boolean = true + } + + @Parcelize + data class Color( + override val id: String, + val title: () -> String, + val description: () -> String? = { null }, + val icon: () -> ImageVector? = { null }, + override val value: Int, + val colors: ImmutableList, + ) : PreferenceSetting { + @IgnoredOnParcel + override val requiresEditView: Boolean = true + } + + @Parcelize + data class SingleChoice( + override val id: String, + val title: () -> String, + val description: () -> String? = { null }, + override val value: Choice, + val options: ImmutableList, + ) : PreferenceSetting { + @IgnoredOnParcel + override val requiresEditView: Boolean = false + + @Parcelize + data class Choice( + val id: String, + val title: () -> String, + ) : Parcelable + } + + @Parcelize + data class SingleChoiceCompact( + override val id: String, + val title: () -> String, + val description: () -> String? = { null }, + val icon: () -> ImageVector? = { null }, + override val value: CompactChoice, + val options: ImmutableList, + ) : PreferenceSetting { + @IgnoredOnParcel + override val requiresEditView: Boolean = true + + @Parcelize + data class CompactChoice( + val id: String, + val title: () -> String, + ) : Parcelable + } + + @Parcelize + data class Switch( + override val id: String, + val title: () -> String, + val description: () -> String? = { null }, + val enabled: Boolean, + override val value: Boolean, + ) : PreferenceSetting { + @IgnoredOnParcel + override val requiresEditView: Boolean = false + } +} + +/** + * A preference that does not hold a value. It is used to display a section, a divider or custom UI. + */ +sealed interface PreferenceDisplay : Preference { + + @Parcelize + data class Custom( + override val id: String, + val customUi: @Composable (Modifier) -> Unit, + ) : PreferenceDisplay + + @Parcelize + data class SectionHeader( + override val id: String, + val title: () -> String, + val color: () -> Color = { Color.Unspecified }, + ) : PreferenceDisplay + + @Parcelize + data class SectionDivider( + override val id: String, + ) : PreferenceDisplay +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceView.kt new file mode 100644 index 0000000..d8a4f4d --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceView.kt @@ -0,0 +1,36 @@ +package net.thunderbird.core.ui.compose.preference.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.collections.immutable.ImmutableList +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +/** + * A view that displays a list of preferences. + * + * @param title The title of the view. + * @param subtitle The subtitle of the view (optional). + * @param preferences The list of preferences to display. + * @param onPreferenceChange The callback to be invoked when a preference is changed. + * @param onBack The callback to be invoked when the back button is clicked. + * @param modifier The modifier to be applied to the view. + */ +@Composable +fun PreferenceView( + title: String, + preferences: ImmutableList, + onPreferenceChange: (PreferenceSetting<*>) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + PreferenceViewWithDialog( + title = title, + subtitle = subtitle, + preferences = preferences, + onPreferenceChange = onPreferenceChange, + onBack = onBack, + modifier = modifier, + ) +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceViewWithDialog.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceViewWithDialog.kt new file mode 100644 index 0000000..a0570f5 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/PreferenceViewWithDialog.kt @@ -0,0 +1,70 @@ +package net.thunderbird.core.ui.compose.preference.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import kotlinx.collections.immutable.ImmutableList +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.core.ui.compose.preference.ui.components.PreferenceTopBar +import net.thunderbird.core.ui.compose.preference.ui.components.dialog.PreferenceDialog +import net.thunderbird.core.ui.compose.preference.ui.components.list.PreferenceList + +@Composable +internal fun PreferenceViewWithDialog( + title: String, + preferences: ImmutableList, + onPreferenceChange: (PreferenceSetting<*>) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + var showDialog by rememberSaveable { mutableStateOf(false) } + var selectedIndex by rememberSaveable { mutableIntStateOf(0) } + + Scaffold( + topBar = { + PreferenceTopBar( + title = title, + subtitle = subtitle, + onBack = onBack, + ) + }, + modifier = modifier, + ) { innerPadding -> + ResponsiveWidthContainer { contentPadding -> + PreferenceList( + preferences = preferences, + onItemClick = { index, _ -> + selectedIndex = index + showDialog = true + }, + onPreferenceChange = onPreferenceChange, + modifier = Modifier + .padding(innerPadding) + .padding(contentPadding), + ) + } + } + + if (showDialog) { + val preference = preferences[selectedIndex] + + PreferenceDialog( + preference = preference, + onConfirmClick = { preference -> + onPreferenceChange(preference) + showDialog = false + }, + onDismissClick = { showDialog = false }, + onDismissRequest = { showDialog = false }, + ) + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/PreferenceTopBar.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/PreferenceTopBar.kt new file mode 100644 index 0000000..845752f --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/PreferenceTopBar.kt @@ -0,0 +1,25 @@ +package net.thunderbird.core.ui.compose.preference.ui.components + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.designsystem.organism.SubtitleTopAppBarWithBackButton +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBarWithBackButton + +@Composable +internal fun PreferenceTopBar( + title: String, + subtitle: String?, + onBack: () -> Unit, +) { + if (subtitle != null) { + SubtitleTopAppBarWithBackButton( + title = title, + subtitle = subtitle, + onBackClick = onBack, + ) + } else { + TopAppBarWithBackButton( + title = title, + onBackClick = onBack, + ) + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/common/ColorView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/common/ColorView.kt new file mode 100644 index 0000000..2789cee --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/common/ColorView.kt @@ -0,0 +1,46 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +internal fun ColorView( + color: Int, + onClick: ((Int) -> Unit)?, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + size: Dp = MainTheme.sizes.icon, +) { + Surface( + color = Color(color), + modifier = modifier + .size(size) + .clip(CircleShape) + .let { + if (onClick != null) { + it.clickable(onClick = { onClick(color) }) + } else { + it + } + }, + ) { + if (isSelected) { + Icon( + tint = MainTheme.colors.onSecondary, + imageVector = Icons.Outlined.Check, + modifier = Modifier.padding(MainTheme.spacings.default), + ) + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialog.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialog.kt new file mode 100644 index 0000000..7f7a305 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialog.kt @@ -0,0 +1,54 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceDialog( + preference: Preference, + onConfirmClick: (PreferenceSetting<*>) -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + require(preference is PreferenceSetting<*>) { + "Unsupported preference type: ${preference::class.java.simpleName}" + } + + when (preference) { + is PreferenceSetting.Text -> { + PreferenceDialogTextView( + preference = preference, + onConfirmClick = onConfirmClick, + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) + } + + is PreferenceSetting.Color -> { + PreferenceDialogColorView( + preference = preference, + onConfirmClick = onConfirmClick, + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) + } + + is PreferenceSetting.SingleChoiceCompact -> { + PreferenceDialogSingleChoiceCompactView( + preference = preference, + onConfirmClick = onConfirmClick, + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) + } + + // No dialog needed + is PreferenceSetting.SingleChoice, is PreferenceSetting.Switch -> Unit + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogColorView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogColorView.kt new file mode 100644 index 0000000..dec5e76 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogColorView.kt @@ -0,0 +1,62 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.core.ui.compose.preference.ui.components.common.ColorView + +@Composable +internal fun PreferenceDialogColorView( + preference: PreferenceSetting.Color, + onConfirmClick: (PreferenceSetting<*>) -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + val currentColor = rememberSaveable { mutableIntStateOf(preference.value) } + val gridState = rememberLazyGridState() + + PreferenceDialogLayout( + title = preference.title(), + icon = preference.icon(), + onConfirmClick = { onConfirmClick(preference.copy(value = currentColor.intValue)) }, + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) { + preference.description()?.let { + TextBodyMedium(text = it) + + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + } + LazyVerticalGrid( + state = gridState, + columns = GridCells.Adaptive(minSize = MainTheme.sizes.iconAvatar), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + items(preference.colors) { color -> + ColorView( + color = color, + onClick = { newColor -> + currentColor.intValue = newColor + }, + isSelected = color == currentColor.intValue, + modifier = Modifier.size(MainTheme.sizes.iconAvatar), + ) + } + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogLayout.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogLayout.kt new file mode 100644 index 0000000..e35f0e3 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogLayout.kt @@ -0,0 +1,42 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.organism.AlertDialog +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.preference.R + +@Composable +internal fun PreferenceDialogLayout( + title: String, + icon: ImageVector?, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + AlertDialog( + title = title, + icon = icon, + confirmText = stringResource(id = R.string.core_ui_preference_dialog_button_accept), + onConfirmClick = onConfirmClick, + dismissText = stringResource(id = R.string.core_ui_preference_dialog_button_cancel), + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + modifier = Modifier.fillMaxWidth(), + ) { + content() + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogSingleChoiceCompactView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogSingleChoiceCompactView.kt new file mode 100644 index 0000000..7179171 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogSingleChoiceCompactView.kt @@ -0,0 +1,50 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.RadioGroup +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceDialogSingleChoiceCompactView( + preference: PreferenceSetting.SingleChoiceCompact, + onConfirmClick: (PreferenceSetting<*>) -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + val options by remember { mutableStateOf(preference.options) } + var selectedOption by remember { mutableStateOf(preference.value) } + + PreferenceDialogLayout( + title = preference.title(), + icon = preference.icon(), + onConfirmClick = { + onConfirmClick(preference.copy(value = selectedOption)) + }, + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) { + preference.description()?.let { + TextBodyMedium(text = it) + + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + } + + RadioGroup( + onClick = { selectedOption = it }, + options = options, + optionTitle = { it.title() }, + selectedOption = selectedOption, + ) + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogTextView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogTextView.kt new file mode 100644 index 0000000..0cc8b82 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/dialog/PreferenceDialogTextView.kt @@ -0,0 +1,76 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.dialog + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.molecule.input.AdvancedTextInput +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.coroutines.delay +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +// This a workaround for a bug in Compose, preventing the keyboard been show when requesting focus on a dialog, +// see: https://issuetracker.google.com/issues/204502668 +private const val EDIT_TEXT_FOCUS_DELAY = 200L + +@Composable +internal fun PreferenceDialogTextView( + preference: PreferenceSetting.Text, + onConfirmClick: (PreferenceSetting<*>) -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = preference.value, + selection = TextRange(preference.value.length), + ), + ) + } + + LaunchedEffect(Unit) { + delay(EDIT_TEXT_FOCUS_DELAY) + focusRequester.requestFocus() + } + + PreferenceDialogLayout( + title = preference.title(), + icon = preference.icon(), + onConfirmClick = { + onConfirmClick(preference.copy(value = textFieldValue.text)) + }, + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + ) { + preference.description()?.let { + TextBodyMedium(text = it) + + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + } + + AdvancedTextInput( + text = textFieldValue, + contentPadding = PaddingValues(), + onTextChange = { changedText -> + textFieldValue = changedText + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItem.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItem.kt new file mode 100644 index 0000000..24349e5 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItem.kt @@ -0,0 +1,77 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceItem( + preference: Preference, + onClick: () -> Unit, + onPreferenceChange: (PreferenceSetting<*>) -> Unit, + modifier: Modifier = Modifier, +) { + when (preference) { + // PreferenceSetting + is PreferenceSetting.Text -> { + PreferenceItemTextView( + preference = preference, + onClick = onClick, + modifier = modifier, + ) + } + + is PreferenceSetting.Color -> { + PreferenceItemColorView( + preference = preference, + onClick = onClick, + modifier = modifier, + ) + } + + is PreferenceSetting.SingleChoice -> { + PreferenceItemSingleChoiceView( + preference = preference, + onPreferenceChange = onPreferenceChange, + modifier = modifier, + ) + } + + is PreferenceSetting.Switch -> { + PreferenceItemSwitchView( + preference = preference, + onPreferenceChange = onPreferenceChange, + modifier = modifier, + ) + } + + is PreferenceSetting.SingleChoiceCompact -> PreferenceItemSingleChoiceCompactView( + preference = preference, + onClick = onClick, + modifier = modifier, + ) + + // PreferenceDisplay + is PreferenceDisplay.Custom -> { + PreferenceItemCustomView( + preference = preference, + modifier = modifier, + ) + } + + is PreferenceDisplay.SectionHeader -> { + PreferenceItemSectionHeaderView( + preference = preference, + modifier = modifier, + ) + } + + is PreferenceDisplay.SectionDivider -> { + PreferenceItemSectionDividerView( + modifier = modifier, + ) + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemColorView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemColorView.kt new file mode 100644 index 0000000..a747d18 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemColorView.kt @@ -0,0 +1,44 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.core.ui.compose.preference.ui.components.common.ColorView + +@Composable +internal fun PreferenceItemColorView( + preference: PreferenceSetting.Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceItemLayout( + onClick = onClick, + icon = preference.icon(), + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier.weight(1f), + ) { + TextTitleMedium(text = preference.title()) + preference.description()?.let { + TextBodyMedium(text = it) + } + } + ColorView( + color = preference.value, + onClick = null, + modifier = Modifier.padding(start = MainTheme.spacings.default), + ) + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemCustomView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemCustomView.kt new file mode 100644 index 0000000..bc1227c --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemCustomView.kt @@ -0,0 +1,13 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay + +@Composable +internal fun PreferenceItemCustomView( + preference: PreferenceDisplay.Custom, + modifier: Modifier = Modifier, +) { + preference.customUi(modifier) +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemLayout.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemLayout.kt new file mode 100644 index 0000000..1a16394 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemLayout.kt @@ -0,0 +1,49 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +internal fun PreferenceItemLayout( + onClick: () -> Unit, + icon: ImageVector?, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Box( + modifier = modifier + .clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(MainTheme.spacings.double), + ) { + Icon( + imageVector = it, + ) + } + } + Column( + modifier = Modifier.padding(MainTheme.spacings.double), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + ) { + content() + } + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSectionDividerView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSectionDividerView.kt new file mode 100644 index 0000000..e54995d --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSectionDividerView.kt @@ -0,0 +1,14 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.DividerHorizontal + +@Composable +internal fun PreferenceItemSectionDividerView( + modifier: Modifier = Modifier, +) { + DividerHorizontal( + modifier = modifier, + ) +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSectionHeaderView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSectionHeaderView.kt new file mode 100644 index 0000000..761a5e3 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSectionHeaderView.kt @@ -0,0 +1,22 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay + +@Composable +internal fun PreferenceItemSectionHeaderView( + preference: PreferenceDisplay.SectionHeader, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(MainTheme.spacings.double)) { + TextTitleMedium( + text = preference.title(), + color = preference.color(), + ) + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceCompactView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceCompactView.kt new file mode 100644 index 0000000..609c54d --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceCompactView.kt @@ -0,0 +1,36 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceItemSingleChoiceCompactView( + preference: PreferenceSetting.SingleChoiceCompact, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceItemLayout( + onClick = onClick, + icon = preference.icon(), + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier.weight(1f), + ) { + TextTitleMedium(text = preference.value.title()) + preference.description()?.let { + TextBodyMedium(text = it) + } + } + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceView.kt new file mode 100644 index 0000000..8349bd0 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSingleChoiceView.kt @@ -0,0 +1,43 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonSegmentedSingleChoice +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceItemSingleChoiceView( + preference: PreferenceSetting.SingleChoice, + onPreferenceChange: (PreferenceSetting<*>) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(MainTheme.spacings.double), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + ) { + TextTitleMedium(text = preference.title()) + + ButtonSegmentedSingleChoice( + onClick = { + onPreferenceChange(preference.copy(value = it)) + }, + options = preference.options, + optionTitle = { it.title() }, + selectedOption = preference.value, + ) + + preference.description()?.let { + TextBodyMedium( + modifier = Modifier.padding(start = MainTheme.spacings.oneHalf), + color = MainTheme.colors.onSurfaceVariant, + text = it, + ) + } + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSwitchView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSwitchView.kt new file mode 100644 index 0000000..6baaa88 --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemSwitchView.kt @@ -0,0 +1,41 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.Switch +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceItemSwitchView( + preference: PreferenceSetting.Switch, + modifier: Modifier = Modifier, + onPreferenceChange: (PreferenceSetting<*>) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(MainTheme.spacings.double), + ) { + Column( + Modifier.weight(1f), + ) { + TextTitleMedium(text = preference.title()) + preference.description()?.let { + TextBodyMedium(text = it) + } + } + Switch( + checked = preference.value, + onCheckedChange = { + onPreferenceChange(preference.copy(value = it)) + }, + enabled = preference.enabled, + ) + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemTextView.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemTextView.kt new file mode 100644 index 0000000..a07672c --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceItemTextView.kt @@ -0,0 +1,23 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceItemTextView( + preference: PreferenceSetting.Text, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceItemLayout( + onClick = onClick, + icon = preference.icon(), + modifier = modifier, + ) { + TextTitleMedium(text = preference.title()) + TextBodyMedium(text = preference.value) + } +} diff --git a/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceList.kt b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceList.kt new file mode 100644 index 0000000..b27d95d --- /dev/null +++ b/core/ui/compose/preference/src/main/kotlin/net/thunderbird/core/ui/compose/preference/ui/components/list/PreferenceList.kt @@ -0,0 +1,33 @@ +package net.thunderbird.core.ui.compose.preference.ui.components.list + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.collections.immutable.ImmutableList +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +@Composable +internal fun PreferenceList( + preferences: ImmutableList, + onItemClick: (index: Int, item: Preference) -> Unit, + onPreferenceChange: (PreferenceSetting<*>) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + ) { + itemsIndexed(preferences) { index, item -> + PreferenceItem( + preference = item, + onClick = { + onItemClick(index, item) + }, + onPreferenceChange = onPreferenceChange, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/core/ui/compose/preference/src/main/res/values/strings.xml b/core/ui/compose/preference/src/main/res/values/strings.xml new file mode 100644 index 0000000..3d01935 --- /dev/null +++ b/core/ui/compose/preference/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Accept + Cancel + diff --git a/core/ui/compose/testing/README.md b/core/ui/compose/testing/README.md new file mode 100644 index 0000000..21fc963 --- /dev/null +++ b/core/ui/compose/testing/README.md @@ -0,0 +1,3 @@ +## Core - UI - Compose - Testing + +Uses [`:core:ui:compose:theme`](../theme/README.md) diff --git a/core/ui/compose/testing/build.gradle.kts b/core/ui/compose/testing/build.gradle.kts new file mode 100644 index 0000000..1544f1d --- /dev/null +++ b/core/ui/compose/testing/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.core.ui.compose.testing" +} + +dependencies { + api(projects.core.testing) + api(libs.turbine) + api(libs.assertk) + + implementation(projects.core.ui.compose.theme2.thunderbird) + + implementation(libs.bundles.shared.jvm.test.compose) +} diff --git a/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/BaseFakeViewModel.kt b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/BaseFakeViewModel.kt new file mode 100644 index 0000000..a2fa7ba --- /dev/null +++ b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/BaseFakeViewModel.kt @@ -0,0 +1,36 @@ +package app.k9mail.core.ui.compose.testing + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel + +/** + * Base class for providing fake MVI ViewModels for testing. + * + * This class provides a way to capture events and emit effects on a fake ViewModel. + * The state can be set directly using [applyState]. + * + * Example usage: + * + * ``` + * class FakeViewModel( + * initialState: State = State(), + * ) : BaseFakeViewModel(initialState), ViewModel + * ``` + */ +abstract class BaseFakeViewModel( + initialState: STATE, +) : BaseViewModel(initialState = initialState) { + + val events = mutableListOf() + + override fun event(event: EVENT) { + events.add(event) + } + + fun effect(effect: EFFECT) { + emitEffect(effect) + } + + fun applyState(state: STATE) { + updateState { state } + } +} diff --git a/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt new file mode 100644 index 0000000..62849a6 --- /dev/null +++ b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt @@ -0,0 +1,109 @@ +@file:Suppress("TooManyFunctions") + +package app.k9mail.core.ui.compose.testing + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.test.espresso.Espresso +import app.k9mail.core.ui.compose.theme2.thunderbird.ThunderbirdTheme2 +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +open class ComposeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + fun getString(@StringRes resourceId: Int): String = RuntimeEnvironment.getApplication().getString(resourceId) + + fun runComposeTest(testContent: ComposeContentTestRule.() -> Unit) = with(composeTestRule) { + testContent() + } +} + +/** + * Set the content of the test + */ +fun ComposeTest.setContent(content: @Composable () -> Unit) = composeTestRule.setContent(content) + +/** + * Set the content of the test and wrap it in the default theme. + */ +fun ComposeTest.setContentWithTheme(content: @Composable () -> Unit) = composeTestRule.setContent { + ThunderbirdTheme2 { + content() + } +} + +fun ComposeTest.onNodeWithTag( + tag: String, + useUnmergedTree: Boolean = false, +) = composeTestRule.onNodeWithTag(tag, useUnmergedTree) + +fun ComposeTest.onAllNodesWithTag( + tag: String, + useUnmergedTree: Boolean = false, +) = composeTestRule.onAllNodesWithTag(tag, useUnmergedTree) + +fun ComposeTest.onNodeWithContentDescription( + label: String, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false, +) = composeTestRule.onNodeWithContentDescription(label, substring, ignoreCase, useUnmergedTree) + +fun ComposeTest.onNodeWithText( + text: String, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false, +) = composeTestRule.onNodeWithText(text, substring, ignoreCase, useUnmergedTree) + +fun ComposeTest.onNodeWithText( + @StringRes resourceId: Int, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false, +) = composeTestRule.onNodeWithText(getString(resourceId), substring, ignoreCase, useUnmergedTree) + +fun ComposeTest.onNodeWithTextIgnoreCase( + text: String, + substring: Boolean = false, + useUnmergedTree: Boolean = false, +) = composeTestRule.onNodeWithText(text, substring, true, useUnmergedTree) + +fun ComposeTest.onNodeWithTextIgnoreCase( + @StringRes resourceId: Int, + substring: Boolean = false, + useUnmergedTree: Boolean = false, +) = composeTestRule.onNodeWithText(getString(resourceId), substring, true, useUnmergedTree) + +fun ComposeTest.onAllNodesWithText( + text: String, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false, +) = composeTestRule.onAllNodesWithText(text, substring, ignoreCase, useUnmergedTree) + +fun ComposeTest.onAllNodesWithContentDescription( + label: String, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false, +) = composeTestRule.onAllNodesWithContentDescription(label, substring, ignoreCase, useUnmergedTree) + +fun ComposeTest.onRoot(useUnmergedTree: Boolean = false) = composeTestRule.onRoot(useUnmergedTree) + +fun ComposeTest.pressBack() = Espresso.pressBack() diff --git a/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/mvi/EventStateTestUtil.kt b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/mvi/EventStateTestUtil.kt new file mode 100644 index 0000000..d73ff16 --- /dev/null +++ b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/mvi/EventStateTestUtil.kt @@ -0,0 +1,21 @@ +package app.k9mail.core.ui.compose.testing.mvi + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import assertk.assertThat +import assertk.assertions.isEqualTo + +/** + * Tests that the state of the [viewModel] changes as expected when the [event] is sent. + */ +suspend inline fun MviContext.eventStateTest( + viewModel: UnidirectionalViewModel, + initialState: STATE, + event: EVENT, + expectedState: STATE, +) { + val turbines = turbinesWithInitialStateCheck(viewModel, initialState) + + viewModel.event(event) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(expectedState) +} diff --git a/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/mvi/MviTurbineExtension.kt b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/mvi/MviTurbineExtension.kt new file mode 100644 index 0000000..2a072c5 --- /dev/null +++ b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/mvi/MviTurbineExtension.kt @@ -0,0 +1,157 @@ +package app.k9mail.core.ui.compose.testing.mvi + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.TurbineContext +import app.cash.turbine.turbineScope +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import assertk.Assert +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest + +/** + * The `runMviTest` function is a wrapper around `runTest` and `turbineScope` + * that provides a MviContext to the test body. + */ +fun runMviTest( + testBody: suspend MviContext.() -> Unit, +) { + runTest { + val testScope = this + turbineScope { + val turbineContext = this + testBody( + DefaultMviContext( + testScope = testScope, + turbineContext = turbineContext, + ), + ) + } + } +} + +interface MviContext { + val testScope: TestScope + val turbineContext: TurbineContext +} + +class DefaultMviContext( + override val testScope: TestScope, + override val turbineContext: TurbineContext, +) : MviContext + +@OptIn(ExperimentalCoroutinesApi::class) +fun MviContext.advanceUntilIdle() { + testScope.advanceUntilIdle() +} + +/** + * The `turbines` extension function creates a MviTurbines instance for the given MVI ViewModel. + */ +inline fun MviContext.turbines( + viewModel: UnidirectionalViewModel, +): MviTurbines { + with(turbineContext) { + return MviTurbines( + stateTurbine = viewModel.state.testIn(testScope.backgroundScope), + effectTurbine = viewModel.effect.testIn(testScope.backgroundScope), + ) + } +} + +/** + * The `turbinesWithInitialStateCheck` extension function creates a MviTurbines instance for the given MVI ViewModel + * and ensures that the initial state is emitted. + */ +suspend inline fun MviContext.turbinesWithInitialStateCheck( + viewModel: UnidirectionalViewModel, + initialState: STATE, +): MviTurbines { + val turbines = turbines(viewModel) + + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(initialState) + } + + return turbines +} + +/** + * The `assertThatAndMviTurbinesConsumed` function ensures that the assertion passed and + * all events in the given MviTurbines have been consumed. + * + * Usage: + * val actualValue: T = getActualValue() + * val turbines = viewModel.turbines(coroutineScope) + * assertThatAndMviTurbinesConsumed(actualValue, turbines) { + * // your assertion here + * } + * + * @param T The type of the actual value. + * @param STATE The type of the state. + * @param EFFECT The type of the effect. + * @param actual The actual value being asserted. + * @param turbines The MviTurbines instance to check if all events are consumed. + * @param assertion An extension function on `Assert`, which is used to define assertions on the actual value. + */ +fun assertThatAndMviTurbinesConsumed( + actual: T, + turbines: MviTurbines, + assertion: Assert.() -> Unit, +) { + assertThat(actual).all { + assertion() + } + + turbines.stateTurbine.ensureAllEventsConsumed() + turbines.effectTurbine.ensureAllEventsConsumed() +} + +/** + * The `assertThatAndStateTurbineConsumed` function ensures that the assertion passed and + * all events in the state turbine have been consumed. + */ +suspend fun MviTurbines.assertThatAndStateTurbineConsumed( + assertion: Assert.() -> Unit, +) { + assertThat(stateTurbine.awaitItem()).all { + assertion() + } + + stateTurbine.ensureAllEventsConsumed() + effectTurbine.ensureAllEventsConsumed() +} + +/** + * The `assertThatAndEffectTurbineConsumed` function ensures that the assertion passed and + * all events in the effect turbine have been consumed. + */ +suspend fun MviTurbines.assertThatAndEffectTurbineConsumed( + assertion: Assert.() -> Unit, +) { + assertThat(effectTurbine.awaitItem()).all { + assertion() + } + + stateTurbine.ensureAllEventsConsumed() + effectTurbine.ensureAllEventsConsumed() +} + +/** + * A container class for the state and effect turbines of an MVI ViewModel. + */ +data class MviTurbines( + val stateTurbine: ReceiveTurbine, + val effectTurbine: ReceiveTurbine, +) { + suspend fun awaitStateItem() = stateTurbine.awaitItem() + + suspend fun awaitEffectItem() = effectTurbine.awaitItem() +} diff --git a/core/ui/compose/theme2/common/README.md b/core/ui/compose/theme2/common/README.md new file mode 100644 index 0000000..147ca10 --- /dev/null +++ b/core/ui/compose/theme2/common/README.md @@ -0,0 +1,15 @@ +## Core - UI - Compose - Theme2 - Common + +This provides the common `MainTheme` with dark/light variation support, a wrapper for the Compose Material 3 theme. It supports [CompositionLocal](https://developer.android.com/jetpack/compose/compositionlocal) changes to colors, typography, shapes and adds additionally elevations, sizes, spacings and images. + +To change Material 3 related properties use `MainTheme` instead of `MaterialTheme`: + +- `MainTheme.colors`: Material 3 color scheme +- `MainTheme.elevations`: Elevation levels as [defined](https://m3.material.io/styles/elevation/overview) in Material3 +- `MainTheme.images`: Images used across the theme, e.g. logo +- `MainTheme.shapes`: Shapes as [defined](https://m3.material.io/styles/shape/overview) in Material 3 +- `MainTheme.sizes`: Sizes (smaller, small, medium, large, larger, huge, huger) +- `MainTheme.spacings`: Spacings (quarter, half, default, oneHalf, double, triple, quadruple) while default is 8 dp. +- `MainTheme.typography`: Material 3 typography + +To use the MainTheme, you need to provide a `ThemeConfig` with your desired colors, typography, shapes, elevations, sizes, spacings and images. The `ThemeConfig` is a data class that holds all the necessary information for the `MainTheme` to work. diff --git a/core/ui/compose/theme2/common/build.gradle.kts b/core/ui/compose/theme2/common/build.gradle.kts new file mode 100644 index 0000000..e9ccecf --- /dev/null +++ b/core/ui/compose/theme2/common/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.core.ui.compose.theme2" + resourcePrefix = "core_ui_theme2" +} + +dependencies { + api(projects.core.ui.compose.common) + + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.android.material) + + implementation(libs.androidx.activity) +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/MainTheme.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/MainTheme.kt new file mode 100644 index 0000000..d66440f --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/MainTheme.kt @@ -0,0 +1,111 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable + +@Composable +fun MainTheme( + themeConfig: ThemeConfig, + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val themeColorScheme = selectThemeColorScheme( + themeConfig = themeConfig, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + ) + val themeImages = selectThemeImages( + themeConfig = themeConfig, + darkTheme = darkTheme, + ) + + SystemBar( + darkTheme = darkTheme, + colorScheme = themeColorScheme, + ) + + CompositionLocalProvider( + LocalThemeColorScheme provides themeColorScheme, + LocalThemeElevations provides themeConfig.elevations, + LocalThemeImages provides themeImages, + LocalThemeShapes provides themeConfig.shapes, + LocalThemeSizes provides themeConfig.sizes, + LocalThemeSpacings provides themeConfig.spacings, + LocalThemeTypography provides themeConfig.typography, + ) { + MaterialTheme( + colorScheme = themeColorScheme.toMaterial3ColorScheme(), + shapes = themeConfig.shapes.toMaterial3Shapes(), + typography = themeConfig.typography.toMaterial3Typography(), + content = content, + ) + } +} + +/** + * Contains functions to access the current theme values provided at the call site's position in + * the hierarchy. + */ +object MainTheme { + + /** + * Retrieves the current [ColorScheme] at the call site's position in the hierarchy. + */ + val colors: ThemeColorScheme + @Composable + @ReadOnlyComposable + get() = LocalThemeColorScheme.current + + /** + * Retrieves the current [ThemeElevations] at the call site's position in the hierarchy. + */ + val elevations: ThemeElevations + @Composable + @ReadOnlyComposable + get() = LocalThemeElevations.current + + /** + * Retrieves the current [ThemeImages] at the call site's position in the hierarchy. + */ + val images: ThemeImages + @Composable + @ReadOnlyComposable + get() = LocalThemeImages.current + + /** + * Retrieves the current [ThemeShapes] at the call site's position in the hierarchy. + */ + val shapes: ThemeShapes + @Composable + @ReadOnlyComposable + get() = LocalThemeShapes.current + + /** + * Retrieves the current [ThemeSizes] at the call site's position in the hierarchy. + */ + val sizes: ThemeSizes + @Composable + @ReadOnlyComposable + get() = LocalThemeSizes.current + + /** + * Retrieves the current [ThemeSpacings] at the call site's position in the hierarchy. + */ + val spacings: ThemeSpacings + @Composable + @ReadOnlyComposable + get() = LocalThemeSpacings.current + + /** + * Retrieves the current [ThemeTypography] at the call site's position in the hierarchy. + */ + val typography: ThemeTypography + @Composable + @ReadOnlyComposable + get() = LocalThemeTypography.current +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SelectThemeColorScheme.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SelectThemeColorScheme.kt new file mode 100644 index 0000000..5fcc9c5 --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SelectThemeColorScheme.kt @@ -0,0 +1,182 @@ +package app.k9mail.core.ui.compose.theme2 + +import android.content.Context +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import com.google.android.material.color.MaterialColors + +@Composable +internal fun selectThemeColorScheme( + themeConfig: ThemeConfig, + darkTheme: Boolean, + dynamicColor: Boolean, +): ThemeColorScheme { + return when { + dynamicColor && supportsDynamicColor() -> { + val context = LocalContext.current + val colorScheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + colorScheme.toDynamicThemeColorScheme(darkTheme, themeConfig.colors) + } + + darkTheme -> themeConfig.colors.dark + else -> themeConfig.colors.light + } +} + +// Supported from Android 12+ +private fun supportsDynamicColor(): Boolean { + return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S +} + +@Suppress("LongMethod") +private fun ColorScheme.toDynamicThemeColorScheme( + darkTheme: Boolean, + colors: ThemeColorSchemeVariants, +): ThemeColorScheme { + val colorScheme = if (darkTheme) colors.dark else colors.light + + val info = colorScheme.info.toHarmonizedColor(primary) + val onInfo = colorScheme.onInfo.toHarmonizedColor(primary) + val infoContainer = colorScheme.infoContainer.toHarmonizedColor(primary) + val onInfoContainer = colorScheme.onInfoContainer.toHarmonizedColor(primary) + + val success = colorScheme.success.toHarmonizedColor(primary) + val onSuccess = colorScheme.onSuccess.toHarmonizedColor(primary) + val successContainer = colorScheme.successContainer.toHarmonizedColor(primary) + val onSuccessContainer = colorScheme.onSuccessContainer.toHarmonizedColor(primary) + + val warning = colorScheme.warning.toHarmonizedColor(primary) + val onWarning = colorScheme.onWarning.toHarmonizedColor(primary) + val warningContainer = colorScheme.warningContainer.toHarmonizedColor(primary) + val onWarningContainer = colorScheme.onWarningContainer.toHarmonizedColor(primary) + + return ThemeColorScheme( + primary = primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + + surface = surface, + onSurface = onSurface, + onSurfaceVariant = onSurfaceVariant, + surfaceContainerLowest = surfaceContainerLowest, + surfaceContainerLow = surfaceContainerLow, + surfaceContainer = surfaceContainer, + surfaceContainerHigh = surfaceContainerHigh, + surfaceContainerHighest = surfaceContainerHighest, + + inverseSurface = inverseSurface, + inverseOnSurface = inverseOnSurface, + inversePrimary = inversePrimary, + + outline = outline, + outlineVariant = outlineVariant, + + surfaceBright = surfaceBright, + surfaceDim = surfaceDim, + + scrim = scrim, + + info = info, + onInfo = onInfo, + infoContainer = infoContainer, + onInfoContainer = onInfoContainer, + + success = success, + onSuccess = onSuccess, + successContainer = successContainer, + onSuccessContainer = onSuccessContainer, + + warning = warning, + onWarning = onWarning, + warningContainer = warningContainer, + onWarningContainer = onWarningContainer, + ) +} + +/** + * The color roles of a theme accent color. They are used to define the main accent color and its complementary colors + * in a Material Design theme. + * + * These roles are used to create a harmonious color scheme that works well together. + * + * The roles are: + * - `accent`: The main accent color. + * - `onAccent`: The color used for text and icons on top of the accent color. + * - `accentContainer`: A container color that complements the accent color. + * - `onAccentContainer`: The color used for text and icons on top of the accent container color. + * + * @param accent The main accent color. + * @param onAccent The color used for text and icons on top of the accent color. + * @param accentContainer A container color that complements the accent color. + * @param onAccentContainer The color used for text and icons on top of the accent container color. + */ +data class ColorRoles( + val accent: Color, + val onAccent: Color, + val accentContainer: Color, + val onAccentContainer: Color, +) + +/** + * Returns a harmonized color that is derived from the given color and the target color. + * + * This function uses Material Colors to harmonize the two colors. + * + * @param target The target color to harmonize with. + * @return A new color that is harmonized with the target color. + */ +fun Color.toHarmonizedColor(target: Color) = Color(MaterialColors.harmonize(toArgb(), target.toArgb())) + +/** + * Returns a [ColorRoles] object that contains the accent colors derived from the given color. + * + * This function uses Material Colors to retrieve the accent colors based on the provided color. + * + * @param context The context to use for retrieving the color roles. + * @return A [ColorRoles] object containing the accent colors. + */ +fun Color.toColorRoles(context: Context): ColorRoles { + val colorRoles = MaterialColors.getColorRoles(context, this.toArgb()) + return ColorRoles( + accent = Color(colorRoles.accent), + onAccent = Color(colorRoles.onAccent), + accentContainer = Color(colorRoles.accentContainer), + onAccentContainer = Color(colorRoles.onAccentContainer), + ) +} + +/** + * Returns a surface container color that is a composite of the given color and the theme surface container color. + * + * The alpha value is applied to the given color before compositing. + * + * @param alpha The alpha value to apply to the color. + * @return A new color that is a composite of the given color and the theme surface container color. + */ +@Composable +fun Color.toSurfaceContainer(alpha: Float): Color { + val color = copy(alpha = alpha) + return color.compositeOver(MainTheme.colors.surfaceContainer) +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SelectThemeImages.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SelectThemeImages.kt new file mode 100644 index 0000000..534f45b --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SelectThemeImages.kt @@ -0,0 +1,12 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.runtime.Composable + +@Composable +internal fun selectThemeImages( + themeConfig: ThemeConfig, + darkTheme: Boolean, +) = when { + darkTheme -> themeConfig.images.dark + else -> themeConfig.images.light +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SystemBar.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SystemBar.kt new file mode 100644 index 0000000..9648b3e --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/SystemBar.kt @@ -0,0 +1,24 @@ +package app.k9mail.core.ui.compose.theme2 + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +@Composable +fun SystemBar( + darkTheme: Boolean, + colorScheme: ThemeColorScheme, +) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.surfaceContainer.toArgb() + window.navigationBarColor = colorScheme.surfaceContainer.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeColorScheme.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeColorScheme.kt new file mode 100644 index 0000000..1a379b5 --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeColorScheme.kt @@ -0,0 +1,144 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.material3.LocalContentColor as Material3LocalContentColor + +/** + * Theme color scheme following Material 3 color roles. + * + * This supports tone-based Surfaces introduced for Material 3. + * + * @see: https://m3.material.io/styles/color/roles + * @see: https://material.io/blog/tone-based-surface-color-m3 + */ +@Immutable +data class ThemeColorScheme( + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + + val tertiary: Color, + val onTertiary: Color, + val tertiaryContainer: Color, + val onTertiaryContainer: Color, + + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, + + val surfaceDim: Color, + val surface: Color, + val surfaceBright: Color, + val onSurface: Color, + val onSurfaceVariant: Color, + + val surfaceContainerLowest: Color, + val surfaceContainerLow: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + + val inverseSurface: Color, + val inverseOnSurface: Color, + val inversePrimary: Color, + + val outline: Color, + val outlineVariant: Color, + + val scrim: Color, + + // extra colors + val info: Color, + val onInfo: Color, + val infoContainer: Color, + val onInfoContainer: Color, + + val success: Color, + val onSuccess: Color, + val successContainer: Color, + val onSuccessContainer: Color, + + val warning: Color, + val onWarning: Color, + val warningContainer: Color, + val onWarningContainer: Color, +) + +/** + * Convert a [ThemeColorScheme] to a Material 3 [ColorScheme]. + * + * Note: background, onBackground are deprecated and mapped to surface, onSurface. + */ +internal fun ThemeColorScheme.toMaterial3ColorScheme(): ColorScheme { + return ColorScheme( + primary = primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + + surfaceDim = surfaceDim, + surface = surface, + surfaceBright = surfaceBright, + onSurface = onSurface, + onSurfaceVariant = onSurfaceVariant, + + surfaceContainerLowest = surfaceContainerLowest, + surfaceContainerLow = surfaceContainerLow, + surfaceContainer = surfaceContainer, + surfaceContainerHigh = surfaceContainerHigh, + surfaceContainerHighest = surfaceContainerHighest, + + inverseSurface = inverseSurface, + inverseOnSurface = inverseOnSurface, + inversePrimary = inversePrimary, + + outline = outline, + outlineVariant = outlineVariant, + + scrim = scrim, + + // Remapping properties due to changes in Material 3 tone based surface colors + // https://material.io/blog/tone-based-surface-color-m3 + background = surface, + onBackground = onSurface, + surfaceVariant = surfaceContainerHighest, + + surfaceTint = surfaceContainerHighest, + ) +} + +internal val LocalThemeColorScheme = staticCompositionLocalOf { + error("No ThemeColorScheme provided") +} + +/** + * CompositionLocal used to specify the default color for text and icons. + * + * This uses the Material 3 [LocalContentColor] implementation. + */ +val LocalContentColor get() = Material3LocalContentColor diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeConfig.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeConfig.kt new file mode 100644 index 0000000..756d70e --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeConfig.kt @@ -0,0 +1,26 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.runtime.Immutable + +@Immutable +data class ThemeConfig( + val colors: ThemeColorSchemeVariants, + val elevations: ThemeElevations, + val images: ThemeImageVariants, + val shapes: ThemeShapes, + val sizes: ThemeSizes, + val spacings: ThemeSpacings, + val typography: ThemeTypography, +) + +@Immutable +data class ThemeColorSchemeVariants( + val dark: ThemeColorScheme, + val light: ThemeColorScheme, +) + +@Immutable +data class ThemeImageVariants( + val dark: ThemeImages, + val light: ThemeImages, +) diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeElevations.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeElevations.kt new file mode 100644 index 0000000..2a8989c --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeElevations.kt @@ -0,0 +1,29 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Elevation values used in the app. + * + * Material uses six levels of elevation, each with a corresponding dp value. These values are named for their + * relative distance above the UI’s surface: 0, +1, +2, +3, +4, and +5. An element’s resting state can be on + * levels 0 to +3, while levels +4 and +5 are reserved for user-interacted states such as hover and dragged. + * + * @see: https://m3.material.io/styles/elevation/tokens + */ +@Immutable +data class ThemeElevations( + val level0: Dp = 0.dp, + val level1: Dp = 1.dp, + val level2: Dp = 3.dp, + val level3: Dp = 6.dp, + val level4: Dp = 8.dp, + val level5: Dp = 12.dp, +) + +internal val LocalThemeElevations = staticCompositionLocalOf { + error("No ThemeElevations provided") +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeImages.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeImages.kt new file mode 100644 index 0000000..7bcf81c --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeImages.kt @@ -0,0 +1,15 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf + +@Suppress("detekt.UnnecessaryAnnotationUseSiteTarget") // https://github.com/detekt/detekt/issues/8212 +@Immutable +data class ThemeImages( + @param:DrawableRes val logo: Int, +) + +internal val LocalThemeImages = staticCompositionLocalOf { + error("No ThemeImages provided") +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeShapes.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeShapes.kt new file mode 100644 index 0000000..9f1402d --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeShapes.kt @@ -0,0 +1,51 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp + +/** + * The shapes used in the app. + * + * The shapes are defined as: + * + * - None + * - ExtraSmall + * - Small + * - Medium + * - Large + * - ExtraLarge + * - Full + * + * The default values are based on the Material Design guidelines. + * + * Shapes None and Full are omitted as None is a RectangleShape and Full is a CircleShape. + * + * @see: https://m3.material.io/styles/shape/overview + */ +@Immutable +data class ThemeShapes( + val extraSmall: CornerBasedShape = RoundedCornerShape(4.dp), + val small: CornerBasedShape = RoundedCornerShape(8.dp), + val medium: CornerBasedShape = RoundedCornerShape(12.dp), + val large: CornerBasedShape = RoundedCornerShape(16.dp), + val extraLarge: CornerBasedShape = RoundedCornerShape(28.dp), +) + +/** + * Converts the [ThemeShapes] to Material 3 [Shapes]. + */ +internal fun ThemeShapes.toMaterial3Shapes() = Shapes( + extraSmall = extraSmall, + small = small, + medium = medium, + large = large, + extraLarge = extraLarge, +) + +internal val LocalThemeShapes = staticCompositionLocalOf { + error("No ThemeShapes provided") +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeSizes.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeSizes.kt new file mode 100644 index 0000000..8da958b --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeSizes.kt @@ -0,0 +1,30 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp + +@Immutable +data class ThemeSizes( + val smaller: Dp, + val small: Dp, + val medium: Dp, + val large: Dp, + val larger: Dp, + val huge: Dp, + val huger: Dp, + + val iconSmall: Dp, + val icon: Dp, + val iconLarge: Dp, + val iconAvatar: Dp, + + val topBarHeight: Dp, + val bottomBarHeight: Dp, + val bottomBarHeightWithFab: Dp, + val bannerGlobalHeight: Dp, +) + +internal val LocalThemeSizes = staticCompositionLocalOf { + error("No ThemeSizes provided") +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeSpacings.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeSpacings.kt new file mode 100644 index 0000000..d8c831a --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeSpacings.kt @@ -0,0 +1,21 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp + +@Immutable +data class ThemeSpacings( + val zero: Dp, + val quarter: Dp, + val half: Dp, + val default: Dp, + val oneHalf: Dp, + val double: Dp, + val triple: Dp, + val quadruple: Dp, +) + +internal val LocalThemeSpacings = staticCompositionLocalOf { + error("No ThemeSpacings provided") +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeTypography.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeTypography.kt new file mode 100644 index 0000000..4387a69 --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/ThemeTypography.kt @@ -0,0 +1,50 @@ +package app.k9mail.core.ui.compose.theme2 + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle + +@Immutable +data class ThemeTypography( + val displayLarge: TextStyle, + val displayMedium: TextStyle, + val displaySmall: TextStyle, + val headlineLarge: TextStyle, + val headlineMedium: TextStyle, + val headlineSmall: TextStyle, + val titleLarge: TextStyle, + val titleMedium: TextStyle, + val titleSmall: TextStyle, + val bodyLarge: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + val labelLarge: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle, +) + +/** + * Convert [ThemeTypography] to Material 3 [Typography] + */ +internal fun ThemeTypography.toMaterial3Typography() = Typography( + displayLarge = displayLarge, + displayMedium = displayMedium, + displaySmall = displaySmall, + headlineLarge = headlineLarge, + headlineMedium = headlineMedium, + headlineSmall = headlineSmall, + titleLarge = titleLarge, + titleMedium = titleMedium, + titleSmall = titleSmall, + bodyLarge = bodyLarge, + bodyMedium = bodyMedium, + bodySmall = bodySmall, + labelLarge = labelLarge, + labelMedium = labelMedium, + labelSmall = labelSmall, +) + +internal val LocalThemeTypography = staticCompositionLocalOf { + error("No ThemeTypography provided") +} diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeElevations.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeElevations.kt new file mode 100644 index 0000000..9bd0a3c --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeElevations.kt @@ -0,0 +1,16 @@ +package app.k9mail.core.ui.compose.theme2.default + +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.theme2.ThemeElevations + +/** + * Default values for Material elevation taken from https://m3.material.io/styles/elevation/tokens + */ +val defaultThemeElevations = ThemeElevations( + level0 = 0.dp, + level1 = 1.dp, + level2 = 3.dp, + level3 = 6.dp, + level4 = 8.dp, + level5 = 12.dp, +) diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeShapes.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeShapes.kt new file mode 100644 index 0000000..7a1c6a5 --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeShapes.kt @@ -0,0 +1,13 @@ +package app.k9mail.core.ui.compose.theme2.default + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.theme2.ThemeShapes + +val defaultThemeShapes = ThemeShapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(28.dp), +) diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeSizes.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeSizes.kt new file mode 100644 index 0000000..5038f2f --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeSizes.kt @@ -0,0 +1,24 @@ +package app.k9mail.core.ui.compose.theme2.default + +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.theme2.ThemeSizes + +val defaultThemeSizes = ThemeSizes( + smaller = 8.dp, + small = 16.dp, + medium = 32.dp, + large = 64.dp, + larger = 128.dp, + huge = 256.dp, + huger = 384.dp, + + iconSmall = 16.dp, + icon = 24.dp, + iconLarge = 32.dp, + iconAvatar = 48.dp, + + topBarHeight = 64.dp, + bottomBarHeight = 80.dp, + bottomBarHeightWithFab = 72.dp, + bannerGlobalHeight = 48.dp, +) diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeSpacings.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeSpacings.kt new file mode 100644 index 0000000..95c6c60 --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeSpacings.kt @@ -0,0 +1,15 @@ +package app.k9mail.core.ui.compose.theme2.default + +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.theme2.ThemeSpacings + +val defaultThemeSpacings = ThemeSpacings( + zero = 0.dp, + quarter = 2.dp, + half = 4.dp, + default = 8.dp, + oneHalf = 12.dp, + double = 16.dp, + triple = 24.dp, + quadruple = 32.dp, +) diff --git a/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeTypography.kt b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeTypography.kt new file mode 100644 index 0000000..87b9323 --- /dev/null +++ b/core/ui/compose/theme2/common/src/main/kotlin/app/k9mail/core/ui/compose/theme2/default/DefaultThemeTypography.kt @@ -0,0 +1,116 @@ +package app.k9mail.core.ui.compose.theme2.default + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import app.k9mail.core.ui.compose.theme2.ThemeTypography + +@Suppress("MagicNumber") +val defaultTypography = ThemeTypography( + displayLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.2).sp, + ), + displayMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.2.sp, + ), + titleSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.2.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/core/ui/compose/theme2/k9mail/README.md b/core/ui/compose/theme2/k9mail/README.md new file mode 100644 index 0000000..1accfa7 --- /dev/null +++ b/core/ui/compose/theme2/k9mail/README.md @@ -0,0 +1,13 @@ +## Core - UI - Compose - Theme2 - K9Mail + +This provides the `K9MailTheme2` composable, that's setting up the `MainTheme` with K-9 Mail specific colors, typography, shapes, elevations, sizes, spacings and images. + +```kotlin +@Composable +fun MyComposable() { + K9MailTheme2 { + // Your app content + } +} +``` + diff --git a/core/ui/compose/theme2/k9mail/build.gradle.kts b/core/ui/compose/theme2/k9mail/build.gradle.kts new file mode 100644 index 0000000..a815547 --- /dev/null +++ b/core/ui/compose/theme2/k9mail/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.core.ui.compose.theme2.k9mail" + resourcePrefix = "core_ui_theme2_k9mail" +} + +dependencies { + api(projects.core.ui.compose.theme2.common) +} diff --git a/core/ui/compose/theme2/k9mail/src/main/kotlin/app/k9mail/core/ui/compose/theme2/k9mail/K9MailTheme2.kt b/core/ui/compose/theme2/k9mail/src/main/kotlin/app/k9mail/core/ui/compose/theme2/k9mail/K9MailTheme2.kt new file mode 100644 index 0000000..f9c8d6b --- /dev/null +++ b/core/ui/compose/theme2/k9mail/src/main/kotlin/app/k9mail/core/ui/compose/theme2/k9mail/K9MailTheme2.kt @@ -0,0 +1,48 @@ +package app.k9mail.core.ui.compose.theme2.k9mail + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.core.ui.compose.theme2.ThemeColorSchemeVariants +import app.k9mail.core.ui.compose.theme2.ThemeConfig +import app.k9mail.core.ui.compose.theme2.ThemeImageVariants +import app.k9mail.core.ui.compose.theme2.ThemeImages +import app.k9mail.core.ui.compose.theme2.default.defaultThemeElevations +import app.k9mail.core.ui.compose.theme2.default.defaultThemeShapes +import app.k9mail.core.ui.compose.theme2.default.defaultThemeSizes +import app.k9mail.core.ui.compose.theme2.default.defaultThemeSpacings +import app.k9mail.core.ui.compose.theme2.default.defaultTypography + +@Composable +fun K9MailTheme2( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val images = ThemeImages( + logo = R.drawable.core_ui_theme2_k9mail_logo, + ) + + val themeConfig = ThemeConfig( + colors = ThemeColorSchemeVariants( + dark = darkThemeColorScheme, + light = lightThemeColorScheme, + ), + elevations = defaultThemeElevations, + images = ThemeImageVariants( + light = images, + dark = images, + ), + sizes = defaultThemeSizes, + spacings = defaultThemeSpacings, + shapes = defaultThemeShapes, + typography = defaultTypography, + ) + + MainTheme( + themeConfig = themeConfig, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + content = content, + ) +} diff --git a/core/ui/compose/theme2/k9mail/src/main/kotlin/app/k9mail/core/ui/compose/theme2/k9mail/ThemeColors.kt b/core/ui/compose/theme2/k9mail/src/main/kotlin/app/k9mail/core/ui/compose/theme2/k9mail/ThemeColors.kt new file mode 100644 index 0000000..1d985a8 --- /dev/null +++ b/core/ui/compose/theme2/k9mail/src/main/kotlin/app/k9mail/core/ui/compose/theme2/k9mail/ThemeColors.kt @@ -0,0 +1,120 @@ +package app.k9mail.core.ui.compose.theme2.k9mail + +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.theme2.ThemeColorScheme + +internal val lightThemeColorScheme = ThemeColorScheme( + primary = Color(color = 0xFF5F303D), + onPrimary = Color(color = 0xFFFFFFFF), + primaryContainer = Color(color = 0xFF875360), + onPrimaryContainer = Color(color = 0xFFFFFFFF), + + secondary = Color(color = 0xFF422129), + onSecondary = Color(color = 0xFFFFFFFF), + secondaryContainer = Color(color = 0xFF68414B), + onSecondaryContainer = Color(color = 0xFFFFE2E7), + + tertiary = Color(color = 0xFF443968), + onTertiary = Color(color = 0xFFFFFFFF), + tertiaryContainer = Color(color = 0xFF685C8E), + onTertiaryContainer = Color(color = 0xFFFFFFFF), + + error = Color(color = 0xFF7F1D1D), + onError = Color(color = 0xFFFFFFFF), + errorContainer = Color(color = 0xFFFEF2F2), + onErrorContainer = Color(color = 0xFF7F1D1D), + + surfaceDim = Color(color = 0xFFDCD9D9), + surface = Color(color = 0xFFFCF8F8), + surfaceBright = Color(color = 0xFFFCF8F8), + onSurface = Color(color = 0xFF1C1B1B), + onSurfaceVariant = Color(color = 0xFF45474A), + + surfaceContainerLowest = Color(color = 0xFFFFFFFF), + surfaceContainerLow = Color(color = 0xFFF6F3F2), + surfaceContainer = Color(color = 0xFFF1EDEC), + surfaceContainerHigh = Color(color = 0xFFEBE7E7), + surfaceContainerHighest = Color(color = 0xFFE5E2E1), + + inverseSurface = Color(color = 0xFF313030), + inverseOnSurface = Color(color = 0xFFF3F0EF), + inversePrimary = Color(color = 0xFFF7B5C4), + + outline = Color(color = 0xFF75777A), + outlineVariant = Color(color = 0xFFC5C6CA), + + scrim = Color.Black, + + info = Color(color = 0xFF004F9B), + onInfo = Color(color = 0xFFFFFFFF), + infoContainer = Color(color = 0xFFF0F8FF), + onInfoContainer = Color(color = 0xFF004F9B), + + success = Color(color = 0xFF194E2C), + onSuccess = Color(color = 0xFFFFFFFF), + successContainer = Color(color = 0xFFF4F9F4), + onSuccessContainer = Color(color = 0xFF194E2C), + + warning = Color(color = 0xFF713F12), + onWarning = Color(color = 0xFFFEFAE8), + warningContainer = Color(color = 0xFFFEFAE8), + onWarningContainer = Color(color = 0xFF713F12), +) + +internal val darkThemeColorScheme = ThemeColorScheme( + primary = Color(color = 0xFFF1E7FF), + onPrimary = Color(color = 0xFF37265D), + primaryContainer = Color(color = 0xFFCBB7F9), + onPrimaryContainer = Color(color = 0xFF39285F), + + secondary = Color(color = 0xFFF1E7FF), + onSecondary = Color(color = 0xFF332D41), + secondaryContainer = Color(color = 0xFFC7BDD7), + onSecondaryContainer = Color(color = 0xFF352F43), + + tertiary = Color(color = 0xFFFFDBE5), + onTertiary = Color(color = 0xFF472732), + tertiaryContainer = Color(color = 0xFFDDAEBC), + onTertiaryContainer = Color(color = 0xFF43242F), + + error = Color(color = 0xFFFCA5A5), + onError = Color(color = 0xFF450A0A), + errorContainer = Color(color = 0xFF7F1D1D), + onErrorContainer = Color(color = 0xFFFEF2F2), + + surfaceDim = Color(color = 0xFF131314), + surface = Color(color = 0xFF131314), + surfaceBright = Color(color = 0xFF39393A), + onSurface = Color(color = 0xFFE5E2E3), + onSurfaceVariant = Color(color = 0xFFC5C6CC), + + surfaceContainerLowest = Color(color = 0xFF0E0E0F), + surfaceContainerLow = Color(color = 0xFF1B1B1C), + surfaceContainer = Color(color = 0xFF201F20), + surfaceContainerHigh = Color(color = 0xFF2A2A2B), + surfaceContainerHighest = Color(color = 0xFF353436), + + inverseSurface = Color(color = 0xFFE5E2E3), + inverseOnSurface = Color(color = 0xFF313031), + inversePrimary = Color(color = 0xFF66558F), + + outline = Color(color = 0xFF8F9096), + outlineVariant = Color(color = 0xFF44474C), + + scrim = Color.Black, + + info = Color(color = 0xFFBEE6FF), + onInfo = Color(color = 0xFF002E41), + infoContainer = Color(color = 0xFF262C40), + onInfoContainer = Color(color = 0xFFBEE6FF), + + success = Color(color = 0xFF8EE7AA), + onSuccess = Color(color = 0xFF082B16), + successContainer = Color(color = 0xFF082B16), + onSuccessContainer = Color(color = 0xFF8EE7AA), + + warning = Color(color = 0xFFFEE78A), + onWarning = Color(color = 0xFF411107), + warningContainer = Color(color = 0xFF423606), + onWarningContainer = Color(color = 0xFFFEE78A), +) diff --git a/core/ui/compose/theme2/k9mail/src/main/res/drawable/core_ui_theme2_k9mail_logo.xml b/core/ui/compose/theme2/k9mail/src/main/res/drawable/core_ui_theme2_k9mail_logo.xml new file mode 100644 index 0000000..4da267d --- /dev/null +++ b/core/ui/compose/theme2/k9mail/src/main/res/drawable/core_ui_theme2_k9mail_logo.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/compose/theme2/thunderbird/build.gradle.kts b/core/ui/compose/theme2/thunderbird/build.gradle.kts new file mode 100644 index 0000000..92011d7 --- /dev/null +++ b/core/ui/compose/theme2/thunderbird/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.core.ui.compose.theme2.thunderbird" + resourcePrefix = "core_ui_theme2_thunderbird" +} + +dependencies { + api(projects.core.ui.compose.theme2.common) +} diff --git a/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt b/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt new file mode 100644 index 0000000..c86552d --- /dev/null +++ b/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThemeColors.kt @@ -0,0 +1,120 @@ +package app.k9mail.core.ui.compose.theme2.thunderbird + +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.theme2.ThemeColorScheme + +internal val lightThemeColorScheme = ThemeColorScheme( + primary = Color(color = 0xFF004F9B), + onPrimary = Color(color = 0xFFFFFFFF), + primaryContainer = Color(color = 0xFF1373D9), + onPrimaryContainer = Color(color = 0xFFFFFFFF), + + secondary = Color(color = 0xFF003D75), + onSecondary = Color(color = 0xFFFFFFFF), + secondaryContainer = Color(color = 0xFF2E61A0), + onSecondaryContainer = Color(color = 0xFFFFFFFF), + + tertiary = Color(color = 0xFF54008E), + onTertiary = Color(color = 0xFFFFFFFF), + tertiaryContainer = Color(color = 0xFF7B35B8), + onTertiaryContainer = Color(color = 0xFFFFFFFF), + + error = Color(color = 0xFF7F1D1D), + onError = Color(color = 0xFFFFFFFF), + errorContainer = Color(color = 0xFFFEF2F2), + onErrorContainer = Color(color = 0xFF7F1D1D), + + surfaceDim = Color(color = 0xFFDCD9D9), + surface = Color(color = 0xFFFCF8F8), + surfaceBright = Color(color = 0xFFFCF8F8), + onSurface = Color(color = 0xFF1C1B1B), + onSurfaceVariant = Color(color = 0xFF45474A), + + surfaceContainerLowest = Color(color = 0xFFFFFFFF), + surfaceContainerLow = Color(color = 0xFFF6F3F2), + surfaceContainer = Color(color = 0xFFF1EDEC), + surfaceContainerHigh = Color(color = 0xFFEBE7E7), + surfaceContainerHighest = Color(color = 0xFFE5E2E1), + + inverseSurface = Color(color = 0xFF313030), + inverseOnSurface = Color(color = 0xFFF3F0EF), + inversePrimary = Color(color = 0xFFA9C7FF), + + outline = Color(color = 0xFF75777A), + outlineVariant = Color(color = 0xFFC5C6CA), + + scrim = Color.Black, + + info = Color(color = 0xFF004F9B), + onInfo = Color(color = 0xFFFFFFFF), + infoContainer = Color(color = 0xFFF0F8FF), + onInfoContainer = Color(color = 0xFF004F9B), + + success = Color(color = 0xFF194E2C), + onSuccess = Color(color = 0xFFFFFFFF), + successContainer = Color(color = 0xFFF4F9F4), + onSuccessContainer = Color(color = 0xFF194E2C), + + warning = Color(color = 0xFF713F12), + onWarning = Color(color = 0xFFFEFAE8), + warningContainer = Color(color = 0xFFFEFAE8), + onWarningContainer = Color(color = 0xFF713F12), +) + +internal val darkThemeColorScheme = ThemeColorScheme( + primary = Color(color = 0xFFBEE6FF), + onPrimary = Color(color = 0xFF003549), + primaryContainer = Color(color = 0xFF50C2F8), + onPrimaryContainer = Color(color = 0xFF002E41), + + secondary = Color(color = 0xFF96CDFF), + onSecondary = Color(color = 0xFF003352), + secondaryContainer = Color(color = 0xFF24A7F7), + onSecondaryContainer = Color(color = 0xFF001423), + + tertiary = Color(color = 0xFFFFFFFF), + onTertiary = Color(color = 0xFF352D3E), + tertiaryContainer = Color(color = 0xFFDCD0E6), + onTertiaryContainer = Color(color = 0xFF443C4E), + + error = Color(color = 0xFFFCA5A5), + onError = Color(color = 0xFF450A0A), + errorContainer = Color(color = 0xFF7F1D1D), + onErrorContainer = Color(color = 0xFFFEF2F2), + + surfaceDim = Color(color = 0xFF131314), + surface = Color(color = 0xFF131314), + surfaceBright = Color(color = 0xFF39393A), + onSurface = Color(color = 0xFFE5E2E3), + onSurfaceVariant = Color(color = 0xFFC5C6CC), + + surfaceContainerLowest = Color(color = 0xFF0E0E0F), + surfaceContainerLow = Color(color = 0xFF1B1B1C), + surfaceContainer = Color(color = 0xFF201F20), + surfaceContainerHigh = Color(color = 0xFF2A2A2B), + surfaceContainerHighest = Color(color = 0xFF353436), + + inverseSurface = Color(color = 0xFFE5E2E3), + inverseOnSurface = Color(color = 0xFF313031), + inversePrimary = Color(color = 0xFF006689), + + outline = Color(color = 0xFF8F9096), + outlineVariant = Color(color = 0xFF44474C), + + scrim = Color.Black, + + info = Color(color = 0xFFBEE6FF), + onInfo = Color(color = 0xFF002E41), + infoContainer = Color(color = 0xFF262C40), + onInfoContainer = Color(color = 0xFFBEE6FF), + + success = Color(color = 0xFF8EE7AA), + onSuccess = Color(color = 0xFF082B16), + successContainer = Color(color = 0xFF082B16), + onSuccessContainer = Color(color = 0xFF8EE7AA), + + warning = Color(color = 0xFFFEE78A), + onWarning = Color(color = 0xFF411107), + warningContainer = Color(color = 0xFF423606), + onWarningContainer = Color(color = 0xFFFEE78A), +) diff --git a/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThunderbirdTheme2.kt b/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThunderbirdTheme2.kt new file mode 100644 index 0000000..d09dc38 --- /dev/null +++ b/core/ui/compose/theme2/thunderbird/src/main/kotlin/app/k9mail/core/ui/compose/theme2/thunderbird/ThunderbirdTheme2.kt @@ -0,0 +1,48 @@ +package app.k9mail.core.ui.compose.theme2.thunderbird + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.core.ui.compose.theme2.ThemeColorSchemeVariants +import app.k9mail.core.ui.compose.theme2.ThemeConfig +import app.k9mail.core.ui.compose.theme2.ThemeImageVariants +import app.k9mail.core.ui.compose.theme2.ThemeImages +import app.k9mail.core.ui.compose.theme2.default.defaultThemeElevations +import app.k9mail.core.ui.compose.theme2.default.defaultThemeShapes +import app.k9mail.core.ui.compose.theme2.default.defaultThemeSizes +import app.k9mail.core.ui.compose.theme2.default.defaultThemeSpacings +import app.k9mail.core.ui.compose.theme2.default.defaultTypography + +@Composable +fun ThunderbirdTheme2( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val images = ThemeImages( + logo = R.drawable.core_ui_theme2_thunderbird_logo, + ) + + val themeConfig = ThemeConfig( + colors = ThemeColorSchemeVariants( + dark = darkThemeColorScheme, + light = lightThemeColorScheme, + ), + elevations = defaultThemeElevations, + images = ThemeImageVariants( + light = images, + dark = images, + ), + sizes = defaultThemeSizes, + spacings = defaultThemeSpacings, + shapes = defaultThemeShapes, + typography = defaultTypography, + ) + + MainTheme( + themeConfig = themeConfig, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + content = content, + ) +} diff --git a/core/ui/compose/theme2/thunderbird/src/main/res/drawable/core_ui_theme2_thunderbird_logo.xml b/core/ui/compose/theme2/thunderbird/src/main/res/drawable/core_ui_theme2_thunderbird_logo.xml new file mode 100644 index 0000000..734bd5a --- /dev/null +++ b/core/ui/compose/theme2/thunderbird/src/main/res/drawable/core_ui_theme2_thunderbird_logo.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/legacy/README.md b/core/ui/legacy/README.md new file mode 100644 index 0000000..7946afc --- /dev/null +++ b/core/ui/legacy/README.md @@ -0,0 +1,14 @@ +## Core - UI - Legacy + +The modules in this section are dedicated to the legacy UI implementation based on XML-based layouts for Android. + +> [!WARNING] +> It's not suggested to use the contained modules for new features! +> +> This is only maintained for the purpose of supporting the existing implementation. + +--- + +> [!IMPORTANT] +> Use the Composable UI along our [theme 2](../compose/theme2) and [design system](../compose/designsystem) design system instead. + diff --git a/core/ui/legacy/designsystem/README.md b/core/ui/legacy/designsystem/README.md new file mode 100644 index 0000000..aa2c56b --- /dev/null +++ b/core/ui/legacy/designsystem/README.md @@ -0,0 +1,14 @@ +## Core - UI - Legacy - Design System + +This is the design system dedicated to the legacy UI implementation based on XML-based layouts for Android. + +> [!WARNING] +> It's not suggested to use this design system for new features! +> +> This is only maintained for the purpose of supporting the existing implementation. + +--- + +> [!IMPORTANT] +> Use the Composable UI along our [theme 2](../compose/theme2) and [design system](../compose/designsystem) design system instead. + diff --git a/core/ui/legacy/designsystem/build.gradle.kts b/core/ui/legacy/designsystem/build.gradle.kts new file mode 100644 index 0000000..af0c7dd --- /dev/null +++ b/core/ui/legacy/designsystem/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "app.k9mail.core.ui.legacy.designsystem" +} + +dependencies { + api(projects.core.ui.legacy.theme2.common) +} diff --git a/core/ui/legacy/designsystem/src/main/kotlin/app/k9mail/core/ui/legacy/designsystem/atom/icon/Icons.kt b/core/ui/legacy/designsystem/src/main/kotlin/app/k9mail/core/ui/legacy/designsystem/atom/icon/Icons.kt new file mode 100644 index 0000000..f08b2d8 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/kotlin/app/k9mail/core/ui/legacy/designsystem/atom/icon/Icons.kt @@ -0,0 +1,98 @@ +package app.k9mail.core.ui.legacy.designsystem.atom.icon + +import app.k9mail.core.ui.legacy.designsystem.R + +/** + * Icons used in the legacy design system. + * + * For Material 3 we use mainly Outlined icons. Filled icons are used for special cases. + * + * Each object contains the icons as drawableRes. + */ +object Icons { + object Filled { + val Star = R.drawable.ic_star_filled + } + + object Outlined { + @JvmField + val AccountCircle = R.drawable.ic_account_circle + val Add = R.drawable.ic_add + val AddCircle = R.drawable.ic_add_circle + val Adjust = R.drawable.ic_adjust + val Archive = R.drawable.ic_archive + val ArrowBack = R.drawable.ic_arrow_back + val Attachment = R.drawable.ic_attachment + val Block = R.drawable.ic_block + val Bolt = R.drawable.ic_bolt + val BugReport = R.drawable.ic_bug_report + val Check = R.drawable.ic_check + val CheckCircle = R.drawable.ic_check_circle + val ChevronRight = R.drawable.ic_chevron_right + val Close = R.drawable.ic_close + val Code = R.drawable.ic_code + val CompareArrows = R.drawable.ic_compare_arrows + val ContentCopy = R.drawable.ic_content_copy + val Delete = R.drawable.ic_delete + val Description = R.drawable.ic_description + val DoNotDisturbOn = R.drawable.ic_do_not_disturb_on + val Download = R.drawable.ic_download + val Draft = R.drawable.ic_draft + val DragHandle = R.drawable.ic_drag_handle + val DriveFileMove = R.drawable.ic_drive_file_move + val Edit = R.drawable.ic_edit + val Error = R.drawable.ic_error + val ExpandLess = R.drawable.ic_expand_less + val ExpandMore = R.drawable.ic_expand_more + val Favorite = R.drawable.ic_favorite + val FilterList = R.drawable.ic_filter_list + val Folder = R.drawable.ic_folder + val Forum = R.drawable.ic_forum + val Forward = R.drawable.ic_forward + val Group = R.drawable.ic_group + val Healing = R.drawable.ic_healing + val Help = R.drawable.ic_help + + @JvmField + val Image = R.drawable.ic_image + val Inbox = R.drawable.ic_inbox + val Info = R.drawable.ic_info + val Key = R.drawable.ic_key + val Link = R.drawable.ic_link + val Lock = R.drawable.ic_lock + val Login = R.drawable.ic_login + val Mail = R.drawable.ic_mail + val MarkEmailRead = R.drawable.ic_mark_email_read + val MarkEmailUnread = R.drawable.ic_mark_email_unread + val Menu = R.drawable.ic_menu + val Monitor = R.drawable.ic_monitor + val MoreVert = R.drawable.ic_more_vert + val NoEncryption = R.drawable.ic_no_encryption + val Notifications = R.drawable.ic_notifications + val Outbox = R.drawable.ic_outbox + val Person = R.drawable.ic_person + val PersonAdd = R.drawable.ic_person_add + val Refresh = R.drawable.ic_refresh + + @JvmField + val Reply = R.drawable.ic_reply + + @JvmField + val ReplyAll = R.drawable.ic_reply_all + val Report = R.drawable.ic_report + val Save = R.drawable.ic_save + val Search = R.drawable.ic_search + val Security = R.drawable.ic_security + val SelectAll = R.drawable.ic_select_all + val Send = R.drawable.ic_send + val Settings = R.drawable.ic_settings + val Sort = R.drawable.ic_sort + val Star = R.drawable.ic_star + val SwapVert = R.drawable.ic_swap_vert + val Sync = R.drawable.ic_sync + val TouchApp = R.drawable.ic_touch_app + val Upload = R.drawable.ic_upload + val Visibility = R.drawable.ic_visibility + val Warning = R.drawable.ic_warning + } +} diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_account_circle.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_account_circle.xml new file mode 100644 index 0000000..7cadf6c --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_account_circle.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_add.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..679d493 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_add.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_add_circle.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_add_circle.xml new file mode 100644 index 0000000..27b401a --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_add_circle.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_adjust.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_adjust.xml new file mode 100644 index 0000000..7ad33e4 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_adjust.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_analytics.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_analytics.xml new file mode 100644 index 0000000..b1ecf6e --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_analytics.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_archive.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_archive.xml new file mode 100644 index 0000000..cff1ed1 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_archive.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_arrow_back.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..1987d0c --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_attachment.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_attachment.xml new file mode 100644 index 0000000..c2d3c74 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_attachment.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_block.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_block.xml new file mode 100644 index 0000000..3158772 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_block.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_bolt.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_bolt.xml new file mode 100644 index 0000000..8ad1be7 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_bolt.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_bug_report.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_bug_report.xml new file mode 100644 index 0000000..565dfe1 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_bug_report.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_check.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..9c45e9b --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_check.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_check_circle.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..d7d7115 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_chevron_right.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..816c409 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_close.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..7818c59 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_close.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_code.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..8203807 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_code.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_compare_arrows.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_compare_arrows.xml new file mode 100644 index 0000000..e7311c3 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_compare_arrows.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_content_copy.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 0000000..05e6bfd --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_delete.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..9102cf1 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_description.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_description.xml new file mode 100644 index 0000000..9c23108 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_description.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_do_not_disturb_on.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_do_not_disturb_on.xml new file mode 100644 index 0000000..7e67663 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_do_not_disturb_on.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_download.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..6e551a9 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_download.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_draft.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_draft.xml new file mode 100644 index 0000000..a855aff --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_draft.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_drag_handle.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 0000000..0597f48 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_drive_file_move.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_drive_file_move.xml new file mode 100644 index 0000000..4371522 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_drive_file_move.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_edit.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..775b4cd --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_error.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_error.xml new file mode 100644 index 0000000..5d89be5 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_error.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_expand_less.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 0000000..663dffc --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_expand_more.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000..ca2def2 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_favorite.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_favorite.xml new file mode 100644 index 0000000..58d5c6f --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_favorite.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_filter_list.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_filter_list.xml new file mode 100644 index 0000000..ea40e0d --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_filter_list.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_folder.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000..dcd7a53 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_forum.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_forum.xml new file mode 100644 index 0000000..e4388f1 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_forum.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_forward.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_forward.xml new file mode 100644 index 0000000..176a05d --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_forward.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_group.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_group.xml new file mode 100644 index 0000000..b412c36 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_group.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_healing.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_healing.xml new file mode 100644 index 0000000..c5062e5 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_healing.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_help.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_help.xml new file mode 100644 index 0000000..f3b11b5 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_help.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_image.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000..8ee3a22 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_image.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_inbox.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_inbox.xml new file mode 100644 index 0000000..4775355 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_inbox.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_info.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..0be5ded --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_info.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_key.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..f41b069 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_key.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_link.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_link.xml new file mode 100644 index 0000000..e4ef4f2 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_link.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_lock.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..be27952 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_login.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000..b8da497 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_login.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_mail.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_mail.xml new file mode 100644 index 0000000..11e2265 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_mail.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_mark_email_read.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_mark_email_read.xml new file mode 100644 index 0000000..96fb7e3 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_mark_email_read.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_mark_email_unread.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_mark_email_unread.xml new file mode 100644 index 0000000..39bced6 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_mark_email_unread.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_menu.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..e1703ba --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_monitor.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_monitor.xml new file mode 100644 index 0000000..fe3212c --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_monitor.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_more_vert.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..209665a --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_no_encryption.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_no_encryption.xml new file mode 100644 index 0000000..f146674 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_no_encryption.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_notifications.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_notifications.xml new file mode 100644 index 0000000..5b0d7c0 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_notifications.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_outbox.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_outbox.xml new file mode 100644 index 0000000..9935c58 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_outbox.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_person.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..16126e5 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_person.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_person_add.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_person_add.xml new file mode 100644 index 0000000..f590740 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_person_add.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_refresh.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..5a0f23a --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_reply.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000..ace9799 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_reply_all.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_reply_all.xml new file mode 100644 index 0000000..f22f915 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_reply_all.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_report.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_report.xml new file mode 100644 index 0000000..4bb9c95 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_report.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_save.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..2a1717d --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_save.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_search.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..3b69c6e --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_search.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_security.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_security.xml new file mode 100644 index 0000000..e5cc986 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_security.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_select_all.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_select_all.xml new file mode 100644 index 0000000..6e4606c --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_select_all.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_send.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..40e5317 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_send.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_settings.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..66954d3 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_sort.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000..22d5d6b --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,14 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_star.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_star.xml new file mode 100644 index 0000000..5deb82a --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_star.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_star_filled.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_star_filled.xml new file mode 100644 index 0000000..8711680 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_star_filled.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_swap_vert.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_swap_vert.xml new file mode 100644 index 0000000..30988b5 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_swap_vert.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_sync.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..2acfe5c --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_touch_app.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_touch_app.xml new file mode 100644 index 0000000..2a8c5c6 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_touch_app.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_upload.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_upload.xml new file mode 100644 index 0000000..a066fb5 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_upload.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_visibility.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 0000000..89de529 --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/drawable/ic_warning.xml b/core/ui/legacy/designsystem/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..faf88ab --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/ui/legacy/designsystem/src/main/res/values/attrs.xml b/core/ui/legacy/designsystem/src/main/res/values/attrs.xml new file mode 100644 index 0000000..e27792d --- /dev/null +++ b/core/ui/legacy/designsystem/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/core/ui/legacy/theme2/README.md b/core/ui/legacy/theme2/README.md new file mode 100644 index 0000000..19bbcc3 --- /dev/null +++ b/core/ui/legacy/theme2/README.md @@ -0,0 +1,12 @@ +## Core - UI - Legacy - Theme 2 + +Legacy Theme 2 represents a Material 3 adaptation of the legacy application theme. It follows the design principles of Material 3, while ensuring compatibility with the existing implementation. + +It is available in two variants: + +- [K9Mail](./k9mail) - The theme for the K-9 Mail app. +- [Thunderbird](./thunderbird) - The theme for the Thunderbird app. + +It's not suggested to use the contained modules for new features. Use the Composable UI along our [theme 2](../../compose/theme2) and [design system](../../compose/designsystem) instead. + +This is only maintained for the purpose of supporting the existing implementation. diff --git a/core/ui/legacy/theme2/common/build.gradle.kts b/core/ui/legacy/theme2/common/build.gradle.kts new file mode 100644 index 0000000..48c44b2 --- /dev/null +++ b/core/ui/legacy/theme2/common/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "app.k9mail.core.ui.legacy.theme2.common" +} + +dependencies { + api(libs.android.material) +} diff --git a/core/ui/legacy/theme2/common/src/main/res/values-night/themes.xml b/core/ui/legacy/theme2/common/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6d57ee4 --- /dev/null +++ b/core/ui/legacy/theme2/common/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + + + + + diff --git a/core/ui/legacy/theme2/common/src/main/res/values-v27/themes.xml b/core/ui/legacy/theme2/common/src/main/res/values-v27/themes.xml new file mode 100644 index 0000000..7420b6a --- /dev/null +++ b/core/ui/legacy/theme2/common/src/main/res/values-v27/themes.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/core/ui/legacy/theme2/common/src/main/res/values-v28/themes.xml b/core/ui/legacy/theme2/common/src/main/res/values-v28/themes.xml new file mode 100644 index 0000000..61145b3 --- /dev/null +++ b/core/ui/legacy/theme2/common/src/main/res/values-v28/themes.xml @@ -0,0 +1,4 @@ + + + + + + + + + + + + + + + diff --git a/core/ui/legacy/theme2/thunderbird/build.gradle.kts b/core/ui/legacy/theme2/thunderbird/build.gradle.kts new file mode 100644 index 0000000..fb9828e --- /dev/null +++ b/core/ui/legacy/theme2/thunderbird/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "app.k9mail.core.ui.legacy.theme2.thunderbird" +} + +dependencies { + implementation(projects.core.ui.legacy.theme2.common) +} diff --git a/core/ui/legacy/theme2/thunderbird/src/main/res/drawable/ic_app_logo.xml b/core/ui/legacy/theme2/thunderbird/src/main/res/drawable/ic_app_logo.xml new file mode 100644 index 0000000..25b474e --- /dev/null +++ b/core/ui/legacy/theme2/thunderbird/src/main/res/drawable/ic_app_logo.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/legacy/theme2/thunderbird/src/main/res/drawable/ic_app_logo_monochrome.xml b/core/ui/legacy/theme2/thunderbird/src/main/res/drawable/ic_app_logo_monochrome.xml new file mode 100644 index 0000000..b794468 --- /dev/null +++ b/core/ui/legacy/theme2/thunderbird/src/main/res/drawable/ic_app_logo_monochrome.xml @@ -0,0 +1,17 @@ + + + + diff --git a/core/ui/legacy/theme2/thunderbird/src/main/res/values/app_logo_colors.xml b/core/ui/legacy/theme2/thunderbird/src/main/res/values/app_logo_colors.xml new file mode 100644 index 0000000..250aa60 --- /dev/null +++ b/core/ui/legacy/theme2/thunderbird/src/main/res/values/app_logo_colors.xml @@ -0,0 +1,4 @@ + + + #F0F8FF + diff --git a/core/ui/legacy/theme2/thunderbird/src/main/res/values/colors.xml b/core/ui/legacy/theme2/thunderbird/src/main/res/values/colors.xml new file mode 100644 index 0000000..5220929 --- /dev/null +++ b/core/ui/legacy/theme2/thunderbird/src/main/res/values/colors.xml @@ -0,0 +1,113 @@ + + + + #004F9B + #FFFFFF + #1373D9 + #FFFFFF + + #003D75 + #FFFFFF + #2E61A0 + #FFFFFF + + #54008E + #FFFFFF + #7B35B8 + #FFFFFF + + #A0000E + #FFFFFF + #DC2626 + #FFFFFF + + #DCD9D9 + #FCF8F8 + #FCF8F8 + #1C1B1B + #45474A + + #FFFFFF + #F6F3F2 + #F1EDEC + #EBE7E7 + #E5E2E1 + + #313030 + #F3F0EF + #A9C7FF + + #75777A + #C5C6CA + + #D6E3FF + #001B3D + #A9C7FF + #00468B + + #D5E3FF + #001C3B + #A6C8FF + #054785 + + #F1DBFF + #2D0050 + #DFB7FF + #2D0050 + + + #BEE6FF + #003549 + #50C2F8 + #002E41 + + #96CDFF + #003352 + #24A7F7 + #001423 + + #FFFFFF + #352D3E + #DCD0E6 + #443C4E + + #FFB4AB + #690005 + #93000A + #FFDAD6 + + #131314 + #131314 + #39393A + #E5E2E3 + #C5C6CC + + #0E0E0F + #1B1B1C + #201F20 + #2A2A2B + #353436 + + #E5E2E3 + #313031 + #006689 + + #8F9096 + #44474C + + #C3E8FF + #001E2C + #79D1FF + #004C68 + + #CDE5FF + #001D32 + #94CCFF + #004B74 + + #EBDEF5 + #1F1928 + #CEC2D8 + #4C4355 + + diff --git a/core/ui/legacy/theme2/thunderbird/src/main/res/values/themes.xml b/core/ui/legacy/theme2/thunderbird/src/main/res/values/themes.xml new file mode 100644 index 0000000..74e401b --- /dev/null +++ b/core/ui/legacy/theme2/thunderbird/src/main/res/values/themes.xml @@ -0,0 +1,136 @@ + + + + + + + + diff --git a/core/ui/theme/README.md b/core/ui/theme/README.md new file mode 100644 index 0000000..9e4e205 --- /dev/null +++ b/core/ui/theme/README.md @@ -0,0 +1,3 @@ +## Theme + +The app theme is provided by the `ThemeProvider` found in the `api` module and should be implemented in the app modules with the required app themes from the `core:compose` and `core:legacy` modules. diff --git a/core/ui/theme/api/build.gradle.kts b/core/ui/theme/api/build.gradle.kts new file mode 100644 index 0000000..8e3e387 --- /dev/null +++ b/core/ui/theme/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id(ThunderbirdPlugins.Library.kmpCompose) +} + +android { + namespace = "net.thunderbird.core.ui.theme.api" +} diff --git a/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/FeatureThemeProvider.kt b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/FeatureThemeProvider.kt new file mode 100644 index 0000000..82eee7e --- /dev/null +++ b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/FeatureThemeProvider.kt @@ -0,0 +1,19 @@ +package net.thunderbird.core.ui.theme.api + +import androidx.compose.runtime.Composable + +/** + * Provides the compose theme for a feature. + */ +interface FeatureThemeProvider { + @Composable + fun WithTheme( + content: @Composable () -> Unit, + ) + + @Composable + fun WithTheme( + darkTheme: Boolean, + content: @Composable () -> Unit, + ) +} diff --git a/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/Theme.kt b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/Theme.kt new file mode 100644 index 0000000..b5c8f17 --- /dev/null +++ b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/Theme.kt @@ -0,0 +1,6 @@ +package net.thunderbird.core.ui.theme.api + +enum class Theme { + LIGHT, + DARK, +} diff --git a/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/ThemeManager.kt b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/ThemeManager.kt new file mode 100644 index 0000000..6673c7f --- /dev/null +++ b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/ThemeManager.kt @@ -0,0 +1,25 @@ +package net.thunderbird.core.ui.theme.api + +import androidx.annotation.StyleRes + +interface ThemeManager { + + val appTheme: Theme + val messageViewTheme: Theme + val messageComposeTheme: Theme + + @get:StyleRes + val appThemeResourceId: Int + + @get:StyleRes + val messageViewThemeResourceId: Int + + @get:StyleRes + val messageComposeThemeResourceId: Int + + @get:StyleRes + val dialogThemeResourceId: Int + + @get:StyleRes + val translucentDialogThemeResourceId: Int +} diff --git a/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/ThemeProvider.kt b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/ThemeProvider.kt new file mode 100644 index 0000000..6208b27 --- /dev/null +++ b/core/ui/theme/api/src/commonMain/kotlin/net/thunderbird/core/ui/theme/api/ThemeProvider.kt @@ -0,0 +1,20 @@ +package net.thunderbird.core.ui.theme.api + +import androidx.annotation.StyleRes + +interface ThemeProvider { + @get:StyleRes + val appThemeResourceId: Int + + @get:StyleRes + val appLightThemeResourceId: Int + + @get:StyleRes + val appDarkThemeResourceId: Int + + @get:StyleRes + val dialogThemeResourceId: Int + + @get:StyleRes + val translucentDialogThemeResourceId: Int +} diff --git a/core/ui/theme/manager/build.gradle.kts b/core/ui/theme/manager/build.gradle.kts new file mode 100644 index 0000000..9b17c9e --- /dev/null +++ b/core/ui/theme/manager/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.core.ui.theme.manager" +} + +dependencies { + api(projects.core.ui.theme.api) + + implementation(projects.core.ui.legacy.designsystem) + + implementation(projects.core.preference.api) +} diff --git a/core/ui/theme/manager/src/main/java/net/thunderbird/core/ui/theme/manager/ThemeManager.kt b/core/ui/theme/manager/src/main/java/net/thunderbird/core/ui/theme/manager/ThemeManager.kt new file mode 100644 index 0000000..8bc7ab0 --- /dev/null +++ b/core/ui/theme/manager/src/main/java/net/thunderbird/core/ui/theme/manager/ThemeManager.kt @@ -0,0 +1,125 @@ +package net.thunderbird.core.ui.theme.manager + +import android.content.Context +import android.content.res.Configuration +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatDelegate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus +import net.thunderbird.core.preference.AppTheme +import net.thunderbird.core.preference.GeneralSettings +import net.thunderbird.core.preference.GeneralSettingsManager +import net.thunderbird.core.preference.SubTheme +import net.thunderbird.core.preference.update +import net.thunderbird.core.ui.theme.api.Theme +import net.thunderbird.core.ui.theme.api.ThemeManager +import net.thunderbird.core.ui.theme.api.ThemeProvider + +class ThemeManager( + private val context: Context, + private val themeProvider: ThemeProvider, + private val generalSettingsManager: GeneralSettingsManager, + private val appCoroutineScope: CoroutineScope, +) : ThemeManager { + + private val generalSettings: GeneralSettings + get() = generalSettingsManager.getConfig() + + override val appTheme: Theme + get() = when (generalSettings.display.coreSettings.appTheme) { + AppTheme.LIGHT -> Theme.LIGHT + AppTheme.DARK -> Theme.DARK + AppTheme.FOLLOW_SYSTEM -> getSystemTheme() + } + + override val messageViewTheme: Theme + get() = resolveTheme(generalSettings.display.coreSettings.messageViewTheme) + + override val messageComposeTheme: Theme + get() = resolveTheme(generalSettings.display.coreSettings.messageComposeTheme) + + @get:StyleRes + override val appThemeResourceId: Int = themeProvider.appThemeResourceId + + @get:StyleRes + override val messageViewThemeResourceId: Int + get() = getSubThemeResourceId(generalSettings.display.coreSettings.messageViewTheme) + + @get:StyleRes + override val messageComposeThemeResourceId: Int + get() = getSubThemeResourceId(generalSettings.display.coreSettings.messageComposeTheme) + + @get:StyleRes + override val dialogThemeResourceId: Int = themeProvider.dialogThemeResourceId + + @get:StyleRes + override val translucentDialogThemeResourceId: Int = themeProvider.translucentDialogThemeResourceId + + fun init() { + generalSettingsManager.getSettingsFlow() + .map { it.display.coreSettings.appTheme } + .distinctUntilChanged() + .onEach { + updateAppTheme(it) + } + .launchIn(appCoroutineScope + Dispatchers.Main.immediate) + } + + private fun updateAppTheme(appTheme: AppTheme) { + val defaultNightMode = when (appTheme) { + AppTheme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + AppTheme.DARK -> AppCompatDelegate.MODE_NIGHT_YES + AppTheme.FOLLOW_SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + AppCompatDelegate.setDefaultNightMode(defaultNightMode) + } + + fun toggleMessageViewTheme() { + if (messageViewTheme === Theme.DARK) { + generalSettingsManager.update { settings -> + settings.copy( + display = settings.display.copy( + coreSettings = settings.display.coreSettings.copy( + messageViewTheme = SubTheme.LIGHT, + ), + ), + ) + } + } else { + generalSettingsManager.update { settings -> + settings.copy( + display = settings.display.copy( + coreSettings = settings.display.coreSettings.copy( + messageViewTheme = SubTheme.DARK, + ), + ), + ) + } + } + } + + private fun getSubThemeResourceId(subTheme: SubTheme): Int = when (subTheme) { + SubTheme.LIGHT -> themeProvider.appLightThemeResourceId + SubTheme.DARK -> themeProvider.appDarkThemeResourceId + SubTheme.USE_GLOBAL -> themeProvider.appThemeResourceId + } + + private fun resolveTheme(theme: SubTheme): Theme = when (theme) { + SubTheme.LIGHT -> Theme.LIGHT + SubTheme.DARK -> Theme.DARK + SubTheme.USE_GLOBAL -> appTheme + } + + private fun getSystemTheme(): Theme { + return when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> Theme.LIGHT + Configuration.UI_MODE_NIGHT_YES -> Theme.DARK + else -> Theme.LIGHT + } + } +} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..7585238 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +book diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..ef76beb --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to Thunderbird for Android + +Welcome to the Thunderbird for Android project! We're excited to have you here and welcome your contributions. + +## Getting Started + +Before you start contributing, please take a moment to familiarize yourself with the following: + +- **Mozilla Community Participation Guidelines:** [https://www.mozilla.org/en-US/about/governance/policies/participation/](https://www.mozilla.org/en-US/about/governance/policies/participation/) +- **Frequently Asked Questions:** [https://forum.k9mail.app/c/faq](https://forum.k9mail.app/c/faq) +- **Support Forum:** [https://forum.k9mail.app/](https://forum.k9mail.app/) + +## Bug Reports and Feature Requests + +If you encounter a bug or have a feature request, please follow these steps: + +- Search the [existing issues](https://github.com/thunderbird/thunderbird-android/issues?q=is%3Aissue) to see if your issue or feature has already been reported. +- If you can't find an existing issue, please [open a new issue](https://github.com/thunderbird/thunderbird-android/issues/new/choose) on GitHub. + +## Translations + +If you'd like to help to translate K-9 Mail / Thunderbird for Android, please visit the [Weblate - K-9 Mail/Thunderbird project](https://hosted.weblate.org/projects/tb-android/). + +## Contributing Code + +Thank you for your willingness to contribute code! Here's how you can get started: + +**1. Find an issue:** + +- Check the issue tracker for [open issues](https://github.com/thunderbird/thunderbird-android/issues?q=is%3Aissue+is%3Aopen+-label%3Aunconfirmed+-label%3Atb-team). +- Look for issues labeled [good first issue](https://github.com/thunderbird/thunderbird-android/labels/good%20first%20issue) for a good starting point. +- Propose a new feature by [opening a new issue](https://github.com/thunderbird/thunderbird-android/issues/new/choose) +- Avoid issues labeled [unconfirmed](https://github.com/thunderbird/thunderbird-android/labels/unconfirmed) or [tb-team](https://github.com/thunderbird/thunderbird-android/labels/tb-team) as they are not yet ready for contributions. + +**2. Discuss your plan:** + +- Leave a comment on the issue you want to work on, explaining what you plan to do. This helps avoid duplicate work and gets you feedback from the team. + +**3. Fork the repository:** + +- Create your own [fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) of the Thunderbird for Android repository on GitHub. + +**4. Create a branch:** + +- Start a new branch from the `main` branch to keep your changes separate. +- Name your branch descriptively (e.g., `fix-issue-123` or `add-feature-xyz`). + +**5. Make your changes:** + +- Write your code and commit it to your branch. +- Follow our [Code Style Guidelines](https://github.com/thunderbird/thunderbird-android/wiki/CodeStyle) + +**6. Test your changes:** + +- Run the project's tests to make sure everything works and that your changes don't introduce any regressions. +- If applicable, write new tests to cover your changes. + +**7. Push your changes:** + +- Upload your branch to your forked repository. + +**8. Open a pull request:** + +- Create a pull request to merge your changes into the main project. +- Provide a clear and concise description of your changes, including: + - A reference to the issue you're addressing. + - A summary of the changes you made. + - Any relevant screenshots or testing results. + +## Thank You! + +Thank you for taking the time to contribute to Thunderbird for Android! We appreciate your help in making the project better and more useful for everyone. diff --git a/docs/HOW-TO-DOCUMENT.md b/docs/HOW-TO-DOCUMENT.md new file mode 100644 index 0000000..05ad0ea --- /dev/null +++ b/docs/HOW-TO-DOCUMENT.md @@ -0,0 +1,118 @@ +# How to Document + +This guide provides detailed instructions for contributing to and maintaining the documentation for the Thunderbird for Android project. It explains the tools used, the structure of the documentation, and guidelines for creating and editing content. + +We use [mdbook](https://rust-lang.github.io/mdBook/) to generate the documentation. The source files for the documentation are located in the `docs/` directory. + +## Contributing + +If you'd like to contribute to this project, please familiarize yourself with our [Contribution Guide](CONTRIBUTING.md). + +To add or modify the documentation, please edit the markdown files located in the `docs/` directory using standard Markdown syntax, including [GitHub flavored Markdown](https://github.github.com/gfm/). You can use `headers`, `lists`, `links`, `code blocks`, and other Markdown features to structure your content. + +For creating diagrams, we use the [mermaid](https://mermaid-js.github.io/mermaid/#/) syntax. To include mermaid diagrams in your Markdown files, use the following syntax: + +````markdown +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; +``` +```` + +Result: + +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; +``` + +### Adding a New Page + +To add a new page, create a markdown file in the `docs/` directory or within a suitable subfolder. For example: + +- To create a new top-level page: `docs/NEW_PAGE.md`. +- To create a page within a subfolder: `docs/subfolder/new-subpage.md`. + +To include the new page in the table of contents, add a link to the `SUMMARY.md` file pointing to newly created page. + +For consistency with GitHub conventions and other mandatory files, markdown files in the top level +`docs/` directory shall be written in uppercase, as well the README.md file within subfolders. +Further markdown files in subdirectories shall use a lowercase filename. + +### Organizing with Subfolders + +Subfolders in the `docs/` folder can be used to organize related documentation. This can be useful if related topics should be grouped together. For example, we have a subfolder named `architecture/` for all documentation related to our application's architecture. + +### Linking New Pages in the Summary + +The `SUMMARY.md` file serves as the table of contents (TOC) for the documentation. To include the new page in the TOC, a link needs to be added in the `SUMMARY.md` file, like so: + +```markdown +- [Page Title](relative/path/to/file.md) +``` + +Indentation is used to create hierarchy in the TOC: + +```markdown +- [Page Title](relative/path/to/file.md) + - [Subpage Title](relative/path/to/subfolder/file.md) +``` + +### Assets + +If you need to embed images, put them in the assets folder closest to the file they are being used +in. This can either be the top-level assets folder, or a (potentially new) assets subfolder in the +respective section. + +## Documentation Toolchain + +The documentation is built using mdbook and several extensions. Follow these steps to set up the required tools. + +### Install mdbook and extensions + +Ensure you have [Cargo](https://doc.rust-lang.org/cargo/) installed, then run: + +```shell +./docs/install.sh +``` + +This script installs `mdbook` and the required extensions and other dependencies. + +Use --force to update the dependencies, recommended when mdbook was updated: + +```shell +./docs/install.sh --force +``` + +### Extensions + +We use the following mdbook extensions: + +- [mdbook-external-links](https://github.com/jonahgoldwastaken/mdbook-external-links) for opening external links in a new tab. +- [mdbook-last-changed](https://github.com/badboy/mdbook-last-changed) for last change date inclusion. +- [mdbook-mermaid](https://github.com/badboy/mdbook-mermaid) for diagram generation. +- [mdbook-pagetoc](https://github.com/slowsage/mdbook-pagetoc) for automatic page table of contents. + +## Building the Documentation + +Once you have `mdbook` and its extensions installed, you can build the documentation by running this command: + +```shell +mdbook build docs +``` + +The generated documentation will be available in the `book/docs/latest/` folder. + +To preview the documentation, run the following command: + +```shell +mdbook serve docs --open +``` + +The `mdbook serve docs` command will serve the book at [http://localhost:3000](http://localhost:3000) and rebuild the documentation on changes. The `--open` option will open the book in your web browser and is optional. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f743103 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Thunderbird for Android Documentation + +The latest available documentation is rendered at: [https://thunderbird.github.io/thunderbird-android/docs/latest/](https://thunderbird.github.io/thunderbird-android/docs/latest/) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..231419f --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,41 @@ +# Summary + +This file is not intended for direct reading by users, but rather serves as a configuration file for the documentation +generator, in this case, **mdbook**. It defines the structure and navigation of the documentation. + +--- + +- [Contributing](CONTRIBUTING.md) + - [Git Commit Guide](contributing/git-commit-guide.md) + - [Testing Guide](contributing/testing-guide.md) + - [Java to Kotlin Conversion Guide](contributing/java-to-kotlin-conversion-guide.md) +- [Architecture](architecture/README.md) + - [Module Organization](architecture/module-organization.md) + - [Module Structure](architecture/module-structure.md) + - [Feature Modules](architecture/feature-modules.md) + - [UI Architecture](architecture/ui-architecture.md) + - [Theme System](architecture/theme-system.md) + - [Design System](architecture/design-system.md) + - [User Flows](architecture/user-flows.md) + - [Legacy Module Integration](architecture/legacy-module-integration.md) + - [Architecture Decision Records](architecture/adr/README.md) + - [Accepted]() + - [0001 - Switch From Java to Kotlin](architecture/adr/0001-switch-from-java-to-kotlin.md) + - [0002 - UI - Wrap Material Components in Atomic Design System](architecture/adr/0002-ui-wrap-material-components-in-atomic-design-system.md) + - [0003 - Test - Switch Test Assertions From Truth to Assertk](architecture/adr/0003-switch-test-assertions-from-truth-to-assertk.md) + - [0004 - Naming Conventions for Interfaces and Their Implementations](architecture/adr/0004-naming-conventions-for-interfaces-and-their-implementations.md) + - [0005 - Central Project Configuration](architecture/adr/0005-central-project-configuration.md) + - [0006 - White Label Architecture](architecture/adr/0006-white-label-architecture.md) + - [0007 - Project Structure](architecture/adr/0007-project-structure.md) + - [0008 - Change Shared Module package to `net.thunderbird`](architecture/adr/0008-change-shared-modules-package-name.md) + - [Proposed]() + - [Rejected]() +- [Release](ci/README.md) + - [Release Process](ci/RELEASE.md) + - [Release Automation](ci/AUTOMATION.md) + - [Manual Release (historical)](ci/HISTORICAL_RELEASE.md) +- [Translations](translations.md) + +--- + +[How to Document](HOW-TO-DOCUMENT.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..b130b25 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,657 @@ +# 🏗️ Architecture + +The application follows a modular architecture with clear separation between different layers and components. The architecture is designed to support both the Thunderbird for Android and K-9 Mail applications while maximizing code reuse, maintainability and enable adoption of Kotlin Multiplatform in the future. + +## 🔑 Key Architectural Principles + +- **🚀 Multi platform Compatibility**: The architecture is designed to support future Kotlin Multiplatform adoption +- **📱 Offline-First**: The application is designed to work offline with local data storage and synchronization with remote servers +- **🧩 Modularity**: The application is divided into distinct modules with clear responsibilities +- **🔀 Separation of Concerns**: Each module focuses on a specific aspect of the application +- **⬇️ Dependency Inversion**: Higher-level modules do not depend on lower-level modules directly +- **🎯 Single Responsibility**: Each component has a single responsibility +- **🔄 API/Implementation Separation**: Clear separation between public APIs and implementation details +- **🧹 Clean Architecture**: Separation of UI, domain, and data layers +- **🧪 Testability**: The architecture facilitates comprehensive testing at all levels + +## 📝 Architecture Decision Records + +The [Architecture Decision Records](adr/README.md) document the architectural decisions made during the development of the +project, providing context and rationale for key technical choices. Reading through these decisions will improve your +contributions and ensure long-term maintainability of the project. + +## 📦 Module Structure + +The application is organized into several module types: + +- **📱 App Modules**: `app-thunderbird` and `app-k9mail` - Application entry points +- **🔄 App Common**: `app-common` - Shared code between applications +- **✨ Feature Modules**: `feature:*` - Independent feature modules +- **🧰 Core Modules**: `core:*` - Foundational components and utilities used across multiple features +- **📚 Library Modules**: `library:*` - Specific implementations for reuse +- **🔙 Legacy Modules**: Legacy code being gradually migrated + +For more details on the module organization and structure, see the [Module Organization](module-organization.md) and +[Module Structure](module-structure.md) documents. + +## 🧩 Architectural Patterns + +The architecture follows several key patterns to ensure maintainability, testability, and separation of concerns: + +### 🔄 API/Implementation Separation + +Each module should be split into two main parts: **API** and **implementation**. This separation provides clear +boundaries between what a module exposes to other modules and how it implements its functionality internally: + +- **📝 API**: Public interfaces, models, and contracts +- **⚙️ Implementation**: Concrete implementations of the interfaces + +This separation provides clear boundaries, improves testability, and enables flexibility. + +See [API Module](module-structure.md#-api-module) and +[Implementation Module](module-structure.md#-implementation-module) for more details. + +### Clean Architecture + +Thunderbird for Android uses **Clean Architecture** with three main layers (UI, domain, and data) to break down complex +feature implementation into manageable components. Each layer has a specific responsibility: + +```mermaid +graph TD + subgraph UI[UI Layer] + UI_COMPONENTS[UI Components] + VIEW_MODEL[ViewModels] + end + + subgraph DOMAIN["Domain Layer"] + USE_CASE[Use Cases] + REPO[Repositories] + end + + subgraph DATA[Data Layer] + DATA_SOURCE[Data Sources] + API[API Clients] + DB[Local Database] + end + + UI_COMPONENTS --> VIEW_MODEL + VIEW_MODEL --> USE_CASE + USE_CASE --> REPO + REPO --> DATA_SOURCE + DATA_SOURCE --> API + DATA_SOURCE --> DB + + classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000 + classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000 + classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 + classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 + classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 + classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class UI ui_layer + class UI_COMPONENTS,VIEW_MODEL ui_class + class DOMAIN domain_layer + class USE_CASE,REPO domain_class + class DATA data_layer + class DATA_SOURCE,API,DB data_class +``` + +#### 🖼️ UI Layer (Presentation) + +The UI layer is responsible for displaying data to the user and handling user interactions. + +**Key Components:** +- **🎨 [Compose UI](ui-architecture.md#-screens)**: Screen components built with Jetpack Compose +- **🧠 [ViewModels](ui-architecture.md#-viewmodel)**: Manage UI state and handle UI events +- **📊 [UI State](ui-architecture.md#-state)**: Immutable data classes representing the UI state +- **🎮 [Events](ui-architecture.md#-events)**: User interactions or system events that trigger state changes +- **🔔 [Effects](ui-architecture.md#effects)**: One-time side effects like navigation or showing messages + +**Pattern: Model-View-Intent (MVI)** + +- **📋 Model**: UI state representing the current state of the screen +- **👁️ View**: Compose UI that renders the state +- **🎮 Event**: User interactions that trigger state changes (equivalent to "Intent" in standard MVI) +- **🔔 Effect**: One-time side effects like navigation or notifications + +#### 🧠 Domain Layer (Business Logic) + +The domain layer contains the business logic and rules of the application. It is independent of the UI and data layers, +allowing for easy testing and reuse. + +**Key Components:** +- **⚙️ Use Cases**: Encapsulate business logic operations +- **📋 Domain Models**: Represent business entities +- **📝 Repository Interfaces**: Define data access contracts + +```mermaid +graph TB + subgraph DOMAIN[Domain Layer] + USE_CASE[Use Cases] + MODEL[Domain Models] + REPO_API[Repository Interfaces] + end + + subgraph DATA[Data Layer] + REPO_IMPL[Repository Implementations] + end + + USE_CASE --> |uses| REPO_API + USE_CASE --> |uses| MODEL + REPO_API --> |uses| MODEL + REPO_IMPL --> |implements| REPO_API + REPO_IMPL --> |uses| MODEL + + classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 + classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 + classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 + classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class DOMAIN domain_layer + class USE_CASE,REPO_API,MODEL domain_class + class DATA data_layer + class REPO_IMPL data_class +``` + +#### 💾 Data Layer + +The data layer is responsible for data retrieval, storage, and synchronization. + +**Key Components:** +- **📦 Repository implementations**: Implement repository interfaces from the domain layer +- **🔌 Data Sources**: Provide data from specific sources (API, database, preferences) +- **📄 Data Transfer Objects**: Represent data at the data layer + +**Pattern: Data Source Pattern** +- 🔍 Abstracts data sources behind a clean API +- Maps data between domain models and data transfer objects + +```mermaid +graph TD + subgraph DOMAIN[Domain Layer] + REPO_API[Repository] + end + + subgraph DATA[Data Layer] + REPO_IMPL[Repository implementations] + RDS[Remote Data Sources] + LDS[Local Data Sources] + MAPPER[Data Mappers] + DTO[Data Transfer Objects] + end + + REPO_IMPL --> |implements| REPO_API + REPO_IMPL --> RDS + REPO_IMPL --> LDS + REPO_IMPL --> MAPPER + RDS --> MAPPER + LDS --> MAPPER + MAPPER --> DTO + + classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 + classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 + classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 + classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class DOMAIN domain_layer + class REPO_API domain_class + class DATA data_layer + class REPO_IMPL,RDS,LDS,MAPPER,DTO data_class +``` + +### 🔄 Immutability + +Immutability means that once an object is created, it cannot be changed. Instead of modifying existing objects, new objects are created with the desired changes. In the context of UI state, this means that each state object represents a complete snapshot of the UI at a specific point in time. + +**Why is Immutability Important?** + +Immutability provides several benefits: +- **Predictability**: With immutable state, the UI can only change when a new state object is provided, making the flow of data more predictable and easier to reason about. +- **Debugging**: Each state change creates a new state object, making it easier to track changes and debug issues by comparing state objects. +- **Concurrency**: Immutable objects are thread-safe by nature, eliminating many concurrency issues. +- **Performance**: While creating new objects might seem inefficient, modern frameworks optimize this process, and the benefits of immutability often outweigh the costs. +- **Time-travel debugging**: Immutability enables storing previous states, allowing developers to "time travel" back to previous application states during debugging. + +## 🎨 UI Architecture + +The UI is built using Jetpack Compose with a component-based architecture following our modified Model-View-Intent (MVI) pattern. This architecture provides a unidirectional data flow, clear separation of concerns, and improved testability. + +For detailed information about the UI architecture and theming, see the [UI Architecture](ui-architecture.md) and +[Theme System](theme-system.md) documents. + +## 📱 Offline-First Approach + +The application implements an offline-first Approach to provide a reliable user experience regardless of network conditions: + +- 💾 Local database as the single source of truth +- 🔄 Background synchronization with remote servers +- 📋 Operation queueing for network operations +- 🔀 Conflict resolution for data modified both locally and remotely + +#### Implementation Approach + +```mermaid +graph LR + subgraph UI[UI Layer] + VIEW_MODEL[ViewModel] + end + + subgraph DOMAIN[Domain Layer] + USE_CASE[Use Cases] + end + + subgraph DATA[Data Layer] + subgraph SYNC[Synchronization] + SYNC_MANAGER[Sync Manager] + SYNC_QUEUE[Sync Queue] + end + REPO[Repository] + LOCAL[Local Data Source] + REMOTE[Remote Data Source] + end + + VIEW_MODEL --> USE_CASE + USE_CASE --> REPO + SYNC_MANAGER --> LOCAL + SYNC_MANAGER --> REMOTE + SYNC_MANAGER --> SYNC_QUEUE + REPO --> LOCAL + REPO --> REMOTE + REPO --> SYNC_MANAGER + REPO ~~~ SYNC + + classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000 + classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000 + classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 + classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 + classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 + classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 + classDef sync_layer fill:#e6cce6,stroke:#000000,color:#000000 + classDef sync_class fill:#cc99cc,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class UI ui_layer + class VIEW_MODEL ui_class + class DOMAIN domain_layer + class USE_CASE domain_class + class DATA data_layer + class REPO,LOCAL,REMOTE data_class + class SYNC sync_layer + class SYNC_MANAGER,SYNC_API,SYNC_QUEUE sync_class +``` + +The offline-first approach is implemented across all layers of the application: + +1. **💾 Data Layer**: + - 📊 Local database as the primary data source + - 🌐 Remote data source for server communication + - 📦 Repository pattern to coordinate between data sources + - 🔄 Synchronization manager to handle data syncing +2. **🧠 Domain Layer**: + - ⚙️ Use cases handle both online and offline scenarios + - 📝 Business logic accounts for potential network unavailability + - 📋 Domain models represent data regardless of connectivity state +3. **🖼️ UI Layer**: + - 🧠 ViewModels expose UI state that reflects connectivity status + - 🚦 UI components display appropriate indicators for offline mode + - 👆 User interactions are designed to work regardless of connectivity + +## 💉 Dependency Injection + +The application uses [Koin](https://insert-koin.io/) for dependency injection, with modules organized by feature: + +- **📱 App Modules**: Configure application-wide dependencies +- **🔄 App Common**: Shared dependencies between applications +- **✨ Feature Modules**: Configure feature-specific dependencies +- **🧰 Core Modules**: Configure core dependencies + +```kotlin +// Example Koin module for a feature +val featureModule = module { + viewModel { FeatureViewModel(get()) } + single { FeatureRepositoryImpl(get(), get()) } + single { FeatureUseCaseImpl(get()) } + single { FeatureApiClientImpl() } +} +``` + +## 🔄 Cross-Cutting Concerns + +Cross-cutting concerns are aspects of the application that affect multiple features and cannot be cleanly handled +individually for every feature. These concerns require consistent implementation throughout the codebase to ensure +maintainability an reliability. + +In Thunderbird for Android, several cross-cutting concerns are implemented as dedicated core modules to provide +standardized solutions that can be reused across the application: + +- **⚠️ Error Handling**: Comprehensive error handling (`core/outcome`) transforms exceptions into domain-specific errors and provides user-friendly feedback. +- **📋 Logging**: Centralized logging system (`core/logging`) ensures consistent log formatting, levels, and storage. +- **🔒 Security**: Modules like `core/security` handle encryption, authentication, and secure data storage. + +Work in progress: +- **🔐 Encryption**: The `core/crypto` module provides encryption and decryption utilities for secure data handling. +- **📦 Feature Flags**: The `core/feature-flags` module manages feature toggles and experimental features. +- **🔄 Synchronization**: The `core/sync` module manages background synchronization, conflict resolution, and offline-first behavior. +- **🛠️ Configuration Management**: Centralized handling of application settings and environment-specific configurations. + +By implementing these concerns as core modules, the application achieves a clean and modular architecture that is easier to maintain and extend. + +### ⚠️ Error Handling + +The application implements a comprehensive error handling strategy across all layers. We favor using the Outcome pattern +over exceptions for expected error conditions, while exceptions are reserved for truly exceptional situations that +indicate programming errors or unrecoverable system failures. + +- 🧠 **Domain Errors**: Encapsulate business logic errors as sealed classes, ensuring clear representation of specific + error cases. +- 💾 **Data Errors**: Transform network or database exceptions into domain-specific errors using result patterns in repository implementations. +- 🖼️ **UI Error Handling**: Provide user-friendly error feedback by: + - Mapping domain errors to UI state in ViewModels. + - Displaying actionable error states in Compose UI components. + - Offering retry options for network connectivity issues. + +> [!NOTE] +> Exceptions should be used sparingly. Favor the Outcome pattern and sealed classes for predictable error conditions to +> enhance maintainability and clarity. + +#### 🛠️ How to Implement Error Handling + +When implementing error handling in your code: + +1. **Define domain-specific errors** as sealed classes in your feature's domain layer: + + ```kotlin + sealed class AccountError { + data class AuthenticationFailed(val reason: String) : AccountError() + data class NetworkError(val exception: Exception) : AccountError() + data class ValidationError(val field: String, val message: String) : AccountError() + } + ``` +2. **Use result patterns (Outcome)** instead of exceptions for error handling: + + ```kotlin + // Use the Outcome class for representing success or failure + sealed class Outcome { + data class Success(val value: T) : Outcome() + data class Failure(val error: E) : Outcome() + } + ``` +3. **Transform external errors** into domain errors in your repositories using result patterns: + + ```kotlin + // Return Outcome instead of throwing exceptions + fun authenticate(credentials: Credentials): Outcome { + return try { + val result = apiClient.authenticate(credentials) + Outcome.Success(result) + } catch (e: HttpException) { + val error = when (e.code()) { + 401 -> AccountError.AuthenticationFailed("Invalid credentials") + else -> AccountError.NetworkError(e) + } + logger.error(e) { "Authentication failed: ${error::class.simpleName}" } + Outcome.Failure(error) + } catch (e: Exception) { + logger.error(e) { "Authentication failed with unexpected error" } + Outcome.Failure(AccountError.NetworkError(e)) + } + } + ``` +4. **Handle errors in Use Cases** by propagating the Outcome: + + ```kotlin + class LoginUseCase( + private val accountRepository: AccountRepository, + private val credentialValidator: CredentialValidator, + ) { + fun execute(credentials: Credentials): Outcome { + // Validate input first + val validationResult = credentialValidator.validate(credentials) + if (validationResult is ValidationResult.Failure) { + return Outcome.Failure( + AccountError.ValidationError( + field = validationResult.field, + message = validationResult.message + ) + ) + } + + // Proceed with authentication + return accountRepository.authenticate(credentials) + } + } + ``` +5. **Handle outcomes in ViewModels** and transform them into UI state: + + ```kotlin + viewModelScope.launch { + val outcome = loginUseCase.execute(credentials) + + when (outcome) { + is Outcome.Success -> { + _uiState.update { it.copy(isLoggedIn = true) } + } + is Outcome.Failure -> { + val errorMessage = when (val error = outcome.error) { + is AccountError.AuthenticationFailed -> + stringProvider.getString(R.string.error_authentication_failed, error.reason) + is AccountError.NetworkError -> + stringProvider.getString(R.string.error_network, error.exception.message) + is AccountError.ValidationError -> + stringProvider.getString(R.string.error_validation, error.field, error.message) + } + _uiState.update { it.copy(error = errorMessage) } + } + } + } + ``` +6. **Always log errors** for debugging purposes: + + ```kotlin + // Logging is integrated into the Outcome pattern + fun fetchMessages(): Outcome, MessageError> { + return try { + val messages = messageService.fetchMessages() + logger.info { "Successfully fetched ${messages.size} messages" } + Outcome.Success(messages) + } catch (e: Exception) { + logger.error(e) { "Failed to fetch messages" } + Outcome.Failure(MessageError.FetchFailed(e)) + } + } + ``` +7. **Compose multiple operations** that return Outcomes: + + ```kotlin + fun synchronizeAccount(): Outcome { + // First operation + val messagesOutcome = fetchMessages() + if (messagesOutcome is Outcome.Failure) { + return Outcome.Failure(SyncError.MessageSyncFailed(messagesOutcome.error)) + } + + // Second operation using the result of the first + val messages = messagesOutcome.getOrNull()!! + val folderOutcome = updateFolders(messages) + if (folderOutcome is Outcome.Failure) { + return Outcome.Failure(SyncError.FolderUpdateFailed(folderOutcome.error)) + } + + // Return success with combined results + return Outcome.Success( + SyncResult( + messageCount = messages.size, + folderCount = folderOutcome.getOrNull()!!.size + ) + ) + } + ``` + +### 📝 Logging + +The application uses a structured logging system with a well-defined API: + +- 📊 **Logging Architecture**: + - Core logging API (`core/logging/api`) defines interfaces like `Logger` and `LogSink` + - Multiple implementations (composite, console) allow for flexible logging targets + - Composite implementation enables logging to multiple sinks simultaneously +- 🔄 **Logger vs. Sink**: + - **Logger**: The front-facing interface that application code interacts with to create log entries + - Provides methods for different log levels (verbose, debug, info, warn, error) + - Handles the creation of log events with appropriate metadata (timestamp, tag, etc.) + - Example: `DefaultLogger` implements the `Logger` interface and delegates to a `LogSink` + - **LogSink**: The back-end component that receives log events and determines how to process them + - Defines where and how log messages are actually stored or displayed + - Filters log events based on configured log levels + - Can be implemented in various ways (console output, file storage, remote logging service) + - Multiple sinks can be used simultaneously via composite pattern +- 📋 **Log Levels**: + - `VERBOSE`: Most detailed log level for debugging + - `DEBUG`: Detailed information for diagnosing problems + - `INFO`: General information about application flow + - `WARN`: Potential issues that don't affect functionality + - `ERROR`: Issues that affect functionality but don't crash the application + +#### 🛠️ How to Implement Logging + +When adding logging to your code: + +1. **Inject a Logger** into your class: + + ```kotlin + class AccountRepository( + private val apiClient: ApiClient, + private val logger: Logger, + ) { + // Repository implementation + } + ``` +2. **Choose the appropriate log level** based on the importance of the information: + - Use `verbose` for detailed debugging information (only visible in debug builds) + - Use `debug` for general debugging information + - Use `info` for important events that should be visible in production + - Use `warn` for potential issues that don't affect functionality + - Use `error` for issues that affect functionality +3. **Use lambda syntax** to avoid string concatenation when logging isn't needed: + + ```kotlin + // Good - string is only created if this log level is enabled + logger.debug { "Processing message with ID: $messageId" } + + // Avoid - string is always created even if debug logging is disabled + logger.debug("Processing message with ID: " + messageId) + ``` +4. **Include relevant context** in log messages: + + ```kotlin + logger.info { "Syncing account: ${account.email}, folders: ${folders.size}" } + ``` +5. **Log exceptions** with the appropriate level and context: + + ```kotlin + try { + apiClient.fetchMessages() + } catch (e: Exception) { + logger.error(e) { "Failed to fetch messages for account: ${account.email}" } + throw MessageSyncError.FetchFailed(e) + } + ``` +6. **Use tags** for better filtering when needed: + + ```kotlin + private val logTag = LogTag("AccountSync") + + fun syncAccount() { + logger.info(logTag) { "Starting account sync for: ${account.email}" } + } + ``` + +### 🔒 Security + +Security is a critical aspect of an email client. The application implements: + +- 🔐 **Data Encryption**: + - End-to-end encryption using OpenPGP (via the `legacy/crypto-openpgp` module) + - Classes like `EncryptionDetector` and `OpenPgpEncryptionExtractor` handle encrypted emails + - Local storage encryption for sensitive data like account credentials +- 🔑 **Authentication**: + - Support for various authentication types (OAuth, password, client certificate) + - Secure token storage and management + - Authentication error handling and recovery +- 🛡️ **Network Security**: + - TLS for all network connections with certificate validation + - Certificate pinning for critical connections + - Protection against MITM attacks + +> [!NOTE] +> This section is a work in progress. The security architecture is being developed and will be documented in detail +> as it evolves. + +#### 🛠️ How to Implement Security + +When implementing security features in your code: + +1. **Never store sensitive data in plain text**: + + ```kotlin + // Bad - storing password in plain text + sharedPreferences.putString("password", password) + + // Good - use the secure credential storage + val credentialStorage = get() + credentialStorage.storeCredentials(accountUuid, credentials) + ``` +2. **Use encryption for sensitive data**: + + ```kotlin + // For data that needs to be stored encrypted + val encryptionManager = get() + val encryptedData = encryptionManager.encrypt(sensitiveData) + database.storeEncryptedData(encryptedData) + ``` +3. **Validate user input** to prevent injection attacks: + + ```kotlin + // Validate input before using it + if (!InputValidator.isValidEmailAddress(userInput)) { + throw ValidationError("Invalid email address") + } + ``` +4. **Use secure network connections**: + + ```kotlin + // The networking modules enforce TLS by default + // Make sure to use the provided clients rather than creating your own + val networkClient = get() + ``` + +## 🧪 Testing Strategy + +The architecture supports comprehensive testing: + +- **🔬 Unit Tests**: Test individual components in isolation +- **🔌 Integration Tests**: Test interactions between components +- **📱 UI Tests**: Test the UI behavior and user flows + +See the [Testing guide](../contributing/testing-guide.md) document for more details on how to write and run tests +for the application. + +## 🔙 Legacy Integration + +The application includes legacy code that is gradually being migrated to the new architecture: +- **📦 Legacy Modules**: Contain code from the original K-9 Mail application +- **🔄 Migration Strategy**: Gradual migration to the new architecture +- **🔌 Integration Points**: Clear interfaces between legacy and new code + +For more details on the legacy integration, see the [Legacy Integration](legacy-module-integration.md) document. + +## 🔄 User Flows + +The [User Flows](user-flows.md) provides visual representations of typical user flows through the application, helping to understand how different components interact. diff --git a/docs/architecture/adr/0000-adr-template.md b/docs/architecture/adr/0000-adr-template.md new file mode 100644 index 0000000..a6faf44 --- /dev/null +++ b/docs/architecture/adr/0000-adr-template.md @@ -0,0 +1,36 @@ +# Descriptive Title in Title Case + +- Issue: [#NNNN](https://github.com/thunderbird/thunderbird-android/issues/NNNN) +- Pull Request: [#NNNN](https://github.com/thunderbird/thunderbird-android/pull/NNNN) + + + +## Status + + +- **Status** + +## Context + + + +## Decision + + + +## Consequences + + + +### Positive Consequences + +- consequence 1 +- consequence 2 + +### Negative Consequences + +- consequence 1 +- consequence 2 + diff --git a/docs/architecture/adr/0001-switch-from-java-to-kotlin.md b/docs/architecture/adr/0001-switch-from-java-to-kotlin.md new file mode 100644 index 0000000..5252c92 --- /dev/null +++ b/docs/architecture/adr/0001-switch-from-java-to-kotlin.md @@ -0,0 +1,32 @@ +# Switch from Java to Kotlin + +- Pull Request: [#7221](https://github.com/thunderbird/thunderbird-android/pull/7221) + +## Status + +- **Accepted** + +## Context + +We've been using Java as our primary language for Android development. While Java has served us well, it has certain +limitations in terms of null safety, verbosity, functional programming, and more. Kotlin, officially supported by +Google for Android development, offers solutions to many of these issues and provides more modern language features +that can improve productivity, maintainability, and overall code quality. + +## Decision + +Switch our primary programming language for Android development from Java to Kotlin. This will involve rewriting our +existing Java codebase in Kotlin and writing all new code in Kotlin. To facilitate the transition, we will gradually +refactor our existing Java codebase to Kotlin. + +## Consequences + +- **Positive Consequences** + - Improved null safety, reducing potential for null pointer exceptions. + - Increased code readability and maintainability due to less verbose syntax. + - Availability of modern language features such as coroutines for asynchronous programming, and extension functions. + - Officially supported by Google for Android development, ensuring future-proof development. +- **Negative Consequences** + - The process of refactoring existing Java code to Kotlin can be time-consuming. + - Potential for introduction of new bugs during refactoring. + diff --git a/docs/architecture/adr/0002-ui-wrap-material-components-in-atomic-design-system.md b/docs/architecture/adr/0002-ui-wrap-material-components-in-atomic-design-system.md new file mode 100644 index 0000000..f4aa9ed --- /dev/null +++ b/docs/architecture/adr/0002-ui-wrap-material-components-in-atomic-design-system.md @@ -0,0 +1,35 @@ +# UI - Wrap Material Components in Atomic Design System + +- Pull Request: [#7221](https://github.com/thunderbird/thunderbird-android/pull/7221) + +## Status + +- **Accepted** + +## Context + +As we continued developing our Jetpack Compose application, we found a need to increase the consistency, reusability, +and maintainability of our user interface (UI) components. We have been using Material components directly throughout our +application. This lead to a lack of uniformity and increases the complexity of changes as the same modifications had to +be implemented multiple times across different screens. + +## Decision + +To address these challenges, we've decided to adopt an +[Atomic Design System](../design-system.md) as a foundation for our application UI. +This system encapsulates Material components within our [own components](../../../core/ui/compose/designsystem/), +organized into categories of _atoms_, _molecules_, and _organisms_. We also defined _templates_ as layout structures +that can be flexibly combined to construct _pages_. These components collectively form the building blocks that we are +using to construct our application's UI. + +## Consequences + +- **Positive Consequences** + - Increased reusability of components across the application, reducing code duplication. + - More consistent UI and uniform styling across the entire application. + - Improved maintainability, as changes to a component only need to be made in one place. +- **Negative Consequences** + - Initial effort and time investment needed to implement the atomic design system. + - Developers need to adapt to the new system and learn how to use it effectively. + - Potential for over-complication if simple components are excessively broken down into atomic parts. + diff --git a/docs/architecture/adr/0003-switch-test-assertions-from-truth-to-assertk.md b/docs/architecture/adr/0003-switch-test-assertions-from-truth-to-assertk.md new file mode 100644 index 0000000..3a1f9e6 --- /dev/null +++ b/docs/architecture/adr/0003-switch-test-assertions-from-truth-to-assertk.md @@ -0,0 +1,37 @@ +# Switch Test Assertions from Truth to assertk + +- Pull Request: [#7242](https://github.com/thunderbird/thunderbird-android/pull/7242) + +## Status + +- **Accepted** + +## Context + +Our project has been using the Truth testing library for writing tests. While Truth has served us well, it is primarily +designed for Java and lacks some features that make our Kotlin tests more idiomatic and expressive. As our codebase is +[primarily Kotlin](0001-switch-from-java-to-kotlin.md), we have been looking for a testing library that is more aligned +with Kotlin's features and idioms. + +## Decision + +We have decided to use [assertk](https://github.com/willowtreeapps/assertk) as the default assertions framework for +writing tests in our project. assertk provides a fluent API that is very similar to Truth, making the transition easier. +Moreover, it is designed to work well with Kotlin, enabling us to leverage Kotlin-specific features in our tests. + +We've further committed to converting all pre-existing tests from Truth to assertk. + +## Consequences + +**Note**: The migration of all Truth tests to assertk has already been completed. + +- **Positive Consequences** + - **Ease of Transition**: The syntax of assertk is very similar to Truth, which makes the migration process smoother. + - **Kotlin-Friendly**: assertk is designed specifically for Kotlin, allowing us to write more idiomatic and + expressive Kotlin tests. +- **Negative Consequences** + - **Dependency**: While we are replacing one library with another, introducing a new library always carries the risk + of bugs or future deprecation. + - **Migration Effort**: Existing tests written using Truth will need to be migrated to use assertk, requiring some + effort, although mitigated by the similar syntax. + diff --git a/docs/architecture/adr/0004-naming-conventions-for-interfaces-and-their-implementations.md b/docs/architecture/adr/0004-naming-conventions-for-interfaces-and-their-implementations.md new file mode 100644 index 0000000..c90becd --- /dev/null +++ b/docs/architecture/adr/0004-naming-conventions-for-interfaces-and-their-implementations.md @@ -0,0 +1,43 @@ +# Naming Conventions for Interfaces and Their Implementations + +- Pull Request: [#7794](https://github.com/thunderbird/thunderbird-android/pull/7794) + +## Status + +- **Accepted** + +## Context + +When there's an interface that has multiple implementations it's often easy enough to give meaningful names to both the +interface and the implementations (e.g. the interface `Backend` with the implementations `ImapBackend` and +`Pop3Backend`). Naming becomes harder when the interface mainly exists to allow having isolated unit tests and the +production code contains exactly one implementation of the interface. +Prior to this ADR we didn't have any naming guidelines and the names varied widely. Often when there was only one +(production) implementation, the class name used one of the prefixes `Default`, `Real`, or `K9`. None of these had any +special meaning and it wasn't clear which one to pick when creating a new interface/class pair. + +## Decision + +We'll be using the following guidelines for naming interfaces and their implementation classes: + +1. **Interface Naming:** Name interfaces as if they were classes, using a clear and descriptive name. Avoid using the + "IInterface" pattern. +2. **Implementation Naming:** Use a prefix that clearly indicates the relationship between the interface and + implementation, such as `DatabaseMessageStore` or `InMemoryMessageStore` for the `MessageStore` interface. +3. **Descriptive Names:** Use descriptive names for interfaces and implementing classes that accurately reflect their + purpose and functionality. +4. **Platform-specific Implementations:** Use the platform name as a prefix for interface implementations specific to + that platform, e.g. `AndroidPowerManager`. +5. **App-specific Implementations:** Use the prefix `K9` for K-9 Mail and `Tb` for Thunderbird when app-specific + implementations are needed, e.g. `K9AppNameProvider` and `TbAppNameProvider`. +6. **Flexibility:** If no brief descriptive name fits and there is only one production implementation, use the prefix + `Default`, like `DefaultImapFolder`. + +## Consequences + +- **Positive Consequences** + - Improved code readability and maintainability through consistent naming. + - Reduced confusion and misunderstandings by using clear and descriptive names. +- **Negative Consequences** + - Initial effort is required to rename existing classes that do not follow these naming conventions. + diff --git a/docs/architecture/adr/0005-central-project-configuration.md b/docs/architecture/adr/0005-central-project-configuration.md new file mode 100644 index 0000000..8fdf015 --- /dev/null +++ b/docs/architecture/adr/0005-central-project-configuration.md @@ -0,0 +1,34 @@ +# Central Management of Android Project Dependencies and Gradle Configurations via Build-Plugin Module + +- Pull Request: [#7803](https://github.com/thunderbird/thunderbird-android/pull/7803) + +## Status + +- **Accepted** + +## Context + +In our Android project, managing dependencies and configurations directly within each module's `build.gradle.kts` file has historically led to inconsistencies, duplication, and difficulty in updates. This challenge was particularly noticeable when maintaining the project configuration. By centralizing this setup in a `build-plugin` module, we can encapsulate and reuse Gradle logic, streamline the build process, and ensure consistency across all project modules and ease maintainability of our codebase. + +## Decision + +To address these challenges, we have decided to establish a `build-plugin` module within our project. This module will serve as the foundation for all common Gradle configurations, dependency management, and custom plugins, allowing for simplified configuration across various project modules and plugins. Key components of this module include: + +- **Custom Plugins:** A suite of custom plugins that configure Gradle for different project aspects, ensuring each project type has tailored and streamlined build processes. These plugins should cover Android application, Android library, Jetpack Compose and Java modules. +- **Dependency Management:** Utilizing the [Gradle Version Catalog](https://docs.gradle.org/current/userguide/platforms.html) to centrally manage and update all dependencies and plugins, ensuring that every module uses the same versions and reduces the risk of conflicts. +- **Common Configuration Settings:** Establishing common configurations for Java, Kotlin, and Android to reduce the complexity and variability in setup across different modules. + +## Consequences + +### Positive Consequences + +1. **Consistency Across Modules:** All project modules will use the same versions of dependencies and plugins, reducing the risk of conflicts and enhancing uniformity. They will also share common configurations, ensuring consistency in the build process. +2. **Ease of Maintenance:** Centralizing dependency versions in the Gradle Version Catalog allows for simple and quick updates to libraries and tools across all project modules from a single source. +3. **Simplified Configuration Process:** The custom plugins within the `build-plugin` module provides a streamlined way to apply settings and dependencies uniformly, enhancing productivity and reducing setup complexity. + +### Negative Consequences + +1. **Initial Overhead:** The setup of the build-plugin module with a Gradle Version Catalog and the migration of existing configurations required an initial investment of time and resources, but this has been completed. +2. **Complexity for New Developers:** The centralized build architecture, particularly with the use of a Gradle Version Catalog, may initially seem daunting to new team members who are unfamiliar with this level of abstraction. +3. **Dependency on the Build-Plugin Module:** The entire project becomes reliant on the stability and accuracy of the `build-plugin` module. Errors within this module or the catalog could impact the build process across all modules. + diff --git a/docs/architecture/adr/0006-white-label-architecture.md b/docs/architecture/adr/0006-white-label-architecture.md new file mode 100644 index 0000000..9280197 --- /dev/null +++ b/docs/architecture/adr/0006-white-label-architecture.md @@ -0,0 +1,37 @@ +# White Label Architecture + +- Issue: [#7807](https://github.com/thunderbird/thunderbird-android/issues/7807) +- Pull Request: [#7805](https://github.com/thunderbird/thunderbird-android/pull/7805) + +## Status + +- **Accepted** + +## Context + +Our project hosts two separate applications, K-9 Mail and Thunderbird for Android, which share a significant amount of functionality. Despite their common features, each app requires distinct branding elements such as app names, themes, and specific strings. + +## Decision + +We have decided to adopt a modular white-label architecture, where each application is developed as a separate module that relies on a shared codebase. This structure allows us to streamline configuration details specific to each brand either during build or at runtime. This is how we structure the modules: + +### Application Modules + +There will be 2 separate modules for each of the two applications: **Thunderbird for Android** will be located in `app-thunderbird` and **K-9 Mail** in `app-k9mail`. These modules will contain app-specific implementations, configurations, resources, and startup logic. They should solely depend on the `app-common` module for shared functionalities and may selectively integrate other modules when needed to configure app-specific functionality. + +### App Common Module + +A central module named `app-common` acts as the central integration point for shared code among the applications. This module contains the core functionality, shared resources, and configurations that are common to both apps. It should be kept as lean as possible to avoid unnecessary dependencies and ensure that it remains focused on shared functionality. + +## Consequences + +### Positive Consequences + +- Enhanced maintainability due to a shared codebase for common functionalities, reducing code duplication. +- Increased agility in developing and deploying new features across both applications, as common enhancements need to be implemented only once. + +## Negative Consequences + +- Potential for configuration complexities as differentiations increase between the two applications. +- Higher initial setup time and learning curve for new developers due to the modular and decoupled architecture. + diff --git a/docs/architecture/adr/0007-project-structure.md b/docs/architecture/adr/0007-project-structure.md new file mode 100644 index 0000000..dc7ce15 --- /dev/null +++ b/docs/architecture/adr/0007-project-structure.md @@ -0,0 +1,158 @@ +# Project Structure + +- Issue: [#7852](https://github.com/thunderbird/thunderbird-android/issues/7852) +- Pull Request: [#7829](https://github.com/thunderbird/thunderbird-android/pull/7829) + +## Status + +- **Accepted** + +## Context + +The project consists of two distinct applications. To improve maintainability and streamline development, we propose a modular structure using Gradle. This structure is designed to enable clear separation of concerns, facilitate scalable growth, and ensure efficient dependency management. It consists of various module types such as `app`, `app-common`, `feature`, `core`, and `library` modules, promoting enhanced modular reusability. + +## Decision + +To achieve the goals outlined in the context, we have decided to adopt the following modular structure: + +1. **App Modules**: + - `app-thunderbird` and `app-k9mail` are the modules for the two applications, Thunderbird for Android and K-9 Mail respectively. These modules will contain app-specific implementations, configurations, resources, and startup logic. They should solely depend on the `app-common` module for shared functionalities and may selectively integrate `feature` and `core` to setup app-specific needs. +2. **App Common Module**: + - `app-common`: Acts as the central hub for shared code between both applications. This module serves as the primary "glue" that binds various `feature` modules together, providing a seamless integration point. While it can depend on `library` modules for additional functionalities, its main purpose is to orchestrate the interactions among the `feature` and `core` modules, ensuring similar functionality across both applications. This module should be kept lean to avoid unnecessary dependencies and ensure it remains focused on shared functionality. +3. **Feature Modules**: + - `feature:*`: These are independent feature modules, that encapsulate distinct user-facing features. They are designed to be reusable and can be integrated into any application module as needed. They maintain dependencies on `core` modules and may interact with other `feature` or `library` modules. +4. **Core Module**: + - `core:*`: The core modules contain essential utilities and base classes used across the entire project. These modules are grouped by their functionality (e.g., networking, database management, theming, common utilities). This segmentation allows for cleaner dependency management and specialization within foundational aspects. +5. **Library Modules**: + - `library:*` These modules are for specific implementations that might be used across various features or applications. They could be third-party integrations or complex utilities and eventually shared across multiple projects. + +```mermaid +graph TD + subgraph APP[App] + APP_K9["` + **:app-k9mail** + K-9 Mail + `"] + APP_TB["` + **:app-thunderbird** + Thunderbird for Android + `"] + end + + subgraph COMMON[App Common] + APP_COMMON["` + **:app-common** + Integration Code + `"] + end + + subgraph FEATURE[Feature] + FEATURE1[Feature 1] + FEATURE2[Feature 2] + end + + subgraph CORE[Core] + CORE1[Core 1] + CORE2[Core 2] + end + + subgraph LIBRARY[Library] + LIB1[Library 1] + LIB2[Library 2] + end + + APP --> |depends on| COMMON + COMMON --> |integrates| FEATURE + FEATURE --> |uses| CORE + FEATURE --> |uses| LIBRARY + + classDef module fill:yellow + classDef app fill:azure + classDef app_common fill:#ddd + class APP_K9 app + class APP_TB app + class APP_COMMON app_common +``` + +### Legacy Modules + +Modules that are still required for the project to function, but don't follow the new project structure. + +These modules should not be used for new development. + +The goal is to migrate the functionality of these modules to the new structure over time. By placing them under the `legacy` module, we can easily identify and manage them. + +```mermaid +graph TD + subgraph APP[App] + APP_K9["` + **:app-k9mail** + K-9 Mail + `"] + APP_TB["` + **:app-thunderbird** + Thunderbird for Android + `"] + end + + subgraph COMMON[App Common] + APP_COMMON["` + **:app-common** + Integration Code + `"] + end + + subgraph FEATURE[Feature] + FEATURE1[Feature 1] + FEATURE2[Feature 2] + FEATURE3[Feature from Legacy] + end + + subgraph CORE[Core] + CORE1[Core 1] + CORE2[Core 2] + CORE3[Core from Legacy] + end + + subgraph LIBRARY[Library] + LIB1[Library 1] + LIB2[Library 2] + end + + APP --> |depends on| COMMON + COMMON --> |integrates| FEATURE + FEATURE --> |uses| CORE + FEATURE --> |uses| LIBRARY + + subgraph LEGACY[Legacy] + LEG[Legacy Code] + end + + COMMON -.-> |integrates| LEGACY + LEG -.-> |migrate to| FEATURE3 + LEG -.-> |migrate to| CORE3 + + classDef module fill:yellow + classDef app fill:azure + classDef app_common fill:#ddd + classDef legacy fill:#F99 + class APP_K9 app + class APP_TB app + class APP_COMMON app_common + class LEGACY legacy +``` + +## Consequences + +### Positive Consequences + +- Improved modularity facilitates easier code maintenance and scaling. +- Clear separation of concerns reduces dependencies and potential conflicts between modules. +- Enhanced reusability of the `feature`, `core` and `library` modules across different parts of the application or even in different projects. + +### Negative Consequences + +- Initial complexity in setting up and managing multiple modules may increase the learning curve and setup time for new developers. +- Over-modularization can lead to excessive abstraction, potentially impacting runtime performance and complicating the debugging process. +- Legacy modules may require additional effort to migrate to the new structure, potentially causing delays in the adoption of the new architecture. + diff --git a/docs/architecture/adr/0008-change-shared-modules-package-name.md b/docs/architecture/adr/0008-change-shared-modules-package-name.md new file mode 100644 index 0000000..08a5314 --- /dev/null +++ b/docs/architecture/adr/0008-change-shared-modules-package-name.md @@ -0,0 +1,51 @@ +# Change Shared Modules package to `net.thunderbird` + +- Issue: [#9012](https://github.com/thunderbird/thunderbird-android/issues/9012) + +## Status + +- **Accepted** + +## Context + +The Thunderbird Android project is a white-label version of K-9 Mail, and both apps — `app-thunderbird` and `app-kmail` +— coexist in the same repository. They have distinct application IDs and branding, but share a significant portion of +the code through common modules. + +These shared modules currently use the `app.k9mail` or `com.fsck` package name, which are legacy artifacts from +K-9 Mail. While K-9 will remain available for some time, the project’s primary focus has shifted toward Thunderbird. + +To reflect this shift, establish clearer ownership, and prepare for future development (including cross-platform code +integration), we will rename the packages in shared modules from `app.k9mail` and `com.fsck` to `net.thunderbird`. +The actual application IDs and package names of `app-thunderbird` and `app-k9mail` must remain **unchanged**. + +## Decision + +We decided to rename the base package in all shared modules from `app.k9mail` and `com.fsck` to `net.thunderbird`. + +Specifically: + +- All Kotlin/Java packages in shared modules will be refactored to use `net.thunderbird` as the base +- This must not affect the application IDs or packages of `app-thunderbird` or `app-kmail`, which will remain as-is +- All references, imports, and configuration references will be updated accordingly +- Tests, resources, and Gradle module settings will be adjusted to match the new package structure + +This change will establish a clearer identity for the shared code, align with Thunderbird's branding, and prepare the +project for cross-platform development. + +## Consequences + +## Positive Consequences + +- Shared code reflects Thunderbird branding and identity +- Reduces confusion when navigating codebase shared by both apps +- Sets the foundation for cross-platform compatibility and future modularization +- Helps reinforce long-term direction of the project toward Thunderbird + +## Negative Consequences + +- Large-scale refactoring required across multiple modules +- Risk of introducing regressions during package renaming +- Potential for disruption in local development setups (e.g., IDE caching, broken imports) +- Contributors familiar with the old structure may need time to adjust + diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md new file mode 100644 index 0000000..03f02c6 --- /dev/null +++ b/docs/architecture/adr/README.md @@ -0,0 +1,83 @@ +# Architecture Decision Records + +The [docs/architecture/adr](/docs/architecture/adr) folder contains the architecture decision records (ADRs) for our project. + +ADRs are short text documents that serve as a historical context for the architecture decisions we make over the +course of the project. + +## What is an ADR? + +An Architecture Decision Record (ADR) is a document that captures an important architectural decision made along +with its context and consequences. ADRs record the decision making process and allow others to understand the +rationale behind decisions, providing insight and facilitating future decision-making processes. + +## Format of an ADR + +We adhere to Michael Nygard's [ADR format proposal](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions), +where each ADR document should contain: + +1. **Title**: A short descriptive name for the decision. + 1. **Link to Issue**: A link to the issue that prompted the decision. + 2. **Link to Pull Request**: A link to the pull request that implements the ADR. + 3. **Link to Tracking Issue**: A link to the tracking issue, if applicable. +2. **Status**: The current status of the decision (proposed, accepted, rejected, deprecated, superseded) +3. **Context**: The context that motivates this decision. +4. **Decision**: The change that we're proposing and/or doing. +5. **Consequences**: What becomes easier or more difficult to do and any risks introduced as a result of the decision. + +## Creating a new ADR + +When creating a new ADR, please follow the provided [ADR template file](0000-adr-template.md) and ensure that your +document is clear and concise. + +Once you are ready to propose your ADR, you should: + +1. Create an issue in the repository, get consensus from at least one other project contributor. +2. Make a post on [the mobile-planning list](https://thunderbird.topicbox.com/groups/mobile-planning) + to announce your ADR. You can use the below template as needed. +3. Create a pull request in the repository linking the issue. +4. Make a decision together with mobile module owners, the PR will be merged when accepted. + +## Directory Structure + +The ADRs will be stored in a directory named `docs/adr`, and each ADR will be a file named `NNNN-title-with-dashes.md` +where `NNNN` is a four-digit number that is increased by 1 for every new adr. + +## ADR Life Cycle + +The life cycle of an ADR is as follows: + +1. **Proposed**: The ADR is under consideration. +2. **Accepted**: The decision described in the ADR has been accepted and should be adhered to, unless it is superseded by another ADR. +3. **Rejected**: The decision described in the ADR has been rejected. +4. **Deprecated**: The decision described in the ADR is no longer relevant due to changes in system context. +5. **Superseded**: The decision described in the ADR has been replaced by another decision. + +Each ADR will have a status indicating its current life-cycle stage. An ADR can be updated over time, either to change +the status or to add more information. + +## Contributions + +We welcome contributions in the form of new ADRs or updates to existing ones. Please ensure all contributions follow +the standard format and provide clear and concise information. + +## Appendix: Intent to Adopt Template + +You may use this template in your Intent to Adopt email as noted above. Tweak it as you feel is useful. + +> Hello everyone, +> +> I’m writing to share an intent to adopt a new architecture decision: [ADR-[Number]] [Title of ADR] +> +> This change addresses [brief summary of the problem] and proposes [brief description of the approach]. +> +> This decision is based on [briefly mention motivating factors, constraints, or technical context]. +> +> You can read the full proposal here: [link to ADR] +> +> If you have feedback or concerns, please respond in the linked issue. We plan to finalize the +> decision after [proposed date], factoring in discussion at that time. +> +> Thanks, +> [Your Name] + diff --git a/docs/architecture/assets/atomic_design.svg b/docs/architecture/assets/atomic_design.svg new file mode 100644 index 0000000..80b8ca7 --- /dev/null +++ b/docs/architecture/assets/atomic_design.svg @@ -0,0 +1,4 @@ + + + +
ATOMS
ATOMS
MOLECULES
MOLECUL...
ORGANISM
ORGANISM
TEMPLATES
TEMPLAT...
PAGES
PAGES
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/architecture/design-system.md b/docs/architecture/design-system.md new file mode 100644 index 0000000..b04e242 --- /dev/null +++ b/docs/architecture/design-system.md @@ -0,0 +1,39 @@ +# 🎨 Design System + +The design system is a collection of guidelines, principles, and tools that help teams create consistent and cohesive visual designs and user experiences. It is built using the Atomic Design Methodology. + +## 📚 Background + +[Jetpack Compose](https://developer.android.com/jetpack/compose) is a declarative UI toolkit for Android that provides a modern and efficient way to build UIs for Android apps. In this context, design systems and atomic design can help designers and developers create more scalable, maintainable, and reusable UIs. + +### 🧩 Design System + +A design system is a collection of guidelines, principles, and tools that help teams create consistent and cohesive visual designs and user experiences. +It typically includes a set of reusable components, such as icons, typography, color palettes, and layouts, that can be combined and customized to create new designs. + +The design system also provides documentation and resources for designers and developers to ensure that the designs are implemented consistently and efficiently across all platforms and devices. +The goal of a design system is to streamline the design process, improve design quality, and maintain brand consistency. + +An example is Google's [Material Design](https://m3.material.io/) that is used to develop cohesive apps. + +### 🧪 Atomic Design + +![Atomic design](assets/atomic_design.svg) + +Atomic design is a methodology for creating user interfaces (UI) in a design system by breaking them down into smaller, reusable components. +These components are classified into five categories based on their level of abstraction: **atoms**, **molecules**, **organisms**, **templates**, and **pages**. + +- **Atoms** are the smallest building blocks, such as buttons, labels, and input fields and could be combined to create more complex components. +- **Molecules** are groups of atoms that work together, like search bars, forms or menus +- **Organisms** are more complex components that combine molecules and atoms, such as headers or cards. +- **Templates** are pages with placeholders for components +- **Pages** are the final UI + +By using atomic design, designers and developers can create more consistent and reusable UIs. +This can save time and improve the overall quality, as well as facilitate collaboration between team members. + +## 📝 Acknowledgement + +- [Atomic Design Methodology | Atomic Design by Brad Frost](https://atomicdesign.bradfrost.com/chapter-2/) +- [Atomic Design: Getting Started | Blog | We Are Mobile First](https://www.wearemobilefirst.com/blog/atomic-design) + diff --git a/docs/architecture/feature-modules.md b/docs/architecture/feature-modules.md new file mode 100644 index 0000000..060646d --- /dev/null +++ b/docs/architecture/feature-modules.md @@ -0,0 +1,487 @@ +# 📦 Feature Modules and Extensions + +The Thunderbird for Android project is organized into multiple feature modules, each encapsulating a specific +functionality of the application. This document provides an overview of the main feature modules, how they are +split into subfeatures, and how the application can be extended with additional features. + +## 📏 Feature Module Best Practices + +When developing new feature modules or extending existing ones, follow these best practices: + +1. **API-First Design**: Define clear public interfaces before implementation +2. **Single Responsibility**: Each feature module should have a single, well-defined responsibility +3. **Minimal Dependencies**: Minimize dependencies between feature modules +4. **Proper Layering**: Follow Clean Architecture principles within each feature +5. **Testability**: Design features to be easily testable in isolation +6. **Documentation**: Document the purpose and usage of each feature module +7. **Consistent Naming**: Follow the established naming conventions +8. **Feature Flags**: Use feature flags for gradual rollout and A/B testing +9. **Accessibility**: Ensure all features are accessible to all users +10. **Internationalization**: Design features with internationalization in mind + +By following these guidelines, the Thunderbird for Android application can maintain a clean, modular architecture while +expanding its functionality to meet user needs. + +## 📋 Feature Module Overview + +The application is composed of several core feature modules, each responsible for a specific aspect of the +application's functionality: + +```mermaid +graph TB + subgraph FEATURE[App Features] + direction TB + + + subgraph ROW_2[" "] + direction LR + SETTINGS["`**Settings**
App configuration`"] + NOTIFICATION["`**Notification**
Push and alert handling`"] + SEARCH["`**Search**
Content discovery`"] + WIDGET["`**Widget**
Home screen components`"] + end + + subgraph ROW_1[" "] + direction LR + ACCOUNT["`**Account**
User accounts management`"] + MAIL["`**Mail**
Email handling and display`"] + NAVIGATION["`**Navigation**
App navigation and UI components`"] + ONBOARDING["`**Onboarding**
User setup and introduction`"] + end + end + + classDef row fill: #d9ffd9, stroke: #d9ffd9, color: #d9ffd9 + classDef feature fill: #d9ffd9,stroke: #000000, color: #000000 + classDef feature_module fill: #33cc33, stroke: #000000, color:#000000 + + class ROW_1,ROW_2 row + class FEATURE feature + class ACCOUNT,MAIL,NAVIGATION,ONBOARDING,SETTINGS,NOTIFICATION,SEARCH,WIDGET feature_module +``` + +## 🧩 Feature Module Details + +### 🔑 Account Module + +The Account module manages all aspects of email accounts, including setup, configuration, and authentication. + +```shell +feature:account +├── feature:account:api +├── feature:account:impl +├── feature:account:setup +│ ├── feature:account:setup:api +│ └── feature:account:setup:impl +├── feature:account:settings +│ ├── feature:account:settings:api +│ └── feature:account:settings:impl +├── feature:account:server +│ ├── feature:account:server:api +│ ├── feature:account:server:impl +│ ├── feature:account:server:certificate +│ │ ├── feature:account:server:certificate:api +│ │ └── feature:account:server:certificate:impl +│ ├── feature:account:server:settings +│ │ ├── feature:account:server:settings:api +│ │ └── feature:account:server:settings:impl +│ └── feature:account:server:validation +│ ├── feature:account:server:validation:api +│ └── feature:account:server:validation:impl +├── feature:account:auth +│ ├── feature:account:auth:api +│ ├── feature:account:auth:impl +│ └── feature:account:auth:oauth +│ ├── feature:account:auth:oauth:api +│ └── feature:account:auth:oauth:impl +└── feature:account:storage + ├── feature:account:storage:api + ├── feature:account:storage:impl + └── feature:account:storage:legacy + ├── feature:account:storage:legacy:api + └── feature:account:storage:legacy:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for account management +- **Setup**: New account setup wizard functionality + - **API**: Public interfaces for account setup + - **Implementation**: Concrete implementations of setup flows +- **Settings**: Account-specific settings management + - **API**: Public interfaces for account settings + - **Implementation**: Concrete implementations of settings functionality +- **Server**: Server configuration and management + - **API/Implementation**: Core server management interfaces and implementations + - **Certificate**: SSL certificate handling + - **Settings**: Server settings configuration + - **Validation**: Server connection validation +- **Auth**: Authentication functionality + - **API/Implementation**: Core authentication interfaces and implementations + - **OAuth**: OAuth-specific authentication implementation +- **Storage**: Account data persistence + - **API/Implementation**: Core storage interfaces and implementations + - **Legacy**: Legacy storage implementation + +### 📧 Mail Module + +The Mail module handles core email functionality, including message display, composition, and folder management. + +```shell +feature:mail +├── feature:mail:api +├── feature:mail:impl +├── feature:mail:account +│ ├── feature:mail:account:api +│ └── feature:mail:account:impl +├── feature:mail:folder +│ ├── feature:mail:folder:api +│ └── feature:mail:folder:impl +├── feature:mail:compose +│ ├── feature:mail:compose:api +│ └── feature:mail:compose:impl +└── feature:mail:message + ├── feature:mail:message:api + ├── feature:mail:message:impl + ├── feature:mail:message:view + │ ├── feature:mail:message:view:api + │ └── feature:mail:message:view:impl + └── feature:mail:message:list + ├── feature:mail:message:list:api + └── feature:mail:message:list:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for mail functionality +- **Account**: Mail-specific account interfaces and implementations + - **API**: Public interfaces for mail account integration + - **Implementation**: Concrete implementations of mail account functionality +- **Folder**: Email folder management + - **API**: Public interfaces for folder operations + - **Implementation**: Concrete implementations of folder management +- **Compose**: Email composition functionality + - **API**: Public interfaces for message composition + - **Implementation**: Concrete implementations of composition features +- **Message**: Message handling and display + - **API/Implementation**: Core message handling interfaces and implementations + - **View**: Individual message viewing functionality + - **List**: Message list display and interaction + +### 🧭 Navigation Module + +The Navigation module provides UI components for navigating through the application. + +```shell +feature:navigation +├── feature:navigation:api +├── feature:navigation:impl +└── feature:navigation:drawer + ├── feature:navigation:drawer:api + ├── feature:navigation:drawer:impl + ├── feature:navigation:drawer:dropdown + │ ├── feature:navigation:drawer:dropdown:api + │ └── feature:navigation:drawer:dropdown:impl + └── feature:navigation:drawer:siderail + ├── feature:navigation:drawer:siderail:api + └── feature:navigation:drawer:siderail:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for navigation +- **Drawer**: Navigation drawer functionality + - **API/Implementation**: Core drawer interfaces and implementations + - **Dropdown**: Dropdown-style navigation implementation + - **Siderail**: Side rail navigation implementation + +### 🚀 Onboarding Module + +The Onboarding module guides new users through the initial setup process. + +```shell +feature:onboarding +├── feature:onboarding:api +├── feature:onboarding:impl +├── feature:onboarding:main +│ ├── feature:onboarding:main:api +│ └── feature:onboarding:main:impl +├── feature:onboarding:welcome +│ ├── feature:onboarding:welcome:api +│ └── feature:onboarding:welcome:impl +├── feature:onboarding:permissions +│ ├── feature:onboarding:permissions:api +│ └── feature:onboarding:permissions:impl +└── feature:onboarding:migration + ├── feature:onboarding:migration:api + ├── feature:onboarding:migration:impl + ├── feature:onboarding:migration:thunderbird + │ ├── feature:onboarding:migration:thunderbird:api + │ └── feature:onboarding:migration:thunderbird:impl + └── feature:onboarding:migration:noop + ├── feature:onboarding:migration:noop:api + └── feature:onboarding:migration:noop:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for onboarding +- **Main**: Main onboarding flow + - **API**: Public interfaces for the main onboarding process + - **Implementation**: Concrete implementations of the onboarding flow +- **Welcome**: Welcome screens and initial user experience + - **API**: Public interfaces for welcome screens + - **Implementation**: Concrete implementations of welcome screens +- **Permissions**: Permission request handling + - **API**: Public interfaces for permission management + - **Implementation**: Concrete implementations of permission requests +- **Migration**: Data migration from other apps + - **API/Implementation**: Core migration interfaces and implementations + - **Thunderbird**: Thunderbird-specific migration implementation + - **Noop**: No-operation implementation for testing + +### ⚙️ Settings Module + +The Settings module provides interfaces for configuring application behavior. + +```shell +feature:settings +├── feature:settings:api +├── feature:settings:impl +├── feature:settings:import +│ ├── feature:settings:import:api +│ └── feature:settings:import:impl +└── feature:settings:ui + ├── feature:settings:ui:api + └── feature:settings:ui:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for settings +- **Import**: Settings import functionality + - **API**: Public interfaces for settings import + - **Implementation**: Concrete implementations of import functionality +- **UI**: Settings user interface components + - **API**: Public interfaces for settings UI + - **Implementation**: Concrete implementations of settings screens + +### 🔔 Notification Module + +The Notification module handles push notifications and alerts for new emails and events. + +```shell +feature:notification +├── feature:notification:api +├── feature:notification:impl +├── feature:notification:email +│ ├── feature:notification:email:api +│ └── feature:notification:email:impl +└── feature:notification:push + ├── feature:notification:push:api + └── feature:notification:push:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for notifications +- **Email**: Email-specific notification handling + - **API**: Public interfaces for email notifications + - **Implementation**: Concrete implementations of email alerts +- **Push**: Push notification handling + - **API**: Public interfaces for push notifications + - **Implementation**: Concrete implementations of push notification processing + +### 🔍 Search Module + +The Search module provides functionality for searching through emails and contacts. + +```shell +feature:search +├── feature:search:api +├── feature:search:impl +├── feature:search:email +│ ├── feature:search:email:api +│ └── feature:search:email:impl +├── feature:search:contact +│ ├── feature:search:contact:api +│ └── feature:search:contact:impl +└── feature:search:ui + ├── feature:search:ui:api + └── feature:search:ui:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for search functionality +- **Email**: Email-specific search capabilities + - **API**: Public interfaces for email search + - **Implementation**: Concrete implementations of email search +- **Contact**: Contact search functionality + - **API**: Public interfaces for contact search + - **Implementation**: Concrete implementations of contact search +- **UI**: Search user interface components + - **API**: Public interfaces for search UI + - **Implementation**: Concrete implementations of search screens + +### 📱 Widget Module + +The Widget module provides home screen widgets for quick access to email functionality. + +```shell +feature:widget +├── feature:widget:api +├── feature:widget:impl +├── feature:widget:message-list +│ ├── feature:widget:message-list:api +│ └── feature:widget:message-list:impl +├── feature:widget:message-list-glance +│ ├── feature:widget:message-list-glance:api +│ └── feature:widget:message-list-glance:impl +├── feature:widget:shortcut +│ ├── feature:widget:shortcut:api +│ └── feature:widget:shortcut:impl +└── feature:widget:unread + ├── feature:widget:unread:api + └── feature:widget:unread:impl +``` + +#### Subfeatures: + +- **API/Implementation**: Core public interfaces and implementations for widgets +- **Message List**: Email list widget + - **API**: Public interfaces for message list widget + - **Implementation**: Concrete implementations of message list widget +- **Message List Glance**: Glanceable message widget + - **API**: Public interfaces for glance widget + - **Implementation**: Concrete implementations of glance widget +- **Shortcut**: App shortcut widgets + - **API**: Public interfaces for shortcut widgets + - **Implementation**: Concrete implementations of shortcut widgets +- **Unread**: Unread message counter widget + - **API**: Public interfaces for unread counter widget + - **Implementation**: Concrete implementations of unread counter widget + +## 🔄 Supporting Feature Modules + +In addition to the core email functionality, the application includes several supporting feature modules: + +### 🔎 Autodiscovery Module + +The Autodiscovery module automatically detects email server settings. + +#### Subfeatures: + +- **API** (`feature:autodiscovery:api`): Public interfaces +- **Autoconfig** (`feature:autodiscovery:autoconfig`): Automatic configuration +- **Service** (`feature:autodiscovery:service`): Service implementation +- **Demo** (`feature:autodiscovery:demo`): Demonstration implementation + +### 💰 Funding Module + +The Funding module handles in-app financial contributions and funding options. + +#### Subfeatures: + +- **API** (`feature:funding:api`): Public interfaces +- **Google Play** (`feature:funding:googleplay`): Google Play billing integration +- **Link** (`feature:funding:link`): External funding link handling +- **Noop** (`feature:funding:noop`): No-operation implementation + +### 🔄 Migration Module + +The Migration module handles data migration between different email clients. + +#### Subfeatures: + +- **Provider** (`feature:migration:provider`): Migration data providers +- **QR Code** (`feature:migration:qrcode`): QR code-based migration +- **Launcher** (`feature:migration:launcher`): Migration launcher + - **API** (`feature:migration:launcher:api`): Launcher interfaces + - **Noop** (`feature:migration:launcher:noop`): No-operation implementation + - **Thunderbird** (`feature:migration:launcher:thunderbird`): Thunderbird-specific implementation + +### 📊 Telemetry Module + +The Telemetry module handles usage analytics and reporting. + +#### Subfeatures: + +- **API** (`feature:telemetry:api`): Public interfaces +- **Noop** (`feature:telemetry:noop`): No-operation implementation +- **Glean** (`feature:telemetry:glean`): Mozilla Glean integration + +## 🔌 Extending with Additional Features + +The modular architecture of Thunderbird for Android allows for easy extension with additional features. To give you an +idea how the app could be extended when building a new feature, here are some theoretical examples along with their +structure: + +### 📅 Calendar Feature + +A Calendar feature could be added to integrate calendar functionality with email. + +```shell +feature:calendar +├── feature:calendar:api +├── feature:calendar:impl +├── feature:calendar:event +│ ├── feature:calendar:event:api +│ └── feature:calendar:event:impl +└── feature:calendar:sync + ├── feature:calendar:sync:api + └── feature:calendar:sync:impl +``` + +### 🗓️ Appointments Feature + +An Appointments feature could manage meetings and appointments. + +```shell +feature:appointment +├── feature:appointment:api +├── feature:appointment:impl +├── feature:appointment:scheduler +│ ├── feature:appointment:scheduler:api +│ └── feature:appointment:scheduler:impl +└── feature:appointment:notification + ├── feature:appointment:notification:api + └── feature:appointment:notification:impl +``` + +## 🔗 Feature Relationships + +Features in the application interact with each other through well-defined APIs. The diagram below illustrates the +relationships between different features: + +```mermaid +graph TB + subgraph CORE[Core Features] + ACCOUNT[Account] + MAIL[Mail] + end + + subgraph EXTENSIONS[Potential Extensions] + CALENDAR[Calendar] + APPOINTMENT[Appointments] + end + + MAIL --> |uses| ACCOUNT + + CALENDAR --> |integrates with| MAIL + CALENDAR --> |uses| ACCOUNT + APPOINTMENT --> |uses| ACCOUNT + APPOINTMENT --> |integrates with| CALENDAR + APPOINTMENT --> |uses| MAIL + + linkStyle default stroke:#999,stroke-width:2px + + classDef core fill:#e8c8ff,stroke:#000000,color:#000000 + classDef core_module fill:#c090ff,stroke:#000000,color:#000000 + classDef extension fill:#d0e0ff,stroke:#000000,color:#000000 + classDef extension_module fill:#8090ff,stroke:#000000,color:#000000 + class CORE core + class ACCOUNT,MAIL,NAVIGATION,SETTINGS core_module + class EXTENSIONS extension + class CALENDAR,TODO,SYNC,NOTES,APPOINTMENT extension_module +``` + diff --git a/docs/architecture/legacy-module-integration.md b/docs/architecture/legacy-module-integration.md new file mode 100644 index 0000000..b387526 --- /dev/null +++ b/docs/architecture/legacy-module-integration.md @@ -0,0 +1,459 @@ +# 🔙 Legacy Module Integration + +This document outlines how existing legacy code is integrated into the new modular architecture of the application and +the strategy for its migration. The core principle is to isolate legacy code and provide a controlled way for +newer modules to interact with legacy functionality without becoming directly dependent on it. + +> [!NOTE] +> This document should be read in conjunction with [Module Structure](module-structure.md) and [Module Organization](module-organization.md) to get a complete understanding of the modular architecture. + +## Overview + +The Thunderbird for Android project is transitioning from a monolithic architecture to a modular one. During this +transition, we need to maintain compatibility with existing legacy code while gradually migrating to the new +architecture. The `legacy:*`, `mail:*`, and `backend:*` modules contain functionality that is still essential for the +project but does not yet adhere to the new modular architecture. These modules are integrated into the new architecture +through the `:app-common` module, which acts as a bridge or adapter to provide access to legacy functionality without +directly depending on it. + +The key components in this integration strategy are: + +1. **Legacy Modules**: `legacy:*`, `mail:*`, and `backend:*` modules containing existing functionality +2. **Interfaces**: Well-defined interfaces in `feature:*:api` and `core:*` modules +3. **App Common Bridge**: The `:app-common` module that implements these interfaces and delegates to legacy code +4. **Dependency Injection**: Configuration that provides the appropriate implementations to modules + +## Integration Approach "_The App Common Bridge_" + +Newer application modules (such as features or core components) depend on well-defined **Interfaces** +(e.g., those found in `feature:*:api` modules). Typically, a feature will provide its own modern **Implementation** +(e.g., `:feature:mail:impl`) for its API. + +However, to manage dependencies on code still within `legacy:*`, `mail:*`, and `backend:*` modules and prevent it +from spreading, we use `app-common` as **bridge** or **adapter** to provide an alternative implementation for these. In +this role, `app-common` is responsible for: + +1. **Implementing interfaces**: `app-common` provides concrete implementations for interfaces that newer modules define. +2. **Delegating to legacy code**: Internally, these `app-common` implementations will delegate calls, adapt data, and manage interactions with the underlying `legacy:*`, `mail:*`, and `backend:*` modules. +3. **Containing glue code**: All logic required to connect the modern interfaces with the legacy systems is encapsulated within `app-common`. + +This approach ensures that: +* Newer modules are decoupled from legacy implementations: They only interact with the defined interfaces, regardless of whether the implementation is the modern feature `impl` or the `app-common` bridge. +* Legacy code is isolated. +* A clear path for refactoring is maintained: Initially, the application might be configured to use the `app-common` bridge. As new, native implementations in feature modules (e.g., `:feature:mail:impl`) mature, the dependency injection can be switched to use them, often without changes to the modules consuming the interface. + +### Bridge Pattern Flow + +The typical flow is: + +1. **Interfaces**: Interfaces are defined, usually within the `api` module of a feature (e.g., `:feature:mail:api`) or a core module. These interfaces represent the contract for a piece of functionality. +2. **New Module Dependency**: Newer modules (e.g., `:feature:somefeature:impl` or other parts of `:app-common`) depend on these defined interfaces, to avoid dependency on concrete legacy classes. +3. **Implementation**: The `:app-common` module provides concrete implementations for these interfaces. +4. **Delegation to Legacy**: Internally, these implementations within `:app-common` delegate the actual work to the code residing in the legacy modules (e.g., `legacy:*`, `mail:*`, `backend:*`). +5. **Dependency Injection**: The application's dependency injection framework is configured to provide instances of these `:app-common` bridge implementations when a newer module requests an implementation of the interface. + +This pattern ensures that newer modules remain decoupled from the specifics of legacy code. + +The following diagram illustrates this pattern, showing how both a feature's own implementation and `app-common` can relate to the interfaces, with `app-common` specifically bridging to legacy systems: + +```mermaid +graph TB + subgraph FEATURE[Feature Modules] + direction TB + INTERFACES["`**Interfaces**
(e.g., :feature:mail:api)`"] + IMPLEMENTATIONS["`**Implementations**
(e.g., :feature:mail:impl)`"] + OTHER_MODULES["`**Other Modules**
(depend on Interfaces)`"] + end + + subgraph COMMON[App Common Module] + direction TB + COMMON_APP["`**:app-common**
Integration Code`"] + end + + subgraph LEGACY[Legacy Modules] + direction TB + LEGACY_K9["`**:legacy**`"] + LEGACY_MAIL["`**:mail**`"] + LEGACY_BACKEND["`**:backend**`"] + end + + OTHER_MODULES --> |uses| INTERFACES + IMPLEMENTATIONS --> |depends on| INTERFACES + COMMON_APP --> |implements| INTERFACES + COMMON_APP --> |delegates to / wraps| LEGACY_K9 + COMMON_APP --> |delegates to / wraps| LEGACY_MAIL + COMMON_APP --> |delegates to / wraps| LEGACY_BACKEND + + classDef common fill:#e6e6e6,stroke:#000000,color:#000000 + classDef common_module fill:#999999,stroke:#000000,color:#000000 + classDef feature fill:#d9ffd9,stroke:#000000,color:#000000 + classDef feature_module fill:#33cc33,stroke:#000000,color:#000000 + classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000 + classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class COMMON common + class COMMON_APP common_module + class FEATURE feature + class INTERFACES,IMPLEMENTATIONS,OTHER_MODULES feature_module + class LEGACY legacy + class LEGACY_MAIL,LEGACY_BACKEND,LEGACY_K9 legacy_module +``` + +### Implementation Techniques + +Several techniques are used to implement the bridge pattern effectively: + +1. **Wrapper Classes**: Creating immutable data classes that wrap legacy data structures, implementing interfaces from the new architecture. These wrappers should not contain conversion methods but should delegate this responsibility to specific mapper classes. + +2. **Adapter Implementations**: Classes in `:app-common` that implement interfaces from the new architecture but delegate to legacy code internally. + +3. **Data Conversion**: Dedicated mapper classes that handle mapping between legacy and new data structures, ensuring clean separation of concerns. + +#### Example: Account Profile Bridge + +A concrete example of this pattern is the account profile bridge, which demonstrates a complete implementation of the bridge pattern across multiple layers: + +1. **Modern Interfaces**: + - `AccountProfileRepository` in `feature:account:api` defines the high-level contract for account profile management + - `AccountProfileLocalDataSource` in `feature:account:core` defines the data access contract +2. **Modern Data Structure**: `AccountProfile` in `feature:account:api` is a clean, immutable data class that represents account profile information in the new architecture. +3. **Repository Implementation**: `DefaultAccountProfileRepository` in `feature:account:core` implements the `AccountProfileRepository` interface and depends on `AccountProfileLocalDataSource`. +4. **Bridge Implementation**: `DefaultAccountProfileLocalDataSource` in `app-common` implements the `AccountProfileLocalDataSource` interface and serves as the bridge to legacy code. +5. **Legacy Access**: The bridge uses `DefaultLegacyAccountWrapperManager` to access legacy account data: + - `LegacyAccountWrapperManager` in `core:android:account` defines the contract for legacy account access + - `LegacyAccountWrapper` in `core:android:account` is an immutable wrapper around the legacy `LegacyAccount` class +6. **Data Conversion**: The bridge uses a dedicated mapper class to convert between modern `AccountProfile` objects and legacy account data. +7. **Dependency Injection**: The `appCommonAccountModule` in `app-common` registers `DefaultAccountProfileLocalDataSource` as implementations of the respective interface. + +This multi-layered approach allows newer modules to interact with legacy account functionality through clean, modern interfaces without directly depending on legacy code. It also demonstrates how bridges can be composed, with higher-level bridges (AccountProfile) building on lower-level bridges (LegacyAccountWrapper). + +## Testing Considerations + +Testing bridge implementations requires special attention to ensure both the bridge itself and its integration with legacy code work correctly: + +1. **Unit Testing Bridge Classes**: + - Test the bridge implementation in isolation by faking/stubbing the legacy dependencies + - Verify that the bridge correctly translates between the new interfaces and legacy code + - Focus on testing the conversion logic and error handling +2. **Integration Testing**: + - Test the bridge with actual legacy code to ensure proper integration + - Verify that the bridge correctly handles all edge cases from legacy code +3. **Test Doubles**: + - Create fake implementations of bridge classes for testing other components + - Example: `FakeLegacyAccountWrapperManager` can be used to test components that depend on `LegacyAccountWrapperManager` +4. **Migration Testing**: + - When migrating from a legacy bridge to a new implementation, test both implementations with the same test suite + - Ensure behavior consistency during the transition + +### Testing Examples + +Below are examples of tests for legacy module integration, demonstrating different testing approaches and best practices. + +#### Example 1: Unit Testing a Bridge Implementation + +This example shows how to test a bridge implementation (`DefaultAccountProfileLocalDataSource`) in isolation by using a fake implementation of the legacy dependency (`FakeLegacyAccountWrapperManager`): + +```kotlin +class DefaultAccountProfileLocalDataSourceTest { + + @Test + fun `getById should return account profile`() = runTest { + // arrange + val accountId = AccountIdFactory.create() + val legacyAccount = createLegacyAccount(accountId) + val accountProfile = createAccountProfile(accountId) + val testSubject = createTestSubject(legacyAccount) + + // act & assert + testSubject.getById(accountId).test { + assertThat(awaitItem()).isEqualTo(accountProfile) + } + } + + @Test + fun `getById should return null when account is not found`() = runTest { + // arrange + val accountId = AccountIdFactory.create() + val testSubject = createTestSubject(null) + + // act & assert + testSubject.getById(accountId).test { + assertThat(awaitItem()).isEqualTo(null) + } + } + + @Test + fun `update should save account profile`() = runTest { + // arrange + val accountId = AccountIdFactory.create() + val legacyAccount = createLegacyAccount(accountId) + val accountProfile = createAccountProfile(accountId) + + val updatedName = "updatedName" + val updatedAccountProfile = accountProfile.copy(name = updatedName) + + val testSubject = createTestSubject(legacyAccount) + + // act & assert + testSubject.getById(accountId).test { + assertThat(awaitItem()).isEqualTo(accountProfile) + + testSubject.update(updatedAccountProfile) + + assertThat(awaitItem()).isEqualTo(updatedAccountProfile) + } + } + + private fun createTestSubject( + legacyAccount: LegacyAccountWrapper?, + ): DefaultAccountProfileLocalDataSource { + return DefaultAccountProfileLocalDataSource( + accountManager = FakeLegacyAccountWrapperManager( + initialAccounts = if (legacyAccount != null) { + listOf(legacyAccount) + } else { + emptyList() + }, + ), + dataMapper = DefaultAccountProfileDataMapper( + avatarMapper = DefaultAccountAvatarDataMapper(), + ), + ) + } +} +``` + +Key points: +- The test creates a controlled test environment using a fake implementation of the legacy dependency +- It tests both success cases and error handling (account not found) +- It verifies that the bridge correctly translates between legacy data structures and domain models +- The test is structured with clear arrange, act, and assert sections + +#### Example 2: Creating Test Doubles for Legacy Dependencies + +This example shows how to create a fake implementation of a legacy dependency (`FakeLegacyAccountWrapperManager`) for testing: + +```kotlin +internal class FakeLegacyAccountWrapperManager( + initialAccounts: List = emptyList(), +) : LegacyAccountWrapperManager { + + private val accountsState = MutableStateFlow( + initialAccounts, + ) + private val accounts: StateFlow> = accountsState + + override fun getAll(): Flow> = accounts + + override fun getById(id: AccountId): Flow = accounts + .map { list -> + list.find { it.id == id } + } + + override suspend fun update(account: LegacyAccountWrapper) { + accountsState.update { currentList -> + currentList.toMutableList().apply { + removeIf { it.uuid == account.uuid } + add(account) + } + } + } +} +``` + +Key points: +- The fake implementation implements the same interface as the real implementation +- It provides a simple in-memory implementation for testing +- It uses Kotlin Flows to simulate the reactive behavior of the real implementation +- It allows for easy setup of test data through the constructor parameter + +#### Example 3: Testing Data Conversion Logic + +This example shows how to test data conversion logic in bridge implementations: + +```kotlin +class DefaultAccountProfileDataMapperTest { + + @Test + fun `toDomain should convert ProfileDto to AccountProfile`() { + // Arrange + val dto = createProfileDto() + val expected = createAccountProfile() + + val testSubject = DefaultAccountProfileDataMapper( + avatarMapper = FakeAccountAvatarDataMapper( + dto = dto.avatar, + domain = expected.avatar, + ), + ) + + // Act + val result = testSubject.toDomain(dto) + + // Assert + assertThat(result.id).isEqualTo(expected.id) + assertThat(result.name).isEqualTo(expected.name) + assertThat(result.color).isEqualTo(expected.color) + assertThat(result.avatar).isEqualTo(expected.avatar) + } + + @Test + fun `toDto should convert AccountProfile to ProfileDto`() { + // Arrange + val domain = createAccountProfile() + val expected = createProfileDto() + + val testSubject = DefaultAccountProfileDataMapper( + avatarMapper = FakeAccountAvatarDataMapper( + dto = expected.avatar, + domain = domain.avatar, + ), + ) + + // Act + val result = testSubject.toDto(domain) + + // Assert + assertThat(result.id).isEqualTo(expected.id) + assertThat(result.name).isEqualTo(expected.name) + assertThat(result.color).isEqualTo(expected.color) + assertThat(result.avatar).isEqualTo(expected.avatar) + } +} +``` + +Key points: +- The test verifies that the mapper correctly converts between legacy data structures (DTOs) and domain models +- It tests both directions of the conversion (toDomain and toDto) +- It uses a fake implementation of a dependency (FakeAccountAvatarDataMapper) to isolate the test +- It verifies that all properties are correctly mapped + +#### Best Practices for Testing Legacy Module Integration + +1. **Isolate the Bridge**: Test the bridge implementation in isolation by using fake or mock implementations of legacy dependencies. +2. **Test Both Directions**: For data conversion, test both directions (legacy to domain and domain to legacy). +3. **Cover Edge Cases**: Test edge cases such as null values, empty collections, and error conditions. +4. **Use Clear Test Structure**: Structure tests with clear arrange, act, and assert sections. +5. **Create Reusable Test Fixtures**: Create helper methods for creating test data to make tests more readable and maintainable. +6. **Test Reactive Behavior**: For reactive code (using Flows, LiveData, etc.), use appropriate testing utilities (e.g., Turbine for Flow testing). +7. **Verify Integration**: In addition to unit tests, create integration tests that verify the bridge works correctly with actual legacy code. +8. **Test Migration Path**: When migrating from a legacy bridge to a new implementation, test both implementations with the same test suite to ensure behavior consistency. + +## Migration Strategy + +The long-term strategy involves gradually migrating functionality out of the legacy modules: + +1. **Identify Functionality**: Pinpoint specific functionalities within legacy modules that need to be modernized. +2. **Define Interfaces**: Ensure clear interfaces are defined (typically in feature `api` modules) for this functionality. +3. **Entity Modeling**: Create proper domain entity models that represent the business objects as immutable data classes. +4. **Implement in New Modules**: Re-implement the functionality within new, dedicated feature `impl` modules or core modules. +5. **Update Bridge (Optional)**: If `:app-common` was bridging to this specific legacy code, its bridge implementation can be updated or removed. +6. **Switch DI Configuration**: Update the dependency injection to provide the new modern implementation instead of the legacy bridge. +7. **Retire Legacy Code**: Once no longer referenced, the corresponding legacy code can be safely removed. + +### Migration Example + +Using the account profile example, the migration process would look like: + +1. **Identify**: Account profile functionality in legacy modules needs modernization. +2. **Define Interfaces**: + - `AccountProfileRepository` interface is defined in `feature:account:api` + - `AccountProfileLocalDataSource` interface is defined in `feature:account:core` +3. **Entity Modeling**: Create `AccountProfile` as an immutable data class in `feature:account:api`. +4. **Implement**: Create a new implementation of `AccountProfileLocalDataSource` in a modern module, e.g., `feature:account:impl`. +5. **Update Bridge**: Update or remove `DefaultAccountProfileLocalDataSource` in `app-common`. +6. **Switch DI**: Update `appCommonAccountModule` to provide the new implementation instead of `DefaultAccountProfileLocalDataSource`. +7. **Retire**: Once all references to legacy account code are removed, the legacy code and lower-level bridges (`LegacyAccountWrapperManager`, `DefaultLegacyAccountWrapperManager`) can be safely deleted. + +This approach ensures a smooth transition with minimal disruption to the application's functionality. + +## Dependency Direction + +A strict dependency rule is enforced: **New modules (features, core) must not directly depend on legacy modules.** +The dependency flow is always from newer modules to interfaces, with `:app-common` providing the implementation. +If `:app-common` bridges to legacy code, that is an internal detail of `:app-common`. + +The legacy module integration diagram below explains how legacy code is integrated into the new modular architecture: + +```mermaid +graph TB + subgraph APP[App Modules] + direction TB + APP_TB["`**:app-thunderbird**
Thunderbird for Android`"] + APP_K9["`**:app-k9mail**
K-9 Mail`"] + end + + subgraph COMMON[App Common Module] + direction TB + COMMON_APP["`**:app-common**
Integration Code`"] + end + + subgraph FEATURE[Feature Modules] + direction TB + FEATURE1[Feature 1] + FEATURE2[Feature 2] + FEATURE3[Feature from Legacy] + end + + subgraph CORE[Core Modules] + direction TB + CORE1[Core 1] + CORE2[Core 2] + CORE3[Core from Legacy] + end + + subgraph LIBRARY[Library Modules] + direction TB + LIB1[Library 1] + LIB2[Library 2] + end + + subgraph LEGACY[Legacy Modules] + direction TB + LEGACY_CODE[Legacy Code] + end + + APP_K9 --> |depends on| COMMON_APP + APP_TB --> |depends on| COMMON_APP + COMMON_APP --> |integrates| FEATURE1 + COMMON_APP --> |integrates| FEATURE2 + COMMON_APP --> |integrates| FEATURE3 + FEATURE1 --> |uses| CORE1 + FEATURE1 --> |uses| LIB2 + FEATURE2 --> |uses| CORE2 + FEATURE2 --> |uses| CORE3 + COMMON_APP --> |integrates| LEGACY_CODE + LEGACY_CODE -.-> |migrate to| FEATURE3 + LEGACY_CODE -.-> |migrate to| CORE3 + + classDef app fill:#d9e9ff,stroke:#000000,color:#000000 + classDef app_module fill:#4d94ff,stroke:#000000,color:#000000 + classDef common fill:#e6e6e6,stroke:#000000,color:#000000 + classDef common_module fill:#999999,stroke:#000000,color:#000000 + classDef feature fill:#d9ffd9,stroke:#000000,color:#000000 + classDef feature_module fill:#33cc33,stroke:#000000,color:#000000 + classDef core fill:#e6cce6,stroke:#000000,color:#000000 + classDef core_module fill:#cc99cc,stroke:#000000,color:#000000 + classDef library fill:#fff0d0,stroke:#000000,color:#000000 + classDef library_module fill:#ffaa33,stroke:#000000,color:#000000 + classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000 + classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class APP app + class APP_K9,APP_TB app_module + class COMMON common + class COMMON_APP common_module + class FEATURE feature + class FEATURE1,FEATURE2,FEATURE3 feature_module + class CORE core + class CORE1,CORE2,CORE3 core_module + class LIBRARY library + class LIB1,LIB2 library_module + class LEGACY legacy + class LEGACY_CODE legacy_module +``` + diff --git a/docs/architecture/module-organization.md b/docs/architecture/module-organization.md new file mode 100644 index 0000000..145277e --- /dev/null +++ b/docs/architecture/module-organization.md @@ -0,0 +1,310 @@ +# 📦 Module Organization + +The Thunderbird for Android project is following a modularization approach, where the codebase is divided into multiple +distinct modules. These modules encapsulate specific functionality and can be developed, tested, and maintained +independently. This modular architecture promotes reusability, scalability, and maintainability of the codebase. + +This document outlines the adopted module organization for the Thunderbird for Android project, serving as a guide for +developers to understand the codebase structure and ensure consistent architectural patterns. + +## 📂 Module Overview + +The modules are organized into several types, each serving a specific purpose in the overall architecture: + +```mermaid +graph TB + subgraph APP[App Modules] + direction TB + APP_TB["`**:app-thunderbird**
Thunderbird for Android`"] + APP_K9["`**:app-k9mail**
K-9 Mail`"] + end + + subgraph COMMON[App Common Module] + direction TB + COMMON_APP["`**:app-common**
Integration Code`"] + end + + subgraph FEATURE[Feature Modules] + direction TB + FEATURE_ACCOUNT["`**:feature:account**`"] + FEATURE_SETTINGS["`**:feature:settings**`"] + FEATURE_ONBOARDING["`**:feature:onboarding**`"] + FEATURE_MAIL["`**:feature:mail**`"] + end + + subgraph CORE[Core Modules] + direction TB + CORE_UI["`**:core:ui**`"] + CORE_COMMON["`**:core:common**`"] + CORE_ANDROID["`**:core:android**`"] + CORE_NETWORK["`**:core:network**`"] + CORE_DATABASE["`**:core:database**`"] + CORE_TESTING["`**:core:testing**`"] + end + + subgraph LIBRARY[Library Modules] + direction TB + LIB_AUTH["`**:library:auth**`"] + LIB_CRYPTO["`**:library:crypto**`"] + LIB_STORAGE["`**:library:storage**`"] + end + + subgraph LEGACY[Legacy Modules] + direction TB + LEGACY_K9["`**:legacy**`"] + LEGACY_MAIL["`**:mail**`"] + LEGACY_BACKEND["`**:backend**`"] + end + + APP ~~~ COMMON + COMMON ~~~ FEATURE + FEATURE ~~~ CORE + CORE ~~~ LIBRARY + LIBRARY ~~~ LEGACY + + APP --> |depends on| COMMON + COMMON --> |depends on| FEATURE + FEATURE --> |depends on| CORE + CORE --> |depends on| LIBRARY + COMMON --> |depends on
as legacy bridge| LEGACY + + classDef app fill:#d9e9ff,stroke:#000000,color:#000000 + classDef app_module fill:#4d94ff,stroke:#000000,color:#000000 + classDef common fill:#e6e6e6,stroke:#000000,color:#000000 + classDef common_module fill:#999999,stroke:#000000,color:#000000 + classDef feature fill:#d9ffd9,stroke:#000000,color:#000000 + classDef feature_module fill:#33cc33,stroke:#000000,color:#000000 + classDef core fill:#e6cce6,stroke:#000000,color:#000000 + classDef core_module fill:#cc99cc,stroke:#000000,color:#000000 + classDef library fill:#fff0d0,stroke:#000000,color:#000000 + classDef library_module fill:#ffaa33,stroke:#000000,color:#000000 + classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000 + classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + linkStyle 0,1,2,3,4 stroke-width:0px + + class APP app + class APP_TB,APP_K9 app_module + class COMMON common + class COMMON_APP common_module + class FEATURE feature + class FEATURE_ACCOUNT,FEATURE_SETTINGS,FEATURE_ONBOARDING,FEATURE_MAIL feature_module + class CORE core + class CORE_UI,CORE_COMMON,CORE_ANDROID,CORE_DATABASE,CORE_NETWORK,CORE_TESTING core_module + class LIBRARY library + class LIB_AUTH,LIB_CRYPTO,LIB_STORAGE library_module + class LEGACY legacy + class LEGACY_MAIL,LEGACY_BACKEND,LEGACY_K9 legacy_module +``` + +### Module Types + +#### 📱 App Modules + +The App Modules (`app-thunderbird` and `app-k9mail`) contain the application-specific code, including: +- Application entry points and initialization logic +- Final dependency injection setup +- Navigation configuration +- Integration with feature modules solely for that application +- App-specific themes and resources (strings, themes, etc.) + +#### 🔄 App Common Module + +The `app-common` module acts as the central hub for shared code between both applications. This module serves as the +primary "glue" that binds various `feature` modules together, providing a seamless integration point. It also contains: +- Shared application logic +- Feature coordination +- Common dependency injection setup +- Legacy code bridges and adapters + +##### What Should Go in App Common + +The app-common module should contain: + +1. **Shared Application Logic**: Code that's needed by both app modules but isn't specific to any one feature. + - Example: `BaseApplication` provides common application initialization, language management, and theme setup. + - This avoids duplication between app-thunderbird and app-k9mail. +2. **Feature Integration Code**: Code that connects different features together. + - Example: Code that coordinates between account and mail features. + - This maintains separation between features while allowing them to work together. +3. **Common Dependency Injection Setup**: Koin modules that configure dependencies shared by both applications. + - Example: `AppCommonModule` includes legacy modules and app-common specific modules. + - This ensures consistent dependency configuration across both applications. +4. **Legacy Code Bridges/Adapters**: Implementations of interfaces defined in feature modules that delegate to legacy code. + - Example: `DefaultAccountProfileLocalDataSource` implements `AccountProfileLocalDataSource` from `feature:account:core` and delegates to legacy account code. + - These bridges isolate legacy code and prevent direct dependencies on it from feature modules. + +##### What Should NOT Go in App Common + +The following should NOT be placed in app-common: + +1. **Feature-Specific Business Logic**: Business logic that belongs to a specific feature domain should be in that feature's module. + - Example: Mail composition logic should be in `feature:mail`, not in app-common. + - This maintains clear separation of concerns and feature independence. +2. **UI Components**: UI components should be in core:ui or in feature modules. + - Example: A custom button component should be in core:ui, while a mail-specific UI component should be in feature:mail. + - This ensures proper layering and reusability. +3. **Direct Legacy Code**: Legacy code should remain in legacy modules, with app-common providing bridges. + - Example: Don't move legacy mail code into app-common; instead, create a bridge in app-common. + - This maintains the separation between legacy and modern code. +4. **New Feature Implementations**: New features should be implemented in feature modules, not in app-common. + - Example: A new calendar feature should be in `feature:calendar`, not in app-common. + - This ensures features can evolve independently. + +##### Decision Criteria for New Contributors + +When deciding whether code belongs in app-common or a feature module, consider: + +1. **Is it shared between both applications?** If yes, it might belong in app-common. +2. **Is it specific to a single feature domain?** If yes, it belongs in that feature module. +3. **Does it bridge to legacy code?** If yes, it belongs in app-common. +4. **Does it coordinate between multiple features?** If yes, it might belong in app-common. +5. **Is it a new feature implementation?** If yes, create a new feature module instead. + +Remember that app-common should primarily contain integration code, shared application logic, and bridges to legacy code. Feature-specific logic should be in feature modules, even if used by both applications. + +#### ✨ Feature Modules + +The `feature:*` modules are independent and encapsulate distinct user-facing feature domains. They are designed to be +reusable and can be integrated into any application module as needed. + +Feature implementation modules (e.g., `:feature:account:impl`) should ideally not depend directly on other feature +implementation modules. Instead, they should depend on the public `:api` module of other features (e.g., +`:feature:someOtherFeature:api`) to access their functionality through defined contracts, see +[module structure](module-structure.md#-api-module) for more details. + +When features are complex, they can be split into smaller sub feature modules, addressing specific aspects or +functionality within a feature domain: + +- `:feature:account:api`: Public interfaces for account management +- `:feature:account:settings:api`: Public interfaces for account settings +- `:feature:account:settings:impl`: Concrete implementations of account settings + +#### 🧰 Core Modules + +The `core:*` modules contain foundational functionality used across the application: + +- **core:ui**: UI components, themes, and utilities +- **core:common**: Common utilities and extensions +- **core:network**: Networking utilities and API client infrastructure +- **core:database**: Database infrastructure and utilities +- **core:testing**: Testing utilities + +Core modules should only contain generic, reusable components that have no specific business logic. +Business objects (e.g., account, mail, etc.) should live in their respective feature modules. + +#### 📚 Library Modules + +The `library:*` modules are for specific implementations that might be used across various features or applications. +They could be third-party integrations or complex utilities and eventually shared across multiple projects. + +#### 🔙 Legacy Modules + +The `legacy:*` modules that are still required for the project to function, but don't follow the new project structure. +These modules should not be used for new development. The goal is to migrate the functionality of these modules to the +new structure over time. + +Similarly the `mail:*` and `backend:*` modules are legacy modules that contain the old mail and backend implementations. +These modules are being gradually replaced by the new feature modules. + +The `legacy` modules are integrated into the `app-common` module, allowing them to be used by other parts of the app. +The glue code for bridging legacy code to the new modular architecture is also located in the `app-common` module. See +[module legacy integration](legacy-module-integration.md) for more details. + +## 🔗 Module Dependencies + +The module dependency diagram below illustrates how different modules interact with each other in the project, +showing the dependencies and integration points between modules: + +- **App Modules**: Depend on the App Common module for shared functionality and selectively integrate feature modules +- **App Common**: Integrates various feature modules to provide a cohesive application +- **Feature Modules**: Use core modules and libraries for their implementation, may depend on other feature api modules +- **App-Specific Features**: Some features are integrated directly by specific apps (K-9 Mail or Thunderbird) + +Rules for module dependencies: +- **One-Way Dependencies**: Modules should not depend on each other in a circular manner +- **API-Implementation Separation**: Modules should depend on `api` modules, not `implementation` modules, see [module structure](module-structure.md#module-structure) +- **Feature Integration**: Features should be integrated through the `app-common` module, which acts as the central integration hub +- **Dependency Direction**: Dependencies should flow from app modules to common, then to features, and finally to core and libraries + +```mermaid +graph TB + subgraph APP[App Modules] + direction TB + APP_TB["`**:app-thunderbird**
Thunderbird for Android`"] + APP_K9["`**:app-k9mail**
K-9 Mail`"] + end + + subgraph COMMON[App Common Module] + direction TB + COMMON_APP["`**:app-common**
Integration Code`"] + end + + subgraph FEATURE[Feature Modules] + direction TB + FEATURE_ACCOUNT_API["`**:feature:account:api**`"] + FEATURE_ACCOUNT_IMPL["`**:feature:account:impl**`"] + FEATURE_SETTINGS_API["`**:feature:settings:api**`"] + FEATURE_K9["`**:feature:k9OnlyFeature:impl**`"] + FEATURE_TB["`**:feature:tfaOnlyFeature:impl**`"] + end + + subgraph CORE[Core Modules] + direction TB + CORE_UI_API["`**:core:ui:api**`"] + CORE_COMMON_API["`**:core:common:api**`"] + end + + subgraph LIBRARY[Library Modules] + direction TB + LIB_AUTH["`**:library:auth**`"] + LIB_STORAGE["`**:library:storage**`"] + end + + APP_K9 --> |depends on| COMMON_APP + APP_TB --> |depends on| COMMON_APP + COMMON_APP --> |uses| FEATURE_ACCOUNT_API + COMMON_APP --> |injects/uses impl of| FEATURE_ACCOUNT_IMPL + FEATURE_ACCOUNT_IMPL --> FEATURE_ACCOUNT_API + COMMON_APP --> |uses| FEATURE_SETTINGS_API + APP_K9 --> |injects/uses impl of| FEATURE_K9 + APP_TB --> |injects/uses impl of| FEATURE_TB + FEATURE_ACCOUNT_API --> |uses| CORE_UI_API + FEATURE_SETTINGS_API --> |uses| CORE_COMMON_API + FEATURE_TB --> |uses| LIB_AUTH + FEATURE_K9 --> |uses| LIB_STORAGE + CORE_COMMON_API --> |uses| LIB_STORAGE + + classDef app fill:#d9e9ff,stroke:#000000,color:#000000 + classDef app_module fill:#4d94ff,stroke:#000000,color:#000000 + classDef common fill:#e6e6e6,stroke:#000000,color:#000000 + classDef common_module fill:#999999,stroke:#000000,color:#000000 + classDef feature fill:#d9ffd9,stroke:#000000,color:#000000 + classDef feature_module fill:#33cc33,stroke:#000000,color:#000000 + classDef core fill:#e6cce6,stroke:#000000,color:#000000 + classDef core_module fill:#cc99cc,stroke:#000000,color:#000000 + classDef library fill:#fff0d0,stroke:#000000,color:#000000 + classDef library_module fill:#ffaa33,stroke:#000000,color:#000000 + classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000 + classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class APP app + class APP_TB,APP_K9 app_module + class COMMON common + class COMMON_APP common_module + class FEATURE feature + class FEATURE_ACCOUNT_API,FEATURE_ACCOUNT_IMPL,FEATURE_SETTINGS_API,FEATURE_MAIL feature_module + class CORE core + class CORE_UI_API,CORE_COMMON_API core_module + class LIBRARY library + class LIB_AUTH,LIB_STORAGE library_module + + classDef featureK9 fill:#ffcccc,stroke:#cc0000,color:#000000 + classDef featureTB fill:#ccccff,stroke:#0000cc,color:#000000 + class FEATURE_K9 featureK9 + class FEATURE_TB featureTB +``` + diff --git a/docs/architecture/module-structure.md b/docs/architecture/module-structure.md new file mode 100644 index 0000000..80fef1f --- /dev/null +++ b/docs/architecture/module-structure.md @@ -0,0 +1,423 @@ +# 📦 Module Structure + +The Thunderbird for Android project is following a modularization approach, where the codebase is divided into multiple +distinct modules. These modules encapsulate specific functionality and can be developed, tested, and maintained +independently. This modular architecture promotes reusability, scalability, and maintainability of the codebase. + +Each module should be split into two main parts: **API** and **implementation**. This separation provides clear +boundaries between what a module exposes to other modules and how it implements its functionality internally. + +When a feature is complex, it can be further split into sub modules, allowing for better organization and smaller modules +for distinct functionalities within a feature domain. + +This approach promotes: +- **Loose coupling**: Modules interact through well-defined interfaces +- **Interchangeable implementations**: Different implementations can be swapped without affecting consumers +- **Improved build times**: Reduces the scope of recompilation when changes are made +- **Better testability**: Modules can be tested in isolation +- **Clear ownership**: Teams can own specific modules + +### 📝 API Module + +The API module defines the public contract that other modules can depend on. It should be stable, well-documented, and +change infrequently. + +The API module contains: + +- **Public interfaces**: Contracts that define the module's capabilities +- **Data models**: Entities that are part of the public API +- **Constants and enums**: Shared constants and enumeration types +- **Extension functions**: Utility functions that extend public types +- **Navigation definitions**: Navigation routes and arguments + +The API module should be minimal and focused on defining the contract that other modules can depend on. It should not +contain any implementation details. + +#### Naming Convention + +API modules should follow the naming convention: +- `feature::api` for feature modules +- `core::api` for core modules + +#### Example structure for a feature API module: + +```bash +feature:account:api +├── src/main/kotlin/net/thunderbird/feature/account/api +│ ├── AccountManager.kt (interface) +│ ├── Account.kt (entity) +│ ├── AccountNavigation.kt (interface) +│ ├── AccountType.kt (entity) +│ └── AccountExtensions.kt (extension functions) +``` + +#### API Design Guidelines + +When designing APIs, follow these principles: +- **Minimal surface area**: Expose only what is necessary +- **Immutable data**: Use immutable data structures where possible +- **Clear contracts**: Define clear method signatures with documented parameters and return values +- **Error handling**: Define how errors are communicated (exceptions, result types, etc.) + +### ⚙️ Implementation Module + +The implementation module depends on the API module but should not be depended upon by other modules (except for +dependency injection setup). + +The implementation module contains: + +- **Interface implementations**: Concrete implementations of the interfaces defined in the API module +- **Internal components**: Classes and functions used internally +- **Data sources**: Repositories, database access, network clients +- **UI components**: Screens, composables, and ViewModels + +#### Naming Convention + +Implementation modules should follow the naming convention: +- `feature::impl` for standard implementations +- `feature::impl-` for variant-specific implementations +- `core::impl` for core module implementations + +#### Multiple Implementations + +When multiple implementations are needed, such as for different providers or platforms, they can be placed in separate +modules and named accordingly: +- `feature:account:impl-gmail` - Gmail-specific implementation +- `feature:account:impl-yahoo` - Yahoo-specific implementation +- `feature:account:impl-noop` - No-operation implementation for testing + +#### Example structure for a variant implementation: + +```bash +feature:account:impl-gmail +├── src/main/kotlin/app/thunderbird/feature/account/gmail +│ └── GmailAccountManager.kt +``` + +#### Clean Architecture in Implementation Modules + +A complex feature implementation module should apply **Clean Architecture** principles, separating concerns into: + +- **UI Layer**: Compose UI components, ViewModels, and UI state management +- **Domain Layer**: Use cases, domain models, and business logic +- **Data Layer**: Repositories, data sources, and data mapping + +```bash +feature:account:impl +├── src/main/kotlin/app/thunderbird/feature/account/impl +│ ├── data/ +│ │ ├── repository/ +│ │ ├── datasource/ +│ │ └── mapper/ +│ ├── domain/ +│ │ ├── repository/ +│ │ ├── entity/ +│ │ └── usecase/ +│ └── ui/ +│ ├── AccountScreen.kt +│ └── AccountViewModel.kt +``` + +#### Implementation Best Practices + +- **Internal visibility**: Use the `internal` modifier for classes and functions that should not be part of the public API +- **Encapsulation**: Keep implementation details hidden from consumers +- **Testability**: Design implementations to be easily testable +- **Dependency injection**: Use constructor injection for dependencies +- **Error handling**: Implement robust error handling according to API contracts +- **Performance**: Consider performance implications of implementations +- **Logging**: Include appropriate logging for debugging and monitoring + +### 🧪 Testing Module + +Testing modules provide test implementations, utilities, and frameworks for testing other modules. They are essential for ensuring the quality and correctness of the codebase. + +#### Contents + +The testing module contains: + +- **Test utilities**: Helper functions and classes for testing +- **Test frameworks**: Custom test frameworks and extensions +- **Test fixtures**: Reusable test setups and teardowns +- **Test matchers**: Custom matchers for assertions + +#### Naming Convention + +Testing modules should follow the naming convention: +- `feature::testing` for feature-specific test utilities +- `core::testing` for core test utilities +- `:test` for module-specific tests + +#### Example structure for a testing module: + +```bash +feature:account:testing +├── src/main/kotlin/app/thunderbird/feature/account/testing +│ ├── AccountTestUtils.kt +│ └── AccountTestMatchers.kt +``` + +#### Testing Best Practices + +- **Reusability**: Create reusable test utilities and data factories +- **Isolation**: Tests should be isolated and not depend on external systems +- **Readability**: Tests should be easy to read and understand +- **Maintainability**: Tests should be easy to maintain and update +- **Coverage**: Tests should cover all critical paths and edge cases + +### 🤖 Fake Module + +Fake modules provide alternative implementations of interfaces for testing, development, or demonstration purposes. They are simpler than the real implementations and are designed to be used in controlled environments. + +#### Contents + +The fake module contains: + +- **Fake implementations**: Simplified implementations of interfaces +- **Generic test data**: Basic, reusable sample data for testing and demonstration +- **In-memory data stores**: In-memory alternatives to real data stores +- **Controlled behavior**: Implementations with predictable, configurable behavior +- **Test doubles**: Mocks, stubs, and spies for testing + +> [!IMPORTANT] +> Fake modules should be limited to the most generic data and implementations. Specific use cases or test setups should be part of the actual test, not the fake module. + +#### Naming Convention + +Fake modules should follow the naming convention: +- `feature::fake` for feature-specific fake implementations +- `core::fake` for core fake implementations + +#### Example structure for a fake module: + +```bash +feature:account:fake +├── src/main/kotlin/app/thunderbird/feature/account/fake +│ ├── FakeAccountRepository.kt +│ ├── FakeAccountDataSource.kt +│ ├── InMemoryAccountStore.kt +│ ├── FakeAccountManager.kt +│ └── data/ +│ ├── FakeAccountData.kt +│ └── FakeAccountProfileData.kt +``` + +#### Fake Implementation Best Practices + +- **Simplicity**: Fake implementations should be simpler than real implementations +- **Deterministic behavior**: Behavior should be predictable and controllable +- **Configuration**: Allow configuration of behavior for different test scenarios +- **Visibility**: Make internal state visible for testing assertions +- **Performance**: Fake implementations should be fast for testing efficiency +- **Generic test data**: Include basic, reusable test data that can be used across different tests +- **Realistic but generic data**: Test data should be realistic enough to be useful but generic enough to be reused +- **Separation of concerns**: Keep specific test scenarios and edge cases in the actual tests, not in the fake module + +### 🔄 Common Module + +Common modules provide shared functionality that is used by multiple modules within a feature. They contain +implementation details, utilities, and components that need to be shared between related modules but are not part of +the public API. + +#### Contents + +The common module contains: + +- **Shared utilities**: Helper functions and classes used across related modules +- **Internal implementations**: Implementation details shared between modules +- **Shared UI components**: Reusable UI components specific to a feature domain +- **Data repositories**: Shared data storage and access implementations +- **Constants and resources**: Shared constants, strings, and other resources + +#### Naming Convention + +Common modules should follow the naming convention: +- `feature::common` for feature-specific common code +- `core::common` for core common code + +#### Example structure for a common module: + +```bash +feature:account:common +├── src/main/kotlin/net/thunderbird/feature/account/common +│ ├── AccountCommonModule.kt +│ ├── data/ +│ │ └── InMemoryAccountStateRepository.kt +│ ├── domain/ +│ │ ├── AccountDomainContract.kt +│ │ ├── input/ +│ │ │ └── NumberInputField.kt +│ │ └── entity/ +│ │ ├── AccountState.kt +│ │ ├── AccountDisplayOptions.kt +│ │ └── AuthorizationState.kt +│ └── ui/ +│ ├── WizardNavigationBar.kt +│ └── WizardNavigationBarState.kt +``` + +#### Common Module Best Practices + +- **Internal visibility**: Use the `internal` modifier for classes and functions that should not be part of the public API +- **Clear organization**: Organize code into data, domain, and UI packages for better maintainability +- **Shared contracts**: Define clear interfaces for functionality that will be implemented by multiple modules +- **Reusable components**: Create UI components that can be reused across different screens within a feature +- **Stateless where possible**: Design components to be stateless and receive state through parameters +- **Minimal dependencies**: Keep dependencies to a minimum to avoid transitive dependency issues +- **Documentation**: Document the purpose and usage of shared components +- **Avoid leaking implementation details**: Don't expose implementation details that could create tight coupling + +## 🔗 Module Dependencies + +The module dependency diagram below illustrates how different modules interact with each other in the project, showing +the dependencies and integration points between modules. + +```mermaid +graph TB + subgraph APP[App Modules] + direction TB + APP_TB["`**:app-thunderbird**
Thunderbird for Android`"] + APP_K9["`**:app-k9mail**
K-9 Mail`"] + end + + subgraph COMMON[App Common Module] + direction TB + COMMON_APP["`**:app-common**
Integration Code`"] + end + + subgraph FEATURE[Feature] + direction TB + FEATURE1[feature:account:api] + FEATURE2[feature:account:impl] + FEATURE3[Feature 2] + FEATURE_K9[Feature K-9 Only] + FEATURE_TB[Feature TfA Only] + end + + subgraph CORE[Core] + direction TB + CORE1[Core 1] + CORE2[Core 2] + end + + subgraph LIBRARY[Library] + direction TB + LIB1[Library 1] + LIB2[Library 2] + end + + APP_K9 --> |depends on| COMMON_APP + APP_TB --> |depends on| COMMON_APP + COMMON_APP --> |integrates| FEATURE1 + COMMON_APP --> |injects| FEATURE2 + FEATURE2 --> FEATURE1 + COMMON_APP --> |integrates| FEATURE3 + APP_K9 --> |integrates| FEATURE_K9 + APP_TB --> |integrates| FEATURE_TB + FEATURE1 --> |uses| CORE1 + FEATURE3 --> |uses| CORE2 + FEATURE_TB --> |uses| CORE1 + FEATURE_K9 --> |uses| LIB2 + CORE2 --> |uses| LIB1 + + classDef app fill:#d9e9ff,stroke:#000000,color:#000000 + classDef app_module fill:#4d94ff,stroke:#000000,color:#000000 + classDef common fill:#e6e6e6,stroke:#000000,color:#000000 + classDef common_module fill:#999999,stroke:#000000,color:#000000 + classDef feature fill:#d9ffd9,stroke:#000000,color:#000000 + classDef feature_module fill:#33cc33,stroke:#000000,color:#000000 + classDef core fill:#e6cce6,stroke:#000000,color:#000000 + classDef core_module fill:#cc99cc,stroke:#000000,color:#000000 + classDef library fill:#fff0d0,stroke:#000000,color:#000000 + classDef library_module fill:#ffaa33,stroke:#000000,color:#000000 + classDef legacy fill:#ffe6e6,stroke:#000000,color:#000000 + classDef legacy_module fill:#ff9999,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class APP app + class APP_K9,APP_TB app_module + class COMMON common + class COMMON_APP common_module + class FEATURE feature + class FEATURE1,FEATURE2,FEATURE3 feature_module + class FEATURE_K9 featureK9 + class FEATURE_TB featureTB + class CORE core + class CORE1,CORE2 core_module + class LIBRARY library + class LIB1,LIB2 library_module +``` + +### Module Interaction Patterns + +- **App Modules**: Depend on the App Common module for shared functionality and selectively integrate feature modules +- **App Common**: Integrates various feature modules to provide a cohesive application +- **Feature Modules**: Use core modules and libraries for their implementation, may depend on other feature API modules +- **App-Specific Features**: Some features are integrated directly by specific apps (K-9 Mail or Thunderbird) + +### Dependency Rules + +These rules must be strictly followed: + +1. **One-Way Dependencies**: + - Modules should not depend on each other in a circular manner + - Dependencies should form a directed acyclic graph (DAG) +2. **API-Implementation Separation**: + - Modules should depend only on API modules, not implementation modules + - Implementation modules should be referenced only in dependency injection setup +3. **Feature Integration**: + - Features should be integrated through the App Common module, which acts as a central hub + - Direct dependencies between feature implementations should be avoided, or limited to API modules +4. **Dependency Direction**: + - Dependencies should flow from app modules to common, then to features, and finally to core and libraries + - Higher-level modules should depend on lower-level modules, not vice versa +5. **Minimal Dependencies**: + - Each module should have the minimal set of dependencies required + - Avoid unnecessary dependencies that could lead to bloat + +### Dependency Management + +- **Explicit Dependencies**: All dependencies should be explicitly declared in the module's build file +- **Transitive Dependencies**: Avoid relying on transitive dependencies +- **Version Management**: Use centralized version management for dependencies +- **Dependency Visibility**: Use appropriate visibility modifiers to limit access to implementation details + +### Dependency Injection + +- Use Koin for dependency injection +- Configure module dependencies in dedicated Koin modules +- Inject API interfaces, not implementation classes +- Use lazy injection where appropriate to improve startup performance + +## 📏 Module Granularity + +Determining the right granularity for modules is crucial for maintainability and scalability. This section provides +guidelines on when to create new modules and how to structure them. + +### When to Create a New Module + +Create a new module when: + +1. **Distinct Functionality**: The code represents a distinct piece of functionality with clear boundaries +2. **Reusability**: The functionality could be reused across multiple features or applications +3. **Build Performance**: Breaking down large modules improves build performance +4. **Testing**: Isolation improves testability + +### When to Split a Module + +Split an existing module when: + +1. **Size**: The module has grown too large (>10,000 lines of code as a rough guideline) +2. **Complexity**: The module has become too complex with many responsibilities +3. **Dependencies**: The module has too many dependencies +4. **Build Time**: The module takes too long to build + +### When to Keep Modules Together + +Keep functionality in the same module when: + +1. **Cohesion**: The functionality is highly cohesive and tightly coupled +2. **Small Size**: The functionality is small and simple +3. **Single Responsibility**: The functionality represents a single responsibility + diff --git a/docs/architecture/theme-system.md b/docs/architecture/theme-system.md new file mode 100644 index 0000000..b0f2747 --- /dev/null +++ b/docs/architecture/theme-system.md @@ -0,0 +1,688 @@ +# 🎭 Theming + +This document provides a detailed explanation of the theming system used in our applications. It covers the theme +architecture, components, customization, and usage. + +- **✨ Material Design 3**: Based on Material Design 3 principles +- **🎨 Colors**: Custom color schemes with light and dark modes + - **🌓 Dark Mode**: Full support for light and dark themes + - **🌈 Dynamic Color**: Support for dynamic color based on system settings +- **🪜 Elevations**: Consistent elevation system for shadows +- **🖼️ Images**: Images and icons consistent with the theme +- **🔶 Shapes**: Customizable shape system for components +- **📐 Sizes**: Standardized sizes for components +- **📏 Spacings**: Consistent spacing system for layout +- **🅰️ Typography**: Consistent typography system + +## 📱 Theme Architecture + +Our theme architecture is designed with several key principles in mind: + +1. **Consistency**: Provide a unified look and feel across all applications while allowing for brand-specific customization +2. **Flexibility**: Support different visual identities for different applications (Thunderbird, K-9 Mail) using the same underlying system +3. **Extensibility**: Enable easy addition of new theme components or modification of existing ones +4. **Maintainability**: Centralize theme definitions to simplify updates and changes +5. **Material Design Compatibility**: Build on top of Material Design 3 while extending it with our specific needs + +The theming system follows a hierarchical structure: + +```mermaid +graph TD + subgraph APP_THEMES["App-Specific Themes"] + TB_THEME[ThunderbirdTheme2] + K9_THEME[K9MailTheme2] + end + + subgraph MAIN["Main Theme"] + MAIN_THEME[MainTheme] + THEME_CONFIG[ThemeConfig] + end + + subgraph MATERIAL["Material Design 3"] + MAT_THEME[MaterialTheme] + end + + TB_THEME --> |uses| MAIN_THEME + TB_THEME --> |defines| THEME_CONFIG + K9_THEME --> |uses| MAIN_THEME + K9_THEME --> |defines| THEME_CONFIG + THEME_CONFIG --> |configures| MAIN_THEME + MAIN_THEME --> |wraps| MAT_THEME + + classDef app_theme fill:#d9ffd9,stroke:#000000,color:#000000 + classDef main_theme fill:#d9e9ff,stroke:#000000,color:#000000 + classDef material fill:#ffe6cc,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class TB_THEME,K9_THEME app_theme + class MAIN_THEME,THEME_CONFIG main_theme + class MAT_THEME material +``` + +### 🏗️ Architecture Layers + +The theme system consists of three main layers: + +1. **App-Specific Themes Layer**: The top layer contains theme implementations for specific applications (ThunderbirdTheme2, K9MailTheme2). Each app theme: + - Defines its own brand colors, logos, and other app-specific visual elements + - Creates a ThemeConfig with these customizations + - Uses the MainTheme as its foundation +2. **Main Theme Layer**: The middle layer provides our extended theming system: + - MainTheme: A composable function that sets up the theme environment + - ThemeConfig: A data class that holds all theme components + - This layer extends Material Design with additional components like custom spacings, elevations, and app-specific colors +3. **Material Design Layer**: The foundation layer is Material Design 3: + - Provides the base theming system (colors, typography, shapes) + - Ensures compatibility with standard Material components + - Our MainTheme wraps MaterialTheme and converts our theme components to Material 3 format when needed + +### 🔄 Data Flow + +The theme data flows through the system as follows: + +1. App-specific themes (ThunderbirdTheme2, K9MailTheme2) define their visual identity through a ThemeConfig +2. ThemeConfig is passed to MainTheme, which: + - Selects the appropriate color scheme based on dark/light mode + - Configures system bars (status bar, navigation bar) + - Provides all theme components through CompositionLocal providers + - Converts our theme components to Material 3 format and configures MaterialTheme +3. Composables access theme properties through the MainTheme object +4. Material components automatically use the Material 3 theme derived from our theme + +### 🌟 Benefits + +This architecture provides several benefits: + +- **Separation of Concerns**: Each layer has a specific responsibility +- **Code Reuse**: Common theme logic is shared between applications +- **Customization**: Each application can have its own visual identity +- **Consistency**: All applications share the same theming structure and components +- **Extensibility**: New theme components can be added without changing the overall architecture +- **Compatibility**: Works with both our custom components and standard Material components + +## 🧩 Theme Components + +The theming system consists of several components that work together to provide a comprehensive and consistent visual experience across the application. Each component is responsible for a specific aspect of the UI design. + +### 🔧 ThemeConfig + +The `ThemeConfig` is the central configuration class that holds all theme components. It serves as a container for all theme-related settings and is passed to the `MainTheme` composable. + +```kotlin +data class ThemeConfig( + val colors: ThemeColorSchemeVariants, + val elevations: ThemeElevations, + val images: ThemeImageVariants, + val shapes: ThemeShapes, + val sizes: ThemeSizes, + val spacings: ThemeSpacings, + val typography: ThemeTypography, +) +``` + +The `ThemeConfig` allows for: +- Centralized management of all theme components +- Easy switching between light and dark themes +- Simplified theme customization for different applications +- Consistent theme application throughout the app + +### 🎨 ThemeColorScheme + +The `ThemeColorScheme` defines all colors used in the application. It extends Material Design 3's color system with additional colors specific to our applications. + +```kotlin +data class ThemeColorScheme( + // Material 3 colors + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val tertiary: Color, + val onTertiary: Color, + val tertiaryContainer: Color, + val onTertiaryContainer: Color, + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, + val surfaceDim: Color, + val surface: Color, + val surfaceBright: Color, + val onSurface: Color, + val onSurfaceVariant: Color, + val surfaceContainerLowest: Color, + val surfaceContainerLow: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val inverseSurface: Color, + val inverseOnSurface: Color, + val inversePrimary: Color, + val outline: Color, + val outlineVariant: Color, + val scrim: Color, + + // Extra colors + val info: Color, + val onInfo: Color, + val infoContainer: Color, + val onInfoContainer: Color, + val success: Color, + val onSuccess: Color, + val successContainer: Color, + val onSuccessContainer: Color, + val warning: Color, + val onWarning: Color, + val warningContainer: Color, + val onWarningContainer: Color, +) +``` + +The color scheme is organized into: +- **Base colors**: Primary, secondary, and tertiary colors that define the app's brand identity +- **Surface colors**: Colors for backgrounds, cards, and other surfaces +- **Content colors**: Colors for text and icons that appear on various backgrounds (prefixed with "on") +- **Container colors**: Colors for containers like buttons, chips, and other interactive elements +- **Utility colors**: Colors for specific purposes like errors, outlines, and scrims + +Colors are provided in variants for both light and dark themes through the `ThemeColorSchemeVariants` class: + +```kotlin +data class ThemeColorSchemeVariants( + val light: ThemeColorScheme, + val dark: ThemeColorScheme, +) +``` + +### 🪜 ThemeElevations + +The `ThemeElevations` component defines standard elevation values used throughout the application to create a consistent sense of depth and hierarchy. + +```kotlin +data class ThemeElevations( + val level0: Dp, + val level1: Dp, + val level2: Dp, + val level3: Dp, + val level4: Dp, + val level5: Dp, +) +``` + +Typical usage includes: +- **level0**: For elements that are flush with their background (0dp) +- **level1**: For subtle elevation like dividers (1dp) +- **level2**: For cards, buttons in their resting state (3dp) +- **level3**: For floating action buttons, navigation drawers (6dp) +- **level4**: For dialogs, bottom sheets (8dp) +- **level5**: For modal surfaces that should appear prominently (12dp) + +### 🖼️ ThemeImages + +The `ThemeImages` component stores references to app-specific images like logos, icons, and illustrations. + +```kotlin +data class ThemeImages( + val logo: Int, // Resource ID + // ... other image resources +) +``` + +These images can have light and dark variants through the `ThemeImageVariants` class: + +```kotlin +data class ThemeImageVariants( + val light: ThemeImages, + val dark: ThemeImages, +) +``` + +### 🔶 ThemeShapes + +The `ThemeShapes` component defines the corner shapes used for UI elements throughout the application. + +```kotlin +data class ThemeShapes( + val extraSmall: CornerBasedShape, + val small: CornerBasedShape, + val medium: CornerBasedShape, + val large: CornerBasedShape, + val extraLarge: CornerBasedShape, +) +``` + +These shapes are used for: +- **extraSmall**: Subtle rounding for elements like text fields (4dp) +- **small**: Light rounding for cards, buttons (8dp) +- **medium**: Moderate rounding for floating elements (12dp) +- **large**: Significant rounding for prominent elements (16dp) +- **extraLarge**: Very rounded corners for special elements (28dp) + +Note: For no rounding (0% corner radius), use `RectangleShape`. For completely rounded corners (50% corner radius) for circular elements, use `CircleShape`. + +The `ThemeShapes` can be converted to Material 3 shapes using the `toMaterial3Shapes()` method for compatibility with Material components. + +### 📐 ThemeSizes + +The `ThemeSizes` component defines standard size values for UI elements to ensure consistent sizing throughout the application. + +```kotlin +data class ThemeSizes( + val smaller: Dp, + val small: Dp, + val medium: Dp, + val large: Dp, + val larger: Dp, + val huge: Dp, + val huger: Dp, + + val iconSmall: Dp, + val icon: Dp, + val iconLarge: Dp, + val iconAvatar: Dp, + + val topBarHeight: Dp, + val bottomBarHeight: Dp, + val bottomBarHeightWithFab: Dp, +) +``` + +These sizes are used for: +- **General sizes**: `smaller`, `small`, `medium`, `large`, `larger`, `huge`, `huger` for component dimensions (width, height), button heights, and other UI element dimensions that need standardization +- **Icon sizes**: `iconSmall`, `icon`, `iconLarge` for different icon sizes throughout the app +- **Avatar size**: `iconAvatar` for user avatars and profile pictures +- **Layout sizes**: `topBarHeight`, `bottomBarHeight`, `bottomBarHeightWithFab` for consistent app bar and navigation bar heights + +### 📏 ThemeSpacings + +The `ThemeSpacings` component defines standard spacing values used for margins, padding, and gaps between elements. + +```kotlin +data class ThemeSpacings( + val zero: Dp, + val quarter: Dp, + val half: Dp, + val default: Dp, + val oneHalf: Dp, + val double: Dp, + val triple: Dp, + val quadruple: Dp, +) +``` + +Consistent spacing helps create a rhythmic and harmonious layout: +- **zero**: No spacing (0dp) +- **quarter**: Quarter of the default spacing, for very tight layouts (4dp) +- **half**: Half of the default spacing, for tight layouts (8dp) +- **default**: The standard spacing unit for general use (16dp) +- **oneHalf**: One and a half times the default spacing (24dp) +- **double**: Twice the default spacing, for separating sections (32dp) +- **triple**: Three times the default spacing, for major layout divisions (48dp) +- **quadruple**: Four times the default spacing, for maximum separation (64dp) + +### 🅰️ ThemeTypography + +The `ThemeTypography` component defines text styles for different types of content throughout the application. + +```kotlin +data class ThemeTypography( + // Display styles for large headlines + val displayLarge: TextStyle, + val displayMedium: TextStyle, + val displaySmall: TextStyle, + + // Headline styles for section headers + val headlineLarge: TextStyle, + val headlineMedium: TextStyle, + val headlineSmall: TextStyle, + + // Title styles for content titles + val titleLarge: TextStyle, + val titleMedium: TextStyle, + val titleSmall: TextStyle, + + // Body styles for main content + val bodyLarge: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + + // Label styles for buttons and small text + val labelLarge: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle, +) +``` + +Each `TextStyle` includes: +- Font family +- Font weight +- Font size +- Line height +- Letter spacing +- Other typographic attributes + +The `ThemeTypography` can be converted to Material 3 typography using the `toMaterial3Typography()` method for compatibility with Material components. + +### ↔️ Component Interaction + +These theme components work together to create a cohesive design system: + +1. **ThemeConfig** aggregates all components and provides them to the `MainTheme` +2. **MainTheme** makes components available through `CompositionLocal` providers +3. Composables access theme components through the `MainTheme` object +4. Components like `ThemeColorScheme` and `ThemeShapes` are converted to Material 3 equivalents for use with Material components + +This structured approach ensures consistent design application throughout the app while providing flexibility for customization. + +## 🌟 MainTheme + +The `MainTheme` is the foundation of our theming system: + +- Acts as a wrapper around Material Design 3's `MaterialTheme` +- Provides additional theme components beyond what Material Design offers +- Configurable through a `ThemeConfig` parameter +- Supports dark mode and dynamic color +- Exposes theme components through the `MainTheme` object + +### 🔌 Theme Provider Implementation and Usage + +#### 🛠️ How the Theme Provider Works + +The `MainTheme` function uses Jetpack Compose's `CompositionLocalProvider` to make theme components available throughout the composition tree: + +```kotlin +@Composable +fun MainTheme( + themeConfig: ThemeConfig, + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val themeColorScheme = selectThemeColorScheme( + themeConfig = themeConfig, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + ) + val themeImages = selectThemeImages( + themeConfig = themeConfig, + darkTheme = darkTheme, + ) + + SystemBar( + darkTheme = darkTheme, + colorScheme = themeColorScheme, + ) + + CompositionLocalProvider( + LocalThemeColorScheme provides themeColorScheme, + LocalThemeElevations provides themeConfig.elevations, + LocalThemeImages provides themeImages, + LocalThemeShapes provides themeConfig.shapes, + LocalThemeSizes provides themeConfig.sizes, + LocalThemeSpacings provides themeConfig.spacings, + LocalThemeTypography provides themeConfig.typography, + ) { + MaterialTheme( + colorScheme = themeColorScheme.toMaterial3ColorScheme(), + shapes = themeConfig.shapes.toMaterial3Shapes(), + typography = themeConfig.typography.toMaterial3Typography(), + content = content, + ) + } +} +``` + +Each theme component is provided through a `CompositionLocal` that makes it available to all composables in the composition tree. These `CompositionLocal` values are defined using `staticCompositionLocalOf` in their respective files: + +```kotlin +internal val LocalThemeColorScheme = staticCompositionLocalOf { + error("No ThemeColorScheme provided") +} + +internal val LocalThemeElevations = staticCompositionLocalOf { + error("No ThemeElevations provided") +} + +// ... other LocalTheme* definitions +``` + +The `MainTheme` object provides properties to access these values from anywhere in the composition tree: + +```kotlin +object MainTheme { + val colors: ThemeColorScheme + @Composable + @ReadOnlyComposable + get() = LocalThemeColorScheme.current + + val elevations: ThemeElevations + @Composable + @ReadOnlyComposable + get() = LocalThemeElevations.current + + // ... other properties +} +``` + +This theme provider mechanism ensures that theme components are available throughout the app without having to pass them as parameters to every composable. + +## 🎭 App-Specific Themes + +The app-specific themes (`ThunderbirdTheme2` and `K9MailTheme2`) customize the `MainTheme` for each application: + +- Provide app-specific color schemes +- Include app-specific assets (like logos) +- Configure theme components through `ThemeConfig` +- Use default values for common components (elevations, sizes, spacings, shapes, typography) + +### ThunderbirdTheme2 + +```kotlin +@Composable +fun ThunderbirdTheme2( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val images = ThemeImages( + logo = R.drawable.core_ui_theme2_thunderbird_logo, + ) + + val themeConfig = ThemeConfig( + colors = ThemeColorSchemeVariants( + dark = darkThemeColorScheme, + light = lightThemeColorScheme, + ), + elevations = defaultThemeElevations, + images = ThemeImageVariants( + light = images, + dark = images, + ), + sizes = defaultThemeSizes, + spacings = defaultThemeSpacings, + shapes = defaultThemeShapes, + typography = defaultTypography, + ) + + MainTheme( + themeConfig = themeConfig, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + content = content, + ) +} +``` + +### K9MailTheme2 + +```kotlin +@Composable +fun K9MailTheme2( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val images = ThemeImages( + logo = R.drawable.core_ui_theme2_k9mail_logo, + ) + + val themeConfig = ThemeConfig( + colors = ThemeColorSchemeVariants( + dark = darkThemeColorScheme, + light = lightThemeColorScheme, + ), + elevations = defaultThemeElevations, + images = ThemeImageVariants( + light = images, + dark = images, + ), + sizes = defaultThemeSizes, + spacings = defaultThemeSpacings, + shapes = defaultThemeShapes, + typography = defaultTypography, + ) + + MainTheme( + themeConfig = themeConfig, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + content = content, + ) +} +``` + +## 🎨 Using Themes in the App + +### 🧩 Applying a Theme + +To apply a theme to your UI, wrap your composables with the appropriate theme composable: + +```kotlin +// For Thunderbird app +@Composable +fun ThunderbirdApp() { + ThunderbirdTheme2 { + // App content + } +} + +// For K9Mail app +@Composable +fun K9MailApp() { + K9MailTheme2 { + // App content + } +} +``` + +### 🔑 Accessing Theme Components + +Inside themed content, you can access theme properties through the `MainTheme` object: + +```kotlin +@Composable +fun ThemedButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.buttonColors( + containerColor = MainTheme.colors.primary, + contentColor = MainTheme.colors.onPrimary, + ), + shape = MainTheme.shapes.medium, + ) { + Text( + text = text, + style = MainTheme.typography.labelLarge, + ) + } +} +``` + +## 🌓 Dark Mode and Dynamic Color + +The theming system supports both dark mode and dynamic color: + +- **Dark Mode**: Automatically applies the appropriate color scheme based on the system's dark mode setting +- **Dynamic Color**: Optionally uses the device's wallpaper colors for the theme (Android 12+) + +```kotlin +@Composable +fun ThunderbirdTheme2( + darkTheme: Boolean = isSystemInDarkTheme(), // Default to system setting + dynamicColor: Boolean = false, // Disabled by default + content: @Composable () -> Unit, +) { + // ... +} +``` + +## 🔧 Customizing Themes + +To customize a theme, you can create a new theme composable that wraps `MainTheme` with your custom `ThemeConfig`: + +```kotlin +@Composable +fun CustomTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val images = ThemeImages( + logo = R.drawable.custom_logo, + ) + + val themeConfig = ThemeConfig( + colors = ThemeColorSchemeVariants( + dark = customDarkThemeColorScheme, + light = customLightThemeColorScheme, + ), + elevations = customThemeElevations, + images = ThemeImageVariants( + light = images, + dark = images, + ), + sizes = customThemeSizes, + spacings = customThemeSpacings, + shapes = customThemeShapes, + typography = customTypography, + ) + + MainTheme( + themeConfig = themeConfig, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + content = content, + ) +} +``` + +## 🧪 Testing with Themes + +When writing tests for composables that use theme components, you need to wrap them in a theme: + +```kotlin +@Test +fun testThemedButton() { + composeTestRule.setContent { + ThunderbirdTheme2 { + ThemedButton( + text = "Click Me", + onClick = {}, + ) + } + } + + composeTestRule.onNodeWithText("Click Me").assertExists() +} +``` + diff --git a/docs/architecture/ui-architecture.md b/docs/architecture/ui-architecture.md new file mode 100644 index 0000000..5118f12 --- /dev/null +++ b/docs/architecture/ui-architecture.md @@ -0,0 +1,1302 @@ +# 🎨 UI Architecture + +The UI is built using Jetpack Compose with a component-based architecture following a modified Model-View-Intent (MVI) pattern. While we refer to it as MVI, our implementation uses "Events" instead of "Intents" for user interactions and "Actions" for use case calls. This architecture provides a unidirectional data flow, clear separation of concerns, and improved testability. + +## 📱 Component Hierarchy + +The UI components are organized in a hierarchical structure: + +```mermaid +graph TD + subgraph UI_ARCHITECTURE["UI Architecture"] + SCREENS[Screens] + COMPONENTS[Components] + DESIGN[Design System Components] + THEME[Theme] + end + + SCREENS --> COMPONENTS + COMPONENTS --> DESIGN + DESIGN --> THEME + + classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000 + classDef screen fill:#99ccff,stroke:#000000,color:#000000 + classDef component fill:#99ff99,stroke:#000000,color:#000000 + classDef design fill:#ffcc99,stroke:#000000,color:#000000 + classDef theme fill:#ffff99,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class UI_ARCHITECTURE ui_layer + class SCREENS screen + class COMPONENTS component + class DESIGN design + class THEME theme +``` + +### 🖥️ Screens + +- Top-level composables that represent a full screen in the application +- Typically associated with a specific route in the navigation graph +- Responsible for orchestrating components and managing screen-level state +- Connected to ViewModels that handle interaction logic and state management + +Example: + +```kotlin +@Composable +fun AccountSettingsScreen( + viewModel: AccountSettingsViewModel = koinViewModel(), + onNavigateNext: () -> Unit, + onNavigateBack: () -> Unit, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + AccountSettingsEffect.NavigateNext -> onNavigateNext() + AccountSettingsEffect.NavigateBack -> onNavigateBack() + } + } + + AccountSettingsContent( + state = state.value, + onEvent = dispatch, + ) +} +``` + +### 🧩 Components + +- Reusable UI elements that encapsulate specific functionality +- Can be composed of multiple smaller components +- Follow a clear input-output model with immutable state passed in and events emitted out +- Designed to be reusable across different screens + +Example: + +```kotlin +@Composable +fun AccountSettingsContent( + state: AccountSettingsState, + onEvent: (AccountSettingsEvent) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.account_settings_title), + onNavigateBack = { onEvent(AccountSettingsEvent.BackClicked) }, + ) + }, + ) { + when { + state.isLoading -> LoadingIndicator() + state.error != null -> ErrorView( + message = state.error, + onRetryClicked = { onEvent(AccountSettingsEvent.RetryClicked) } + ) + state.settings != null -> AccountSettingsForm( + settings = state.settings, + onSettingChanged = { setting, value -> + onEvent(AccountSettingsEvent.SettingChanged(setting, value)) + }, + onSaveClicked = { onEvent(AccountSettingsEvent.SaveClicked) } + ) + } + } +} +``` + +### 🎨 Design System Components + +- Foundational UI elements that implement the design system +- Consistent visual language across the application +- Encapsulate styling, theming, and behavior from Material Design 3 +- Located in the `core:ui:compose:designsystem` module for reuse across features +- Built using the [Atomic Design Methodology](design-system.md) + +Example: + +```kotlin +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + buttonStyle: ButtonStyle = ButtonStyle.Primary, +) { + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = buttonStyle.colors(), + shape = MaterialTheme.shapes.medium, + ) { + Text(text = text) + } +} +``` + +### 🎭 Theme + +- Defines colors, typography, shapes, and other design tokens +- Supports light and dark modes +- Provides consistent visual appearance across the application +- Implemented using Material Design 3 theming system +- Located in the `core:ui:compose:theme2` module for reuse across features +- Provides a `ThunderbirdTheme2` and a `K9MailTheme2` composable that wraps the MaterialTheme with custom color schemes, typography, and shapes +- Uses Jetpack Compose's `CompositionLocalProvider` as a theme provider to make theme components available throughout the app + +For a more detailed explanation of the theming system, including the theme provider implementation, see +[Theme System](theme-system.md). + +## 📊 Unidirectional Data Flow + +The UI architecture follows a unidirectional data flow pattern, which is a fundamental concept that ensures data moves +in a single, well-defined direction throughout the application. This architectural approach creates a predictable and +maintainable system by enforcing a strict flow of information. + +### 🔄 What is Unidirectional Data Flow? + +Unidirectional data flow is a design pattern where: + +1. Data travels in one direction only +2. State changes are predictable and traceable +3. Components have clear, single responsibilities +4. The UI is a pure function of the application state + +In our implementation, the flow follows this cycle: + +1. **User Interaction**: The user interacts with the UI (e.g., clicks a button) +2. **Event Dispatch**: The UI captures this interaction as an Event and dispatches it to the ViewModel +3. **Event Processing**: The ViewModel processes the Event and determines what Action to take +4. **Action Execution**: The ViewModel executes an Action, typically by calling a Use Case +5. **Domain Logic**: The Use Case performs business logic, often involving repositories +6. **Result Return**: The Use Case returns a Result to the ViewModel +7. **State Update**: The ViewModel updates the State based on the Result +8. **UI Rendering**: The UI observes the State change and re-renders accordingly +9. **Effect Handling**: For one-time actions like navigation, the ViewModel emits an Effect that the UI handles + +This cycle ensures that data flows in a single direction: UI → ViewModel → Domain → ViewModel → UI. + +```mermaid +flowchart LR + User([User]) --> |Interaction| UI + UI --> |Event| ViewModel + ViewModel --> |Action| Domain + Domain --> |Result| ViewModel + ViewModel --> |State| UI + ViewModel --> |Effect| UI + UI --> |Render| User +``` + +### 🌟 Benefits of Unidirectional Data Flow + +Unidirectional data flow provides numerous advantages over bidirectional or unstructured data flow patterns: + +1. **Predictability**: Since data flows in only one direction, the system behavior becomes more predictable and easier to reason about. + +2. **Debugging**: Tracing issues becomes simpler because you can follow the data flow from source to destination without worrying about circular dependencies. + +3. **State Management**: With a single source of truth (the ViewModel's state), there's no risk of inconsistent state across different parts of the application. + +4. **Testability**: Each component in the flow can be tested in isolation with clear inputs and expected outputs. + +5. **Separation of Concerns**: Each component has a well-defined responsibility: + + - UI: Render state and capture user interactions + - ViewModel: Process events, update state, and emit effects + - Domain: Execute business logic +6. **Scalability**: The pattern scales well as the application grows because new features can follow the same consistent pattern. +7. **Maintainability**: Code is easier to maintain because changes in one part of the flow don't unexpectedly affect other parts. +8. **Concurrency**: Reduces race conditions and timing issues since state updates happen in a controlled, sequential manner. + +We leverage unidirectional data flow in our MVI implementation to ensure that the UI remains responsive, predictable, +and easy to test. + +## 🔄 Model-View-Intent (MVI) + +The UI layer follows the Model-View-Intent (MVI) pattern (with our Events/Effects/Actions adaptation as noted above), which +provides a unidirectional data flow and clear separation between UI state and UI logic. + +```mermaid +graph LR + subgraph UI[UI Layer] + VIEW[View] + VIEW_MODEL[ViewModel] + end + + subgraph DOMAIN[Domain Layer] + USE_CASE[Use Cases] + end + + VIEW --> |Events| VIEW_MODEL + VIEW_MODEL --> |State| VIEW + VIEW_MODEL --> |Effects| VIEW + VIEW_MODEL --> |Actions| USE_CASE + USE_CASE --> |Results| VIEW_MODEL + + classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000 + classDef view fill:#7fd3e0,stroke:#000000,color:#000000 + classDef view_model fill:#cc99ff,stroke:#000000,color:#000000 + classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 + classDef use_case fill:#99ffcc,stroke:#000000,color:#000000 + + linkStyle default stroke:#999,stroke-width:2px + + class UI ui_layer + class VIEW view + class VIEW_MODEL view_model + class DOMAIN domain_layer + class USE_CASE use_case +``` + +**Key components**: +- **👁️ [View](ui-architecture.md#-view)**: Renders the UI based on the current state and sends user events to the ViewModel +- **🧠 [ViewModel](ui-architecture.md#-viewmodel)**: Processes user events, converting them into actions and sending them to the Domain Layer. It also maps the results to a state and sends state updates to the UI. +- **🧪 [Use Cases](ui-architecture.md#-use-cases)**: Encapsulate business logic and interact with repositories to perform data operations. They return results to the ViewModel, which updates the state. + +**Unidirectional Data flow**: +- **📊 [State](ui-architecture.md#-state)**: Immutable representation of the UI state. States are the single source of truth for the UI and represent everything that can be displayed on the screen. +- **🎮 [Events](ui-architecture.md#-events)**: User interactions or system events that are passed to the ViewModel to be processed. Events trigger state changes or side effects. +- **🔔 [Effects](ui-architecture.md#-effects)**: One-time side effects that don't belong in the state, such as navigation actions, showing toasts, etc. +- **⚡ [Actions](ui-architecture.md#-actions)**: Operations triggered by the ViewModel to interact with the domain layer. +- **📊 [Results](ui-architecture.md#-results)**: Responses from the domain layer that are processed by the ViewModel to update the state. + +### 🧩 Components + +The MVI architecture is implemented using the following components: + +#### 👁️ View + +- Represents the UI layer in the MVI pattern +- Composed of Jetpack Compose components (Screens, Components, etc.) +- Responsible for rendering the UI state and capturing user interactions +- Sends events to the ViewModel and receives state updates +- Purely presentational with no business logic + +In our architecture, the View is implemented using Jetpack Compose and consists of: + +1. **Screen Composables**: Top-level composables that represent a full screen +2. **Content Composables**: Composables that render the UI based on the state +3. **Component Composables**: Reusable UI elements + +Example of a View implementation: + +```kotlin +// Screen Composable (part of the View) +@Composable +internal fun AccountSettingsScreen( + onNavigateNext: () -> Unit, + onNavigateBack: () -> Unit, + viewModel: AccountSettingsViewModel = koinViewModel(), +) { + // Observe state and handle effects + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + AccountSettingsEffect.NavigateNext -> onNavigateNext() + AccountSettingsEffect.NavigateBack -> onNavigateBack() + } + } + + // Content Composable (also part of the View) + AccountSettingsContent( + state = state.value, + onEvent = dispatch, + ) +} + +// Content Composable (part of the View) +@Composable +private fun AccountSettingsContent( + state: AccountSettingsState, + onEvent: (AccountSettingsEvent) -> Unit, +) { + // Render UI based on state + when { + state.isLoading -> LoadingIndicator() + state.error != null -> ErrorView( + message = state.error, + onRetryClicked = { onEvent(AccountSettingsEvent.RetryClicked) } + ) + state.settings != null -> AccountSettingsForm( + settings = state.settings, + onSettingChanged = { setting, value -> + onEvent(AccountSettingsEvent.SettingChanged(setting, value)) + }, + onSaveClicked = { onEvent(AccountSettingsEvent.SaveClicked) } + ) + } +} +``` + +The View is responsible for: +- Rendering the UI based on the current state +- Capturing user interactions and converting them to events +- Sending events to the ViewModel +- Handling side effects (like navigation) +- Maintaining a clear separation from business logic + +#### 🧠 ViewModel + +- Acts as the mediator between the View and the Domain layer +- Processes events from the View and updates state +- Coordinates with use cases for business logic +- Exposes state as a StateFlow for the View to observe +- Emits side effects for one-time actions like navigation + +The ViewModel is implemented using the `BaseViewModel` class, which provides the core functionality for the MVI pattern: + +```kotlin +abstract class BaseViewModel( + initialState: STATE, +) : ViewModel(), + UnidirectionalViewModel { + + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + override val effect: SharedFlow = _effect.asSharedFlow() + + /** + * Updates the [STATE] of the ViewModel. + */ + protected fun updateState(update: (STATE) -> STATE) { + _state.update(update) + } + + /** + * Emits a side effect. + */ + protected fun emitEffect(effect: EFFECT) { + viewModelScope.launch { + _effect.emit(effect) + } + } +} +``` + +Example of a ViewModel implementation: + +```kotlin +class AccountViewModel( + private val getAccount: GetAccount, + private val updateAccount: UpdateAccount, +) : BaseViewModel( + initialState = AccountState() +) { + // Handle events from the UI + override fun event(event: AccountEvent) { + when (event) { + is AccountEvent.LoadAccount -> loadAccount(event.accountId) + is AccountEvent.UpdateAccount -> saveAccount(event.account) + is AccountEvent.BackClicked -> emitEffect(AccountEffect.NavigateBack) + } + } + + // Load account data + private fun loadAccount(accountId: String) { + viewModelScope.launch { + // Update state to show loading + updateState { it.copy(isLoading = true) } + + // Call use case to get account + val account = getAccount(accountId) + + // Update state with account data + updateState { + it.copy( + isLoading = false, + account = account + ) + } + } + } + + // Save account changes + private fun saveAccount(account: Account) { + viewModelScope.launch { + // Update state to show loading + updateState { it.copy(isLoading = true) } + + // Call use case to update account + val result = updateAccount(account) + + // Handle result + if (result.isSuccess) { + updateState { it.copy(isLoading = false) } + emitEffect(AccountEffect.NavigateBack) + } else { + updateState { + it.copy( + isLoading = false, + error = "Failed to save account" + ) + } + } + } + } +} +``` + +#### 🧪 Use Cases + +- Encapsulate business logic in the domain layer +- Follow the single responsibility principle +- Independent of UI and framework concerns +- Can be easily tested in isolation +- Invoked by ViewModels through Actions +- Implemented using the `operator fun invoke` pattern for cleaner, more concise code + +Use Cases represent the business logic of the application and are part of the domain layer. They encapsulate specific operations that the application can perform, such as creating an account, fetching data, or updating settings. Use cases should be implemented using the `operator fun invoke` pattern, which allows them to be called like functions. + +> [!NOTE] +> Use Cases are only required when there needs to be business logic (such as validation, transformation, or complex operations). For simple CRUD operations or direct data access with no additional logic, ViewModels can use repositories directly. This approach reduces unnecessary abstraction layers while still maintaining clean architecture principles. + +Example of a Use Case: + +```kotlin +// Use Case interface using operator fun invoke pattern +fun interface CreateAccount { + suspend operator fun invoke(accountState: AccountState): AccountCreatorResult +} + +// Use Case implementation +class CreateAccountImpl( + private val accountCreator: AccountCreator, + private val accountValidator: AccountValidator, +) : CreateAccount { + + override suspend operator fun invoke(accountState: AccountState): AccountCreatorResult { + // Validate account data + val validationResult = accountValidator.validate(accountState) + if (validationResult is ValidationResult.Failure) { + return AccountCreatorResult.Error.Validation(validationResult.errors) + } + + // Create account + return try { + val accountUuid = accountCreator.createAccount(accountState) + AccountCreatorResult.Success(accountUuid) + } catch (e: Exception) { + AccountCreatorResult.Error.Creation(e.message ?: "Unknown error") + } + } +} +``` + +Use Cases are typically: +- Injected into ViewModels +- Invoked in response to user events +- Responsible for orchestrating repositories and other domain services +- Returning results that the ViewModel can use to update the UI state + +The separation of Use Cases from ViewModels allows for: +- Better testability of business logic +- Reuse of business logic across different features +- Clear separation of concerns +- Easier maintenance and evolution of the codebase + +### Data Flow Components + +#### 📊 State + +- Immutable data classes representing the UI state +- Single source of truth for the UI +- Exposed as a StateFlow from the ViewModel +- Rendered by Compose UI components + +**Example: State in Action** + +Here's a complete example showing how state is defined, updated, and consumed: + +```kotlin +// 1. Define the state +data class AccountSettingsState( + val isLoading: Boolean = false, + val settings: AccountSettings? = null, + val error: String? = null, +) + +// 2. Update state in ViewModel +class AccountSettingsViewModel( + private val getSettings: GetAccountSettings, +) : BaseViewModel( + initialState = AccountSettingsState(isLoading = true) +) { + init { + loadSettings() + } + + private fun loadSettings() { + viewModelScope.launch { + try { + val settings = getSettings() + // Update state with loaded settings + updateState { it.copy(isLoading = false, settings = settings, error = null) } + } catch (e: Exception) { + // Update state with error + updateState { it.copy(isLoading = false, settings = null, error = e.message) } + } + } + } + + override fun event(event: AccountSettingsEvent) { + when (event) { + is AccountSettingsEvent.RetryClicked -> { + // Update state to show loading and retry + updateState { it.copy(isLoading = true, error = null) } + loadSettings() + } + // Handle other events... + } + } +} + +// 3. Consume state in UI +@Composable +fun AccountSettingsContent( + state: AccountSettingsState, + onEvent: (AccountSettingsEvent) -> Unit, +) { + when { + state.isLoading -> { + // Show loading UI + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + state.error != null -> { + // Show error UI + ErrorView( + message = state.error, + onRetryClicked = { onEvent(AccountSettingsEvent.RetryClicked) } + ) + } + state.settings != null -> { + // Show settings form + AccountSettingsForm( + settings = state.settings, + onSettingChanged = { setting, value -> + onEvent(AccountSettingsEvent.SettingChanged(setting, value)) + } + ) + } + } +} +``` + +#### 🎮 Events + +- Represent user interactions or system events +- Passed from the UI to the ViewModel +- Trigger state updates or side effects + +**Example: Events in Action** + +Here's a complete example showing how events are defined, dispatched, and handled: + +```kotlin +// 1. Define events +sealed interface AccountSettingsEvent { + data class SettingChanged(val setting: Setting, val value: Any) : AccountSettingsEvent + data object SaveClicked : AccountSettingsEvent + data object RetryClicked : AccountSettingsEvent + data object BackClicked : AccountSettingsEvent +} + +// 2. Handle events in ViewModel +class AccountSettingsViewModel( + private val saveSettings: SaveAccountSettings, +) : BaseViewModel( + initialState = AccountSettingsState() +) { + override fun event(event: AccountSettingsEvent) { + when (event) { + is AccountSettingsEvent.SettingChanged -> { + // Update state with new setting value + updateState { state -> + val updatedSettings = state.settings?.copy() ?: return@updateState state + updatedSettings.updateSetting(event.setting, event.value) + state.copy(settings = updatedSettings) + } + } + is AccountSettingsEvent.SaveClicked -> saveAccountSettings() + is AccountSettingsEvent.RetryClicked -> loadSettings() + is AccountSettingsEvent.BackClicked -> + emitEffect(AccountSettingsEffect.NavigateBack) + } + } + + private fun saveAccountSettings() { + viewModelScope.launch { + updateState { it.copy(isLoading = true) } + + val result = saveSettings(state.value.settings!!) + + if (result.isSuccess) { + emitEffect(AccountSettingsEffect.ShowMessage("Settings saved")) + emitEffect(AccountSettingsEffect.NavigateBack) + } else { + updateState { it.copy( + isLoading = false, + error = "Failed to save settings" + )} + } + } + } + + // Other methods... +} + +// 3. Dispatch events from UI +@Composable +fun AccountSettingsContent( + state: AccountSettingsState, + onEvent: (AccountSettingsEvent) -> Unit, +) { + Column(modifier = Modifier.padding(16.dp)) { + if (state.settings != null) { + // Setting fields + for (setting in state.settings.items) { + SettingItem( + setting = setting, + onValueChanged = { newValue -> + // Dispatch SettingChanged event + onEvent(AccountSettingsEvent.SettingChanged(setting, newValue)) + } + ) + } + + // Save button + Button( + onClick = { + // Dispatch SaveClicked event + onEvent(AccountSettingsEvent.SaveClicked) + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Save") + } + } + + // Back button + TextButton( + onClick = { + // Dispatch BackClicked event + onEvent(AccountSettingsEvent.BackClicked) + } + ) { + Text("Back") + } + } +} +``` + +#### 🔔 Effects + +- Represent one-time side effects that don't belong in the state +- Emitted by the ViewModel to trigger navigation, show messages, or perform other one-time actions +- Handled by the UI layer (Screen composables) to execute the appropriate action +- Implemented using Kotlin's `SharedFlow` for asynchronous, non-blocking delivery + +Effects are essential for handling actions that should happen only once and shouldn't be part of the UI state. Common use cases for effects include: + +- Navigation (e.g., navigating to another screen) +- Showing transient UI elements (e.g., snackbars, toasts) +- Playing sounds or haptic feedback +- Triggering system actions (e.g., sharing content, opening URLs) + +**Example: Effects in Action** + +Here's a simplified example showing how effects are defined, emitted, and handled: + +```kotlin +// 1. Define effects +sealed interface AccountSettingsEffect { + data object NavigateBack : AccountSettingsEffect + data class ShowMessage(val message: String) : AccountSettingsEffect +} + +// 2. Emit effects from ViewModel +class AccountSettingsViewModel : BaseViewModel( + initialState = AccountSettingsState() +) { + override fun event(event: AccountSettingsEvent) { + when (event) { + is AccountSettingsEvent.SaveClicked -> { + // Save settings and show success message + emitEffect(AccountSettingsEffect.ShowMessage("Settings saved")) + emitEffect(AccountSettingsEffect.NavigateBack) + } + is AccountSettingsEvent.BackClicked -> + emitEffect(AccountSettingsEffect.NavigateBack) + } + } +} + +// 3. Handle effects in UI +@Composable +fun AccountSettingsScreen( + onNavigateBack: () -> Unit, + viewModel: AccountSettingsViewModel = koinViewModel(), +) { + val snackbarHostState = remember { SnackbarHostState() } + + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + AccountSettingsEffect.NavigateBack -> onNavigateBack() + is AccountSettingsEffect.ShowMessage -> { + CoroutineScope(Dispatchers.Main).launch { + snackbarHostState.showSnackbar(effect.message) + } + } + } + } + + // Screen content with snackbar host... +} +``` + +#### ⚡ Actions + +- Represent calls to domain layer use cases +- Triggered by the ViewModel in response to events +- Bridge between UI and domain layers +- Execute business logic and return results to the ViewModel + +Example: + +```kotlin +// In a domain layer repository interface +interface AccountRepository { + suspend fun getAccount(accountId: String): Account + suspend fun updateAccount(account: Account): Result + suspend fun deleteAccount(accountId: String): Result +} + +// Use case with operator fun invoke pattern (recommended approach) +// In a domain layer use case interface +fun interface UpdateAccount { + suspend operator fun invoke(account: Account): Result +} + +// Use case implementation +class UpdateAccountImpl( + private val accountRepository: AccountRepository +) : UpdateAccount { + override suspend operator fun invoke(account: Account): Result { + return accountRepository.updateAccount(account) + } +} + +// In the ViewModel +class AccountSettingsViewModel( + private val updateAccount: UpdateAccount, +) : BaseViewModel( + initialState = AccountSettingsState() +) { + // Event handler + override fun event(event: AccountSettingsEvent) { + when (event) { + is AccountSettingsEvent.SaveClicked -> saveAccount() // Triggers an action + } + } + + // Action + private fun saveAccount() { + viewModelScope.launch { + updateState { it.copy(isLoading = true) } + + // Call to domain layer use case (the action) using invoke operator + val result = updateAccount(currentAccount) + + when (result) { + is Result.Success -> { + updateState { it.copy(isLoading = false) } + emitEffect(AccountSettingsEffect.NavigateBack) + } + is Result.Error -> { + updateState { + it.copy( + isLoading = false, + error = result.message + ) + } + } + } + } + } +} +``` + +#### 📊 Results (Outcomes) + +- Represent the outcome of actions executed by use cases +- Can be success or error +- Used by the ViewModel to update the state or emit effects + +**Example:** + +```kotlin +// Result types for account creation +sealed interface AccountCreatorResult { + data class Success(val accountUuid: String) : AccountCreatorResult + + sealed interface Error : AccountCreatorResult { + data class Validation(val errors: List) : Error + data class Creation(val message: String) : Error + data class Network(val exception: NetworkException) : Error + } +} + +// In ViewModel +private fun handleResult(result: AccountCreatorResult) { + when (result) { + is AccountCreatorResult.Success -> { + // Update state with success + updateState { it.copy(isLoading = false, error = null) } + // Emit navigation effect + emitEffect(Effect.NavigateNext(AccountUuid(result.accountUuid))) + } + is AccountCreatorResult.Error -> { + // Update state with error + updateState { it.copy(isLoading = false, error = result) } + // Optionally emit effect for error handling + when (result) { + is AccountCreatorResult.Error.Network -> + emitEffect(Effect.ShowNetworkError(result.exception)) + else -> { /* Handle other errors */ } + } + } + } +} +``` + +## 🧭 Navigation + +The application uses the Jetpack Navigation Compose library for navigation between screens: + +- **📱 Navigation Graph**: Defines the screens and their relationships +- **🔗 Navigation Arguments**: Type-safe arguments passed between destinations +- **🔙 Back Stack Management**: Handles the navigation back stack +- **↩️ Deep Linking**: Supports deep linking to specific screens + +### Navigation Setup + +To set up navigation in the app, you need to: + +1. Define route constants +2. Create a NavHost with composable destinations +3. Handle navigation callbacks in screens +4. Use ViewModels to emit navigation effects + +Example: + +```kotlin +// Define route constants +private const val ROUTE_HOME = "home" +private const val ROUTE_SETTINGS = "settings" +private const val ROUTE_DETAILS = "details/{itemId}" + +@Composable +fun AppNavHost( + onFinish: () -> Unit, +) { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = ROUTE_HOME, + ) { + composable(route = ROUTE_HOME) { + HomeScreen( + onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) }, + onNavigateToDetails = { itemId -> + navController.navigate("details/$itemId") + }, + viewModel = koinViewModel(), + ) + } + + composable(route = ROUTE_SETTINGS) { + SettingsScreen( + onBack = { navController.popBackStack() }, + onFinish = onFinish, + viewModel = koinViewModel(), + ) + } + + composable( + route = ROUTE_DETAILS, + arguments = listOf( + navArgument("itemId") { type = NavType.StringType } + ) + ) { backStackEntry -> + val itemId = backStackEntry.arguments?.getString("itemId") ?: "" + DetailsScreen( + itemId = itemId, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + ) + } + } +} +``` + +### Navigation in Screens + +In your screen composables, you handle navigation by observing effects from the ViewModel: + +```kotlin +@Composable +fun HomeScreen( + onNavigateToSettings: () -> Unit, + onNavigateToDetails: (String) -> Unit, + viewModel: HomeViewModel, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is HomeEffect.NavigateToSettings -> onNavigateToSettings() + is HomeEffect.NavigateToDetails -> onNavigateToDetails(effect.itemId) + } + } + + // Screen content +} +``` + +### Navigation in ViewModels + +In your ViewModels, you emit navigation effects: + +```kotlin +class HomeViewModel : BaseViewModel( + initialState = HomeState() +) { + override fun event(event: HomeEvent) { + when (event) { + is HomeEvent.SettingsClicked -> emitEffect(HomeEffect.NavigateToSettings) + is HomeEvent.ItemClicked -> emitEffect(HomeEffect.NavigateToDetails(event.itemId)) + } + } +} +``` + +## 🔄 Complete End-to-End Example + +Here's a complete example of how all the components work together in a real-world scenario, using the CreateAccount feature: + +### 1. Define the Contract + +First, define the contract that specifies the State, Events, and Effects: + +```kotlin +interface CreateAccountContract { + + interface ViewModel : UnidirectionalViewModel + + data class State( + override val isLoading: Boolean = true, + override val error: Error? = null, + ) : LoadingErrorState + + sealed interface Event { + data object CreateAccount : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + data class NavigateNext(val accountUuid: AccountUuid) : Effect + data object NavigateBack : Effect + } +} +``` + +### 2. Implement the ViewModel + +Next, implement the ViewModel that handles events, updates state, and emits effects: + +```kotlin +class CreateAccountViewModel( + private val createAccount: CreateAccount, + private val accountStateRepository: AccountStateRepository, + initialState: State = State(), +) : BaseViewModel(initialState), + CreateAccountContract.ViewModel { + + override fun event(event: Event) { + when (event) { + Event.CreateAccount -> createAccount() + Event.OnBackClicked -> maybeNavigateBack() + } + } + + private fun createAccount() { + val accountState = accountStateRepository.getState() + + viewModelScope.launch { + updateState { it.copy(isLoading = true, error = null) } + + when (val result = createAccount(accountState)) { + is AccountCreatorResult.Success -> showSuccess(AccountUuid(result.accountUuid)) + is AccountCreatorResult.Error -> showError(result) + } + } + } + + private fun showSuccess(accountUuid: AccountUuid) { + updateState { + it.copy( + isLoading = false, + error = null, + ) + } + + viewModelScope.launch { + delay(WizardConstants.CONTINUE_NEXT_DELAY) + navigateNext(accountUuid) + } + } + + private fun showError(error: AccountCreatorResult.Error) { + updateState { + it.copy( + isLoading = false, + error = error, + ) + } + } + + private fun maybeNavigateBack() { + if (!state.value.isLoading) { + navigateBack() + } + } + + private fun navigateBack() { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateBack) + } + + private fun navigateNext(accountUuid: AccountUuid) { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateNext(accountUuid)) + } +} +``` + +### 3. Create the Screen Composable + +Then, create the screen composable that observes the ViewModel and handles effects: + +```kotlin +@Composable +internal fun CreateAccountScreen( + onNext: (AccountUuid) -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.NavigateBack -> onBack() + is Effect.NavigateNext -> onNext(effect.accountUuid) + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.CreateAccount) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + topBar = { + AppTitleTopHeader( + title = brandNameProvider.brandName, + ) + }, + bottomBar = { + WizardNavigationBar( + onNextClick = {}, + onBackClick = { + dispatch(Event.OnBackClicked) + }, + state = WizardNavigationBarState( + showNext = false, + isBackEnabled = state.value.error != null, + ), + ) + }, + modifier = modifier, + ) { innerPadding -> + CreateAccountContent( + state = state.value, + contentPadding = innerPadding, + ) + } +} +``` + +### 4. Create the Content Composable + +Finally, create the content composable that renders the UI based on the state: + +```kotlin +@Composable +private fun CreateAccountContent( + state: State, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(contentPadding), + ) { + when { + state.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + state.error != null -> { + ErrorView( + error = state.error, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} +``` + +### 5. Add to Navigation + +Add the screen to the navigation graph: + +```kotlin +NavHost( + navController = navController, + startDestination = ROUTE_HOME, +) { + // Other composables... + + composable(route = NESTED_NAVIGATION_CREATE_ACCOUNT) { + CreateAccountScreen( + onNext = { accountUuid -> onFinish(AccountSetupRoute.AccountSetup(accountUuid.value)) }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } +} +``` + +This example demonstrates the complete flow from UI to ViewModel to Domain and back, showing how all the components work together in a real-world scenario. + +## 🔄 Component Interactions and State Changes + +Understanding how components interact and how state changes flow through the system is crucial for working with our MVI architecture. Here's a detailed explanation of the interaction flow: + +```mermaid +sequenceDiagram + participant User + participant View + participant ViewModel + participant UseCase + participant Repository + + User->>View: User Interaction + View->>ViewModel: Event + ViewModel->>ViewModel: Process Event + ViewModel->>UseCase: Action (Execute Use Case) + UseCase->>Repository: Data Operation + Repository-->>UseCase: Result + UseCase-->>ViewModel: Result + ViewModel->>ViewModel: Update State + ViewModel-->>View: New State + View-->>User: UI Update + + Note over ViewModel,View: Side Effect (if needed) + ViewModel->>View: Effect + View->>User: One-time Action (e.g., Navigation) +``` + +### Interaction Flow + +1. **User Interaction**: The user interacts with the UI (e.g., clicks a button, enters text) +2. **Event Dispatch**: The View captures this interaction and dispatches an Event to the ViewModel +3. **Event Processing**: The ViewModel processes the Event and determines what action to take +4. **Action Execution**: The ViewModel executes an Action, typically by calling a Use Case +5. **Domain Logic**: The Use Case executes business logic, often involving repositories or other domain services +6. **Result Handling**: The Use Case returns a result to the ViewModel +7. **State Update**: The ViewModel updates its State based on the result +8. **UI Update**: The View observes the State change and updates the UI accordingly +9. **Side Effects (if needed)**: For one-time actions like navigation, the ViewModel emits an Effect that the View handles + +### State Changes + +State changes follow a unidirectional flow: + +1. **State Immutability**: The State is an immutable data class that represents the entire UI state +2. **Single Source of Truth**: The ViewModel is the single source of truth for the State +3. **State Updates**: Only the ViewModel can update the State, using the `updateState` method +4. **State Observation**: The View observes the State using `collectAsStateWithLifecycle()` and recomposes when it changes +5. **State Rendering**: The View renders the UI based on the current State + +Example of state changes in the ViewModel: + +```kotlin +// Initial state +val initialState = AccountSettingsState(isLoading = false, settings = null, error = null) + +// Update state to show loading +updateState { it.copy(isLoading = true, error = null) } + +// Update state with loaded settings +updateState { it.copy(isLoading = false, settings = loadedSettings, error = null) } + +// Update state to show error +updateState { it.copy(isLoading = false, error = "Failed to load settings") } +``` + +### Component Responsibilities + +Each component has specific responsibilities in the interaction flow: + +1. **View**: + - Render UI based on State + - Capture user interactions + - Dispatch Events to ViewModel + - Handle Effects (e.g., navigation) +2. **ViewModel**: + - Process Events + - Execute Actions (Use Cases) + - Update State + - Emit Effects +3. **Use Cases**: + - Execute business logic + - Coordinate with repositories and domain services + - Return results to ViewModel +4. **Repositories**: + - Provide data access + - Handle data operations + - Return data to Use Cases + +This clear separation of responsibilities ensures that each component focuses on its specific role, making the codebase more maintainable, testable, and scalable. + +## ♿ Accessibility + +The UI is designed with accessibility in mind: + +- **🔍 Content Scaling**: Support for font scaling and dynamic text sizes +- **🎙️ Screen Readers**: Semantic properties for screen reader support +- **🎯 Touch Targets**: Appropriately sized touch targets +- **🎨 Color Contrast**: Sufficient color contrast for readability +- **⌨️ Keyboard Navigation**: Support for keyboard navigation + diff --git a/docs/architecture/user-flows.md b/docs/architecture/user-flows.md new file mode 100644 index 0000000..7a7d774 --- /dev/null +++ b/docs/architecture/user-flows.md @@ -0,0 +1,24 @@ +# User Flows + +The user flows diagrams below illustrate typical paths users take through the application, helping developers understand how different components interact from a user perspective. + +For information about the repository structure and module organization, see the [Project Structure document](project-structure.md). + +## Mail + +### Reading email + +![read email sequence](../assets/ReadEmail.png) + +![read email classes](../assets/ReadEmailClasses.png) + +### Sending email + +![send email sequence](../assets/SendEmail.png) + +## Verifying Flows + +We plan to test these user flows using [maestro](https://maestro.dev/), a tool for automating UI tests. Maestro allows us to write tests in a +simple YAML format, making it easy to define user interactions and verify application behavior. + +The current flows could be found in the *`ui-flows` directory in the repository. diff --git a/docs/assets/ReadEmail.png b/docs/assets/ReadEmail.png new file mode 100644 index 0000000..39d57f0 Binary files /dev/null and b/docs/assets/ReadEmail.png differ diff --git a/docs/assets/ReadEmailClasses.png b/docs/assets/ReadEmailClasses.png new file mode 100644 index 0000000..f769c65 Binary files /dev/null and b/docs/assets/ReadEmailClasses.png differ diff --git a/docs/assets/SendEmail.png b/docs/assets/SendEmail.png new file mode 100644 index 0000000..28c9281 Binary files /dev/null and b/docs/assets/SendEmail.png differ diff --git a/docs/assets/activity_diagram.graphml b/docs/assets/activity_diagram.graphml new file mode 100644 index 0000000..05a291a --- /dev/null +++ b/docs/assets/activity_diagram.graphml @@ -0,0 +1,936 @@ + + + + + + + + + + + + + + + + + + + + + + + Accounts + + + + + + + + + + + FolderList + + + + + + + + + + + MessageList + + + + + + + + + + + MessageView + + + + + + + + + + + AccountSettings + + + + + + + + + + + AccountSetupIncoming + + + + + + + + + + + AccountSetupOutgoing + + + + + + + + + + + Prefs + + + + + + + + + + + FontSizeSettings + + + + + + + + + + + AccountSetupOptions + + + + + + + + + + + AccountSetupNames + + + + + + + + + + + AccountSetupComposition + + + + + + + + + + + AccountSetupCheckSettings + + + + + + + + + + + AccountSetupBasics + + + + + + + + + + + AccountSetupAccountType + + + + + + + + + + + FolderSettings + + + + + + + + + + + ChooseAccount + + + + + + + + + + + ChooseFolder + + + + + + + + + + + ChooseIdentity + + + + + + + + + + + EditIdentity + + + + + + + + + + + LauncherShortcuts + + + + + + + + + + + ManageIdentities + + + + + + + + + + + MessageCompose + + + + + + + + + + + Other Apps / OS + + + + + + + + + + + Search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/draw.io/CreateAccount.xml b/docs/assets/draw.io/CreateAccount.xml new file mode 100644 index 0000000..b5fb87a --- /dev/null +++ b/docs/assets/draw.io/CreateAccount.xml @@ -0,0 +1 @@ +1VjbcpswEP0aHpPhYrD9aDtpOp02ScedaSZvMqxBjUCMELGdr+8KxM041EmdScKDLR2WRXt0dr2y4Szi7ZUgafSDB8AM2wy2hnNh2PbYc/BTAbsSGLlmCYSCBiVkNcCSPoEGK7OcBpB1DCXnTNK0C/o8ScCXHYwIwTddszVn3bemJIQesPQJ66O/aSCjEp3Y4wb/CjSMqjdb3rS8E5PKWEeSRSTgmxbkXBrOQnAuy1G8XQBT3FW8lM99eeZuvTABiTzmgZtkJuZ36fj628+n+9ub4C4nszPt5ZGwXAc8832eJ3IJMk8XEfgPOJI0CTMdhdxV1Ai0C0B5Nw1nvomohGVKfHV3g1pALJIxw5mFw0cQkiKtM0bDBDHJlUE/hmpBaA7bFqRjugIegxQ7NNF3R6bmtxKYnm6a3bImGotaO1VhRAskrD03HOJA0/gCSu0epQWNmtdfJHswbI/hKuYrgaNQ1jy8lttTsOh2WfTcPov2ARZHb8XipMdijyMIMFP1lAsZ8ZAnhF026LzLYouxP6jpnS42JJccocbDd660OcxsxnPhw8D6R7pWERGCHLDTPKtYBvdJACOSPnar0slZHw2WgznJqP+fdeAkGf+qhH8zqVr9jP9cWnWP1KplfSixuoNivSYx9g0fsNJ21WuZ7y1f7wj1JsFM9VI48xnJsAocScvJyuSxymux6B4gscKOFqh+wy2nGFm9h87EOp+2rklnS+vetXJYsqB9tDu0fbfT8ZBbd89tSVrPbSGCmpLX62La08WCMIaZgtu1obirNvo1+xm25kK5TnweY9uo1puoZ3guQ14Ae+LCvJFdOQnI6BNZFQYqKVMVYhG0OzfcC0SI7iJ9FByIA+1lTIOgKKuMrIDNif8QFom+4AyXp97rrIvroHoH82Q/0+uzh16x0W7vD1UA89xxx9OTCPHM8s7djkrO7D2V8PU6gzcRiPXs4eEz1F3vveuu5fQTTACR6lfLzFLwKVE+GPeLb3VyBdHvvvBYmaphwiX8m8hVnQk3uWQ0AY1nZX9huSci2+uyPT1wnDAH6vMLyMZpc4Yupd38EeFc/gU= \ No newline at end of file diff --git a/docs/assets/draw.io/ImportExport.xml b/docs/assets/draw.io/ImportExport.xml new file mode 100644 index 0000000..db0bfdf --- /dev/null +++ b/docs/assets/draw.io/ImportExport.xml @@ -0,0 +1 @@ +5Zlhc5owGMc/jS97J4mCvHStrr12N+/s1p1vdimkEBsIC7HoPv1CCSBELXO14O2NF56EhPyeJ/8niT14Gaw/cxT5X5iLaQ/03XUPXvUAMCGQv6lhkxmgBTODx4mbmYzSMCe/sTL2lXVFXBxXGgrGqCBR1eiwMMSOqNgQ5yypNntitDpqhDysGeYOorr1gbjCz6wjYJX2a0w8Px/ZMO2sJkB5YzWT2EcuS7ZMcNKDl5wxkZWC9SWmKbucS/bedE9t8WEch6LJCw9L6+F2Ft5fL8n3xb354+tP9HihnPGC6EpNGEWR7MNhHPeASWXPnx65LHlpSU1EbHI6nK1CF6cD9GV14hOB5xFy0tpEhoO0+SKg8smQxRfMBZFkx5R4obQJFhV9pnV4vXdmRsFLxhlmARZ8I5uoF+BIIVYxNlCPSekwCJXN33ZW3hCpIPGKrkuOsqBQ/gVWsAfripwN1MHbTMGpmP5afqMgni3Gyc1i7iSwv5hOLwyN6RwLQUIvvgkiJmfK35vtHmQ7wB6gWMVoQJ1jgWyb48kwjjSMGiTsSglUjxKszzwWIjoprZ+qGLeQLaVLNkrF0UowaSp7uGNpcB5GG7MVd/CB71efKxD3sDjQTgl0OpeDjuKYIkFeqnL//sE7+E+wG7BT3HUhHjuOpCjiDorFsKoVVttSYeiSe15BazUMWrtTMWtp1LMEN8dU7nAJC68IoszrfgC3n+zyU8TZRrDdVHY7FcH2nggex5vQuUfxc+uRWpwvuxOq+lFshuI4YdydcRZEorOrvs7yI/PWztOXvteacfyE5RQdfKK8/49HrY4BHDZQzdAdp9cr8smhMk6JswtLTY+O08D82mdLA/e7XYe8RXG4A2Jua6yMaoQZI3IihQ9NuyYoZs052TTVW2DrmqbWkVU7eBtGraOMg9bRq6OLaR/ve1PzfXZ1EQvG07uyc7m/qF8Kgb6+qnbeCo1Otar0fd08YzpxiSx0UJiGZotZcidD/fbi1j43iqB1ivr+7JC+P1LmPFepyNopScd8RXic1h/y75tab7Wp9TasOnQwPFLrbe3i+mO1vtHxqHuJvlXng7rzj030oO78D070BtCcf8dkJk7l9N3T/AlktPVd8o4T2zmsnszvrS2f4RtR33T5wNqhCdbX4dHLRz6W/8pmzcu/tuHkDw== \ No newline at end of file diff --git a/docs/assets/draw.io/Modules.xml b/docs/assets/draw.io/Modules.xml new file mode 100644 index 0000000..72b58a5 --- /dev/null +++ b/docs/assets/draw.io/Modules.xml @@ -0,0 +1 @@ +7Zxfl6I2FMA/jee0D87hv/rouDM7065d22l3Z/sWIUK6SCjEcdxP3wSCQoKKiMhs1xchhEDu/eXm5nKhp0+Wr+8jEHpT7EC/pynOa09/19M0VVUs+sdKNmmJZWppgRshh1faFTyhb5AXKrx0hRwYFyoSjH2CwmKhjYMA2qRQBqIIr4vVFtgvXjUELpQKnmzgy6WfkUO8rF/WaHfgASLX45ceaoP0wBJklXlPYg84eJ0r0u96+iTCmKRby9cJ9JnwMrmk593vObq9sQgGpMoJs/fA+Pu36cvtJzgJPvbV4fQXs8+V8QL8Fe/wEiB61n0YYYJt7Md0Gy1ByPtANplgIrwKHMjaVnr67dpDBD6FwGZH1xQFWuaRpU/3VLr5AiOCqFDHPnIDWkYwq7BAvj/BPo6SFvX7+wn90XK5Z7yzrBn4miviPX0P8RKSaEOr8KOGwaXOsTNGwxszLVnn1DjktbycBi1eBjg57rbxnXDpBpfvCbLWZWFLUoWBM2bU0j3bB3GM7KIg4Ssiz0zmNwOT737JDgX0JvPH2P4Xrp+9Io3xKrL5xUdx+G68+uwONoE+/7Z+jZ8fBn2dDzoQuZAc6l1aDzqF8SQrKCd+s0T6WVkEfUDQS3EUlqmEX2GGEe3ZVv+jUVH/prHVf9ZI2nN+Xn7cCE2pyn6WsrZS6UhtJZhs+34GORn/55BzCI8DWNUnR6tIjtYtcnSBHLM2OaOh0JRyQXBKVSBzMwf2V4oKbQ2EqKdZPpXe7TwqsGT9u2KT0u0CB6QfJ1PymFZQlfB1d5BuufzfzyqzVrIZPKunyqc8BgRGCzZXsAk5uTa9JRS4TDDs3qgOSYTgS1q0hHFMZ9k4uxK99/RixRs4rxMtzG53VlOzmyZYJN28/uwmo8Y9CRsvlzjIa6motzgEQX3NTXDEMEIMqQCSHS7MJ2R2kHGj/ARv3Bv6P32c3v2c4yi9tsxRi1Q05/OIVJgdoMLaRwWBMUmG92Esym1MPVSePCpdJ+m5H8KoAEhqhthNHTMzV6HGAbGXtHBwRj7BPe4eKQOJlNBfuShgC5AHOmCSQTNDdPaK9kNzHiDpuKSi5VdRVnHCy5xJDtg21eNRPC7LQQOqN5Wi7tUOrIyGB3SPQxiEbtin7krfR/NL6X48e9yagY/0kr/Cje0BxGampAwTLzUZ0SYk2GXBDiZUukx+Qc4Oye+FCm1wfSqyEFAOCxCGiUfB5vyruK7TFAkqa3YGXrBJw0t8DezQv0kiLyouTRkHToQRMx8OpDw5MLDR2/dhmb9yGW+lE8TJTiwnLhn1fW6LrsQeM0uz97N+HEIbLej6XrJGKYQ/CCt1ctQsEHs1vIyRhNcRkebE9w8kZMPj42BFMC3CEfGwiwPgf8BMmMUIYS6Ow8M6e2WaD+WUD4yqsRy1ahjwzCgNdVmLyh1UjdKMowhsctVCViE+cCUhHjQ0860drS5E5elGegONxgfNKvFBqpEnvrsD525Xens2em3wpTbN11kj2tQ7Ifhd+LZW9PZMlZjtDHldGFnVh3xTwywTieQefB2lUY3m3YJ6rXAvld6cT2dcgnDAgl+zlc8e5ynUYYB8NQOSTqce7HaFg5chDuC1l7qCY3BnXc71NK7tGKiyGUnBigmOkljmVRzOp98/sJp2hEKSgpO4nBAQxksaqV+F1PtkXieniKxZJXomSio5gIA5iH8sfPa7pR2IvxhymPaN+Q9G1clK6ZT/kN23NPBLwuNtDvxGIuVdG9MXDqJ3IoBh7uFphcpm7UYCp9s411+PgvegxCvbS7upKWObDidEkhgYnzs+Ibj+3zgZ1w8/qBXCD9WzSHr5FJIbLVt4POeWJLlD9U27VdWyVzTs7WSRqGLqh1Y7i0SMKBjyuufS6UdyIP50cC4Rlqqam9YtNvqmoFG9fnJa3xBA09pPTpPnHIkOl9rqsLpR3abzgnnWglKusT3pnuUJMVuTnFfq1i6fY28P5b+VJl+1llK7N+nosC6PT3NtTGOHElJLxRriUH/bYs2CXQLPLfoOpUKX1yk7oa/h3AEv34XYraLYrWu7bDLrJen5rUHfYHr+YK97dDVZy9NYiaxbZL05aYvJHV1I99KrPDnp3MsQVcNMVR+JtLQYUU0BAL3+2xCaAJMxbN3hlNPFTkanPgKajMCh1PuOIGBp5jGtVSXAGghNyTBdGACjgddh6gOQvYX4thadpmgC1PoAiBbAah0AeWl1GQAOPkN4YybAENSmy2qrSoAhwKTLMF2aAHlBcmUCDiU2d4QAS1Rb/VfbBiJMl3y1rZyACmGnqg5kMWC9cxhTB1KTHMiiu38GR5UDmFUfW7YDkq415k/qg7ru5MmJfMOi36JxG3LRzDxDfq2lTTtlvk2+tNFRKCrzJeb6tO6tmhczVO3ZqaqLHq1TGA0EM6Vltubk2U4wUpr4zKAhEyU+zBm0kTtcJffnCJ6NcVbVXg06zZl6hl8lkKZW9qtOZc0ciTfdAmyy114SY42XpJ1nYw1GWLMvdGQO0fD6EVaziXSP/Q5yUSEH80JaMAjdWmgJPsegfqxFGKRW5VDLyVOP8OhLtQ6/5yLW10Z6o+YDB/ibN9b+cH999uIvkfbwrMCSb0w0k8P2uAx9uKR0JrnyLEEtebNTzkHbPvRUxrNHllN/B1iGm1gtfeMzaYfl4ft4DaO+D1+Sb6PJ1WUbuEujoxexwJKZr2Aepy/7lrTwOB3Pella3ezjjOqCvbxeWvdp+ierK3x4ZU8SnmAvYg+EbNNeRf7mNmLSIPtcAba38FH4Kbf9wLd9MIf+DMeIyZuWRekQ3BruD8LxJXKcJMcYcIvuw0W+fmbot/UasOmWMPb0kqfBWokNaSShpBR/7VL45z6ekqCoZCiyOxLHxlmkHJm4vx96RJ9w+wz2GD3m6fTQ3d1nBFNbu/sYo373Hw== \ No newline at end of file diff --git a/docs/assets/draw.io/ReadEmail.xml b/docs/assets/draw.io/ReadEmail.xml new file mode 100644 index 0000000..38be7c5 --- /dev/null +++ b/docs/assets/draw.io/ReadEmail.xml @@ -0,0 +1 @@ +7Vxtc5s4EP41+ZgMeuPlY+PGvZtrbnrNzN1nbGRbLUYekJukv/6EEQYkQ1wXYwPxB49ZYDHa1fPsrhZu0GT98in2N6tHHtDwBlrByw36eAOhR7D8TgWvmYBglAmWMQsyESgET+wnVUJLSbcsoEnlQMF5KNimKpzzKKJzUZH5ccyfq4cteFi96sZfUkPwNPdDU/ofC8RKSYHtFTv+oGy5Upd2oZPtWPv5wepOkpUf8OeSCD3coEnMuch+rV8mNEzHLh+X7Lxpzd79H4tpJI454atPAgT+/fbpUTxa5J9v5P7Lz1uYafnhh1t1w3+u/c29P/9Oo0D9b/GaD4a8hU36c7sOP7MFDVkkt+43NGZrKmgs94RK/KWQ3T+vmKBPG3+envosvUTKVmIdyi0gf0rLCV+eEu+3w9DfJGy2u6olJTGdb+OE/aBfaZI5SCrlW5FeabI3fCpcSGXKicBum4XhhIc83t0Amk4f7MlEytVt01jQl9rxBHsrSe+mXN5P/CoPyV0bK8Pmnq02n0tuYinZquQhOBf6yjWXe9WF9eQPZcBfMCZAhjUfaZJID/3MEjE8az7Y02lb1iRXZ0xiGNOwoJyjH1KMk1uzkM+/V00R+jMaplN5GfNtFOQDF/GdneW5Uxbmx2aqaWCAoTZ+8vJ8G8/pEV4o/HhJRRP2WIctUhpzcmDIc1lMQ19IN6pC/gEzqCt84Uzeyt7gwK5aHHmaJbMbVWeVcVVThJB3RyqqANRUZUNhqJK2819Lh23SA5KGv+xZB69TuFmmsXC6/aj+hh/ahh/OV1T6WkpxTJ5uh9Iw97NY/lqKvSuVvFROSlH1zDid+X4BCerG5dHk/oZ8lBI/ZMtICubS/Xawk05uJmn5g9ox40Lw9ZteXosMKl5Qf6Jg6bLHN8zLWhy5te4ArLrDLW7FYd2DSvPz+WKRUKG5QysOkI9MHavo9p/G/nJNo3e+afQTHX4uTzjQnOiZnVm0lMMkYjmycpgHZ9XptD2rIvfarIrM2duPMCKHnbfDCPuSYQS2vDsPusQBOPvGGkXvg4NfjSsIMOMKQ1lLkcWe/vIroQ4iCwQM10xeo/kq5lE6W7NkPUWckUUYqCYuHmyEgcy8tScYZR+JUQheEqMcoGFUda7bJyY+LjYASlfVEjwRjVYB7gKesOGVfEOjEeMSGhsu9bUE0xNcIqQRl8iJuGTbBi7pqs6ES4h0gUsHCjIhT8YcMI2tJIOcoQPTOy79Fi7phWK7C1xyDadM07nxoZEzNjTyDMOni8hPO+Nrph5AtbC9FWRgXd2qIzZrMj1hliOJJXPWK2WWUzNxz+kqEweWXil0OqAWDN+ppZidI6IWbFYG//JUd9JUJTvDY5j21qNc+DbBgG4Jxqyq9YNg8ijnTYbBNWWybhgmpQK79NFowTmRYYDlSoqxig9s1tsS3XhIYxuvC7Yxy36pxaE1y9si94WWYbMNvhDbEC2Y6Y5uzNJaufNBjzbSDCftgaHROw818xD2ri7RMUtojV1NH+bSoZl4Ha692+xugs7V2dssUgwt7rhoH4xMDI0c9ORgA5gttWcKMFytiT9/7uSsAQapS2elM/py1gXXEVhITFjsPueJLmoKMUq9dWdjYlejCwDcdnwVVPPZ/fb5IwzS11YXfOzKDa4pf3cEQyZ0QHQiDDmed+cCC2HsQoe4drPWtpZutIwHdtGJR8ykfHyARJrbW4YKSH3tcelLXNR3QHK1NSrYRe8dMRNxBUgRfZbf6yxVGwUsNTe3DBSWbHMd8m8u2ELaRTAevT+PclweblvXlofbZu7TD745OgC2a0zSId9gr/ho3HPqEnPKPQgiAj0MgGNrj7kYatuKhrU6EnQ7IB/HzNH8ICi4Z/fUq2WWCKMSQI2BmbKp3Lw+kI/lqV7dQfeSCUifuRzjJ8FjM8B4Z5eShR1wdezS6erybqiVYnAJqrlsrcXsZ4WnVnyRUTw2VLXEKEhnlC5WlG0zya4+6Vgwy4y/jCu/sZtXmc+Z37igorfL9MbslT4jTrUITT3pggFQoyasv5Ll6LUoBN/Q1Fari1ZnQVYXwGSuiu483gr9dBHc2r3pJS3/WkIGM4nw15vRNV1mc/UCbTC3wLpUI0xOkgPGp8tWhVNiK3XTWVU7I3xqlk6kN9Z2/xlq24IubREddbGI7hx+e4SULFjEktU4Fq2c5ndFDLQ67PS1ibgnmR1xqugEq9P71PUrhL07t1RDzLP1Or0twRPWiuCoiyV153DKNzZ4ulxydxZ4kpvFC3uzw4u3HqOH/wE= \ No newline at end of file diff --git a/docs/assets/draw.io/ReadEmailClasses.xml b/docs/assets/draw.io/ReadEmailClasses.xml new file mode 100644 index 0000000..09cdb75 --- /dev/null +++ b/docs/assets/draw.io/ReadEmailClasses.xml @@ -0,0 +1 @@ +3Vpdc6IwFP01Pu6MSRT00bra7kdndrbd2ecUUsg0EibEqvvrN5SAktSKrHy4Lw5cwg2cnHvuvcEBmq+2twLH4T33CRvAob8doM8DCF0Hqd/UsMsMo/EwMwSC+pkJ7A0P9A/RxnzYmvokKQ2UnDNJ47LR41FEPFmyYSH4pjzsmbPyrDEOiGV48DCzrb+pL0NtBc50f+GO0CDUU0+gm11Y4XywfpMkxD7fHJjQYoDmgnOZHa22c8JS7HJcsvuWR64WDyZIJKvc8HW3RPJncO8+3oLHu7Gcicf4k/byitlav/DMk/SVyt13mkgSEaEfXu5yRARfRz5JnQ4H6GYTUkkeYuylVzeKAsoWyhVTZ0AdPlPG5pxx8XYvWjjL5Xyu7HpaIiTZHn0fUKCk2EX4ikixU0P0DSONqyYWcPX55mCZHG0LD1YIaRvWzAgKz3vw1IHG7wwsoYXlA13FjNyTJMEBjYJGIF0um4N02DWkyIL0asEcdY3lyMLSgo5E/izVTHX2xLj3ooDwcRK+gZmipa4vaTrrG7TqTOs1gGVUM8fEt6TVwE5NztfCI6eDSmIREHmKKfZavIe2IAwrjSs/23twa3c/OFVPvV9XUF5YNDVWLHsnfdehIBuOxu4JR9lLW47eVr94x/qEcCxCLLYqpPykCi8OFt+xmHFABcxoEKljT628ClZ0k6hYTMOXPEs9Oo0yqjLuTA+VPL4sh0BFDsHWOATH5aUfOzU5ZJLRctQwh1w75+0iryFtXjiX0ubeifPEwnHOIyk4Y0Q0iOjlsl3/yrHpVea7SUWtclvTKseQGFBXqyxHsF2tyqevyAif4hWP/MeQRiUqgBIV4KgxKlRNW5PWqOAaQY7qUmFq5L8ijbVFBbvvLRqLvfAOoMMU8DdP6VEgi9W9AvntPqMBux2+wd6LipzeVgWjSc+qAmD3v78S8pFo8ZhEdqoqJbWGxKpqo9Ze4gLDqZFw6nZqwKzXLU9N69V5zXvnqatqFQOOBGQLLVcR7P/aclmOmubC2OLClxWOr01cwdDpWl3tDZCPgqovHUK+/qeDq739DNcMrmHdwtAMLtNRw8EFjxWGJO3Ar/XbSPeVTB5a//9mI6zatrWX+gAy6JBLyNl1kOGo2ORpKzrtelinvSVnfo/3G52+dRbQLii/TZvE8nLaZmIJJp2D+X5Flu7bNr6F0GCB1j2u5xVonXc9sHJlNm5N+6HRA0M0qaf9Zv8Ekduu9tufmvq6F1KZB7DFr9YGD5yaJYBJA9NP0yywP5R9RIG+9Gx5uu0TI5Dx9wMwrtmzWY4u9l1Hne7/H5cN3//JEC3+Ag== \ No newline at end of file diff --git a/docs/assets/draw.io/SendEmail.xml b/docs/assets/draw.io/SendEmail.xml new file mode 100644 index 0000000..c6acc03 --- /dev/null +++ b/docs/assets/draw.io/SendEmail.xml @@ -0,0 +1 @@ +7VrbcqM4EP0aV+0+ZIqLufgxduzZS7I7Nc5Udh9lkG0qAlFCTux8/TYgDEgY34jHSW0eUqjRBdSnj0437pmjcP2VoXj5QH1Meobmr3vmXc8wHNuE/6lhkxv6lpYbFizwc5NeGqbBGxbGotsq8HFS68gpJTyI60aPRhH2eM2GGKOv9W5zSuqrxmiBFcPUQ0S1PgU+Xwqrbg/KG7/hYLEUS7uGk98IUdFZvEmyRD59rZjMcc8cMUp5fhWuR5ike1fsSz5usuPu9sEYjvghA7DrJrPX5NZ/mv/59Pi34Zhvf9xY+SwviKzECyc48nuGTWDO4YzB1SK9CnGSpDuVvwnfFNsDnW/TXYaWR1CSBB50XvKQgEGHyxfMeAC7eUuCRQS2MPD9dOyQoBkmQ+Q9LxhdRf6IEsrgfkSj9K76buJ1E7piHm55IUO8EUdsgXlLR1PMiP0aAMTefcU0xJxtoMNr6fYCu8uKwwsbwwTx4KUOGyTQt9hOt13hGw3g3QxtXUwj5hFxYhbzFlPkby5GVd0sT2RJE2nSRPnOKBOBG9Gm0i1OOyS7H9h06+voTg1+cJHPWLQqe1qaMogeAVddV/CK4jhDrKaCllMw0xWf0fXRwG3HpwzrGeWchmcBt8DjfuC6VwVcW8KBIePtUOA6pgQoOQI6Am7fkdZxLwHco4lWvtExgi8BVOuqgOpIQDX7JwLVNWTE2xcBarER7wtU5/MA9WApMLguoNp1v/ftU4GqSxPp78OojqRdDPsSQB0oQH3IITmiYUwTVauCAI/Ty1VI7oM5JkGGrxizAJ4Fp5AjwvyttA1flwHH0xhlUHuFHKcOXsg7OIIhbNsmBMVJMMtW1cDCsLdiCcDhO07y9Ca1gixJVxpt05bUOIfJRAqkZ+2AkCIceoY5tieT0agtJlJdgtetGC7IRYKGaFYQrmsNEDdk0qzCueLwVn9OH260UbB6/DHz3fFY/74Zv/11U2Cz4k/Fg5V9F5gEszXsWXeSIynjS7qgESJVV7YTjbTZ8+xvu9nKztb3vwWiu11g1XWI1uSDBhd04IHm1En1gIio4SogPmzhp4uoyaS7iDJk2v7pIaUy5EePqErh5ICIcg4LKPPdAkrNVtvEEYVE9hhlBEMnQfpEGdJP10nFSbpXJxUEcSU6SZcrHe6JOkmXdJIpI6IjnaTLJRP9AjqpwFwNhLlmT2IUFap9llI8dCtlPQpTYo5mSZyhKx8Cj1Ad1TTTLyjZRN6vO0dIAQAcwOuoZymro5LuVWJCouziAdYzHtpZjzk2t2iPY5WxRNlZPG3P2ENQN9oX3aiT1E2/k2BwGyctxtP5PMFcAls38DIVeCUrzwMUtVHdjFDvuZOCW0mC+nl1Y+1AEtSvK1k0JSFvDk4kwe3AXeWRrkhQIlu9fwkSVMtvU0COkLePKHlW0PrhtW2X2aJ81F5U2zZ71D5TW11ePV1XOVSuACgy+FDiMCTi0GUZ1hVxSPmVfokqk6HmUHgNIcpBIWlbpfMBJU0RPmdLGlA0fcc+D5LvL1NMNRXLyT+IFsCqnAER/1/gaD8ETOfaDgFz18eKho/B6VdicHbjPZH0NEhW4OqpaJYVkHFprfq1CGGC5yIn8mDB+6x1Z+RHjiyAsxDGvnAm+IZt/tkeUND4V9zpJOgP/snD2b9lqBO1c9jJcvRnB6txmcbzQH3G/hfHcm1Ds92BbVm6UZ9LVtE7zrAT6Km5+N2UpH/sUp255/uD5DyziUxULpH90h2VqBnC7yGK0w3LGOXzHQxju6uDwRr8xIOhOZ7UqsSHjyfrmHjaBs+eeJK/CHcXT67igXsKcnbKKfuEn2a71Fl99+p0lpoFTUMePzIUJTGEx2f052jUlT/ln0dcAT0e8GHqeulxT6lqICkL+92YEJrlz7dzKVj+Bt4c/wc= \ No newline at end of file diff --git a/docs/assets/get-it-on-fdroid.png b/docs/assets/get-it-on-fdroid.png new file mode 100644 index 0000000..4df34a9 Binary files /dev/null and b/docs/assets/get-it-on-fdroid.png differ diff --git a/docs/assets/get-it-on-play.png b/docs/assets/get-it-on-play.png new file mode 100644 index 0000000..926df0a Binary files /dev/null and b/docs/assets/get-it-on-play.png differ diff --git a/docs/assets/theme/last-changed.css b/docs/assets/theme/last-changed.css new file mode 100644 index 0000000..58aaf15 --- /dev/null +++ b/docs/assets/theme/last-changed.css @@ -0,0 +1,6 @@ +footer { + font-size: 0.8em; + text-align: center; + border-top: 1px solid var(--fg); + padding: 5px 0; +} diff --git a/docs/assets/theme/mermaid-init.js b/docs/assets/theme/mermaid-init.js new file mode 100644 index 0000000..15a7f4e --- /dev/null +++ b/docs/assets/theme/mermaid-init.js @@ -0,0 +1,35 @@ +(() => { + const darkThemes = ['ayu', 'navy', 'coal']; + const lightThemes = ['light', 'rust']; + + const classList = document.getElementsByTagName('html')[0].classList; + + let lastThemeWasLight = true; + for (const cssClass of classList) { + if (darkThemes.includes(cssClass)) { + lastThemeWasLight = false; + break; + } + } + + const theme = lastThemeWasLight ? 'default' : 'dark'; + mermaid.initialize({ startOnLoad: true, theme }); + + // Simplest way to make mermaid re-render the diagrams in the new theme is via refreshing the page + + for (const darkTheme of darkThemes) { + document.getElementById(darkTheme).addEventListener('click', () => { + if (lastThemeWasLight) { + window.location.reload(); + } + }); + } + + for (const lightTheme of lightThemes) { + document.getElementById(lightTheme).addEventListener('click', () => { + if (!lastThemeWasLight) { + window.location.reload(); + } + }); + } +})(); diff --git a/docs/assets/theme/mermaid.min.js b/docs/assets/theme/mermaid.min.js new file mode 100644 index 0000000..6e12566 --- /dev/null +++ b/docs/assets/theme/mermaid.min.js @@ -0,0 +1,2607 @@ +"use strict";var __esbuild_esm_mermaid=(()=>{var B2e=Object.create;var by=Object.defineProperty;var F2e=Object.getOwnPropertyDescriptor;var $2e=Object.getOwnPropertyNames;var z2e=Object.getPrototypeOf,G2e=Object.prototype.hasOwnProperty;var o=(t,e)=>by(t,"name",{value:e,configurable:!0});var N=(t,e)=>()=>(t&&(e=t(t=0)),e);var Mi=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports),hr=(t,e)=>{for(var r in e)by(t,r,{get:e[r],enumerable:!0})},L4=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of $2e(e))!G2e.call(t,i)&&i!==r&&by(t,i,{get:()=>e[i],enumerable:!(n=F2e(e,i))||n.enumerable});return t},Sr=(t,e,r)=>(L4(t,e,"default"),r&&L4(r,e,"default")),Sa=(t,e,r)=>(r=t!=null?B2e(z2e(t)):{},L4(e||!t||!t.__esModule?by(r,"default",{value:t,enumerable:!0}):r,t)),V2e=t=>L4(by({},"__esModule",{value:!0}),t);var R4=Mi((EC,SC)=>{"use strict";(function(t,e){typeof EC=="object"&&typeof SC<"u"?SC.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs=e()})(EC,function(){"use strict";var t=1e3,e=6e4,r=36e5,n="millisecond",i="second",a="minute",s="hour",l="day",u="week",h="month",f="quarter",d="year",p="date",m="Invalid Date",g=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,y=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,v={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:o(function(k){var L=["th","st","nd","rd"],R=k%100;return"["+k+(L[(R-20)%10]||L[R]||L[0])+"]"},"ordinal")},x=o(function(k,L,R){var O=String(k);return!O||O.length>=L?k:""+Array(L+1-O.length).join(R)+k},"m"),b={s:x,z:o(function(k){var L=-k.utcOffset(),R=Math.abs(L),O=Math.floor(R/60),M=R%60;return(L<=0?"+":"-")+x(O,2,"0")+":"+x(M,2,"0")},"z"),m:o(function k(L,R){if(L.date()1)return k(F[0])}else{var P=L.name;C[P]=L,M=P}return!O&&M&&(w=M),M||!O&&w},"t"),S=o(function(k,L){if(E(k))return k.clone();var R=typeof L=="object"?L:{};return R.date=k,R.args=arguments,new I(R)},"O"),_=b;_.l=A,_.i=E,_.w=function(k,L){return S(k,{locale:L.$L,utc:L.$u,x:L.$x,$offset:L.$offset})};var I=function(){function k(R){this.$L=A(R.locale,null,!0),this.parse(R),this.$x=this.$x||R.x||{},this[T]=!0}o(k,"M");var L=k.prototype;return L.parse=function(R){this.$d=function(O){var M=O.date,B=O.utc;if(M===null)return new Date(NaN);if(_.u(M))return new Date;if(M instanceof Date)return new Date(M);if(typeof M=="string"&&!/Z$/i.test(M)){var F=M.match(g);if(F){var P=F[2]-1||0,z=(F[7]||"0").substring(0,3);return B?new Date(Date.UTC(F[1],P,F[3]||1,F[4]||0,F[5]||0,F[6]||0,z)):new Date(F[1],P,F[3]||1,F[4]||0,F[5]||0,F[6]||0,z)}}return new Date(M)}(R),this.init()},L.init=function(){var R=this.$d;this.$y=R.getFullYear(),this.$M=R.getMonth(),this.$D=R.getDate(),this.$W=R.getDay(),this.$H=R.getHours(),this.$m=R.getMinutes(),this.$s=R.getSeconds(),this.$ms=R.getMilliseconds()},L.$utils=function(){return _},L.isValid=function(){return this.$d.toString()!==m},L.isSame=function(R,O){var M=S(R);return this.startOf(O)<=M&&M<=this.endOf(O)},L.isAfter=function(R,O){return S(R){"use strict";CF=Sa(R4(),1),eu={trace:0,debug:1,info:2,warn:3,error:4,fatal:5},Y={trace:o((...t)=>{},"trace"),debug:o((...t)=>{},"debug"),info:o((...t)=>{},"info"),warn:o((...t)=>{},"warn"),error:o((...t)=>{},"error"),fatal:o((...t)=>{},"fatal")},wy=o(function(t="fatal"){let e=eu.fatal;typeof t=="string"?t.toLowerCase()in eu&&(e=eu[t]):typeof t=="number"&&(e=t),Y.trace=()=>{},Y.debug=()=>{},Y.info=()=>{},Y.warn=()=>{},Y.error=()=>{},Y.fatal=()=>{},e<=eu.fatal&&(Y.fatal=console.error?console.error.bind(console,bo("FATAL"),"color: orange"):console.log.bind(console,"\x1B[35m",bo("FATAL"))),e<=eu.error&&(Y.error=console.error?console.error.bind(console,bo("ERROR"),"color: orange"):console.log.bind(console,"\x1B[31m",bo("ERROR"))),e<=eu.warn&&(Y.warn=console.warn?console.warn.bind(console,bo("WARN"),"color: orange"):console.log.bind(console,"\x1B[33m",bo("WARN"))),e<=eu.info&&(Y.info=console.info?console.info.bind(console,bo("INFO"),"color: lightblue"):console.log.bind(console,"\x1B[34m",bo("INFO"))),e<=eu.debug&&(Y.debug=console.debug?console.debug.bind(console,bo("DEBUG"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",bo("DEBUG"))),e<=eu.trace&&(Y.trace=console.debug?console.debug.bind(console,bo("TRACE"),"color: lightgreen"):console.log.bind(console,"\x1B[32m",bo("TRACE")))},"setLogLevel"),bo=o(t=>`%c${(0,CF.default)().format("ss.SSS")} : ${t} : `,"format")});var U2e,e0,CC,AF,N4=N(()=>{"use strict";U2e=Object.freeze({left:0,top:0,width:16,height:16}),e0=Object.freeze({rotate:0,vFlip:!1,hFlip:!1}),CC=Object.freeze({...U2e,...e0}),AF=Object.freeze({...CC,body:"",hidden:!1})});var H2e,_F,DF=N(()=>{"use strict";N4();H2e=Object.freeze({width:null,height:null}),_F=Object.freeze({...H2e,...e0})});var AC,M4,LF=N(()=>{"use strict";AC=o((t,e,r,n="")=>{let i=t.split(":");if(t.slice(0,1)==="@"){if(i.length<2||i.length>3)return null;n=i.shift().slice(1)}if(i.length>3||!i.length)return null;if(i.length>1){let l=i.pop(),u=i.pop(),h={provider:i.length>0?i[0]:n,prefix:u,name:l};return e&&!M4(h)?null:h}let a=i[0],s=a.split("-");if(s.length>1){let l={provider:n,prefix:s.shift(),name:s.join("-")};return e&&!M4(l)?null:l}if(r&&n===""){let l={provider:n,prefix:"",name:a};return e&&!M4(l,r)?null:l}return null},"stringToIcon"),M4=o((t,e)=>t?!!((e&&t.prefix===""||t.prefix)&&t.name):!1,"validateIconName")});function RF(t,e){let r={};!t.hFlip!=!e.hFlip&&(r.hFlip=!0),!t.vFlip!=!e.vFlip&&(r.vFlip=!0);let n=((t.rotate||0)+(e.rotate||0))%4;return n&&(r.rotate=n),r}var NF=N(()=>{"use strict";o(RF,"mergeIconTransformations")});function _C(t,e){let r=RF(t,e);for(let n in AF)n in e0?n in t&&!(n in r)&&(r[n]=e0[n]):n in e?r[n]=e[n]:n in t&&(r[n]=t[n]);return r}var MF=N(()=>{"use strict";N4();NF();o(_C,"mergeIconData")});function IF(t,e){let r=t.icons,n=t.aliases||Object.create(null),i=Object.create(null);function a(s){if(r[s])return i[s]=[];if(!(s in i)){i[s]=null;let l=n[s]&&n[s].parent,u=l&&a(l);u&&(i[s]=[l].concat(u))}return i[s]}return o(a,"resolve"),(e||Object.keys(r).concat(Object.keys(n))).forEach(a),i}var OF=N(()=>{"use strict";o(IF,"getIconsTree")});function PF(t,e,r){let n=t.icons,i=t.aliases||Object.create(null),a={};function s(l){a=_C(n[l]||i[l],a)}return o(s,"parse"),s(e),r.forEach(s),_C(t,a)}function DC(t,e){if(t.icons[e])return PF(t,e,[]);let r=IF(t,[e])[e];return r?PF(t,e,r):null}var BF=N(()=>{"use strict";MF();OF();o(PF,"internalGetIconData");o(DC,"getIconData")});function LC(t,e,r){if(e===1)return t;if(r=r||100,typeof t=="number")return Math.ceil(t*e*r)/r;if(typeof t!="string")return t;let n=t.split(W2e);if(n===null||!n.length)return t;let i=[],a=n.shift(),s=q2e.test(a);for(;;){if(s){let l=parseFloat(a);isNaN(l)?i.push(a):i.push(Math.ceil(l*e*r)/r)}else i.push(a);if(a=n.shift(),a===void 0)return i.join("");s=!s}}var W2e,q2e,FF=N(()=>{"use strict";W2e=/(-?[0-9.]*[0-9]+[0-9.]*)/g,q2e=/^-?[0-9.]*[0-9]+[0-9.]*$/g;o(LC,"calculateSize")});function Y2e(t,e="defs"){let r="",n=t.indexOf("<"+e);for(;n>=0;){let i=t.indexOf(">",n),a=t.indexOf("",a);if(s===-1)break;r+=t.slice(i+1,a).trim(),t=t.slice(0,n).trim()+t.slice(s+1)}return{defs:r,content:t}}function X2e(t,e){return t?""+t+""+e:e}function $F(t,e,r){let n=Y2e(t);return X2e(n.defs,e+n.content+r)}var zF=N(()=>{"use strict";o(Y2e,"splitSVGDefs");o(X2e,"mergeDefsAndContent");o($F,"wrapSVGContent")});function RC(t,e){let r={...CC,...t},n={..._F,...e},i={left:r.left,top:r.top,width:r.width,height:r.height},a=r.body;[r,n].forEach(y=>{let v=[],x=y.hFlip,b=y.vFlip,w=y.rotate;x?b?w+=2:(v.push("translate("+(i.width+i.left).toString()+" "+(0-i.top).toString()+")"),v.push("scale(-1 1)"),i.top=i.left=0):b&&(v.push("translate("+(0-i.left).toString()+" "+(i.height+i.top).toString()+")"),v.push("scale(1 -1)"),i.top=i.left=0);let C;switch(w<0&&(w-=Math.floor(w/4)*4),w=w%4,w){case 1:C=i.height/2+i.top,v.unshift("rotate(90 "+C.toString()+" "+C.toString()+")");break;case 2:v.unshift("rotate(180 "+(i.width/2+i.left).toString()+" "+(i.height/2+i.top).toString()+")");break;case 3:C=i.width/2+i.left,v.unshift("rotate(-90 "+C.toString()+" "+C.toString()+")");break}w%2===1&&(i.left!==i.top&&(C=i.left,i.left=i.top,i.top=C),i.width!==i.height&&(C=i.width,i.width=i.height,i.height=C)),v.length&&(a=$F(a,'',""))});let s=n.width,l=n.height,u=i.width,h=i.height,f,d;s===null?(d=l===null?"1em":l==="auto"?h:l,f=LC(d,u/h)):(f=s==="auto"?u:s,d=l===null?LC(f,h/u):l==="auto"?h:l);let p={},m=o((y,v)=>{j2e(v)||(p[y]=v.toString())},"setAttr");m("width",f),m("height",d);let g=[i.left,i.top,u,h];return p.viewBox=g.join(" "),{attributes:p,viewBox:g,body:a}}var j2e,GF=N(()=>{"use strict";N4();DF();FF();zF();j2e=o(t=>t==="unset"||t==="undefined"||t==="none","isUnsetKeyword");o(RC,"iconToSVG")});function NC(t,e=Q2e){let r=[],n;for(;n=K2e.exec(t);)r.push(n[1]);if(!r.length)return t;let i="suffix"+(Math.random()*16777216|Date.now()).toString(16);return r.forEach(a=>{let s=typeof e=="function"?e(a):e+(Z2e++).toString(),l=a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");t=t.replace(new RegExp('([#;"])('+l+')([")]|\\.[a-z])',"g"),"$1"+s+i+"$3")}),t=t.replace(new RegExp(i,"g"),""),t}var K2e,Q2e,Z2e,VF=N(()=>{"use strict";K2e=/\sid="(\S+)"/g,Q2e="IconifyId"+Date.now().toString(16)+(Math.random()*16777216|0).toString(16),Z2e=0;o(NC,"replaceIDs")});function MC(t,e){let r=t.indexOf("xlink:")===-1?"":' xmlns:xlink="http://www.w3.org/1999/xlink"';for(let n in e)r+=" "+n+'="'+e[n]+'"';return'"+t+""}var UF=N(()=>{"use strict";o(MC,"iconToHTML")});var WF=Mi((iit,HF)=>{"use strict";var t0=1e3,r0=t0*60,n0=r0*60,Wf=n0*24,J2e=Wf*7,exe=Wf*365.25;HF.exports=function(t,e){e=e||{};var r=typeof t;if(r==="string"&&t.length>0)return txe(t);if(r==="number"&&isFinite(t))return e.long?nxe(t):rxe(t);throw new Error("val is not a non-empty string or a valid number. val="+JSON.stringify(t))};function txe(t){if(t=String(t),!(t.length>100)){var e=/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(t);if(e){var r=parseFloat(e[1]),n=(e[2]||"ms").toLowerCase();switch(n){case"years":case"year":case"yrs":case"yr":case"y":return r*exe;case"weeks":case"week":case"w":return r*J2e;case"days":case"day":case"d":return r*Wf;case"hours":case"hour":case"hrs":case"hr":case"h":return r*n0;case"minutes":case"minute":case"mins":case"min":case"m":return r*r0;case"seconds":case"second":case"secs":case"sec":case"s":return r*t0;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return r;default:return}}}}o(txe,"parse");function rxe(t){var e=Math.abs(t);return e>=Wf?Math.round(t/Wf)+"d":e>=n0?Math.round(t/n0)+"h":e>=r0?Math.round(t/r0)+"m":e>=t0?Math.round(t/t0)+"s":t+"ms"}o(rxe,"fmtShort");function nxe(t){var e=Math.abs(t);return e>=Wf?I4(t,e,Wf,"day"):e>=n0?I4(t,e,n0,"hour"):e>=r0?I4(t,e,r0,"minute"):e>=t0?I4(t,e,t0,"second"):t+" ms"}o(nxe,"fmtLong");function I4(t,e,r,n){var i=e>=r*1.5;return Math.round(t/r)+" "+n+(i?"s":"")}o(I4,"plural")});var YF=Mi((sit,qF)=>{"use strict";function ixe(t){r.debug=r,r.default=r,r.coerce=u,r.disable=s,r.enable=i,r.enabled=l,r.humanize=WF(),r.destroy=h,Object.keys(t).forEach(f=>{r[f]=t[f]}),r.names=[],r.skips=[],r.formatters={};function e(f){let d=0;for(let p=0;p{if(E==="%%")return"%";C++;let S=r.formatters[A];if(typeof S=="function"){let _=v[C];E=S.call(x,_),v.splice(C,1),C--}return E}),r.formatArgs.call(x,v),(x.log||r.log).apply(x,v)}return o(y,"debug"),y.namespace=f,y.useColors=r.useColors(),y.color=r.selectColor(f),y.extend=n,y.destroy=r.destroy,Object.defineProperty(y,"enabled",{enumerable:!0,configurable:!1,get:o(()=>p!==null?p:(m!==r.namespaces&&(m=r.namespaces,g=r.enabled(f)),g),"get"),set:o(v=>{p=v},"set")}),typeof r.init=="function"&&r.init(y),y}o(r,"createDebug");function n(f,d){let p=r(this.namespace+(typeof d>"u"?":":d)+f);return p.log=this.log,p}o(n,"extend");function i(f){r.save(f),r.namespaces=f,r.names=[],r.skips=[];let d=(typeof f=="string"?f:"").trim().replace(" ",",").split(",").filter(Boolean);for(let p of d)p[0]==="-"?r.skips.push(p.slice(1)):r.names.push(p)}o(i,"enable");function a(f,d){let p=0,m=0,g=-1,y=0;for(;p"-"+d)].join(",");return r.enable(""),f}o(s,"disable");function l(f){for(let d of r.skips)if(a(f,d))return!1;for(let d of r.names)if(a(f,d))return!0;return!1}o(l,"enabled");function u(f){return f instanceof Error?f.stack||f.message:f}o(u,"coerce");function h(){console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.")}return o(h,"destroy"),r.enable(r.load()),r}o(ixe,"setup");qF.exports=ixe});var XF=Mi((qs,O4)=>{"use strict";qs.formatArgs=sxe;qs.save=oxe;qs.load=lxe;qs.useColors=axe;qs.storage=cxe();qs.destroy=(()=>{let t=!1;return()=>{t||(t=!0,console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."))}})();qs.colors=["#0000CC","#0000FF","#0033CC","#0033FF","#0066CC","#0066FF","#0099CC","#0099FF","#00CC00","#00CC33","#00CC66","#00CC99","#00CCCC","#00CCFF","#3300CC","#3300FF","#3333CC","#3333FF","#3366CC","#3366FF","#3399CC","#3399FF","#33CC00","#33CC33","#33CC66","#33CC99","#33CCCC","#33CCFF","#6600CC","#6600FF","#6633CC","#6633FF","#66CC00","#66CC33","#9900CC","#9900FF","#9933CC","#9933FF","#99CC00","#99CC33","#CC0000","#CC0033","#CC0066","#CC0099","#CC00CC","#CC00FF","#CC3300","#CC3333","#CC3366","#CC3399","#CC33CC","#CC33FF","#CC6600","#CC6633","#CC9900","#CC9933","#CCCC00","#CCCC33","#FF0000","#FF0033","#FF0066","#FF0099","#FF00CC","#FF00FF","#FF3300","#FF3333","#FF3366","#FF3399","#FF33CC","#FF33FF","#FF6600","#FF6633","#FF9900","#FF9933","#FFCC00","#FFCC33"];function axe(){if(typeof window<"u"&&window.process&&(window.process.type==="renderer"||window.process.__nwjs))return!0;if(typeof navigator<"u"&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/))return!1;let t;return typeof document<"u"&&document.documentElement&&document.documentElement.style&&document.documentElement.style.WebkitAppearance||typeof window<"u"&&window.console&&(window.console.firebug||window.console.exception&&window.console.table)||typeof navigator<"u"&&navigator.userAgent&&(t=navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/))&&parseInt(t[1],10)>=31||typeof navigator<"u"&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/)}o(axe,"useColors");function sxe(t){if(t[0]=(this.useColors?"%c":"")+this.namespace+(this.useColors?" %c":" ")+t[0]+(this.useColors?"%c ":" ")+"+"+O4.exports.humanize(this.diff),!this.useColors)return;let e="color: "+this.color;t.splice(1,0,e,"color: inherit");let r=0,n=0;t[0].replace(/%[a-zA-Z%]/g,i=>{i!=="%%"&&(r++,i==="%c"&&(n=r))}),t.splice(n,0,e)}o(sxe,"formatArgs");qs.log=console.debug||console.log||(()=>{});function oxe(t){try{t?qs.storage.setItem("debug",t):qs.storage.removeItem("debug")}catch{}}o(oxe,"save");function lxe(){let t;try{t=qs.storage.getItem("debug")}catch{}return!t&&typeof process<"u"&&"env"in process&&(t=process.env.DEBUG),t}o(lxe,"load");function cxe(){try{return localStorage}catch{}}o(cxe,"localstorage");O4.exports=YF()(qs);var{formatters:uxe}=O4.exports;uxe.j=function(t){try{return JSON.stringify(t)}catch(e){return"[UnexpectedJSONParseError]: "+e.message}}});var uit,jF=N(()=>{"use strict";LF();BF();GF();VF();UF();uit=Sa(XF(),1)});var OC,IC,KF,P4,hxe,wo,tu=N(()=>{"use strict";vt();jF();OC={body:'?',height:80,width:80},IC=new Map,KF=new Map,P4=o(t=>{for(let e of t){if(!e.name)throw new Error('Invalid icon loader. Must have a "name" property with non-empty string value.');if(Y.debug("Registering icon pack:",e.name),"loader"in e)KF.set(e.name,e.loader);else if("icons"in e)IC.set(e.name,e.icons);else throw Y.error("Invalid icon loader:",e),new Error('Invalid icon loader. Must have either "icons" or "loader" property.')}},"registerIconPacks"),hxe=o(async(t,e)=>{let r=AC(t,!0,e!==void 0);if(!r)throw new Error(`Invalid icon name: ${t}`);let n=r.prefix||e;if(!n)throw new Error(`Icon name must contain a prefix: ${t}`);let i=IC.get(n);if(!i){let s=KF.get(n);if(!s)throw new Error(`Icon set not found: ${r.prefix}`);try{i={...await s(),prefix:n},IC.set(n,i)}catch(l){throw Y.error(l),new Error(`Failed to load icon set: ${r.prefix}`)}}let a=DC(i,r.name);if(!a)throw new Error(`Icon not found: ${t}`);return a},"getRegisteredIconData"),wo=o(async(t,e)=>{let r;try{r=await hxe(t,e?.fallbackPrefix)}catch(a){Y.error(a),r=OC}let n=RC(r,e);return MC(NC(n.body),n.attributes)},"getIconSVG")});function B4(t){for(var e=[],r=1;r{"use strict";o(B4,"dedent")});var F4,qf,QF,$4=N(()=>{"use strict";F4=/^-{3}\s*[\n\r](.*?)[\n\r]-{3}\s*[\n\r]+/s,qf=/%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,QF=/\s*%%.*\n/gm});var i0,BC=N(()=>{"use strict";i0=class extends Error{static{o(this,"UnknownDiagramError")}constructor(e){super(e),this.name="UnknownDiagramError"}}});var Yf,a0,z4,FC,ZF,Xf=N(()=>{"use strict";vt();$4();BC();Yf={},a0=o(function(t,e){t=t.replace(F4,"").replace(qf,"").replace(QF,` +`);for(let[r,{detector:n}]of Object.entries(Yf))if(n(t,e))return r;throw new i0(`No diagram type detected matching given configuration for text: ${t}`)},"detectType"),z4=o((...t)=>{for(let{id:e,detector:r,loader:n}of t)FC(e,r,n)},"registerLazyLoadedDiagrams"),FC=o((t,e,r)=>{Yf[t]&&Y.warn(`Detector with key ${t} already exists. Overwriting.`),Yf[t]={detector:e,loader:r},Y.debug(`Detector with key ${t} added${r?" with loader":""}`)},"addDetector"),ZF=o(t=>Yf[t].loader,"getDiagramLoader")});var Ty,JF,$C=N(()=>{"use strict";Ty=function(){var t=o(function($e,Re,Ie,be){for(Ie=Ie||{},be=$e.length;be--;Ie[$e[be]]=Re);return Ie},"o"),e=[1,24],r=[1,25],n=[1,26],i=[1,27],a=[1,28],s=[1,63],l=[1,64],u=[1,65],h=[1,66],f=[1,67],d=[1,68],p=[1,69],m=[1,29],g=[1,30],y=[1,31],v=[1,32],x=[1,33],b=[1,34],w=[1,35],C=[1,36],T=[1,37],E=[1,38],A=[1,39],S=[1,40],_=[1,41],I=[1,42],D=[1,43],k=[1,44],L=[1,45],R=[1,46],O=[1,47],M=[1,48],B=[1,50],F=[1,51],P=[1,52],z=[1,53],$=[1,54],H=[1,55],Q=[1,56],j=[1,57],ie=[1,58],ne=[1,59],le=[1,60],he=[14,42],K=[14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],X=[12,14,34,36,37,38,39,40,41,42,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],te=[1,82],J=[1,83],se=[1,84],ue=[1,85],Z=[12,14,42],Se=[12,14,33,42],ce=[12,14,33,42,76,77,79,80],ae=[12,33],Oe=[34,36,37,38,39,40,41,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],ge={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,direction:5,direction_tb:6,direction_bt:7,direction_rl:8,direction_lr:9,graphConfig:10,C4_CONTEXT:11,NEWLINE:12,statements:13,EOF:14,C4_CONTAINER:15,C4_COMPONENT:16,C4_DYNAMIC:17,C4_DEPLOYMENT:18,otherStatements:19,diagramStatements:20,otherStatement:21,title:22,accDescription:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,boundaryStatement:29,boundaryStartStatement:30,boundaryStopStatement:31,boundaryStart:32,LBRACE:33,ENTERPRISE_BOUNDARY:34,attributes:35,SYSTEM_BOUNDARY:36,BOUNDARY:37,CONTAINER_BOUNDARY:38,NODE:39,NODE_L:40,NODE_R:41,RBRACE:42,diagramStatement:43,PERSON:44,PERSON_EXT:45,SYSTEM:46,SYSTEM_DB:47,SYSTEM_QUEUE:48,SYSTEM_EXT:49,SYSTEM_EXT_DB:50,SYSTEM_EXT_QUEUE:51,CONTAINER:52,CONTAINER_DB:53,CONTAINER_QUEUE:54,CONTAINER_EXT:55,CONTAINER_EXT_DB:56,CONTAINER_EXT_QUEUE:57,COMPONENT:58,COMPONENT_DB:59,COMPONENT_QUEUE:60,COMPONENT_EXT:61,COMPONENT_EXT_DB:62,COMPONENT_EXT_QUEUE:63,REL:64,BIREL:65,REL_U:66,REL_D:67,REL_L:68,REL_R:69,REL_B:70,REL_INDEX:71,UPDATE_EL_STYLE:72,UPDATE_REL_STYLE:73,UPDATE_LAYOUT_CONFIG:74,attribute:75,STR:76,STR_KEY:77,STR_VALUE:78,ATTRIBUTE:79,ATTRIBUTE_EMPTY:80,$accept:0,$end:1},terminals_:{2:"error",6:"direction_tb",7:"direction_bt",8:"direction_rl",9:"direction_lr",11:"C4_CONTEXT",12:"NEWLINE",14:"EOF",15:"C4_CONTAINER",16:"C4_COMPONENT",17:"C4_DYNAMIC",18:"C4_DEPLOYMENT",22:"title",23:"accDescription",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"LBRACE",34:"ENTERPRISE_BOUNDARY",36:"SYSTEM_BOUNDARY",37:"BOUNDARY",38:"CONTAINER_BOUNDARY",39:"NODE",40:"NODE_L",41:"NODE_R",42:"RBRACE",44:"PERSON",45:"PERSON_EXT",46:"SYSTEM",47:"SYSTEM_DB",48:"SYSTEM_QUEUE",49:"SYSTEM_EXT",50:"SYSTEM_EXT_DB",51:"SYSTEM_EXT_QUEUE",52:"CONTAINER",53:"CONTAINER_DB",54:"CONTAINER_QUEUE",55:"CONTAINER_EXT",56:"CONTAINER_EXT_DB",57:"CONTAINER_EXT_QUEUE",58:"COMPONENT",59:"COMPONENT_DB",60:"COMPONENT_QUEUE",61:"COMPONENT_EXT",62:"COMPONENT_EXT_DB",63:"COMPONENT_EXT_QUEUE",64:"REL",65:"BIREL",66:"REL_U",67:"REL_D",68:"REL_L",69:"REL_R",70:"REL_B",71:"REL_INDEX",72:"UPDATE_EL_STYLE",73:"UPDATE_REL_STYLE",74:"UPDATE_LAYOUT_CONFIG",76:"STR",77:"STR_KEY",78:"STR_VALUE",79:"ATTRIBUTE",80:"ATTRIBUTE_EMPTY"},productions_:[0,[3,1],[3,1],[5,1],[5,1],[5,1],[5,1],[4,1],[10,4],[10,4],[10,4],[10,4],[10,4],[13,1],[13,1],[13,2],[19,1],[19,2],[19,3],[21,1],[21,1],[21,2],[21,2],[21,1],[29,3],[30,3],[30,3],[30,4],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[32,2],[31,1],[20,1],[20,2],[20,3],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,1],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[43,2],[35,1],[35,2],[75,1],[75,2],[75,1],[75,1]],performAction:o(function(Re,Ie,be,W,de,re,oe){var V=re.length-1;switch(de){case 3:W.setDirection("TB");break;case 4:W.setDirection("BT");break;case 5:W.setDirection("RL");break;case 6:W.setDirection("LR");break;case 8:case 9:case 10:case 11:case 12:W.setC4Type(re[V-3]);break;case 19:W.setTitle(re[V].substring(6)),this.$=re[V].substring(6);break;case 20:W.setAccDescription(re[V].substring(15)),this.$=re[V].substring(15);break;case 21:this.$=re[V].trim(),W.setTitle(this.$);break;case 22:case 23:this.$=re[V].trim(),W.setAccDescription(this.$);break;case 28:re[V].splice(2,0,"ENTERPRISE"),W.addPersonOrSystemBoundary(...re[V]),this.$=re[V];break;case 29:re[V].splice(2,0,"SYSTEM"),W.addPersonOrSystemBoundary(...re[V]),this.$=re[V];break;case 30:W.addPersonOrSystemBoundary(...re[V]),this.$=re[V];break;case 31:re[V].splice(2,0,"CONTAINER"),W.addContainerBoundary(...re[V]),this.$=re[V];break;case 32:W.addDeploymentNode("node",...re[V]),this.$=re[V];break;case 33:W.addDeploymentNode("nodeL",...re[V]),this.$=re[V];break;case 34:W.addDeploymentNode("nodeR",...re[V]),this.$=re[V];break;case 35:W.popBoundaryParseStack();break;case 39:W.addPersonOrSystem("person",...re[V]),this.$=re[V];break;case 40:W.addPersonOrSystem("external_person",...re[V]),this.$=re[V];break;case 41:W.addPersonOrSystem("system",...re[V]),this.$=re[V];break;case 42:W.addPersonOrSystem("system_db",...re[V]),this.$=re[V];break;case 43:W.addPersonOrSystem("system_queue",...re[V]),this.$=re[V];break;case 44:W.addPersonOrSystem("external_system",...re[V]),this.$=re[V];break;case 45:W.addPersonOrSystem("external_system_db",...re[V]),this.$=re[V];break;case 46:W.addPersonOrSystem("external_system_queue",...re[V]),this.$=re[V];break;case 47:W.addContainer("container",...re[V]),this.$=re[V];break;case 48:W.addContainer("container_db",...re[V]),this.$=re[V];break;case 49:W.addContainer("container_queue",...re[V]),this.$=re[V];break;case 50:W.addContainer("external_container",...re[V]),this.$=re[V];break;case 51:W.addContainer("external_container_db",...re[V]),this.$=re[V];break;case 52:W.addContainer("external_container_queue",...re[V]),this.$=re[V];break;case 53:W.addComponent("component",...re[V]),this.$=re[V];break;case 54:W.addComponent("component_db",...re[V]),this.$=re[V];break;case 55:W.addComponent("component_queue",...re[V]),this.$=re[V];break;case 56:W.addComponent("external_component",...re[V]),this.$=re[V];break;case 57:W.addComponent("external_component_db",...re[V]),this.$=re[V];break;case 58:W.addComponent("external_component_queue",...re[V]),this.$=re[V];break;case 60:W.addRel("rel",...re[V]),this.$=re[V];break;case 61:W.addRel("birel",...re[V]),this.$=re[V];break;case 62:W.addRel("rel_u",...re[V]),this.$=re[V];break;case 63:W.addRel("rel_d",...re[V]),this.$=re[V];break;case 64:W.addRel("rel_l",...re[V]),this.$=re[V];break;case 65:W.addRel("rel_r",...re[V]),this.$=re[V];break;case 66:W.addRel("rel_b",...re[V]),this.$=re[V];break;case 67:re[V].splice(0,1),W.addRel("rel",...re[V]),this.$=re[V];break;case 68:W.updateElStyle("update_el_style",...re[V]),this.$=re[V];break;case 69:W.updateRelStyle("update_rel_style",...re[V]),this.$=re[V];break;case 70:W.updateLayoutConfig("update_layout_config",...re[V]),this.$=re[V];break;case 71:this.$=[re[V]];break;case 72:re[V].unshift(re[V-1]),this.$=re[V];break;case 73:case 75:this.$=re[V].trim();break;case 74:let xe={};xe[re[V-1].trim()]=re[V].trim(),this.$=xe;break;case 76:this.$="";break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],7:[1,6],8:[1,7],9:[1,8],10:4,11:[1,9],15:[1,10],16:[1,11],17:[1,12],18:[1,13]},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,7]},{1:[2,3]},{1:[2,4]},{1:[2,5]},{1:[2,6]},{12:[1,14]},{12:[1,15]},{12:[1,16]},{12:[1,17]},{12:[1,18]},{13:19,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le},{13:70,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le},{13:71,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le},{13:72,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le},{13:73,19:20,20:21,21:22,22:e,23:r,24:n,26:i,28:a,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le},{14:[1,74]},t(he,[2,13],{43:23,29:49,30:61,32:62,20:75,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le}),t(he,[2,14]),t(K,[2,16],{12:[1,76]}),t(he,[2,36],{12:[1,77]}),t(X,[2,19]),t(X,[2,20]),{25:[1,78]},{27:[1,79]},t(X,[2,23]),{35:80,75:81,76:te,77:J,79:se,80:ue},{35:86,75:81,76:te,77:J,79:se,80:ue},{35:87,75:81,76:te,77:J,79:se,80:ue},{35:88,75:81,76:te,77:J,79:se,80:ue},{35:89,75:81,76:te,77:J,79:se,80:ue},{35:90,75:81,76:te,77:J,79:se,80:ue},{35:91,75:81,76:te,77:J,79:se,80:ue},{35:92,75:81,76:te,77:J,79:se,80:ue},{35:93,75:81,76:te,77:J,79:se,80:ue},{35:94,75:81,76:te,77:J,79:se,80:ue},{35:95,75:81,76:te,77:J,79:se,80:ue},{35:96,75:81,76:te,77:J,79:se,80:ue},{35:97,75:81,76:te,77:J,79:se,80:ue},{35:98,75:81,76:te,77:J,79:se,80:ue},{35:99,75:81,76:te,77:J,79:se,80:ue},{35:100,75:81,76:te,77:J,79:se,80:ue},{35:101,75:81,76:te,77:J,79:se,80:ue},{35:102,75:81,76:te,77:J,79:se,80:ue},{35:103,75:81,76:te,77:J,79:se,80:ue},{35:104,75:81,76:te,77:J,79:se,80:ue},t(Z,[2,59]),{35:105,75:81,76:te,77:J,79:se,80:ue},{35:106,75:81,76:te,77:J,79:se,80:ue},{35:107,75:81,76:te,77:J,79:se,80:ue},{35:108,75:81,76:te,77:J,79:se,80:ue},{35:109,75:81,76:te,77:J,79:se,80:ue},{35:110,75:81,76:te,77:J,79:se,80:ue},{35:111,75:81,76:te,77:J,79:se,80:ue},{35:112,75:81,76:te,77:J,79:se,80:ue},{35:113,75:81,76:te,77:J,79:se,80:ue},{35:114,75:81,76:te,77:J,79:se,80:ue},{35:115,75:81,76:te,77:J,79:se,80:ue},{20:116,29:49,30:61,32:62,34:s,36:l,37:u,38:h,39:f,40:d,41:p,43:23,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le},{12:[1,118],33:[1,117]},{35:119,75:81,76:te,77:J,79:se,80:ue},{35:120,75:81,76:te,77:J,79:se,80:ue},{35:121,75:81,76:te,77:J,79:se,80:ue},{35:122,75:81,76:te,77:J,79:se,80:ue},{35:123,75:81,76:te,77:J,79:se,80:ue},{35:124,75:81,76:te,77:J,79:se,80:ue},{35:125,75:81,76:te,77:J,79:se,80:ue},{14:[1,126]},{14:[1,127]},{14:[1,128]},{14:[1,129]},{1:[2,8]},t(he,[2,15]),t(K,[2,17],{21:22,19:130,22:e,23:r,24:n,26:i,28:a}),t(he,[2,37],{19:20,20:21,21:22,43:23,29:49,30:61,32:62,13:131,22:e,23:r,24:n,26:i,28:a,34:s,36:l,37:u,38:h,39:f,40:d,41:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w,51:C,52:T,53:E,54:A,55:S,56:_,57:I,58:D,59:k,60:L,61:R,62:O,63:M,64:B,65:F,66:P,67:z,68:$,69:H,70:Q,71:j,72:ie,73:ne,74:le}),t(X,[2,21]),t(X,[2,22]),t(Z,[2,39]),t(Se,[2,71],{75:81,35:132,76:te,77:J,79:se,80:ue}),t(ce,[2,73]),{78:[1,133]},t(ce,[2,75]),t(ce,[2,76]),t(Z,[2,40]),t(Z,[2,41]),t(Z,[2,42]),t(Z,[2,43]),t(Z,[2,44]),t(Z,[2,45]),t(Z,[2,46]),t(Z,[2,47]),t(Z,[2,48]),t(Z,[2,49]),t(Z,[2,50]),t(Z,[2,51]),t(Z,[2,52]),t(Z,[2,53]),t(Z,[2,54]),t(Z,[2,55]),t(Z,[2,56]),t(Z,[2,57]),t(Z,[2,58]),t(Z,[2,60]),t(Z,[2,61]),t(Z,[2,62]),t(Z,[2,63]),t(Z,[2,64]),t(Z,[2,65]),t(Z,[2,66]),t(Z,[2,67]),t(Z,[2,68]),t(Z,[2,69]),t(Z,[2,70]),{31:134,42:[1,135]},{12:[1,136]},{33:[1,137]},t(ae,[2,28]),t(ae,[2,29]),t(ae,[2,30]),t(ae,[2,31]),t(ae,[2,32]),t(ae,[2,33]),t(ae,[2,34]),{1:[2,9]},{1:[2,10]},{1:[2,11]},{1:[2,12]},t(K,[2,18]),t(he,[2,38]),t(Se,[2,72]),t(ce,[2,74]),t(Z,[2,24]),t(Z,[2,35]),t(Oe,[2,25]),t(Oe,[2,26],{12:[1,138]}),t(Oe,[2,27])],defaultActions:{2:[2,1],3:[2,2],4:[2,7],5:[2,3],6:[2,4],7:[2,5],8:[2,6],74:[2,8],126:[2,9],127:[2,10],128:[2,11],129:[2,12]},parseError:o(function(Re,Ie){if(Ie.recoverable)this.trace(Re);else{var be=new Error(Re);throw be.hash=Ie,be}},"parseError"),parse:o(function(Re){var Ie=this,be=[0],W=[],de=[null],re=[],oe=this.table,V="",xe=0,q=0,pe=0,ve=2,Pe=1,_e=re.slice.call(arguments,1),we=Object.create(this.lexer),Ve={yy:{}};for(var De in this.yy)Object.prototype.hasOwnProperty.call(this.yy,De)&&(Ve.yy[De]=this.yy[De]);we.setInput(Re,Ve.yy),Ve.yy.lexer=we,Ve.yy.parser=this,typeof we.yylloc>"u"&&(we.yylloc={});var qe=we.yylloc;re.push(qe);var at=we.options&&we.options.ranges;typeof Ve.yy.parseError=="function"?this.parseError=Ve.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Rt(nt){be.length=be.length-2*nt,de.length=de.length-nt,re.length=re.length-nt}o(Rt,"popStack");function st(){var nt;return nt=W.pop()||we.lex()||Pe,typeof nt!="number"&&(nt instanceof Array&&(W=nt,nt=W.pop()),nt=Ie.symbols_[nt]||nt),nt}o(st,"lex");for(var Ue,ct,We,ot,Yt,bt,Mt={},xt,ut,Et,ft;;){if(We=be[be.length-1],this.defaultActions[We]?ot=this.defaultActions[We]:((Ue===null||typeof Ue>"u")&&(Ue=st()),ot=oe[We]&&oe[We][Ue]),typeof ot>"u"||!ot.length||!ot[0]){var yt="";ft=[];for(xt in oe[We])this.terminals_[xt]&&xt>ve&&ft.push("'"+this.terminals_[xt]+"'");we.showPosition?yt="Parse error on line "+(xe+1)+`: +`+we.showPosition()+` +Expecting `+ft.join(", ")+", got '"+(this.terminals_[Ue]||Ue)+"'":yt="Parse error on line "+(xe+1)+": Unexpected "+(Ue==Pe?"end of input":"'"+(this.terminals_[Ue]||Ue)+"'"),this.parseError(yt,{text:we.match,token:this.terminals_[Ue]||Ue,line:we.yylineno,loc:qe,expected:ft})}if(ot[0]instanceof Array&&ot.length>1)throw new Error("Parse Error: multiple actions possible at state: "+We+", token: "+Ue);switch(ot[0]){case 1:be.push(Ue),de.push(we.yytext),re.push(we.yylloc),be.push(ot[1]),Ue=null,ct?(Ue=ct,ct=null):(q=we.yyleng,V=we.yytext,xe=we.yylineno,qe=we.yylloc,pe>0&&pe--);break;case 2:if(ut=this.productions_[ot[1]][1],Mt.$=de[de.length-ut],Mt._$={first_line:re[re.length-(ut||1)].first_line,last_line:re[re.length-1].last_line,first_column:re[re.length-(ut||1)].first_column,last_column:re[re.length-1].last_column},at&&(Mt._$.range=[re[re.length-(ut||1)].range[0],re[re.length-1].range[1]]),bt=this.performAction.apply(Mt,[V,q,xe,Ve.yy,ot[1],de,re].concat(_e)),typeof bt<"u")return bt;ut&&(be=be.slice(0,-1*ut*2),de=de.slice(0,-1*ut),re=re.slice(0,-1*ut)),be.push(this.productions_[ot[1]][0]),de.push(Mt.$),re.push(Mt._$),Et=oe[be[be.length-2]][be[be.length-1]],be.push(Et);break;case 3:return!0}}return!0},"parse")},ze=function(){var $e={EOF:1,parseError:o(function(Ie,be){if(this.yy.parser)this.yy.parser.parseError(Ie,be);else throw new Error(Ie)},"parseError"),setInput:o(function(Re,Ie){return this.yy=Ie||this.yy||{},this._input=Re,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var Re=this._input[0];this.yytext+=Re,this.yyleng++,this.offset++,this.match+=Re,this.matched+=Re;var Ie=Re.match(/(?:\r\n?|\n).*/g);return Ie?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),Re},"input"),unput:o(function(Re){var Ie=Re.length,be=Re.split(/(?:\r\n?|\n)/g);this._input=Re+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-Ie),this.offset-=Ie;var W=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),be.length-1&&(this.yylineno-=be.length-1);var de=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:be?(be.length===W.length?this.yylloc.first_column:0)+W[W.length-be.length].length-be[0].length:this.yylloc.first_column-Ie},this.options.ranges&&(this.yylloc.range=[de[0],de[0]+this.yyleng-Ie]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(Re){this.unput(this.match.slice(Re))},"less"),pastInput:o(function(){var Re=this.matched.substr(0,this.matched.length-this.match.length);return(Re.length>20?"...":"")+Re.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var Re=this.match;return Re.length<20&&(Re+=this._input.substr(0,20-Re.length)),(Re.substr(0,20)+(Re.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var Re=this.pastInput(),Ie=new Array(Re.length+1).join("-");return Re+this.upcomingInput()+` +`+Ie+"^"},"showPosition"),test_match:o(function(Re,Ie){var be,W,de;if(this.options.backtrack_lexer&&(de={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(de.yylloc.range=this.yylloc.range.slice(0))),W=Re[0].match(/(?:\r\n?|\n).*/g),W&&(this.yylineno+=W.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:W?W[W.length-1].length-W[W.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+Re[0].length},this.yytext+=Re[0],this.match+=Re[0],this.matches=Re,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(Re[0].length),this.matched+=Re[0],be=this.performAction.call(this,this.yy,this,Ie,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),be)return be;if(this._backtrack){for(var re in de)this[re]=de[re];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var Re,Ie,be,W;this._more||(this.yytext="",this.match="");for(var de=this._currentRules(),re=0;reIe[0].length)){if(Ie=be,W=re,this.options.backtrack_lexer){if(Re=this.test_match(be,de[re]),Re!==!1)return Re;if(this._backtrack){Ie=!1;continue}else return!1}else if(!this.options.flex)break}return Ie?(Re=this.test_match(Ie,de[W]),Re!==!1?Re:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var Ie=this.next();return Ie||this.lex()},"lex"),begin:o(function(Ie){this.conditionStack.push(Ie)},"begin"),popState:o(function(){var Ie=this.conditionStack.length-1;return Ie>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(Ie){return Ie=this.conditionStack.length-1-Math.abs(Ie||0),Ie>=0?this.conditionStack[Ie]:"INITIAL"},"topState"),pushState:o(function(Ie){this.begin(Ie)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(Ie,be,W,de){var re=de;switch(W){case 0:return 6;case 1:return 7;case 2:return 8;case 3:return 9;case 4:return 22;case 5:return 23;case 6:return this.begin("acc_title"),24;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),26;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:break;case 14:c;break;case 15:return 12;case 16:break;case 17:return 11;case 18:return 15;case 19:return 16;case 20:return 17;case 21:return 18;case 22:return this.begin("person_ext"),45;break;case 23:return this.begin("person"),44;break;case 24:return this.begin("system_ext_queue"),51;break;case 25:return this.begin("system_ext_db"),50;break;case 26:return this.begin("system_ext"),49;break;case 27:return this.begin("system_queue"),48;break;case 28:return this.begin("system_db"),47;break;case 29:return this.begin("system"),46;break;case 30:return this.begin("boundary"),37;break;case 31:return this.begin("enterprise_boundary"),34;break;case 32:return this.begin("system_boundary"),36;break;case 33:return this.begin("container_ext_queue"),57;break;case 34:return this.begin("container_ext_db"),56;break;case 35:return this.begin("container_ext"),55;break;case 36:return this.begin("container_queue"),54;break;case 37:return this.begin("container_db"),53;break;case 38:return this.begin("container"),52;break;case 39:return this.begin("container_boundary"),38;break;case 40:return this.begin("component_ext_queue"),63;break;case 41:return this.begin("component_ext_db"),62;break;case 42:return this.begin("component_ext"),61;break;case 43:return this.begin("component_queue"),60;break;case 44:return this.begin("component_db"),59;break;case 45:return this.begin("component"),58;break;case 46:return this.begin("node"),39;break;case 47:return this.begin("node"),39;break;case 48:return this.begin("node_l"),40;break;case 49:return this.begin("node_r"),41;break;case 50:return this.begin("rel"),64;break;case 51:return this.begin("birel"),65;break;case 52:return this.begin("rel_u"),66;break;case 53:return this.begin("rel_u"),66;break;case 54:return this.begin("rel_d"),67;break;case 55:return this.begin("rel_d"),67;break;case 56:return this.begin("rel_l"),68;break;case 57:return this.begin("rel_l"),68;break;case 58:return this.begin("rel_r"),69;break;case 59:return this.begin("rel_r"),69;break;case 60:return this.begin("rel_b"),70;break;case 61:return this.begin("rel_index"),71;break;case 62:return this.begin("update_el_style"),72;break;case 63:return this.begin("update_rel_style"),73;break;case 64:return this.begin("update_layout_config"),74;break;case 65:return"EOF_IN_STRUCT";case 66:return this.begin("attribute"),"ATTRIBUTE_EMPTY";break;case 67:this.begin("attribute");break;case 68:this.popState(),this.popState();break;case 69:return 80;case 70:break;case 71:return 80;case 72:this.begin("string");break;case 73:this.popState();break;case 74:return"STR";case 75:this.begin("string_kv");break;case 76:return this.begin("string_kv_key"),"STR_KEY";break;case 77:this.popState(),this.begin("string_kv_value");break;case 78:return"STR_VALUE";case 79:this.popState(),this.popState();break;case 80:return"STR";case 81:return"LBRACE";case 82:return"RBRACE";case 83:return"SPACE";case 84:return"EOL";case 85:return 14}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:title\s[^#\n;]+)/,/^(?:accDescription\s[^#\n;]+)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:C4Context\b)/,/^(?:C4Container\b)/,/^(?:C4Component\b)/,/^(?:C4Dynamic\b)/,/^(?:C4Deployment\b)/,/^(?:Person_Ext\b)/,/^(?:Person\b)/,/^(?:SystemQueue_Ext\b)/,/^(?:SystemDb_Ext\b)/,/^(?:System_Ext\b)/,/^(?:SystemQueue\b)/,/^(?:SystemDb\b)/,/^(?:System\b)/,/^(?:Boundary\b)/,/^(?:Enterprise_Boundary\b)/,/^(?:System_Boundary\b)/,/^(?:ContainerQueue_Ext\b)/,/^(?:ContainerDb_Ext\b)/,/^(?:Container_Ext\b)/,/^(?:ContainerQueue\b)/,/^(?:ContainerDb\b)/,/^(?:Container\b)/,/^(?:Container_Boundary\b)/,/^(?:ComponentQueue_Ext\b)/,/^(?:ComponentDb_Ext\b)/,/^(?:Component_Ext\b)/,/^(?:ComponentQueue\b)/,/^(?:ComponentDb\b)/,/^(?:Component\b)/,/^(?:Deployment_Node\b)/,/^(?:Node\b)/,/^(?:Node_L\b)/,/^(?:Node_R\b)/,/^(?:Rel\b)/,/^(?:BiRel\b)/,/^(?:Rel_Up\b)/,/^(?:Rel_U\b)/,/^(?:Rel_Down\b)/,/^(?:Rel_D\b)/,/^(?:Rel_Left\b)/,/^(?:Rel_L\b)/,/^(?:Rel_Right\b)/,/^(?:Rel_R\b)/,/^(?:Rel_Back\b)/,/^(?:RelIndex\b)/,/^(?:UpdateElementStyle\b)/,/^(?:UpdateRelStyle\b)/,/^(?:UpdateLayoutConfig\b)/,/^(?:$)/,/^(?:[(][ ]*[,])/,/^(?:[(])/,/^(?:[)])/,/^(?:,,)/,/^(?:,)/,/^(?:[ ]*["]["])/,/^(?:[ ]*["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:[ ]*[\$])/,/^(?:[^=]*)/,/^(?:[=][ ]*["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:[^,]+)/,/^(?:\{)/,/^(?:\})/,/^(?:[\s]+)/,/^(?:[\n\r]+)/,/^(?:$)/],conditions:{acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},string_kv_value:{rules:[78,79],inclusive:!1},string_kv_key:{rules:[77],inclusive:!1},string_kv:{rules:[76],inclusive:!1},string:{rules:[73,74],inclusive:!1},attribute:{rules:[68,69,70,71,72,75,80],inclusive:!1},update_layout_config:{rules:[65,66,67,68],inclusive:!1},update_rel_style:{rules:[65,66,67,68],inclusive:!1},update_el_style:{rules:[65,66,67,68],inclusive:!1},rel_b:{rules:[65,66,67,68],inclusive:!1},rel_r:{rules:[65,66,67,68],inclusive:!1},rel_l:{rules:[65,66,67,68],inclusive:!1},rel_d:{rules:[65,66,67,68],inclusive:!1},rel_u:{rules:[65,66,67,68],inclusive:!1},rel_bi:{rules:[],inclusive:!1},rel:{rules:[65,66,67,68],inclusive:!1},node_r:{rules:[65,66,67,68],inclusive:!1},node_l:{rules:[65,66,67,68],inclusive:!1},node:{rules:[65,66,67,68],inclusive:!1},index:{rules:[],inclusive:!1},rel_index:{rules:[65,66,67,68],inclusive:!1},component_ext_queue:{rules:[],inclusive:!1},component_ext_db:{rules:[65,66,67,68],inclusive:!1},component_ext:{rules:[65,66,67,68],inclusive:!1},component_queue:{rules:[65,66,67,68],inclusive:!1},component_db:{rules:[65,66,67,68],inclusive:!1},component:{rules:[65,66,67,68],inclusive:!1},container_boundary:{rules:[65,66,67,68],inclusive:!1},container_ext_queue:{rules:[65,66,67,68],inclusive:!1},container_ext_db:{rules:[65,66,67,68],inclusive:!1},container_ext:{rules:[65,66,67,68],inclusive:!1},container_queue:{rules:[65,66,67,68],inclusive:!1},container_db:{rules:[65,66,67,68],inclusive:!1},container:{rules:[65,66,67,68],inclusive:!1},birel:{rules:[65,66,67,68],inclusive:!1},system_boundary:{rules:[65,66,67,68],inclusive:!1},enterprise_boundary:{rules:[65,66,67,68],inclusive:!1},boundary:{rules:[65,66,67,68],inclusive:!1},system_ext_queue:{rules:[65,66,67,68],inclusive:!1},system_ext_db:{rules:[65,66,67,68],inclusive:!1},system_ext:{rules:[65,66,67,68],inclusive:!1},system_queue:{rules:[65,66,67,68],inclusive:!1},system_db:{rules:[65,66,67,68],inclusive:!1},system:{rules:[65,66,67,68],inclusive:!1},person_ext:{rules:[65,66,67,68],inclusive:!1},person:{rules:[65,66,67,68],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,81,82,83,84,85],inclusive:!0}}};return $e}();ge.lexer=ze;function He(){this.yy={}}return o(He,"Parser"),He.prototype=ge,ge.Parser=He,new He}();Ty.parser=Ty;JF=Ty});var zC,Gn,s0=N(()=>{"use strict";zC=o((t,e,{depth:r=2,clobber:n=!1}={})=>{let i={depth:r,clobber:n};return Array.isArray(e)&&!Array.isArray(t)?(e.forEach(a=>zC(t,a,i)),t):Array.isArray(e)&&Array.isArray(t)?(e.forEach(a=>{t.includes(a)||t.push(a)}),t):t===void 0||r<=0?t!=null&&typeof t=="object"&&typeof e=="object"?Object.assign(t,e):e:(e!==void 0&&typeof t=="object"&&typeof e=="object"&&Object.keys(e).forEach(a=>{typeof e[a]=="object"&&(t[a]===void 0||typeof t[a]=="object")?(t[a]===void 0&&(t[a]=Array.isArray(e[a])?[]:{}),t[a]=zC(t[a],e[a],{depth:r-1,clobber:n})):(n||typeof t[a]!="object"&&typeof e[a]!="object")&&(t[a]=e[a])}),t)},"assignWithDepth"),Gn=zC});var G4,e$,t$=N(()=>{"use strict";G4={min:{r:0,g:0,b:0,s:0,l:0,a:0},max:{r:255,g:255,b:255,h:360,s:100,l:100,a:1},clamp:{r:o(t=>t>=255?255:t<0?0:t,"r"),g:o(t=>t>=255?255:t<0?0:t,"g"),b:o(t=>t>=255?255:t<0?0:t,"b"),h:o(t=>t%360,"h"),s:o(t=>t>=100?100:t<0?0:t,"s"),l:o(t=>t>=100?100:t<0?0:t,"l"),a:o(t=>t>=1?1:t<0?0:t,"a")},toLinear:o(t=>{let e=t/255;return t>.03928?Math.pow((e+.055)/1.055,2.4):e/12.92},"toLinear"),hue2rgb:o((t,e,r)=>(r<0&&(r+=1),r>1&&(r-=1),r<.16666666666666666?t+(e-t)*6*r:r<.5?e:r<.6666666666666666?t+(e-t)*(.6666666666666666-r)*6:t),"hue2rgb"),hsl2rgb:o(({h:t,s:e,l:r},n)=>{if(!e)return r*2.55;t/=360,e/=100,r/=100;let i=r<.5?r*(1+e):r+e-r*e,a=2*r-i;switch(n){case"r":return G4.hue2rgb(a,i,t+.3333333333333333)*255;case"g":return G4.hue2rgb(a,i,t)*255;case"b":return G4.hue2rgb(a,i,t-.3333333333333333)*255}},"hsl2rgb"),rgb2hsl:o(({r:t,g:e,b:r},n)=>{t/=255,e/=255,r/=255;let i=Math.max(t,e,r),a=Math.min(t,e,r),s=(i+a)/2;if(n==="l")return s*100;if(i===a)return 0;let l=i-a,u=s>.5?l/(2-i-a):l/(i+a);if(n==="s")return u*100;switch(i){case t:return((e-r)/l+(e{"use strict";fxe={clamp:o((t,e,r)=>e>r?Math.min(e,Math.max(r,t)):Math.min(r,Math.max(e,t)),"clamp"),round:o(t=>Math.round(t*1e10)/1e10,"round")},r$=fxe});var dxe,i$,a$=N(()=>{"use strict";dxe={dec2hex:o(t=>{let e=Math.round(t).toString(16);return e.length>1?e:`0${e}`},"dec2hex")},i$=dxe});var pxe,jt,Wl=N(()=>{"use strict";t$();n$();a$();pxe={channel:e$,lang:r$,unit:i$},jt=pxe});var ru,Ii,ky=N(()=>{"use strict";Wl();ru={};for(let t=0;t<=255;t++)ru[t]=jt.unit.dec2hex(t);Ii={ALL:0,RGB:1,HSL:2}});var GC,s$,o$=N(()=>{"use strict";ky();GC=class{static{o(this,"Type")}constructor(){this.type=Ii.ALL}get(){return this.type}set(e){if(this.type&&this.type!==e)throw new Error("Cannot change both RGB and HSL channels at the same time");this.type=e}reset(){this.type=Ii.ALL}is(e){return this.type===e}},s$=GC});var VC,l$,c$=N(()=>{"use strict";Wl();o$();ky();VC=class{static{o(this,"Channels")}constructor(e,r){this.color=r,this.changed=!1,this.data=e,this.type=new s$}set(e,r){return this.color=r,this.changed=!1,this.data=e,this.type.type=Ii.ALL,this}_ensureHSL(){let e=this.data,{h:r,s:n,l:i}=e;r===void 0&&(e.h=jt.channel.rgb2hsl(e,"h")),n===void 0&&(e.s=jt.channel.rgb2hsl(e,"s")),i===void 0&&(e.l=jt.channel.rgb2hsl(e,"l"))}_ensureRGB(){let e=this.data,{r,g:n,b:i}=e;r===void 0&&(e.r=jt.channel.hsl2rgb(e,"r")),n===void 0&&(e.g=jt.channel.hsl2rgb(e,"g")),i===void 0&&(e.b=jt.channel.hsl2rgb(e,"b"))}get r(){let e=this.data,r=e.r;return!this.type.is(Ii.HSL)&&r!==void 0?r:(this._ensureHSL(),jt.channel.hsl2rgb(e,"r"))}get g(){let e=this.data,r=e.g;return!this.type.is(Ii.HSL)&&r!==void 0?r:(this._ensureHSL(),jt.channel.hsl2rgb(e,"g"))}get b(){let e=this.data,r=e.b;return!this.type.is(Ii.HSL)&&r!==void 0?r:(this._ensureHSL(),jt.channel.hsl2rgb(e,"b"))}get h(){let e=this.data,r=e.h;return!this.type.is(Ii.RGB)&&r!==void 0?r:(this._ensureRGB(),jt.channel.rgb2hsl(e,"h"))}get s(){let e=this.data,r=e.s;return!this.type.is(Ii.RGB)&&r!==void 0?r:(this._ensureRGB(),jt.channel.rgb2hsl(e,"s"))}get l(){let e=this.data,r=e.l;return!this.type.is(Ii.RGB)&&r!==void 0?r:(this._ensureRGB(),jt.channel.rgb2hsl(e,"l"))}get a(){return this.data.a}set r(e){this.type.set(Ii.RGB),this.changed=!0,this.data.r=e}set g(e){this.type.set(Ii.RGB),this.changed=!0,this.data.g=e}set b(e){this.type.set(Ii.RGB),this.changed=!0,this.data.b=e}set h(e){this.type.set(Ii.HSL),this.changed=!0,this.data.h=e}set s(e){this.type.set(Ii.HSL),this.changed=!0,this.data.s=e}set l(e){this.type.set(Ii.HSL),this.changed=!0,this.data.l=e}set a(e){this.changed=!0,this.data.a=e}},l$=VC});var mxe,ih,Ey=N(()=>{"use strict";c$();mxe=new l$({r:0,g:0,b:0,a:0},"transparent"),ih=mxe});var u$,jf,UC=N(()=>{"use strict";Ey();ky();u$={re:/^#((?:[a-f0-9]{2}){2,4}|[a-f0-9]{3})$/i,parse:o(t=>{if(t.charCodeAt(0)!==35)return;let e=t.match(u$.re);if(!e)return;let r=e[1],n=parseInt(r,16),i=r.length,a=i%4===0,s=i>4,l=s?1:17,u=s?8:4,h=a?0:-1,f=s?255:15;return ih.set({r:(n>>u*(h+3)&f)*l,g:(n>>u*(h+2)&f)*l,b:(n>>u*(h+1)&f)*l,a:a?(n&f)*l/255:1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`#${ru[Math.round(e)]}${ru[Math.round(r)]}${ru[Math.round(n)]}${ru[Math.round(i*255)]}`:`#${ru[Math.round(e)]}${ru[Math.round(r)]}${ru[Math.round(n)]}`},"stringify")},jf=u$});var V4,Sy,h$=N(()=>{"use strict";Wl();Ey();V4={re:/^hsla?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(?:deg|grad|rad|turn)?)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?%)(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e-?\d+)?(%)?))?\s*?\)$/i,hueRe:/^(.+?)(deg|grad|rad|turn)$/i,_hue2deg:o(t=>{let e=t.match(V4.hueRe);if(e){let[,r,n]=e;switch(n){case"grad":return jt.channel.clamp.h(parseFloat(r)*.9);case"rad":return jt.channel.clamp.h(parseFloat(r)*180/Math.PI);case"turn":return jt.channel.clamp.h(parseFloat(r)*360)}}return jt.channel.clamp.h(parseFloat(t))},"_hue2deg"),parse:o(t=>{let e=t.charCodeAt(0);if(e!==104&&e!==72)return;let r=t.match(V4.re);if(!r)return;let[,n,i,a,s,l]=r;return ih.set({h:V4._hue2deg(n),s:jt.channel.clamp.s(parseFloat(i)),l:jt.channel.clamp.l(parseFloat(a)),a:s?jt.channel.clamp.a(l?parseFloat(s)/100:parseFloat(s)):1},t)},"parse"),stringify:o(t=>{let{h:e,s:r,l:n,a:i}=t;return i<1?`hsla(${jt.lang.round(e)}, ${jt.lang.round(r)}%, ${jt.lang.round(n)}%, ${i})`:`hsl(${jt.lang.round(e)}, ${jt.lang.round(r)}%, ${jt.lang.round(n)}%)`},"stringify")},Sy=V4});var U4,HC,f$=N(()=>{"use strict";UC();U4={colors:{aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyanaqua:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",transparent:"#00000000",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"},parse:o(t=>{t=t.toLowerCase();let e=U4.colors[t];if(e)return jf.parse(e)},"parse"),stringify:o(t=>{let e=jf.stringify(t);for(let r in U4.colors)if(U4.colors[r]===e)return r},"stringify")},HC=U4});var d$,Cy,p$=N(()=>{"use strict";Wl();Ey();d$={re:/^rgba?\(\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))\s*?(?:,|\s)\s*?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?))(?:\s*?(?:,|\/)\s*?\+?(-?(?:\d+(?:\.\d+)?|(?:\.\d+))(?:e\d+)?(%?)))?\s*?\)$/i,parse:o(t=>{let e=t.charCodeAt(0);if(e!==114&&e!==82)return;let r=t.match(d$.re);if(!r)return;let[,n,i,a,s,l,u,h,f]=r;return ih.set({r:jt.channel.clamp.r(i?parseFloat(n)*2.55:parseFloat(n)),g:jt.channel.clamp.g(s?parseFloat(a)*2.55:parseFloat(a)),b:jt.channel.clamp.b(u?parseFloat(l)*2.55:parseFloat(l)),a:h?jt.channel.clamp.a(f?parseFloat(h)/100:parseFloat(h)):1},t)},"parse"),stringify:o(t=>{let{r:e,g:r,b:n,a:i}=t;return i<1?`rgba(${jt.lang.round(e)}, ${jt.lang.round(r)}, ${jt.lang.round(n)}, ${jt.lang.round(i)})`:`rgb(${jt.lang.round(e)}, ${jt.lang.round(r)}, ${jt.lang.round(n)})`},"stringify")},Cy=d$});var gxe,Oi,nu=N(()=>{"use strict";UC();h$();f$();p$();ky();gxe={format:{keyword:HC,hex:jf,rgb:Cy,rgba:Cy,hsl:Sy,hsla:Sy},parse:o(t=>{if(typeof t!="string")return t;let e=jf.parse(t)||Cy.parse(t)||Sy.parse(t)||HC.parse(t);if(e)return e;throw new Error(`Unsupported color format: "${t}"`)},"parse"),stringify:o(t=>!t.changed&&t.color?t.color:t.type.is(Ii.HSL)||t.data.r===void 0?Sy.stringify(t):t.a<1||!Number.isInteger(t.r)||!Number.isInteger(t.g)||!Number.isInteger(t.b)?Cy.stringify(t):jf.stringify(t),"stringify")},Oi=gxe});var yxe,H4,WC=N(()=>{"use strict";Wl();nu();yxe=o((t,e)=>{let r=Oi.parse(t);for(let n in e)r[n]=jt.channel.clamp[n](e[n]);return Oi.stringify(r)},"change"),H4=yxe});var vxe,qa,qC=N(()=>{"use strict";Wl();Ey();nu();WC();vxe=o((t,e,r=0,n=1)=>{if(typeof t!="number")return H4(t,{a:e});let i=ih.set({r:jt.channel.clamp.r(t),g:jt.channel.clamp.g(e),b:jt.channel.clamp.b(r),a:jt.channel.clamp.a(n)});return Oi.stringify(i)},"rgba"),qa=vxe});var xxe,Kf,m$=N(()=>{"use strict";Wl();nu();xxe=o((t,e)=>jt.lang.round(Oi.parse(t)[e]),"channel"),Kf=xxe});var bxe,g$,y$=N(()=>{"use strict";Wl();nu();bxe=o(t=>{let{r:e,g:r,b:n}=Oi.parse(t),i=.2126*jt.channel.toLinear(e)+.7152*jt.channel.toLinear(r)+.0722*jt.channel.toLinear(n);return jt.lang.round(i)},"luminance"),g$=bxe});var wxe,v$,x$=N(()=>{"use strict";y$();wxe=o(t=>g$(t)>=.5,"isLight"),v$=wxe});var Txe,ca,b$=N(()=>{"use strict";x$();Txe=o(t=>!v$(t),"isDark"),ca=Txe});var kxe,W4,YC=N(()=>{"use strict";Wl();nu();kxe=o((t,e,r)=>{let n=Oi.parse(t),i=n[e],a=jt.channel.clamp[e](i+r);return i!==a&&(n[e]=a),Oi.stringify(n)},"adjustChannel"),W4=kxe});var Exe,Dt,w$=N(()=>{"use strict";YC();Exe=o((t,e)=>W4(t,"l",e),"lighten"),Dt=Exe});var Sxe,Ot,T$=N(()=>{"use strict";YC();Sxe=o((t,e)=>W4(t,"l",-e),"darken"),Ot=Sxe});var Cxe,Me,k$=N(()=>{"use strict";nu();WC();Cxe=o((t,e)=>{let r=Oi.parse(t),n={};for(let i in e)e[i]&&(n[i]=r[i]+e[i]);return H4(t,n)},"adjust"),Me=Cxe});var Axe,E$,S$=N(()=>{"use strict";nu();qC();Axe=o((t,e,r=50)=>{let{r:n,g:i,b:a,a:s}=Oi.parse(t),{r:l,g:u,b:h,a:f}=Oi.parse(e),d=r/100,p=d*2-1,m=s-f,y=((p*m===-1?p:(p+m)/(1+p*m))+1)/2,v=1-y,x=n*y+l*v,b=i*y+u*v,w=a*y+h*v,C=s*d+f*(1-d);return qa(x,b,w,C)},"mix"),E$=Axe});var _xe,wt,C$=N(()=>{"use strict";nu();S$();_xe=o((t,e=100)=>{let r=Oi.parse(t);return r.r=255-r.r,r.g=255-r.g,r.b=255-r.b,E$(r,t,e)},"invert"),wt=_xe});var A$=N(()=>{"use strict";qC();m$();b$();w$();T$();k$();C$()});var Ys=N(()=>{"use strict";A$()});var ah,sh,Ay=N(()=>{"use strict";ah="#ffffff",sh="#f2f2f2"});var Ti,o0=N(()=>{"use strict";Ys();Ti=o((t,e)=>e?Me(t,{s:-40,l:10}):Me(t,{s:-40,l:-10}),"mkBorder")});var jC,_$,D$=N(()=>{"use strict";Ys();Ay();o0();jC=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#fff4dd",this.noteBkgColor="#fff5ad",this.noteTextColor="#333",this.THEME_COLOR_LIMIT=12,this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px"}updateColors(){if(this.primaryTextColor=this.primaryTextColor||(this.darkMode?"#eee":"#333"),this.secondaryColor=this.secondaryColor||Me(this.primaryColor,{h:-120}),this.tertiaryColor=this.tertiaryColor||Me(this.primaryColor,{h:180,l:5}),this.primaryBorderColor=this.primaryBorderColor||Ti(this.primaryColor,this.darkMode),this.secondaryBorderColor=this.secondaryBorderColor||Ti(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=this.tertiaryBorderColor||Ti(this.tertiaryColor,this.darkMode),this.noteBorderColor=this.noteBorderColor||Ti(this.noteBkgColor,this.darkMode),this.noteBkgColor=this.noteBkgColor||"#fff5ad",this.noteTextColor=this.noteTextColor||"#333",this.secondaryTextColor=this.secondaryTextColor||wt(this.secondaryColor),this.tertiaryTextColor=this.tertiaryTextColor||wt(this.tertiaryColor),this.lineColor=this.lineColor||wt(this.background),this.arrowheadColor=this.arrowheadColor||wt(this.background),this.textColor=this.textColor||this.primaryTextColor,this.border2=this.border2||this.tertiaryBorderColor,this.nodeBkg=this.nodeBkg||this.primaryColor,this.mainBkg=this.mainBkg||this.primaryColor,this.nodeBorder=this.nodeBorder||this.primaryBorderColor,this.clusterBkg=this.clusterBkg||this.tertiaryColor,this.clusterBorder=this.clusterBorder||this.tertiaryBorderColor,this.defaultLinkColor=this.defaultLinkColor||this.lineColor,this.titleColor=this.titleColor||this.tertiaryTextColor,this.edgeLabelBackground=this.edgeLabelBackground||(this.darkMode?Ot(this.secondaryColor,30):this.secondaryColor),this.nodeTextColor=this.nodeTextColor||this.primaryTextColor,this.actorBorder=this.actorBorder||this.primaryBorderColor,this.actorBkg=this.actorBkg||this.mainBkg,this.actorTextColor=this.actorTextColor||this.primaryTextColor,this.actorLineColor=this.actorLineColor||this.actorBorder,this.labelBoxBkgColor=this.labelBoxBkgColor||this.actorBkg,this.signalColor=this.signalColor||this.textColor,this.signalTextColor=this.signalTextColor||this.textColor,this.labelBoxBorderColor=this.labelBoxBorderColor||this.actorBorder,this.labelTextColor=this.labelTextColor||this.actorTextColor,this.loopTextColor=this.loopTextColor||this.actorTextColor,this.activationBorderColor=this.activationBorderColor||Ot(this.secondaryColor,10),this.activationBkgColor=this.activationBkgColor||this.secondaryColor,this.sequenceNumberColor=this.sequenceNumberColor||wt(this.lineColor),this.sectionBkgColor=this.sectionBkgColor||this.tertiaryColor,this.altSectionBkgColor=this.altSectionBkgColor||"white",this.sectionBkgColor=this.sectionBkgColor||this.secondaryColor,this.sectionBkgColor2=this.sectionBkgColor2||this.primaryColor,this.excludeBkgColor=this.excludeBkgColor||"#eeeeee",this.taskBorderColor=this.taskBorderColor||this.primaryBorderColor,this.taskBkgColor=this.taskBkgColor||this.primaryColor,this.activeTaskBorderColor=this.activeTaskBorderColor||this.primaryColor,this.activeTaskBkgColor=this.activeTaskBkgColor||Dt(this.primaryColor,23),this.gridColor=this.gridColor||"lightgrey",this.doneTaskBkgColor=this.doneTaskBkgColor||"lightgrey",this.doneTaskBorderColor=this.doneTaskBorderColor||"grey",this.critBorderColor=this.critBorderColor||"#ff8888",this.critBkgColor=this.critBkgColor||"red",this.todayLineColor=this.todayLineColor||"red",this.taskTextColor=this.taskTextColor||this.textColor,this.taskTextOutsideColor=this.taskTextOutsideColor||this.textColor,this.taskTextLightColor=this.taskTextLightColor||this.textColor,this.taskTextColor=this.taskTextColor||this.primaryTextColor,this.taskTextDarkColor=this.taskTextDarkColor||this.textColor,this.taskTextClickableColor=this.taskTextClickableColor||"#003163",this.personBorder=this.personBorder||this.primaryBorderColor,this.personBkg=this.personBkg||this.mainBkg,this.darkMode?(this.rowOdd=this.rowOdd||Ot(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Ot(this.mainBkg,10)):(this.rowOdd=this.rowOdd||Dt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||Dt(this.mainBkg,5)),this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||this.tertiaryColor,this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.nodeBorder,this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.transitionColor=this.transitionColor||this.lineColor,this.specialStateColor=this.lineColor,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Me(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Me(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Me(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Me(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Me(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Me(this.primaryColor,{h:210,l:150}),this.cScale9=this.cScale9||Me(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Me(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Me(this.primaryColor,{h:330}),this.darkMode)for(let r=0;r{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},_$=o(t=>{let e=new jC;return e.calculate(t),e},"getThemeVariables")});var KC,L$,R$=N(()=>{"use strict";Ys();o0();KC=class{static{o(this,"Theme")}constructor(){this.background="#333",this.primaryColor="#1f2020",this.secondaryColor=Dt(this.primaryColor,16),this.tertiaryColor=Me(this.primaryColor,{h:-160}),this.primaryBorderColor=wt(this.background),this.secondaryBorderColor=Ti(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=Ti(this.tertiaryColor,this.darkMode),this.primaryTextColor=wt(this.primaryColor),this.secondaryTextColor=wt(this.secondaryColor),this.tertiaryTextColor=wt(this.tertiaryColor),this.lineColor=wt(this.background),this.textColor=wt(this.background),this.mainBkg="#1f2020",this.secondBkg="calculated",this.mainContrastColor="lightgrey",this.darkTextColor=Dt(wt("#323D47"),10),this.lineColor="calculated",this.border1="#ccc",this.border2=qa(255,255,255,.25),this.arrowheadColor="calculated",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="#181818",this.textColor="#ccc",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#F9FFFE",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="calculated",this.activationBkgColor="calculated",this.sequenceNumberColor="black",this.sectionBkgColor=Ot("#EAE8D9",30),this.altSectionBkgColor="calculated",this.sectionBkgColor2="#EAE8D9",this.excludeBkgColor=Ot(this.sectionBkgColor,10),this.taskBorderColor=qa(255,255,255,70),this.taskBkgColor="calculated",this.taskTextColor="calculated",this.taskTextLightColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor=qa(255,255,255,50),this.activeTaskBkgColor="#81B1DB",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="grey",this.critBorderColor="#E83737",this.critBkgColor="#E83737",this.taskTextDarkColor="calculated",this.todayLineColor="#DB5757",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Dt(this.mainBkg,5)||"#ffffff",this.rowEven=this.rowEven||Ot(this.mainBkg,10),this.labelColor="calculated",this.errorBkgColor="#a44141",this.errorTextColor="#ddd"}updateColors(){this.secondBkg=Dt(this.mainBkg,16),this.lineColor=this.mainContrastColor,this.arrowheadColor=this.mainContrastColor,this.nodeBkg=this.mainBkg,this.nodeBorder=this.border1,this.clusterBkg=this.secondBkg,this.clusterBorder=this.border2,this.defaultLinkColor=this.lineColor,this.edgeLabelBackground=Dt(this.labelBackground,25),this.actorBorder=this.border1,this.actorBkg=this.mainBkg,this.actorTextColor=this.mainContrastColor,this.actorLineColor=this.actorBorder,this.signalColor=this.mainContrastColor,this.signalTextColor=this.mainContrastColor,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.mainContrastColor,this.loopTextColor=this.mainContrastColor,this.noteBorderColor=this.secondaryBorderColor,this.noteBkgColor=this.secondBkg,this.noteTextColor=this.secondaryTextColor,this.activationBorderColor=this.border1,this.activationBkgColor=this.secondBkg,this.altSectionBkgColor=this.background,this.taskBkgColor=Dt(this.mainBkg,23),this.taskTextColor=this.darkTextColor,this.taskTextLightColor=this.mainContrastColor,this.taskTextOutsideColor=this.taskTextLightColor,this.gridColor=this.mainContrastColor,this.doneTaskBkgColor=this.mainContrastColor,this.taskTextDarkColor=this.darkTextColor,this.archEdgeColor=this.lineColor,this.archEdgeArrowColor=this.lineColor,this.transitionColor=this.transitionColor||this.lineColor,this.transitionLabelColor=this.transitionLabelColor||this.textColor,this.stateLabelColor=this.stateLabelColor||this.stateBkg||this.primaryTextColor,this.stateBkg=this.stateBkg||this.mainBkg,this.labelBackgroundColor=this.labelBackgroundColor||this.stateBkg,this.compositeBackground=this.compositeBackground||this.background||this.tertiaryColor,this.altBackground=this.altBackground||"#555",this.compositeTitleBackground=this.compositeTitleBackground||this.mainBkg,this.compositeBorder=this.compositeBorder||this.nodeBorder,this.innerEndBackground=this.primaryBorderColor,this.specialStateColor="#f4f4f4",this.errorBkgColor=this.errorBkgColor||this.tertiaryColor,this.errorTextColor=this.errorTextColor||this.tertiaryTextColor,this.fillType0=this.primaryColor,this.fillType1=this.secondaryColor,this.fillType2=Me(this.primaryColor,{h:64}),this.fillType3=Me(this.secondaryColor,{h:64}),this.fillType4=Me(this.primaryColor,{h:-64}),this.fillType5=Me(this.secondaryColor,{h:-64}),this.fillType6=Me(this.primaryColor,{h:128}),this.fillType7=Me(this.secondaryColor,{h:128}),this.cScale1=this.cScale1||"#0b0000",this.cScale2=this.cScale2||"#4d1037",this.cScale3=this.cScale3||"#3f5258",this.cScale4=this.cScale4||"#4f2f1b",this.cScale5=this.cScale5||"#6e0a0a",this.cScale6=this.cScale6||"#3b0048",this.cScale7=this.cScale7||"#995a01",this.cScale8=this.cScale8||"#154706",this.cScale9=this.cScale9||"#161722",this.cScale10=this.cScale10||"#00296f",this.cScale11=this.cScale11||"#01629c",this.cScale12=this.cScale12||"#010029",this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Me(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Me(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Me(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Me(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Me(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Me(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Me(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Me(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Me(this.primaryColor,{h:330});for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},L$=o(t=>{let e=new KC;return e.calculate(t),e},"getThemeVariables")});var QC,oh,_y=N(()=>{"use strict";Ys();o0();Ay();QC=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#ECECFF",this.secondaryColor=Me(this.primaryColor,{h:120}),this.secondaryColor="#ffffde",this.tertiaryColor=Me(this.primaryColor,{h:-160}),this.primaryBorderColor=Ti(this.primaryColor,this.darkMode),this.secondaryBorderColor=Ti(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=Ti(this.tertiaryColor,this.darkMode),this.primaryTextColor=wt(this.primaryColor),this.secondaryTextColor=wt(this.secondaryColor),this.tertiaryTextColor=wt(this.tertiaryColor),this.lineColor=wt(this.background),this.textColor=wt(this.background),this.background="white",this.mainBkg="#ECECFF",this.secondBkg="#ffffde",this.lineColor="#333333",this.border1="#9370DB",this.border2="#aaaa33",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.labelBackground="rgba(232,232,232, 0.8)",this.textColor="#333",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="calculated",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="calculated",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="calculated",this.taskTextColor=this.taskTextLightColor,this.taskTextDarkColor="calculated",this.taskTextOutsideColor=this.taskTextDarkColor,this.taskTextClickableColor="calculated",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBorderColor="calculated",this.critBkgColor="calculated",this.todayLineColor="calculated",this.sectionBkgColor=qa(102,102,255,.49),this.altSectionBkgColor="white",this.sectionBkgColor2="#fff400",this.taskBorderColor="#534fbc",this.taskBkgColor="#8a90dd",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="#534fbc",this.activeTaskBkgColor="#bfc7ff",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd="calculated",this.rowEven="calculated",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222",this.updateColors()}updateColors(){this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Me(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Me(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Me(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Me(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Me(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Me(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Me(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Me(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Me(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Ot(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Ot(this.tertiaryColor,40);for(let e=0;e{this[n]==="calculated"&&(this[n]=void 0)}),typeof e!="object"){this.updateColors();return}let r=Object.keys(e);r.forEach(n=>{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},oh=o(t=>{let e=new QC;return e.calculate(t),e},"getThemeVariables")});var ZC,N$,M$=N(()=>{"use strict";Ys();Ay();o0();ZC=class{static{o(this,"Theme")}constructor(){this.background="#f4f4f4",this.primaryColor="#cde498",this.secondaryColor="#cdffb2",this.background="white",this.mainBkg="#cde498",this.secondBkg="#cdffb2",this.lineColor="green",this.border1="#13540c",this.border2="#6eaa49",this.arrowheadColor="green",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.tertiaryColor=Dt("#cde498",10),this.primaryBorderColor=Ti(this.primaryColor,this.darkMode),this.secondaryBorderColor=Ti(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=Ti(this.tertiaryColor,this.darkMode),this.primaryTextColor=wt(this.primaryColor),this.secondaryTextColor=wt(this.secondaryColor),this.tertiaryTextColor=wt(this.primaryColor),this.lineColor=wt(this.background),this.textColor=wt(this.background),this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="#333",this.edgeLabelBackground="#e8e8e8",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="black",this.actorLineColor="calculated",this.signalColor="#333",this.signalTextColor="#333",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="#326932",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="#fff5ad",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="#6eaa49",this.altSectionBkgColor="white",this.sectionBkgColor2="#6eaa49",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="#487e3a",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="black",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="lightgrey",this.doneTaskBkgColor="lightgrey",this.doneTaskBorderColor="grey",this.critBorderColor="#ff8888",this.critBkgColor="red",this.todayLineColor="red",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.actorBorder=Ot(this.mainBkg,20),this.actorBkg=this.mainBkg,this.labelBoxBkgColor=this.actorBkg,this.labelTextColor=this.actorTextColor,this.loopTextColor=this.actorTextColor,this.noteBorderColor=this.border2,this.noteTextColor=this.actorTextColor,this.actorLineColor=this.actorBorder,this.cScale0=this.cScale0||this.primaryColor,this.cScale1=this.cScale1||this.secondaryColor,this.cScale2=this.cScale2||this.tertiaryColor,this.cScale3=this.cScale3||Me(this.primaryColor,{h:30}),this.cScale4=this.cScale4||Me(this.primaryColor,{h:60}),this.cScale5=this.cScale5||Me(this.primaryColor,{h:90}),this.cScale6=this.cScale6||Me(this.primaryColor,{h:120}),this.cScale7=this.cScale7||Me(this.primaryColor,{h:150}),this.cScale8=this.cScale8||Me(this.primaryColor,{h:210}),this.cScale9=this.cScale9||Me(this.primaryColor,{h:270}),this.cScale10=this.cScale10||Me(this.primaryColor,{h:300}),this.cScale11=this.cScale11||Me(this.primaryColor,{h:330}),this.cScalePeer1=this.cScalePeer1||Ot(this.secondaryColor,45),this.cScalePeer2=this.cScalePeer2||Ot(this.tertiaryColor,40);for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},N$=o(t=>{let e=new ZC;return e.calculate(t),e},"getThemeVariables")});var JC,I$,O$=N(()=>{"use strict";Ys();o0();Ay();JC=class{static{o(this,"Theme")}constructor(){this.primaryColor="#eee",this.contrast="#707070",this.secondaryColor=Dt(this.contrast,55),this.background="#ffffff",this.tertiaryColor=Me(this.primaryColor,{h:-160}),this.primaryBorderColor=Ti(this.primaryColor,this.darkMode),this.secondaryBorderColor=Ti(this.secondaryColor,this.darkMode),this.tertiaryBorderColor=Ti(this.tertiaryColor,this.darkMode),this.primaryTextColor=wt(this.primaryColor),this.secondaryTextColor=wt(this.secondaryColor),this.tertiaryTextColor=wt(this.tertiaryColor),this.lineColor=wt(this.background),this.textColor=wt(this.background),this.mainBkg="#eee",this.secondBkg="calculated",this.lineColor="#666",this.border1="#999",this.border2="calculated",this.note="#ffa",this.text="#333",this.critical="#d42",this.done="#bbb",this.arrowheadColor="#333333",this.fontFamily='"trebuchet ms", verdana, arial, sans-serif',this.fontSize="16px",this.THEME_COLOR_LIMIT=12,this.nodeBkg="calculated",this.nodeBorder="calculated",this.clusterBkg="calculated",this.clusterBorder="calculated",this.defaultLinkColor="calculated",this.titleColor="calculated",this.edgeLabelBackground="white",this.actorBorder="calculated",this.actorBkg="calculated",this.actorTextColor="calculated",this.actorLineColor=this.actorBorder,this.signalColor="calculated",this.signalTextColor="calculated",this.labelBoxBkgColor="calculated",this.labelBoxBorderColor="calculated",this.labelTextColor="calculated",this.loopTextColor="calculated",this.noteBorderColor="calculated",this.noteBkgColor="calculated",this.noteTextColor="calculated",this.activationBorderColor="#666",this.activationBkgColor="#f4f4f4",this.sequenceNumberColor="white",this.sectionBkgColor="calculated",this.altSectionBkgColor="white",this.sectionBkgColor2="calculated",this.excludeBkgColor="#eeeeee",this.taskBorderColor="calculated",this.taskBkgColor="calculated",this.taskTextLightColor="white",this.taskTextColor="calculated",this.taskTextDarkColor="calculated",this.taskTextOutsideColor="calculated",this.taskTextClickableColor="#003163",this.activeTaskBorderColor="calculated",this.activeTaskBkgColor="calculated",this.gridColor="calculated",this.doneTaskBkgColor="calculated",this.doneTaskBorderColor="calculated",this.critBkgColor="calculated",this.critBorderColor="calculated",this.todayLineColor="calculated",this.personBorder=this.primaryBorderColor,this.personBkg=this.mainBkg,this.archEdgeColor="calculated",this.archEdgeArrowColor="calculated",this.archEdgeWidth="3",this.archGroupBorderColor=this.primaryBorderColor,this.archGroupBorderWidth="2px",this.rowOdd=this.rowOdd||Dt(this.mainBkg,75)||"#ffffff",this.rowEven=this.rowEven||"#f4f4f4",this.labelColor="black",this.errorBkgColor="#552222",this.errorTextColor="#552222"}updateColors(){this.secondBkg=Dt(this.contrast,55),this.border2=this.contrast,this.actorBorder=Dt(this.border1,23),this.actorBkg=this.mainBkg,this.actorTextColor=this.text,this.actorLineColor=this.actorBorder,this.signalColor=this.text,this.signalTextColor=this.text,this.labelBoxBkgColor=this.actorBkg,this.labelBoxBorderColor=this.actorBorder,this.labelTextColor=this.text,this.loopTextColor=this.text,this.noteBorderColor="#999",this.noteBkgColor="#666",this.noteTextColor="#fff",this.cScale0=this.cScale0||"#555",this.cScale1=this.cScale1||"#F4F4F4",this.cScale2=this.cScale2||"#555",this.cScale3=this.cScale3||"#BBB",this.cScale4=this.cScale4||"#777",this.cScale5=this.cScale5||"#999",this.cScale6=this.cScale6||"#DDD",this.cScale7=this.cScale7||"#FFF",this.cScale8=this.cScale8||"#DDD",this.cScale9=this.cScale9||"#BBB",this.cScale10=this.cScale10||"#999",this.cScale11=this.cScale11||"#777";for(let e=0;e{this[n]=e[n]}),this.updateColors(),r.forEach(n=>{this[n]=e[n]})}},I$=o(t=>{let e=new JC;return e.calculate(t),e},"getThemeVariables")});var To,q4=N(()=>{"use strict";D$();R$();_y();M$();O$();To={base:{getThemeVariables:_$},dark:{getThemeVariables:L$},default:{getThemeVariables:oh},forest:{getThemeVariables:N$},neutral:{getThemeVariables:I$}}});var ql,P$=N(()=>{"use strict";ql={flowchart:{useMaxWidth:!0,titleTopMargin:25,subGraphTitleMargin:{top:0,bottom:0},diagramPadding:8,htmlLabels:!0,nodeSpacing:50,rankSpacing:50,curve:"basis",padding:15,defaultRenderer:"dagre-wrapper",wrappingWidth:200},sequence:{useMaxWidth:!0,hideUnusedParticipants:!1,activationWidth:10,diagramMarginX:50,diagramMarginY:10,actorMargin:50,width:150,height:65,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",mirrorActors:!0,forceMenus:!1,bottomMarginAdj:1,rightAngles:!1,showSequenceNumbers:!1,actorFontSize:14,actorFontFamily:'"Open Sans", sans-serif',actorFontWeight:400,noteFontSize:14,noteFontFamily:'"trebuchet ms", verdana, arial, sans-serif',noteFontWeight:400,noteAlign:"center",messageFontSize:16,messageFontFamily:'"trebuchet ms", verdana, arial, sans-serif',messageFontWeight:400,wrap:!1,wrapPadding:10,labelBoxWidth:50,labelBoxHeight:20},gantt:{useMaxWidth:!0,titleTopMargin:25,barHeight:20,barGap:4,topPadding:50,rightPadding:75,leftPadding:75,gridLineStartPadding:35,fontSize:11,sectionFontSize:11,numberSectionStyles:4,axisFormat:"%Y-%m-%d",topAxis:!1,displayMode:"",weekday:"sunday"},journey:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"]},class:{useMaxWidth:!0,titleTopMargin:25,arrowMarkerAbsolute:!1,dividerMargin:10,padding:5,textHeight:10,defaultRenderer:"dagre-wrapper",htmlLabels:!1,hideEmptyMembersBox:!1},state:{useMaxWidth:!0,titleTopMargin:25,dividerMargin:10,sizeUnit:5,padding:8,textHeight:10,titleShift:-15,noteMargin:10,forkWidth:70,forkHeight:7,miniPadding:2,fontSizeFactor:5.02,fontSize:24,labelHeight:16,edgeLengthFactor:"20",compositTitleSize:35,radius:5,defaultRenderer:"dagre-wrapper"},er:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:20,layoutDirection:"TB",minEntityWidth:100,minEntityHeight:75,entityPadding:15,nodeSpacing:140,rankSpacing:80,stroke:"gray",fill:"honeydew",fontSize:12},pie:{useMaxWidth:!0,textPosition:.75},quadrantChart:{useMaxWidth:!0,chartWidth:500,chartHeight:500,titleFontSize:20,titlePadding:10,quadrantPadding:5,xAxisLabelPadding:5,yAxisLabelPadding:5,xAxisLabelFontSize:16,yAxisLabelFontSize:16,quadrantLabelFontSize:16,quadrantTextTopPadding:5,pointTextPadding:5,pointLabelFontSize:12,pointRadius:5,xAxisPosition:"top",yAxisPosition:"left",quadrantInternalBorderStrokeWidth:1,quadrantExternalBorderStrokeWidth:2},xyChart:{useMaxWidth:!0,width:700,height:500,titleFontSize:20,titlePadding:10,showTitle:!0,xAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},yAxis:{$ref:"#/$defs/XYChartAxisConfig",showLabel:!0,labelFontSize:14,labelPadding:5,showTitle:!0,titleFontSize:16,titlePadding:5,showTick:!0,tickLength:5,tickWidth:2,showAxisLine:!0,axisLineWidth:2},chartOrientation:"vertical",plotReservedSpacePercent:50},requirement:{useMaxWidth:!0,rect_fill:"#f9f9f9",text_color:"#333",rect_border_size:"0.5px",rect_border_color:"#bbb",rect_min_width:200,rect_min_height:200,fontSize:14,rect_padding:10,line_height:20},mindmap:{useMaxWidth:!0,padding:10,maxNodeWidth:200},kanban:{useMaxWidth:!0,padding:8,sectionWidth:200,ticketBaseUrl:""},timeline:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,leftMargin:150,width:150,height:50,boxMargin:10,boxTextMargin:5,noteMargin:10,messageMargin:35,messageAlign:"center",bottomMarginAdj:1,rightAngles:!1,taskFontSize:14,taskFontFamily:'"Open Sans", sans-serif',taskMargin:50,activationWidth:10,textPlacement:"fo",actorColours:["#8FBC8F","#7CFC00","#00FFFF","#20B2AA","#B0E0E6","#FFFFE0"],sectionFills:["#191970","#8B008B","#4B0082","#2F4F4F","#800000","#8B4513","#00008B"],sectionColours:["#fff"],disableMulticolor:!1},gitGraph:{useMaxWidth:!0,titleTopMargin:25,diagramPadding:8,nodeLabel:{width:75,height:100,x:-25,y:0},mainBranchName:"main",mainBranchOrder:0,showCommitLabel:!0,showBranches:!0,rotateCommitLabel:!0,parallelCommits:!1,arrowMarkerAbsolute:!1},c4:{useMaxWidth:!0,diagramMarginX:50,diagramMarginY:10,c4ShapeMargin:50,c4ShapePadding:20,width:216,height:60,boxMargin:10,c4ShapeInRow:4,nextLinePaddingX:0,c4BoundaryInRow:2,personFontSize:14,personFontFamily:'"Open Sans", sans-serif',personFontWeight:"normal",external_personFontSize:14,external_personFontFamily:'"Open Sans", sans-serif',external_personFontWeight:"normal",systemFontSize:14,systemFontFamily:'"Open Sans", sans-serif',systemFontWeight:"normal",external_systemFontSize:14,external_systemFontFamily:'"Open Sans", sans-serif',external_systemFontWeight:"normal",system_dbFontSize:14,system_dbFontFamily:'"Open Sans", sans-serif',system_dbFontWeight:"normal",external_system_dbFontSize:14,external_system_dbFontFamily:'"Open Sans", sans-serif',external_system_dbFontWeight:"normal",system_queueFontSize:14,system_queueFontFamily:'"Open Sans", sans-serif',system_queueFontWeight:"normal",external_system_queueFontSize:14,external_system_queueFontFamily:'"Open Sans", sans-serif',external_system_queueFontWeight:"normal",boundaryFontSize:14,boundaryFontFamily:'"Open Sans", sans-serif',boundaryFontWeight:"normal",messageFontSize:12,messageFontFamily:'"Open Sans", sans-serif',messageFontWeight:"normal",containerFontSize:14,containerFontFamily:'"Open Sans", sans-serif',containerFontWeight:"normal",external_containerFontSize:14,external_containerFontFamily:'"Open Sans", sans-serif',external_containerFontWeight:"normal",container_dbFontSize:14,container_dbFontFamily:'"Open Sans", sans-serif',container_dbFontWeight:"normal",external_container_dbFontSize:14,external_container_dbFontFamily:'"Open Sans", sans-serif',external_container_dbFontWeight:"normal",container_queueFontSize:14,container_queueFontFamily:'"Open Sans", sans-serif',container_queueFontWeight:"normal",external_container_queueFontSize:14,external_container_queueFontFamily:'"Open Sans", sans-serif',external_container_queueFontWeight:"normal",componentFontSize:14,componentFontFamily:'"Open Sans", sans-serif',componentFontWeight:"normal",external_componentFontSize:14,external_componentFontFamily:'"Open Sans", sans-serif',external_componentFontWeight:"normal",component_dbFontSize:14,component_dbFontFamily:'"Open Sans", sans-serif',component_dbFontWeight:"normal",external_component_dbFontSize:14,external_component_dbFontFamily:'"Open Sans", sans-serif',external_component_dbFontWeight:"normal",component_queueFontSize:14,component_queueFontFamily:'"Open Sans", sans-serif',component_queueFontWeight:"normal",external_component_queueFontSize:14,external_component_queueFontFamily:'"Open Sans", sans-serif',external_component_queueFontWeight:"normal",wrap:!0,wrapPadding:10,person_bg_color:"#08427B",person_border_color:"#073B6F",external_person_bg_color:"#686868",external_person_border_color:"#8A8A8A",system_bg_color:"#1168BD",system_border_color:"#3C7FC0",system_db_bg_color:"#1168BD",system_db_border_color:"#3C7FC0",system_queue_bg_color:"#1168BD",system_queue_border_color:"#3C7FC0",external_system_bg_color:"#999999",external_system_border_color:"#8A8A8A",external_system_db_bg_color:"#999999",external_system_db_border_color:"#8A8A8A",external_system_queue_bg_color:"#999999",external_system_queue_border_color:"#8A8A8A",container_bg_color:"#438DD5",container_border_color:"#3C7FC0",container_db_bg_color:"#438DD5",container_db_border_color:"#3C7FC0",container_queue_bg_color:"#438DD5",container_queue_border_color:"#3C7FC0",external_container_bg_color:"#B3B3B3",external_container_border_color:"#A6A6A6",external_container_db_bg_color:"#B3B3B3",external_container_db_border_color:"#A6A6A6",external_container_queue_bg_color:"#B3B3B3",external_container_queue_border_color:"#A6A6A6",component_bg_color:"#85BBF0",component_border_color:"#78A8D8",component_db_bg_color:"#85BBF0",component_db_border_color:"#78A8D8",component_queue_bg_color:"#85BBF0",component_queue_border_color:"#78A8D8",external_component_bg_color:"#CCCCCC",external_component_border_color:"#BFBFBF",external_component_db_bg_color:"#CCCCCC",external_component_db_border_color:"#BFBFBF",external_component_queue_bg_color:"#CCCCCC",external_component_queue_border_color:"#BFBFBF"},sankey:{useMaxWidth:!0,width:600,height:400,linkColor:"gradient",nodeAlignment:"justify",showValues:!0,prefix:"",suffix:""},block:{useMaxWidth:!0,padding:8},packet:{useMaxWidth:!0,rowHeight:32,bitWidth:32,bitsPerRow:32,showBits:!0,paddingX:5,paddingY:5},architecture:{useMaxWidth:!0,padding:40,iconSize:80,fontSize:16},radar:{useMaxWidth:!0,width:600,height:600,marginTop:50,marginRight:50,marginBottom:50,marginLeft:50,axisScaleFactor:1,axisLabelFactor:1.05,curveTension:.17},theme:"default",look:"classic",handDrawnSeed:0,layout:"dagre",maxTextSize:5e4,maxEdges:500,darkMode:!1,fontFamily:'"trebuchet ms", verdana, arial, sans-serif;',logLevel:5,securityLevel:"strict",startOnLoad:!0,arrowMarkerAbsolute:!1,secure:["secure","securityLevel","startOnLoad","maxTextSize","suppressErrorRendering","maxEdges"],legacyMathML:!1,forceLegacyMathML:!1,deterministicIds:!1,fontSize:16,markdownAutoWrap:!0,suppressErrorRendering:!1}});var B$,F$,$$,or,Ya=N(()=>{"use strict";q4();P$();B$={...ql,deterministicIDSeed:void 0,elk:{mergeEdges:!1,nodePlacementStrategy:"BRANDES_KOEPF"},themeCSS:void 0,themeVariables:To.default.getThemeVariables(),sequence:{...ql.sequence,messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont"),noteFont:o(function(){return{fontFamily:this.noteFontFamily,fontSize:this.noteFontSize,fontWeight:this.noteFontWeight}},"noteFont"),actorFont:o(function(){return{fontFamily:this.actorFontFamily,fontSize:this.actorFontSize,fontWeight:this.actorFontWeight}},"actorFont")},class:{hideEmptyMembersBox:!1},gantt:{...ql.gantt,tickInterval:void 0,useWidth:void 0},c4:{...ql.c4,useWidth:void 0,personFont:o(function(){return{fontFamily:this.personFontFamily,fontSize:this.personFontSize,fontWeight:this.personFontWeight}},"personFont"),external_personFont:o(function(){return{fontFamily:this.external_personFontFamily,fontSize:this.external_personFontSize,fontWeight:this.external_personFontWeight}},"external_personFont"),systemFont:o(function(){return{fontFamily:this.systemFontFamily,fontSize:this.systemFontSize,fontWeight:this.systemFontWeight}},"systemFont"),external_systemFont:o(function(){return{fontFamily:this.external_systemFontFamily,fontSize:this.external_systemFontSize,fontWeight:this.external_systemFontWeight}},"external_systemFont"),system_dbFont:o(function(){return{fontFamily:this.system_dbFontFamily,fontSize:this.system_dbFontSize,fontWeight:this.system_dbFontWeight}},"system_dbFont"),external_system_dbFont:o(function(){return{fontFamily:this.external_system_dbFontFamily,fontSize:this.external_system_dbFontSize,fontWeight:this.external_system_dbFontWeight}},"external_system_dbFont"),system_queueFont:o(function(){return{fontFamily:this.system_queueFontFamily,fontSize:this.system_queueFontSize,fontWeight:this.system_queueFontWeight}},"system_queueFont"),external_system_queueFont:o(function(){return{fontFamily:this.external_system_queueFontFamily,fontSize:this.external_system_queueFontSize,fontWeight:this.external_system_queueFontWeight}},"external_system_queueFont"),containerFont:o(function(){return{fontFamily:this.containerFontFamily,fontSize:this.containerFontSize,fontWeight:this.containerFontWeight}},"containerFont"),external_containerFont:o(function(){return{fontFamily:this.external_containerFontFamily,fontSize:this.external_containerFontSize,fontWeight:this.external_containerFontWeight}},"external_containerFont"),container_dbFont:o(function(){return{fontFamily:this.container_dbFontFamily,fontSize:this.container_dbFontSize,fontWeight:this.container_dbFontWeight}},"container_dbFont"),external_container_dbFont:o(function(){return{fontFamily:this.external_container_dbFontFamily,fontSize:this.external_container_dbFontSize,fontWeight:this.external_container_dbFontWeight}},"external_container_dbFont"),container_queueFont:o(function(){return{fontFamily:this.container_queueFontFamily,fontSize:this.container_queueFontSize,fontWeight:this.container_queueFontWeight}},"container_queueFont"),external_container_queueFont:o(function(){return{fontFamily:this.external_container_queueFontFamily,fontSize:this.external_container_queueFontSize,fontWeight:this.external_container_queueFontWeight}},"external_container_queueFont"),componentFont:o(function(){return{fontFamily:this.componentFontFamily,fontSize:this.componentFontSize,fontWeight:this.componentFontWeight}},"componentFont"),external_componentFont:o(function(){return{fontFamily:this.external_componentFontFamily,fontSize:this.external_componentFontSize,fontWeight:this.external_componentFontWeight}},"external_componentFont"),component_dbFont:o(function(){return{fontFamily:this.component_dbFontFamily,fontSize:this.component_dbFontSize,fontWeight:this.component_dbFontWeight}},"component_dbFont"),external_component_dbFont:o(function(){return{fontFamily:this.external_component_dbFontFamily,fontSize:this.external_component_dbFontSize,fontWeight:this.external_component_dbFontWeight}},"external_component_dbFont"),component_queueFont:o(function(){return{fontFamily:this.component_queueFontFamily,fontSize:this.component_queueFontSize,fontWeight:this.component_queueFontWeight}},"component_queueFont"),external_component_queueFont:o(function(){return{fontFamily:this.external_component_queueFontFamily,fontSize:this.external_component_queueFontSize,fontWeight:this.external_component_queueFontWeight}},"external_component_queueFont"),boundaryFont:o(function(){return{fontFamily:this.boundaryFontFamily,fontSize:this.boundaryFontSize,fontWeight:this.boundaryFontWeight}},"boundaryFont"),messageFont:o(function(){return{fontFamily:this.messageFontFamily,fontSize:this.messageFontSize,fontWeight:this.messageFontWeight}},"messageFont")},pie:{...ql.pie,useWidth:984},xyChart:{...ql.xyChart,useWidth:void 0},requirement:{...ql.requirement,useWidth:void 0},packet:{...ql.packet},radar:{...ql.radar}},F$=o((t,e="")=>Object.keys(t).reduce((r,n)=>Array.isArray(t[n])?r:typeof t[n]=="object"&&t[n]!==null?[...r,e+n,...F$(t[n],"")]:[...r,e+n],[]),"keyify"),$$=new Set(F$(B$,"")),or=B$});var l0,Dxe,e7=N(()=>{"use strict";Ya();vt();l0=o(t=>{if(Y.debug("sanitizeDirective called with",t),!(typeof t!="object"||t==null)){if(Array.isArray(t)){t.forEach(e=>l0(e));return}for(let e of Object.keys(t)){if(Y.debug("Checking key",e),e.startsWith("__")||e.includes("proto")||e.includes("constr")||!$$.has(e)||t[e]==null){Y.debug("sanitize deleting key: ",e),delete t[e];continue}if(typeof t[e]=="object"){Y.debug("sanitizing object",e),l0(t[e]);continue}let r=["themeCSS","fontFamily","altFontFamily"];for(let n of r)e.includes(n)&&(Y.debug("sanitizing css option",e),t[e]=Dxe(t[e]))}if(t.themeVariables)for(let e of Object.keys(t.themeVariables)){let r=t.themeVariables[e];r?.match&&!r.match(/^[\d "#%(),.;A-Za-z]+$/)&&(t.themeVariables[e]="")}Y.debug("After sanitization",t)}},"sanitizeDirective"),Dxe=o(t=>{let e=0,r=0;for(let n of t){if(e{"use strict";s0();vt();q4();Ya();e7();lh=Object.freeze(or),xs=Gn({},lh),c0=[],Dy=Gn({},lh),Y4=o((t,e)=>{let r=Gn({},t),n={};for(let i of e)H$(i),n=Gn(n,i);if(r=Gn(r,n),n.theme&&n.theme in To){let i=Gn({},G$),a=Gn(i.themeVariables||{},n.themeVariables);r.theme&&r.theme in To&&(r.themeVariables=To[r.theme].getThemeVariables(a))}return Dy=r,q$(Dy),Dy},"updateCurrentConfig"),t7=o(t=>(xs=Gn({},lh),xs=Gn(xs,t),t.theme&&To[t.theme]&&(xs.themeVariables=To[t.theme].getThemeVariables(t.themeVariables)),Y4(xs,c0),xs),"setSiteConfig"),V$=o(t=>{G$=Gn({},t)},"saveConfigFromInitialize"),U$=o(t=>(xs=Gn(xs,t),Y4(xs,c0),xs),"updateSiteConfig"),r7=o(()=>Gn({},xs),"getSiteConfig"),X4=o(t=>(q$(t),Gn(Dy,t),cr()),"setConfig"),cr=o(()=>Gn({},Dy),"getConfig"),H$=o(t=>{t&&(["secure",...xs.secure??[]].forEach(e=>{Object.hasOwn(t,e)&&(Y.debug(`Denied attempt to modify a secure key ${e}`,t[e]),delete t[e])}),Object.keys(t).forEach(e=>{e.startsWith("__")&&delete t[e]}),Object.keys(t).forEach(e=>{typeof t[e]=="string"&&(t[e].includes("<")||t[e].includes(">")||t[e].includes("url(data:"))&&delete t[e],typeof t[e]=="object"&&H$(t[e])}))},"sanitize"),W$=o(t=>{l0(t),t.fontFamily&&!t.themeVariables?.fontFamily&&(t.themeVariables={...t.themeVariables,fontFamily:t.fontFamily}),c0.push(t),Y4(xs,c0)},"addDirective"),Ly=o((t=xs)=>{c0=[],Y4(t,c0)},"reset"),Lxe={LAZY_LOAD_DEPRECATED:"The configuration options lazyLoadedDiagrams and loadExternalDiagramsAtStartup are deprecated. Please use registerExternalDiagrams instead."},z$={},Rxe=o(t=>{z$[t]||(Y.warn(Lxe[t]),z$[t]=!0)},"issueWarning"),q$=o(t=>{t&&(t.lazyLoadedDiagrams||t.loadExternalDiagramsAtStartup)&&Rxe("LAZY_LOAD_DEPRECATED")},"checkConfig")});function Ka(t){return function(e){for(var r=arguments.length,n=new Array(r>1?r-1:0),i=1;i2&&arguments[2]!==void 0?arguments[2]:Q4;Y$&&Y$(t,null);let n=e.length;for(;n--;){let i=e[n];if(typeof i=="string"){let a=r(i);a!==i&&(Nxe(e)||(e[n]=a),i=a)}t[i]=!0}return t}function zxe(t){for(let e=0;e0&&arguments[0]!==void 0?arguments[0]:Qxe(),e=o(At=>sz(At),"DOMPurify");if(e.version="3.2.4",e.removed=[],!t||!t.document||t.document.nodeType!==Oy.document||!t.Element)return e.isSupported=!1,e;let{document:r}=t,n=r,i=n.currentScript,{DocumentFragment:a,HTMLTemplateElement:s,Node:l,Element:u,NodeFilter:h,NamedNodeMap:f=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:d,DOMParser:p,trustedTypes:m}=t,g=u.prototype,y=Iy(g,"cloneNode"),v=Iy(g,"remove"),x=Iy(g,"nextSibling"),b=Iy(g,"childNodes"),w=Iy(g,"parentNode");if(typeof s=="function"){let At=r.createElement("template");At.content&&At.content.ownerDocument&&(r=At.content.ownerDocument)}let C,T="",{implementation:E,createNodeIterator:A,createDocumentFragment:S,getElementsByTagName:_}=r,{importNode:I}=n,D=tz();e.isSupported=typeof rz=="function"&&typeof w=="function"&&E&&E.createHTMLDocument!==void 0;let{MUSTACHE_EXPR:k,ERB_EXPR:L,TMPLIT_EXPR:R,DATA_ATTR:O,ARIA_ATTR:M,IS_SCRIPT_OR_DATA:B,ATTR_WHITESPACE:F,CUSTOM_ELEMENT:P}=ez,{IS_ALLOWED_URI:z}=ez,$=null,H=Cr({},[...K$,...i7,...a7,...s7,...Q$]),Q=null,j=Cr({},[...Z$,...o7,...J$,...K4]),ie=Object.seal(nz(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ne=null,le=null,he=!0,K=!0,X=!1,te=!0,J=!1,se=!0,ue=!1,Z=!1,Se=!1,ce=!1,ae=!1,Oe=!1,ge=!0,ze=!1,He="user-content-",$e=!0,Re=!1,Ie={},be=null,W=Cr({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),de=null,re=Cr({},["audio","video","img","source","image","track"]),oe=null,V=Cr({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),xe="http://www.w3.org/1998/Math/MathML",q="http://www.w3.org/2000/svg",pe="http://www.w3.org/1999/xhtml",ve=pe,Pe=!1,_e=null,we=Cr({},[xe,q,pe],n7),Ve=Cr({},["mi","mo","mn","ms","mtext"]),De=Cr({},["annotation-xml"]),qe=Cr({},["title","style","font","a","script"]),at=null,Rt=["application/xhtml+xml","text/html"],st="text/html",Ue=null,ct=null,We=r.createElement("form"),ot=o(function(Ce){return Ce instanceof RegExp||Ce instanceof Function},"isRegexOrFunction"),Yt=o(function(){let Ce=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(!(ct&&ct===Ce)){if((!Ce||typeof Ce!="object")&&(Ce={}),Ce=Qf(Ce),at=Rt.indexOf(Ce.PARSER_MEDIA_TYPE)===-1?st:Ce.PARSER_MEDIA_TYPE,Ue=at==="application/xhtml+xml"?n7:Q4,$=sl(Ce,"ALLOWED_TAGS")?Cr({},Ce.ALLOWED_TAGS,Ue):H,Q=sl(Ce,"ALLOWED_ATTR")?Cr({},Ce.ALLOWED_ATTR,Ue):j,_e=sl(Ce,"ALLOWED_NAMESPACES")?Cr({},Ce.ALLOWED_NAMESPACES,n7):we,oe=sl(Ce,"ADD_URI_SAFE_ATTR")?Cr(Qf(V),Ce.ADD_URI_SAFE_ATTR,Ue):V,de=sl(Ce,"ADD_DATA_URI_TAGS")?Cr(Qf(re),Ce.ADD_DATA_URI_TAGS,Ue):re,be=sl(Ce,"FORBID_CONTENTS")?Cr({},Ce.FORBID_CONTENTS,Ue):W,ne=sl(Ce,"FORBID_TAGS")?Cr({},Ce.FORBID_TAGS,Ue):{},le=sl(Ce,"FORBID_ATTR")?Cr({},Ce.FORBID_ATTR,Ue):{},Ie=sl(Ce,"USE_PROFILES")?Ce.USE_PROFILES:!1,he=Ce.ALLOW_ARIA_ATTR!==!1,K=Ce.ALLOW_DATA_ATTR!==!1,X=Ce.ALLOW_UNKNOWN_PROTOCOLS||!1,te=Ce.ALLOW_SELF_CLOSE_IN_ATTR!==!1,J=Ce.SAFE_FOR_TEMPLATES||!1,se=Ce.SAFE_FOR_XML!==!1,ue=Ce.WHOLE_DOCUMENT||!1,ce=Ce.RETURN_DOM||!1,ae=Ce.RETURN_DOM_FRAGMENT||!1,Oe=Ce.RETURN_TRUSTED_TYPE||!1,Se=Ce.FORCE_BODY||!1,ge=Ce.SANITIZE_DOM!==!1,ze=Ce.SANITIZE_NAMED_PROPS||!1,$e=Ce.KEEP_CONTENT!==!1,Re=Ce.IN_PLACE||!1,z=Ce.ALLOWED_URI_REGEXP||iz,ve=Ce.NAMESPACE||pe,Ve=Ce.MATHML_TEXT_INTEGRATION_POINTS||Ve,De=Ce.HTML_INTEGRATION_POINTS||De,ie=Ce.CUSTOM_ELEMENT_HANDLING||{},Ce.CUSTOM_ELEMENT_HANDLING&&ot(Ce.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(ie.tagNameCheck=Ce.CUSTOM_ELEMENT_HANDLING.tagNameCheck),Ce.CUSTOM_ELEMENT_HANDLING&&ot(Ce.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(ie.attributeNameCheck=Ce.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),Ce.CUSTOM_ELEMENT_HANDLING&&typeof Ce.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements=="boolean"&&(ie.allowCustomizedBuiltInElements=Ce.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),J&&(K=!1),ae&&(ce=!0),Ie&&($=Cr({},Q$),Q=[],Ie.html===!0&&(Cr($,K$),Cr(Q,Z$)),Ie.svg===!0&&(Cr($,i7),Cr(Q,o7),Cr(Q,K4)),Ie.svgFilters===!0&&(Cr($,a7),Cr(Q,o7),Cr(Q,K4)),Ie.mathMl===!0&&(Cr($,s7),Cr(Q,J$),Cr(Q,K4))),Ce.ADD_TAGS&&($===H&&($=Qf($)),Cr($,Ce.ADD_TAGS,Ue)),Ce.ADD_ATTR&&(Q===j&&(Q=Qf(Q)),Cr(Q,Ce.ADD_ATTR,Ue)),Ce.ADD_URI_SAFE_ATTR&&Cr(oe,Ce.ADD_URI_SAFE_ATTR,Ue),Ce.FORBID_CONTENTS&&(be===W&&(be=Qf(be)),Cr(be,Ce.FORBID_CONTENTS,Ue)),$e&&($["#text"]=!0),ue&&Cr($,["html","head","body"]),$.table&&(Cr($,["tbody"]),delete ne.tbody),Ce.TRUSTED_TYPES_POLICY){if(typeof Ce.TRUSTED_TYPES_POLICY.createHTML!="function")throw My('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof Ce.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw My('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');C=Ce.TRUSTED_TYPES_POLICY,T=C.createHTML("")}else C===void 0&&(C=Zxe(m,i)),C!==null&&typeof T=="string"&&(T=C.createHTML(""));ja&&ja(Ce),ct=Ce}},"_parseConfig"),bt=Cr({},[...i7,...a7,...Gxe]),Mt=Cr({},[...s7,...Vxe]),xt=o(function(Ce){let tt=w(Ce);(!tt||!tt.tagName)&&(tt={namespaceURI:ve,tagName:"template"});let St=Q4(Ce.tagName),mr=Q4(tt.tagName);return _e[Ce.namespaceURI]?Ce.namespaceURI===q?tt.namespaceURI===pe?St==="svg":tt.namespaceURI===xe?St==="svg"&&(mr==="annotation-xml"||Ve[mr]):!!bt[St]:Ce.namespaceURI===xe?tt.namespaceURI===pe?St==="math":tt.namespaceURI===q?St==="math"&&De[mr]:!!Mt[St]:Ce.namespaceURI===pe?tt.namespaceURI===q&&!De[mr]||tt.namespaceURI===xe&&!Ve[mr]?!1:!Mt[St]&&(qe[St]||!bt[St]):!!(at==="application/xhtml+xml"&&_e[Ce.namespaceURI]):!1},"_checkValidNamespace"),ut=o(function(Ce){Ry(e.removed,{element:Ce});try{w(Ce).removeChild(Ce)}catch{v(Ce)}},"_forceRemove"),Et=o(function(Ce,tt){try{Ry(e.removed,{attribute:tt.getAttributeNode(Ce),from:tt})}catch{Ry(e.removed,{attribute:null,from:tt})}if(tt.removeAttribute(Ce),Ce==="is")if(ce||ae)try{ut(tt)}catch{}else try{tt.setAttribute(Ce,"")}catch{}},"_removeAttribute"),ft=o(function(Ce){let tt=null,St=null;if(Se)Ce=""+Ce;else{let gn=j$(Ce,/^[\r\n\t ]+/);St=gn&&gn[0]}at==="application/xhtml+xml"&&ve===pe&&(Ce=''+Ce+"");let mr=C?C.createHTML(Ce):Ce;if(ve===pe)try{tt=new p().parseFromString(mr,at)}catch{}if(!tt||!tt.documentElement){tt=E.createDocument(ve,"template",null);try{tt.documentElement.innerHTML=Pe?T:mr}catch{}}let rn=tt.body||tt.documentElement;return Ce&&St&&rn.insertBefore(r.createTextNode(St),rn.childNodes[0]||null),ve===pe?_.call(tt,ue?"html":"body")[0]:ue?tt.documentElement:rn},"_initDocument"),yt=o(function(Ce){return A.call(Ce.ownerDocument||Ce,Ce,h.SHOW_ELEMENT|h.SHOW_COMMENT|h.SHOW_TEXT|h.SHOW_PROCESSING_INSTRUCTION|h.SHOW_CDATA_SECTION,null)},"_createNodeIterator"),nt=o(function(Ce){return Ce instanceof d&&(typeof Ce.nodeName!="string"||typeof Ce.textContent!="string"||typeof Ce.removeChild!="function"||!(Ce.attributes instanceof f)||typeof Ce.removeAttribute!="function"||typeof Ce.setAttribute!="function"||typeof Ce.namespaceURI!="string"||typeof Ce.insertBefore!="function"||typeof Ce.hasChildNodes!="function")},"_isClobbered"),dn=o(function(Ce){return typeof l=="function"&&Ce instanceof l},"_isNode");function Tt(At,Ce,tt){j4(At,St=>{St.call(e,Ce,tt,ct)})}o(Tt,"_executeHooks");let On=o(function(Ce){let tt=null;if(Tt(D.beforeSanitizeElements,Ce,null),nt(Ce))return ut(Ce),!0;let St=Ue(Ce.nodeName);if(Tt(D.uponSanitizeElement,Ce,{tagName:St,allowedTags:$}),Ce.hasChildNodes()&&!dn(Ce.firstElementChild)&&Xa(/<[/\w]/g,Ce.innerHTML)&&Xa(/<[/\w]/g,Ce.textContent)||Ce.nodeType===Oy.progressingInstruction||se&&Ce.nodeType===Oy.comment&&Xa(/<[/\w]/g,Ce.data))return ut(Ce),!0;if(!$[St]||ne[St]){if(!ne[St]&&_r(St)&&(ie.tagNameCheck instanceof RegExp&&Xa(ie.tagNameCheck,St)||ie.tagNameCheck instanceof Function&&ie.tagNameCheck(St)))return!1;if($e&&!be[St]){let mr=w(Ce)||Ce.parentNode,rn=b(Ce)||Ce.childNodes;if(rn&&mr){let gn=rn.length;for(let Zr=gn-1;Zr>=0;--Zr){let Ni=y(rn[Zr],!0);Ni.__removalCount=(Ce.__removalCount||0)+1,mr.insertBefore(Ni,x(Ce))}}}return ut(Ce),!0}return Ce instanceof u&&!xt(Ce)||(St==="noscript"||St==="noembed"||St==="noframes")&&Xa(/<\/no(script|embed|frames)/i,Ce.innerHTML)?(ut(Ce),!0):(J&&Ce.nodeType===Oy.text&&(tt=Ce.textContent,j4([k,L,R],mr=>{tt=Ny(tt,mr," ")}),Ce.textContent!==tt&&(Ry(e.removed,{element:Ce.cloneNode()}),Ce.textContent=tt)),Tt(D.afterSanitizeElements,Ce,null),!1)},"_sanitizeElements"),tn=o(function(Ce,tt,St){if(ge&&(tt==="id"||tt==="name")&&(St in r||St in We))return!1;if(!(K&&!le[tt]&&Xa(O,tt))){if(!(he&&Xa(M,tt))){if(!Q[tt]||le[tt]){if(!(_r(Ce)&&(ie.tagNameCheck instanceof RegExp&&Xa(ie.tagNameCheck,Ce)||ie.tagNameCheck instanceof Function&&ie.tagNameCheck(Ce))&&(ie.attributeNameCheck instanceof RegExp&&Xa(ie.attributeNameCheck,tt)||ie.attributeNameCheck instanceof Function&&ie.attributeNameCheck(tt))||tt==="is"&&ie.allowCustomizedBuiltInElements&&(ie.tagNameCheck instanceof RegExp&&Xa(ie.tagNameCheck,St)||ie.tagNameCheck instanceof Function&&ie.tagNameCheck(St))))return!1}else if(!oe[tt]){if(!Xa(z,Ny(St,F,""))){if(!((tt==="src"||tt==="xlink:href"||tt==="href")&&Ce!=="script"&&Bxe(St,"data:")===0&&de[Ce])){if(!(X&&!Xa(B,Ny(St,F,"")))){if(St)return!1}}}}}}return!0},"_isValidAttribute"),_r=o(function(Ce){return Ce!=="annotation-xml"&&j$(Ce,P)},"_isBasicCustomElement"),Dr=o(function(Ce){Tt(D.beforeSanitizeAttributes,Ce,null);let{attributes:tt}=Ce;if(!tt||nt(Ce))return;let St={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Q,forceKeepAttr:void 0},mr=tt.length;for(;mr--;){let rn=tt[mr],{name:gn,namespaceURI:Zr,value:Ni}=rn,Zn=Ue(gn),Sn=gn==="value"?Ni:Fxe(Ni);if(St.attrName=Zn,St.attrValue=Sn,St.keepAttr=!0,St.forceKeepAttr=void 0,Tt(D.uponSanitizeAttribute,Ce,St),Sn=St.attrValue,ze&&(Zn==="id"||Zn==="name")&&(Et(gn,Ce),Sn=He+Sn),se&&Xa(/((--!?|])>)|<\/(style|title)/i,Sn)){Et(gn,Ce);continue}if(St.forceKeepAttr||(Et(gn,Ce),!St.keepAttr))continue;if(!te&&Xa(/\/>/i,Sn)){Et(gn,Ce);continue}J&&j4([k,L,R],et=>{Sn=Ny(Sn,et," ")});let Hr=Ue(Ce.nodeName);if(tn(Hr,Zn,Sn)){if(C&&typeof m=="object"&&typeof m.getAttributeType=="function"&&!Zr)switch(m.getAttributeType(Hr,Zn)){case"TrustedHTML":{Sn=C.createHTML(Sn);break}case"TrustedScriptURL":{Sn=C.createScriptURL(Sn);break}}try{Zr?Ce.setAttributeNS(Zr,gn,Sn):Ce.setAttribute(gn,Sn),nt(Ce)?ut(Ce):X$(e.removed)}catch{}}}Tt(D.afterSanitizeAttributes,Ce,null)},"_sanitizeAttributes"),Pn=o(function At(Ce){let tt=null,St=yt(Ce);for(Tt(D.beforeSanitizeShadowDOM,Ce,null);tt=St.nextNode();)Tt(D.uponSanitizeShadowNode,tt,null),On(tt),Dr(tt),tt.content instanceof a&&At(tt.content);Tt(D.afterSanitizeShadowDOM,Ce,null)},"_sanitizeShadowDOM");return e.sanitize=function(At){let Ce=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},tt=null,St=null,mr=null,rn=null;if(Pe=!At,Pe&&(At=""),typeof At!="string"&&!dn(At))if(typeof At.toString=="function"){if(At=At.toString(),typeof At!="string")throw My("dirty is not a string, aborting")}else throw My("toString is not a function");if(!e.isSupported)return At;if(Z||Yt(Ce),e.removed=[],typeof At=="string"&&(Re=!1),Re){if(At.nodeName){let Ni=Ue(At.nodeName);if(!$[Ni]||ne[Ni])throw My("root node is forbidden and cannot be sanitized in-place")}}else if(At instanceof l)tt=ft(""),St=tt.ownerDocument.importNode(At,!0),St.nodeType===Oy.element&&St.nodeName==="BODY"||St.nodeName==="HTML"?tt=St:tt.appendChild(St);else{if(!ce&&!J&&!ue&&At.indexOf("<")===-1)return C&&Oe?C.createHTML(At):At;if(tt=ft(At),!tt)return ce?null:Oe?T:""}tt&&Se&&ut(tt.firstChild);let gn=yt(Re?At:tt);for(;mr=gn.nextNode();)On(mr),Dr(mr),mr.content instanceof a&&Pn(mr.content);if(Re)return At;if(ce){if(ae)for(rn=S.call(tt.ownerDocument);tt.firstChild;)rn.appendChild(tt.firstChild);else rn=tt;return(Q.shadowroot||Q.shadowrootmode)&&(rn=I.call(n,rn,!0)),rn}let Zr=ue?tt.outerHTML:tt.innerHTML;return ue&&$["!doctype"]&&tt.ownerDocument&&tt.ownerDocument.doctype&&tt.ownerDocument.doctype.name&&Xa(az,tt.ownerDocument.doctype.name)&&(Zr=" +`+Zr),J&&j4([k,L,R],Ni=>{Zr=Ny(Zr,Ni," ")}),C&&Oe?C.createHTML(Zr):Zr},e.setConfig=function(){let At=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};Yt(At),Z=!0},e.clearConfig=function(){ct=null,Z=!1},e.isValidAttribute=function(At,Ce,tt){ct||Yt({});let St=Ue(At),mr=Ue(Ce);return tn(St,mr,tt)},e.addHook=function(At,Ce){typeof Ce=="function"&&Ry(D[At],Ce)},e.removeHook=function(At,Ce){if(Ce!==void 0){let tt=Oxe(D[At],Ce);return tt===-1?void 0:Pxe(D[At],tt,1)[0]}return X$(D[At])},e.removeHooks=function(At){D[At]=[]},e.removeAllHooks=function(){D=tz()},e}var rz,Y$,Nxe,Mxe,Ixe,ja,ko,nz,l7,c7,j4,Oxe,X$,Ry,Pxe,Q4,n7,j$,Ny,Bxe,Fxe,sl,Xa,My,K$,i7,a7,Gxe,s7,Vxe,Q$,Z$,o7,J$,K4,Uxe,Hxe,Wxe,qxe,Yxe,iz,Xxe,jxe,az,Kxe,ez,Oy,Qxe,Zxe,tz,ch,u7=N(()=>{"use strict";({entries:rz,setPrototypeOf:Y$,isFrozen:Nxe,getPrototypeOf:Mxe,getOwnPropertyDescriptor:Ixe}=Object),{freeze:ja,seal:ko,create:nz}=Object,{apply:l7,construct:c7}=typeof Reflect<"u"&&Reflect;ja||(ja=o(function(e){return e},"freeze"));ko||(ko=o(function(e){return e},"seal"));l7||(l7=o(function(e,r,n){return e.apply(r,n)},"apply"));c7||(c7=o(function(e,r){return new e(...r)},"construct"));j4=Ka(Array.prototype.forEach),Oxe=Ka(Array.prototype.lastIndexOf),X$=Ka(Array.prototype.pop),Ry=Ka(Array.prototype.push),Pxe=Ka(Array.prototype.splice),Q4=Ka(String.prototype.toLowerCase),n7=Ka(String.prototype.toString),j$=Ka(String.prototype.match),Ny=Ka(String.prototype.replace),Bxe=Ka(String.prototype.indexOf),Fxe=Ka(String.prototype.trim),sl=Ka(Object.prototype.hasOwnProperty),Xa=Ka(RegExp.prototype.test),My=$xe(TypeError);o(Ka,"unapply");o($xe,"unconstruct");o(Cr,"addToSet");o(zxe,"cleanArray");o(Qf,"clone");o(Iy,"lookupGetter");K$=ja(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),i7=ja(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),a7=ja(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),Gxe=ja(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),s7=ja(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),Vxe=ja(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),Q$=ja(["#text"]),Z$=ja(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns","slot"]),o7=ja(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),J$=ja(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),K4=ja(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),Uxe=ko(/\{\{[\w\W]*|[\w\W]*\}\}/gm),Hxe=ko(/<%[\w\W]*|[\w\W]*%>/gm),Wxe=ko(/\$\{[\w\W]*/gm),qxe=ko(/^data-[\-\w.\u00B7-\uFFFF]+$/),Yxe=ko(/^aria-[\-\w]+$/),iz=ko(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),Xxe=ko(/^(?:\w+script|data):/i),jxe=ko(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),az=ko(/^html$/i),Kxe=ko(/^[a-z][.\w]*(-[.\w]+)+$/i),ez=Object.freeze({__proto__:null,ARIA_ATTR:Yxe,ATTR_WHITESPACE:jxe,CUSTOM_ELEMENT:Kxe,DATA_ATTR:qxe,DOCTYPE_NAME:az,ERB_EXPR:Hxe,IS_ALLOWED_URI:iz,IS_SCRIPT_OR_DATA:Xxe,MUSTACHE_EXPR:Uxe,TMPLIT_EXPR:Wxe}),Oy={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},Qxe=o(function(){return typeof window>"u"?null:window},"getGlobal"),Zxe=o(function(e,r){if(typeof e!="object"||typeof e.createPolicy!="function")return null;let n=null,i="data-tt-policy-suffix";r&&r.hasAttribute(i)&&(n=r.getAttribute(i));let a="dompurify"+(n?"#"+n:"");try{return e.createPolicy(a,{createHTML(s){return s},createScriptURL(s){return s}})}catch{return console.warn("TrustedTypes policy "+a+" could not be created."),null}},"_createTrustedTypesPolicy"),tz=o(function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},"_createHooksMap");o(sz,"createDOMPurify");ch=sz()});var MG={};hr(MG,{default:()=>q4e});function abe(t){return String(t).replace(ibe,e=>nbe[e])}function cbe(t){if(t.default)return t.default;var e=t.type,r=Array.isArray(e)?e[0]:e;if(typeof r!="string")return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}function gbe(t){for(var e=0;e=i[0]&&t<=i[1])return r.name}return null}function $z(t){for(var e=0;e=u3[e]&&t<=u3[e+1])return!0;return!1}function Abe(t,e){jl[t]=e}function P7(t,e,r){if(!jl[e])throw new Error("Font metrics not found for font: "+e+".");var n=t.charCodeAt(0),i=jl[e][n];if(!i&&t[0]in lz&&(n=lz[t[0]].charCodeAt(0),i=jl[e][n]),!i&&r==="text"&&$z(n)&&(i=jl[e][77]),i)return{depth:i[0],height:i[1],italic:i[2],skew:i[3],width:i[4]}}function _be(t){var e;if(t>=5?e=0:t>=3?e=1:e=2,!h7[e]){var r=h7[e]={cssEmPerMu:Z4.quad[e]/18};for(var n in Z4)Z4.hasOwnProperty(n)&&(r[n]=Z4[n][e])}return h7[e]}function hz(t){if(t instanceof Ts)return t;throw new Error("Expected symbolNode but got "+String(t)+".")}function Nbe(t){if(t instanceof td)return t;throw new Error("Expected span but got "+String(t)+".")}function G(t,e,r,n,i,a){An[t][i]={font:e,group:r,replace:n},a&&n&&(An[t][n]=An[t][i])}function Nt(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs,argTypes:n.argTypes,allowedInArgument:!!n.allowedInArgument,allowedInText:!!n.allowedInText,allowedInMath:n.allowedInMath===void 0?!0:n.allowedInMath,numOptionalArgs:n.numOptionalArgs||0,infix:!!n.infix,primitive:!!n.primitive,handler:i},u=0;u0&&(a.push(a3(s,e)),s=[]),a.push(n[l]));s.length>0&&a.push(a3(s,e));var h;r?(h=a3(Pi(r,e,!0)),h.classes=["tag"],a.push(h)):i&&a.push(i);var f=lu(["katex-html"],a);if(f.setAttribute("aria-hidden","true"),h){var d=h.children[0];d.style.height=kt(f.height+f.depth),f.depth&&(d.style.verticalAlign=kt(-f.depth))}return f}function Qz(t){return new ed(t)}function gz(t,e,r,n,i){var a=ks(t,r),s;a.length===1&&a[0]instanceof ws&&Jt.contains(["mrow","mtable"],a[0].type)?s=a[0]:s=new dt.MathNode("mrow",a);var l=new dt.MathNode("annotation",[new dt.TextNode(e)]);l.setAttribute("encoding","application/x-tex");var u=new dt.MathNode("semantics",[s,l]),h=new dt.MathNode("math",[u]);h.setAttribute("xmlns","http://www.w3.org/1998/Math/MathML"),n&&h.setAttribute("display","block");var f=i?"katex":"katex-mathml";return Be.makeSpan([f],[h])}function xr(t,e){if(!t||t.type!==e)throw new Error("Expected node of type "+e+", but got "+(t?"node of type "+t.type:String(t)));return t}function z7(t){var e=w3(t);if(!e)throw new Error("Expected node of symbol group type, but got "+(t?"node of type "+t.type:String(t)));return e}function w3(t){return t&&(t.type==="atom"||Ibe.hasOwnProperty(t.type))?t:null}function tG(t,e){var r=Pi(t.body,e,!0);return u4e([t.mclass],r,e)}function rG(t,e){var r,n=ks(t.body,e);return t.mclass==="minner"?r=new dt.MathNode("mpadded",n):t.mclass==="mord"?t.isCharacterBox?(r=n[0],r.type="mi"):r=new dt.MathNode("mi",n):(t.isCharacterBox?(r=n[0],r.type="mo"):r=new dt.MathNode("mo",n),t.mclass==="mbin"?(r.attributes.lspace="0.22em",r.attributes.rspace="0.22em"):t.mclass==="mpunct"?(r.attributes.lspace="0em",r.attributes.rspace="0.17em"):t.mclass==="mopen"||t.mclass==="mclose"?(r.attributes.lspace="0em",r.attributes.rspace="0em"):t.mclass==="minner"&&(r.attributes.lspace="0.0556em",r.attributes.width="+0.1111em")),r}function d4e(t,e,r){var n=h4e[t];switch(n){case"\\\\cdrightarrow":case"\\\\cdleftarrow":return r.callFunction(n,[e[0]],[e[1]]);case"\\uparrow":case"\\downarrow":{var i=r.callFunction("\\\\cdleft",[e[0]],[]),a={type:"atom",text:n,mode:"math",family:"rel"},s=r.callFunction("\\Big",[a],[]),l=r.callFunction("\\\\cdright",[e[1]],[]),u={type:"ordgroup",mode:"math",body:[i,s,l]};return r.callFunction("\\\\cdparent",[u],[])}case"\\\\cdlongequal":return r.callFunction("\\\\cdlongequal",[],[]);case"\\Vert":{var h={type:"textord",text:"\\Vert",mode:"math"};return r.callFunction("\\Big",[h],[])}default:return{type:"textord",text:" ",mode:"math"}}}function p4e(t){var e=[];for(t.gullet.beginGroup(),t.gullet.macros.set("\\cr","\\\\\\relax"),t.gullet.beginGroup();;){e.push(t.parseExpression(!1,"\\\\")),t.gullet.endGroup(),t.gullet.beginGroup();var r=t.fetch().text;if(r==="&"||r==="\\\\")t.consume();else if(r==="\\end"){e[e.length-1].length===0&&e.pop();break}else throw new gt("Expected \\\\ or \\cr or \\end",t.nextToken)}for(var n=[],i=[n],a=0;a-1))if("<>AV".indexOf(h)>-1)for(var d=0;d<2;d++){for(var p=!0,m=u+1;mAV=|." after @',s[u]);var g=d4e(h,f,t),y={type:"styling",body:[g],mode:"math",style:"display"};n.push(y),l=yz()}a%2===0?n.push(l):n.shift(),n=[],i.push(n)}t.gullet.endGroup(),t.gullet.endGroup();var v=new Array(i[0].length).fill({type:"align",align:"c",pregap:.25,postgap:.25});return{type:"array",mode:"math",body:i,arraystretch:1,addJot:!0,rowGaps:[null],cols:v,colSeparationType:"CD",hLinesBeforeRow:new Array(i.length+1).fill([])}}function k3(t,e){var r=w3(t);if(r&&Jt.contains(A4e,r.text))return r;throw r?new gt("Invalid delimiter '"+r.text+"' after '"+e.funcName+"'",t):new gt("Invalid delimiter type '"+t.type+"'",t)}function bz(t){if(!t.body)throw new Error("Bug: The leftright ParseNode wasn't fully parsed.")}function Ql(t){for(var{type:e,names:r,props:n,handler:i,htmlBuilder:a,mathmlBuilder:s}=t,l={type:e,numArgs:n.numArgs||0,allowedInText:!1,numOptionalArgs:0,handler:i},u=0;u1||!f)&&y.pop(),x.length{"use strict";Xs=class t{static{o(this,"SourceLocation")}constructor(e,r,n){this.lexer=void 0,this.start=void 0,this.end=void 0,this.lexer=e,this.start=r,this.end=n}static range(e,r){return r?!e||!e.loc||!r.loc||e.loc.lexer!==r.loc.lexer?null:new t(e.loc.lexer,e.loc.start,r.loc.end):e&&e.loc}},So=class t{static{o(this,"Token")}constructor(e,r){this.text=void 0,this.loc=void 0,this.noexpand=void 0,this.treatAsRelax=void 0,this.text=e,this.loc=r}range(e,r){return new t(r,Xs.range(this,e))}},gt=class t{static{o(this,"ParseError")}constructor(e,r){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;var n="KaTeX parse error: "+e,i,a,s=r&&r.loc;if(s&&s.start<=s.end){var l=s.lexer.input;i=s.start,a=s.end,i===l.length?n+=" at end of input: ":n+=" at position "+(i+1)+": ";var u=l.slice(i,a).replace(/[^]/g,"$&\u0332"),h;i>15?h="\u2026"+l.slice(i-15,i):h=l.slice(0,i);var f;a+15":">","<":"<",'"':""","'":"'"},ibe=/[&><"']/g;o(abe,"escape");Fz=o(function t(e){return e.type==="ordgroup"||e.type==="color"?e.body.length===1?t(e.body[0]):e:e.type==="font"?t(e.body):e},"getBaseElem"),sbe=o(function(e){var r=Fz(e);return r.type==="mathord"||r.type==="textord"||r.type==="atom"},"isCharacterBox"),obe=o(function(e){if(!e)throw new Error("Expected non-null, but got "+String(e));return e},"assert"),lbe=o(function(e){var r=/^[\x00-\x20]*([^\\/#?]*?)(:|�*58|�*3a|&colon)/i.exec(e);return r?r[2]!==":"||!/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(r[1])?null:r[1].toLowerCase():"_relative"},"protocolFromUrl"),Jt={contains:Jxe,deflt:ebe,escape:abe,hyphenate:rbe,getBaseElem:Fz,isCharacterBox:sbe,protocolFromUrl:lbe},c3={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format "},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color ",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:o(t=>"#"+t,"cliProcessor")},macros:{type:"object",cli:"-m, --macro ",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:o((t,e)=>(e.push(t),e),"cliProcessor")},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:o(t=>Math.max(0,t),"processor"),cli:"--min-rule-thickness ",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:o(t=>Math.max(0,t),"processor"),cli:"-s, --max-size ",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:o(t=>Math.max(0,t),"processor"),cli:"-e, --max-expand ",cliProcessor:o(t=>t==="Infinity"?1/0:parseInt(t),"cliProcessor")},globalGroup:{type:"boolean",cli:!1}};o(cbe,"getDefaultValue");zy=class{static{o(this,"Settings")}constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(var r in c3)if(c3.hasOwnProperty(r)){var n=c3[r];this[r]=e[r]!==void 0?n.processor?n.processor(e[r]):e[r]:cbe(n)}}reportNonstrict(e,r,n){var i=this.strict;if(typeof i=="function"&&(i=i(e,r,n)),!(!i||i==="ignore")){if(i===!0||i==="error")throw new gt("LaTeX-incompatible input and strict mode is set to 'error': "+(r+" ["+e+"]"),n);i==="warn"?typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")):typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]"))}}useStrictBehavior(e,r,n){var i=this.strict;if(typeof i=="function")try{i=i(e,r,n)}catch{i="error"}return!i||i==="ignore"?!1:i===!0||i==="error"?!0:i==="warn"?(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to 'warn': "+(r+" ["+e+"]")),!1):(typeof console<"u"&&console.warn("LaTeX-incompatible input and strict mode is set to "+("unrecognized '"+i+"': "+r+" ["+e+"]")),!1)}isTrusted(e){if(e.url&&!e.protocol){var r=Jt.protocolFromUrl(e.url);if(r==null)return!1;e.protocol=r}var n=typeof this.trust=="function"?this.trust(e):this.trust;return!!n}},Yl=class{static{o(this,"Style")}constructor(e,r,n){this.id=void 0,this.size=void 0,this.cramped=void 0,this.id=e,this.size=r,this.cramped=n}sup(){return Xl[ube[this.id]]}sub(){return Xl[hbe[this.id]]}fracNum(){return Xl[fbe[this.id]]}fracDen(){return Xl[dbe[this.id]]}cramp(){return Xl[pbe[this.id]]}text(){return Xl[mbe[this.id]]}isTight(){return this.size>=2}},O7=0,h3=1,f0=2,su=3,Gy=4,Eo=5,d0=6,Qa=7,Xl=[new Yl(O7,0,!1),new Yl(h3,0,!0),new Yl(f0,1,!1),new Yl(su,1,!0),new Yl(Gy,2,!1),new Yl(Eo,2,!0),new Yl(d0,3,!1),new Yl(Qa,3,!0)],ube=[Gy,Eo,Gy,Eo,d0,Qa,d0,Qa],hbe=[Eo,Eo,Eo,Eo,Qa,Qa,Qa,Qa],fbe=[f0,su,Gy,Eo,d0,Qa,d0,Qa],dbe=[su,su,Eo,Eo,Qa,Qa,Qa,Qa],pbe=[h3,h3,su,su,Eo,Eo,Qa,Qa],mbe=[O7,h3,f0,su,f0,su,f0,su],tr={DISPLAY:Xl[O7],TEXT:Xl[f0],SCRIPT:Xl[Gy],SCRIPTSCRIPT:Xl[d0]},k7=[{name:"latin",blocks:[[256,591],[768,879]]},{name:"cyrillic",blocks:[[1024,1279]]},{name:"armenian",blocks:[[1328,1423]]},{name:"brahmic",blocks:[[2304,4255]]},{name:"georgian",blocks:[[4256,4351]]},{name:"cjk",blocks:[[12288,12543],[19968,40879],[65280,65376]]},{name:"hangul",blocks:[[44032,55215]]}];o(gbe,"scriptFromCodepoint");u3=[];k7.forEach(t=>t.blocks.forEach(e=>u3.push(...e)));o($z,"supportedCodepoint");h0=80,ybe=o(function(e,r){return"M95,"+(622+e+r)+` +c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14 +c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54 +c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10 +s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429 +c69,-144,104.5,-217.7,106.5,-221 +l`+e/2.075+" -"+e+` +c5.3,-9.3,12,-14,20,-14 +H400000v`+(40+e)+`H845.2724 +s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7 +c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z +M`+(834+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtMain"),vbe=o(function(e,r){return"M263,"+(601+e+r)+`c0.7,0,18,39.7,52,119 +c34,79.3,68.167,158.7,102.5,238c34.3,79.3,51.8,119.3,52.5,120 +c340,-704.7,510.7,-1060.3,512,-1067 +l`+e/2.084+" -"+e+` +c4.7,-7.3,11,-11,19,-11 +H40000v`+(40+e)+`H1012.3 +s-271.3,567,-271.3,567c-38.7,80.7,-84,175,-136,283c-52,108,-89.167,185.3,-111.5,232 +c-22.3,46.7,-33.8,70.3,-34.5,71c-4.7,4.7,-12.3,7,-23,7s-12,-1,-12,-1 +s-109,-253,-109,-253c-72.7,-168,-109.3,-252,-110,-252c-10.7,8,-22,16.7,-34,26 +c-22,17.3,-33.3,26,-34,26s-26,-26,-26,-26s76,-59,76,-59s76,-60,76,-60z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize1"),xbe=o(function(e,r){return"M983 "+(10+e+r)+` +l`+e/3.13+" -"+e+` +c4,-6.7,10,-10,18,-10 H400000v`+(40+e)+` +H1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7 +s-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744 +c-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30 +c26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722 +c56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5 +c53.7,-170.3,84.5,-266.8,92.5,-289.5z +M`+(1001+e)+" "+r+"h400000v"+(40+e)+"h-400000z"},"sqrtSize2"),bbe=o(function(e,r){return"M424,"+(2398+e+r)+` +c-1.3,-0.7,-38.5,-172,-111.5,-514c-73,-342,-109.8,-513.3,-110.5,-514 +c0,-2,-10.7,14.3,-32,49c-4.7,7.3,-9.8,15.7,-15.5,25c-5.7,9.3,-9.8,16,-12.5,20 +s-5,7,-5,7c-4,-3.3,-8.3,-7.7,-13,-13s-13,-13,-13,-13s76,-122,76,-122s77,-121,77,-121 +s209,968,209,968c0,-2,84.7,-361.7,254,-1079c169.3,-717.3,254.7,-1077.7,256,-1081 +l`+e/4.223+" -"+e+`c4,-6.7,10,-10,18,-10 H400000 +v`+(40+e)+`H1014.6 +s-87.3,378.7,-272.6,1166c-185.3,787.3,-279.3,1182.3,-282,1185 +c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2z M`+(1001+e)+" "+r+` +h400000v`+(40+e)+"h-400000z"},"sqrtSize3"),wbe=o(function(e,r){return"M473,"+(2713+e+r)+` +c339.3,-1799.3,509.3,-2700,510,-2702 l`+e/5.298+" -"+e+` +c3.3,-7.3,9.3,-11,18,-11 H400000v`+(40+e)+`H1017.7 +s-90.5,478,-276.2,1466c-185.7,988,-279.5,1483,-281.5,1485c-2,6,-10,9,-24,9 +c-8,0,-12,-0.7,-12,-2c0,-1.3,-5.3,-32,-16,-92c-50.7,-293.3,-119.7,-693.3,-207,-1200 +c0,-1.3,-5.3,8.7,-16,30c-10.7,21.3,-21.3,42.7,-32,64s-16,33,-16,33s-26,-26,-26,-26 +s76,-153,76,-153s77,-151,77,-151c0.7,0.7,35.7,202,105,604c67.3,400.7,102,602.7,104, +606zM`+(1001+e)+" "+r+"h400000v"+(40+e)+"H1017.7z"},"sqrtSize4"),Tbe=o(function(e){var r=e/2;return"M400000 "+e+" H0 L"+r+" 0 l65 45 L145 "+(e-80)+" H400000z"},"phasePath"),kbe=o(function(e,r,n){var i=n-54-r-e;return"M702 "+(e+r)+"H400000"+(40+e)+` +H742v`+i+`l-4 4-4 4c-.667.7 -2 1.5-4 2.5s-4.167 1.833-6.5 2.5-5.5 1-9.5 1 +h-12l-28-84c-16.667-52-96.667 -294.333-240-727l-212 -643 -85 170 +c-4-3.333-8.333-7.667-13 -13l-13-13l77-155 77-156c66 199.333 139 419.667 +219 661 l218 661zM702 `+r+"H400000v"+(40+e)+"H742z"},"sqrtTall"),Ebe=o(function(e,r,n){r=1e3*r;var i="";switch(e){case"sqrtMain":i=ybe(r,h0);break;case"sqrtSize1":i=vbe(r,h0);break;case"sqrtSize2":i=xbe(r,h0);break;case"sqrtSize3":i=bbe(r,h0);break;case"sqrtSize4":i=wbe(r,h0);break;case"sqrtTall":i=kbe(r,h0,n)}return i},"sqrtPath"),Sbe=o(function(e,r){switch(e){case"\u239C":return"M291 0 H417 V"+r+" H291z M291 0 H417 V"+r+" H291z";case"\u2223":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z";case"\u2225":return"M145 0 H188 V"+r+" H145z M145 0 H188 V"+r+" H145z"+("M367 0 H410 V"+r+" H367z M367 0 H410 V"+r+" H367z");case"\u239F":return"M457 0 H583 V"+r+" H457z M457 0 H583 V"+r+" H457z";case"\u23A2":return"M319 0 H403 V"+r+" H319z M319 0 H403 V"+r+" H319z";case"\u23A5":return"M263 0 H347 V"+r+" H263z M263 0 H347 V"+r+" H263z";case"\u23AA":return"M384 0 H504 V"+r+" H384z M384 0 H504 V"+r+" H384z";case"\u23D0":return"M312 0 H355 V"+r+" H312z M312 0 H355 V"+r+" H312z";case"\u2016":return"M257 0 H300 V"+r+" H257z M257 0 H300 V"+r+" H257z"+("M478 0 H521 V"+r+" H478z M478 0 H521 V"+r+" H478z");default:return""}},"innerPath"),oz={doubleleftarrow:`M262 157 +l10-10c34-36 62.7-77 86-123 3.3-8 5-13.3 5-16 0-5.3-6.7-8-20-8-7.3 + 0-12.2.5-14.5 1.5-2.3 1-4.8 4.5-7.5 10.5-49.3 97.3-121.7 169.3-217 216-28 + 14-57.3 25-88 33-6.7 2-11 3.8-13 5.5-2 1.7-3 4.2-3 7.5s1 5.8 3 7.5 +c2 1.7 6.3 3.5 13 5.5 68 17.3 128.2 47.8 180.5 91.5 52.3 43.7 93.8 96.2 124.5 + 157.5 9.3 8 15.3 12.3 18 13h6c12-.7 18-4 18-10 0-2-1.7-7-5-15-23.3-46-52-87 +-86-123l-10-10h399738v-40H218c328 0 0 0 0 0l-10-8c-26.7-20-65.7-43-117-69 2.7 +-2 6-3.7 10-5 36.7-16 72.3-37.3 107-64l10-8h399782v-40z +m8 0v40h399730v-40zm0 194v40h399730v-40z`,doublerightarrow:`M399738 392l +-10 10c-34 36-62.7 77-86 123-3.3 8-5 13.3-5 16 0 5.3 6.7 8 20 8 7.3 0 12.2-.5 + 14.5-1.5 2.3-1 4.8-4.5 7.5-10.5 49.3-97.3 121.7-169.3 217-216 28-14 57.3-25 88 +-33 6.7-2 11-3.8 13-5.5 2-1.7 3-4.2 3-7.5s-1-5.8-3-7.5c-2-1.7-6.3-3.5-13-5.5-68 +-17.3-128.2-47.8-180.5-91.5-52.3-43.7-93.8-96.2-124.5-157.5-9.3-8-15.3-12.3-18 +-13h-6c-12 .7-18 4-18 10 0 2 1.7 7 5 15 23.3 46 52 87 86 123l10 10H0v40h399782 +c-328 0 0 0 0 0l10 8c26.7 20 65.7 43 117 69-2.7 2-6 3.7-10 5-36.7 16-72.3 37.3 +-107 64l-10 8H0v40zM0 157v40h399730v-40zm0 194v40h399730v-40z`,leftarrow:`M400000 241H110l3-3c68.7-52.7 113.7-120 + 135-202 4-14.7 6-23 6-25 0-7.3-7-11-21-11-8 0-13.2.8-15.5 2.5-2.3 1.7-4.2 5.8 +-5.5 12.5-1.3 4.7-2.7 10.3-4 17-12 48.7-34.8 92-68.5 130S65.3 228.3 18 247 +c-10 4-16 7.7-18 11 0 8.7 6 14.3 18 17 47.3 18.7 87.8 47 121.5 85S196 441.3 208 + 490c.7 2 1.3 5 2 9s1.2 6.7 1.5 8c.3 1.3 1 3.3 2 6s2.2 4.5 3.5 5.5c1.3 1 3.3 + 1.8 6 2.5s6 1 10 1c14 0 21-3.7 21-11 0-2-2-10.3-6-25-20-79.3-65-146.7-135-202 + l-3-3h399890zM100 241v40h399900v-40z`,leftbrace:`M6 548l-6-6v-35l6-11c56-104 135.3-181.3 238-232 57.3-28.7 117 +-45 179-50h399577v120H403c-43.3 7-81 15-113 26-100.7 33-179.7 91-237 174-2.7 + 5-6 9-10 13-.7 1-7.3 1-20 1H6z`,leftbraceunder:`M0 6l6-6h17c12.688 0 19.313.3 20 1 4 4 7.313 8.3 10 13 + 35.313 51.3 80.813 93.8 136.5 127.5 55.688 33.7 117.188 55.8 184.5 66.5.688 + 0 2 .3 4 1 18.688 2.7 76 4.3 172 5h399450v120H429l-6-1c-124.688-8-235-61.7 +-331-161C60.687 138.7 32.312 99.3 7 54L0 41V6z`,leftgroup:`M400000 80 +H435C64 80 168.3 229.4 21 260c-5.9 1.2-18 0-18 0-2 0-3-1-3-3v-38C76 61 257 0 + 435 0h399565z`,leftgroupunder:`M400000 262 +H435C64 262 168.3 112.6 21 82c-5.9-1.2-18 0-18 0-2 0-3 1-3 3v38c76 158 257 219 + 435 219h399565z`,leftharpoon:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3 +-3.3 10.2-9.5 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5 +-18.3 3-21-1.3-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7 +-196 228-6.7 4.7-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40z`,leftharpoonplus:`M0 267c.7 5.3 3 10 7 14h399993v-40H93c3.3-3.3 10.2-9.5 + 20.5-18.5s17.8-15.8 22.5-20.5c50.7-52 88-110.3 112-175 4-11.3 5-18.3 3-21-1.3 +-4-7.3-6-18-6-8 0-13 .7-15 2s-4.7 6.7-8 16c-42 98.7-107.3 174.7-196 228-6.7 4.7 +-10.7 8-12 10-1.3 2-2 5.7-2 11zm100-26v40h399900v-40zM0 435v40h400000v-40z +m0 0v40h400000v-40z`,leftharpoondown:`M7 241c-4 4-6.333 8.667-7 14 0 5.333.667 9 2 11s5.333 + 5.333 12 10c90.667 54 156 130 196 228 3.333 10.667 6.333 16.333 9 17 2 .667 5 + 1 9 1h5c10.667 0 16.667-2 18-6 2-2.667 1-9.667-3-21-32-87.333-82.667-157.667 +-152-211l-3-3h399907v-40zM93 281 H400000 v-40L7 241z`,leftharpoondownplus:`M7 435c-4 4-6.3 8.7-7 14 0 5.3.7 9 2 11s5.3 5.3 12 + 10c90.7 54 156 130 196 228 3.3 10.7 6.3 16.3 9 17 2 .7 5 1 9 1h5c10.7 0 16.7 +-2 18-6 2-2.7 1-9.7-3-21-32-87.3-82.7-157.7-152-211l-3-3h399907v-40H7zm93 0 +v40h399900v-40zM0 241v40h399900v-40zm0 0v40h399900v-40z`,lefthook:`M400000 281 H103s-33-11.2-61-33.5S0 197.3 0 164s14.2-61.2 42.5 +-83.5C70.8 58.2 104 47 142 47 c16.7 0 25 6.7 25 20 0 12-8.7 18.7-26 20-40 3.3 +-68.7 15.7-86 37-10 12-15 25.3-15 40 0 22.7 9.8 40.7 29.5 54 19.7 13.3 43.5 21 + 71.5 23h399859zM103 281v-40h399897v40z`,leftlinesegment:`M40 281 V428 H0 V94 H40 V241 H400000 v40z +M40 281 V428 H0 V94 H40 V241 H400000 v40z`,leftmapsto:`M40 281 V448H0V74H40V241H400000v40z +M40 281 V448H0V74H40V241H400000v40z`,leftToFrom:`M0 147h400000v40H0zm0 214c68 40 115.7 95.7 143 167h22c15.3 0 23 +-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69-70-101l-7-8h399905v-40H95l7-8 +c28.7-32 52-65.7 70-101 10.7-23.3 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 265.3 + 68 321 0 361zm0-174v-40h399900v40zm100 154v40h399900v-40z`,longequal:`M0 50 h400000 v40H0z m0 194h40000v40H0z +M0 50 h400000 v40H0z m0 194h40000v40H0z`,midbrace:`M200428 334 +c-100.7-8.3-195.3-44-280-108-55.3-42-101.7-93-139-153l-9-14c-2.7 4-5.7 8.7-9 14 +-53.3 86.7-123.7 153-211 199-66.7 36-137.3 56.3-212 62H0V214h199568c178.3-11.7 + 311.7-78.3 403-201 6-8 9.7-12 11-12 .7-.7 6.7-1 18-1s17.3.3 18 1c1.3 0 5 4 11 + 12 44.7 59.3 101.3 106.3 170 141s145.3 54.3 229 60h199572v120z`,midbraceunder:`M199572 214 +c100.7 8.3 195.3 44 280 108 55.3 42 101.7 93 139 153l9 14c2.7-4 5.7-8.7 9-14 + 53.3-86.7 123.7-153 211-199 66.7-36 137.3-56.3 212-62h199568v120H200432c-178.3 + 11.7-311.7 78.3-403 201-6 8-9.7 12-11 12-.7.7-6.7 1-18 1s-17.3-.3-18-1c-1.3 0 +-5-4-11-12-44.7-59.3-101.3-106.3-170-141s-145.3-54.3-229-60H0V214z`,oiintSize1:`M512.6 71.6c272.6 0 320.3 106.8 320.3 178.2 0 70.8-47.7 177.6 +-320.3 177.6S193.1 320.6 193.1 249.8c0-71.4 46.9-178.2 319.5-178.2z +m368.1 178.2c0-86.4-60.9-215.4-368.1-215.4-306.4 0-367.3 129-367.3 215.4 0 85.8 +60.9 214.8 367.3 214.8 307.2 0 368.1-129 368.1-214.8z`,oiintSize2:`M757.8 100.1c384.7 0 451.1 137.6 451.1 230 0 91.3-66.4 228.8 +-451.1 228.8-386.3 0-452.7-137.5-452.7-228.8 0-92.4 66.4-230 452.7-230z +m502.4 230c0-111.2-82.4-277.2-502.4-277.2s-504 166-504 277.2 +c0 110 84 276 504 276s502.4-166 502.4-276z`,oiiintSize1:`M681.4 71.6c408.9 0 480.5 106.8 480.5 178.2 0 70.8-71.6 177.6 +-480.5 177.6S202.1 320.6 202.1 249.8c0-71.4 70.5-178.2 479.3-178.2z +m525.8 178.2c0-86.4-86.8-215.4-525.7-215.4-437.9 0-524.7 129-524.7 215.4 0 +85.8 86.8 214.8 524.7 214.8 438.9 0 525.7-129 525.7-214.8z`,oiiintSize2:`M1021.2 53c603.6 0 707.8 165.8 707.8 277.2 0 110-104.2 275.8 +-707.8 275.8-606 0-710.2-165.8-710.2-275.8C311 218.8 415.2 53 1021.2 53z +m770.4 277.1c0-131.2-126.4-327.6-770.5-327.6S248.4 198.9 248.4 330.1 +c0 130 128.8 326.4 772.7 326.4s770.5-196.4 770.5-326.4z`,rightarrow:`M0 241v40h399891c-47.3 35.3-84 78-110 128 +-16.7 32-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 + 11 8 0 13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 + 39-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85 +-40.5-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 + 151.7 139 205zm0 0v40h399900v-40z`,rightbrace:`M400000 542l +-6 6h-17c-12.7 0-19.3-.3-20-1-4-4-7.3-8.3-10-13-35.3-51.3-80.8-93.8-136.5-127.5 +s-117.2-55.8-184.5-66.5c-.7 0-2-.3-4-1-18.7-2.7-76-4.3-172-5H0V214h399571l6 1 +c124.7 8 235 61.7 331 161 31.3 33.3 59.7 72.7 85 118l7 13v35z`,rightbraceunder:`M399994 0l6 6v35l-6 11c-56 104-135.3 181.3-238 232-57.3 + 28.7-117 45-179 50H-300V214h399897c43.3-7 81-15 113-26 100.7-33 179.7-91 237 +-174 2.7-5 6-9 10-13 .7-1 7.3-1 20-1h17z`,rightgroup:`M0 80h399565c371 0 266.7 149.4 414 180 5.9 1.2 18 0 18 0 2 0 + 3-1 3-3v-38c-76-158-257-219-435-219H0z`,rightgroupunder:`M0 262h399565c371 0 266.7-149.4 414-180 5.9-1.2 18 0 18 + 0 2 0 3 1 3 3v38c-76 158-257 219-435 219H0z`,rightharpoon:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3 +-3.7-15.3-11-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2 +-10.7 0-16.7 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 + 69.2 92 94.5zm0 0v40h399900v-40z`,rightharpoonplus:`M0 241v40h399993c4.7-4.7 7-9.3 7-14 0-9.3-3.7-15.3-11 +-18-92.7-56.7-159-133.7-199-231-3.3-9.3-6-14.7-8-16-2-1.3-7-2-15-2-10.7 0-16.7 + 2-18 6-2 2.7-1 9.7 3 21 15.3 42 36.7 81.8 64 119.5 27.3 37.7 58 69.2 92 94.5z +m0 0v40h399900v-40z m100 194v40h399900v-40zm0 0v40h399900v-40z`,rightharpoondown:`M399747 511c0 7.3 6.7 11 20 11 8 0 13-.8 15-2.5s4.7-6.8 + 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 8.5-5.8 9.5 +-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3-64.7 57-92 95 +-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 241v40h399900v-40z`,rightharpoondownplus:`M399747 705c0 7.3 6.7 11 20 11 8 0 13-.8 + 15-2.5s4.7-6.8 8-15.5c40-94 99.3-166.3 178-217 13.3-8 20.3-12.3 21-13 5.3-3.3 + 8.5-5.8 9.5-7.5 1-1.7 1.5-5.2 1.5-10.5s-2.3-10.3-7-15H0v40h399908c-34 25.3 +-64.7 57-92 95-27.3 38-48.7 77.7-64 119-3.3 8.7-5 14-5 16zM0 435v40h399900v-40z +m0-194v40h400000v-40zm0 0v40h400000v-40z`,righthook:`M399859 241c-764 0 0 0 0 0 40-3.3 68.7-15.7 86-37 10-12 15-25.3 + 15-40 0-22.7-9.8-40.7-29.5-54-19.7-13.3-43.5-21-71.5-23-17.3-1.3-26-8-26-20 0 +-13.3 8.7-20 26-20 38 0 71 11.2 99 33.5 0 0 7 5.6 21 16.7 14 11.2 21 33.5 21 + 66.8s-14 61.2-42 83.5c-28 22.3-61 33.5-99 33.5L0 241z M0 281v-40h399859v40z`,rightlinesegment:`M399960 241 V94 h40 V428 h-40 V281 H0 v-40z +M399960 241 V94 h40 V428 h-40 V281 H0 v-40z`,rightToFrom:`M400000 167c-70.7-42-118-97.7-142-167h-23c-15.3 0-23 .3-23 + 1 0 1.3 5.3 13.7 16 37 18 35.3 41.3 69 70 101l7 8H0v40h399905l-7 8c-28.7 32 +-52 65.7-70 101-10.7 23.3-16 35.7-16 37 0 .7 7.7 1 23 1h23c24-69.3 71.3-125 142 +-167z M100 147v40h399900v-40zM0 341v40h399900v-40z`,twoheadleftarrow:`M0 167c68 40 + 115.7 95.7 143 167h22c15.3 0 23-.3 23-1 0-1.3-5.3-13.7-16-37-18-35.3-41.3-69 +-70-101l-7-8h125l9 7c50.7 39.3 85 86 103 140h46c0-4.7-6.3-18.7-19-42-18-35.3 +-40-67.3-66-96l-9-9h399716v-40H284l9-9c26-28.7 48-60.7 66-96 12.7-23.333 19 +-37.333 19-42h-46c-18 54-52.3 100.7-103 140l-9 7H95l7-8c28.7-32 52-65.7 70-101 + 10.7-23.333 16-35.7 16-37 0-.7-7.7-1-23-1h-22C115.7 71.3 68 127 0 167z`,twoheadrightarrow:`M400000 167 +c-68-40-115.7-95.7-143-167h-22c-15.3 0-23 .3-23 1 0 1.3 5.3 13.7 16 37 18 35.3 + 41.3 69 70 101l7 8h-125l-9-7c-50.7-39.3-85-86-103-140h-46c0 4.7 6.3 18.7 19 42 + 18 35.3 40 67.3 66 96l9 9H0v40h399716l-9 9c-26 28.7-48 60.7-66 96-12.7 23.333 +-19 37.333-19 42h46c18-54 52.3-100.7 103-140l9-7h125l-7 8c-28.7 32-52 65.7-70 + 101-10.7 23.333-16 35.7-16 37 0 .7 7.7 1 23 1h22c27.3-71.3 75-127 143-167z`,tilde1:`M200 55.538c-77 0-168 73.953-177 73.953-3 0-7 +-2.175-9-5.437L2 97c-1-2-2-4-2-6 0-4 2-7 5-9l20-12C116 12 171 0 207 0c86 0 + 114 68 191 68 78 0 168-68 177-68 4 0 7 2 9 5l12 19c1 2.175 2 4.35 2 6.525 0 + 4.35-2 7.613-5 9.788l-19 13.05c-92 63.077-116.937 75.308-183 76.128 +-68.267.847-113-73.952-191-73.952z`,tilde2:`M344 55.266c-142 0-300.638 81.316-311.5 86.418 +-8.01 3.762-22.5 10.91-23.5 5.562L1 120c-1-2-1-3-1-4 0-5 3-9 8-10l18.4-9C160.9 + 31.9 283 0 358 0c148 0 188 122 331 122s314-97 326-97c4 0 8 2 10 7l7 21.114 +c1 2.14 1 3.21 1 4.28 0 5.347-3 9.626-7 10.696l-22.3 12.622C852.6 158.372 751 + 181.476 676 181.476c-149 0-189-126.21-332-126.21z`,tilde3:`M786 59C457 59 32 175.242 13 175.242c-6 0-10-3.457 +-11-10.37L.15 138c-1-7 3-12 10-13l19.2-6.4C378.4 40.7 634.3 0 804.3 0c337 0 + 411.8 157 746.8 157 328 0 754-112 773-112 5 0 10 3 11 9l1 14.075c1 8.066-.697 + 16.595-6.697 17.492l-21.052 7.31c-367.9 98.146-609.15 122.696-778.15 122.696 + -338 0-409-156.573-744-156.573z`,tilde4:`M786 58C457 58 32 177.487 13 177.487c-6 0-10-3.345 +-11-10.035L.15 143c-1-7 3-12 10-13l22-6.7C381.2 35 637.15 0 807.15 0c337 0 409 + 177 744 177 328 0 754-127 773-127 5 0 10 3 11 9l1 14.794c1 7.805-3 13.38-9 + 14.495l-20.7 5.574c-366.85 99.79-607.3 139.372-776.3 139.372-338 0-409 + -175.236-744-175.236z`,vec:`M377 20c0-5.333 1.833-10 5.5-14S391 0 397 0c4.667 0 8.667 1.667 12 5 +3.333 2.667 6.667 9 10 19 6.667 24.667 20.333 43.667 41 57 7.333 4.667 11 +10.667 11 18 0 6-1 10-3 12s-6.667 5-14 9c-28.667 14.667-53.667 35.667-75 63 +-1.333 1.333-3.167 3.5-5.5 6.5s-4 4.833-5 5.5c-1 .667-2.5 1.333-4.5 2s-4.333 1 +-7 1c-4.667 0-9.167-1.833-13.5-5.5S337 184 337 178c0-12.667 15.667-32.333 47-59 +H213l-171-1c-8.667-6-13-12.333-13-19 0-4.667 4.333-11.333 13-20h359 +c-16-25.333-24-45-24-59z`,widehat1:`M529 0h5l519 115c5 1 9 5 9 10 0 1-1 2-1 3l-4 22 +c-1 5-5 9-11 9h-2L532 67 19 159h-2c-5 0-9-4-11-9l-5-22c-1-6 2-12 8-13z`,widehat2:`M1181 0h2l1171 176c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 220h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat3:`M1181 0h2l1171 236c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 280h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widehat4:`M1181 0h2l1171 296c6 0 10 5 10 11l-2 23c-1 6-5 10 +-11 10h-1L1182 67 15 340h-1c-6 0-10-4-11-10l-2-23c-1-6 4-11 10-11z`,widecheck1:`M529,159h5l519,-115c5,-1,9,-5,9,-10c0,-1,-1,-2,-1,-3l-4,-22c-1, +-5,-5,-9,-11,-9h-2l-512,92l-513,-92h-2c-5,0,-9,4,-11,9l-5,22c-1,6,2,12,8,13z`,widecheck2:`M1181,220h2l1171,-176c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,153l-1167,-153h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck3:`M1181,280h2l1171,-236c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,213l-1167,-213h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,widecheck4:`M1181,340h2l1171,-296c6,0,10,-5,10,-11l-2,-23c-1,-6,-5,-10, +-11,-10h-1l-1168,273l-1167,-273h-1c-6,0,-10,4,-11,10l-2,23c-1,6,4,11,10,11z`,baraboveleftarrow:`M400000 620h-399890l3 -3c68.7 -52.7 113.7 -120 135 -202 +c4 -14.7 6 -23 6 -25c0 -7.3 -7 -11 -21 -11c-8 0 -13.2 0.8 -15.5 2.5 +c-2.3 1.7 -4.2 5.8 -5.5 12.5c-1.3 4.7 -2.7 10.3 -4 17c-12 48.7 -34.8 92 -68.5 130 +s-74.2 66.3 -121.5 85c-10 4 -16 7.7 -18 11c0 8.7 6 14.3 18 17c47.3 18.7 87.8 47 +121.5 85s56.5 81.3 68.5 130c0.7 2 1.3 5 2 9s1.2 6.7 1.5 8c0.3 1.3 1 3.3 2 6 +s2.2 4.5 3.5 5.5c1.3 1 3.3 1.8 6 2.5s6 1 10 1c14 0 21 -3.7 21 -11 +c0 -2 -2 -10.3 -6 -25c-20 -79.3 -65 -146.7 -135 -202l-3 -3h399890z +M100 620v40h399900v-40z M0 241v40h399900v-40zM0 241v40h399900v-40z`,rightarrowabovebar:`M0 241v40h399891c-47.3 35.3-84 78-110 128-16.7 32 +-27.7 63.7-33 95 0 1.3-.2 2.7-.5 4-.3 1.3-.5 2.3-.5 3 0 7.3 6.7 11 20 11 8 0 +13.2-.8 15.5-2.5 2.3-1.7 4.2-5.5 5.5-11.5 2-13.3 5.7-27 11-41 14.7-44.7 39 +-84.5 73-119.5s73.7-60.2 119-75.5c6-2 9-5.7 9-11s-3-9-9-11c-45.3-15.3-85-40.5 +-119-75.5s-58.3-74.8-73-119.5c-4.7-14-8.3-27.3-11-40-1.3-6.7-3.2-10.8-5.5 +-12.5-2.3-1.7-7.5-2.5-15.5-2.5-14 0-21 3.7-21 11 0 2 2 10.3 6 25 20.7 83.3 67 +151.7 139 205zm96 379h399894v40H0zm0 0h399904v40H0z`,baraboveshortleftharpoon:`M507,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17 +c2,0.7,5,1,9,1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21 +c-32,-87.3,-82.7,-157.7,-152,-211c0,0,-3,-3,-3,-3l399351,0l0,-40 +c-398570,0,-399437,0,-399437,0z M593 435 v40 H399500 v-40z +M0 281 v-40 H399908 v40z M0 281 v-40 H399908 v40z`,rightharpoonaboveshortbar:`M0,241 l0,40c399126,0,399993,0,399993,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M0 241 v40 H399908 v-40z M0 475 v-40 H399500 v40z M0 475 v-40 H399500 v40z`,shortbaraboveleftharpoon:`M7,435c-4,4,-6.3,8.7,-7,14c0,5.3,0.7,9,2,11 +c1.3,2,5.3,5.3,12,10c90.7,54,156,130,196,228c3.3,10.7,6.3,16.3,9,17c2,0.7,5,1,9, +1c0,0,5,0,5,0c10.7,0,16.7,-2,18,-6c2,-2.7,1,-9.7,-3,-21c-32,-87.3,-82.7,-157.7, +-152,-211c0,0,-3,-3,-3,-3l399907,0l0,-40c-399126,0,-399993,0,-399993,0z +M93 435 v40 H400000 v-40z M500 241 v40 H400000 v-40z M500 241 v40 H400000 v-40z`,shortrightharpoonabovebar:`M53,241l0,40c398570,0,399437,0,399437,0 +c4.7,-4.7,7,-9.3,7,-14c0,-9.3,-3.7,-15.3,-11,-18c-92.7,-56.7,-159,-133.7,-199, +-231c-3.3,-9.3,-6,-14.7,-8,-16c-2,-1.3,-7,-2,-15,-2c-10.7,0,-16.7,2,-18,6 +c-2,2.7,-1,9.7,3,21c15.3,42,36.7,81.8,64,119.5c27.3,37.7,58,69.2,92,94.5z +M500 241 v40 H399408 v-40z M500 435 v40 H400000 v-40z`},Cbe=o(function(e,r){switch(e){case"lbrack":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v1759 h347 v-84 +H403z M403 1759 V0 H319 V1759 v`+r+" v1759 h84z";case"rbrack":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v1759 H0 v84 H347z +M347 1759 V0 H263 V1759 v`+r+" v1759 h84z";case"vert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+" v585 h43z";case"doublevert":return"M145 15 v585 v"+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v`+r+` v585 h43z +M367 15 v585 v`+r+` v585 c2.667,10,9.667,15,21,15 +c10,0,16.667,-5,20,-15 v-585 v`+-r+` v-585 c-2.667,-10,-9.667,-15,-21,-15 +c-10,0,-16.667,5,-20,15z M410 15 H367 v585 v`+r+" v585 h43z";case"lfloor":return"M319 602 V0 H403 V602 v"+r+` v1715 h263 v84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"rfloor":return"M319 602 V0 H403 V602 v"+r+` v1799 H0 v-84 H319z +MM319 602 V0 H403 V602 v`+r+" v1715 H319z";case"lceil":return"M403 1759 V84 H666 V0 H319 V1759 v"+r+` v602 h84z +M403 1759 V0 H319 V1759 v`+r+" v602 h84z";case"rceil":return"M347 1759 V0 H0 V84 H263 V1759 v"+r+` v602 h84z +M347 1759 V0 h-84 V1759 v`+r+" v602 h84z";case"lparen":return`M863,9c0,-2,-2,-5,-6,-9c0,0,-17,0,-17,0c-12.7,0,-19.3,0.3,-20,1 +c-5.3,5.3,-10.3,11,-15,17c-242.7,294.7,-395.3,682,-458,1162c-21.3,163.3,-33.3,349, +-36,557 l0,`+(r+84)+`c0.2,6,0,26,0,60c2,159.3,10,310.7,24,454c53.3,528,210, +949.7,470,1265c4.7,6,9.7,11.7,15,17c0.7,0.7,7,1,19,1c0,0,18,0,18,0c4,-4,6,-7,6,-9 +c0,-2.7,-3.3,-8.7,-10,-18c-135.3,-192.7,-235.5,-414.3,-300.5,-665c-65,-250.7,-102.5, +-544.7,-112.5,-882c-2,-104,-3,-167,-3,-189 +l0,-`+(r+92)+`c0,-162.7,5.7,-314,17,-454c20.7,-272,63.7,-513,129,-723c65.3, +-210,155.3,-396.3,270,-559c6.7,-9.3,10,-15.3,10,-18z`;case"rparen":return`M76,0c-16.7,0,-25,3,-25,9c0,2,2,6.3,6,13c21.3,28.7,42.3,60.3, +63,95c96.7,156.7,172.8,332.5,228.5,527.5c55.7,195,92.8,416.5,111.5,664.5 +c11.3,139.3,17,290.7,17,454c0,28,1.7,43,3.3,45l0,`+(r+9)+` +c-3,4,-3.3,16.7,-3.3,38c0,162,-5.7,313.7,-17,455c-18.7,248,-55.8,469.3,-111.5,664 +c-55.7,194.7,-131.8,370.3,-228.5,527c-20.7,34.7,-41.7,66.3,-63,95c-2,3.3,-4,7,-6,11 +c0,7.3,5.7,11,17,11c0,0,11,0,11,0c9.3,0,14.3,-0.3,15,-1c5.3,-5.3,10.3,-11,15,-17 +c242.7,-294.7,395.3,-681.7,458,-1161c21.3,-164.7,33.3,-350.7,36,-558 +l0,-`+(r+144)+`c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7, +-470,-1265c-4.7,-6,-9.7,-11.7,-15,-17c-0.7,-0.7,-6.7,-1,-18,-1z`;default:throw new Error("Unknown stretchy delimiter.")}},"tallDelim"),ed=class{static{o(this,"DocumentFragment")}constructor(e){this.children=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.children=e,this.classes=[],this.height=0,this.depth=0,this.maxFontSize=0,this.style={}}hasClass(e){return Jt.contains(this.classes,e)}toNode(){for(var e=document.createDocumentFragment(),r=0;rr.toText(),"toText");return this.children.map(e).join("")}},jl={"AMS-Regular":{32:[0,0,0,0,.25],65:[0,.68889,0,0,.72222],66:[0,.68889,0,0,.66667],67:[0,.68889,0,0,.72222],68:[0,.68889,0,0,.72222],69:[0,.68889,0,0,.66667],70:[0,.68889,0,0,.61111],71:[0,.68889,0,0,.77778],72:[0,.68889,0,0,.77778],73:[0,.68889,0,0,.38889],74:[.16667,.68889,0,0,.5],75:[0,.68889,0,0,.77778],76:[0,.68889,0,0,.66667],77:[0,.68889,0,0,.94445],78:[0,.68889,0,0,.72222],79:[.16667,.68889,0,0,.77778],80:[0,.68889,0,0,.61111],81:[.16667,.68889,0,0,.77778],82:[0,.68889,0,0,.72222],83:[0,.68889,0,0,.55556],84:[0,.68889,0,0,.66667],85:[0,.68889,0,0,.72222],86:[0,.68889,0,0,.72222],87:[0,.68889,0,0,1],88:[0,.68889,0,0,.72222],89:[0,.68889,0,0,.72222],90:[0,.68889,0,0,.66667],107:[0,.68889,0,0,.55556],160:[0,0,0,0,.25],165:[0,.675,.025,0,.75],174:[.15559,.69224,0,0,.94666],240:[0,.68889,0,0,.55556],295:[0,.68889,0,0,.54028],710:[0,.825,0,0,2.33334],732:[0,.9,0,0,2.33334],770:[0,.825,0,0,2.33334],771:[0,.9,0,0,2.33334],989:[.08167,.58167,0,0,.77778],1008:[0,.43056,.04028,0,.66667],8245:[0,.54986,0,0,.275],8463:[0,.68889,0,0,.54028],8487:[0,.68889,0,0,.72222],8498:[0,.68889,0,0,.55556],8502:[0,.68889,0,0,.66667],8503:[0,.68889,0,0,.44445],8504:[0,.68889,0,0,.66667],8513:[0,.68889,0,0,.63889],8592:[-.03598,.46402,0,0,.5],8594:[-.03598,.46402,0,0,.5],8602:[-.13313,.36687,0,0,1],8603:[-.13313,.36687,0,0,1],8606:[.01354,.52239,0,0,1],8608:[.01354,.52239,0,0,1],8610:[.01354,.52239,0,0,1.11111],8611:[.01354,.52239,0,0,1.11111],8619:[0,.54986,0,0,1],8620:[0,.54986,0,0,1],8621:[-.13313,.37788,0,0,1.38889],8622:[-.13313,.36687,0,0,1],8624:[0,.69224,0,0,.5],8625:[0,.69224,0,0,.5],8630:[0,.43056,0,0,1],8631:[0,.43056,0,0,1],8634:[.08198,.58198,0,0,.77778],8635:[.08198,.58198,0,0,.77778],8638:[.19444,.69224,0,0,.41667],8639:[.19444,.69224,0,0,.41667],8642:[.19444,.69224,0,0,.41667],8643:[.19444,.69224,0,0,.41667],8644:[.1808,.675,0,0,1],8646:[.1808,.675,0,0,1],8647:[.1808,.675,0,0,1],8648:[.19444,.69224,0,0,.83334],8649:[.1808,.675,0,0,1],8650:[.19444,.69224,0,0,.83334],8651:[.01354,.52239,0,0,1],8652:[.01354,.52239,0,0,1],8653:[-.13313,.36687,0,0,1],8654:[-.13313,.36687,0,0,1],8655:[-.13313,.36687,0,0,1],8666:[.13667,.63667,0,0,1],8667:[.13667,.63667,0,0,1],8669:[-.13313,.37788,0,0,1],8672:[-.064,.437,0,0,1.334],8674:[-.064,.437,0,0,1.334],8705:[0,.825,0,0,.5],8708:[0,.68889,0,0,.55556],8709:[.08167,.58167,0,0,.77778],8717:[0,.43056,0,0,.42917],8722:[-.03598,.46402,0,0,.5],8724:[.08198,.69224,0,0,.77778],8726:[.08167,.58167,0,0,.77778],8733:[0,.69224,0,0,.77778],8736:[0,.69224,0,0,.72222],8737:[0,.69224,0,0,.72222],8738:[.03517,.52239,0,0,.72222],8739:[.08167,.58167,0,0,.22222],8740:[.25142,.74111,0,0,.27778],8741:[.08167,.58167,0,0,.38889],8742:[.25142,.74111,0,0,.5],8756:[0,.69224,0,0,.66667],8757:[0,.69224,0,0,.66667],8764:[-.13313,.36687,0,0,.77778],8765:[-.13313,.37788,0,0,.77778],8769:[-.13313,.36687,0,0,.77778],8770:[-.03625,.46375,0,0,.77778],8774:[.30274,.79383,0,0,.77778],8776:[-.01688,.48312,0,0,.77778],8778:[.08167,.58167,0,0,.77778],8782:[.06062,.54986,0,0,.77778],8783:[.06062,.54986,0,0,.77778],8785:[.08198,.58198,0,0,.77778],8786:[.08198,.58198,0,0,.77778],8787:[.08198,.58198,0,0,.77778],8790:[0,.69224,0,0,.77778],8791:[.22958,.72958,0,0,.77778],8796:[.08198,.91667,0,0,.77778],8806:[.25583,.75583,0,0,.77778],8807:[.25583,.75583,0,0,.77778],8808:[.25142,.75726,0,0,.77778],8809:[.25142,.75726,0,0,.77778],8812:[.25583,.75583,0,0,.5],8814:[.20576,.70576,0,0,.77778],8815:[.20576,.70576,0,0,.77778],8816:[.30274,.79383,0,0,.77778],8817:[.30274,.79383,0,0,.77778],8818:[.22958,.72958,0,0,.77778],8819:[.22958,.72958,0,0,.77778],8822:[.1808,.675,0,0,.77778],8823:[.1808,.675,0,0,.77778],8828:[.13667,.63667,0,0,.77778],8829:[.13667,.63667,0,0,.77778],8830:[.22958,.72958,0,0,.77778],8831:[.22958,.72958,0,0,.77778],8832:[.20576,.70576,0,0,.77778],8833:[.20576,.70576,0,0,.77778],8840:[.30274,.79383,0,0,.77778],8841:[.30274,.79383,0,0,.77778],8842:[.13597,.63597,0,0,.77778],8843:[.13597,.63597,0,0,.77778],8847:[.03517,.54986,0,0,.77778],8848:[.03517,.54986,0,0,.77778],8858:[.08198,.58198,0,0,.77778],8859:[.08198,.58198,0,0,.77778],8861:[.08198,.58198,0,0,.77778],8862:[0,.675,0,0,.77778],8863:[0,.675,0,0,.77778],8864:[0,.675,0,0,.77778],8865:[0,.675,0,0,.77778],8872:[0,.69224,0,0,.61111],8873:[0,.69224,0,0,.72222],8874:[0,.69224,0,0,.88889],8876:[0,.68889,0,0,.61111],8877:[0,.68889,0,0,.61111],8878:[0,.68889,0,0,.72222],8879:[0,.68889,0,0,.72222],8882:[.03517,.54986,0,0,.77778],8883:[.03517,.54986,0,0,.77778],8884:[.13667,.63667,0,0,.77778],8885:[.13667,.63667,0,0,.77778],8888:[0,.54986,0,0,1.11111],8890:[.19444,.43056,0,0,.55556],8891:[.19444,.69224,0,0,.61111],8892:[.19444,.69224,0,0,.61111],8901:[0,.54986,0,0,.27778],8903:[.08167,.58167,0,0,.77778],8905:[.08167,.58167,0,0,.77778],8906:[.08167,.58167,0,0,.77778],8907:[0,.69224,0,0,.77778],8908:[0,.69224,0,0,.77778],8909:[-.03598,.46402,0,0,.77778],8910:[0,.54986,0,0,.76042],8911:[0,.54986,0,0,.76042],8912:[.03517,.54986,0,0,.77778],8913:[.03517,.54986,0,0,.77778],8914:[0,.54986,0,0,.66667],8915:[0,.54986,0,0,.66667],8916:[0,.69224,0,0,.66667],8918:[.0391,.5391,0,0,.77778],8919:[.0391,.5391,0,0,.77778],8920:[.03517,.54986,0,0,1.33334],8921:[.03517,.54986,0,0,1.33334],8922:[.38569,.88569,0,0,.77778],8923:[.38569,.88569,0,0,.77778],8926:[.13667,.63667,0,0,.77778],8927:[.13667,.63667,0,0,.77778],8928:[.30274,.79383,0,0,.77778],8929:[.30274,.79383,0,0,.77778],8934:[.23222,.74111,0,0,.77778],8935:[.23222,.74111,0,0,.77778],8936:[.23222,.74111,0,0,.77778],8937:[.23222,.74111,0,0,.77778],8938:[.20576,.70576,0,0,.77778],8939:[.20576,.70576,0,0,.77778],8940:[.30274,.79383,0,0,.77778],8941:[.30274,.79383,0,0,.77778],8994:[.19444,.69224,0,0,.77778],8995:[.19444,.69224,0,0,.77778],9416:[.15559,.69224,0,0,.90222],9484:[0,.69224,0,0,.5],9488:[0,.69224,0,0,.5],9492:[0,.37788,0,0,.5],9496:[0,.37788,0,0,.5],9585:[.19444,.68889,0,0,.88889],9586:[.19444,.74111,0,0,.88889],9632:[0,.675,0,0,.77778],9633:[0,.675,0,0,.77778],9650:[0,.54986,0,0,.72222],9651:[0,.54986,0,0,.72222],9654:[.03517,.54986,0,0,.77778],9660:[0,.54986,0,0,.72222],9661:[0,.54986,0,0,.72222],9664:[.03517,.54986,0,0,.77778],9674:[.11111,.69224,0,0,.66667],9733:[.19444,.69224,0,0,.94445],10003:[0,.69224,0,0,.83334],10016:[0,.69224,0,0,.83334],10731:[.11111,.69224,0,0,.66667],10846:[.19444,.75583,0,0,.61111],10877:[.13667,.63667,0,0,.77778],10878:[.13667,.63667,0,0,.77778],10885:[.25583,.75583,0,0,.77778],10886:[.25583,.75583,0,0,.77778],10887:[.13597,.63597,0,0,.77778],10888:[.13597,.63597,0,0,.77778],10889:[.26167,.75726,0,0,.77778],10890:[.26167,.75726,0,0,.77778],10891:[.48256,.98256,0,0,.77778],10892:[.48256,.98256,0,0,.77778],10901:[.13667,.63667,0,0,.77778],10902:[.13667,.63667,0,0,.77778],10933:[.25142,.75726,0,0,.77778],10934:[.25142,.75726,0,0,.77778],10935:[.26167,.75726,0,0,.77778],10936:[.26167,.75726,0,0,.77778],10937:[.26167,.75726,0,0,.77778],10938:[.26167,.75726,0,0,.77778],10949:[.25583,.75583,0,0,.77778],10950:[.25583,.75583,0,0,.77778],10955:[.28481,.79383,0,0,.77778],10956:[.28481,.79383,0,0,.77778],57350:[.08167,.58167,0,0,.22222],57351:[.08167,.58167,0,0,.38889],57352:[.08167,.58167,0,0,.77778],57353:[0,.43056,.04028,0,.66667],57356:[.25142,.75726,0,0,.77778],57357:[.25142,.75726,0,0,.77778],57358:[.41951,.91951,0,0,.77778],57359:[.30274,.79383,0,0,.77778],57360:[.30274,.79383,0,0,.77778],57361:[.41951,.91951,0,0,.77778],57366:[.25142,.75726,0,0,.77778],57367:[.25142,.75726,0,0,.77778],57368:[.25142,.75726,0,0,.77778],57369:[.25142,.75726,0,0,.77778],57370:[.13597,.63597,0,0,.77778],57371:[.13597,.63597,0,0,.77778]},"Caligraphic-Regular":{32:[0,0,0,0,.25],65:[0,.68333,0,.19445,.79847],66:[0,.68333,.03041,.13889,.65681],67:[0,.68333,.05834,.13889,.52653],68:[0,.68333,.02778,.08334,.77139],69:[0,.68333,.08944,.11111,.52778],70:[0,.68333,.09931,.11111,.71875],71:[.09722,.68333,.0593,.11111,.59487],72:[0,.68333,.00965,.11111,.84452],73:[0,.68333,.07382,0,.54452],74:[.09722,.68333,.18472,.16667,.67778],75:[0,.68333,.01445,.05556,.76195],76:[0,.68333,0,.13889,.68972],77:[0,.68333,0,.13889,1.2009],78:[0,.68333,.14736,.08334,.82049],79:[0,.68333,.02778,.11111,.79611],80:[0,.68333,.08222,.08334,.69556],81:[.09722,.68333,0,.11111,.81667],82:[0,.68333,0,.08334,.8475],83:[0,.68333,.075,.13889,.60556],84:[0,.68333,.25417,0,.54464],85:[0,.68333,.09931,.08334,.62583],86:[0,.68333,.08222,0,.61278],87:[0,.68333,.08222,.08334,.98778],88:[0,.68333,.14643,.13889,.7133],89:[.09722,.68333,.08222,.08334,.66834],90:[0,.68333,.07944,.13889,.72473],160:[0,0,0,0,.25]},"Fraktur-Regular":{32:[0,0,0,0,.25],33:[0,.69141,0,0,.29574],34:[0,.69141,0,0,.21471],38:[0,.69141,0,0,.73786],39:[0,.69141,0,0,.21201],40:[.24982,.74947,0,0,.38865],41:[.24982,.74947,0,0,.38865],42:[0,.62119,0,0,.27764],43:[.08319,.58283,0,0,.75623],44:[0,.10803,0,0,.27764],45:[.08319,.58283,0,0,.75623],46:[0,.10803,0,0,.27764],47:[.24982,.74947,0,0,.50181],48:[0,.47534,0,0,.50181],49:[0,.47534,0,0,.50181],50:[0,.47534,0,0,.50181],51:[.18906,.47534,0,0,.50181],52:[.18906,.47534,0,0,.50181],53:[.18906,.47534,0,0,.50181],54:[0,.69141,0,0,.50181],55:[.18906,.47534,0,0,.50181],56:[0,.69141,0,0,.50181],57:[.18906,.47534,0,0,.50181],58:[0,.47534,0,0,.21606],59:[.12604,.47534,0,0,.21606],61:[-.13099,.36866,0,0,.75623],63:[0,.69141,0,0,.36245],65:[0,.69141,0,0,.7176],66:[0,.69141,0,0,.88397],67:[0,.69141,0,0,.61254],68:[0,.69141,0,0,.83158],69:[0,.69141,0,0,.66278],70:[.12604,.69141,0,0,.61119],71:[0,.69141,0,0,.78539],72:[.06302,.69141,0,0,.7203],73:[0,.69141,0,0,.55448],74:[.12604,.69141,0,0,.55231],75:[0,.69141,0,0,.66845],76:[0,.69141,0,0,.66602],77:[0,.69141,0,0,1.04953],78:[0,.69141,0,0,.83212],79:[0,.69141,0,0,.82699],80:[.18906,.69141,0,0,.82753],81:[.03781,.69141,0,0,.82699],82:[0,.69141,0,0,.82807],83:[0,.69141,0,0,.82861],84:[0,.69141,0,0,.66899],85:[0,.69141,0,0,.64576],86:[0,.69141,0,0,.83131],87:[0,.69141,0,0,1.04602],88:[0,.69141,0,0,.71922],89:[.18906,.69141,0,0,.83293],90:[.12604,.69141,0,0,.60201],91:[.24982,.74947,0,0,.27764],93:[.24982,.74947,0,0,.27764],94:[0,.69141,0,0,.49965],97:[0,.47534,0,0,.50046],98:[0,.69141,0,0,.51315],99:[0,.47534,0,0,.38946],100:[0,.62119,0,0,.49857],101:[0,.47534,0,0,.40053],102:[.18906,.69141,0,0,.32626],103:[.18906,.47534,0,0,.5037],104:[.18906,.69141,0,0,.52126],105:[0,.69141,0,0,.27899],106:[0,.69141,0,0,.28088],107:[0,.69141,0,0,.38946],108:[0,.69141,0,0,.27953],109:[0,.47534,0,0,.76676],110:[0,.47534,0,0,.52666],111:[0,.47534,0,0,.48885],112:[.18906,.52396,0,0,.50046],113:[.18906,.47534,0,0,.48912],114:[0,.47534,0,0,.38919],115:[0,.47534,0,0,.44266],116:[0,.62119,0,0,.33301],117:[0,.47534,0,0,.5172],118:[0,.52396,0,0,.5118],119:[0,.52396,0,0,.77351],120:[.18906,.47534,0,0,.38865],121:[.18906,.47534,0,0,.49884],122:[.18906,.47534,0,0,.39054],160:[0,0,0,0,.25],8216:[0,.69141,0,0,.21471],8217:[0,.69141,0,0,.21471],58112:[0,.62119,0,0,.49749],58113:[0,.62119,0,0,.4983],58114:[.18906,.69141,0,0,.33328],58115:[.18906,.69141,0,0,.32923],58116:[.18906,.47534,0,0,.50343],58117:[0,.69141,0,0,.33301],58118:[0,.62119,0,0,.33409],58119:[0,.47534,0,0,.50073]},"Main-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.35],34:[0,.69444,0,0,.60278],35:[.19444,.69444,0,0,.95833],36:[.05556,.75,0,0,.575],37:[.05556,.75,0,0,.95833],38:[0,.69444,0,0,.89444],39:[0,.69444,0,0,.31944],40:[.25,.75,0,0,.44722],41:[.25,.75,0,0,.44722],42:[0,.75,0,0,.575],43:[.13333,.63333,0,0,.89444],44:[.19444,.15556,0,0,.31944],45:[0,.44444,0,0,.38333],46:[0,.15556,0,0,.31944],47:[.25,.75,0,0,.575],48:[0,.64444,0,0,.575],49:[0,.64444,0,0,.575],50:[0,.64444,0,0,.575],51:[0,.64444,0,0,.575],52:[0,.64444,0,0,.575],53:[0,.64444,0,0,.575],54:[0,.64444,0,0,.575],55:[0,.64444,0,0,.575],56:[0,.64444,0,0,.575],57:[0,.64444,0,0,.575],58:[0,.44444,0,0,.31944],59:[.19444,.44444,0,0,.31944],60:[.08556,.58556,0,0,.89444],61:[-.10889,.39111,0,0,.89444],62:[.08556,.58556,0,0,.89444],63:[0,.69444,0,0,.54305],64:[0,.69444,0,0,.89444],65:[0,.68611,0,0,.86944],66:[0,.68611,0,0,.81805],67:[0,.68611,0,0,.83055],68:[0,.68611,0,0,.88194],69:[0,.68611,0,0,.75555],70:[0,.68611,0,0,.72361],71:[0,.68611,0,0,.90416],72:[0,.68611,0,0,.9],73:[0,.68611,0,0,.43611],74:[0,.68611,0,0,.59444],75:[0,.68611,0,0,.90138],76:[0,.68611,0,0,.69166],77:[0,.68611,0,0,1.09166],78:[0,.68611,0,0,.9],79:[0,.68611,0,0,.86388],80:[0,.68611,0,0,.78611],81:[.19444,.68611,0,0,.86388],82:[0,.68611,0,0,.8625],83:[0,.68611,0,0,.63889],84:[0,.68611,0,0,.8],85:[0,.68611,0,0,.88472],86:[0,.68611,.01597,0,.86944],87:[0,.68611,.01597,0,1.18888],88:[0,.68611,0,0,.86944],89:[0,.68611,.02875,0,.86944],90:[0,.68611,0,0,.70277],91:[.25,.75,0,0,.31944],92:[.25,.75,0,0,.575],93:[.25,.75,0,0,.31944],94:[0,.69444,0,0,.575],95:[.31,.13444,.03194,0,.575],97:[0,.44444,0,0,.55902],98:[0,.69444,0,0,.63889],99:[0,.44444,0,0,.51111],100:[0,.69444,0,0,.63889],101:[0,.44444,0,0,.52708],102:[0,.69444,.10903,0,.35139],103:[.19444,.44444,.01597,0,.575],104:[0,.69444,0,0,.63889],105:[0,.69444,0,0,.31944],106:[.19444,.69444,0,0,.35139],107:[0,.69444,0,0,.60694],108:[0,.69444,0,0,.31944],109:[0,.44444,0,0,.95833],110:[0,.44444,0,0,.63889],111:[0,.44444,0,0,.575],112:[.19444,.44444,0,0,.63889],113:[.19444,.44444,0,0,.60694],114:[0,.44444,0,0,.47361],115:[0,.44444,0,0,.45361],116:[0,.63492,0,0,.44722],117:[0,.44444,0,0,.63889],118:[0,.44444,.01597,0,.60694],119:[0,.44444,.01597,0,.83055],120:[0,.44444,0,0,.60694],121:[.19444,.44444,.01597,0,.60694],122:[0,.44444,0,0,.51111],123:[.25,.75,0,0,.575],124:[.25,.75,0,0,.31944],125:[.25,.75,0,0,.575],126:[.35,.34444,0,0,.575],160:[0,0,0,0,.25],163:[0,.69444,0,0,.86853],168:[0,.69444,0,0,.575],172:[0,.44444,0,0,.76666],176:[0,.69444,0,0,.86944],177:[.13333,.63333,0,0,.89444],184:[.17014,0,0,0,.51111],198:[0,.68611,0,0,1.04166],215:[.13333,.63333,0,0,.89444],216:[.04861,.73472,0,0,.89444],223:[0,.69444,0,0,.59722],230:[0,.44444,0,0,.83055],247:[.13333,.63333,0,0,.89444],248:[.09722,.54167,0,0,.575],305:[0,.44444,0,0,.31944],338:[0,.68611,0,0,1.16944],339:[0,.44444,0,0,.89444],567:[.19444,.44444,0,0,.35139],710:[0,.69444,0,0,.575],711:[0,.63194,0,0,.575],713:[0,.59611,0,0,.575],714:[0,.69444,0,0,.575],715:[0,.69444,0,0,.575],728:[0,.69444,0,0,.575],729:[0,.69444,0,0,.31944],730:[0,.69444,0,0,.86944],732:[0,.69444,0,0,.575],733:[0,.69444,0,0,.575],915:[0,.68611,0,0,.69166],916:[0,.68611,0,0,.95833],920:[0,.68611,0,0,.89444],923:[0,.68611,0,0,.80555],926:[0,.68611,0,0,.76666],928:[0,.68611,0,0,.9],931:[0,.68611,0,0,.83055],933:[0,.68611,0,0,.89444],934:[0,.68611,0,0,.83055],936:[0,.68611,0,0,.89444],937:[0,.68611,0,0,.83055],8211:[0,.44444,.03194,0,.575],8212:[0,.44444,.03194,0,1.14999],8216:[0,.69444,0,0,.31944],8217:[0,.69444,0,0,.31944],8220:[0,.69444,0,0,.60278],8221:[0,.69444,0,0,.60278],8224:[.19444,.69444,0,0,.51111],8225:[.19444,.69444,0,0,.51111],8242:[0,.55556,0,0,.34444],8407:[0,.72444,.15486,0,.575],8463:[0,.69444,0,0,.66759],8465:[0,.69444,0,0,.83055],8467:[0,.69444,0,0,.47361],8472:[.19444,.44444,0,0,.74027],8476:[0,.69444,0,0,.83055],8501:[0,.69444,0,0,.70277],8592:[-.10889,.39111,0,0,1.14999],8593:[.19444,.69444,0,0,.575],8594:[-.10889,.39111,0,0,1.14999],8595:[.19444,.69444,0,0,.575],8596:[-.10889,.39111,0,0,1.14999],8597:[.25,.75,0,0,.575],8598:[.19444,.69444,0,0,1.14999],8599:[.19444,.69444,0,0,1.14999],8600:[.19444,.69444,0,0,1.14999],8601:[.19444,.69444,0,0,1.14999],8636:[-.10889,.39111,0,0,1.14999],8637:[-.10889,.39111,0,0,1.14999],8640:[-.10889,.39111,0,0,1.14999],8641:[-.10889,.39111,0,0,1.14999],8656:[-.10889,.39111,0,0,1.14999],8657:[.19444,.69444,0,0,.70277],8658:[-.10889,.39111,0,0,1.14999],8659:[.19444,.69444,0,0,.70277],8660:[-.10889,.39111,0,0,1.14999],8661:[.25,.75,0,0,.70277],8704:[0,.69444,0,0,.63889],8706:[0,.69444,.06389,0,.62847],8707:[0,.69444,0,0,.63889],8709:[.05556,.75,0,0,.575],8711:[0,.68611,0,0,.95833],8712:[.08556,.58556,0,0,.76666],8715:[.08556,.58556,0,0,.76666],8722:[.13333,.63333,0,0,.89444],8723:[.13333,.63333,0,0,.89444],8725:[.25,.75,0,0,.575],8726:[.25,.75,0,0,.575],8727:[-.02778,.47222,0,0,.575],8728:[-.02639,.47361,0,0,.575],8729:[-.02639,.47361,0,0,.575],8730:[.18,.82,0,0,.95833],8733:[0,.44444,0,0,.89444],8734:[0,.44444,0,0,1.14999],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.31944],8741:[.25,.75,0,0,.575],8743:[0,.55556,0,0,.76666],8744:[0,.55556,0,0,.76666],8745:[0,.55556,0,0,.76666],8746:[0,.55556,0,0,.76666],8747:[.19444,.69444,.12778,0,.56875],8764:[-.10889,.39111,0,0,.89444],8768:[.19444,.69444,0,0,.31944],8771:[.00222,.50222,0,0,.89444],8773:[.027,.638,0,0,.894],8776:[.02444,.52444,0,0,.89444],8781:[.00222,.50222,0,0,.89444],8801:[.00222,.50222,0,0,.89444],8804:[.19667,.69667,0,0,.89444],8805:[.19667,.69667,0,0,.89444],8810:[.08556,.58556,0,0,1.14999],8811:[.08556,.58556,0,0,1.14999],8826:[.08556,.58556,0,0,.89444],8827:[.08556,.58556,0,0,.89444],8834:[.08556,.58556,0,0,.89444],8835:[.08556,.58556,0,0,.89444],8838:[.19667,.69667,0,0,.89444],8839:[.19667,.69667,0,0,.89444],8846:[0,.55556,0,0,.76666],8849:[.19667,.69667,0,0,.89444],8850:[.19667,.69667,0,0,.89444],8851:[0,.55556,0,0,.76666],8852:[0,.55556,0,0,.76666],8853:[.13333,.63333,0,0,.89444],8854:[.13333,.63333,0,0,.89444],8855:[.13333,.63333,0,0,.89444],8856:[.13333,.63333,0,0,.89444],8857:[.13333,.63333,0,0,.89444],8866:[0,.69444,0,0,.70277],8867:[0,.69444,0,0,.70277],8868:[0,.69444,0,0,.89444],8869:[0,.69444,0,0,.89444],8900:[-.02639,.47361,0,0,.575],8901:[-.02639,.47361,0,0,.31944],8902:[-.02778,.47222,0,0,.575],8968:[.25,.75,0,0,.51111],8969:[.25,.75,0,0,.51111],8970:[.25,.75,0,0,.51111],8971:[.25,.75,0,0,.51111],8994:[-.13889,.36111,0,0,1.14999],8995:[-.13889,.36111,0,0,1.14999],9651:[.19444,.69444,0,0,1.02222],9657:[-.02778,.47222,0,0,.575],9661:[.19444,.69444,0,0,1.02222],9667:[-.02778,.47222,0,0,.575],9711:[.19444,.69444,0,0,1.14999],9824:[.12963,.69444,0,0,.89444],9825:[.12963,.69444,0,0,.89444],9826:[.12963,.69444,0,0,.89444],9827:[.12963,.69444,0,0,.89444],9837:[0,.75,0,0,.44722],9838:[.19444,.69444,0,0,.44722],9839:[.19444,.69444,0,0,.44722],10216:[.25,.75,0,0,.44722],10217:[.25,.75,0,0,.44722],10815:[0,.68611,0,0,.9],10927:[.19667,.69667,0,0,.89444],10928:[.19667,.69667,0,0,.89444],57376:[.19444,.69444,0,0,0]},"Main-BoldItalic":{32:[0,0,0,0,.25],33:[0,.69444,.11417,0,.38611],34:[0,.69444,.07939,0,.62055],35:[.19444,.69444,.06833,0,.94444],37:[.05556,.75,.12861,0,.94444],38:[0,.69444,.08528,0,.88555],39:[0,.69444,.12945,0,.35555],40:[.25,.75,.15806,0,.47333],41:[.25,.75,.03306,0,.47333],42:[0,.75,.14333,0,.59111],43:[.10333,.60333,.03306,0,.88555],44:[.19444,.14722,0,0,.35555],45:[0,.44444,.02611,0,.41444],46:[0,.14722,0,0,.35555],47:[.25,.75,.15806,0,.59111],48:[0,.64444,.13167,0,.59111],49:[0,.64444,.13167,0,.59111],50:[0,.64444,.13167,0,.59111],51:[0,.64444,.13167,0,.59111],52:[.19444,.64444,.13167,0,.59111],53:[0,.64444,.13167,0,.59111],54:[0,.64444,.13167,0,.59111],55:[.19444,.64444,.13167,0,.59111],56:[0,.64444,.13167,0,.59111],57:[0,.64444,.13167,0,.59111],58:[0,.44444,.06695,0,.35555],59:[.19444,.44444,.06695,0,.35555],61:[-.10889,.39111,.06833,0,.88555],63:[0,.69444,.11472,0,.59111],64:[0,.69444,.09208,0,.88555],65:[0,.68611,0,0,.86555],66:[0,.68611,.0992,0,.81666],67:[0,.68611,.14208,0,.82666],68:[0,.68611,.09062,0,.87555],69:[0,.68611,.11431,0,.75666],70:[0,.68611,.12903,0,.72722],71:[0,.68611,.07347,0,.89527],72:[0,.68611,.17208,0,.8961],73:[0,.68611,.15681,0,.47166],74:[0,.68611,.145,0,.61055],75:[0,.68611,.14208,0,.89499],76:[0,.68611,0,0,.69777],77:[0,.68611,.17208,0,1.07277],78:[0,.68611,.17208,0,.8961],79:[0,.68611,.09062,0,.85499],80:[0,.68611,.0992,0,.78721],81:[.19444,.68611,.09062,0,.85499],82:[0,.68611,.02559,0,.85944],83:[0,.68611,.11264,0,.64999],84:[0,.68611,.12903,0,.7961],85:[0,.68611,.17208,0,.88083],86:[0,.68611,.18625,0,.86555],87:[0,.68611,.18625,0,1.15999],88:[0,.68611,.15681,0,.86555],89:[0,.68611,.19803,0,.86555],90:[0,.68611,.14208,0,.70888],91:[.25,.75,.1875,0,.35611],93:[.25,.75,.09972,0,.35611],94:[0,.69444,.06709,0,.59111],95:[.31,.13444,.09811,0,.59111],97:[0,.44444,.09426,0,.59111],98:[0,.69444,.07861,0,.53222],99:[0,.44444,.05222,0,.53222],100:[0,.69444,.10861,0,.59111],101:[0,.44444,.085,0,.53222],102:[.19444,.69444,.21778,0,.4],103:[.19444,.44444,.105,0,.53222],104:[0,.69444,.09426,0,.59111],105:[0,.69326,.11387,0,.35555],106:[.19444,.69326,.1672,0,.35555],107:[0,.69444,.11111,0,.53222],108:[0,.69444,.10861,0,.29666],109:[0,.44444,.09426,0,.94444],110:[0,.44444,.09426,0,.64999],111:[0,.44444,.07861,0,.59111],112:[.19444,.44444,.07861,0,.59111],113:[.19444,.44444,.105,0,.53222],114:[0,.44444,.11111,0,.50167],115:[0,.44444,.08167,0,.48694],116:[0,.63492,.09639,0,.385],117:[0,.44444,.09426,0,.62055],118:[0,.44444,.11111,0,.53222],119:[0,.44444,.11111,0,.76777],120:[0,.44444,.12583,0,.56055],121:[.19444,.44444,.105,0,.56166],122:[0,.44444,.13889,0,.49055],126:[.35,.34444,.11472,0,.59111],160:[0,0,0,0,.25],168:[0,.69444,.11473,0,.59111],176:[0,.69444,0,0,.94888],184:[.17014,0,0,0,.53222],198:[0,.68611,.11431,0,1.02277],216:[.04861,.73472,.09062,0,.88555],223:[.19444,.69444,.09736,0,.665],230:[0,.44444,.085,0,.82666],248:[.09722,.54167,.09458,0,.59111],305:[0,.44444,.09426,0,.35555],338:[0,.68611,.11431,0,1.14054],339:[0,.44444,.085,0,.82666],567:[.19444,.44444,.04611,0,.385],710:[0,.69444,.06709,0,.59111],711:[0,.63194,.08271,0,.59111],713:[0,.59444,.10444,0,.59111],714:[0,.69444,.08528,0,.59111],715:[0,.69444,0,0,.59111],728:[0,.69444,.10333,0,.59111],729:[0,.69444,.12945,0,.35555],730:[0,.69444,0,0,.94888],732:[0,.69444,.11472,0,.59111],733:[0,.69444,.11472,0,.59111],915:[0,.68611,.12903,0,.69777],916:[0,.68611,0,0,.94444],920:[0,.68611,.09062,0,.88555],923:[0,.68611,0,0,.80666],926:[0,.68611,.15092,0,.76777],928:[0,.68611,.17208,0,.8961],931:[0,.68611,.11431,0,.82666],933:[0,.68611,.10778,0,.88555],934:[0,.68611,.05632,0,.82666],936:[0,.68611,.10778,0,.88555],937:[0,.68611,.0992,0,.82666],8211:[0,.44444,.09811,0,.59111],8212:[0,.44444,.09811,0,1.18221],8216:[0,.69444,.12945,0,.35555],8217:[0,.69444,.12945,0,.35555],8220:[0,.69444,.16772,0,.62055],8221:[0,.69444,.07939,0,.62055]},"Main-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.12417,0,.30667],34:[0,.69444,.06961,0,.51444],35:[.19444,.69444,.06616,0,.81777],37:[.05556,.75,.13639,0,.81777],38:[0,.69444,.09694,0,.76666],39:[0,.69444,.12417,0,.30667],40:[.25,.75,.16194,0,.40889],41:[.25,.75,.03694,0,.40889],42:[0,.75,.14917,0,.51111],43:[.05667,.56167,.03694,0,.76666],44:[.19444,.10556,0,0,.30667],45:[0,.43056,.02826,0,.35778],46:[0,.10556,0,0,.30667],47:[.25,.75,.16194,0,.51111],48:[0,.64444,.13556,0,.51111],49:[0,.64444,.13556,0,.51111],50:[0,.64444,.13556,0,.51111],51:[0,.64444,.13556,0,.51111],52:[.19444,.64444,.13556,0,.51111],53:[0,.64444,.13556,0,.51111],54:[0,.64444,.13556,0,.51111],55:[.19444,.64444,.13556,0,.51111],56:[0,.64444,.13556,0,.51111],57:[0,.64444,.13556,0,.51111],58:[0,.43056,.0582,0,.30667],59:[.19444,.43056,.0582,0,.30667],61:[-.13313,.36687,.06616,0,.76666],63:[0,.69444,.1225,0,.51111],64:[0,.69444,.09597,0,.76666],65:[0,.68333,0,0,.74333],66:[0,.68333,.10257,0,.70389],67:[0,.68333,.14528,0,.71555],68:[0,.68333,.09403,0,.755],69:[0,.68333,.12028,0,.67833],70:[0,.68333,.13305,0,.65277],71:[0,.68333,.08722,0,.77361],72:[0,.68333,.16389,0,.74333],73:[0,.68333,.15806,0,.38555],74:[0,.68333,.14028,0,.525],75:[0,.68333,.14528,0,.76888],76:[0,.68333,0,0,.62722],77:[0,.68333,.16389,0,.89666],78:[0,.68333,.16389,0,.74333],79:[0,.68333,.09403,0,.76666],80:[0,.68333,.10257,0,.67833],81:[.19444,.68333,.09403,0,.76666],82:[0,.68333,.03868,0,.72944],83:[0,.68333,.11972,0,.56222],84:[0,.68333,.13305,0,.71555],85:[0,.68333,.16389,0,.74333],86:[0,.68333,.18361,0,.74333],87:[0,.68333,.18361,0,.99888],88:[0,.68333,.15806,0,.74333],89:[0,.68333,.19383,0,.74333],90:[0,.68333,.14528,0,.61333],91:[.25,.75,.1875,0,.30667],93:[.25,.75,.10528,0,.30667],94:[0,.69444,.06646,0,.51111],95:[.31,.12056,.09208,0,.51111],97:[0,.43056,.07671,0,.51111],98:[0,.69444,.06312,0,.46],99:[0,.43056,.05653,0,.46],100:[0,.69444,.10333,0,.51111],101:[0,.43056,.07514,0,.46],102:[.19444,.69444,.21194,0,.30667],103:[.19444,.43056,.08847,0,.46],104:[0,.69444,.07671,0,.51111],105:[0,.65536,.1019,0,.30667],106:[.19444,.65536,.14467,0,.30667],107:[0,.69444,.10764,0,.46],108:[0,.69444,.10333,0,.25555],109:[0,.43056,.07671,0,.81777],110:[0,.43056,.07671,0,.56222],111:[0,.43056,.06312,0,.51111],112:[.19444,.43056,.06312,0,.51111],113:[.19444,.43056,.08847,0,.46],114:[0,.43056,.10764,0,.42166],115:[0,.43056,.08208,0,.40889],116:[0,.61508,.09486,0,.33222],117:[0,.43056,.07671,0,.53666],118:[0,.43056,.10764,0,.46],119:[0,.43056,.10764,0,.66444],120:[0,.43056,.12042,0,.46389],121:[.19444,.43056,.08847,0,.48555],122:[0,.43056,.12292,0,.40889],126:[.35,.31786,.11585,0,.51111],160:[0,0,0,0,.25],168:[0,.66786,.10474,0,.51111],176:[0,.69444,0,0,.83129],184:[.17014,0,0,0,.46],198:[0,.68333,.12028,0,.88277],216:[.04861,.73194,.09403,0,.76666],223:[.19444,.69444,.10514,0,.53666],230:[0,.43056,.07514,0,.71555],248:[.09722,.52778,.09194,0,.51111],338:[0,.68333,.12028,0,.98499],339:[0,.43056,.07514,0,.71555],710:[0,.69444,.06646,0,.51111],711:[0,.62847,.08295,0,.51111],713:[0,.56167,.10333,0,.51111],714:[0,.69444,.09694,0,.51111],715:[0,.69444,0,0,.51111],728:[0,.69444,.10806,0,.51111],729:[0,.66786,.11752,0,.30667],730:[0,.69444,0,0,.83129],732:[0,.66786,.11585,0,.51111],733:[0,.69444,.1225,0,.51111],915:[0,.68333,.13305,0,.62722],916:[0,.68333,0,0,.81777],920:[0,.68333,.09403,0,.76666],923:[0,.68333,0,0,.69222],926:[0,.68333,.15294,0,.66444],928:[0,.68333,.16389,0,.74333],931:[0,.68333,.12028,0,.71555],933:[0,.68333,.11111,0,.76666],934:[0,.68333,.05986,0,.71555],936:[0,.68333,.11111,0,.76666],937:[0,.68333,.10257,0,.71555],8211:[0,.43056,.09208,0,.51111],8212:[0,.43056,.09208,0,1.02222],8216:[0,.69444,.12417,0,.30667],8217:[0,.69444,.12417,0,.30667],8220:[0,.69444,.1685,0,.51444],8221:[0,.69444,.06961,0,.51444],8463:[0,.68889,0,0,.54028]},"Main-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.27778],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.77778],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.19444,.10556,0,0,.27778],45:[0,.43056,0,0,.33333],46:[0,.10556,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.64444,0,0,.5],49:[0,.64444,0,0,.5],50:[0,.64444,0,0,.5],51:[0,.64444,0,0,.5],52:[0,.64444,0,0,.5],53:[0,.64444,0,0,.5],54:[0,.64444,0,0,.5],55:[0,.64444,0,0,.5],56:[0,.64444,0,0,.5],57:[0,.64444,0,0,.5],58:[0,.43056,0,0,.27778],59:[.19444,.43056,0,0,.27778],60:[.0391,.5391,0,0,.77778],61:[-.13313,.36687,0,0,.77778],62:[.0391,.5391,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.77778],65:[0,.68333,0,0,.75],66:[0,.68333,0,0,.70834],67:[0,.68333,0,0,.72222],68:[0,.68333,0,0,.76389],69:[0,.68333,0,0,.68056],70:[0,.68333,0,0,.65278],71:[0,.68333,0,0,.78472],72:[0,.68333,0,0,.75],73:[0,.68333,0,0,.36111],74:[0,.68333,0,0,.51389],75:[0,.68333,0,0,.77778],76:[0,.68333,0,0,.625],77:[0,.68333,0,0,.91667],78:[0,.68333,0,0,.75],79:[0,.68333,0,0,.77778],80:[0,.68333,0,0,.68056],81:[.19444,.68333,0,0,.77778],82:[0,.68333,0,0,.73611],83:[0,.68333,0,0,.55556],84:[0,.68333,0,0,.72222],85:[0,.68333,0,0,.75],86:[0,.68333,.01389,0,.75],87:[0,.68333,.01389,0,1.02778],88:[0,.68333,0,0,.75],89:[0,.68333,.025,0,.75],90:[0,.68333,0,0,.61111],91:[.25,.75,0,0,.27778],92:[.25,.75,0,0,.5],93:[.25,.75,0,0,.27778],94:[0,.69444,0,0,.5],95:[.31,.12056,.02778,0,.5],97:[0,.43056,0,0,.5],98:[0,.69444,0,0,.55556],99:[0,.43056,0,0,.44445],100:[0,.69444,0,0,.55556],101:[0,.43056,0,0,.44445],102:[0,.69444,.07778,0,.30556],103:[.19444,.43056,.01389,0,.5],104:[0,.69444,0,0,.55556],105:[0,.66786,0,0,.27778],106:[.19444,.66786,0,0,.30556],107:[0,.69444,0,0,.52778],108:[0,.69444,0,0,.27778],109:[0,.43056,0,0,.83334],110:[0,.43056,0,0,.55556],111:[0,.43056,0,0,.5],112:[.19444,.43056,0,0,.55556],113:[.19444,.43056,0,0,.52778],114:[0,.43056,0,0,.39167],115:[0,.43056,0,0,.39445],116:[0,.61508,0,0,.38889],117:[0,.43056,0,0,.55556],118:[0,.43056,.01389,0,.52778],119:[0,.43056,.01389,0,.72222],120:[0,.43056,0,0,.52778],121:[.19444,.43056,.01389,0,.52778],122:[0,.43056,0,0,.44445],123:[.25,.75,0,0,.5],124:[.25,.75,0,0,.27778],125:[.25,.75,0,0,.5],126:[.35,.31786,0,0,.5],160:[0,0,0,0,.25],163:[0,.69444,0,0,.76909],167:[.19444,.69444,0,0,.44445],168:[0,.66786,0,0,.5],172:[0,.43056,0,0,.66667],176:[0,.69444,0,0,.75],177:[.08333,.58333,0,0,.77778],182:[.19444,.69444,0,0,.61111],184:[.17014,0,0,0,.44445],198:[0,.68333,0,0,.90278],215:[.08333,.58333,0,0,.77778],216:[.04861,.73194,0,0,.77778],223:[0,.69444,0,0,.5],230:[0,.43056,0,0,.72222],247:[.08333,.58333,0,0,.77778],248:[.09722,.52778,0,0,.5],305:[0,.43056,0,0,.27778],338:[0,.68333,0,0,1.01389],339:[0,.43056,0,0,.77778],567:[.19444,.43056,0,0,.30556],710:[0,.69444,0,0,.5],711:[0,.62847,0,0,.5],713:[0,.56778,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.66786,0,0,.27778],730:[0,.69444,0,0,.75],732:[0,.66786,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.68333,0,0,.625],916:[0,.68333,0,0,.83334],920:[0,.68333,0,0,.77778],923:[0,.68333,0,0,.69445],926:[0,.68333,0,0,.66667],928:[0,.68333,0,0,.75],931:[0,.68333,0,0,.72222],933:[0,.68333,0,0,.77778],934:[0,.68333,0,0,.72222],936:[0,.68333,0,0,.77778],937:[0,.68333,0,0,.72222],8211:[0,.43056,.02778,0,.5],8212:[0,.43056,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5],8224:[.19444,.69444,0,0,.44445],8225:[.19444,.69444,0,0,.44445],8230:[0,.123,0,0,1.172],8242:[0,.55556,0,0,.275],8407:[0,.71444,.15382,0,.5],8463:[0,.68889,0,0,.54028],8465:[0,.69444,0,0,.72222],8467:[0,.69444,0,.11111,.41667],8472:[.19444,.43056,0,.11111,.63646],8476:[0,.69444,0,0,.72222],8501:[0,.69444,0,0,.61111],8592:[-.13313,.36687,0,0,1],8593:[.19444,.69444,0,0,.5],8594:[-.13313,.36687,0,0,1],8595:[.19444,.69444,0,0,.5],8596:[-.13313,.36687,0,0,1],8597:[.25,.75,0,0,.5],8598:[.19444,.69444,0,0,1],8599:[.19444,.69444,0,0,1],8600:[.19444,.69444,0,0,1],8601:[.19444,.69444,0,0,1],8614:[.011,.511,0,0,1],8617:[.011,.511,0,0,1.126],8618:[.011,.511,0,0,1.126],8636:[-.13313,.36687,0,0,1],8637:[-.13313,.36687,0,0,1],8640:[-.13313,.36687,0,0,1],8641:[-.13313,.36687,0,0,1],8652:[.011,.671,0,0,1],8656:[-.13313,.36687,0,0,1],8657:[.19444,.69444,0,0,.61111],8658:[-.13313,.36687,0,0,1],8659:[.19444,.69444,0,0,.61111],8660:[-.13313,.36687,0,0,1],8661:[.25,.75,0,0,.61111],8704:[0,.69444,0,0,.55556],8706:[0,.69444,.05556,.08334,.5309],8707:[0,.69444,0,0,.55556],8709:[.05556,.75,0,0,.5],8711:[0,.68333,0,0,.83334],8712:[.0391,.5391,0,0,.66667],8715:[.0391,.5391,0,0,.66667],8722:[.08333,.58333,0,0,.77778],8723:[.08333,.58333,0,0,.77778],8725:[.25,.75,0,0,.5],8726:[.25,.75,0,0,.5],8727:[-.03472,.46528,0,0,.5],8728:[-.05555,.44445,0,0,.5],8729:[-.05555,.44445,0,0,.5],8730:[.2,.8,0,0,.83334],8733:[0,.43056,0,0,.77778],8734:[0,.43056,0,0,1],8736:[0,.69224,0,0,.72222],8739:[.25,.75,0,0,.27778],8741:[.25,.75,0,0,.5],8743:[0,.55556,0,0,.66667],8744:[0,.55556,0,0,.66667],8745:[0,.55556,0,0,.66667],8746:[0,.55556,0,0,.66667],8747:[.19444,.69444,.11111,0,.41667],8764:[-.13313,.36687,0,0,.77778],8768:[.19444,.69444,0,0,.27778],8771:[-.03625,.46375,0,0,.77778],8773:[-.022,.589,0,0,.778],8776:[-.01688,.48312,0,0,.77778],8781:[-.03625,.46375,0,0,.77778],8784:[-.133,.673,0,0,.778],8801:[-.03625,.46375,0,0,.77778],8804:[.13597,.63597,0,0,.77778],8805:[.13597,.63597,0,0,.77778],8810:[.0391,.5391,0,0,1],8811:[.0391,.5391,0,0,1],8826:[.0391,.5391,0,0,.77778],8827:[.0391,.5391,0,0,.77778],8834:[.0391,.5391,0,0,.77778],8835:[.0391,.5391,0,0,.77778],8838:[.13597,.63597,0,0,.77778],8839:[.13597,.63597,0,0,.77778],8846:[0,.55556,0,0,.66667],8849:[.13597,.63597,0,0,.77778],8850:[.13597,.63597,0,0,.77778],8851:[0,.55556,0,0,.66667],8852:[0,.55556,0,0,.66667],8853:[.08333,.58333,0,0,.77778],8854:[.08333,.58333,0,0,.77778],8855:[.08333,.58333,0,0,.77778],8856:[.08333,.58333,0,0,.77778],8857:[.08333,.58333,0,0,.77778],8866:[0,.69444,0,0,.61111],8867:[0,.69444,0,0,.61111],8868:[0,.69444,0,0,.77778],8869:[0,.69444,0,0,.77778],8872:[.249,.75,0,0,.867],8900:[-.05555,.44445,0,0,.5],8901:[-.05555,.44445,0,0,.27778],8902:[-.03472,.46528,0,0,.5],8904:[.005,.505,0,0,.9],8942:[.03,.903,0,0,.278],8943:[-.19,.313,0,0,1.172],8945:[-.1,.823,0,0,1.282],8968:[.25,.75,0,0,.44445],8969:[.25,.75,0,0,.44445],8970:[.25,.75,0,0,.44445],8971:[.25,.75,0,0,.44445],8994:[-.14236,.35764,0,0,1],8995:[-.14236,.35764,0,0,1],9136:[.244,.744,0,0,.412],9137:[.244,.745,0,0,.412],9651:[.19444,.69444,0,0,.88889],9657:[-.03472,.46528,0,0,.5],9661:[.19444,.69444,0,0,.88889],9667:[-.03472,.46528,0,0,.5],9711:[.19444,.69444,0,0,1],9824:[.12963,.69444,0,0,.77778],9825:[.12963,.69444,0,0,.77778],9826:[.12963,.69444,0,0,.77778],9827:[.12963,.69444,0,0,.77778],9837:[0,.75,0,0,.38889],9838:[.19444,.69444,0,0,.38889],9839:[.19444,.69444,0,0,.38889],10216:[.25,.75,0,0,.38889],10217:[.25,.75,0,0,.38889],10222:[.244,.744,0,0,.412],10223:[.244,.745,0,0,.412],10229:[.011,.511,0,0,1.609],10230:[.011,.511,0,0,1.638],10231:[.011,.511,0,0,1.859],10232:[.024,.525,0,0,1.609],10233:[.024,.525,0,0,1.638],10234:[.024,.525,0,0,1.858],10236:[.011,.511,0,0,1.638],10815:[0,.68333,0,0,.75],10927:[.13597,.63597,0,0,.77778],10928:[.13597,.63597,0,0,.77778],57376:[.19444,.69444,0,0,0]},"Math-BoldItalic":{32:[0,0,0,0,.25],48:[0,.44444,0,0,.575],49:[0,.44444,0,0,.575],50:[0,.44444,0,0,.575],51:[.19444,.44444,0,0,.575],52:[.19444,.44444,0,0,.575],53:[.19444,.44444,0,0,.575],54:[0,.64444,0,0,.575],55:[.19444,.44444,0,0,.575],56:[0,.64444,0,0,.575],57:[.19444,.44444,0,0,.575],65:[0,.68611,0,0,.86944],66:[0,.68611,.04835,0,.8664],67:[0,.68611,.06979,0,.81694],68:[0,.68611,.03194,0,.93812],69:[0,.68611,.05451,0,.81007],70:[0,.68611,.15972,0,.68889],71:[0,.68611,0,0,.88673],72:[0,.68611,.08229,0,.98229],73:[0,.68611,.07778,0,.51111],74:[0,.68611,.10069,0,.63125],75:[0,.68611,.06979,0,.97118],76:[0,.68611,0,0,.75555],77:[0,.68611,.11424,0,1.14201],78:[0,.68611,.11424,0,.95034],79:[0,.68611,.03194,0,.83666],80:[0,.68611,.15972,0,.72309],81:[.19444,.68611,0,0,.86861],82:[0,.68611,.00421,0,.87235],83:[0,.68611,.05382,0,.69271],84:[0,.68611,.15972,0,.63663],85:[0,.68611,.11424,0,.80027],86:[0,.68611,.25555,0,.67778],87:[0,.68611,.15972,0,1.09305],88:[0,.68611,.07778,0,.94722],89:[0,.68611,.25555,0,.67458],90:[0,.68611,.06979,0,.77257],97:[0,.44444,0,0,.63287],98:[0,.69444,0,0,.52083],99:[0,.44444,0,0,.51342],100:[0,.69444,0,0,.60972],101:[0,.44444,0,0,.55361],102:[.19444,.69444,.11042,0,.56806],103:[.19444,.44444,.03704,0,.5449],104:[0,.69444,0,0,.66759],105:[0,.69326,0,0,.4048],106:[.19444,.69326,.0622,0,.47083],107:[0,.69444,.01852,0,.6037],108:[0,.69444,.0088,0,.34815],109:[0,.44444,0,0,1.0324],110:[0,.44444,0,0,.71296],111:[0,.44444,0,0,.58472],112:[.19444,.44444,0,0,.60092],113:[.19444,.44444,.03704,0,.54213],114:[0,.44444,.03194,0,.5287],115:[0,.44444,0,0,.53125],116:[0,.63492,0,0,.41528],117:[0,.44444,0,0,.68102],118:[0,.44444,.03704,0,.56666],119:[0,.44444,.02778,0,.83148],120:[0,.44444,0,0,.65903],121:[.19444,.44444,.03704,0,.59028],122:[0,.44444,.04213,0,.55509],160:[0,0,0,0,.25],915:[0,.68611,.15972,0,.65694],916:[0,.68611,0,0,.95833],920:[0,.68611,.03194,0,.86722],923:[0,.68611,0,0,.80555],926:[0,.68611,.07458,0,.84125],928:[0,.68611,.08229,0,.98229],931:[0,.68611,.05451,0,.88507],933:[0,.68611,.15972,0,.67083],934:[0,.68611,0,0,.76666],936:[0,.68611,.11653,0,.71402],937:[0,.68611,.04835,0,.8789],945:[0,.44444,0,0,.76064],946:[.19444,.69444,.03403,0,.65972],947:[.19444,.44444,.06389,0,.59003],948:[0,.69444,.03819,0,.52222],949:[0,.44444,0,0,.52882],950:[.19444,.69444,.06215,0,.50833],951:[.19444,.44444,.03704,0,.6],952:[0,.69444,.03194,0,.5618],953:[0,.44444,0,0,.41204],954:[0,.44444,0,0,.66759],955:[0,.69444,0,0,.67083],956:[.19444,.44444,0,0,.70787],957:[0,.44444,.06898,0,.57685],958:[.19444,.69444,.03021,0,.50833],959:[0,.44444,0,0,.58472],960:[0,.44444,.03704,0,.68241],961:[.19444,.44444,0,0,.6118],962:[.09722,.44444,.07917,0,.42361],963:[0,.44444,.03704,0,.68588],964:[0,.44444,.13472,0,.52083],965:[0,.44444,.03704,0,.63055],966:[.19444,.44444,0,0,.74722],967:[.19444,.44444,0,0,.71805],968:[.19444,.69444,.03704,0,.75833],969:[0,.44444,.03704,0,.71782],977:[0,.69444,0,0,.69155],981:[.19444,.69444,0,0,.7125],982:[0,.44444,.03194,0,.975],1009:[.19444,.44444,0,0,.6118],1013:[0,.44444,0,0,.48333],57649:[0,.44444,0,0,.39352],57911:[.19444,.44444,0,0,.43889]},"Math-Italic":{32:[0,0,0,0,.25],48:[0,.43056,0,0,.5],49:[0,.43056,0,0,.5],50:[0,.43056,0,0,.5],51:[.19444,.43056,0,0,.5],52:[.19444,.43056,0,0,.5],53:[.19444,.43056,0,0,.5],54:[0,.64444,0,0,.5],55:[.19444,.43056,0,0,.5],56:[0,.64444,0,0,.5],57:[.19444,.43056,0,0,.5],65:[0,.68333,0,.13889,.75],66:[0,.68333,.05017,.08334,.75851],67:[0,.68333,.07153,.08334,.71472],68:[0,.68333,.02778,.05556,.82792],69:[0,.68333,.05764,.08334,.7382],70:[0,.68333,.13889,.08334,.64306],71:[0,.68333,0,.08334,.78625],72:[0,.68333,.08125,.05556,.83125],73:[0,.68333,.07847,.11111,.43958],74:[0,.68333,.09618,.16667,.55451],75:[0,.68333,.07153,.05556,.84931],76:[0,.68333,0,.02778,.68056],77:[0,.68333,.10903,.08334,.97014],78:[0,.68333,.10903,.08334,.80347],79:[0,.68333,.02778,.08334,.76278],80:[0,.68333,.13889,.08334,.64201],81:[.19444,.68333,0,.08334,.79056],82:[0,.68333,.00773,.08334,.75929],83:[0,.68333,.05764,.08334,.6132],84:[0,.68333,.13889,.08334,.58438],85:[0,.68333,.10903,.02778,.68278],86:[0,.68333,.22222,0,.58333],87:[0,.68333,.13889,0,.94445],88:[0,.68333,.07847,.08334,.82847],89:[0,.68333,.22222,0,.58056],90:[0,.68333,.07153,.08334,.68264],97:[0,.43056,0,0,.52859],98:[0,.69444,0,0,.42917],99:[0,.43056,0,.05556,.43276],100:[0,.69444,0,.16667,.52049],101:[0,.43056,0,.05556,.46563],102:[.19444,.69444,.10764,.16667,.48959],103:[.19444,.43056,.03588,.02778,.47697],104:[0,.69444,0,0,.57616],105:[0,.65952,0,0,.34451],106:[.19444,.65952,.05724,0,.41181],107:[0,.69444,.03148,0,.5206],108:[0,.69444,.01968,.08334,.29838],109:[0,.43056,0,0,.87801],110:[0,.43056,0,0,.60023],111:[0,.43056,0,.05556,.48472],112:[.19444,.43056,0,.08334,.50313],113:[.19444,.43056,.03588,.08334,.44641],114:[0,.43056,.02778,.05556,.45116],115:[0,.43056,0,.05556,.46875],116:[0,.61508,0,.08334,.36111],117:[0,.43056,0,.02778,.57246],118:[0,.43056,.03588,.02778,.48472],119:[0,.43056,.02691,.08334,.71592],120:[0,.43056,0,.02778,.57153],121:[.19444,.43056,.03588,.05556,.49028],122:[0,.43056,.04398,.05556,.46505],160:[0,0,0,0,.25],915:[0,.68333,.13889,.08334,.61528],916:[0,.68333,0,.16667,.83334],920:[0,.68333,.02778,.08334,.76278],923:[0,.68333,0,.16667,.69445],926:[0,.68333,.07569,.08334,.74236],928:[0,.68333,.08125,.05556,.83125],931:[0,.68333,.05764,.08334,.77986],933:[0,.68333,.13889,.05556,.58333],934:[0,.68333,0,.08334,.66667],936:[0,.68333,.11,.05556,.61222],937:[0,.68333,.05017,.08334,.7724],945:[0,.43056,.0037,.02778,.6397],946:[.19444,.69444,.05278,.08334,.56563],947:[.19444,.43056,.05556,0,.51773],948:[0,.69444,.03785,.05556,.44444],949:[0,.43056,0,.08334,.46632],950:[.19444,.69444,.07378,.08334,.4375],951:[.19444,.43056,.03588,.05556,.49653],952:[0,.69444,.02778,.08334,.46944],953:[0,.43056,0,.05556,.35394],954:[0,.43056,0,0,.57616],955:[0,.69444,0,0,.58334],956:[.19444,.43056,0,.02778,.60255],957:[0,.43056,.06366,.02778,.49398],958:[.19444,.69444,.04601,.11111,.4375],959:[0,.43056,0,.05556,.48472],960:[0,.43056,.03588,0,.57003],961:[.19444,.43056,0,.08334,.51702],962:[.09722,.43056,.07986,.08334,.36285],963:[0,.43056,.03588,0,.57141],964:[0,.43056,.1132,.02778,.43715],965:[0,.43056,.03588,.02778,.54028],966:[.19444,.43056,0,.08334,.65417],967:[.19444,.43056,0,.05556,.62569],968:[.19444,.69444,.03588,.11111,.65139],969:[0,.43056,.03588,0,.62245],977:[0,.69444,0,.08334,.59144],981:[.19444,.69444,0,.08334,.59583],982:[0,.43056,.02778,0,.82813],1009:[.19444,.43056,0,.08334,.51702],1013:[0,.43056,0,.05556,.4059],57649:[0,.43056,0,.02778,.32246],57911:[.19444,.43056,0,.08334,.38403]},"SansSerif-Bold":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.36667],34:[0,.69444,0,0,.55834],35:[.19444,.69444,0,0,.91667],36:[.05556,.75,0,0,.55],37:[.05556,.75,0,0,1.02912],38:[0,.69444,0,0,.83056],39:[0,.69444,0,0,.30556],40:[.25,.75,0,0,.42778],41:[.25,.75,0,0,.42778],42:[0,.75,0,0,.55],43:[.11667,.61667,0,0,.85556],44:[.10556,.13056,0,0,.30556],45:[0,.45833,0,0,.36667],46:[0,.13056,0,0,.30556],47:[.25,.75,0,0,.55],48:[0,.69444,0,0,.55],49:[0,.69444,0,0,.55],50:[0,.69444,0,0,.55],51:[0,.69444,0,0,.55],52:[0,.69444,0,0,.55],53:[0,.69444,0,0,.55],54:[0,.69444,0,0,.55],55:[0,.69444,0,0,.55],56:[0,.69444,0,0,.55],57:[0,.69444,0,0,.55],58:[0,.45833,0,0,.30556],59:[.10556,.45833,0,0,.30556],61:[-.09375,.40625,0,0,.85556],63:[0,.69444,0,0,.51945],64:[0,.69444,0,0,.73334],65:[0,.69444,0,0,.73334],66:[0,.69444,0,0,.73334],67:[0,.69444,0,0,.70278],68:[0,.69444,0,0,.79445],69:[0,.69444,0,0,.64167],70:[0,.69444,0,0,.61111],71:[0,.69444,0,0,.73334],72:[0,.69444,0,0,.79445],73:[0,.69444,0,0,.33056],74:[0,.69444,0,0,.51945],75:[0,.69444,0,0,.76389],76:[0,.69444,0,0,.58056],77:[0,.69444,0,0,.97778],78:[0,.69444,0,0,.79445],79:[0,.69444,0,0,.79445],80:[0,.69444,0,0,.70278],81:[.10556,.69444,0,0,.79445],82:[0,.69444,0,0,.70278],83:[0,.69444,0,0,.61111],84:[0,.69444,0,0,.73334],85:[0,.69444,0,0,.76389],86:[0,.69444,.01528,0,.73334],87:[0,.69444,.01528,0,1.03889],88:[0,.69444,0,0,.73334],89:[0,.69444,.0275,0,.73334],90:[0,.69444,0,0,.67223],91:[.25,.75,0,0,.34306],93:[.25,.75,0,0,.34306],94:[0,.69444,0,0,.55],95:[.35,.10833,.03056,0,.55],97:[0,.45833,0,0,.525],98:[0,.69444,0,0,.56111],99:[0,.45833,0,0,.48889],100:[0,.69444,0,0,.56111],101:[0,.45833,0,0,.51111],102:[0,.69444,.07639,0,.33611],103:[.19444,.45833,.01528,0,.55],104:[0,.69444,0,0,.56111],105:[0,.69444,0,0,.25556],106:[.19444,.69444,0,0,.28611],107:[0,.69444,0,0,.53056],108:[0,.69444,0,0,.25556],109:[0,.45833,0,0,.86667],110:[0,.45833,0,0,.56111],111:[0,.45833,0,0,.55],112:[.19444,.45833,0,0,.56111],113:[.19444,.45833,0,0,.56111],114:[0,.45833,.01528,0,.37222],115:[0,.45833,0,0,.42167],116:[0,.58929,0,0,.40417],117:[0,.45833,0,0,.56111],118:[0,.45833,.01528,0,.5],119:[0,.45833,.01528,0,.74445],120:[0,.45833,0,0,.5],121:[.19444,.45833,.01528,0,.5],122:[0,.45833,0,0,.47639],126:[.35,.34444,0,0,.55],160:[0,0,0,0,.25],168:[0,.69444,0,0,.55],176:[0,.69444,0,0,.73334],180:[0,.69444,0,0,.55],184:[.17014,0,0,0,.48889],305:[0,.45833,0,0,.25556],567:[.19444,.45833,0,0,.28611],710:[0,.69444,0,0,.55],711:[0,.63542,0,0,.55],713:[0,.63778,0,0,.55],728:[0,.69444,0,0,.55],729:[0,.69444,0,0,.30556],730:[0,.69444,0,0,.73334],732:[0,.69444,0,0,.55],733:[0,.69444,0,0,.55],915:[0,.69444,0,0,.58056],916:[0,.69444,0,0,.91667],920:[0,.69444,0,0,.85556],923:[0,.69444,0,0,.67223],926:[0,.69444,0,0,.73334],928:[0,.69444,0,0,.79445],931:[0,.69444,0,0,.79445],933:[0,.69444,0,0,.85556],934:[0,.69444,0,0,.79445],936:[0,.69444,0,0,.85556],937:[0,.69444,0,0,.79445],8211:[0,.45833,.03056,0,.55],8212:[0,.45833,.03056,0,1.10001],8216:[0,.69444,0,0,.30556],8217:[0,.69444,0,0,.30556],8220:[0,.69444,0,0,.55834],8221:[0,.69444,0,0,.55834]},"SansSerif-Italic":{32:[0,0,0,0,.25],33:[0,.69444,.05733,0,.31945],34:[0,.69444,.00316,0,.5],35:[.19444,.69444,.05087,0,.83334],36:[.05556,.75,.11156,0,.5],37:[.05556,.75,.03126,0,.83334],38:[0,.69444,.03058,0,.75834],39:[0,.69444,.07816,0,.27778],40:[.25,.75,.13164,0,.38889],41:[.25,.75,.02536,0,.38889],42:[0,.75,.11775,0,.5],43:[.08333,.58333,.02536,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,.01946,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,.13164,0,.5],48:[0,.65556,.11156,0,.5],49:[0,.65556,.11156,0,.5],50:[0,.65556,.11156,0,.5],51:[0,.65556,.11156,0,.5],52:[0,.65556,.11156,0,.5],53:[0,.65556,.11156,0,.5],54:[0,.65556,.11156,0,.5],55:[0,.65556,.11156,0,.5],56:[0,.65556,.11156,0,.5],57:[0,.65556,.11156,0,.5],58:[0,.44444,.02502,0,.27778],59:[.125,.44444,.02502,0,.27778],61:[-.13,.37,.05087,0,.77778],63:[0,.69444,.11809,0,.47222],64:[0,.69444,.07555,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,.08293,0,.66667],67:[0,.69444,.11983,0,.63889],68:[0,.69444,.07555,0,.72223],69:[0,.69444,.11983,0,.59722],70:[0,.69444,.13372,0,.56945],71:[0,.69444,.11983,0,.66667],72:[0,.69444,.08094,0,.70834],73:[0,.69444,.13372,0,.27778],74:[0,.69444,.08094,0,.47222],75:[0,.69444,.11983,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,.08094,0,.875],78:[0,.69444,.08094,0,.70834],79:[0,.69444,.07555,0,.73611],80:[0,.69444,.08293,0,.63889],81:[.125,.69444,.07555,0,.73611],82:[0,.69444,.08293,0,.64584],83:[0,.69444,.09205,0,.55556],84:[0,.69444,.13372,0,.68056],85:[0,.69444,.08094,0,.6875],86:[0,.69444,.1615,0,.66667],87:[0,.69444,.1615,0,.94445],88:[0,.69444,.13372,0,.66667],89:[0,.69444,.17261,0,.66667],90:[0,.69444,.11983,0,.61111],91:[.25,.75,.15942,0,.28889],93:[.25,.75,.08719,0,.28889],94:[0,.69444,.0799,0,.5],95:[.35,.09444,.08616,0,.5],97:[0,.44444,.00981,0,.48056],98:[0,.69444,.03057,0,.51667],99:[0,.44444,.08336,0,.44445],100:[0,.69444,.09483,0,.51667],101:[0,.44444,.06778,0,.44445],102:[0,.69444,.21705,0,.30556],103:[.19444,.44444,.10836,0,.5],104:[0,.69444,.01778,0,.51667],105:[0,.67937,.09718,0,.23889],106:[.19444,.67937,.09162,0,.26667],107:[0,.69444,.08336,0,.48889],108:[0,.69444,.09483,0,.23889],109:[0,.44444,.01778,0,.79445],110:[0,.44444,.01778,0,.51667],111:[0,.44444,.06613,0,.5],112:[.19444,.44444,.0389,0,.51667],113:[.19444,.44444,.04169,0,.51667],114:[0,.44444,.10836,0,.34167],115:[0,.44444,.0778,0,.38333],116:[0,.57143,.07225,0,.36111],117:[0,.44444,.04169,0,.51667],118:[0,.44444,.10836,0,.46111],119:[0,.44444,.10836,0,.68334],120:[0,.44444,.09169,0,.46111],121:[.19444,.44444,.10836,0,.46111],122:[0,.44444,.08752,0,.43472],126:[.35,.32659,.08826,0,.5],160:[0,0,0,0,.25],168:[0,.67937,.06385,0,.5],176:[0,.69444,0,0,.73752],184:[.17014,0,0,0,.44445],305:[0,.44444,.04169,0,.23889],567:[.19444,.44444,.04169,0,.26667],710:[0,.69444,.0799,0,.5],711:[0,.63194,.08432,0,.5],713:[0,.60889,.08776,0,.5],714:[0,.69444,.09205,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,.09483,0,.5],729:[0,.67937,.07774,0,.27778],730:[0,.69444,0,0,.73752],732:[0,.67659,.08826,0,.5],733:[0,.69444,.09205,0,.5],915:[0,.69444,.13372,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,.07555,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,.12816,0,.66667],928:[0,.69444,.08094,0,.70834],931:[0,.69444,.11983,0,.72222],933:[0,.69444,.09031,0,.77778],934:[0,.69444,.04603,0,.72222],936:[0,.69444,.09031,0,.77778],937:[0,.69444,.08293,0,.72222],8211:[0,.44444,.08616,0,.5],8212:[0,.44444,.08616,0,1],8216:[0,.69444,.07816,0,.27778],8217:[0,.69444,.07816,0,.27778],8220:[0,.69444,.14205,0,.5],8221:[0,.69444,.00316,0,.5]},"SansSerif-Regular":{32:[0,0,0,0,.25],33:[0,.69444,0,0,.31945],34:[0,.69444,0,0,.5],35:[.19444,.69444,0,0,.83334],36:[.05556,.75,0,0,.5],37:[.05556,.75,0,0,.83334],38:[0,.69444,0,0,.75834],39:[0,.69444,0,0,.27778],40:[.25,.75,0,0,.38889],41:[.25,.75,0,0,.38889],42:[0,.75,0,0,.5],43:[.08333,.58333,0,0,.77778],44:[.125,.08333,0,0,.27778],45:[0,.44444,0,0,.33333],46:[0,.08333,0,0,.27778],47:[.25,.75,0,0,.5],48:[0,.65556,0,0,.5],49:[0,.65556,0,0,.5],50:[0,.65556,0,0,.5],51:[0,.65556,0,0,.5],52:[0,.65556,0,0,.5],53:[0,.65556,0,0,.5],54:[0,.65556,0,0,.5],55:[0,.65556,0,0,.5],56:[0,.65556,0,0,.5],57:[0,.65556,0,0,.5],58:[0,.44444,0,0,.27778],59:[.125,.44444,0,0,.27778],61:[-.13,.37,0,0,.77778],63:[0,.69444,0,0,.47222],64:[0,.69444,0,0,.66667],65:[0,.69444,0,0,.66667],66:[0,.69444,0,0,.66667],67:[0,.69444,0,0,.63889],68:[0,.69444,0,0,.72223],69:[0,.69444,0,0,.59722],70:[0,.69444,0,0,.56945],71:[0,.69444,0,0,.66667],72:[0,.69444,0,0,.70834],73:[0,.69444,0,0,.27778],74:[0,.69444,0,0,.47222],75:[0,.69444,0,0,.69445],76:[0,.69444,0,0,.54167],77:[0,.69444,0,0,.875],78:[0,.69444,0,0,.70834],79:[0,.69444,0,0,.73611],80:[0,.69444,0,0,.63889],81:[.125,.69444,0,0,.73611],82:[0,.69444,0,0,.64584],83:[0,.69444,0,0,.55556],84:[0,.69444,0,0,.68056],85:[0,.69444,0,0,.6875],86:[0,.69444,.01389,0,.66667],87:[0,.69444,.01389,0,.94445],88:[0,.69444,0,0,.66667],89:[0,.69444,.025,0,.66667],90:[0,.69444,0,0,.61111],91:[.25,.75,0,0,.28889],93:[.25,.75,0,0,.28889],94:[0,.69444,0,0,.5],95:[.35,.09444,.02778,0,.5],97:[0,.44444,0,0,.48056],98:[0,.69444,0,0,.51667],99:[0,.44444,0,0,.44445],100:[0,.69444,0,0,.51667],101:[0,.44444,0,0,.44445],102:[0,.69444,.06944,0,.30556],103:[.19444,.44444,.01389,0,.5],104:[0,.69444,0,0,.51667],105:[0,.67937,0,0,.23889],106:[.19444,.67937,0,0,.26667],107:[0,.69444,0,0,.48889],108:[0,.69444,0,0,.23889],109:[0,.44444,0,0,.79445],110:[0,.44444,0,0,.51667],111:[0,.44444,0,0,.5],112:[.19444,.44444,0,0,.51667],113:[.19444,.44444,0,0,.51667],114:[0,.44444,.01389,0,.34167],115:[0,.44444,0,0,.38333],116:[0,.57143,0,0,.36111],117:[0,.44444,0,0,.51667],118:[0,.44444,.01389,0,.46111],119:[0,.44444,.01389,0,.68334],120:[0,.44444,0,0,.46111],121:[.19444,.44444,.01389,0,.46111],122:[0,.44444,0,0,.43472],126:[.35,.32659,0,0,.5],160:[0,0,0,0,.25],168:[0,.67937,0,0,.5],176:[0,.69444,0,0,.66667],184:[.17014,0,0,0,.44445],305:[0,.44444,0,0,.23889],567:[.19444,.44444,0,0,.26667],710:[0,.69444,0,0,.5],711:[0,.63194,0,0,.5],713:[0,.60889,0,0,.5],714:[0,.69444,0,0,.5],715:[0,.69444,0,0,.5],728:[0,.69444,0,0,.5],729:[0,.67937,0,0,.27778],730:[0,.69444,0,0,.66667],732:[0,.67659,0,0,.5],733:[0,.69444,0,0,.5],915:[0,.69444,0,0,.54167],916:[0,.69444,0,0,.83334],920:[0,.69444,0,0,.77778],923:[0,.69444,0,0,.61111],926:[0,.69444,0,0,.66667],928:[0,.69444,0,0,.70834],931:[0,.69444,0,0,.72222],933:[0,.69444,0,0,.77778],934:[0,.69444,0,0,.72222],936:[0,.69444,0,0,.77778],937:[0,.69444,0,0,.72222],8211:[0,.44444,.02778,0,.5],8212:[0,.44444,.02778,0,1],8216:[0,.69444,0,0,.27778],8217:[0,.69444,0,0,.27778],8220:[0,.69444,0,0,.5],8221:[0,.69444,0,0,.5]},"Script-Regular":{32:[0,0,0,0,.25],65:[0,.7,.22925,0,.80253],66:[0,.7,.04087,0,.90757],67:[0,.7,.1689,0,.66619],68:[0,.7,.09371,0,.77443],69:[0,.7,.18583,0,.56162],70:[0,.7,.13634,0,.89544],71:[0,.7,.17322,0,.60961],72:[0,.7,.29694,0,.96919],73:[0,.7,.19189,0,.80907],74:[.27778,.7,.19189,0,1.05159],75:[0,.7,.31259,0,.91364],76:[0,.7,.19189,0,.87373],77:[0,.7,.15981,0,1.08031],78:[0,.7,.3525,0,.9015],79:[0,.7,.08078,0,.73787],80:[0,.7,.08078,0,1.01262],81:[0,.7,.03305,0,.88282],82:[0,.7,.06259,0,.85],83:[0,.7,.19189,0,.86767],84:[0,.7,.29087,0,.74697],85:[0,.7,.25815,0,.79996],86:[0,.7,.27523,0,.62204],87:[0,.7,.27523,0,.80532],88:[0,.7,.26006,0,.94445],89:[0,.7,.2939,0,.70961],90:[0,.7,.24037,0,.8212],160:[0,0,0,0,.25]},"Size1-Regular":{32:[0,0,0,0,.25],40:[.35001,.85,0,0,.45834],41:[.35001,.85,0,0,.45834],47:[.35001,.85,0,0,.57778],91:[.35001,.85,0,0,.41667],92:[.35001,.85,0,0,.57778],93:[.35001,.85,0,0,.41667],123:[.35001,.85,0,0,.58334],125:[.35001,.85,0,0,.58334],160:[0,0,0,0,.25],710:[0,.72222,0,0,.55556],732:[0,.72222,0,0,.55556],770:[0,.72222,0,0,.55556],771:[0,.72222,0,0,.55556],8214:[-99e-5,.601,0,0,.77778],8593:[1e-5,.6,0,0,.66667],8595:[1e-5,.6,0,0,.66667],8657:[1e-5,.6,0,0,.77778],8659:[1e-5,.6,0,0,.77778],8719:[.25001,.75,0,0,.94445],8720:[.25001,.75,0,0,.94445],8721:[.25001,.75,0,0,1.05556],8730:[.35001,.85,0,0,1],8739:[-.00599,.606,0,0,.33333],8741:[-.00599,.606,0,0,.55556],8747:[.30612,.805,.19445,0,.47222],8748:[.306,.805,.19445,0,.47222],8749:[.306,.805,.19445,0,.47222],8750:[.30612,.805,.19445,0,.47222],8896:[.25001,.75,0,0,.83334],8897:[.25001,.75,0,0,.83334],8898:[.25001,.75,0,0,.83334],8899:[.25001,.75,0,0,.83334],8968:[.35001,.85,0,0,.47222],8969:[.35001,.85,0,0,.47222],8970:[.35001,.85,0,0,.47222],8971:[.35001,.85,0,0,.47222],9168:[-99e-5,.601,0,0,.66667],10216:[.35001,.85,0,0,.47222],10217:[.35001,.85,0,0,.47222],10752:[.25001,.75,0,0,1.11111],10753:[.25001,.75,0,0,1.11111],10754:[.25001,.75,0,0,1.11111],10756:[.25001,.75,0,0,.83334],10758:[.25001,.75,0,0,.83334]},"Size2-Regular":{32:[0,0,0,0,.25],40:[.65002,1.15,0,0,.59722],41:[.65002,1.15,0,0,.59722],47:[.65002,1.15,0,0,.81111],91:[.65002,1.15,0,0,.47222],92:[.65002,1.15,0,0,.81111],93:[.65002,1.15,0,0,.47222],123:[.65002,1.15,0,0,.66667],125:[.65002,1.15,0,0,.66667],160:[0,0,0,0,.25],710:[0,.75,0,0,1],732:[0,.75,0,0,1],770:[0,.75,0,0,1],771:[0,.75,0,0,1],8719:[.55001,1.05,0,0,1.27778],8720:[.55001,1.05,0,0,1.27778],8721:[.55001,1.05,0,0,1.44445],8730:[.65002,1.15,0,0,1],8747:[.86225,1.36,.44445,0,.55556],8748:[.862,1.36,.44445,0,.55556],8749:[.862,1.36,.44445,0,.55556],8750:[.86225,1.36,.44445,0,.55556],8896:[.55001,1.05,0,0,1.11111],8897:[.55001,1.05,0,0,1.11111],8898:[.55001,1.05,0,0,1.11111],8899:[.55001,1.05,0,0,1.11111],8968:[.65002,1.15,0,0,.52778],8969:[.65002,1.15,0,0,.52778],8970:[.65002,1.15,0,0,.52778],8971:[.65002,1.15,0,0,.52778],10216:[.65002,1.15,0,0,.61111],10217:[.65002,1.15,0,0,.61111],10752:[.55001,1.05,0,0,1.51112],10753:[.55001,1.05,0,0,1.51112],10754:[.55001,1.05,0,0,1.51112],10756:[.55001,1.05,0,0,1.11111],10758:[.55001,1.05,0,0,1.11111]},"Size3-Regular":{32:[0,0,0,0,.25],40:[.95003,1.45,0,0,.73611],41:[.95003,1.45,0,0,.73611],47:[.95003,1.45,0,0,1.04445],91:[.95003,1.45,0,0,.52778],92:[.95003,1.45,0,0,1.04445],93:[.95003,1.45,0,0,.52778],123:[.95003,1.45,0,0,.75],125:[.95003,1.45,0,0,.75],160:[0,0,0,0,.25],710:[0,.75,0,0,1.44445],732:[0,.75,0,0,1.44445],770:[0,.75,0,0,1.44445],771:[0,.75,0,0,1.44445],8730:[.95003,1.45,0,0,1],8968:[.95003,1.45,0,0,.58334],8969:[.95003,1.45,0,0,.58334],8970:[.95003,1.45,0,0,.58334],8971:[.95003,1.45,0,0,.58334],10216:[.95003,1.45,0,0,.75],10217:[.95003,1.45,0,0,.75]},"Size4-Regular":{32:[0,0,0,0,.25],40:[1.25003,1.75,0,0,.79167],41:[1.25003,1.75,0,0,.79167],47:[1.25003,1.75,0,0,1.27778],91:[1.25003,1.75,0,0,.58334],92:[1.25003,1.75,0,0,1.27778],93:[1.25003,1.75,0,0,.58334],123:[1.25003,1.75,0,0,.80556],125:[1.25003,1.75,0,0,.80556],160:[0,0,0,0,.25],710:[0,.825,0,0,1.8889],732:[0,.825,0,0,1.8889],770:[0,.825,0,0,1.8889],771:[0,.825,0,0,1.8889],8730:[1.25003,1.75,0,0,1],8968:[1.25003,1.75,0,0,.63889],8969:[1.25003,1.75,0,0,.63889],8970:[1.25003,1.75,0,0,.63889],8971:[1.25003,1.75,0,0,.63889],9115:[.64502,1.155,0,0,.875],9116:[1e-5,.6,0,0,.875],9117:[.64502,1.155,0,0,.875],9118:[.64502,1.155,0,0,.875],9119:[1e-5,.6,0,0,.875],9120:[.64502,1.155,0,0,.875],9121:[.64502,1.155,0,0,.66667],9122:[-99e-5,.601,0,0,.66667],9123:[.64502,1.155,0,0,.66667],9124:[.64502,1.155,0,0,.66667],9125:[-99e-5,.601,0,0,.66667],9126:[.64502,1.155,0,0,.66667],9127:[1e-5,.9,0,0,.88889],9128:[.65002,1.15,0,0,.88889],9129:[.90001,0,0,0,.88889],9130:[0,.3,0,0,.88889],9131:[1e-5,.9,0,0,.88889],9132:[.65002,1.15,0,0,.88889],9133:[.90001,0,0,0,.88889],9143:[.88502,.915,0,0,1.05556],10216:[1.25003,1.75,0,0,.80556],10217:[1.25003,1.75,0,0,.80556],57344:[-.00499,.605,0,0,1.05556],57345:[-.00499,.605,0,0,1.05556],57680:[0,.12,0,0,.45],57681:[0,.12,0,0,.45],57682:[0,.12,0,0,.45],57683:[0,.12,0,0,.45]},"Typewriter-Regular":{32:[0,0,0,0,.525],33:[0,.61111,0,0,.525],34:[0,.61111,0,0,.525],35:[0,.61111,0,0,.525],36:[.08333,.69444,0,0,.525],37:[.08333,.69444,0,0,.525],38:[0,.61111,0,0,.525],39:[0,.61111,0,0,.525],40:[.08333,.69444,0,0,.525],41:[.08333,.69444,0,0,.525],42:[0,.52083,0,0,.525],43:[-.08056,.53055,0,0,.525],44:[.13889,.125,0,0,.525],45:[-.08056,.53055,0,0,.525],46:[0,.125,0,0,.525],47:[.08333,.69444,0,0,.525],48:[0,.61111,0,0,.525],49:[0,.61111,0,0,.525],50:[0,.61111,0,0,.525],51:[0,.61111,0,0,.525],52:[0,.61111,0,0,.525],53:[0,.61111,0,0,.525],54:[0,.61111,0,0,.525],55:[0,.61111,0,0,.525],56:[0,.61111,0,0,.525],57:[0,.61111,0,0,.525],58:[0,.43056,0,0,.525],59:[.13889,.43056,0,0,.525],60:[-.05556,.55556,0,0,.525],61:[-.19549,.41562,0,0,.525],62:[-.05556,.55556,0,0,.525],63:[0,.61111,0,0,.525],64:[0,.61111,0,0,.525],65:[0,.61111,0,0,.525],66:[0,.61111,0,0,.525],67:[0,.61111,0,0,.525],68:[0,.61111,0,0,.525],69:[0,.61111,0,0,.525],70:[0,.61111,0,0,.525],71:[0,.61111,0,0,.525],72:[0,.61111,0,0,.525],73:[0,.61111,0,0,.525],74:[0,.61111,0,0,.525],75:[0,.61111,0,0,.525],76:[0,.61111,0,0,.525],77:[0,.61111,0,0,.525],78:[0,.61111,0,0,.525],79:[0,.61111,0,0,.525],80:[0,.61111,0,0,.525],81:[.13889,.61111,0,0,.525],82:[0,.61111,0,0,.525],83:[0,.61111,0,0,.525],84:[0,.61111,0,0,.525],85:[0,.61111,0,0,.525],86:[0,.61111,0,0,.525],87:[0,.61111,0,0,.525],88:[0,.61111,0,0,.525],89:[0,.61111,0,0,.525],90:[0,.61111,0,0,.525],91:[.08333,.69444,0,0,.525],92:[.08333,.69444,0,0,.525],93:[.08333,.69444,0,0,.525],94:[0,.61111,0,0,.525],95:[.09514,0,0,0,.525],96:[0,.61111,0,0,.525],97:[0,.43056,0,0,.525],98:[0,.61111,0,0,.525],99:[0,.43056,0,0,.525],100:[0,.61111,0,0,.525],101:[0,.43056,0,0,.525],102:[0,.61111,0,0,.525],103:[.22222,.43056,0,0,.525],104:[0,.61111,0,0,.525],105:[0,.61111,0,0,.525],106:[.22222,.61111,0,0,.525],107:[0,.61111,0,0,.525],108:[0,.61111,0,0,.525],109:[0,.43056,0,0,.525],110:[0,.43056,0,0,.525],111:[0,.43056,0,0,.525],112:[.22222,.43056,0,0,.525],113:[.22222,.43056,0,0,.525],114:[0,.43056,0,0,.525],115:[0,.43056,0,0,.525],116:[0,.55358,0,0,.525],117:[0,.43056,0,0,.525],118:[0,.43056,0,0,.525],119:[0,.43056,0,0,.525],120:[0,.43056,0,0,.525],121:[.22222,.43056,0,0,.525],122:[0,.43056,0,0,.525],123:[.08333,.69444,0,0,.525],124:[.08333,.69444,0,0,.525],125:[.08333,.69444,0,0,.525],126:[0,.61111,0,0,.525],127:[0,.61111,0,0,.525],160:[0,0,0,0,.525],176:[0,.61111,0,0,.525],184:[.19445,0,0,0,.525],305:[0,.43056,0,0,.525],567:[.22222,.43056,0,0,.525],711:[0,.56597,0,0,.525],713:[0,.56555,0,0,.525],714:[0,.61111,0,0,.525],715:[0,.61111,0,0,.525],728:[0,.61111,0,0,.525],730:[0,.61111,0,0,.525],770:[0,.61111,0,0,.525],771:[0,.61111,0,0,.525],776:[0,.61111,0,0,.525],915:[0,.61111,0,0,.525],916:[0,.61111,0,0,.525],920:[0,.61111,0,0,.525],923:[0,.61111,0,0,.525],926:[0,.61111,0,0,.525],928:[0,.61111,0,0,.525],931:[0,.61111,0,0,.525],933:[0,.61111,0,0,.525],934:[0,.61111,0,0,.525],936:[0,.61111,0,0,.525],937:[0,.61111,0,0,.525],8216:[0,.61111,0,0,.525],8217:[0,.61111,0,0,.525],8242:[0,.61111,0,0,.525],9251:[.11111,.21944,0,0,.525]}},Z4={slant:[.25,.25,.25],space:[0,0,0],stretch:[0,0,0],shrink:[0,0,0],xHeight:[.431,.431,.431],quad:[1,1.171,1.472],extraSpace:[0,0,0],num1:[.677,.732,.925],num2:[.394,.384,.387],num3:[.444,.471,.504],denom1:[.686,.752,1.025],denom2:[.345,.344,.532],sup1:[.413,.503,.504],sup2:[.363,.431,.404],sup3:[.289,.286,.294],sub1:[.15,.143,.2],sub2:[.247,.286,.4],supDrop:[.386,.353,.494],subDrop:[.05,.071,.1],delim1:[2.39,1.7,1.98],delim2:[1.01,1.157,1.42],axisHeight:[.25,.25,.25],defaultRuleThickness:[.04,.049,.049],bigOpSpacing1:[.111,.111,.111],bigOpSpacing2:[.166,.166,.166],bigOpSpacing3:[.2,.2,.2],bigOpSpacing4:[.6,.611,.611],bigOpSpacing5:[.1,.143,.143],sqrtRuleThickness:[.04,.04,.04],ptPerEm:[10,10,10],doubleRuleSep:[.2,.2,.2],arrayRuleWidth:[.04,.04,.04],fboxsep:[.3,.3,.3],fboxrule:[.04,.04,.04]},lz={\u00C5:"A",\u00D0:"D",\u00DE:"o",\u00E5:"a",\u00F0:"d",\u00FE:"o",\u0410:"A",\u0411:"B",\u0412:"B",\u0413:"F",\u0414:"A",\u0415:"E",\u0416:"K",\u0417:"3",\u0418:"N",\u0419:"N",\u041A:"K",\u041B:"N",\u041C:"M",\u041D:"H",\u041E:"O",\u041F:"N",\u0420:"P",\u0421:"C",\u0422:"T",\u0423:"y",\u0424:"O",\u0425:"X",\u0426:"U",\u0427:"h",\u0428:"W",\u0429:"W",\u042A:"B",\u042B:"X",\u042C:"B",\u042D:"3",\u042E:"X",\u042F:"R",\u0430:"a",\u0431:"b",\u0432:"a",\u0433:"r",\u0434:"y",\u0435:"e",\u0436:"m",\u0437:"e",\u0438:"n",\u0439:"n",\u043A:"n",\u043B:"n",\u043C:"m",\u043D:"n",\u043E:"o",\u043F:"n",\u0440:"p",\u0441:"c",\u0442:"o",\u0443:"y",\u0444:"b",\u0445:"x",\u0446:"n",\u0447:"n",\u0448:"w",\u0449:"w",\u044A:"a",\u044B:"m",\u044C:"a",\u044D:"e",\u044E:"m",\u044F:"r"};o(Abe,"setFontMetrics");o(P7,"getCharacterMetrics");h7={};o(_be,"getGlobalMetrics");Dbe=[[1,1,1],[2,1,1],[3,1,1],[4,2,1],[5,2,1],[6,3,1],[7,4,2],[8,6,3],[9,7,6],[10,8,7],[11,10,9]],cz=[.5,.6,.7,.8,.9,1,1.2,1.44,1.728,2.074,2.488],uz=o(function(e,r){return r.size<2?e:Dbe[e-1][r.size-1]},"sizeAtStyle"),f3=class t{static{o(this,"Options")}constructor(e){this.style=void 0,this.color=void 0,this.size=void 0,this.textSize=void 0,this.phantom=void 0,this.font=void 0,this.fontFamily=void 0,this.fontWeight=void 0,this.fontShape=void 0,this.sizeMultiplier=void 0,this.maxSize=void 0,this.minRuleThickness=void 0,this._fontMetrics=void 0,this.style=e.style,this.color=e.color,this.size=e.size||t.BASESIZE,this.textSize=e.textSize||this.size,this.phantom=!!e.phantom,this.font=e.font||"",this.fontFamily=e.fontFamily||"",this.fontWeight=e.fontWeight||"",this.fontShape=e.fontShape||"",this.sizeMultiplier=cz[this.size-1],this.maxSize=e.maxSize,this.minRuleThickness=e.minRuleThickness,this._fontMetrics=void 0}extend(e){var r={style:this.style,size:this.size,textSize:this.textSize,color:this.color,phantom:this.phantom,font:this.font,fontFamily:this.fontFamily,fontWeight:this.fontWeight,fontShape:this.fontShape,maxSize:this.maxSize,minRuleThickness:this.minRuleThickness};for(var n in e)e.hasOwnProperty(n)&&(r[n]=e[n]);return new t(r)}havingStyle(e){return this.style===e?this:this.extend({style:e,size:uz(this.textSize,e)})}havingCrampedStyle(){return this.havingStyle(this.style.cramp())}havingSize(e){return this.size===e&&this.textSize===e?this:this.extend({style:this.style.text(),size:e,textSize:e,sizeMultiplier:cz[e-1]})}havingBaseStyle(e){e=e||this.style.text();var r=uz(t.BASESIZE,e);return this.size===r&&this.textSize===t.BASESIZE&&this.style===e?this:this.extend({style:e,size:r})}havingBaseSizing(){var e;switch(this.style.id){case 4:case 5:e=3;break;case 6:case 7:e=1;break;default:e=6}return this.extend({style:this.style.text(),size:e})}withColor(e){return this.extend({color:e})}withPhantom(){return this.extend({phantom:!0})}withFont(e){return this.extend({font:e})}withTextFontFamily(e){return this.extend({fontFamily:e,font:""})}withTextFontWeight(e){return this.extend({fontWeight:e,font:""})}withTextFontShape(e){return this.extend({fontShape:e,font:""})}sizingClasses(e){return e.size!==this.size?["sizing","reset-size"+e.size,"size"+this.size]:[]}baseSizingClasses(){return this.size!==t.BASESIZE?["sizing","reset-size"+this.size,"size"+t.BASESIZE]:[]}fontMetrics(){return this._fontMetrics||(this._fontMetrics=_be(this.size)),this._fontMetrics}getColor(){return this.phantom?"transparent":this.color}};f3.BASESIZE=6;E7={pt:1,mm:7227/2540,cm:7227/254,in:72.27,bp:803/800,pc:12,dd:1238/1157,cc:14856/1157,nd:685/642,nc:1370/107,sp:1/65536,px:803/800},Lbe={ex:!0,em:!0,mu:!0},zz=o(function(e){return typeof e!="string"&&(e=e.unit),e in E7||e in Lbe||e==="ex"},"validUnit"),ti=o(function(e,r){var n;if(e.unit in E7)n=E7[e.unit]/r.fontMetrics().ptPerEm/r.sizeMultiplier;else if(e.unit==="mu")n=r.fontMetrics().cssEmPerMu;else{var i;if(r.style.isTight()?i=r.havingStyle(r.style.text()):i=r,e.unit==="ex")n=i.fontMetrics().xHeight;else if(e.unit==="em")n=i.fontMetrics().quad;else throw new gt("Invalid unit: '"+e.unit+"'");i!==r&&(n*=i.sizeMultiplier/r.sizeMultiplier)}return Math.min(e.number*n,r.maxSize)},"calculateSize"),kt=o(function(e){return+e.toFixed(4)+"em"},"makeEm"),fh=o(function(e){return e.filter(r=>r).join(" ")},"createClass"),Gz=o(function(e,r,n){if(this.classes=e||[],this.attributes={},this.height=0,this.depth=0,this.maxFontSize=0,this.style=n||{},r){r.style.isTight()&&this.classes.push("mtight");var i=r.getColor();i&&(this.style.color=i)}},"initNode"),Vz=o(function(e){var r=document.createElement(e);r.className=fh(this.classes);for(var n in this.style)this.style.hasOwnProperty(n)&&(r.style[n]=this.style[n]);for(var i in this.attributes)this.attributes.hasOwnProperty(i)&&r.setAttribute(i,this.attributes[i]);for(var a=0;a",r},"toMarkup"),td=class{static{o(this,"Span")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.width=void 0,this.maxFontSize=void 0,this.style=void 0,Gz.call(this,e,n,i),this.children=r||[]}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return Jt.contains(this.classes,e)}toNode(){return Vz.call(this,"span")}toMarkup(){return Uz.call(this,"span")}},Vy=class{static{o(this,"Anchor")}constructor(e,r,n,i){this.children=void 0,this.attributes=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,Gz.call(this,r,i),this.children=n||[],this.setAttribute("href",e)}setAttribute(e,r){this.attributes[e]=r}hasClass(e){return Jt.contains(this.classes,e)}toNode(){return Vz.call(this,"a")}toMarkup(){return Uz.call(this,"a")}},S7=class{static{o(this,"Img")}constructor(e,r,n){this.src=void 0,this.alt=void 0,this.classes=void 0,this.height=void 0,this.depth=void 0,this.maxFontSize=void 0,this.style=void 0,this.alt=r,this.src=e,this.classes=["mord"],this.style=n}hasClass(e){return Jt.contains(this.classes,e)}toNode(){var e=document.createElement("img");e.src=this.src,e.alt=this.alt,e.className="mord";for(var r in this.style)this.style.hasOwnProperty(r)&&(e.style[r]=this.style[r]);return e}toMarkup(){var e=''+Jt.escape(this.alt)+'0&&(r=document.createElement("span"),r.style.marginRight=kt(this.italic)),this.classes.length>0&&(r=r||document.createElement("span"),r.className=fh(this.classes));for(var n in this.style)this.style.hasOwnProperty(n)&&(r=r||document.createElement("span"),r.style[n]=this.style[n]);return r?(r.appendChild(e),r):e}toMarkup(){var e=!1,r="0&&(n+="margin-right:"+this.italic+"em;");for(var i in this.style)this.style.hasOwnProperty(i)&&(n+=Jt.hyphenate(i)+":"+this.style[i]+";");n&&(e=!0,r+=' style="'+Jt.escape(n)+'"');var a=Jt.escape(this.text);return e?(r+=">",r+=a,r+="",r):a}},ll=class{static{o(this,"SvgNode")}constructor(e,r){this.children=void 0,this.attributes=void 0,this.children=e||[],this.attributes=r||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"svg");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);for(var i=0;i':''}},Uy=class{static{o(this,"LineNode")}constructor(e){this.attributes=void 0,this.attributes=e||{}}toNode(){var e="http://www.w3.org/2000/svg",r=document.createElementNS(e,"line");for(var n in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,n)&&r.setAttribute(n,this.attributes[n]);return r}toMarkup(){var e="","\\gt",!0);G(U,ee,Ee,"\u2208","\\in",!0);G(U,ee,Ee,"\uE020","\\@not");G(U,ee,Ee,"\u2282","\\subset",!0);G(U,ee,Ee,"\u2283","\\supset",!0);G(U,ee,Ee,"\u2286","\\subseteq",!0);G(U,ee,Ee,"\u2287","\\supseteq",!0);G(U,ke,Ee,"\u2288","\\nsubseteq",!0);G(U,ke,Ee,"\u2289","\\nsupseteq",!0);G(U,ee,Ee,"\u22A8","\\models");G(U,ee,Ee,"\u2190","\\leftarrow",!0);G(U,ee,Ee,"\u2264","\\le");G(U,ee,Ee,"\u2264","\\leq",!0);G(U,ee,Ee,"<","\\lt",!0);G(U,ee,Ee,"\u2192","\\rightarrow",!0);G(U,ee,Ee,"\u2192","\\to");G(U,ke,Ee,"\u2271","\\ngeq",!0);G(U,ke,Ee,"\u2270","\\nleq",!0);G(U,ee,uu,"\xA0","\\ ");G(U,ee,uu,"\xA0","\\space");G(U,ee,uu,"\xA0","\\nobreakspace");G(it,ee,uu,"\xA0","\\ ");G(it,ee,uu,"\xA0"," ");G(it,ee,uu,"\xA0","\\space");G(it,ee,uu,"\xA0","\\nobreakspace");G(U,ee,uu,null,"\\nobreak");G(U,ee,uu,null,"\\allowbreak");G(U,ee,x3,",",",");G(U,ee,x3,";",";");G(U,ke,It,"\u22BC","\\barwedge",!0);G(U,ke,It,"\u22BB","\\veebar",!0);G(U,ee,It,"\u2299","\\odot",!0);G(U,ee,It,"\u2295","\\oplus",!0);G(U,ee,It,"\u2297","\\otimes",!0);G(U,ee,Le,"\u2202","\\partial",!0);G(U,ee,It,"\u2298","\\oslash",!0);G(U,ke,It,"\u229A","\\circledcirc",!0);G(U,ke,It,"\u22A1","\\boxdot",!0);G(U,ee,It,"\u25B3","\\bigtriangleup");G(U,ee,It,"\u25BD","\\bigtriangledown");G(U,ee,It,"\u2020","\\dagger");G(U,ee,It,"\u22C4","\\diamond");G(U,ee,It,"\u22C6","\\star");G(U,ee,It,"\u25C3","\\triangleleft");G(U,ee,It,"\u25B9","\\triangleright");G(U,ee,js,"{","\\{");G(it,ee,Le,"{","\\{");G(it,ee,Le,"{","\\textbraceleft");G(U,ee,Za,"}","\\}");G(it,ee,Le,"}","\\}");G(it,ee,Le,"}","\\textbraceright");G(U,ee,js,"{","\\lbrace");G(U,ee,Za,"}","\\rbrace");G(U,ee,js,"[","\\lbrack",!0);G(it,ee,Le,"[","\\lbrack",!0);G(U,ee,Za,"]","\\rbrack",!0);G(it,ee,Le,"]","\\rbrack",!0);G(U,ee,js,"(","\\lparen",!0);G(U,ee,Za,")","\\rparen",!0);G(it,ee,Le,"<","\\textless",!0);G(it,ee,Le,">","\\textgreater",!0);G(U,ee,js,"\u230A","\\lfloor",!0);G(U,ee,Za,"\u230B","\\rfloor",!0);G(U,ee,js,"\u2308","\\lceil",!0);G(U,ee,Za,"\u2309","\\rceil",!0);G(U,ee,Le,"\\","\\backslash");G(U,ee,Le,"\u2223","|");G(U,ee,Le,"\u2223","\\vert");G(it,ee,Le,"|","\\textbar",!0);G(U,ee,Le,"\u2225","\\|");G(U,ee,Le,"\u2225","\\Vert");G(it,ee,Le,"\u2225","\\textbardbl");G(it,ee,Le,"~","\\textasciitilde");G(it,ee,Le,"\\","\\textbackslash");G(it,ee,Le,"^","\\textasciicircum");G(U,ee,Ee,"\u2191","\\uparrow",!0);G(U,ee,Ee,"\u21D1","\\Uparrow",!0);G(U,ee,Ee,"\u2193","\\downarrow",!0);G(U,ee,Ee,"\u21D3","\\Downarrow",!0);G(U,ee,Ee,"\u2195","\\updownarrow",!0);G(U,ee,Ee,"\u21D5","\\Updownarrow",!0);G(U,ee,ki,"\u2210","\\coprod");G(U,ee,ki,"\u22C1","\\bigvee");G(U,ee,ki,"\u22C0","\\bigwedge");G(U,ee,ki,"\u2A04","\\biguplus");G(U,ee,ki,"\u22C2","\\bigcap");G(U,ee,ki,"\u22C3","\\bigcup");G(U,ee,ki,"\u222B","\\int");G(U,ee,ki,"\u222B","\\intop");G(U,ee,ki,"\u222C","\\iint");G(U,ee,ki,"\u222D","\\iiint");G(U,ee,ki,"\u220F","\\prod");G(U,ee,ki,"\u2211","\\sum");G(U,ee,ki,"\u2A02","\\bigotimes");G(U,ee,ki,"\u2A01","\\bigoplus");G(U,ee,ki,"\u2A00","\\bigodot");G(U,ee,ki,"\u222E","\\oint");G(U,ee,ki,"\u222F","\\oiint");G(U,ee,ki,"\u2230","\\oiiint");G(U,ee,ki,"\u2A06","\\bigsqcup");G(U,ee,ki,"\u222B","\\smallint");G(it,ee,p0,"\u2026","\\textellipsis");G(U,ee,p0,"\u2026","\\mathellipsis");G(it,ee,p0,"\u2026","\\ldots",!0);G(U,ee,p0,"\u2026","\\ldots",!0);G(U,ee,p0,"\u22EF","\\@cdots",!0);G(U,ee,p0,"\u22F1","\\ddots",!0);G(U,ee,Le,"\u22EE","\\varvdots");G(U,ee,Vn,"\u02CA","\\acute");G(U,ee,Vn,"\u02CB","\\grave");G(U,ee,Vn,"\xA8","\\ddot");G(U,ee,Vn,"~","\\tilde");G(U,ee,Vn,"\u02C9","\\bar");G(U,ee,Vn,"\u02D8","\\breve");G(U,ee,Vn,"\u02C7","\\check");G(U,ee,Vn,"^","\\hat");G(U,ee,Vn,"\u20D7","\\vec");G(U,ee,Vn,"\u02D9","\\dot");G(U,ee,Vn,"\u02DA","\\mathring");G(U,ee,er,"\uE131","\\@imath");G(U,ee,er,"\uE237","\\@jmath");G(U,ee,Le,"\u0131","\u0131");G(U,ee,Le,"\u0237","\u0237");G(it,ee,Le,"\u0131","\\i",!0);G(it,ee,Le,"\u0237","\\j",!0);G(it,ee,Le,"\xDF","\\ss",!0);G(it,ee,Le,"\xE6","\\ae",!0);G(it,ee,Le,"\u0153","\\oe",!0);G(it,ee,Le,"\xF8","\\o",!0);G(it,ee,Le,"\xC6","\\AE",!0);G(it,ee,Le,"\u0152","\\OE",!0);G(it,ee,Le,"\xD8","\\O",!0);G(it,ee,Vn,"\u02CA","\\'");G(it,ee,Vn,"\u02CB","\\`");G(it,ee,Vn,"\u02C6","\\^");G(it,ee,Vn,"\u02DC","\\~");G(it,ee,Vn,"\u02C9","\\=");G(it,ee,Vn,"\u02D8","\\u");G(it,ee,Vn,"\u02D9","\\.");G(it,ee,Vn,"\xB8","\\c");G(it,ee,Vn,"\u02DA","\\r");G(it,ee,Vn,"\u02C7","\\v");G(it,ee,Vn,"\xA8",'\\"');G(it,ee,Vn,"\u02DD","\\H");G(it,ee,Vn,"\u25EF","\\textcircled");Hz={"--":!0,"---":!0,"``":!0,"''":!0};G(it,ee,Le,"\u2013","--",!0);G(it,ee,Le,"\u2013","\\textendash");G(it,ee,Le,"\u2014","---",!0);G(it,ee,Le,"\u2014","\\textemdash");G(it,ee,Le,"\u2018","`",!0);G(it,ee,Le,"\u2018","\\textquoteleft");G(it,ee,Le,"\u2019","'",!0);G(it,ee,Le,"\u2019","\\textquoteright");G(it,ee,Le,"\u201C","``",!0);G(it,ee,Le,"\u201C","\\textquotedblleft");G(it,ee,Le,"\u201D","''",!0);G(it,ee,Le,"\u201D","\\textquotedblright");G(U,ee,Le,"\xB0","\\degree",!0);G(it,ee,Le,"\xB0","\\degree");G(it,ee,Le,"\xB0","\\textdegree",!0);G(U,ee,Le,"\xA3","\\pounds");G(U,ee,Le,"\xA3","\\mathsterling",!0);G(it,ee,Le,"\xA3","\\pounds");G(it,ee,Le,"\xA3","\\textsterling",!0);G(U,ke,Le,"\u2720","\\maltese");G(it,ke,Le,"\u2720","\\maltese");fz='0123456789/@."';for(J4=0;J40)return ol(a,h,i,r,s.concat(f));if(u){var d,p;if(u==="boldsymbol"){var m=Bbe(a,i,r,s,n);d=m.fontName,p=[m.fontClass]}else l?(d=Yz[u].fontName,p=[u]):(d=i3(u,r.fontWeight,r.fontShape),p=[u,r.fontWeight,r.fontShape]);if(b3(a,d,i).metrics)return ol(a,d,i,r,s.concat(p));if(Hz.hasOwnProperty(a)&&d.slice(0,10)==="Typewriter"){for(var g=[],y=0;y{if(fh(t.classes)!==fh(e.classes)||t.skew!==e.skew||t.maxFontSize!==e.maxFontSize)return!1;if(t.classes.length===1){var r=t.classes[0];if(r==="mbin"||r==="mord")return!1}for(var n in t.style)if(t.style.hasOwnProperty(n)&&t.style[n]!==e.style[n])return!1;for(var i in e.style)if(e.style.hasOwnProperty(i)&&t.style[i]!==e.style[i])return!1;return!0},"canCombine"),zbe=o(t=>{for(var e=0;er&&(r=s.height),s.depth>n&&(n=s.depth),s.maxFontSize>i&&(i=s.maxFontSize)}e.height=r,e.depth=n,e.maxFontSize=i},"sizeElementFromChildren"),bs=o(function(e,r,n,i){var a=new td(e,r,n,i);return B7(a),a},"makeSpan"),Wz=o((t,e,r,n)=>new td(t,e,r,n),"makeSvgSpan"),Gbe=o(function(e,r,n){var i=bs([e],[],r);return i.height=Math.max(n||r.fontMetrics().defaultRuleThickness,r.minRuleThickness),i.style.borderBottomWidth=kt(i.height),i.maxFontSize=1,i},"makeLineSpan"),Vbe=o(function(e,r,n,i){var a=new Vy(e,r,n,i);return B7(a),a},"makeAnchor"),qz=o(function(e){var r=new ed(e);return B7(r),r},"makeFragment"),Ube=o(function(e,r){return e instanceof ed?bs([],[e],r):e},"wrapFragment"),Hbe=o(function(e){if(e.positionType==="individualShift"){for(var r=e.children,n=[r[0]],i=-r[0].shift-r[0].elem.depth,a=i,s=1;s{var r=bs(["mspace"],[],e),n=ti(t,e);return r.style.marginRight=kt(n),r},"makeGlue"),i3=o(function(e,r,n){var i="";switch(e){case"amsrm":i="AMS";break;case"textrm":i="Main";break;case"textsf":i="SansSerif";break;case"texttt":i="Typewriter";break;default:i=e}var a;return r==="textbf"&&n==="textit"?a="BoldItalic":r==="textbf"?a="Bold":r==="textit"?a="Italic":a="Regular",i+"-"+a},"retrieveTextFontName"),Yz={mathbf:{variant:"bold",fontName:"Main-Bold"},mathrm:{variant:"normal",fontName:"Main-Regular"},textit:{variant:"italic",fontName:"Main-Italic"},mathit:{variant:"italic",fontName:"Main-Italic"},mathnormal:{variant:"italic",fontName:"Math-Italic"},mathbb:{variant:"double-struck",fontName:"AMS-Regular"},mathcal:{variant:"script",fontName:"Caligraphic-Regular"},mathfrak:{variant:"fraktur",fontName:"Fraktur-Regular"},mathscr:{variant:"script",fontName:"Script-Regular"},mathsf:{variant:"sans-serif",fontName:"SansSerif-Regular"},mathtt:{variant:"monospace",fontName:"Typewriter-Regular"}},Xz={vec:["vec",.471,.714],oiintSize1:["oiintSize1",.957,.499],oiintSize2:["oiintSize2",1.472,.659],oiiintSize1:["oiiintSize1",1.304,.499],oiiintSize2:["oiiintSize2",1.98,.659]},Ybe=o(function(e,r){var[n,i,a]=Xz[e],s=new Kl(n),l=new ll([s],{width:kt(i),height:kt(a),style:"width:"+kt(i),viewBox:"0 0 "+1e3*i+" "+1e3*a,preserveAspectRatio:"xMinYMin"}),u=Wz(["overlay"],[l],r);return u.height=a,u.style.height=kt(a),u.style.width=kt(i),u},"staticSvg"),Be={fontMap:Yz,makeSymbol:ol,mathsym:Pbe,makeSpan:bs,makeSvgSpan:Wz,makeLineSpan:Gbe,makeAnchor:Vbe,makeFragment:qz,wrapFragment:Ube,makeVList:Wbe,makeOrd:Fbe,makeGlue:qbe,staticSvg:Ybe,svgData:Xz,tryCombineChars:zbe},ei={number:3,unit:"mu"},Zf={number:4,unit:"mu"},au={number:5,unit:"mu"},Xbe={mord:{mop:ei,mbin:Zf,mrel:au,minner:ei},mop:{mord:ei,mop:ei,mrel:au,minner:ei},mbin:{mord:Zf,mop:Zf,mopen:Zf,minner:Zf},mrel:{mord:au,mop:au,mopen:au,minner:au},mopen:{},mclose:{mop:ei,mbin:Zf,mrel:au,minner:ei},mpunct:{mord:ei,mop:ei,mrel:au,mopen:ei,mclose:ei,mpunct:ei,minner:ei},minner:{mord:ei,mop:ei,mbin:Zf,mrel:au,mopen:ei,mpunct:ei,minner:ei}},jbe={mord:{mop:ei},mop:{mord:ei,mop:ei},mbin:{},mrel:{},mopen:{},mclose:{mop:ei},mpunct:{},minner:{mop:ei}},jz={},p3={},m3={};o(Nt,"defineFunction");o(rd,"defineFunctionBuilders");g3=o(function(e){return e.type==="ordgroup"&&e.body.length===1?e.body[0]:e},"normalizeArgument"),di=o(function(e){return e.type==="ordgroup"?e.body:[e]},"ordargument"),lu=Be.makeSpan,Kbe=["leftmost","mbin","mopen","mrel","mop","mpunct"],Qbe=["rightmost","mrel","mclose","mpunct"],Zbe={display:tr.DISPLAY,text:tr.TEXT,script:tr.SCRIPT,scriptscript:tr.SCRIPTSCRIPT},Jbe={mord:"mord",mop:"mop",mbin:"mbin",mrel:"mrel",mopen:"mopen",mclose:"mclose",mpunct:"mpunct",minner:"minner"},Pi=o(function(e,r,n,i){i===void 0&&(i=[null,null]);for(var a=[],s=0;s{var v=y.classes[0],x=g.classes[0];v==="mbin"&&Jt.contains(Qbe,x)?y.classes[0]="mord":x==="mbin"&&Jt.contains(Kbe,v)&&(g.classes[0]="mord")},{node:d},p,m),mz(a,(g,y)=>{var v=A7(y),x=A7(g),b=v&&x?g.hasClass("mtight")?jbe[v][x]:Xbe[v][x]:null;if(b)return Be.makeGlue(b,h)},{node:d},p,m),a},"buildExpression"),mz=o(function t(e,r,n,i,a){i&&e.push(i);for(var s=0;sp=>{e.splice(d+1,0,p),s++})(s)}i&&e.pop()},"traverseNonSpaceNodes"),Kz=o(function(e){return e instanceof ed||e instanceof Vy||e instanceof td&&e.hasClass("enclosing")?e:null},"checkPartialGroup"),e4e=o(function t(e,r){var n=Kz(e);if(n){var i=n.children;if(i.length){if(r==="right")return t(i[i.length-1],"right");if(r==="left")return t(i[0],"left")}}return e},"getOutermostNode"),A7=o(function(e,r){return e?(r&&(e=e4e(e,r)),Jbe[e.classes[0]]||null):null},"getTypeOfDomTree"),Hy=o(function(e,r){var n=["nulldelimiter"].concat(e.baseSizingClasses());return lu(r.concat(n))},"makeNullDelimiter"),Fr=o(function(e,r,n){if(!e)return lu();if(p3[e.type]){var i=p3[e.type](e,r);if(n&&r.size!==n.size){i=lu(r.sizingClasses(n),[i],r);var a=r.sizeMultiplier/n.sizeMultiplier;i.height*=a,i.depth*=a}return i}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(a3,"buildHTMLUnbreakable");o(_7,"buildHTML");o(Qz,"newDocumentFragment");ws=class{static{o(this,"MathNode")}constructor(e,r,n){this.type=void 0,this.attributes=void 0,this.children=void 0,this.classes=void 0,this.type=e,this.attributes={},this.children=r||[],this.classes=n||[]}setAttribute(e,r){this.attributes[e]=r}getAttribute(e){return this.attributes[e]}toNode(){var e=document.createElementNS("http://www.w3.org/1998/Math/MathML",this.type);for(var r in this.attributes)Object.prototype.hasOwnProperty.call(this.attributes,r)&&e.setAttribute(r,this.attributes[r]);this.classes.length>0&&(e.className=fh(this.classes));for(var n=0;n0&&(e+=' class ="'+Jt.escape(fh(this.classes))+'"'),e+=">";for(var n=0;n",e}toText(){return this.children.map(e=>e.toText()).join("")}},Jf=class{static{o(this,"TextNode")}constructor(e){this.text=void 0,this.text=e}toNode(){return document.createTextNode(this.text)}toMarkup(){return Jt.escape(this.toText())}toText(){return this.text}},D7=class{static{o(this,"SpaceNode")}constructor(e){this.width=void 0,this.character=void 0,this.width=e,e>=.05555&&e<=.05556?this.character="\u200A":e>=.1666&&e<=.1667?this.character="\u2009":e>=.2222&&e<=.2223?this.character="\u2005":e>=.2777&&e<=.2778?this.character="\u2005\u200A":e>=-.05556&&e<=-.05555?this.character="\u200A\u2063":e>=-.1667&&e<=-.1666?this.character="\u2009\u2063":e>=-.2223&&e<=-.2222?this.character="\u205F\u2063":e>=-.2778&&e<=-.2777?this.character="\u2005\u2063":this.character=null}toNode(){if(this.character)return document.createTextNode(this.character);var e=document.createElementNS("http://www.w3.org/1998/Math/MathML","mspace");return e.setAttribute("width",kt(this.width)),e}toMarkup(){return this.character?""+this.character+"":''}toText(){return this.character?this.character:" "}},dt={MathNode:ws,TextNode:Jf,SpaceNode:D7,newDocumentFragment:Qz},Co=o(function(e,r,n){return An[r][e]&&An[r][e].replace&&e.charCodeAt(0)!==55349&&!(Hz.hasOwnProperty(e)&&n&&(n.fontFamily&&n.fontFamily.slice(4,6)==="tt"||n.font&&n.font.slice(4,6)==="tt"))&&(e=An[r][e].replace),new dt.TextNode(e)},"makeText"),F7=o(function(e){return e.length===1?e[0]:new dt.MathNode("mrow",e)},"makeRow"),$7=o(function(e,r){if(r.fontFamily==="texttt")return"monospace";if(r.fontFamily==="textsf")return r.fontShape==="textit"&&r.fontWeight==="textbf"?"sans-serif-bold-italic":r.fontShape==="textit"?"sans-serif-italic":r.fontWeight==="textbf"?"bold-sans-serif":"sans-serif";if(r.fontShape==="textit"&&r.fontWeight==="textbf")return"bold-italic";if(r.fontShape==="textit")return"italic";if(r.fontWeight==="textbf")return"bold";var n=r.font;if(!n||n==="mathnormal")return null;var i=e.mode;if(n==="mathit")return"italic";if(n==="boldsymbol")return e.type==="textord"?"bold":"bold-italic";if(n==="mathbf")return"bold";if(n==="mathbb")return"double-struck";if(n==="mathfrak")return"fraktur";if(n==="mathscr"||n==="mathcal")return"script";if(n==="mathsf")return"sans-serif";if(n==="mathtt")return"monospace";var a=e.text;if(Jt.contains(["\\imath","\\jmath"],a))return null;An[i][a]&&An[i][a].replace&&(a=An[i][a].replace);var s=Be.fontMap[n].fontName;return P7(a,s,i)?Be.fontMap[n].variant:null},"getVariant"),ks=o(function(e,r,n){if(e.length===1){var i=yn(e[0],r);return n&&i instanceof ws&&i.type==="mo"&&(i.setAttribute("lspace","0em"),i.setAttribute("rspace","0em")),[i]}for(var a=[],s,l=0;l0&&(d.text=d.text.slice(0,1)+"\u0338"+d.text.slice(1),a.pop())}}}a.push(u),s=u}return a},"buildExpression"),dh=o(function(e,r,n){return F7(ks(e,r,n))},"buildExpressionRow"),yn=o(function(e,r){if(!e)return new dt.MathNode("mrow");if(m3[e.type]){var n=m3[e.type](e,r);return n}else throw new gt("Got group of unknown type: '"+e.type+"'")},"buildGroup");o(gz,"buildMathML");Zz=o(function(e){return new f3({style:e.displayMode?tr.DISPLAY:tr.TEXT,maxSize:e.maxSize,minRuleThickness:e.minRuleThickness})},"optionsFromSettings"),Jz=o(function(e,r){if(r.displayMode){var n=["katex-display"];r.leqno&&n.push("leqno"),r.fleqn&&n.push("fleqn"),e=Be.makeSpan(n,[e])}return e},"displayWrap"),t4e=o(function(e,r,n){var i=Zz(n),a;if(n.output==="mathml")return gz(e,r,i,n.displayMode,!0);if(n.output==="html"){var s=_7(e,i);a=Be.makeSpan(["katex"],[s])}else{var l=gz(e,r,i,n.displayMode,!1),u=_7(e,i);a=Be.makeSpan(["katex"],[l,u])}return Jz(a,n)},"buildTree"),r4e=o(function(e,r,n){var i=Zz(n),a=_7(e,i),s=Be.makeSpan(["katex"],[a]);return Jz(s,n)},"buildHTMLTree"),n4e={widehat:"^",widecheck:"\u02C7",widetilde:"~",utilde:"~",overleftarrow:"\u2190",underleftarrow:"\u2190",xleftarrow:"\u2190",overrightarrow:"\u2192",underrightarrow:"\u2192",xrightarrow:"\u2192",underbrace:"\u23DF",overbrace:"\u23DE",overgroup:"\u23E0",undergroup:"\u23E1",overleftrightarrow:"\u2194",underleftrightarrow:"\u2194",xleftrightarrow:"\u2194",Overrightarrow:"\u21D2",xRightarrow:"\u21D2",overleftharpoon:"\u21BC",xleftharpoonup:"\u21BC",overrightharpoon:"\u21C0",xrightharpoonup:"\u21C0",xLeftarrow:"\u21D0",xLeftrightarrow:"\u21D4",xhookleftarrow:"\u21A9",xhookrightarrow:"\u21AA",xmapsto:"\u21A6",xrightharpoondown:"\u21C1",xleftharpoondown:"\u21BD",xrightleftharpoons:"\u21CC",xleftrightharpoons:"\u21CB",xtwoheadleftarrow:"\u219E",xtwoheadrightarrow:"\u21A0",xlongequal:"=",xtofrom:"\u21C4",xrightleftarrows:"\u21C4",xrightequilibrium:"\u21CC",xleftequilibrium:"\u21CB","\\cdrightarrow":"\u2192","\\cdleftarrow":"\u2190","\\cdlongequal":"="},i4e=o(function(e){var r=new dt.MathNode("mo",[new dt.TextNode(n4e[e.replace(/^\\/,"")])]);return r.setAttribute("stretchy","true"),r},"mathMLnode"),a4e={overrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],overleftarrow:[["leftarrow"],.888,522,"xMinYMin"],underrightarrow:[["rightarrow"],.888,522,"xMaxYMin"],underleftarrow:[["leftarrow"],.888,522,"xMinYMin"],xrightarrow:[["rightarrow"],1.469,522,"xMaxYMin"],"\\cdrightarrow":[["rightarrow"],3,522,"xMaxYMin"],xleftarrow:[["leftarrow"],1.469,522,"xMinYMin"],"\\cdleftarrow":[["leftarrow"],3,522,"xMinYMin"],Overrightarrow:[["doublerightarrow"],.888,560,"xMaxYMin"],xRightarrow:[["doublerightarrow"],1.526,560,"xMaxYMin"],xLeftarrow:[["doubleleftarrow"],1.526,560,"xMinYMin"],overleftharpoon:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoonup:[["leftharpoon"],.888,522,"xMinYMin"],xleftharpoondown:[["leftharpoondown"],.888,522,"xMinYMin"],overrightharpoon:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoonup:[["rightharpoon"],.888,522,"xMaxYMin"],xrightharpoondown:[["rightharpoondown"],.888,522,"xMaxYMin"],xlongequal:[["longequal"],.888,334,"xMinYMin"],"\\cdlongequal":[["longequal"],3,334,"xMinYMin"],xtwoheadleftarrow:[["twoheadleftarrow"],.888,334,"xMinYMin"],xtwoheadrightarrow:[["twoheadrightarrow"],.888,334,"xMaxYMin"],overleftrightarrow:[["leftarrow","rightarrow"],.888,522],overbrace:[["leftbrace","midbrace","rightbrace"],1.6,548],underbrace:[["leftbraceunder","midbraceunder","rightbraceunder"],1.6,548],underleftrightarrow:[["leftarrow","rightarrow"],.888,522],xleftrightarrow:[["leftarrow","rightarrow"],1.75,522],xLeftrightarrow:[["doubleleftarrow","doublerightarrow"],1.75,560],xrightleftharpoons:[["leftharpoondownplus","rightharpoonplus"],1.75,716],xleftrightharpoons:[["leftharpoonplus","rightharpoondownplus"],1.75,716],xhookleftarrow:[["leftarrow","righthook"],1.08,522],xhookrightarrow:[["lefthook","rightarrow"],1.08,522],overlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],underlinesegment:[["leftlinesegment","rightlinesegment"],.888,522],overgroup:[["leftgroup","rightgroup"],.888,342],undergroup:[["leftgroupunder","rightgroupunder"],.888,342],xmapsto:[["leftmapsto","rightarrow"],1.5,522],xtofrom:[["leftToFrom","rightToFrom"],1.75,528],xrightleftarrows:[["baraboveleftarrow","rightarrowabovebar"],1.75,901],xrightequilibrium:[["baraboveshortleftharpoon","rightharpoonaboveshortbar"],1.75,716],xleftequilibrium:[["shortbaraboveleftharpoon","shortrightharpoonabovebar"],1.75,716]},s4e=o(function(e){return e.type==="ordgroup"?e.body.length:1},"groupLength"),o4e=o(function(e,r){function n(){var l=4e5,u=e.label.slice(1);if(Jt.contains(["widehat","widecheck","widetilde","utilde"],u)){var h=e,f=s4e(h.base),d,p,m;if(f>5)u==="widehat"||u==="widecheck"?(d=420,l=2364,m=.42,p=u+"4"):(d=312,l=2340,m=.34,p="tilde4");else{var g=[1,1,2,2,3,3][f];u==="widehat"||u==="widecheck"?(l=[0,1062,2364,2364,2364][g],d=[0,239,300,360,420][g],m=[0,.24,.3,.3,.36,.42][g],p=u+g):(l=[0,600,1033,2339,2340][g],d=[0,260,286,306,312][g],m=[0,.26,.286,.3,.306,.34][g],p="tilde"+g)}var y=new Kl(p),v=new ll([y],{width:"100%",height:kt(m),viewBox:"0 0 "+l+" "+d,preserveAspectRatio:"none"});return{span:Be.makeSvgSpan([],[v],r),minWidth:0,height:m}}else{var x=[],b=a4e[u],[w,C,T]=b,E=T/1e3,A=w.length,S,_;if(A===1){var I=b[3];S=["hide-tail"],_=[I]}else if(A===2)S=["halfarrow-left","halfarrow-right"],_=["xMinYMin","xMaxYMin"];else if(A===3)S=["brace-left","brace-center","brace-right"],_=["xMinYMin","xMidYMin","xMaxYMin"];else throw new Error(`Correct katexImagesData or update code here to support + `+A+" children.");for(var D=0;D0&&(i.style.minWidth=kt(a)),i},"svgSpan"),l4e=o(function(e,r,n,i,a){var s,l=e.height+e.depth+n+i;if(/fbox|color|angl/.test(r)){if(s=Be.makeSpan(["stretchy",r],[],a),r==="fbox"){var u=a.color&&a.getColor();u&&(s.style.borderColor=u)}}else{var h=[];/^[bx]cancel$/.test(r)&&h.push(new Uy({x1:"0",y1:"0",x2:"100%",y2:"100%","stroke-width":"0.046em"})),/^x?cancel$/.test(r)&&h.push(new Uy({x1:"0",y1:"100%",x2:"100%",y2:"0","stroke-width":"0.046em"}));var f=new ll(h,{width:"100%",height:kt(l)});s=Be.makeSvgSpan([],[f],a)}return s.height=l,s.style.height=kt(l),s},"encloseSpan"),cu={encloseSpan:l4e,mathMLnode:i4e,svgSpan:o4e};o(xr,"assertNodeType");o(z7,"assertSymbolNodeType");o(w3,"checkSymbolNodeType");G7=o((t,e)=>{var r,n,i;t&&t.type==="supsub"?(n=xr(t.base,"accent"),r=n.base,t.base=r,i=Nbe(Fr(t,e)),t.base=n):(n=xr(t,"accent"),r=n.base);var a=Fr(r,e.havingCrampedStyle()),s=n.isShifty&&Jt.isCharacterBox(r),l=0;if(s){var u=Jt.getBaseElem(r),h=Fr(u,e.havingCrampedStyle());l=hz(h).skew}var f=n.label==="\\c",d=f?a.height+a.depth:Math.min(a.height,e.fontMetrics().xHeight),p;if(n.isStretchy)p=cu.svgSpan(n,e),p=Be.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"elem",elem:p,wrapperClasses:["svg-align"],wrapperStyle:l>0?{width:"calc(100% - "+kt(2*l)+")",marginLeft:kt(2*l)}:void 0}]},e);else{var m,g;n.label==="\\vec"?(m=Be.staticSvg("vec",e),g=Be.svgData.vec[1]):(m=Be.makeOrd({mode:n.mode,text:n.label},e,"textord"),m=hz(m),m.italic=0,g=m.width,f&&(d+=m.depth)),p=Be.makeSpan(["accent-body"],[m]);var y=n.label==="\\textcircled";y&&(p.classes.push("accent-full"),d=a.height);var v=l;y||(v-=g/2),p.style.left=kt(v),n.label==="\\textcircled"&&(p.style.top=".2em"),p=Be.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:-d},{type:"elem",elem:p}]},e)}var x=Be.makeSpan(["mord","accent"],[p],e);return i?(i.children[0]=x,i.height=Math.max(x.height,i.height),i.classes[0]="mord",i):x},"htmlBuilder$a"),eG=o((t,e)=>{var r=t.isStretchy?cu.mathMLnode(t.label):new dt.MathNode("mo",[Co(t.label,t.mode)]),n=new dt.MathNode("mover",[yn(t.base,e),r]);return n.setAttribute("accent","true"),n},"mathmlBuilder$9"),c4e=new RegExp(["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring"].map(t=>"\\"+t).join("|"));Nt({type:"accent",names:["\\acute","\\grave","\\ddot","\\tilde","\\bar","\\breve","\\check","\\hat","\\vec","\\dot","\\mathring","\\widecheck","\\widehat","\\widetilde","\\overrightarrow","\\overleftarrow","\\Overrightarrow","\\overleftrightarrow","\\overgroup","\\overlinesegment","\\overleftharpoon","\\overrightharpoon"],props:{numArgs:1},handler:o((t,e)=>{var r=g3(e[0]),n=!c4e.test(t.funcName),i=!n||t.funcName==="\\widehat"||t.funcName==="\\widetilde"||t.funcName==="\\widecheck";return{type:"accent",mode:t.parser.mode,label:t.funcName,isStretchy:n,isShifty:i,base:r}},"handler"),htmlBuilder:G7,mathmlBuilder:eG});Nt({type:"accent",names:["\\'","\\`","\\^","\\~","\\=","\\u","\\.",'\\"',"\\c","\\r","\\H","\\v","\\textcircled"],props:{numArgs:1,allowedInText:!0,allowedInMath:!0,argTypes:["primitive"]},handler:o((t,e)=>{var r=e[0],n=t.parser.mode;return n==="math"&&(t.parser.settings.reportNonstrict("mathVsTextAccents","LaTeX's accent "+t.funcName+" works only in text mode"),n="text"),{type:"accent",mode:n,label:t.funcName,isStretchy:!1,isShifty:!0,base:r}},"handler"),htmlBuilder:G7,mathmlBuilder:eG});Nt({type:"accentUnder",names:["\\underleftarrow","\\underrightarrow","\\underleftrightarrow","\\undergroup","\\underlinesegment","\\utilde"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"accentUnder",mode:r.mode,label:n,base:i}},"handler"),htmlBuilder:o((t,e)=>{var r=Fr(t.base,e),n=cu.svgSpan(t,e),i=t.label==="\\utilde"?.12:0,a=Be.makeVList({positionType:"top",positionData:r.height,children:[{type:"elem",elem:n,wrapperClasses:["svg-align"]},{type:"kern",size:i},{type:"elem",elem:r}]},e);return Be.makeSpan(["mord","accentunder"],[a],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=cu.mathMLnode(t.label),n=new dt.MathNode("munder",[yn(t.base,e),r]);return n.setAttribute("accentunder","true"),n},"mathmlBuilder")});s3=o(t=>{var e=new dt.MathNode("mpadded",t?[t]:[]);return e.setAttribute("width","+0.6em"),e.setAttribute("lspace","0.3em"),e},"paddedNode");Nt({type:"xArrow",names:["\\xleftarrow","\\xrightarrow","\\xLeftarrow","\\xRightarrow","\\xleftrightarrow","\\xLeftrightarrow","\\xhookleftarrow","\\xhookrightarrow","\\xmapsto","\\xrightharpoondown","\\xrightharpoonup","\\xleftharpoondown","\\xleftharpoonup","\\xrightleftharpoons","\\xleftrightharpoons","\\xlongequal","\\xtwoheadrightarrow","\\xtwoheadleftarrow","\\xtofrom","\\xrightleftarrows","\\xrightequilibrium","\\xleftequilibrium","\\\\cdrightarrow","\\\\cdleftarrow","\\\\cdlongequal"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n,funcName:i}=t;return{type:"xArrow",mode:n.mode,label:i,body:e[0],below:r[0]}},htmlBuilder(t,e){var r=e.style,n=e.havingStyle(r.sup()),i=Be.wrapFragment(Fr(t.body,n,e),e),a=t.label.slice(0,2)==="\\x"?"x":"cd";i.classes.push(a+"-arrow-pad");var s;t.below&&(n=e.havingStyle(r.sub()),s=Be.wrapFragment(Fr(t.below,n,e),e),s.classes.push(a+"-arrow-pad"));var l=cu.svgSpan(t,e),u=-e.fontMetrics().axisHeight+.5*l.height,h=-e.fontMetrics().axisHeight-.5*l.height-.111;(i.depth>.25||t.label==="\\xleftequilibrium")&&(h-=i.depth);var f;if(s){var d=-e.fontMetrics().axisHeight+s.height+.5*l.height+.111;f=Be.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u},{type:"elem",elem:s,shift:d}]},e)}else f=Be.makeVList({positionType:"individualShift",children:[{type:"elem",elem:i,shift:h},{type:"elem",elem:l,shift:u}]},e);return f.children[0].children[0].children[1].classes.push("svg-align"),Be.makeSpan(["mrel","x-arrow"],[f],e)},mathmlBuilder(t,e){var r=cu.mathMLnode(t.label);r.setAttribute("minsize",t.label.charAt(0)==="x"?"1.75em":"3.0em");var n;if(t.body){var i=s3(yn(t.body,e));if(t.below){var a=s3(yn(t.below,e));n=new dt.MathNode("munderover",[r,a,i])}else n=new dt.MathNode("mover",[r,i])}else if(t.below){var s=s3(yn(t.below,e));n=new dt.MathNode("munder",[r,s])}else n=s3(),n=new dt.MathNode("mover",[r,n]);return n}});u4e=Be.makeSpan;o(tG,"htmlBuilder$9");o(rG,"mathmlBuilder$8");Nt({type:"mclass",names:["\\mathord","\\mathbin","\\mathrel","\\mathopen","\\mathclose","\\mathpunct","\\mathinner"],props:{numArgs:1,primitive:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"mclass",mode:r.mode,mclass:"m"+n.slice(5),body:di(i),isCharacterBox:Jt.isCharacterBox(i)}},htmlBuilder:tG,mathmlBuilder:rG});T3=o(t=>{var e=t.type==="ordgroup"&&t.body.length?t.body[0]:t;return e.type==="atom"&&(e.family==="bin"||e.family==="rel")?"m"+e.family:"mord"},"binrelClass");Nt({type:"mclass",names:["\\@binrel"],props:{numArgs:2},handler(t,e){var{parser:r}=t;return{type:"mclass",mode:r.mode,mclass:T3(e[0]),body:di(e[1]),isCharacterBox:Jt.isCharacterBox(e[1])}}});Nt({type:"mclass",names:["\\stackrel","\\overset","\\underset"],props:{numArgs:2},handler(t,e){var{parser:r,funcName:n}=t,i=e[1],a=e[0],s;n!=="\\stackrel"?s=T3(i):s="mrel";var l={type:"op",mode:i.mode,limits:!0,alwaysHandleSupSub:!0,parentIsSupSub:!1,symbol:!1,suppressBaseShift:n!=="\\stackrel",body:di(i)},u={type:"supsub",mode:a.mode,base:l,sup:n==="\\underset"?null:a,sub:n==="\\underset"?a:null};return{type:"mclass",mode:r.mode,mclass:s,body:[u],isCharacterBox:Jt.isCharacterBox(u)}},htmlBuilder:tG,mathmlBuilder:rG});Nt({type:"pmb",names:["\\pmb"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"pmb",mode:r.mode,mclass:T3(e[0]),body:di(e[0])}},htmlBuilder(t,e){var r=Pi(t.body,e,!0),n=Be.makeSpan([t.mclass],r,e);return n.style.textShadow="0.02em 0.01em 0.04px",n},mathmlBuilder(t,e){var r=ks(t.body,e),n=new dt.MathNode("mstyle",r);return n.setAttribute("style","text-shadow: 0.02em 0.01em 0.04px"),n}});h4e={">":"\\\\cdrightarrow","<":"\\\\cdleftarrow","=":"\\\\cdlongequal",A:"\\uparrow",V:"\\downarrow","|":"\\Vert",".":"no arrow"},yz=o(()=>({type:"styling",body:[],mode:"math",style:"display"}),"newCell"),vz=o(t=>t.type==="textord"&&t.text==="@","isStartOfArrow"),f4e=o((t,e)=>(t.type==="mathord"||t.type==="atom")&&t.text===e,"isLabelEnd");o(d4e,"cdArrow");o(p4e,"parseCD");Nt({type:"cdlabel",names:["\\\\cdleft","\\\\cdright"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"cdlabel",mode:r.mode,side:n.slice(4),label:e[0]}},htmlBuilder(t,e){var r=e.havingStyle(e.style.sup()),n=Be.wrapFragment(Fr(t.label,r,e),e);return n.classes.push("cd-label-"+t.side),n.style.bottom=kt(.8-n.depth),n.height=0,n.depth=0,n},mathmlBuilder(t,e){var r=new dt.MathNode("mrow",[yn(t.label,e)]);return r=new dt.MathNode("mpadded",[r]),r.setAttribute("width","0"),t.side==="left"&&r.setAttribute("lspace","-1width"),r.setAttribute("voffset","0.7em"),r=new dt.MathNode("mstyle",[r]),r.setAttribute("displaystyle","false"),r.setAttribute("scriptlevel","1"),r}});Nt({type:"cdlabelparent",names:["\\\\cdparent"],props:{numArgs:1},handler(t,e){var{parser:r}=t;return{type:"cdlabelparent",mode:r.mode,fragment:e[0]}},htmlBuilder(t,e){var r=Be.wrapFragment(Fr(t.fragment,e),e);return r.classes.push("cd-vert-arrow"),r},mathmlBuilder(t,e){return new dt.MathNode("mrow",[yn(t.fragment,e)])}});Nt({type:"textord",names:["\\@char"],props:{numArgs:1,allowedInText:!0},handler(t,e){for(var{parser:r}=t,n=xr(e[0],"ordgroup"),i=n.body,a="",s=0;s=1114111)throw new gt("\\@char with invalid code point "+a);return u<=65535?h=String.fromCharCode(u):(u-=65536,h=String.fromCharCode((u>>10)+55296,(u&1023)+56320)),{type:"textord",mode:r.mode,text:h}}});nG=o((t,e)=>{var r=Pi(t.body,e.withColor(t.color),!1);return Be.makeFragment(r)},"htmlBuilder$8"),iG=o((t,e)=>{var r=ks(t.body,e.withColor(t.color)),n=new dt.MathNode("mstyle",r);return n.setAttribute("mathcolor",t.color),n},"mathmlBuilder$7");Nt({type:"color",names:["\\textcolor"],props:{numArgs:2,allowedInText:!0,argTypes:["color","original"]},handler(t,e){var{parser:r}=t,n=xr(e[0],"color-token").color,i=e[1];return{type:"color",mode:r.mode,color:n,body:di(i)}},htmlBuilder:nG,mathmlBuilder:iG});Nt({type:"color",names:["\\color"],props:{numArgs:1,allowedInText:!0,argTypes:["color"]},handler(t,e){var{parser:r,breakOnTokenText:n}=t,i=xr(e[0],"color-token").color;r.gullet.macros.set("\\current@color",i);var a=r.parseExpression(!0,n);return{type:"color",mode:r.mode,color:i,body:a}},htmlBuilder:nG,mathmlBuilder:iG});Nt({type:"cr",names:["\\\\"],props:{numArgs:0,numOptionalArgs:0,allowedInText:!0},handler(t,e,r){var{parser:n}=t,i=n.gullet.future().text==="["?n.parseSizeGroup(!0):null,a=!n.settings.displayMode||!n.settings.useStrictBehavior("newLineInDisplayMode","In LaTeX, \\\\ or \\newline does nothing in display mode");return{type:"cr",mode:n.mode,newLine:a,size:i&&xr(i,"size").value}},htmlBuilder(t,e){var r=Be.makeSpan(["mspace"],[],e);return t.newLine&&(r.classes.push("newline"),t.size&&(r.style.marginTop=kt(ti(t.size,e)))),r},mathmlBuilder(t,e){var r=new dt.MathNode("mspace");return t.newLine&&(r.setAttribute("linebreak","newline"),t.size&&r.setAttribute("height",kt(ti(t.size,e)))),r}});L7={"\\global":"\\global","\\long":"\\\\globallong","\\\\globallong":"\\\\globallong","\\def":"\\gdef","\\gdef":"\\gdef","\\edef":"\\xdef","\\xdef":"\\xdef","\\let":"\\\\globallet","\\futurelet":"\\\\globalfuture"},aG=o(t=>{var e=t.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(e))throw new gt("Expected a control sequence",t);return e},"checkControlSequence"),m4e=o(t=>{var e=t.gullet.popToken();return e.text==="="&&(e=t.gullet.popToken(),e.text===" "&&(e=t.gullet.popToken())),e},"getRHS"),sG=o((t,e,r,n)=>{var i=t.gullet.macros.get(r.text);i==null&&(r.noexpand=!0,i={tokens:[r],numArgs:0,unexpandable:!t.gullet.isExpandable(r.text)}),t.gullet.macros.set(e,i,n)},"letCommand");Nt({type:"internal",names:["\\global","\\long","\\\\globallong"],props:{numArgs:0,allowedInText:!0},handler(t){var{parser:e,funcName:r}=t;e.consumeSpaces();var n=e.fetch();if(L7[n.text])return(r==="\\global"||r==="\\\\globallong")&&(n.text=L7[n.text]),xr(e.parseFunction(),"internal");throw new gt("Invalid token after macro prefix",n)}});Nt({type:"internal",names:["\\def","\\gdef","\\edef","\\xdef"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=e.gullet.popToken(),i=n.text;if(/^(?:[\\{}$&#^_]|EOF)$/.test(i))throw new gt("Expected a control sequence",n);for(var a=0,s,l=[[]];e.gullet.future().text!=="{";)if(n=e.gullet.popToken(),n.text==="#"){if(e.gullet.future().text==="{"){s=e.gullet.future(),l[a].push("{");break}if(n=e.gullet.popToken(),!/^[1-9]$/.test(n.text))throw new gt('Invalid argument number "'+n.text+'"');if(parseInt(n.text)!==a+1)throw new gt('Argument number "'+n.text+'" out of order');a++,l.push([])}else{if(n.text==="EOF")throw new gt("Expected a macro definition");l[a].push(n.text)}var{tokens:u}=e.gullet.consumeArg();return s&&u.unshift(s),(r==="\\edef"||r==="\\xdef")&&(u=e.gullet.expandTokens(u),u.reverse()),e.gullet.macros.set(i,{tokens:u,numArgs:a,delimiters:l},r===L7[r]),{type:"internal",mode:e.mode}}});Nt({type:"internal",names:["\\let","\\\\globallet"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=aG(e.gullet.popToken());e.gullet.consumeSpaces();var i=m4e(e);return sG(e,n,i,r==="\\\\globallet"),{type:"internal",mode:e.mode}}});Nt({type:"internal",names:["\\futurelet","\\\\globalfuture"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t){var{parser:e,funcName:r}=t,n=aG(e.gullet.popToken()),i=e.gullet.popToken(),a=e.gullet.popToken();return sG(e,n,a,r==="\\\\globalfuture"),e.gullet.pushToken(a),e.gullet.pushToken(i),{type:"internal",mode:e.mode}}});Fy=o(function(e,r,n){var i=An.math[e]&&An.math[e].replace,a=P7(i||e,r,n);if(!a)throw new Error("Unsupported symbol "+e+" and font size "+r+".");return a},"getMetrics"),V7=o(function(e,r,n,i){var a=n.havingBaseStyle(r),s=Be.makeSpan(i.concat(a.sizingClasses(n)),[e],n),l=a.sizeMultiplier/n.sizeMultiplier;return s.height*=l,s.depth*=l,s.maxFontSize=a.sizeMultiplier,s},"styleWrap"),oG=o(function(e,r,n){var i=r.havingBaseStyle(n),a=(1-r.sizeMultiplier/i.sizeMultiplier)*r.fontMetrics().axisHeight;e.classes.push("delimcenter"),e.style.top=kt(a),e.height-=a,e.depth+=a},"centerSpan"),g4e=o(function(e,r,n,i,a,s){var l=Be.makeSymbol(e,"Main-Regular",a,i),u=V7(l,r,i,s);return n&&oG(u,i,r),u},"makeSmallDelim"),y4e=o(function(e,r,n,i){return Be.makeSymbol(e,"Size"+r+"-Regular",n,i)},"mathrmSize"),lG=o(function(e,r,n,i,a,s){var l=y4e(e,r,a,i),u=V7(Be.makeSpan(["delimsizing","size"+r],[l],i),tr.TEXT,i,s);return n&&oG(u,i,tr.TEXT),u},"makeLargeDelim"),p7=o(function(e,r,n){var i;r==="Size1-Regular"?i="delim-size1":i="delim-size4";var a=Be.makeSpan(["delimsizinginner",i],[Be.makeSpan([],[Be.makeSymbol(e,r,n)])]);return{type:"elem",elem:a}},"makeGlyphSpan"),m7=o(function(e,r,n){var i=jl["Size4-Regular"][e.charCodeAt(0)]?jl["Size4-Regular"][e.charCodeAt(0)][4]:jl["Size1-Regular"][e.charCodeAt(0)][4],a=new Kl("inner",Sbe(e,Math.round(1e3*r))),s=new ll([a],{width:kt(i),height:kt(r),style:"width:"+kt(i),viewBox:"0 0 "+1e3*i+" "+Math.round(1e3*r),preserveAspectRatio:"xMinYMin"}),l=Be.makeSvgSpan([],[s],n);return l.height=r,l.style.height=kt(r),l.style.width=kt(i),{type:"elem",elem:l}},"makeInner"),R7=.008,o3={type:"kern",size:-1*R7},v4e=["|","\\lvert","\\rvert","\\vert"],x4e=["\\|","\\lVert","\\rVert","\\Vert"],cG=o(function(e,r,n,i,a,s){var l,u,h,f,d="",p=0;l=h=f=e,u=null;var m="Size1-Regular";e==="\\uparrow"?h=f="\u23D0":e==="\\Uparrow"?h=f="\u2016":e==="\\downarrow"?l=h="\u23D0":e==="\\Downarrow"?l=h="\u2016":e==="\\updownarrow"?(l="\\uparrow",h="\u23D0",f="\\downarrow"):e==="\\Updownarrow"?(l="\\Uparrow",h="\u2016",f="\\Downarrow"):Jt.contains(v4e,e)?(h="\u2223",d="vert",p=333):Jt.contains(x4e,e)?(h="\u2225",d="doublevert",p=556):e==="["||e==="\\lbrack"?(l="\u23A1",h="\u23A2",f="\u23A3",m="Size4-Regular",d="lbrack",p=667):e==="]"||e==="\\rbrack"?(l="\u23A4",h="\u23A5",f="\u23A6",m="Size4-Regular",d="rbrack",p=667):e==="\\lfloor"||e==="\u230A"?(h=l="\u23A2",f="\u23A3",m="Size4-Regular",d="lfloor",p=667):e==="\\lceil"||e==="\u2308"?(l="\u23A1",h=f="\u23A2",m="Size4-Regular",d="lceil",p=667):e==="\\rfloor"||e==="\u230B"?(h=l="\u23A5",f="\u23A6",m="Size4-Regular",d="rfloor",p=667):e==="\\rceil"||e==="\u2309"?(l="\u23A4",h=f="\u23A5",m="Size4-Regular",d="rceil",p=667):e==="("||e==="\\lparen"?(l="\u239B",h="\u239C",f="\u239D",m="Size4-Regular",d="lparen",p=875):e===")"||e==="\\rparen"?(l="\u239E",h="\u239F",f="\u23A0",m="Size4-Regular",d="rparen",p=875):e==="\\{"||e==="\\lbrace"?(l="\u23A7",u="\u23A8",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\}"||e==="\\rbrace"?(l="\u23AB",u="\u23AC",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lgroup"||e==="\u27EE"?(l="\u23A7",f="\u23A9",h="\u23AA",m="Size4-Regular"):e==="\\rgroup"||e==="\u27EF"?(l="\u23AB",f="\u23AD",h="\u23AA",m="Size4-Regular"):e==="\\lmoustache"||e==="\u23B0"?(l="\u23A7",f="\u23AD",h="\u23AA",m="Size4-Regular"):(e==="\\rmoustache"||e==="\u23B1")&&(l="\u23AB",f="\u23A9",h="\u23AA",m="Size4-Regular");var g=Fy(l,m,a),y=g.height+g.depth,v=Fy(h,m,a),x=v.height+v.depth,b=Fy(f,m,a),w=b.height+b.depth,C=0,T=1;if(u!==null){var E=Fy(u,m,a);C=E.height+E.depth,T=2}var A=y+w+C,S=Math.max(0,Math.ceil((r-A)/(T*x))),_=A+S*T*x,I=i.fontMetrics().axisHeight;n&&(I*=i.sizeMultiplier);var D=_/2-I,k=[];if(d.length>0){var L=_-y-w,R=Math.round(_*1e3),O=Cbe(d,Math.round(L*1e3)),M=new Kl(d,O),B=(p/1e3).toFixed(3)+"em",F=(R/1e3).toFixed(3)+"em",P=new ll([M],{width:B,height:F,viewBox:"0 0 "+p+" "+R}),z=Be.makeSvgSpan([],[P],i);z.height=R/1e3,z.style.width=B,z.style.height=F,k.push({type:"elem",elem:z})}else{if(k.push(p7(f,m,a)),k.push(o3),u===null){var $=_-y-w+2*R7;k.push(m7(h,$,i))}else{var H=(_-y-w-C)/2+2*R7;k.push(m7(h,H,i)),k.push(o3),k.push(p7(u,m,a)),k.push(o3),k.push(m7(h,H,i))}k.push(o3),k.push(p7(l,m,a))}var Q=i.havingBaseStyle(tr.TEXT),j=Be.makeVList({positionType:"bottom",positionData:D,children:k},Q);return V7(Be.makeSpan(["delimsizing","mult"],[j],Q),tr.TEXT,i,s)},"makeStackedDelim"),g7=80,y7=.08,v7=o(function(e,r,n,i,a){var s=Ebe(e,i,n),l=new Kl(e,s),u=new ll([l],{width:"400em",height:kt(r),viewBox:"0 0 400000 "+n,preserveAspectRatio:"xMinYMin slice"});return Be.makeSvgSpan(["hide-tail"],[u],a)},"sqrtSvg"),b4e=o(function(e,r){var n=r.havingBaseSizing(),i=dG("\\surd",e*n.sizeMultiplier,fG,n),a=n.sizeMultiplier,s=Math.max(0,r.minRuleThickness-r.fontMetrics().sqrtRuleThickness),l,u=0,h=0,f=0,d;return i.type==="small"?(f=1e3+1e3*s+g7,e<1?a=1:e<1.4&&(a=.7),u=(1+s+y7)/a,h=(1+s)/a,l=v7("sqrtMain",u,f,s,r),l.style.minWidth="0.853em",d=.833/a):i.type==="large"?(f=(1e3+g7)*$y[i.size],h=($y[i.size]+s)/a,u=($y[i.size]+s+y7)/a,l=v7("sqrtSize"+i.size,u,f,s,r),l.style.minWidth="1.02em",d=1/a):(u=e+s+y7,h=e+s,f=Math.floor(1e3*e+s)+g7,l=v7("sqrtTall",u,f,s,r),l.style.minWidth="0.742em",d=1.056),l.height=h,l.style.height=kt(u),{span:l,advanceWidth:d,ruleWidth:(r.fontMetrics().sqrtRuleThickness+s)*a}},"makeSqrtImage"),uG=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","\\surd"],w4e=["\\uparrow","\\downarrow","\\updownarrow","\\Uparrow","\\Downarrow","\\Updownarrow","|","\\|","\\vert","\\Vert","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1"],hG=["<",">","\\langle","\\rangle","/","\\backslash","\\lt","\\gt"],$y=[0,1.2,1.8,2.4,3],T4e=o(function(e,r,n,i,a){if(e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle"),Jt.contains(uG,e)||Jt.contains(hG,e))return lG(e,r,!1,n,i,a);if(Jt.contains(w4e,e))return cG(e,$y[r],!1,n,i,a);throw new gt("Illegal delimiter: '"+e+"'")},"makeSizedDelim"),k4e=[{type:"small",style:tr.SCRIPTSCRIPT},{type:"small",style:tr.SCRIPT},{type:"small",style:tr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4}],E4e=[{type:"small",style:tr.SCRIPTSCRIPT},{type:"small",style:tr.SCRIPT},{type:"small",style:tr.TEXT},{type:"stack"}],fG=[{type:"small",style:tr.SCRIPTSCRIPT},{type:"small",style:tr.SCRIPT},{type:"small",style:tr.TEXT},{type:"large",size:1},{type:"large",size:2},{type:"large",size:3},{type:"large",size:4},{type:"stack"}],S4e=o(function(e){if(e.type==="small")return"Main-Regular";if(e.type==="large")return"Size"+e.size+"-Regular";if(e.type==="stack")return"Size4-Regular";throw new Error("Add support for delim type '"+e.type+"' here.")},"delimTypeToFont"),dG=o(function(e,r,n,i){for(var a=Math.min(2,3-i.style.size),s=a;sr)return n[s]}return n[n.length-1]},"traverseSequence"),pG=o(function(e,r,n,i,a,s){e==="<"||e==="\\lt"||e==="\u27E8"?e="\\langle":(e===">"||e==="\\gt"||e==="\u27E9")&&(e="\\rangle");var l;Jt.contains(hG,e)?l=k4e:Jt.contains(uG,e)?l=fG:l=E4e;var u=dG(e,r,l,i);return u.type==="small"?g4e(e,u.style,n,i,a,s):u.type==="large"?lG(e,u.size,n,i,a,s):cG(e,r,n,i,a,s)},"makeCustomSizedDelim"),C4e=o(function(e,r,n,i,a,s){var l=i.fontMetrics().axisHeight*i.sizeMultiplier,u=901,h=5/i.fontMetrics().ptPerEm,f=Math.max(r-l,n+l),d=Math.max(f/500*u,2*f-h);return pG(e,d,!0,i,a,s)},"makeLeftRightDelim"),ou={sqrtImage:b4e,sizedDelim:T4e,sizeToMaxHeight:$y,customSizedDelim:pG,leftRightDelim:C4e},xz={"\\bigl":{mclass:"mopen",size:1},"\\Bigl":{mclass:"mopen",size:2},"\\biggl":{mclass:"mopen",size:3},"\\Biggl":{mclass:"mopen",size:4},"\\bigr":{mclass:"mclose",size:1},"\\Bigr":{mclass:"mclose",size:2},"\\biggr":{mclass:"mclose",size:3},"\\Biggr":{mclass:"mclose",size:4},"\\bigm":{mclass:"mrel",size:1},"\\Bigm":{mclass:"mrel",size:2},"\\biggm":{mclass:"mrel",size:3},"\\Biggm":{mclass:"mrel",size:4},"\\big":{mclass:"mord",size:1},"\\Big":{mclass:"mord",size:2},"\\bigg":{mclass:"mord",size:3},"\\Bigg":{mclass:"mord",size:4}},A4e=["(","\\lparen",")","\\rparen","[","\\lbrack","]","\\rbrack","\\{","\\lbrace","\\}","\\rbrace","\\lfloor","\\rfloor","\u230A","\u230B","\\lceil","\\rceil","\u2308","\u2309","<",">","\\langle","\u27E8","\\rangle","\u27E9","\\lt","\\gt","\\lvert","\\rvert","\\lVert","\\rVert","\\lgroup","\\rgroup","\u27EE","\u27EF","\\lmoustache","\\rmoustache","\u23B0","\u23B1","/","\\backslash","|","\\vert","\\|","\\Vert","\\uparrow","\\Uparrow","\\downarrow","\\Downarrow","\\updownarrow","\\Updownarrow","."];o(k3,"checkDelimiter");Nt({type:"delimsizing",names:["\\bigl","\\Bigl","\\biggl","\\Biggl","\\bigr","\\Bigr","\\biggr","\\Biggr","\\bigm","\\Bigm","\\biggm","\\Biggm","\\big","\\Big","\\bigg","\\Bigg"],props:{numArgs:1,argTypes:["primitive"]},handler:o((t,e)=>{var r=k3(e[0],t);return{type:"delimsizing",mode:t.parser.mode,size:xz[t.funcName].size,mclass:xz[t.funcName].mclass,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>t.delim==="."?Be.makeSpan([t.mclass]):ou.sizedDelim(t.delim,t.size,e,t.mode,[t.mclass]),"htmlBuilder"),mathmlBuilder:o(t=>{var e=[];t.delim!=="."&&e.push(Co(t.delim,t.mode));var r=new dt.MathNode("mo",e);t.mclass==="mopen"||t.mclass==="mclose"?r.setAttribute("fence","true"):r.setAttribute("fence","false"),r.setAttribute("stretchy","true");var n=kt(ou.sizeToMaxHeight[t.size]);return r.setAttribute("minsize",n),r.setAttribute("maxsize",n),r},"mathmlBuilder")});o(bz,"assertParsed");Nt({type:"leftright-right",names:["\\right"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=t.parser.gullet.macros.get("\\current@color");if(r&&typeof r!="string")throw new gt("\\current@color set to non-string in \\right");return{type:"leftright-right",mode:t.parser.mode,delim:k3(e[0],t).text,color:r}},"handler")});Nt({type:"leftright",names:["\\left"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=k3(e[0],t),n=t.parser;++n.leftrightDepth;var i=n.parseExpression(!1);--n.leftrightDepth,n.expect("\\right",!1);var a=xr(n.parseFunction(),"leftright-right");return{type:"leftright",mode:n.mode,body:i,left:r.text,right:a.delim,rightColor:a.color}},"handler"),htmlBuilder:o((t,e)=>{bz(t);for(var r=Pi(t.body,e,!0,["mopen","mclose"]),n=0,i=0,a=!1,s=0;s{bz(t);var r=ks(t.body,e);if(t.left!=="."){var n=new dt.MathNode("mo",[Co(t.left,t.mode)]);n.setAttribute("fence","true"),r.unshift(n)}if(t.right!=="."){var i=new dt.MathNode("mo",[Co(t.right,t.mode)]);i.setAttribute("fence","true"),t.rightColor&&i.setAttribute("mathcolor",t.rightColor),r.push(i)}return F7(r)},"mathmlBuilder")});Nt({type:"middle",names:["\\middle"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var r=k3(e[0],t);if(!t.parser.leftrightDepth)throw new gt("\\middle without preceding \\left",r);return{type:"middle",mode:t.parser.mode,delim:r.text}},"handler"),htmlBuilder:o((t,e)=>{var r;if(t.delim===".")r=Hy(e,[]);else{r=ou.sizedDelim(t.delim,1,e,t.mode,[]);var n={delim:t.delim,options:e};r.isMiddle=n}return r},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=t.delim==="\\vert"||t.delim==="|"?Co("|","text"):Co(t.delim,t.mode),n=new dt.MathNode("mo",[r]);return n.setAttribute("fence","true"),n.setAttribute("lspace","0.05em"),n.setAttribute("rspace","0.05em"),n},"mathmlBuilder")});U7=o((t,e)=>{var r=Be.wrapFragment(Fr(t.body,e),e),n=t.label.slice(1),i=e.sizeMultiplier,a,s=0,l=Jt.isCharacterBox(t.body);if(n==="sout")a=Be.makeSpan(["stretchy","sout"]),a.height=e.fontMetrics().defaultRuleThickness/i,s=-.5*e.fontMetrics().xHeight;else if(n==="phase"){var u=ti({number:.6,unit:"pt"},e),h=ti({number:.35,unit:"ex"},e),f=e.havingBaseSizing();i=i/f.sizeMultiplier;var d=r.height+r.depth+u+h;r.style.paddingLeft=kt(d/2+u);var p=Math.floor(1e3*d*i),m=Tbe(p),g=new ll([new Kl("phase",m)],{width:"400em",height:kt(p/1e3),viewBox:"0 0 400000 "+p,preserveAspectRatio:"xMinYMin slice"});a=Be.makeSvgSpan(["hide-tail"],[g],e),a.style.height=kt(d),s=r.depth+u+h}else{/cancel/.test(n)?l||r.classes.push("cancel-pad"):n==="angl"?r.classes.push("anglpad"):r.classes.push("boxpad");var y=0,v=0,x=0;/box/.test(n)?(x=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness),y=e.fontMetrics().fboxsep+(n==="colorbox"?0:x),v=y):n==="angl"?(x=Math.max(e.fontMetrics().defaultRuleThickness,e.minRuleThickness),y=4*x,v=Math.max(0,.25-r.depth)):(y=l?.2:0,v=y),a=cu.encloseSpan(r,n,y,v,e),/fbox|boxed|fcolorbox/.test(n)?(a.style.borderStyle="solid",a.style.borderWidth=kt(x)):n==="angl"&&x!==.049&&(a.style.borderTopWidth=kt(x),a.style.borderRightWidth=kt(x)),s=r.depth+v,t.backgroundColor&&(a.style.backgroundColor=t.backgroundColor,t.borderColor&&(a.style.borderColor=t.borderColor))}var b;if(t.backgroundColor)b=Be.makeVList({positionType:"individualShift",children:[{type:"elem",elem:a,shift:s},{type:"elem",elem:r,shift:0}]},e);else{var w=/cancel|phase/.test(n)?["svg-align"]:[];b=Be.makeVList({positionType:"individualShift",children:[{type:"elem",elem:r,shift:0},{type:"elem",elem:a,shift:s,wrapperClasses:w}]},e)}return/cancel/.test(n)&&(b.height=r.height,b.depth=r.depth),/cancel/.test(n)&&!l?Be.makeSpan(["mord","cancel-lap"],[b],e):Be.makeSpan(["mord"],[b],e)},"htmlBuilder$7"),H7=o((t,e)=>{var r=0,n=new dt.MathNode(t.label.indexOf("colorbox")>-1?"mpadded":"menclose",[yn(t.body,e)]);switch(t.label){case"\\cancel":n.setAttribute("notation","updiagonalstrike");break;case"\\bcancel":n.setAttribute("notation","downdiagonalstrike");break;case"\\phase":n.setAttribute("notation","phasorangle");break;case"\\sout":n.setAttribute("notation","horizontalstrike");break;case"\\fbox":n.setAttribute("notation","box");break;case"\\angl":n.setAttribute("notation","actuarial");break;case"\\fcolorbox":case"\\colorbox":if(r=e.fontMetrics().fboxsep*e.fontMetrics().ptPerEm,n.setAttribute("width","+"+2*r+"pt"),n.setAttribute("height","+"+2*r+"pt"),n.setAttribute("lspace",r+"pt"),n.setAttribute("voffset",r+"pt"),t.label==="\\fcolorbox"){var i=Math.max(e.fontMetrics().fboxrule,e.minRuleThickness);n.setAttribute("style","border: "+i+"em solid "+String(t.borderColor))}break;case"\\xcancel":n.setAttribute("notation","updiagonalstrike downdiagonalstrike");break}return t.backgroundColor&&n.setAttribute("mathbackground",t.backgroundColor),n},"mathmlBuilder$6");Nt({type:"enclose",names:["\\colorbox"],props:{numArgs:2,allowedInText:!0,argTypes:["color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=xr(e[0],"color-token").color,s=e[1];return{type:"enclose",mode:n.mode,label:i,backgroundColor:a,body:s}},htmlBuilder:U7,mathmlBuilder:H7});Nt({type:"enclose",names:["\\fcolorbox"],props:{numArgs:3,allowedInText:!0,argTypes:["color","color","text"]},handler(t,e,r){var{parser:n,funcName:i}=t,a=xr(e[0],"color-token").color,s=xr(e[1],"color-token").color,l=e[2];return{type:"enclose",mode:n.mode,label:i,backgroundColor:s,borderColor:a,body:l}},htmlBuilder:U7,mathmlBuilder:H7});Nt({type:"enclose",names:["\\fbox"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\fbox",body:e[0]}}});Nt({type:"enclose",names:["\\cancel","\\bcancel","\\xcancel","\\sout","\\phase"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"enclose",mode:r.mode,label:n,body:i}},htmlBuilder:U7,mathmlBuilder:H7});Nt({type:"enclose",names:["\\angl"],props:{numArgs:1,argTypes:["hbox"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"enclose",mode:r.mode,label:"\\angl",body:e[0]}}});mG={};o(Ql,"defineEnvironment");gG={};o(fe,"defineMacro");o(wz,"getHLines");E3=o(t=>{var e=t.parser.settings;if(!e.displayMode)throw new gt("{"+t.envName+"} can be used only in display mode.")},"validateAmsEnvironmentContext");o(W7,"getAutoTag");o(ph,"parseArray");o(q7,"dCellStyle");Zl=o(function(e,r){var n,i,a=e.body.length,s=e.hLinesBeforeRow,l=0,u=new Array(a),h=[],f=Math.max(r.fontMetrics().arrayRuleWidth,r.minRuleThickness),d=1/r.fontMetrics().ptPerEm,p=5*d;if(e.colSeparationType&&e.colSeparationType==="small"){var m=r.havingStyle(tr.SCRIPT).sizeMultiplier;p=.2778*(m/r.sizeMultiplier)}var g=e.colSeparationType==="CD"?ti({number:3,unit:"ex"},r):12*d,y=3*d,v=e.arraystretch*g,x=.7*v,b=.3*v,w=0;function C(ae){for(var Oe=0;Oe0&&(w+=.25),h.push({pos:w,isDashed:ae[Oe]})}for(o(C,"setHLinePos"),C(s[0]),n=0;n0&&(D+=b,Aae))for(n=0;n=l)){var le=void 0;(i>0||e.hskipBeforeAndAfter)&&(le=Jt.deflt(H.pregap,p),le!==0&&(O=Be.makeSpan(["arraycolsep"],[]),O.style.width=kt(le),R.push(O)));var he=[];for(n=0;n0){for(var J=Be.makeLineSpan("hline",r,f),se=Be.makeLineSpan("hdashline",r,f),ue=[{type:"elem",elem:u,shift:0}];h.length>0;){var Z=h.pop(),Se=Z.pos-k;Z.isDashed?ue.push({type:"elem",elem:se,shift:Se}):ue.push({type:"elem",elem:J,shift:Se})}u=Be.makeVList({positionType:"individualShift",children:ue},r)}if(B.length===0)return Be.makeSpan(["mord"],[u],r);var ce=Be.makeVList({positionType:"individualShift",children:B},r);return ce=Be.makeSpan(["tag"],[ce],r),Be.makeFragment([u,ce])},"htmlBuilder"),_4e={c:"center ",l:"left ",r:"right "},Jl=o(function(e,r){for(var n=[],i=new dt.MathNode("mtd",[],["mtr-glue"]),a=new dt.MathNode("mtd",[],["mml-eqn-num"]),s=0;s0){var g=e.cols,y="",v=!1,x=0,b=g.length;g[0].type==="separator"&&(p+="top ",x=1),g[g.length-1].type==="separator"&&(p+="bottom ",b-=1);for(var w=x;w0?"left ":"",p+=S[S.length-1].length>0?"right ":"";for(var _=1;_-1?"alignat":"align",a=e.envName==="split",s=ph(e.parser,{cols:n,addJot:!0,autoTag:a?void 0:W7(e.envName),emptySingleRow:!0,colSeparationType:i,maxNumCols:a?2:void 0,leqno:e.parser.settings.leqno},"display"),l,u=0,h={type:"ordgroup",mode:e.mode,body:[]};if(r[0]&&r[0].type==="ordgroup"){for(var f="",d=0;d0&&m&&(v=1),n[g]={type:"align",align:y,pregap:v,postgap:0}}return s.colSeparationType=m?"align":"alignat",s},"alignedHandler");Ql({type:"array",names:["array","darray"],props:{numArgs:1},handler(t,e){var r=w3(e[0]),n=r?[e[0]]:xr(e[0],"ordgroup").body,i=n.map(function(s){var l=z7(s),u=l.text;if("lcr".indexOf(u)!==-1)return{type:"align",align:u};if(u==="|")return{type:"separator",separator:"|"};if(u===":")return{type:"separator",separator:":"};throw new gt("Unknown column alignment: "+u,s)}),a={cols:i,hskipBeforeAndAfter:!0,maxNumCols:i.length};return ph(t.parser,a,q7(t.envName))},htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["matrix","pmatrix","bmatrix","Bmatrix","vmatrix","Vmatrix","matrix*","pmatrix*","bmatrix*","Bmatrix*","vmatrix*","Vmatrix*"],props:{numArgs:0},handler(t){var e={matrix:null,pmatrix:["(",")"],bmatrix:["[","]"],Bmatrix:["\\{","\\}"],vmatrix:["|","|"],Vmatrix:["\\Vert","\\Vert"]}[t.envName.replace("*","")],r="c",n={hskipBeforeAndAfter:!1,cols:[{type:"align",align:r}]};if(t.envName.charAt(t.envName.length-1)==="*"){var i=t.parser;if(i.consumeSpaces(),i.fetch().text==="["){if(i.consume(),i.consumeSpaces(),r=i.fetch().text,"lcr".indexOf(r)===-1)throw new gt("Expected l or c or r",i.nextToken);i.consume(),i.consumeSpaces(),i.expect("]"),i.consume(),n.cols=[{type:"align",align:r}]}}var a=ph(t.parser,n,q7(t.envName)),s=Math.max(0,...a.body.map(l=>l.length));return a.cols=new Array(s).fill({type:"align",align:r}),e?{type:"leftright",mode:t.mode,body:[a],left:e[0],right:e[1],rightColor:void 0}:a},htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["smallmatrix"],props:{numArgs:0},handler(t){var e={arraystretch:.5},r=ph(t.parser,e,"script");return r.colSeparationType="small",r},htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["subarray"],props:{numArgs:1},handler(t,e){var r=w3(e[0]),n=r?[e[0]]:xr(e[0],"ordgroup").body,i=n.map(function(s){var l=z7(s),u=l.text;if("lc".indexOf(u)!==-1)return{type:"align",align:u};throw new gt("Unknown column alignment: "+u,s)});if(i.length>1)throw new gt("{subarray} can contain only one column");var a={cols:i,hskipBeforeAndAfter:!1,arraystretch:.5};if(a=ph(t.parser,a,"script"),a.body.length>0&&a.body[0].length>1)throw new gt("{subarray} can contain only one column");return a},htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["cases","dcases","rcases","drcases"],props:{numArgs:0},handler(t){var e={arraystretch:1.2,cols:[{type:"align",align:"l",pregap:0,postgap:1},{type:"align",align:"l",pregap:0,postgap:0}]},r=ph(t.parser,e,q7(t.envName));return{type:"leftright",mode:t.mode,body:[r],left:t.envName.indexOf("r")>-1?".":"\\{",right:t.envName.indexOf("r")>-1?"\\}":".",rightColor:void 0}},htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["align","align*","aligned","split"],props:{numArgs:0},handler:yG,htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["gathered","gather","gather*"],props:{numArgs:0},handler(t){Jt.contains(["gather","gather*"],t.envName)&&E3(t);var e={cols:[{type:"align",align:"c"}],addJot:!0,colSeparationType:"gather",autoTag:W7(t.envName),emptySingleRow:!0,leqno:t.parser.settings.leqno};return ph(t.parser,e,"display")},htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["alignat","alignat*","alignedat"],props:{numArgs:1},handler:yG,htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["equation","equation*"],props:{numArgs:0},handler(t){E3(t);var e={autoTag:W7(t.envName),emptySingleRow:!0,singleRow:!0,maxNumCols:1,leqno:t.parser.settings.leqno};return ph(t.parser,e,"display")},htmlBuilder:Zl,mathmlBuilder:Jl});Ql({type:"array",names:["CD"],props:{numArgs:0},handler(t){return E3(t),p4e(t.parser)},htmlBuilder:Zl,mathmlBuilder:Jl});fe("\\nonumber","\\gdef\\@eqnsw{0}");fe("\\notag","\\nonumber");Nt({type:"text",names:["\\hline","\\hdashline"],props:{numArgs:0,allowedInText:!0,allowedInMath:!0},handler(t,e){throw new gt(t.funcName+" valid only within array environment")}});Tz=mG;Nt({type:"environment",names:["\\begin","\\end"],props:{numArgs:1,argTypes:["text"]},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];if(i.type!=="ordgroup")throw new gt("Invalid environment name",i);for(var a="",s=0;s{var r=t.font,n=e.withFont(r);return Fr(t.body,n)},"htmlBuilder$5"),xG=o((t,e)=>{var r=t.font,n=e.withFont(r);return yn(t.body,n)},"mathmlBuilder$4"),kz={"\\Bbb":"\\mathbb","\\bold":"\\mathbf","\\frak":"\\mathfrak","\\bm":"\\boldsymbol"};Nt({type:"font",names:["\\mathrm","\\mathit","\\mathbf","\\mathnormal","\\mathbb","\\mathcal","\\mathfrak","\\mathscr","\\mathsf","\\mathtt","\\Bbb","\\bold","\\frak"],props:{numArgs:1,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=g3(e[0]),a=n;return a in kz&&(a=kz[a]),{type:"font",mode:r.mode,font:a.slice(1),body:i}},"handler"),htmlBuilder:vG,mathmlBuilder:xG});Nt({type:"mclass",names:["\\boldsymbol","\\bm"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r}=t,n=e[0],i=Jt.isCharacterBox(n);return{type:"mclass",mode:r.mode,mclass:T3(n),body:[{type:"font",mode:r.mode,font:"boldsymbol",body:n}],isCharacterBox:i}},"handler")});Nt({type:"font",names:["\\rm","\\sf","\\tt","\\bf","\\it","\\cal"],props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n,breakOnTokenText:i}=t,{mode:a}=r,s=r.parseExpression(!0,i),l="math"+n.slice(1);return{type:"font",mode:a,font:l,body:{type:"ordgroup",mode:r.mode,body:s}}},"handler"),htmlBuilder:vG,mathmlBuilder:xG});bG=o((t,e)=>{var r=e;return t==="display"?r=r.id>=tr.SCRIPT.id?r.text():tr.DISPLAY:t==="text"&&r.size===tr.DISPLAY.size?r=tr.TEXT:t==="script"?r=tr.SCRIPT:t==="scriptscript"&&(r=tr.SCRIPTSCRIPT),r},"adjustStyle"),Y7=o((t,e)=>{var r=bG(t.size,e.style),n=r.fracNum(),i=r.fracDen(),a;a=e.havingStyle(n);var s=Fr(t.numer,a,e);if(t.continued){var l=8.5/e.fontMetrics().ptPerEm,u=3.5/e.fontMetrics().ptPerEm;s.height=s.height0?g=3*p:g=7*p,y=e.fontMetrics().denom1):(d>0?(m=e.fontMetrics().num2,g=p):(m=e.fontMetrics().num3,g=3*p),y=e.fontMetrics().denom2);var v;if(f){var b=e.fontMetrics().axisHeight;m-s.depth-(b+.5*d){var r=new dt.MathNode("mfrac",[yn(t.numer,e),yn(t.denom,e)]);if(!t.hasBarLine)r.setAttribute("linethickness","0px");else if(t.barSize){var n=ti(t.barSize,e);r.setAttribute("linethickness",kt(n))}var i=bG(t.size,e.style);if(i.size!==e.style.size){r=new dt.MathNode("mstyle",[r]);var a=i.size===tr.DISPLAY.size?"true":"false";r.setAttribute("displaystyle",a),r.setAttribute("scriptlevel","0")}if(t.leftDelim!=null||t.rightDelim!=null){var s=[];if(t.leftDelim!=null){var l=new dt.MathNode("mo",[new dt.TextNode(t.leftDelim.replace("\\",""))]);l.setAttribute("fence","true"),s.push(l)}if(s.push(r),t.rightDelim!=null){var u=new dt.MathNode("mo",[new dt.TextNode(t.rightDelim.replace("\\",""))]);u.setAttribute("fence","true"),s.push(u)}return F7(s)}return r},"mathmlBuilder$3");Nt({type:"genfrac",names:["\\dfrac","\\frac","\\tfrac","\\dbinom","\\binom","\\tbinom","\\\\atopfrac","\\\\bracefrac","\\\\brackfrac"],props:{numArgs:2,allowedInArgument:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1],s,l=null,u=null,h="auto";switch(n){case"\\dfrac":case"\\frac":case"\\tfrac":s=!0;break;case"\\\\atopfrac":s=!1;break;case"\\dbinom":case"\\binom":case"\\tbinom":s=!1,l="(",u=")";break;case"\\\\bracefrac":s=!1,l="\\{",u="\\}";break;case"\\\\brackfrac":s=!1,l="[",u="]";break;default:throw new Error("Unrecognized genfrac command")}switch(n){case"\\dfrac":case"\\dbinom":h="display";break;case"\\tfrac":case"\\tbinom":h="text";break}return{type:"genfrac",mode:r.mode,continued:!1,numer:i,denom:a,hasBarLine:s,leftDelim:l,rightDelim:u,size:h,barSize:null}},"handler"),htmlBuilder:Y7,mathmlBuilder:X7});Nt({type:"genfrac",names:["\\cfrac"],props:{numArgs:2},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=e[1];return{type:"genfrac",mode:r.mode,continued:!0,numer:i,denom:a,hasBarLine:!0,leftDelim:null,rightDelim:null,size:"display",barSize:null}},"handler")});Nt({type:"infix",names:["\\over","\\choose","\\atop","\\brace","\\brack"],props:{numArgs:0,infix:!0},handler(t){var{parser:e,funcName:r,token:n}=t,i;switch(r){case"\\over":i="\\frac";break;case"\\choose":i="\\binom";break;case"\\atop":i="\\\\atopfrac";break;case"\\brace":i="\\\\bracefrac";break;case"\\brack":i="\\\\brackfrac";break;default:throw new Error("Unrecognized infix genfrac command")}return{type:"infix",mode:e.mode,replaceWith:i,token:n}}});Ez=["display","text","script","scriptscript"],Sz=o(function(e){var r=null;return e.length>0&&(r=e,r=r==="."?null:r),r},"delimFromValue");Nt({type:"genfrac",names:["\\genfrac"],props:{numArgs:6,allowedInArgument:!0,argTypes:["math","math","size","text","math","math"]},handler(t,e){var{parser:r}=t,n=e[4],i=e[5],a=g3(e[0]),s=a.type==="atom"&&a.family==="open"?Sz(a.text):null,l=g3(e[1]),u=l.type==="atom"&&l.family==="close"?Sz(l.text):null,h=xr(e[2],"size"),f,d=null;h.isBlank?f=!0:(d=h.value,f=d.number>0);var p="auto",m=e[3];if(m.type==="ordgroup"){if(m.body.length>0){var g=xr(m.body[0],"textord");p=Ez[Number(g.text)]}}else m=xr(m,"textord"),p=Ez[Number(m.text)];return{type:"genfrac",mode:r.mode,numer:n,denom:i,continued:!1,hasBarLine:f,barSize:d,leftDelim:s,rightDelim:u,size:p}},htmlBuilder:Y7,mathmlBuilder:X7});Nt({type:"infix",names:["\\above"],props:{numArgs:1,argTypes:["size"],infix:!0},handler(t,e){var{parser:r,funcName:n,token:i}=t;return{type:"infix",mode:r.mode,replaceWith:"\\\\abovefrac",size:xr(e[0],"size").value,token:i}}});Nt({type:"genfrac",names:["\\\\abovefrac"],props:{numArgs:3,argTypes:["math","size","math"]},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0],a=obe(xr(e[1],"infix").size),s=e[2],l=a.number>0;return{type:"genfrac",mode:r.mode,numer:i,denom:s,continued:!1,hasBarLine:l,barSize:a,leftDelim:null,rightDelim:null,size:"auto"}},"handler"),htmlBuilder:Y7,mathmlBuilder:X7});wG=o((t,e)=>{var r=e.style,n,i;t.type==="supsub"?(n=t.sup?Fr(t.sup,e.havingStyle(r.sup()),e):Fr(t.sub,e.havingStyle(r.sub()),e),i=xr(t.base,"horizBrace")):i=xr(t,"horizBrace");var a=Fr(i.base,e.havingBaseStyle(tr.DISPLAY)),s=cu.svgSpan(i,e),l;if(i.isOver?(l=Be.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:a},{type:"kern",size:.1},{type:"elem",elem:s}]},e),l.children[0].children[0].children[1].classes.push("svg-align")):(l=Be.makeVList({positionType:"bottom",positionData:a.depth+.1+s.height,children:[{type:"elem",elem:s},{type:"kern",size:.1},{type:"elem",elem:a}]},e),l.children[0].children[0].children[0].classes.push("svg-align")),n){var u=Be.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e);i.isOver?l=Be.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:u},{type:"kern",size:.2},{type:"elem",elem:n}]},e):l=Be.makeVList({positionType:"bottom",positionData:u.depth+.2+n.height+n.depth,children:[{type:"elem",elem:n},{type:"kern",size:.2},{type:"elem",elem:u}]},e)}return Be.makeSpan(["mord",i.isOver?"mover":"munder"],[l],e)},"htmlBuilder$3"),D4e=o((t,e)=>{var r=cu.mathMLnode(t.label);return new dt.MathNode(t.isOver?"mover":"munder",[yn(t.base,e),r])},"mathmlBuilder$2");Nt({type:"horizBrace",names:["\\overbrace","\\underbrace"],props:{numArgs:1},handler(t,e){var{parser:r,funcName:n}=t;return{type:"horizBrace",mode:r.mode,label:n,isOver:/^\\over/.test(n),base:e[0]}},htmlBuilder:wG,mathmlBuilder:D4e});Nt({type:"href",names:["\\href"],props:{numArgs:2,argTypes:["url","original"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[1],i=xr(e[0],"url").url;return r.settings.isTrusted({command:"\\href",url:i})?{type:"href",mode:r.mode,href:i,body:di(n)}:r.formatUnsupportedCmd("\\href")},"handler"),htmlBuilder:o((t,e)=>{var r=Pi(t.body,e,!1);return Be.makeAnchor(t.href,[],r,e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=dh(t.body,e);return r instanceof ws||(r=new ws("mrow",[r])),r.setAttribute("href",t.href),r},"mathmlBuilder")});Nt({type:"href",names:["\\url"],props:{numArgs:1,argTypes:["url"],allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=xr(e[0],"url").url;if(!r.settings.isTrusted({command:"\\url",url:n}))return r.formatUnsupportedCmd("\\url");for(var i=[],a=0;a{var{parser:r,funcName:n,token:i}=t,a=xr(e[0],"raw").string,s=e[1];r.settings.strict&&r.settings.reportNonstrict("htmlExtension","HTML extension is disabled on strict mode");var l,u={};switch(n){case"\\htmlClass":u.class=a,l={command:"\\htmlClass",class:a};break;case"\\htmlId":u.id=a,l={command:"\\htmlId",id:a};break;case"\\htmlStyle":u.style=a,l={command:"\\htmlStyle",style:a};break;case"\\htmlData":{for(var h=a.split(","),f=0;f{var r=Pi(t.body,e,!1),n=["enclosing"];t.attributes.class&&n.push(...t.attributes.class.trim().split(/\s+/));var i=Be.makeSpan(n,r,e);for(var a in t.attributes)a!=="class"&&t.attributes.hasOwnProperty(a)&&i.setAttribute(a,t.attributes[a]);return i},"htmlBuilder"),mathmlBuilder:o((t,e)=>dh(t.body,e),"mathmlBuilder")});Nt({type:"htmlmathml",names:["\\html@mathml"],props:{numArgs:2,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"htmlmathml",mode:r.mode,html:di(e[0]),mathml:di(e[1])}},"handler"),htmlBuilder:o((t,e)=>{var r=Pi(t.html,e,!1);return Be.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>dh(t.mathml,e),"mathmlBuilder")});x7=o(function(e){if(/^[-+]? *(\d+(\.\d*)?|\.\d+)$/.test(e))return{number:+e,unit:"bp"};var r=/([-+]?) *(\d+(?:\.\d*)?|\.\d+) *([a-z]{2})/.exec(e);if(!r)throw new gt("Invalid size: '"+e+"' in \\includegraphics");var n={number:+(r[1]+r[2]),unit:r[3]};if(!zz(n))throw new gt("Invalid unit: '"+n.unit+"' in \\includegraphics.");return n},"sizeData");Nt({type:"includegraphics",names:["\\includegraphics"],props:{numArgs:1,numOptionalArgs:1,argTypes:["raw","url"],allowedInText:!1},handler:o((t,e,r)=>{var{parser:n}=t,i={number:0,unit:"em"},a={number:.9,unit:"em"},s={number:0,unit:"em"},l="";if(r[0])for(var u=xr(r[0],"raw").string,h=u.split(","),f=0;f{var r=ti(t.height,e),n=0;t.totalheight.number>0&&(n=ti(t.totalheight,e)-r);var i=0;t.width.number>0&&(i=ti(t.width,e));var a={height:kt(r+n)};i>0&&(a.width=kt(i)),n>0&&(a.verticalAlign=kt(-n));var s=new S7(t.src,t.alt,a);return s.height=r,s.depth=n,s},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new dt.MathNode("mglyph",[]);r.setAttribute("alt",t.alt);var n=ti(t.height,e),i=0;if(t.totalheight.number>0&&(i=ti(t.totalheight,e)-n,r.setAttribute("valign",kt(-i))),r.setAttribute("height",kt(n+i)),t.width.number>0){var a=ti(t.width,e);r.setAttribute("width",kt(a))}return r.setAttribute("src",t.src),r},"mathmlBuilder")});Nt({type:"kern",names:["\\kern","\\mkern","\\hskip","\\mskip"],props:{numArgs:1,argTypes:["size"],primitive:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=xr(e[0],"size");if(r.settings.strict){var a=n[1]==="m",s=i.value.unit==="mu";a?(s||r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" supports only mu units, "+("not "+i.value.unit+" units")),r.mode!=="math"&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" works only in math mode")):s&&r.settings.reportNonstrict("mathVsTextUnits","LaTeX's "+n+" doesn't support mu units")}return{type:"kern",mode:r.mode,dimension:i.value}},htmlBuilder(t,e){return Be.makeGlue(t.dimension,e)},mathmlBuilder(t,e){var r=ti(t.dimension,e);return new dt.SpaceNode(r)}});Nt({type:"lap",names:["\\mathllap","\\mathrlap","\\mathclap"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"lap",mode:r.mode,alignment:n.slice(5),body:i}},"handler"),htmlBuilder:o((t,e)=>{var r;t.alignment==="clap"?(r=Be.makeSpan([],[Fr(t.body,e)]),r=Be.makeSpan(["inner"],[r],e)):r=Be.makeSpan(["inner"],[Fr(t.body,e)]);var n=Be.makeSpan(["fix"],[]),i=Be.makeSpan([t.alignment],[r,n],e),a=Be.makeSpan(["strut"]);return a.style.height=kt(i.height+i.depth),i.depth&&(a.style.verticalAlign=kt(-i.depth)),i.children.unshift(a),i=Be.makeSpan(["thinbox"],[i],e),Be.makeSpan(["mord","vbox"],[i],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=new dt.MathNode("mpadded",[yn(t.body,e)]);if(t.alignment!=="rlap"){var n=t.alignment==="llap"?"-1":"-0.5";r.setAttribute("lspace",n+"width")}return r.setAttribute("width","0px"),r},"mathmlBuilder")});Nt({type:"styling",names:["\\(","$"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){var{funcName:r,parser:n}=t,i=n.mode;n.switchMode("math");var a=r==="\\("?"\\)":"$",s=n.parseExpression(!1,a);return n.expect(a),n.switchMode(i),{type:"styling",mode:n.mode,style:"text",body:s}}});Nt({type:"text",names:["\\)","\\]"],props:{numArgs:0,allowedInText:!0,allowedInMath:!1},handler(t,e){throw new gt("Mismatched "+t.funcName)}});Cz=o((t,e)=>{switch(e.style.size){case tr.DISPLAY.size:return t.display;case tr.TEXT.size:return t.text;case tr.SCRIPT.size:return t.script;case tr.SCRIPTSCRIPT.size:return t.scriptscript;default:return t.text}},"chooseMathStyle");Nt({type:"mathchoice",names:["\\mathchoice"],props:{numArgs:4,primitive:!0},handler:o((t,e)=>{var{parser:r}=t;return{type:"mathchoice",mode:r.mode,display:di(e[0]),text:di(e[1]),script:di(e[2]),scriptscript:di(e[3])}},"handler"),htmlBuilder:o((t,e)=>{var r=Cz(t,e),n=Pi(r,e,!1);return Be.makeFragment(n)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=Cz(t,e);return dh(r,e)},"mathmlBuilder")});TG=o((t,e,r,n,i,a,s)=>{t=Be.makeSpan([],[t]);var l=r&&Jt.isCharacterBox(r),u,h;if(e){var f=Fr(e,n.havingStyle(i.sup()),n);h={elem:f,kern:Math.max(n.fontMetrics().bigOpSpacing1,n.fontMetrics().bigOpSpacing3-f.depth)}}if(r){var d=Fr(r,n.havingStyle(i.sub()),n);u={elem:d,kern:Math.max(n.fontMetrics().bigOpSpacing2,n.fontMetrics().bigOpSpacing4-d.height)}}var p;if(h&&u){var m=n.fontMetrics().bigOpSpacing5+u.elem.height+u.elem.depth+u.kern+t.depth+s;p=Be.makeVList({positionType:"bottom",positionData:m,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:kt(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:kt(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else if(u){var g=t.height-s;p=Be.makeVList({positionType:"top",positionData:g,children:[{type:"kern",size:n.fontMetrics().bigOpSpacing5},{type:"elem",elem:u.elem,marginLeft:kt(-a)},{type:"kern",size:u.kern},{type:"elem",elem:t}]},n)}else if(h){var y=t.depth+s;p=Be.makeVList({positionType:"bottom",positionData:y,children:[{type:"elem",elem:t},{type:"kern",size:h.kern},{type:"elem",elem:h.elem,marginLeft:kt(a)},{type:"kern",size:n.fontMetrics().bigOpSpacing5}]},n)}else return t;var v=[p];if(u&&a!==0&&!l){var x=Be.makeSpan(["mspace"],[],n);x.style.marginRight=kt(a),v.unshift(x)}return Be.makeSpan(["mop","op-limits"],v,n)},"assembleSupSub"),kG=["\\smallint"],m0=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=xr(t.base,"op"),i=!0):a=xr(t,"op");var s=e.style,l=!1;s.size===tr.DISPLAY.size&&a.symbol&&!Jt.contains(kG,a.name)&&(l=!0);var u;if(a.symbol){var h=l?"Size2-Regular":"Size1-Regular",f="";if((a.name==="\\oiint"||a.name==="\\oiiint")&&(f=a.name.slice(1),a.name=f==="oiint"?"\\iint":"\\iiint"),u=Be.makeSymbol(a.name,h,"math",e,["mop","op-symbol",l?"large-op":"small-op"]),f.length>0){var d=u.italic,p=Be.staticSvg(f+"Size"+(l?"2":"1"),e);u=Be.makeVList({positionType:"individualShift",children:[{type:"elem",elem:u,shift:0},{type:"elem",elem:p,shift:l?.08:0}]},e),a.name="\\"+f,u.classes.unshift("mop"),u.italic=d}}else if(a.body){var m=Pi(a.body,e,!0);m.length===1&&m[0]instanceof Ts?(u=m[0],u.classes[0]="mop"):u=Be.makeSpan(["mop"],m,e)}else{for(var g=[],y=1;y{var r;if(t.symbol)r=new ws("mo",[Co(t.name,t.mode)]),Jt.contains(kG,t.name)&&r.setAttribute("largeop","false");else if(t.body)r=new ws("mo",ks(t.body,e));else{r=new ws("mi",[new Jf(t.name.slice(1))]);var n=new ws("mo",[Co("\u2061","text")]);t.parentIsSupSub?r=new ws("mrow",[r,n]):r=Qz([r,n])}return r},"mathmlBuilder$1"),L4e={"\u220F":"\\prod","\u2210":"\\coprod","\u2211":"\\sum","\u22C0":"\\bigwedge","\u22C1":"\\bigvee","\u22C2":"\\bigcap","\u22C3":"\\bigcup","\u2A00":"\\bigodot","\u2A01":"\\bigoplus","\u2A02":"\\bigotimes","\u2A04":"\\biguplus","\u2A06":"\\bigsqcup"};Nt({type:"op",names:["\\coprod","\\bigvee","\\bigwedge","\\biguplus","\\bigcap","\\bigcup","\\intop","\\prod","\\sum","\\bigotimes","\\bigoplus","\\bigodot","\\bigsqcup","\\smallint","\u220F","\u2210","\u2211","\u22C0","\u22C1","\u22C2","\u22C3","\u2A00","\u2A01","\u2A02","\u2A04","\u2A06"],props:{numArgs:0},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=n;return i.length===1&&(i=L4e[i]),{type:"op",mode:r.mode,limits:!0,parentIsSupSub:!1,symbol:!0,name:i}},"handler"),htmlBuilder:m0,mathmlBuilder:Wy});Nt({type:"op",names:["\\mathop"],props:{numArgs:1,primitive:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"op",mode:r.mode,limits:!1,parentIsSupSub:!1,symbol:!1,body:di(n)}},"handler"),htmlBuilder:m0,mathmlBuilder:Wy});R4e={"\u222B":"\\int","\u222C":"\\iint","\u222D":"\\iiint","\u222E":"\\oint","\u222F":"\\oiint","\u2230":"\\oiiint"};Nt({type:"op",names:["\\arcsin","\\arccos","\\arctan","\\arctg","\\arcctg","\\arg","\\ch","\\cos","\\cosec","\\cosh","\\cot","\\cotg","\\coth","\\csc","\\ctg","\\cth","\\deg","\\dim","\\exp","\\hom","\\ker","\\lg","\\ln","\\log","\\sec","\\sin","\\sinh","\\sh","\\tan","\\tanh","\\tg","\\th"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:m0,mathmlBuilder:Wy});Nt({type:"op",names:["\\det","\\gcd","\\inf","\\lim","\\max","\\min","\\Pr","\\sup"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t;return{type:"op",mode:e.mode,limits:!0,parentIsSupSub:!1,symbol:!1,name:r}},htmlBuilder:m0,mathmlBuilder:Wy});Nt({type:"op",names:["\\int","\\iint","\\iiint","\\oint","\\oiint","\\oiiint","\u222B","\u222C","\u222D","\u222E","\u222F","\u2230"],props:{numArgs:0},handler(t){var{parser:e,funcName:r}=t,n=r;return n.length===1&&(n=R4e[n]),{type:"op",mode:e.mode,limits:!1,parentIsSupSub:!1,symbol:!0,name:n}},htmlBuilder:m0,mathmlBuilder:Wy});EG=o((t,e)=>{var r,n,i=!1,a;t.type==="supsub"?(r=t.sup,n=t.sub,a=xr(t.base,"operatorname"),i=!0):a=xr(t,"operatorname");var s;if(a.body.length>0){for(var l=a.body.map(d=>{var p=d.text;return typeof p=="string"?{type:"textord",mode:d.mode,text:p}:d}),u=Pi(l,e.withFont("mathrm"),!0),h=0;h{for(var r=ks(t.body,e.withFont("mathrm")),n=!0,i=0;if.toText()).join("");r=[new dt.TextNode(l)]}var u=new dt.MathNode("mi",r);u.setAttribute("mathvariant","normal");var h=new dt.MathNode("mo",[Co("\u2061","text")]);return t.parentIsSupSub?new dt.MathNode("mrow",[u,h]):dt.newDocumentFragment([u,h])},"mathmlBuilder");Nt({type:"operatorname",names:["\\operatorname@","\\operatornamewithlimits"],props:{numArgs:1},handler:o((t,e)=>{var{parser:r,funcName:n}=t,i=e[0];return{type:"operatorname",mode:r.mode,body:di(i),alwaysHandleSupSub:n==="\\operatornamewithlimits",limits:!1,parentIsSupSub:!1}},"handler"),htmlBuilder:EG,mathmlBuilder:N4e});fe("\\operatorname","\\@ifstar\\operatornamewithlimits\\operatorname@");rd({type:"ordgroup",htmlBuilder(t,e){return t.semisimple?Be.makeFragment(Pi(t.body,e,!1)):Be.makeSpan(["mord"],Pi(t.body,e,!0),e)},mathmlBuilder(t,e){return dh(t.body,e,!0)}});Nt({type:"overline",names:["\\overline"],props:{numArgs:1},handler(t,e){var{parser:r}=t,n=e[0];return{type:"overline",mode:r.mode,body:n}},htmlBuilder(t,e){var r=Fr(t.body,e.havingCrampedStyle()),n=Be.makeLineSpan("overline-line",e),i=e.fontMetrics().defaultRuleThickness,a=Be.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r},{type:"kern",size:3*i},{type:"elem",elem:n},{type:"kern",size:i}]},e);return Be.makeSpan(["mord","overline"],[a],e)},mathmlBuilder(t,e){var r=new dt.MathNode("mo",[new dt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new dt.MathNode("mover",[yn(t.body,e),r]);return n.setAttribute("accent","true"),n}});Nt({type:"phantom",names:["\\phantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"phantom",mode:r.mode,body:di(n)}},"handler"),htmlBuilder:o((t,e)=>{var r=Pi(t.body,e.withPhantom(),!1);return Be.makeFragment(r)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=ks(t.body,e);return new dt.MathNode("mphantom",r)},"mathmlBuilder")});Nt({type:"hphantom",names:["\\hphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"hphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=Be.makeSpan([],[Fr(t.body,e.withPhantom())]);if(r.height=0,r.depth=0,r.children)for(var n=0;n{var r=ks(di(t.body),e),n=new dt.MathNode("mphantom",r),i=new dt.MathNode("mpadded",[n]);return i.setAttribute("height","0px"),i.setAttribute("depth","0px"),i},"mathmlBuilder")});Nt({type:"vphantom",names:["\\vphantom"],props:{numArgs:1,allowedInText:!0},handler:o((t,e)=>{var{parser:r}=t,n=e[0];return{type:"vphantom",mode:r.mode,body:n}},"handler"),htmlBuilder:o((t,e)=>{var r=Be.makeSpan(["inner"],[Fr(t.body,e.withPhantom())]),n=Be.makeSpan(["fix"],[]);return Be.makeSpan(["mord","rlap"],[r,n],e)},"htmlBuilder"),mathmlBuilder:o((t,e)=>{var r=ks(di(t.body),e),n=new dt.MathNode("mphantom",r),i=new dt.MathNode("mpadded",[n]);return i.setAttribute("width","0px"),i},"mathmlBuilder")});Nt({type:"raisebox",names:["\\raisebox"],props:{numArgs:2,argTypes:["size","hbox"],allowedInText:!0},handler(t,e){var{parser:r}=t,n=xr(e[0],"size").value,i=e[1];return{type:"raisebox",mode:r.mode,dy:n,body:i}},htmlBuilder(t,e){var r=Fr(t.body,e),n=ti(t.dy,e);return Be.makeVList({positionType:"shift",positionData:-n,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){var r=new dt.MathNode("mpadded",[yn(t.body,e)]),n=t.dy.number+t.dy.unit;return r.setAttribute("voffset",n),r}});Nt({type:"internal",names:["\\relax"],props:{numArgs:0,allowedInText:!0},handler(t){var{parser:e}=t;return{type:"internal",mode:e.mode}}});Nt({type:"rule",names:["\\rule"],props:{numArgs:2,numOptionalArgs:1,argTypes:["size","size","size"]},handler(t,e,r){var{parser:n}=t,i=r[0],a=xr(e[0],"size"),s=xr(e[1],"size");return{type:"rule",mode:n.mode,shift:i&&xr(i,"size").value,width:a.value,height:s.value}},htmlBuilder(t,e){var r=Be.makeSpan(["mord","rule"],[],e),n=ti(t.width,e),i=ti(t.height,e),a=t.shift?ti(t.shift,e):0;return r.style.borderRightWidth=kt(n),r.style.borderTopWidth=kt(i),r.style.bottom=kt(a),r.width=n,r.height=i+a,r.depth=-a,r.maxFontSize=i*1.125*e.sizeMultiplier,r},mathmlBuilder(t,e){var r=ti(t.width,e),n=ti(t.height,e),i=t.shift?ti(t.shift,e):0,a=e.color&&e.getColor()||"black",s=new dt.MathNode("mspace");s.setAttribute("mathbackground",a),s.setAttribute("width",kt(r)),s.setAttribute("height",kt(n));var l=new dt.MathNode("mpadded",[s]);return i>=0?l.setAttribute("height",kt(i)):(l.setAttribute("height",kt(i)),l.setAttribute("depth",kt(-i))),l.setAttribute("voffset",kt(i)),l}});o(SG,"sizingGroup");Az=["\\tiny","\\sixptsize","\\scriptsize","\\footnotesize","\\small","\\normalsize","\\large","\\Large","\\LARGE","\\huge","\\Huge"],M4e=o((t,e)=>{var r=e.havingSize(t.size);return SG(t.body,r,e)},"htmlBuilder");Nt({type:"sizing",names:Az,props:{numArgs:0,allowedInText:!0},handler:o((t,e)=>{var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!1,r);return{type:"sizing",mode:i.mode,size:Az.indexOf(n)+1,body:a}},"handler"),htmlBuilder:M4e,mathmlBuilder:o((t,e)=>{var r=e.havingSize(t.size),n=ks(t.body,r),i=new dt.MathNode("mstyle",n);return i.setAttribute("mathsize",kt(r.sizeMultiplier)),i},"mathmlBuilder")});Nt({type:"smash",names:["\\smash"],props:{numArgs:1,numOptionalArgs:1,allowedInText:!0},handler:o((t,e,r)=>{var{parser:n}=t,i=!1,a=!1,s=r[0]&&xr(r[0],"ordgroup");if(s)for(var l="",u=0;u{var r=Be.makeSpan([],[Fr(t.body,e)]);if(!t.smashHeight&&!t.smashDepth)return r;if(t.smashHeight&&(r.height=0,r.children))for(var n=0;n{var r=new dt.MathNode("mpadded",[yn(t.body,e)]);return t.smashHeight&&r.setAttribute("height","0px"),t.smashDepth&&r.setAttribute("depth","0px"),r},"mathmlBuilder")});Nt({type:"sqrt",names:["\\sqrt"],props:{numArgs:1,numOptionalArgs:1},handler(t,e,r){var{parser:n}=t,i=r[0],a=e[0];return{type:"sqrt",mode:n.mode,body:a,index:i}},htmlBuilder(t,e){var r=Fr(t.body,e.havingCrampedStyle());r.height===0&&(r.height=e.fontMetrics().xHeight),r=Be.wrapFragment(r,e);var n=e.fontMetrics(),i=n.defaultRuleThickness,a=i;e.style.idr.height+r.depth+s&&(s=(s+d-r.height-r.depth)/2);var p=u.height-r.height-s-h;r.style.paddingLeft=kt(f);var m=Be.makeVList({positionType:"firstBaseline",children:[{type:"elem",elem:r,wrapperClasses:["svg-align"]},{type:"kern",size:-(r.height+p)},{type:"elem",elem:u},{type:"kern",size:h}]},e);if(t.index){var g=e.havingStyle(tr.SCRIPTSCRIPT),y=Fr(t.index,g,e),v=.6*(m.height-m.depth),x=Be.makeVList({positionType:"shift",positionData:-v,children:[{type:"elem",elem:y}]},e),b=Be.makeSpan(["root"],[x]);return Be.makeSpan(["mord","sqrt"],[b,m],e)}else return Be.makeSpan(["mord","sqrt"],[m],e)},mathmlBuilder(t,e){var{body:r,index:n}=t;return n?new dt.MathNode("mroot",[yn(r,e),yn(n,e)]):new dt.MathNode("msqrt",[yn(r,e)])}});_z={display:tr.DISPLAY,text:tr.TEXT,script:tr.SCRIPT,scriptscript:tr.SCRIPTSCRIPT};Nt({type:"styling",names:["\\displaystyle","\\textstyle","\\scriptstyle","\\scriptscriptstyle"],props:{numArgs:0,allowedInText:!0,primitive:!0},handler(t,e){var{breakOnTokenText:r,funcName:n,parser:i}=t,a=i.parseExpression(!0,r),s=n.slice(1,n.length-5);return{type:"styling",mode:i.mode,style:s,body:a}},htmlBuilder(t,e){var r=_z[t.style],n=e.havingStyle(r).withFont("");return SG(t.body,n,e)},mathmlBuilder(t,e){var r=_z[t.style],n=e.havingStyle(r),i=ks(t.body,n),a=new dt.MathNode("mstyle",i),s={display:["0","true"],text:["0","false"],script:["1","false"],scriptscript:["2","false"]},l=s[t.style];return a.setAttribute("scriptlevel",l[0]),a.setAttribute("displaystyle",l[1]),a}});I4e=o(function(e,r){var n=e.base;if(n)if(n.type==="op"){var i=n.limits&&(r.style.size===tr.DISPLAY.size||n.alwaysHandleSupSub);return i?m0:null}else if(n.type==="operatorname"){var a=n.alwaysHandleSupSub&&(r.style.size===tr.DISPLAY.size||n.limits);return a?EG:null}else{if(n.type==="accent")return Jt.isCharacterBox(n.base)?G7:null;if(n.type==="horizBrace"){var s=!e.sub;return s===n.isOver?wG:null}else return null}else return null},"htmlBuilderDelegate");rd({type:"supsub",htmlBuilder(t,e){var r=I4e(t,e);if(r)return r(t,e);var{base:n,sup:i,sub:a}=t,s=Fr(n,e),l,u,h=e.fontMetrics(),f=0,d=0,p=n&&Jt.isCharacterBox(n);if(i){var m=e.havingStyle(e.style.sup());l=Fr(i,m,e),p||(f=s.height-m.fontMetrics().supDrop*m.sizeMultiplier/e.sizeMultiplier)}if(a){var g=e.havingStyle(e.style.sub());u=Fr(a,g,e),p||(d=s.depth+g.fontMetrics().subDrop*g.sizeMultiplier/e.sizeMultiplier)}var y;e.style===tr.DISPLAY?y=h.sup1:e.style.cramped?y=h.sup3:y=h.sup2;var v=e.sizeMultiplier,x=kt(.5/h.ptPerEm/v),b=null;if(u){var w=t.base&&t.base.type==="op"&&t.base.name&&(t.base.name==="\\oiint"||t.base.name==="\\oiiint");(s instanceof Ts||w)&&(b=kt(-s.italic))}var C;if(l&&u){f=Math.max(f,y,l.depth+.25*h.xHeight),d=Math.max(d,h.sub2);var T=h.defaultRuleThickness,E=4*T;if(f-l.depth-(u.height-d)0&&(f+=A,d-=A)}var S=[{type:"elem",elem:u,shift:d,marginRight:x,marginLeft:b},{type:"elem",elem:l,shift:-f,marginRight:x}];C=Be.makeVList({positionType:"individualShift",children:S},e)}else if(u){d=Math.max(d,h.sub1,u.height-.8*h.xHeight);var _=[{type:"elem",elem:u,marginLeft:b,marginRight:x}];C=Be.makeVList({positionType:"shift",positionData:d,children:_},e)}else if(l)f=Math.max(f,y,l.depth+.25*h.xHeight),C=Be.makeVList({positionType:"shift",positionData:-f,children:[{type:"elem",elem:l,marginRight:x}]},e);else throw new Error("supsub must have either sup or sub.");var I=A7(s,"right")||"mord";return Be.makeSpan([I],[s,Be.makeSpan(["msupsub"],[C])],e)},mathmlBuilder(t,e){var r=!1,n,i;t.base&&t.base.type==="horizBrace"&&(i=!!t.sup,i===t.base.isOver&&(r=!0,n=t.base.isOver)),t.base&&(t.base.type==="op"||t.base.type==="operatorname")&&(t.base.parentIsSupSub=!0);var a=[yn(t.base,e)];t.sub&&a.push(yn(t.sub,e)),t.sup&&a.push(yn(t.sup,e));var s;if(r)s=n?"mover":"munder";else if(t.sub)if(t.sup){var h=t.base;h&&h.type==="op"&&h.limits&&e.style===tr.DISPLAY||h&&h.type==="operatorname"&&h.alwaysHandleSupSub&&(e.style===tr.DISPLAY||h.limits)?s="munderover":s="msubsup"}else{var u=t.base;u&&u.type==="op"&&u.limits&&(e.style===tr.DISPLAY||u.alwaysHandleSupSub)||u&&u.type==="operatorname"&&u.alwaysHandleSupSub&&(u.limits||e.style===tr.DISPLAY)?s="munder":s="msub"}else{var l=t.base;l&&l.type==="op"&&l.limits&&(e.style===tr.DISPLAY||l.alwaysHandleSupSub)||l&&l.type==="operatorname"&&l.alwaysHandleSupSub&&(l.limits||e.style===tr.DISPLAY)?s="mover":s="msup"}return new dt.MathNode(s,a)}});rd({type:"atom",htmlBuilder(t,e){return Be.mathsym(t.text,t.mode,e,["m"+t.family])},mathmlBuilder(t,e){var r=new dt.MathNode("mo",[Co(t.text,t.mode)]);if(t.family==="bin"){var n=$7(t,e);n==="bold-italic"&&r.setAttribute("mathvariant",n)}else t.family==="punct"?r.setAttribute("separator","true"):(t.family==="open"||t.family==="close")&&r.setAttribute("stretchy","false");return r}});CG={mi:"italic",mn:"normal",mtext:"normal"};rd({type:"mathord",htmlBuilder(t,e){return Be.makeOrd(t,e,"mathord")},mathmlBuilder(t,e){var r=new dt.MathNode("mi",[Co(t.text,t.mode,e)]),n=$7(t,e)||"italic";return n!==CG[r.type]&&r.setAttribute("mathvariant",n),r}});rd({type:"textord",htmlBuilder(t,e){return Be.makeOrd(t,e,"textord")},mathmlBuilder(t,e){var r=Co(t.text,t.mode,e),n=$7(t,e)||"normal",i;return t.mode==="text"?i=new dt.MathNode("mtext",[r]):/[0-9]/.test(t.text)?i=new dt.MathNode("mn",[r]):t.text==="\\prime"?i=new dt.MathNode("mo",[r]):i=new dt.MathNode("mi",[r]),n!==CG[i.type]&&i.setAttribute("mathvariant",n),i}});b7={"\\nobreak":"nobreak","\\allowbreak":"allowbreak"},w7={" ":{},"\\ ":{},"~":{className:"nobreak"},"\\space":{},"\\nobreakspace":{className:"nobreak"}};rd({type:"spacing",htmlBuilder(t,e){if(w7.hasOwnProperty(t.text)){var r=w7[t.text].className||"";if(t.mode==="text"){var n=Be.makeOrd(t,e,"textord");return n.classes.push(r),n}else return Be.makeSpan(["mspace",r],[Be.mathsym(t.text,t.mode,e)],e)}else{if(b7.hasOwnProperty(t.text))return Be.makeSpan(["mspace",b7[t.text]],[],e);throw new gt('Unknown type of space "'+t.text+'"')}},mathmlBuilder(t,e){var r;if(w7.hasOwnProperty(t.text))r=new dt.MathNode("mtext",[new dt.TextNode("\xA0")]);else{if(b7.hasOwnProperty(t.text))return new dt.MathNode("mspace");throw new gt('Unknown type of space "'+t.text+'"')}return r}});Dz=o(()=>{var t=new dt.MathNode("mtd",[]);return t.setAttribute("width","50%"),t},"pad");rd({type:"tag",mathmlBuilder(t,e){var r=new dt.MathNode("mtable",[new dt.MathNode("mtr",[Dz(),new dt.MathNode("mtd",[dh(t.body,e)]),Dz(),new dt.MathNode("mtd",[dh(t.tag,e)])])]);return r.setAttribute("width","100%"),r}});Lz={"\\text":void 0,"\\textrm":"textrm","\\textsf":"textsf","\\texttt":"texttt","\\textnormal":"textrm"},Rz={"\\textbf":"textbf","\\textmd":"textmd"},O4e={"\\textit":"textit","\\textup":"textup"},Nz=o((t,e)=>{var r=t.font;if(r){if(Lz[r])return e.withTextFontFamily(Lz[r]);if(Rz[r])return e.withTextFontWeight(Rz[r]);if(r==="\\emph")return e.fontShape==="textit"?e.withTextFontShape("textup"):e.withTextFontShape("textit")}else return e;return e.withTextFontShape(O4e[r])},"optionsWithFont");Nt({type:"text",names:["\\text","\\textrm","\\textsf","\\texttt","\\textnormal","\\textbf","\\textmd","\\textit","\\textup","\\emph"],props:{numArgs:1,argTypes:["text"],allowedInArgument:!0,allowedInText:!0},handler(t,e){var{parser:r,funcName:n}=t,i=e[0];return{type:"text",mode:r.mode,body:di(i),font:n}},htmlBuilder(t,e){var r=Nz(t,e),n=Pi(t.body,r,!0);return Be.makeSpan(["mord","text"],n,r)},mathmlBuilder(t,e){var r=Nz(t,e);return dh(t.body,r)}});Nt({type:"underline",names:["\\underline"],props:{numArgs:1,allowedInText:!0},handler(t,e){var{parser:r}=t;return{type:"underline",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Fr(t.body,e),n=Be.makeLineSpan("underline-line",e),i=e.fontMetrics().defaultRuleThickness,a=Be.makeVList({positionType:"top",positionData:r.height,children:[{type:"kern",size:i},{type:"elem",elem:n},{type:"kern",size:3*i},{type:"elem",elem:r}]},e);return Be.makeSpan(["mord","underline"],[a],e)},mathmlBuilder(t,e){var r=new dt.MathNode("mo",[new dt.TextNode("\u203E")]);r.setAttribute("stretchy","true");var n=new dt.MathNode("munder",[yn(t.body,e),r]);return n.setAttribute("accentunder","true"),n}});Nt({type:"vcenter",names:["\\vcenter"],props:{numArgs:1,argTypes:["original"],allowedInText:!1},handler(t,e){var{parser:r}=t;return{type:"vcenter",mode:r.mode,body:e[0]}},htmlBuilder(t,e){var r=Fr(t.body,e),n=e.fontMetrics().axisHeight,i=.5*(r.height-n-(r.depth+n));return Be.makeVList({positionType:"shift",positionData:i,children:[{type:"elem",elem:r}]},e)},mathmlBuilder(t,e){return new dt.MathNode("mpadded",[yn(t.body,e)],["vcenter"])}});Nt({type:"verb",names:["\\verb"],props:{numArgs:0,allowedInText:!0},handler(t,e,r){throw new gt("\\verb ended by end of line instead of matching delimiter")},htmlBuilder(t,e){for(var r=Mz(t),n=[],i=e.havingStyle(e.style.text()),a=0;at.body.replace(/ /g,t.star?"\u2423":"\xA0"),"makeVerb"),hh=jz,AG=`[ \r + ]`,P4e="\\\\[a-zA-Z@]+",B4e="\\\\[^\uD800-\uDFFF]",F4e="("+P4e+")"+AG+"*",$4e=`\\\\( +|[ \r ]+ +?)[ \r ]*`,N7="[\u0300-\u036F]",z4e=new RegExp(N7+"+$"),G4e="("+AG+"+)|"+($4e+"|")+"([!-\\[\\]-\u2027\u202A-\uD7FF\uF900-\uFFFF]"+(N7+"*")+"|[\uD800-\uDBFF][\uDC00-\uDFFF]"+(N7+"*")+"|\\\\verb\\*([^]).*?\\4|\\\\verb([^*a-zA-Z]).*?\\5"+("|"+F4e)+("|"+B4e+")"),y3=class{static{o(this,"Lexer")}constructor(e,r){this.input=void 0,this.settings=void 0,this.tokenRegex=void 0,this.catcodes=void 0,this.input=e,this.settings=r,this.tokenRegex=new RegExp(G4e,"g"),this.catcodes={"%":14,"~":13}}setCatcode(e,r){this.catcodes[e]=r}lex(){var e=this.input,r=this.tokenRegex.lastIndex;if(r===e.length)return new So("EOF",new Xs(this,r,r));var n=this.tokenRegex.exec(e);if(n===null||n.index!==r)throw new gt("Unexpected character: '"+e[r]+"'",new So(e[r],new Xs(this,r,r+1)));var i=n[6]||n[3]||(n[2]?"\\ ":" ");if(this.catcodes[i]===14){var a=e.indexOf(` +`,this.tokenRegex.lastIndex);return a===-1?(this.tokenRegex.lastIndex=e.length,this.settings.reportNonstrict("commentAtEnd","% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)")):this.tokenRegex.lastIndex=a+1,this.lex()}return new So(i,new Xs(this,r,this.tokenRegex.lastIndex))}},M7=class{static{o(this,"Namespace")}constructor(e,r){e===void 0&&(e={}),r===void 0&&(r={}),this.current=void 0,this.builtins=void 0,this.undefStack=void 0,this.current=r,this.builtins=e,this.undefStack=[]}beginGroup(){this.undefStack.push({})}endGroup(){if(this.undefStack.length===0)throw new gt("Unbalanced namespace destruction: attempt to pop global namespace; please report this as a bug");var e=this.undefStack.pop();for(var r in e)e.hasOwnProperty(r)&&(e[r]==null?delete this.current[r]:this.current[r]=e[r])}endGroups(){for(;this.undefStack.length>0;)this.endGroup()}has(e){return this.current.hasOwnProperty(e)||this.builtins.hasOwnProperty(e)}get(e){return this.current.hasOwnProperty(e)?this.current[e]:this.builtins[e]}set(e,r,n){if(n===void 0&&(n=!1),n){for(var i=0;i0&&(this.undefStack[this.undefStack.length-1][e]=r)}else{var a=this.undefStack[this.undefStack.length-1];a&&!a.hasOwnProperty(e)&&(a[e]=this.current[e])}r==null?delete this.current[e]:this.current[e]=r}},V4e=gG;fe("\\noexpand",function(t){var e=t.popToken();return t.isExpandable(e.text)&&(e.noexpand=!0,e.treatAsRelax=!0),{tokens:[e],numArgs:0}});fe("\\expandafter",function(t){var e=t.popToken();return t.expandOnce(!0),{tokens:[e],numArgs:0}});fe("\\@firstoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[0],numArgs:0}});fe("\\@secondoftwo",function(t){var e=t.consumeArgs(2);return{tokens:e[1],numArgs:0}});fe("\\@ifnextchar",function(t){var e=t.consumeArgs(3);t.consumeSpaces();var r=t.future();return e[0].length===1&&e[0][0].text===r.text?{tokens:e[1],numArgs:0}:{tokens:e[2],numArgs:0}});fe("\\@ifstar","\\@ifnextchar *{\\@firstoftwo{#1}}");fe("\\TextOrMath",function(t){var e=t.consumeArgs(2);return t.mode==="text"?{tokens:e[0],numArgs:0}:{tokens:e[1],numArgs:0}});Iz={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15};fe("\\char",function(t){var e=t.popToken(),r,n="";if(e.text==="'")r=8,e=t.popToken();else if(e.text==='"')r=16,e=t.popToken();else if(e.text==="`")if(e=t.popToken(),e.text[0]==="\\")n=e.text.charCodeAt(1);else{if(e.text==="EOF")throw new gt("\\char` missing argument");n=e.text.charCodeAt(0)}else r=10;if(r){if(n=Iz[e.text],n==null||n>=r)throw new gt("Invalid base-"+r+" digit "+e.text);for(var i;(i=Iz[t.future().text])!=null&&i{var n=t.consumeArg().tokens;if(n.length!==1)throw new gt("\\newcommand's first argument must be a macro name");var i=n[0].text,a=t.isDefined(i);if(a&&!e)throw new gt("\\newcommand{"+i+"} attempting to redefine "+(i+"; use \\renewcommand"));if(!a&&!r)throw new gt("\\renewcommand{"+i+"} when command "+i+" does not yet exist; use \\newcommand");var s=0;if(n=t.consumeArg().tokens,n.length===1&&n[0].text==="["){for(var l="",u=t.expandNextToken();u.text!=="]"&&u.text!=="EOF";)l+=u.text,u=t.expandNextToken();if(!l.match(/^\s*[0-9]+\s*$/))throw new gt("Invalid number of arguments: "+l);s=parseInt(l),n=t.consumeArg().tokens}return t.macros.set(i,{tokens:n,numArgs:s}),""},"newcommand");fe("\\newcommand",t=>j7(t,!1,!0));fe("\\renewcommand",t=>j7(t,!0,!1));fe("\\providecommand",t=>j7(t,!0,!0));fe("\\message",t=>{var e=t.consumeArgs(1)[0];return console.log(e.reverse().map(r=>r.text).join("")),""});fe("\\errmessage",t=>{var e=t.consumeArgs(1)[0];return console.error(e.reverse().map(r=>r.text).join("")),""});fe("\\show",t=>{var e=t.popToken(),r=e.text;return console.log(e,t.macros.get(r),hh[r],An.math[r],An.text[r]),""});fe("\\bgroup","{");fe("\\egroup","}");fe("~","\\nobreakspace");fe("\\lq","`");fe("\\rq","'");fe("\\aa","\\r a");fe("\\AA","\\r A");fe("\\textcopyright","\\html@mathml{\\textcircled{c}}{\\char`\xA9}");fe("\\copyright","\\TextOrMath{\\textcopyright}{\\text{\\textcopyright}}");fe("\\textregistered","\\html@mathml{\\textcircled{\\scriptsize R}}{\\char`\xAE}");fe("\u212C","\\mathscr{B}");fe("\u2130","\\mathscr{E}");fe("\u2131","\\mathscr{F}");fe("\u210B","\\mathscr{H}");fe("\u2110","\\mathscr{I}");fe("\u2112","\\mathscr{L}");fe("\u2133","\\mathscr{M}");fe("\u211B","\\mathscr{R}");fe("\u212D","\\mathfrak{C}");fe("\u210C","\\mathfrak{H}");fe("\u2128","\\mathfrak{Z}");fe("\\Bbbk","\\Bbb{k}");fe("\xB7","\\cdotp");fe("\\llap","\\mathllap{\\textrm{#1}}");fe("\\rlap","\\mathrlap{\\textrm{#1}}");fe("\\clap","\\mathclap{\\textrm{#1}}");fe("\\mathstrut","\\vphantom{(}");fe("\\underbar","\\underline{\\text{#1}}");fe("\\not",'\\html@mathml{\\mathrel{\\mathrlap\\@not}}{\\char"338}');fe("\\neq","\\html@mathml{\\mathrel{\\not=}}{\\mathrel{\\char`\u2260}}");fe("\\ne","\\neq");fe("\u2260","\\neq");fe("\\notin","\\html@mathml{\\mathrel{{\\in}\\mathllap{/\\mskip1mu}}}{\\mathrel{\\char`\u2209}}");fe("\u2209","\\notin");fe("\u2258","\\html@mathml{\\mathrel{=\\kern{-1em}\\raisebox{0.4em}{$\\scriptsize\\frown$}}}{\\mathrel{\\char`\u2258}}");fe("\u2259","\\html@mathml{\\stackrel{\\tiny\\wedge}{=}}{\\mathrel{\\char`\u2258}}");fe("\u225A","\\html@mathml{\\stackrel{\\tiny\\vee}{=}}{\\mathrel{\\char`\u225A}}");fe("\u225B","\\html@mathml{\\stackrel{\\scriptsize\\star}{=}}{\\mathrel{\\char`\u225B}}");fe("\u225D","\\html@mathml{\\stackrel{\\tiny\\mathrm{def}}{=}}{\\mathrel{\\char`\u225D}}");fe("\u225E","\\html@mathml{\\stackrel{\\tiny\\mathrm{m}}{=}}{\\mathrel{\\char`\u225E}}");fe("\u225F","\\html@mathml{\\stackrel{\\tiny?}{=}}{\\mathrel{\\char`\u225F}}");fe("\u27C2","\\perp");fe("\u203C","\\mathclose{!\\mkern-0.8mu!}");fe("\u220C","\\notni");fe("\u231C","\\ulcorner");fe("\u231D","\\urcorner");fe("\u231E","\\llcorner");fe("\u231F","\\lrcorner");fe("\xA9","\\copyright");fe("\xAE","\\textregistered");fe("\uFE0F","\\textregistered");fe("\\ulcorner",'\\html@mathml{\\@ulcorner}{\\mathop{\\char"231c}}');fe("\\urcorner",'\\html@mathml{\\@urcorner}{\\mathop{\\char"231d}}');fe("\\llcorner",'\\html@mathml{\\@llcorner}{\\mathop{\\char"231e}}');fe("\\lrcorner",'\\html@mathml{\\@lrcorner}{\\mathop{\\char"231f}}');fe("\\vdots","\\mathord{\\varvdots\\rule{0pt}{15pt}}");fe("\u22EE","\\vdots");fe("\\varGamma","\\mathit{\\Gamma}");fe("\\varDelta","\\mathit{\\Delta}");fe("\\varTheta","\\mathit{\\Theta}");fe("\\varLambda","\\mathit{\\Lambda}");fe("\\varXi","\\mathit{\\Xi}");fe("\\varPi","\\mathit{\\Pi}");fe("\\varSigma","\\mathit{\\Sigma}");fe("\\varUpsilon","\\mathit{\\Upsilon}");fe("\\varPhi","\\mathit{\\Phi}");fe("\\varPsi","\\mathit{\\Psi}");fe("\\varOmega","\\mathit{\\Omega}");fe("\\substack","\\begin{subarray}{c}#1\\end{subarray}");fe("\\colon","\\nobreak\\mskip2mu\\mathpunct{}\\mathchoice{\\mkern-3mu}{\\mkern-3mu}{}{}{:}\\mskip6mu\\relax");fe("\\boxed","\\fbox{$\\displaystyle{#1}$}");fe("\\iff","\\DOTSB\\;\\Longleftrightarrow\\;");fe("\\implies","\\DOTSB\\;\\Longrightarrow\\;");fe("\\impliedby","\\DOTSB\\;\\Longleftarrow\\;");Oz={",":"\\dotsc","\\not":"\\dotsb","+":"\\dotsb","=":"\\dotsb","<":"\\dotsb",">":"\\dotsb","-":"\\dotsb","*":"\\dotsb",":":"\\dotsb","\\DOTSB":"\\dotsb","\\coprod":"\\dotsb","\\bigvee":"\\dotsb","\\bigwedge":"\\dotsb","\\biguplus":"\\dotsb","\\bigcap":"\\dotsb","\\bigcup":"\\dotsb","\\prod":"\\dotsb","\\sum":"\\dotsb","\\bigotimes":"\\dotsb","\\bigoplus":"\\dotsb","\\bigodot":"\\dotsb","\\bigsqcup":"\\dotsb","\\And":"\\dotsb","\\longrightarrow":"\\dotsb","\\Longrightarrow":"\\dotsb","\\longleftarrow":"\\dotsb","\\Longleftarrow":"\\dotsb","\\longleftrightarrow":"\\dotsb","\\Longleftrightarrow":"\\dotsb","\\mapsto":"\\dotsb","\\longmapsto":"\\dotsb","\\hookrightarrow":"\\dotsb","\\doteq":"\\dotsb","\\mathbin":"\\dotsb","\\mathrel":"\\dotsb","\\relbar":"\\dotsb","\\Relbar":"\\dotsb","\\xrightarrow":"\\dotsb","\\xleftarrow":"\\dotsb","\\DOTSI":"\\dotsi","\\int":"\\dotsi","\\oint":"\\dotsi","\\iint":"\\dotsi","\\iiint":"\\dotsi","\\iiiint":"\\dotsi","\\idotsint":"\\dotsi","\\DOTSX":"\\dotsx"};fe("\\dots",function(t){var e="\\dotso",r=t.expandAfterFuture().text;return r in Oz?e=Oz[r]:(r.slice(0,4)==="\\not"||r in An.math&&Jt.contains(["bin","rel"],An.math[r].group))&&(e="\\dotsb"),e});K7={")":!0,"]":!0,"\\rbrack":!0,"\\}":!0,"\\rbrace":!0,"\\rangle":!0,"\\rceil":!0,"\\rfloor":!0,"\\rgroup":!0,"\\rmoustache":!0,"\\right":!0,"\\bigr":!0,"\\biggr":!0,"\\Bigr":!0,"\\Biggr":!0,$:!0,";":!0,".":!0,",":!0};fe("\\dotso",function(t){var e=t.future().text;return e in K7?"\\ldots\\,":"\\ldots"});fe("\\dotsc",function(t){var e=t.future().text;return e in K7&&e!==","?"\\ldots\\,":"\\ldots"});fe("\\cdots",function(t){var e=t.future().text;return e in K7?"\\@cdots\\,":"\\@cdots"});fe("\\dotsb","\\cdots");fe("\\dotsm","\\cdots");fe("\\dotsi","\\!\\cdots");fe("\\dotsx","\\ldots\\,");fe("\\DOTSI","\\relax");fe("\\DOTSB","\\relax");fe("\\DOTSX","\\relax");fe("\\tmspace","\\TextOrMath{\\kern#1#3}{\\mskip#1#2}\\relax");fe("\\,","\\tmspace+{3mu}{.1667em}");fe("\\thinspace","\\,");fe("\\>","\\mskip{4mu}");fe("\\:","\\tmspace+{4mu}{.2222em}");fe("\\medspace","\\:");fe("\\;","\\tmspace+{5mu}{.2777em}");fe("\\thickspace","\\;");fe("\\!","\\tmspace-{3mu}{.1667em}");fe("\\negthinspace","\\!");fe("\\negmedspace","\\tmspace-{4mu}{.2222em}");fe("\\negthickspace","\\tmspace-{5mu}{.277em}");fe("\\enspace","\\kern.5em ");fe("\\enskip","\\hskip.5em\\relax");fe("\\quad","\\hskip1em\\relax");fe("\\qquad","\\hskip2em\\relax");fe("\\tag","\\@ifstar\\tag@literal\\tag@paren");fe("\\tag@paren","\\tag@literal{({#1})}");fe("\\tag@literal",t=>{if(t.macros.get("\\df@tag"))throw new gt("Multiple \\tag");return"\\gdef\\df@tag{\\text{#1}}"});fe("\\bmod","\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}\\mathbin{\\rm mod}\\mathchoice{\\mskip1mu}{\\mskip1mu}{\\mskip5mu}{\\mskip5mu}");fe("\\pod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern8mu}{\\mkern8mu}{\\mkern8mu}(#1)");fe("\\pmod","\\pod{{\\rm mod}\\mkern6mu#1}");fe("\\mod","\\allowbreak\\mathchoice{\\mkern18mu}{\\mkern12mu}{\\mkern12mu}{\\mkern12mu}{\\rm mod}\\,\\,#1");fe("\\newline","\\\\\\relax");fe("\\TeX","\\textrm{\\html@mathml{T\\kern-.1667em\\raisebox{-.5ex}{E}\\kern-.125emX}{TeX}}");_G=kt(jl["Main-Regular"][84][1]-.7*jl["Main-Regular"][65][1]);fe("\\LaTeX","\\textrm{\\html@mathml{"+("L\\kern-.36em\\raisebox{"+_G+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{LaTeX}}");fe("\\KaTeX","\\textrm{\\html@mathml{"+("K\\kern-.17em\\raisebox{"+_G+"}{\\scriptstyle A}")+"\\kern-.15em\\TeX}{KaTeX}}");fe("\\hspace","\\@ifstar\\@hspacer\\@hspace");fe("\\@hspace","\\hskip #1\\relax");fe("\\@hspacer","\\rule{0pt}{0pt}\\hskip #1\\relax");fe("\\ordinarycolon",":");fe("\\vcentcolon","\\mathrel{\\mathop\\ordinarycolon}");fe("\\dblcolon",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-.9mu}\\vcentcolon}}{\\mathop{\\char"2237}}');fe("\\coloneqq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2254}}');fe("\\Coloneqq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}=}}{\\mathop{\\char"2237\\char"3d}}');fe("\\coloneq",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"3a\\char"2212}}');fe("\\Coloneq",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\mathrel{-}}}{\\mathop{\\char"2237\\char"2212}}');fe("\\eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2255}}');fe("\\Eqqcolon",'\\html@mathml{\\mathrel{=\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"3d\\char"2237}}');fe("\\eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\vcentcolon}}{\\mathop{\\char"2239}}');fe("\\Eqcolon",'\\html@mathml{\\mathrel{\\mathrel{-}\\mathrel{\\mkern-1.2mu}\\dblcolon}}{\\mathop{\\char"2212\\char"2237}}');fe("\\colonapprox",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"3a\\char"2248}}');fe("\\Colonapprox",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\approx}}{\\mathop{\\char"2237\\char"2248}}');fe("\\colonsim",'\\html@mathml{\\mathrel{\\vcentcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"3a\\char"223c}}');fe("\\Colonsim",'\\html@mathml{\\mathrel{\\dblcolon\\mathrel{\\mkern-1.2mu}\\sim}}{\\mathop{\\char"2237\\char"223c}}');fe("\u2237","\\dblcolon");fe("\u2239","\\eqcolon");fe("\u2254","\\coloneqq");fe("\u2255","\\eqqcolon");fe("\u2A74","\\Coloneqq");fe("\\ratio","\\vcentcolon");fe("\\coloncolon","\\dblcolon");fe("\\colonequals","\\coloneqq");fe("\\coloncolonequals","\\Coloneqq");fe("\\equalscolon","\\eqqcolon");fe("\\equalscoloncolon","\\Eqqcolon");fe("\\colonminus","\\coloneq");fe("\\coloncolonminus","\\Coloneq");fe("\\minuscolon","\\eqcolon");fe("\\minuscoloncolon","\\Eqcolon");fe("\\coloncolonapprox","\\Colonapprox");fe("\\coloncolonsim","\\Colonsim");fe("\\simcolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\vcentcolon}");fe("\\simcoloncolon","\\mathrel{\\sim\\mathrel{\\mkern-1.2mu}\\dblcolon}");fe("\\approxcolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\vcentcolon}");fe("\\approxcoloncolon","\\mathrel{\\approx\\mathrel{\\mkern-1.2mu}\\dblcolon}");fe("\\notni","\\html@mathml{\\not\\ni}{\\mathrel{\\char`\u220C}}");fe("\\limsup","\\DOTSB\\operatorname*{lim\\,sup}");fe("\\liminf","\\DOTSB\\operatorname*{lim\\,inf}");fe("\\injlim","\\DOTSB\\operatorname*{inj\\,lim}");fe("\\projlim","\\DOTSB\\operatorname*{proj\\,lim}");fe("\\varlimsup","\\DOTSB\\operatorname*{\\overline{lim}}");fe("\\varliminf","\\DOTSB\\operatorname*{\\underline{lim}}");fe("\\varinjlim","\\DOTSB\\operatorname*{\\underrightarrow{lim}}");fe("\\varprojlim","\\DOTSB\\operatorname*{\\underleftarrow{lim}}");fe("\\gvertneqq","\\html@mathml{\\@gvertneqq}{\u2269}");fe("\\lvertneqq","\\html@mathml{\\@lvertneqq}{\u2268}");fe("\\ngeqq","\\html@mathml{\\@ngeqq}{\u2271}");fe("\\ngeqslant","\\html@mathml{\\@ngeqslant}{\u2271}");fe("\\nleqq","\\html@mathml{\\@nleqq}{\u2270}");fe("\\nleqslant","\\html@mathml{\\@nleqslant}{\u2270}");fe("\\nshortmid","\\html@mathml{\\@nshortmid}{\u2224}");fe("\\nshortparallel","\\html@mathml{\\@nshortparallel}{\u2226}");fe("\\nsubseteqq","\\html@mathml{\\@nsubseteqq}{\u2288}");fe("\\nsupseteqq","\\html@mathml{\\@nsupseteqq}{\u2289}");fe("\\varsubsetneq","\\html@mathml{\\@varsubsetneq}{\u228A}");fe("\\varsubsetneqq","\\html@mathml{\\@varsubsetneqq}{\u2ACB}");fe("\\varsupsetneq","\\html@mathml{\\@varsupsetneq}{\u228B}");fe("\\varsupsetneqq","\\html@mathml{\\@varsupsetneqq}{\u2ACC}");fe("\\imath","\\html@mathml{\\@imath}{\u0131}");fe("\\jmath","\\html@mathml{\\@jmath}{\u0237}");fe("\\llbracket","\\html@mathml{\\mathopen{[\\mkern-3.2mu[}}{\\mathopen{\\char`\u27E6}}");fe("\\rrbracket","\\html@mathml{\\mathclose{]\\mkern-3.2mu]}}{\\mathclose{\\char`\u27E7}}");fe("\u27E6","\\llbracket");fe("\u27E7","\\rrbracket");fe("\\lBrace","\\html@mathml{\\mathopen{\\{\\mkern-3.2mu[}}{\\mathopen{\\char`\u2983}}");fe("\\rBrace","\\html@mathml{\\mathclose{]\\mkern-3.2mu\\}}}{\\mathclose{\\char`\u2984}}");fe("\u2983","\\lBrace");fe("\u2984","\\rBrace");fe("\\minuso","\\mathbin{\\html@mathml{{\\mathrlap{\\mathchoice{\\kern{0.145em}}{\\kern{0.145em}}{\\kern{0.1015em}}{\\kern{0.0725em}}\\circ}{-}}}{\\char`\u29B5}}");fe("\u29B5","\\minuso");fe("\\darr","\\downarrow");fe("\\dArr","\\Downarrow");fe("\\Darr","\\Downarrow");fe("\\lang","\\langle");fe("\\rang","\\rangle");fe("\\uarr","\\uparrow");fe("\\uArr","\\Uparrow");fe("\\Uarr","\\Uparrow");fe("\\N","\\mathbb{N}");fe("\\R","\\mathbb{R}");fe("\\Z","\\mathbb{Z}");fe("\\alef","\\aleph");fe("\\alefsym","\\aleph");fe("\\Alpha","\\mathrm{A}");fe("\\Beta","\\mathrm{B}");fe("\\bull","\\bullet");fe("\\Chi","\\mathrm{X}");fe("\\clubs","\\clubsuit");fe("\\cnums","\\mathbb{C}");fe("\\Complex","\\mathbb{C}");fe("\\Dagger","\\ddagger");fe("\\diamonds","\\diamondsuit");fe("\\empty","\\emptyset");fe("\\Epsilon","\\mathrm{E}");fe("\\Eta","\\mathrm{H}");fe("\\exist","\\exists");fe("\\harr","\\leftrightarrow");fe("\\hArr","\\Leftrightarrow");fe("\\Harr","\\Leftrightarrow");fe("\\hearts","\\heartsuit");fe("\\image","\\Im");fe("\\infin","\\infty");fe("\\Iota","\\mathrm{I}");fe("\\isin","\\in");fe("\\Kappa","\\mathrm{K}");fe("\\larr","\\leftarrow");fe("\\lArr","\\Leftarrow");fe("\\Larr","\\Leftarrow");fe("\\lrarr","\\leftrightarrow");fe("\\lrArr","\\Leftrightarrow");fe("\\Lrarr","\\Leftrightarrow");fe("\\Mu","\\mathrm{M}");fe("\\natnums","\\mathbb{N}");fe("\\Nu","\\mathrm{N}");fe("\\Omicron","\\mathrm{O}");fe("\\plusmn","\\pm");fe("\\rarr","\\rightarrow");fe("\\rArr","\\Rightarrow");fe("\\Rarr","\\Rightarrow");fe("\\real","\\Re");fe("\\reals","\\mathbb{R}");fe("\\Reals","\\mathbb{R}");fe("\\Rho","\\mathrm{P}");fe("\\sdot","\\cdot");fe("\\sect","\\S");fe("\\spades","\\spadesuit");fe("\\sub","\\subset");fe("\\sube","\\subseteq");fe("\\supe","\\supseteq");fe("\\Tau","\\mathrm{T}");fe("\\thetasym","\\vartheta");fe("\\weierp","\\wp");fe("\\Zeta","\\mathrm{Z}");fe("\\argmin","\\DOTSB\\operatorname*{arg\\,min}");fe("\\argmax","\\DOTSB\\operatorname*{arg\\,max}");fe("\\plim","\\DOTSB\\mathop{\\operatorname{plim}}\\limits");fe("\\bra","\\mathinner{\\langle{#1}|}");fe("\\ket","\\mathinner{|{#1}\\rangle}");fe("\\braket","\\mathinner{\\langle{#1}\\rangle}");fe("\\Bra","\\left\\langle#1\\right|");fe("\\Ket","\\left|#1\\right\\rangle");DG=o(t=>e=>{var r=e.consumeArg().tokens,n=e.consumeArg().tokens,i=e.consumeArg().tokens,a=e.consumeArg().tokens,s=e.macros.get("|"),l=e.macros.get("\\|");e.macros.beginGroup();var u=o(d=>p=>{t&&(p.macros.set("|",s),i.length&&p.macros.set("\\|",l));var m=d;if(!d&&i.length){var g=p.future();g.text==="|"&&(p.popToken(),m=!0)}return{tokens:m?i:n,numArgs:0}},"midMacro");e.macros.set("|",u(!1)),i.length&&e.macros.set("\\|",u(!0));var h=e.consumeArg().tokens,f=e.expandTokens([...a,...h,...r]);return e.macros.endGroup(),{tokens:f.reverse(),numArgs:0}},"braketHelper");fe("\\bra@ket",DG(!1));fe("\\bra@set",DG(!0));fe("\\Braket","\\bra@ket{\\left\\langle}{\\,\\middle\\vert\\,}{\\,\\middle\\vert\\,}{\\right\\rangle}");fe("\\Set","\\bra@set{\\left\\{\\:}{\\;\\middle\\vert\\;}{\\;\\middle\\Vert\\;}{\\:\\right\\}}");fe("\\set","\\bra@set{\\{\\,}{\\mid}{}{\\,\\}}");fe("\\angln","{\\angl n}");fe("\\blue","\\textcolor{##6495ed}{#1}");fe("\\orange","\\textcolor{##ffa500}{#1}");fe("\\pink","\\textcolor{##ff00af}{#1}");fe("\\red","\\textcolor{##df0030}{#1}");fe("\\green","\\textcolor{##28ae7b}{#1}");fe("\\gray","\\textcolor{gray}{#1}");fe("\\purple","\\textcolor{##9d38bd}{#1}");fe("\\blueA","\\textcolor{##ccfaff}{#1}");fe("\\blueB","\\textcolor{##80f6ff}{#1}");fe("\\blueC","\\textcolor{##63d9ea}{#1}");fe("\\blueD","\\textcolor{##11accd}{#1}");fe("\\blueE","\\textcolor{##0c7f99}{#1}");fe("\\tealA","\\textcolor{##94fff5}{#1}");fe("\\tealB","\\textcolor{##26edd5}{#1}");fe("\\tealC","\\textcolor{##01d1c1}{#1}");fe("\\tealD","\\textcolor{##01a995}{#1}");fe("\\tealE","\\textcolor{##208170}{#1}");fe("\\greenA","\\textcolor{##b6ffb0}{#1}");fe("\\greenB","\\textcolor{##8af281}{#1}");fe("\\greenC","\\textcolor{##74cf70}{#1}");fe("\\greenD","\\textcolor{##1fab54}{#1}");fe("\\greenE","\\textcolor{##0d923f}{#1}");fe("\\goldA","\\textcolor{##ffd0a9}{#1}");fe("\\goldB","\\textcolor{##ffbb71}{#1}");fe("\\goldC","\\textcolor{##ff9c39}{#1}");fe("\\goldD","\\textcolor{##e07d10}{#1}");fe("\\goldE","\\textcolor{##a75a05}{#1}");fe("\\redA","\\textcolor{##fca9a9}{#1}");fe("\\redB","\\textcolor{##ff8482}{#1}");fe("\\redC","\\textcolor{##f9685d}{#1}");fe("\\redD","\\textcolor{##e84d39}{#1}");fe("\\redE","\\textcolor{##bc2612}{#1}");fe("\\maroonA","\\textcolor{##ffbde0}{#1}");fe("\\maroonB","\\textcolor{##ff92c6}{#1}");fe("\\maroonC","\\textcolor{##ed5fa6}{#1}");fe("\\maroonD","\\textcolor{##ca337c}{#1}");fe("\\maroonE","\\textcolor{##9e034e}{#1}");fe("\\purpleA","\\textcolor{##ddd7ff}{#1}");fe("\\purpleB","\\textcolor{##c6b9fc}{#1}");fe("\\purpleC","\\textcolor{##aa87ff}{#1}");fe("\\purpleD","\\textcolor{##7854ab}{#1}");fe("\\purpleE","\\textcolor{##543b78}{#1}");fe("\\mintA","\\textcolor{##f5f9e8}{#1}");fe("\\mintB","\\textcolor{##edf2df}{#1}");fe("\\mintC","\\textcolor{##e0e5cc}{#1}");fe("\\grayA","\\textcolor{##f6f7f7}{#1}");fe("\\grayB","\\textcolor{##f0f1f2}{#1}");fe("\\grayC","\\textcolor{##e3e5e6}{#1}");fe("\\grayD","\\textcolor{##d6d8da}{#1}");fe("\\grayE","\\textcolor{##babec2}{#1}");fe("\\grayF","\\textcolor{##888d93}{#1}");fe("\\grayG","\\textcolor{##626569}{#1}");fe("\\grayH","\\textcolor{##3b3e40}{#1}");fe("\\grayI","\\textcolor{##21242c}{#1}");fe("\\kaBlue","\\textcolor{##314453}{#1}");fe("\\kaGreen","\\textcolor{##71B307}{#1}");LG={"^":!0,_:!0,"\\limits":!0,"\\nolimits":!0},I7=class{static{o(this,"MacroExpander")}constructor(e,r,n){this.settings=void 0,this.expansionCount=void 0,this.lexer=void 0,this.macros=void 0,this.stack=void 0,this.mode=void 0,this.settings=r,this.expansionCount=0,this.feed(e),this.macros=new M7(V4e,r.macros),this.mode=n,this.stack=[]}feed(e){this.lexer=new y3(e,this.settings)}switchMode(e){this.mode=e}beginGroup(){this.macros.beginGroup()}endGroup(){this.macros.endGroup()}endGroups(){this.macros.endGroups()}future(){return this.stack.length===0&&this.pushToken(this.lexer.lex()),this.stack[this.stack.length-1]}popToken(){return this.future(),this.stack.pop()}pushToken(e){this.stack.push(e)}pushTokens(e){this.stack.push(...e)}scanArgument(e){var r,n,i;if(e){if(this.consumeSpaces(),this.future().text!=="[")return null;r=this.popToken(),{tokens:i,end:n}=this.consumeArg(["]"])}else({tokens:i,start:r,end:n}=this.consumeArg());return this.pushToken(new So("EOF",n.loc)),this.pushTokens(i),r.range(n,"")}consumeSpaces(){for(;;){var e=this.future();if(e.text===" ")this.stack.pop();else break}}consumeArg(e){var r=[],n=e&&e.length>0;n||this.consumeSpaces();var i=this.future(),a,s=0,l=0;do{if(a=this.popToken(),r.push(a),a.text==="{")++s;else if(a.text==="}"){if(--s,s===-1)throw new gt("Extra }",a)}else if(a.text==="EOF")throw new gt("Unexpected end of input in a macro argument, expected '"+(e&&n?e[l]:"}")+"'",a);if(e&&n)if((s===0||s===1&&e[l]==="{")&&a.text===e[l]){if(++l,l===e.length){r.splice(-l,l);break}}else l=0}while(s!==0||n);return i.text==="{"&&r[r.length-1].text==="}"&&(r.pop(),r.shift()),r.reverse(),{tokens:r,start:i,end:a}}consumeArgs(e,r){if(r){if(r.length!==e+1)throw new gt("The length of delimiters doesn't match the number of args!");for(var n=r[0],i=0;ithis.settings.maxExpand)throw new gt("Too many expansions: infinite loop or need to increase maxExpand setting")}expandOnce(e){var r=this.popToken(),n=r.text,i=r.noexpand?null:this._getExpansion(n);if(i==null||e&&i.unexpandable){if(e&&i==null&&n[0]==="\\"&&!this.isDefined(n))throw new gt("Undefined control sequence: "+n);return this.pushToken(r),!1}this.countExpansion(1);var a=i.tokens,s=this.consumeArgs(i.numArgs,i.delimiters);if(i.numArgs){a=a.slice();for(var l=a.length-1;l>=0;--l){var u=a[l];if(u.text==="#"){if(l===0)throw new gt("Incomplete placeholder at end of macro body",u);if(u=a[--l],u.text==="#")a.splice(l+1,1);else if(/^[1-9]$/.test(u.text))a.splice(l,2,...s[+u.text-1]);else throw new gt("Not a valid argument number",u)}}}return this.pushTokens(a),a.length}expandAfterFuture(){return this.expandOnce(),this.future()}expandNextToken(){for(;;)if(this.expandOnce()===!1){var e=this.stack.pop();return e.treatAsRelax&&(e.text="\\relax"),e}throw new Error}expandMacro(e){return this.macros.has(e)?this.expandTokens([new So(e)]):void 0}expandTokens(e){var r=[],n=this.stack.length;for(this.pushTokens(e);this.stack.length>n;)if(this.expandOnce(!0)===!1){var i=this.stack.pop();i.treatAsRelax&&(i.noexpand=!1,i.treatAsRelax=!1),r.push(i)}return this.countExpansion(r.length),r}expandMacroAsText(e){var r=this.expandMacro(e);return r&&r.map(n=>n.text).join("")}_getExpansion(e){var r=this.macros.get(e);if(r==null)return r;if(e.length===1){var n=this.lexer.catcodes[e];if(n!=null&&n!==13)return}var i=typeof r=="function"?r(this):r;if(typeof i=="string"){var a=0;if(i.indexOf("#")!==-1)for(var s=i.replace(/##/g,"");s.indexOf("#"+(a+1))!==-1;)++a;for(var l=new y3(i,this.settings),u=[],h=l.lex();h.text!=="EOF";)u.push(h),h=l.lex();u.reverse();var f={tokens:u,numArgs:a};return f}return i}isDefined(e){return this.macros.has(e)||hh.hasOwnProperty(e)||An.math.hasOwnProperty(e)||An.text.hasOwnProperty(e)||LG.hasOwnProperty(e)}isExpandable(e){var r=this.macros.get(e);return r!=null?typeof r=="string"||typeof r=="function"||!r.unexpandable:hh.hasOwnProperty(e)&&!hh[e].primitive}},Pz=/^[₊₋₌₍₎₀₁₂₃₄₅₆₇₈₉ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓᵦᵧᵨᵩᵪ]/,l3=Object.freeze({"\u208A":"+","\u208B":"-","\u208C":"=","\u208D":"(","\u208E":")","\u2080":"0","\u2081":"1","\u2082":"2","\u2083":"3","\u2084":"4","\u2085":"5","\u2086":"6","\u2087":"7","\u2088":"8","\u2089":"9","\u2090":"a","\u2091":"e","\u2095":"h","\u1D62":"i","\u2C7C":"j","\u2096":"k","\u2097":"l","\u2098":"m","\u2099":"n","\u2092":"o","\u209A":"p","\u1D63":"r","\u209B":"s","\u209C":"t","\u1D64":"u","\u1D65":"v","\u2093":"x","\u1D66":"\u03B2","\u1D67":"\u03B3","\u1D68":"\u03C1","\u1D69":"\u03D5","\u1D6A":"\u03C7","\u207A":"+","\u207B":"-","\u207C":"=","\u207D":"(","\u207E":")","\u2070":"0","\xB9":"1","\xB2":"2","\xB3":"3","\u2074":"4","\u2075":"5","\u2076":"6","\u2077":"7","\u2078":"8","\u2079":"9","\u1D2C":"A","\u1D2E":"B","\u1D30":"D","\u1D31":"E","\u1D33":"G","\u1D34":"H","\u1D35":"I","\u1D36":"J","\u1D37":"K","\u1D38":"L","\u1D39":"M","\u1D3A":"N","\u1D3C":"O","\u1D3E":"P","\u1D3F":"R","\u1D40":"T","\u1D41":"U","\u2C7D":"V","\u1D42":"W","\u1D43":"a","\u1D47":"b","\u1D9C":"c","\u1D48":"d","\u1D49":"e","\u1DA0":"f","\u1D4D":"g",\u02B0:"h","\u2071":"i",\u02B2:"j","\u1D4F":"k",\u02E1:"l","\u1D50":"m",\u207F:"n","\u1D52":"o","\u1D56":"p",\u02B3:"r",\u02E2:"s","\u1D57":"t","\u1D58":"u","\u1D5B":"v",\u02B7:"w",\u02E3:"x",\u02B8:"y","\u1DBB":"z","\u1D5D":"\u03B2","\u1D5E":"\u03B3","\u1D5F":"\u03B4","\u1D60":"\u03D5","\u1D61":"\u03C7","\u1DBF":"\u03B8"}),T7={"\u0301":{text:"\\'",math:"\\acute"},"\u0300":{text:"\\`",math:"\\grave"},"\u0308":{text:'\\"',math:"\\ddot"},"\u0303":{text:"\\~",math:"\\tilde"},"\u0304":{text:"\\=",math:"\\bar"},"\u0306":{text:"\\u",math:"\\breve"},"\u030C":{text:"\\v",math:"\\check"},"\u0302":{text:"\\^",math:"\\hat"},"\u0307":{text:"\\.",math:"\\dot"},"\u030A":{text:"\\r",math:"\\mathring"},"\u030B":{text:"\\H"},"\u0327":{text:"\\c"}},Bz={\u00E1:"a\u0301",\u00E0:"a\u0300",\u00E4:"a\u0308",\u01DF:"a\u0308\u0304",\u00E3:"a\u0303",\u0101:"a\u0304",\u0103:"a\u0306",\u1EAF:"a\u0306\u0301",\u1EB1:"a\u0306\u0300",\u1EB5:"a\u0306\u0303",\u01CE:"a\u030C",\u00E2:"a\u0302",\u1EA5:"a\u0302\u0301",\u1EA7:"a\u0302\u0300",\u1EAB:"a\u0302\u0303",\u0227:"a\u0307",\u01E1:"a\u0307\u0304",\u00E5:"a\u030A",\u01FB:"a\u030A\u0301",\u1E03:"b\u0307",\u0107:"c\u0301",\u1E09:"c\u0327\u0301",\u010D:"c\u030C",\u0109:"c\u0302",\u010B:"c\u0307",\u00E7:"c\u0327",\u010F:"d\u030C",\u1E0B:"d\u0307",\u1E11:"d\u0327",\u00E9:"e\u0301",\u00E8:"e\u0300",\u00EB:"e\u0308",\u1EBD:"e\u0303",\u0113:"e\u0304",\u1E17:"e\u0304\u0301",\u1E15:"e\u0304\u0300",\u0115:"e\u0306",\u1E1D:"e\u0327\u0306",\u011B:"e\u030C",\u00EA:"e\u0302",\u1EBF:"e\u0302\u0301",\u1EC1:"e\u0302\u0300",\u1EC5:"e\u0302\u0303",\u0117:"e\u0307",\u0229:"e\u0327",\u1E1F:"f\u0307",\u01F5:"g\u0301",\u1E21:"g\u0304",\u011F:"g\u0306",\u01E7:"g\u030C",\u011D:"g\u0302",\u0121:"g\u0307",\u0123:"g\u0327",\u1E27:"h\u0308",\u021F:"h\u030C",\u0125:"h\u0302",\u1E23:"h\u0307",\u1E29:"h\u0327",\u00ED:"i\u0301",\u00EC:"i\u0300",\u00EF:"i\u0308",\u1E2F:"i\u0308\u0301",\u0129:"i\u0303",\u012B:"i\u0304",\u012D:"i\u0306",\u01D0:"i\u030C",\u00EE:"i\u0302",\u01F0:"j\u030C",\u0135:"j\u0302",\u1E31:"k\u0301",\u01E9:"k\u030C",\u0137:"k\u0327",\u013A:"l\u0301",\u013E:"l\u030C",\u013C:"l\u0327",\u1E3F:"m\u0301",\u1E41:"m\u0307",\u0144:"n\u0301",\u01F9:"n\u0300",\u00F1:"n\u0303",\u0148:"n\u030C",\u1E45:"n\u0307",\u0146:"n\u0327",\u00F3:"o\u0301",\u00F2:"o\u0300",\u00F6:"o\u0308",\u022B:"o\u0308\u0304",\u00F5:"o\u0303",\u1E4D:"o\u0303\u0301",\u1E4F:"o\u0303\u0308",\u022D:"o\u0303\u0304",\u014D:"o\u0304",\u1E53:"o\u0304\u0301",\u1E51:"o\u0304\u0300",\u014F:"o\u0306",\u01D2:"o\u030C",\u00F4:"o\u0302",\u1ED1:"o\u0302\u0301",\u1ED3:"o\u0302\u0300",\u1ED7:"o\u0302\u0303",\u022F:"o\u0307",\u0231:"o\u0307\u0304",\u0151:"o\u030B",\u1E55:"p\u0301",\u1E57:"p\u0307",\u0155:"r\u0301",\u0159:"r\u030C",\u1E59:"r\u0307",\u0157:"r\u0327",\u015B:"s\u0301",\u1E65:"s\u0301\u0307",\u0161:"s\u030C",\u1E67:"s\u030C\u0307",\u015D:"s\u0302",\u1E61:"s\u0307",\u015F:"s\u0327",\u1E97:"t\u0308",\u0165:"t\u030C",\u1E6B:"t\u0307",\u0163:"t\u0327",\u00FA:"u\u0301",\u00F9:"u\u0300",\u00FC:"u\u0308",\u01D8:"u\u0308\u0301",\u01DC:"u\u0308\u0300",\u01D6:"u\u0308\u0304",\u01DA:"u\u0308\u030C",\u0169:"u\u0303",\u1E79:"u\u0303\u0301",\u016B:"u\u0304",\u1E7B:"u\u0304\u0308",\u016D:"u\u0306",\u01D4:"u\u030C",\u00FB:"u\u0302",\u016F:"u\u030A",\u0171:"u\u030B",\u1E7D:"v\u0303",\u1E83:"w\u0301",\u1E81:"w\u0300",\u1E85:"w\u0308",\u0175:"w\u0302",\u1E87:"w\u0307",\u1E98:"w\u030A",\u1E8D:"x\u0308",\u1E8B:"x\u0307",\u00FD:"y\u0301",\u1EF3:"y\u0300",\u00FF:"y\u0308",\u1EF9:"y\u0303",\u0233:"y\u0304",\u0177:"y\u0302",\u1E8F:"y\u0307",\u1E99:"y\u030A",\u017A:"z\u0301",\u017E:"z\u030C",\u1E91:"z\u0302",\u017C:"z\u0307",\u00C1:"A\u0301",\u00C0:"A\u0300",\u00C4:"A\u0308",\u01DE:"A\u0308\u0304",\u00C3:"A\u0303",\u0100:"A\u0304",\u0102:"A\u0306",\u1EAE:"A\u0306\u0301",\u1EB0:"A\u0306\u0300",\u1EB4:"A\u0306\u0303",\u01CD:"A\u030C",\u00C2:"A\u0302",\u1EA4:"A\u0302\u0301",\u1EA6:"A\u0302\u0300",\u1EAA:"A\u0302\u0303",\u0226:"A\u0307",\u01E0:"A\u0307\u0304",\u00C5:"A\u030A",\u01FA:"A\u030A\u0301",\u1E02:"B\u0307",\u0106:"C\u0301",\u1E08:"C\u0327\u0301",\u010C:"C\u030C",\u0108:"C\u0302",\u010A:"C\u0307",\u00C7:"C\u0327",\u010E:"D\u030C",\u1E0A:"D\u0307",\u1E10:"D\u0327",\u00C9:"E\u0301",\u00C8:"E\u0300",\u00CB:"E\u0308",\u1EBC:"E\u0303",\u0112:"E\u0304",\u1E16:"E\u0304\u0301",\u1E14:"E\u0304\u0300",\u0114:"E\u0306",\u1E1C:"E\u0327\u0306",\u011A:"E\u030C",\u00CA:"E\u0302",\u1EBE:"E\u0302\u0301",\u1EC0:"E\u0302\u0300",\u1EC4:"E\u0302\u0303",\u0116:"E\u0307",\u0228:"E\u0327",\u1E1E:"F\u0307",\u01F4:"G\u0301",\u1E20:"G\u0304",\u011E:"G\u0306",\u01E6:"G\u030C",\u011C:"G\u0302",\u0120:"G\u0307",\u0122:"G\u0327",\u1E26:"H\u0308",\u021E:"H\u030C",\u0124:"H\u0302",\u1E22:"H\u0307",\u1E28:"H\u0327",\u00CD:"I\u0301",\u00CC:"I\u0300",\u00CF:"I\u0308",\u1E2E:"I\u0308\u0301",\u0128:"I\u0303",\u012A:"I\u0304",\u012C:"I\u0306",\u01CF:"I\u030C",\u00CE:"I\u0302",\u0130:"I\u0307",\u0134:"J\u0302",\u1E30:"K\u0301",\u01E8:"K\u030C",\u0136:"K\u0327",\u0139:"L\u0301",\u013D:"L\u030C",\u013B:"L\u0327",\u1E3E:"M\u0301",\u1E40:"M\u0307",\u0143:"N\u0301",\u01F8:"N\u0300",\u00D1:"N\u0303",\u0147:"N\u030C",\u1E44:"N\u0307",\u0145:"N\u0327",\u00D3:"O\u0301",\u00D2:"O\u0300",\u00D6:"O\u0308",\u022A:"O\u0308\u0304",\u00D5:"O\u0303",\u1E4C:"O\u0303\u0301",\u1E4E:"O\u0303\u0308",\u022C:"O\u0303\u0304",\u014C:"O\u0304",\u1E52:"O\u0304\u0301",\u1E50:"O\u0304\u0300",\u014E:"O\u0306",\u01D1:"O\u030C",\u00D4:"O\u0302",\u1ED0:"O\u0302\u0301",\u1ED2:"O\u0302\u0300",\u1ED6:"O\u0302\u0303",\u022E:"O\u0307",\u0230:"O\u0307\u0304",\u0150:"O\u030B",\u1E54:"P\u0301",\u1E56:"P\u0307",\u0154:"R\u0301",\u0158:"R\u030C",\u1E58:"R\u0307",\u0156:"R\u0327",\u015A:"S\u0301",\u1E64:"S\u0301\u0307",\u0160:"S\u030C",\u1E66:"S\u030C\u0307",\u015C:"S\u0302",\u1E60:"S\u0307",\u015E:"S\u0327",\u0164:"T\u030C",\u1E6A:"T\u0307",\u0162:"T\u0327",\u00DA:"U\u0301",\u00D9:"U\u0300",\u00DC:"U\u0308",\u01D7:"U\u0308\u0301",\u01DB:"U\u0308\u0300",\u01D5:"U\u0308\u0304",\u01D9:"U\u0308\u030C",\u0168:"U\u0303",\u1E78:"U\u0303\u0301",\u016A:"U\u0304",\u1E7A:"U\u0304\u0308",\u016C:"U\u0306",\u01D3:"U\u030C",\u00DB:"U\u0302",\u016E:"U\u030A",\u0170:"U\u030B",\u1E7C:"V\u0303",\u1E82:"W\u0301",\u1E80:"W\u0300",\u1E84:"W\u0308",\u0174:"W\u0302",\u1E86:"W\u0307",\u1E8C:"X\u0308",\u1E8A:"X\u0307",\u00DD:"Y\u0301",\u1EF2:"Y\u0300",\u0178:"Y\u0308",\u1EF8:"Y\u0303",\u0232:"Y\u0304",\u0176:"Y\u0302",\u1E8E:"Y\u0307",\u0179:"Z\u0301",\u017D:"Z\u030C",\u1E90:"Z\u0302",\u017B:"Z\u0307",\u03AC:"\u03B1\u0301",\u1F70:"\u03B1\u0300",\u1FB1:"\u03B1\u0304",\u1FB0:"\u03B1\u0306",\u03AD:"\u03B5\u0301",\u1F72:"\u03B5\u0300",\u03AE:"\u03B7\u0301",\u1F74:"\u03B7\u0300",\u03AF:"\u03B9\u0301",\u1F76:"\u03B9\u0300",\u03CA:"\u03B9\u0308",\u0390:"\u03B9\u0308\u0301",\u1FD2:"\u03B9\u0308\u0300",\u1FD1:"\u03B9\u0304",\u1FD0:"\u03B9\u0306",\u03CC:"\u03BF\u0301",\u1F78:"\u03BF\u0300",\u03CD:"\u03C5\u0301",\u1F7A:"\u03C5\u0300",\u03CB:"\u03C5\u0308",\u03B0:"\u03C5\u0308\u0301",\u1FE2:"\u03C5\u0308\u0300",\u1FE1:"\u03C5\u0304",\u1FE0:"\u03C5\u0306",\u03CE:"\u03C9\u0301",\u1F7C:"\u03C9\u0300",\u038E:"\u03A5\u0301",\u1FEA:"\u03A5\u0300",\u03AB:"\u03A5\u0308",\u1FE9:"\u03A5\u0304",\u1FE8:"\u03A5\u0306",\u038F:"\u03A9\u0301",\u1FFA:"\u03A9\u0300"},v3=class t{static{o(this,"Parser")}constructor(e,r){this.mode=void 0,this.gullet=void 0,this.settings=void 0,this.leftrightDepth=void 0,this.nextToken=void 0,this.mode="math",this.gullet=new I7(e,r,this.mode),this.settings=r,this.leftrightDepth=0}expect(e,r){if(r===void 0&&(r=!0),this.fetch().text!==e)throw new gt("Expected '"+e+"', got '"+this.fetch().text+"'",this.fetch());r&&this.consume()}consume(){this.nextToken=null}fetch(){return this.nextToken==null&&(this.nextToken=this.gullet.expandNextToken()),this.nextToken}switchMode(e){this.mode=e,this.gullet.switchMode(e)}parse(){this.settings.globalGroup||this.gullet.beginGroup(),this.settings.colorIsTextColor&&this.gullet.macros.set("\\color","\\textcolor");try{var e=this.parseExpression(!1);return this.expect("EOF"),this.settings.globalGroup||this.gullet.endGroup(),e}finally{this.gullet.endGroups()}}subparse(e){var r=this.nextToken;this.consume(),this.gullet.pushToken(new So("}")),this.gullet.pushTokens(e);var n=this.parseExpression(!1);return this.expect("}"),this.nextToken=r,n}parseExpression(e,r){for(var n=[];;){this.mode==="math"&&this.consumeSpaces();var i=this.fetch();if(t.endOfExpression.indexOf(i.text)!==-1||r&&i.text===r||e&&hh[i.text]&&hh[i.text].infix)break;var a=this.parseAtom(r);if(a){if(a.type==="internal")continue}else break;n.push(a)}return this.mode==="text"&&this.formLigatures(n),this.handleInfixNodes(n)}handleInfixNodes(e){for(var r=-1,n,i=0;i=0&&this.settings.reportNonstrict("unicodeTextInMathMode",'Latin-1/Unicode text character "'+r[0]+'" used in math mode',e);var l=An[this.mode][r].group,u=Xs.range(e),h;if(Mbe.hasOwnProperty(l)){var f=l;h={type:"atom",mode:this.mode,family:f,loc:u,text:r}}else h={type:l,mode:this.mode,loc:u,text:r};s=h}else if(r.charCodeAt(0)>=128)this.settings.strict&&($z(r.charCodeAt(0))?this.mode==="math"&&this.settings.reportNonstrict("unicodeTextInMathMode",'Unicode text character "'+r[0]+'" used in math mode',e):this.settings.reportNonstrict("unknownSymbol",'Unrecognized Unicode character "'+r[0]+'"'+(" ("+r.charCodeAt(0)+")"),e)),s={type:"textord",mode:"text",loc:Xs.range(e),text:r};else return null;if(this.consume(),a)for(var d=0;d{e instanceof Element&&e.tagName==="A"&&e.hasAttribute("target")&&e.setAttribute(t,e.getAttribute("target")??"")}),ch.addHook("afterSanitizeAttributes",e=>{e instanceof Element&&e.tagName==="A"&&e.hasAttribute(t)&&(e.setAttribute("target",e.getAttribute(t)??""),e.removeAttribute(t),e.getAttribute("target")==="_blank"&&e.setAttribute("rel","noopener"))})}var nd,Y4e,X4e,BG,OG,Tr,K4e,Q4e,Z4e,J4e,FG,e3e,fr,t3e,r3e,ec,J7,n3e,i3e,PG,eA,pi,id,mh,Ze,gr=N(()=>{"use strict";u7();nd=//gi,Y4e=o(t=>t?FG(t).replace(/\\n/g,"#br#").split("#br#"):[""],"getRows"),X4e=(()=>{let t=!1;return()=>{t||(j4e(),t=!0)}})();o(j4e,"setupDompurifyHooks");BG=o(t=>(X4e(),ch.sanitize(t)),"removeScript"),OG=o((t,e)=>{if(e.flowchart?.htmlLabels!==!1){let r=e.securityLevel;r==="antiscript"||r==="strict"?t=BG(t):r!=="loose"&&(t=FG(t),t=t.replace(//g,">"),t=t.replace(/=/g,"="),t=J4e(t))}return t},"sanitizeMore"),Tr=o((t,e)=>t&&(e.dompurifyConfig?t=ch.sanitize(OG(t,e),e.dompurifyConfig).toString():t=ch.sanitize(OG(t,e),{FORBID_TAGS:["style"]}).toString(),t),"sanitizeText"),K4e=o((t,e)=>typeof t=="string"?Tr(t,e):t.flat().map(r=>Tr(r,e)),"sanitizeTextOrArray"),Q4e=o(t=>nd.test(t),"hasBreaks"),Z4e=o(t=>t.split(nd),"splitBreaks"),J4e=o(t=>t.replace(/#br#/g,"
"),"placeholderToBreak"),FG=o(t=>t.replace(nd,"#br#"),"breakToPlaceholder"),e3e=o(t=>{let e="";return t&&(e=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,e=e.replaceAll(/\(/g,"\\("),e=e.replaceAll(/\)/g,"\\)")),e},"getUrl"),fr=o(t=>!(t===!1||["false","null","0"].includes(String(t).trim().toLowerCase())),"evaluate"),t3e=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.max(...e)},"getMax"),r3e=o(function(...t){let e=t.filter(r=>!isNaN(r));return Math.min(...e)},"getMin"),ec=o(function(t){let e=t.split(/(,)/),r=[];for(let n=0;n0&&n+1Math.max(0,t.split(e).length-1),"countOccurrence"),n3e=o((t,e)=>{let r=J7(t,"~"),n=J7(e,"~");return r===1&&n===1},"shouldCombineSets"),i3e=o(t=>{let e=J7(t,"~"),r=!1;if(e<=1)return t;e%2!==0&&t.startsWith("~")&&(t=t.substring(1),r=!0);let n=[...t],i=n.indexOf("~"),a=n.lastIndexOf("~");for(;i!==-1&&a!==-1&&i!==a;)n[i]="<",n[a]=">",i=n.indexOf("~"),a=n.lastIndexOf("~");return r&&n.unshift("~"),n.join("")},"processSet"),PG=o(()=>window.MathMLElement!==void 0,"isMathMLSupported"),eA=/\$\$(.*)\$\$/g,pi=o(t=>(t.match(eA)?.length??0)>0,"hasKatex"),id=o(async(t,e)=>{t=await mh(t,e);let r=document.createElement("div");r.innerHTML=t,r.id="katex-temp",r.style.visibility="hidden",r.style.position="absolute",r.style.top="0",document.querySelector("body")?.insertAdjacentElement("beforeend",r);let i={width:r.clientWidth,height:r.clientHeight};return r.remove(),i},"calculateMathMLDimensions"),mh=o(async(t,e)=>{if(!pi(t))return t;if(!(PG()||e.legacyMathML||e.forceLegacyMathML))return t.replace(eA,"MathML is unsupported in this environment.");let{default:r}=await Promise.resolve().then(()=>(IG(),MG)),n=e.forceLegacyMathML||!PG()&&e.legacyMathML?"htmlAndMathml":"mathml";return t.split(nd).map(i=>pi(i)?`

${i}
`:`
${i}
`).join("").replace(eA,(i,a)=>r.renderToString(a,{throwOnError:!0,displayMode:!0,output:n}).replace(/\n/g," ").replace(//g,""))},"renderKatex"),Ze={getRows:Y4e,sanitizeText:Tr,sanitizeTextOrArray:K4e,hasBreaks:Q4e,splitBreaks:Z4e,lineBreakRegex:nd,removeScript:BG,getUrl:e3e,evaluate:fr,getMax:t3e,getMin:r3e}});var a3e,s3e,vn,Ao,Ei=N(()=>{"use strict";vt();a3e=o(function(t,e){for(let r of e)t.attr(r[0],r[1])},"d3Attrs"),s3e=o(function(t,e,r){let n=new Map;return r?(n.set("width","100%"),n.set("style",`max-width: ${e}px;`)):(n.set("height",t),n.set("width",e)),n},"calculateSvgSizeAttrs"),vn=o(function(t,e,r,n){let i=s3e(e,r,n);a3e(t,i)},"configureSvgSize"),Ao=o(function(t,e,r,n){let i=e.node().getBBox(),a=i.width,s=i.height;Y.info(`SVG bounds: ${a}x${s}`,i);let l=0,u=0;Y.info(`Graph bounds: ${l}x${u}`,t),l=a+r*2,u=s+r*2,Y.info(`Calculated bounds: ${l}x${u}`),vn(e,u,l,n);let h=`${i.x-r} ${i.y-r} ${i.width+2*r} ${i.height+2*r}`;e.attr("viewBox",h)},"setupGraphViewbox")});var S3,o3e,$G,zG,tA=N(()=>{"use strict";vt();S3={},o3e=o((t,e,r)=>{let n="";return t in S3&&S3[t]?n=S3[t](r):Y.warn(`No theme found for ${t}`),` & { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + fill: ${r.textColor} + } + @keyframes edge-animation-frame { + from { + stroke-dashoffset: 0; + } + } + @keyframes dash { + to { + stroke-dashoffset: 0; + } + } + & .edge-animation-slow { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 50s linear infinite; + stroke-linecap: round; + } + & .edge-animation-fast { + stroke-dasharray: 9,5 !important; + stroke-dashoffset: 900; + animation: dash 20s linear infinite; + stroke-linecap: round; + } + /* Classes common for multiple diagrams */ + + & .error-icon { + fill: ${r.errorBkgColor}; + } + & .error-text { + fill: ${r.errorTextColor}; + stroke: ${r.errorTextColor}; + } + + & .edge-thickness-normal { + stroke-width: 1px; + } + & .edge-thickness-thick { + stroke-width: 3.5px + } + & .edge-pattern-solid { + stroke-dasharray: 0; + } + & .edge-thickness-invisible { + stroke-width: 0; + fill: none; + } + & .edge-pattern-dashed{ + stroke-dasharray: 3; + } + .edge-pattern-dotted { + stroke-dasharray: 2; + } + + & .marker { + fill: ${r.lineColor}; + stroke: ${r.lineColor}; + } + & .marker.cross { + stroke: ${r.lineColor}; + } + + & svg { + font-family: ${r.fontFamily}; + font-size: ${r.fontSize}; + } + & p { + margin: 0 + } + + ${n} + + ${e} +`},"getStyles"),$G=o((t,e)=>{e!==void 0&&(S3[t]=e)},"addStylesForDiagram"),zG=o3e});var qy={};hr(qy,{clear:()=>Ar,getAccDescription:()=>Mr,getAccTitle:()=>Rr,getDiagramTitle:()=>Ir,setAccDescription:()=>Nr,setAccTitle:()=>Lr,setDiagramTitle:()=>$r});var rA,nA,iA,aA,Ar,Lr,Rr,Nr,Mr,$r,Ir,mi=N(()=>{"use strict";gr();ji();rA="",nA="",iA="",aA=o(t=>Tr(t,cr()),"sanitizeText"),Ar=o(()=>{rA="",iA="",nA=""},"clear"),Lr=o(t=>{rA=aA(t).replace(/^\s+/g,"")},"setAccTitle"),Rr=o(()=>rA,"getAccTitle"),Nr=o(t=>{iA=aA(t).replace(/\n\s+/g,` +`)},"setAccDescription"),Mr=o(()=>iA,"getAccDescription"),$r=o(t=>{nA=aA(t)},"setDiagramTitle"),Ir=o(()=>nA,"getDiagramTitle")});var GG,l3e,me,Yy,A3,Xy,oA,c3e,C3,ad,jy,sA,zt=N(()=>{"use strict";Xf();vt();ji();gr();Ei();tA();mi();GG=Y,l3e=wy,me=cr,Yy=X4,A3=lh,Xy=o(t=>Tr(t,me()),"sanitizeText"),oA=Ao,c3e=o(()=>qy,"getCommonDb"),C3={},ad=o((t,e,r)=>{C3[t]&&GG.warn(`Diagram with id ${t} already registered. Overwriting.`),C3[t]=e,r&&FC(t,r),$G(t,e.styles),e.injectUtils?.(GG,l3e,me,Xy,oA,c3e(),()=>{})},"registerDiagram"),jy=o(t=>{if(t in C3)return C3[t];throw new sA(t)},"getDiagram"),sA=class extends Error{static{o(this,"DiagramNotFoundError")}constructor(e){super(`Diagram ${e} not found.`)}}});var ul,gh,Ja,cl,tc,Ky,lA,cA,_3,D3,VG,u3e,h3e,f3e,d3e,p3e,m3e,g3e,y3e,v3e,x3e,b3e,w3e,T3e,k3e,E3e,S3e,C3e,UG,A3e,_3e,HG,D3e,L3e,R3e,N3e,yh,M3e,I3e,O3e,P3e,B3e,Qy,uA=N(()=>{"use strict";zt();gr();mi();ul=[],gh=[""],Ja="global",cl="",tc=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],Ky=[],lA="",cA=!1,_3=4,D3=2,u3e=o(function(){return VG},"getC4Type"),h3e=o(function(t){VG=Tr(t,me())},"setC4Type"),f3e=o(function(t,e,r,n,i,a,s,l,u){if(t==null||e===void 0||e===null||r===void 0||r===null||n===void 0||n===null)return;let h={},f=Ky.find(d=>d.from===e&&d.to===r);if(f?h=f:Ky.push(h),h.type=t,h.from=e,h.to=r,h.label={text:n},i==null)h.techn={text:""};else if(typeof i=="object"){let[d,p]=Object.entries(i)[0];h[d]={text:p}}else h.techn={text:i};if(a==null)h.descr={text:""};else if(typeof a=="object"){let[d,p]=Object.entries(a)[0];h[d]={text:p}}else h.descr={text:a};if(typeof s=="object"){let[d,p]=Object.entries(s)[0];h[d]=p}else h.sprite=s;if(typeof l=="object"){let[d,p]=Object.entries(l)[0];h[d]=p}else h.tags=l;if(typeof u=="object"){let[d,p]=Object.entries(u)[0];h[d]=p}else h.link=u;h.wrap=yh()},"addRel"),d3e=o(function(t,e,r,n,i,a,s){if(e===null||r===null)return;let l={},u=ul.find(h=>h.alias===e);if(u&&e===u.alias?l=u:(l.alias=e,ul.push(l)),r==null?l.label={text:""}:l.label={text:r},n==null)l.descr={text:""};else if(typeof n=="object"){let[h,f]=Object.entries(n)[0];l[h]={text:f}}else l.descr={text:n};if(typeof i=="object"){let[h,f]=Object.entries(i)[0];l[h]=f}else l.sprite=i;if(typeof a=="object"){let[h,f]=Object.entries(a)[0];l[h]=f}else l.tags=a;if(typeof s=="object"){let[h,f]=Object.entries(s)[0];l[h]=f}else l.link=s;l.typeC4Shape={text:t},l.parentBoundary=Ja,l.wrap=yh()},"addPersonOrSystem"),p3e=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ul.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ul.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=yh(),u.typeC4Shape={text:t},u.parentBoundary=Ja},"addContainer"),m3e=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=ul.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,ul.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.techn={text:""};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.techn={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof a=="object"){let[f,d]=Object.entries(a)[0];u[f]=d}else u.sprite=a;if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.wrap=yh(),u.typeC4Shape={text:t},u.parentBoundary=Ja},"addComponent"),g3e=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=tc.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,tc.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"system"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=Ja,a.wrap=yh(),cl=Ja,Ja=t,gh.push(cl)},"addPersonOrSystemBoundary"),y3e=o(function(t,e,r,n,i){if(t===null||e===null)return;let a={},s=tc.find(l=>l.alias===t);if(s&&t===s.alias?a=s:(a.alias=t,tc.push(a)),e==null?a.label={text:""}:a.label={text:e},r==null)a.type={text:"container"};else if(typeof r=="object"){let[l,u]=Object.entries(r)[0];a[l]={text:u}}else a.type={text:r};if(typeof n=="object"){let[l,u]=Object.entries(n)[0];a[l]=u}else a.tags=n;if(typeof i=="object"){let[l,u]=Object.entries(i)[0];a[l]=u}else a.link=i;a.parentBoundary=Ja,a.wrap=yh(),cl=Ja,Ja=t,gh.push(cl)},"addContainerBoundary"),v3e=o(function(t,e,r,n,i,a,s,l){if(e===null||r===null)return;let u={},h=tc.find(f=>f.alias===e);if(h&&e===h.alias?u=h:(u.alias=e,tc.push(u)),r==null?u.label={text:""}:u.label={text:r},n==null)u.type={text:"node"};else if(typeof n=="object"){let[f,d]=Object.entries(n)[0];u[f]={text:d}}else u.type={text:n};if(i==null)u.descr={text:""};else if(typeof i=="object"){let[f,d]=Object.entries(i)[0];u[f]={text:d}}else u.descr={text:i};if(typeof s=="object"){let[f,d]=Object.entries(s)[0];u[f]=d}else u.tags=s;if(typeof l=="object"){let[f,d]=Object.entries(l)[0];u[f]=d}else u.link=l;u.nodeType=t,u.parentBoundary=Ja,u.wrap=yh(),cl=Ja,Ja=e,gh.push(cl)},"addDeploymentNode"),x3e=o(function(){Ja=cl,gh.pop(),cl=gh.pop(),gh.push(cl)},"popBoundaryParseStack"),b3e=o(function(t,e,r,n,i,a,s,l,u,h,f){let d=ul.find(p=>p.alias===e);if(!(d===void 0&&(d=tc.find(p=>p.alias===e),d===void 0))){if(r!=null)if(typeof r=="object"){let[p,m]=Object.entries(r)[0];d[p]=m}else d.bgColor=r;if(n!=null)if(typeof n=="object"){let[p,m]=Object.entries(n)[0];d[p]=m}else d.fontColor=n;if(i!=null)if(typeof i=="object"){let[p,m]=Object.entries(i)[0];d[p]=m}else d.borderColor=i;if(a!=null)if(typeof a=="object"){let[p,m]=Object.entries(a)[0];d[p]=m}else d.shadowing=a;if(s!=null)if(typeof s=="object"){let[p,m]=Object.entries(s)[0];d[p]=m}else d.shape=s;if(l!=null)if(typeof l=="object"){let[p,m]=Object.entries(l)[0];d[p]=m}else d.sprite=l;if(u!=null)if(typeof u=="object"){let[p,m]=Object.entries(u)[0];d[p]=m}else d.techn=u;if(h!=null)if(typeof h=="object"){let[p,m]=Object.entries(h)[0];d[p]=m}else d.legendText=h;if(f!=null)if(typeof f=="object"){let[p,m]=Object.entries(f)[0];d[p]=m}else d.legendSprite=f}},"updateElStyle"),w3e=o(function(t,e,r,n,i,a,s){let l=Ky.find(u=>u.from===e&&u.to===r);if(l!==void 0){if(n!=null)if(typeof n=="object"){let[u,h]=Object.entries(n)[0];l[u]=h}else l.textColor=n;if(i!=null)if(typeof i=="object"){let[u,h]=Object.entries(i)[0];l[u]=h}else l.lineColor=i;if(a!=null)if(typeof a=="object"){let[u,h]=Object.entries(a)[0];l[u]=parseInt(h)}else l.offsetX=parseInt(a);if(s!=null)if(typeof s=="object"){let[u,h]=Object.entries(s)[0];l[u]=parseInt(h)}else l.offsetY=parseInt(s)}},"updateRelStyle"),T3e=o(function(t,e,r){let n=_3,i=D3;if(typeof e=="object"){let a=Object.values(e)[0];n=parseInt(a)}else n=parseInt(e);if(typeof r=="object"){let a=Object.values(r)[0];i=parseInt(a)}else i=parseInt(r);n>=1&&(_3=n),i>=1&&(D3=i)},"updateLayoutConfig"),k3e=o(function(){return _3},"getC4ShapeInRow"),E3e=o(function(){return D3},"getC4BoundaryInRow"),S3e=o(function(){return Ja},"getCurrentBoundaryParse"),C3e=o(function(){return cl},"getParentBoundaryParse"),UG=o(function(t){return t==null?ul:ul.filter(e=>e.parentBoundary===t)},"getC4ShapeArray"),A3e=o(function(t){return ul.find(e=>e.alias===t)},"getC4Shape"),_3e=o(function(t){return Object.keys(UG(t))},"getC4ShapeKeys"),HG=o(function(t){return t==null?tc:tc.filter(e=>e.parentBoundary===t)},"getBoundaries"),D3e=HG,L3e=o(function(){return Ky},"getRels"),R3e=o(function(){return lA},"getTitle"),N3e=o(function(t){cA=t},"setWrap"),yh=o(function(){return cA},"autoWrap"),M3e=o(function(){ul=[],tc=[{alias:"global",label:{text:"global"},type:{text:"global"},tags:null,link:null,parentBoundary:""}],cl="",Ja="global",gh=[""],Ky=[],gh=[""],lA="",cA=!1,_3=4,D3=2},"clear"),I3e={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25},O3e={FILLED:0,OPEN:1},P3e={LEFTOF:0,RIGHTOF:1,OVER:2},B3e=o(function(t){lA=Tr(t,me())},"setTitle"),Qy={addPersonOrSystem:d3e,addPersonOrSystemBoundary:g3e,addContainer:p3e,addContainerBoundary:y3e,addComponent:m3e,addDeploymentNode:v3e,popBoundaryParseStack:x3e,addRel:f3e,updateElStyle:b3e,updateRelStyle:w3e,updateLayoutConfig:T3e,autoWrap:yh,setWrap:N3e,getC4ShapeArray:UG,getC4Shape:A3e,getC4ShapeKeys:_3e,getBoundaries:HG,getBoundarys:D3e,getCurrentBoundaryParse:S3e,getParentBoundaryParse:C3e,getRels:L3e,getTitle:R3e,getC4Type:u3e,getC4ShapeInRow:k3e,getC4BoundaryInRow:E3e,setAccTitle:Lr,getAccTitle:Rr,getAccDescription:Mr,setAccDescription:Nr,getConfig:o(()=>me().c4,"getConfig"),clear:M3e,LINETYPE:I3e,ARROWTYPE:O3e,PLACEMENT:P3e,setTitle:B3e,setC4Type:h3e}});function sd(t,e){return t==null||e==null?NaN:te?1:t>=e?0:NaN}var hA=N(()=>{"use strict";o(sd,"ascending")});function fA(t,e){return t==null||e==null?NaN:et?1:e>=t?0:NaN}var WG=N(()=>{"use strict";o(fA,"descending")});function od(t){let e,r,n;t.length!==2?(e=sd,r=o((l,u)=>sd(t(l),u),"compare2"),n=o((l,u)=>t(l)-u,"delta")):(e=t===sd||t===fA?t:F3e,r=t,n=t);function i(l,u,h=0,f=l.length){if(h>>1;r(l[d],u)<0?h=d+1:f=d}while(h>>1;r(l[d],u)<=0?h=d+1:f=d}while(hh&&n(l[d-1],u)>-n(l[d],u)?d-1:d}return o(s,"center"),{left:i,center:s,right:a}}function F3e(){return 0}var dA=N(()=>{"use strict";hA();WG();o(od,"bisector");o(F3e,"zero")});function pA(t){return t===null?NaN:+t}var qG=N(()=>{"use strict";o(pA,"number")});var YG,XG,$3e,z3e,mA,jG=N(()=>{"use strict";hA();dA();qG();YG=od(sd),XG=YG.right,$3e=YG.left,z3e=od(pA).center,mA=XG});function KG({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):r}function G3e({_intern:t,_key:e},r){let n=e(r);return t.has(n)?t.get(n):(t.set(n,r),r)}function V3e({_intern:t,_key:e},r){let n=e(r);return t.has(n)&&(r=t.get(n),t.delete(n)),r}function U3e(t){return t!==null&&typeof t=="object"?t.valueOf():t}var g0,QG=N(()=>{"use strict";g0=class extends Map{static{o(this,"InternMap")}constructor(e,r=U3e){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:r}}),e!=null)for(let[n,i]of e)this.set(n,i)}get(e){return super.get(KG(this,e))}has(e){return super.has(KG(this,e))}set(e,r){return super.set(G3e(this,e),r)}delete(e){return super.delete(V3e(this,e))}};o(KG,"intern_get");o(G3e,"intern_set");o(V3e,"intern_delete");o(U3e,"keyof")});function L3(t,e,r){let n=(e-t)/Math.max(0,r),i=Math.floor(Math.log10(n)),a=n/Math.pow(10,i),s=a>=H3e?10:a>=W3e?5:a>=q3e?2:1,l,u,h;return i<0?(h=Math.pow(10,-i)/s,l=Math.round(t*h),u=Math.round(e*h),l/he&&--u,h=-h):(h=Math.pow(10,i)*s,l=Math.round(t/h),u=Math.round(e/h),l*he&&--u),u0))return[];if(t===e)return[t];let n=e=i))return[];let l=a-i+1,u=new Array(l);if(n)if(s<0)for(let h=0;h{"use strict";H3e=Math.sqrt(50),W3e=Math.sqrt(10),q3e=Math.sqrt(2);o(L3,"tickSpec");o(R3,"ticks");o(Zy,"tickIncrement");o(y0,"tickStep")});function N3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var JG=N(()=>{"use strict";o(N3,"max")});function M3(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var eV=N(()=>{"use strict";o(M3,"min")});function I3(t,e,r){t=+t,e=+e,r=(i=arguments.length)<2?(e=t,t=0,1):i<3?1:+r;for(var n=-1,i=Math.max(0,Math.ceil((e-t)/r))|0,a=new Array(i);++n{"use strict";o(I3,"range")});var vh=N(()=>{"use strict";jG();dA();JG();eV();tV();ZG();QG()});function gA(t){return t}var rV=N(()=>{"use strict";o(gA,"default")});function Y3e(t){return"translate("+t+",0)"}function X3e(t){return"translate(0,"+t+")"}function j3e(t){return e=>+t(e)}function K3e(t,e){return e=Math.max(0,t.bandwidth()-e*2)/2,t.round()&&(e=Math.round(e)),r=>+t(r)+e}function Q3e(){return!this.__axis}function iV(t,e){var r=[],n=null,i=null,a=6,s=6,l=3,u=typeof window<"u"&&window.devicePixelRatio>1?0:.5,h=t===P3||t===O3?-1:1,f=t===O3||t===yA?"x":"y",d=t===P3||t===vA?Y3e:X3e;function p(m){var g=n??(e.ticks?e.ticks.apply(e,r):e.domain()),y=i??(e.tickFormat?e.tickFormat.apply(e,r):gA),v=Math.max(a,0)+l,x=e.range(),b=+x[0]+u,w=+x[x.length-1]+u,C=(e.bandwidth?K3e:j3e)(e.copy(),u),T=m.selection?m.selection():m,E=T.selectAll(".domain").data([null]),A=T.selectAll(".tick").data(g,e).order(),S=A.exit(),_=A.enter().append("g").attr("class","tick"),I=A.select("line"),D=A.select("text");E=E.merge(E.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),A=A.merge(_),I=I.merge(_.append("line").attr("stroke","currentColor").attr(f+"2",h*a)),D=D.merge(_.append("text").attr("fill","currentColor").attr(f,h*v).attr("dy",t===P3?"0em":t===vA?"0.71em":"0.32em")),m!==T&&(E=E.transition(m),A=A.transition(m),I=I.transition(m),D=D.transition(m),S=S.transition(m).attr("opacity",nV).attr("transform",function(k){return isFinite(k=C(k))?d(k+u):this.getAttribute("transform")}),_.attr("opacity",nV).attr("transform",function(k){var L=this.parentNode.__axis;return d((L&&isFinite(L=L(k))?L:C(k))+u)})),S.remove(),E.attr("d",t===O3||t===yA?s?"M"+h*s+","+b+"H"+u+"V"+w+"H"+h*s:"M"+u+","+b+"V"+w:s?"M"+b+","+h*s+"V"+u+"H"+w+"V"+h*s:"M"+b+","+u+"H"+w),A.attr("opacity",1).attr("transform",function(k){return d(C(k)+u)}),I.attr(f+"2",h*a),D.attr(f,h*v).text(y),T.filter(Q3e).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===yA?"start":t===O3?"end":"middle"),T.each(function(){this.__axis=C})}return o(p,"axis"),p.scale=function(m){return arguments.length?(e=m,p):e},p.ticks=function(){return r=Array.from(arguments),p},p.tickArguments=function(m){return arguments.length?(r=m==null?[]:Array.from(m),p):r.slice()},p.tickValues=function(m){return arguments.length?(n=m==null?null:Array.from(m),p):n&&n.slice()},p.tickFormat=function(m){return arguments.length?(i=m,p):i},p.tickSize=function(m){return arguments.length?(a=s=+m,p):a},p.tickSizeInner=function(m){return arguments.length?(a=+m,p):a},p.tickSizeOuter=function(m){return arguments.length?(s=+m,p):s},p.tickPadding=function(m){return arguments.length?(l=+m,p):l},p.offset=function(m){return arguments.length?(u=+m,p):u},p}function xA(t){return iV(P3,t)}function bA(t){return iV(vA,t)}var P3,yA,vA,O3,nV,aV=N(()=>{"use strict";rV();P3=1,yA=2,vA=3,O3=4,nV=1e-6;o(Y3e,"translateX");o(X3e,"translateY");o(j3e,"number");o(K3e,"center");o(Q3e,"entering");o(iV,"axis");o(xA,"axisTop");o(bA,"axisBottom")});var sV=N(()=>{"use strict";aV()});function lV(){for(var t=0,e=arguments.length,r={},n;t=0&&(n=r.slice(i+1),r=r.slice(0,i)),r&&!e.hasOwnProperty(r))throw new Error("unknown type: "+r);return{type:r,name:n}})}function e5e(t,e){for(var r=0,n=t.length,i;r{"use strict";Z3e={value:o(()=>{},"value")};o(lV,"dispatch");o(B3,"Dispatch");o(J3e,"parseTypenames");B3.prototype=lV.prototype={constructor:B3,on:o(function(t,e){var r=this._,n=J3e(t+"",r),i,a=-1,s=n.length;if(arguments.length<2){for(;++a0)for(var r=new Array(i),n=0,i,a;n{"use strict";cV()});var F3,kA,EA=N(()=>{"use strict";F3="http://www.w3.org/1999/xhtml",kA={svg:"http://www.w3.org/2000/svg",xhtml:F3,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"}});function rc(t){var e=t+="",r=e.indexOf(":");return r>=0&&(e=t.slice(0,r))!=="xmlns"&&(t=t.slice(r+1)),kA.hasOwnProperty(e)?{space:kA[e],local:t}:t}var $3=N(()=>{"use strict";EA();o(rc,"default")});function t5e(t){return function(){var e=this.ownerDocument,r=this.namespaceURI;return r===F3&&e.documentElement.namespaceURI===F3?e.createElement(t):e.createElementNS(r,t)}}function r5e(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Jy(t){var e=rc(t);return(e.local?r5e:t5e)(e)}var SA=N(()=>{"use strict";$3();EA();o(t5e,"creatorInherit");o(r5e,"creatorFixed");o(Jy,"default")});function n5e(){}function xh(t){return t==null?n5e:function(){return this.querySelector(t)}}var z3=N(()=>{"use strict";o(n5e,"none");o(xh,"default")});function CA(t){typeof t!="function"&&(t=xh(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";hl();z3();o(CA,"default")});function AA(t){return t==null?[]:Array.isArray(t)?t:Array.from(t)}var hV=N(()=>{"use strict";o(AA,"array")});function i5e(){return[]}function v0(t){return t==null?i5e:function(){return this.querySelectorAll(t)}}var _A=N(()=>{"use strict";o(i5e,"empty");o(v0,"default")});function a5e(t){return function(){return AA(t.apply(this,arguments))}}function DA(t){typeof t=="function"?t=a5e(t):t=v0(t);for(var e=this._groups,r=e.length,n=[],i=[],a=0;a{"use strict";hl();hV();_A();o(a5e,"arrayAll");o(DA,"default")});function x0(t){return function(){return this.matches(t)}}function G3(t){return function(e){return e.matches(t)}}var ev=N(()=>{"use strict";o(x0,"default");o(G3,"childMatcher")});function o5e(t){return function(){return s5e.call(this.children,t)}}function l5e(){return this.firstElementChild}function LA(t){return this.select(t==null?l5e:o5e(typeof t=="function"?t:G3(t)))}var s5e,dV=N(()=>{"use strict";ev();s5e=Array.prototype.find;o(o5e,"childFind");o(l5e,"childFirst");o(LA,"default")});function u5e(){return Array.from(this.children)}function h5e(t){return function(){return c5e.call(this.children,t)}}function RA(t){return this.selectAll(t==null?u5e:h5e(typeof t=="function"?t:G3(t)))}var c5e,pV=N(()=>{"use strict";ev();c5e=Array.prototype.filter;o(u5e,"children");o(h5e,"childrenFilter");o(RA,"default")});function NA(t){typeof t!="function"&&(t=x0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";hl();ev();o(NA,"default")});function tv(t){return new Array(t.length)}var MA=N(()=>{"use strict";o(tv,"default")});function IA(){return new oi(this._enter||this._groups.map(tv),this._parents)}function rv(t,e){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=e}var OA=N(()=>{"use strict";MA();hl();o(IA,"default");o(rv,"EnterNode");rv.prototype={constructor:rv,appendChild:o(function(t){return this._parent.insertBefore(t,this._next)},"appendChild"),insertBefore:o(function(t,e){return this._parent.insertBefore(t,e)},"insertBefore"),querySelector:o(function(t){return this._parent.querySelector(t)},"querySelector"),querySelectorAll:o(function(t){return this._parent.querySelectorAll(t)},"querySelectorAll")}});function PA(t){return function(){return t}}var gV=N(()=>{"use strict";o(PA,"default")});function f5e(t,e,r,n,i,a){for(var s=0,l,u=e.length,h=a.length;s=w&&(w=b+1);!(T=v[w])&&++w{"use strict";hl();OA();gV();o(f5e,"bindIndex");o(d5e,"bindKey");o(p5e,"datum");o(BA,"default");o(m5e,"arraylike")});function FA(){return new oi(this._exit||this._groups.map(tv),this._parents)}var vV=N(()=>{"use strict";MA();hl();o(FA,"default")});function $A(t,e,r){var n=this.enter(),i=this,a=this.exit();return typeof t=="function"?(n=t(n),n&&(n=n.selection())):n=n.append(t+""),e!=null&&(i=e(i),i&&(i=i.selection())),r==null?a.remove():r(a),n&&i?n.merge(i).order():i}var xV=N(()=>{"use strict";o($A,"default")});function zA(t){for(var e=t.selection?t.selection():t,r=this._groups,n=e._groups,i=r.length,a=n.length,s=Math.min(i,a),l=new Array(i),u=0;u{"use strict";hl();o(zA,"default")});function GA(){for(var t=this._groups,e=-1,r=t.length;++e=0;)(s=n[i])&&(a&&s.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(s,a),a=s);return this}var wV=N(()=>{"use strict";o(GA,"default")});function VA(t){t||(t=g5e);function e(d,p){return d&&p?t(d.__data__,p.__data__):!d-!p}o(e,"compareNode");for(var r=this._groups,n=r.length,i=new Array(n),a=0;ae?1:t>=e?0:NaN}var TV=N(()=>{"use strict";hl();o(VA,"default");o(g5e,"ascending")});function UA(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}var kV=N(()=>{"use strict";o(UA,"default")});function HA(){return Array.from(this)}var EV=N(()=>{"use strict";o(HA,"default")});function WA(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(WA,"default")});function qA(){let t=0;for(let e of this)++t;return t}var CV=N(()=>{"use strict";o(qA,"default")});function YA(){return!this.node()}var AV=N(()=>{"use strict";o(YA,"default")});function XA(t){for(var e=this._groups,r=0,n=e.length;r{"use strict";o(XA,"default")});function y5e(t){return function(){this.removeAttribute(t)}}function v5e(t){return function(){this.removeAttributeNS(t.space,t.local)}}function x5e(t,e){return function(){this.setAttribute(t,e)}}function b5e(t,e){return function(){this.setAttributeNS(t.space,t.local,e)}}function w5e(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttribute(t):this.setAttribute(t,r)}}function T5e(t,e){return function(){var r=e.apply(this,arguments);r==null?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,r)}}function jA(t,e){var r=rc(t);if(arguments.length<2){var n=this.node();return r.local?n.getAttributeNS(r.space,r.local):n.getAttribute(r)}return this.each((e==null?r.local?v5e:y5e:typeof e=="function"?r.local?T5e:w5e:r.local?b5e:x5e)(r,e))}var DV=N(()=>{"use strict";$3();o(y5e,"attrRemove");o(v5e,"attrRemoveNS");o(x5e,"attrConstant");o(b5e,"attrConstantNS");o(w5e,"attrFunction");o(T5e,"attrFunctionNS");o(jA,"default")});function nv(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}var KA=N(()=>{"use strict";o(nv,"default")});function k5e(t){return function(){this.style.removeProperty(t)}}function E5e(t,e,r){return function(){this.style.setProperty(t,e,r)}}function S5e(t,e,r){return function(){var n=e.apply(this,arguments);n==null?this.style.removeProperty(t):this.style.setProperty(t,n,r)}}function QA(t,e,r){return arguments.length>1?this.each((e==null?k5e:typeof e=="function"?S5e:E5e)(t,e,r??"")):bh(this.node(),t)}function bh(t,e){return t.style.getPropertyValue(e)||nv(t).getComputedStyle(t,null).getPropertyValue(e)}var ZA=N(()=>{"use strict";KA();o(k5e,"styleRemove");o(E5e,"styleConstant");o(S5e,"styleFunction");o(QA,"default");o(bh,"styleValue")});function C5e(t){return function(){delete this[t]}}function A5e(t,e){return function(){this[t]=e}}function _5e(t,e){return function(){var r=e.apply(this,arguments);r==null?delete this[t]:this[t]=r}}function JA(t,e){return arguments.length>1?this.each((e==null?C5e:typeof e=="function"?_5e:A5e)(t,e)):this.node()[t]}var LV=N(()=>{"use strict";o(C5e,"propertyRemove");o(A5e,"propertyConstant");o(_5e,"propertyFunction");o(JA,"default")});function RV(t){return t.trim().split(/^|\s+/)}function e8(t){return t.classList||new NV(t)}function NV(t){this._node=t,this._names=RV(t.getAttribute("class")||"")}function MV(t,e){for(var r=e8(t),n=-1,i=e.length;++n{"use strict";o(RV,"classArray");o(e8,"classList");o(NV,"ClassList");NV.prototype={add:o(function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},"add"),remove:o(function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},"remove"),contains:o(function(t){return this._names.indexOf(t)>=0},"contains")};o(MV,"classedAdd");o(IV,"classedRemove");o(D5e,"classedTrue");o(L5e,"classedFalse");o(R5e,"classedFunction");o(t8,"default")});function N5e(){this.textContent=""}function M5e(t){return function(){this.textContent=t}}function I5e(t){return function(){var e=t.apply(this,arguments);this.textContent=e??""}}function r8(t){return arguments.length?this.each(t==null?N5e:(typeof t=="function"?I5e:M5e)(t)):this.node().textContent}var PV=N(()=>{"use strict";o(N5e,"textRemove");o(M5e,"textConstant");o(I5e,"textFunction");o(r8,"default")});function O5e(){this.innerHTML=""}function P5e(t){return function(){this.innerHTML=t}}function B5e(t){return function(){var e=t.apply(this,arguments);this.innerHTML=e??""}}function n8(t){return arguments.length?this.each(t==null?O5e:(typeof t=="function"?B5e:P5e)(t)):this.node().innerHTML}var BV=N(()=>{"use strict";o(O5e,"htmlRemove");o(P5e,"htmlConstant");o(B5e,"htmlFunction");o(n8,"default")});function F5e(){this.nextSibling&&this.parentNode.appendChild(this)}function i8(){return this.each(F5e)}var FV=N(()=>{"use strict";o(F5e,"raise");o(i8,"default")});function $5e(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function a8(){return this.each($5e)}var $V=N(()=>{"use strict";o($5e,"lower");o(a8,"default")});function s8(t){var e=typeof t=="function"?t:Jy(t);return this.select(function(){return this.appendChild(e.apply(this,arguments))})}var zV=N(()=>{"use strict";SA();o(s8,"default")});function z5e(){return null}function o8(t,e){var r=typeof t=="function"?t:Jy(t),n=e==null?z5e:typeof e=="function"?e:xh(e);return this.select(function(){return this.insertBefore(r.apply(this,arguments),n.apply(this,arguments)||null)})}var GV=N(()=>{"use strict";SA();z3();o(z5e,"constantNull");o(o8,"default")});function G5e(){var t=this.parentNode;t&&t.removeChild(this)}function l8(){return this.each(G5e)}var VV=N(()=>{"use strict";o(G5e,"remove");o(l8,"default")});function V5e(){var t=this.cloneNode(!1),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function U5e(){var t=this.cloneNode(!0),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function c8(t){return this.select(t?U5e:V5e)}var UV=N(()=>{"use strict";o(V5e,"selection_cloneShallow");o(U5e,"selection_cloneDeep");o(c8,"default")});function u8(t){return arguments.length?this.property("__data__",t):this.node().__data__}var HV=N(()=>{"use strict";o(u8,"default")});function H5e(t){return function(e){t.call(this,e,this.__data__)}}function W5e(t){return t.trim().split(/^|\s+/).map(function(e){var r="",n=e.indexOf(".");return n>=0&&(r=e.slice(n+1),e=e.slice(0,n)),{type:e,name:r}})}function q5e(t){return function(){var e=this.__on;if(e){for(var r=0,n=-1,i=e.length,a;r{"use strict";o(H5e,"contextListener");o(W5e,"parseTypenames");o(q5e,"onRemove");o(Y5e,"onAdd");o(h8,"default")});function qV(t,e,r){var n=nv(t),i=n.CustomEvent;typeof i=="function"?i=new i(e,r):(i=n.document.createEvent("Event"),r?(i.initEvent(e,r.bubbles,r.cancelable),i.detail=r.detail):i.initEvent(e,!1,!1)),t.dispatchEvent(i)}function X5e(t,e){return function(){return qV(this,t,e)}}function j5e(t,e){return function(){return qV(this,t,e.apply(this,arguments))}}function f8(t,e){return this.each((typeof e=="function"?j5e:X5e)(t,e))}var YV=N(()=>{"use strict";KA();o(qV,"dispatchEvent");o(X5e,"dispatchConstant");o(j5e,"dispatchFunction");o(f8,"default")});function*d8(){for(var t=this._groups,e=0,r=t.length;e{"use strict";o(d8,"default")});function oi(t,e){this._groups=t,this._parents=e}function jV(){return new oi([[document.documentElement]],p8)}function K5e(){return this}var p8,hu,hl=N(()=>{"use strict";uV();fV();dV();pV();mV();yV();OA();vV();xV();bV();wV();TV();kV();EV();SV();CV();AV();_V();DV();ZA();LV();OV();PV();BV();FV();$V();zV();GV();VV();UV();HV();WV();YV();XV();p8=[null];o(oi,"Selection");o(jV,"selection");o(K5e,"selection_selection");oi.prototype=jV.prototype={constructor:oi,select:CA,selectAll:DA,selectChild:LA,selectChildren:RA,filter:NA,data:BA,enter:IA,exit:FA,join:$A,merge:zA,selection:K5e,order:GA,sort:VA,call:UA,nodes:HA,node:WA,size:qA,empty:YA,each:XA,attr:jA,style:QA,property:JA,classed:t8,text:r8,html:n8,raise:i8,lower:a8,append:s8,insert:o8,remove:l8,clone:c8,datum:u8,on:h8,dispatch:f8,[Symbol.iterator]:d8};hu=jV});function Ge(t){return typeof t=="string"?new oi([[document.querySelector(t)]],[document.documentElement]):new oi([[t]],p8)}var KV=N(()=>{"use strict";hl();o(Ge,"default")});var fl=N(()=>{"use strict";ev();$3();KV();hl();z3();_A();ZA()});var QV=N(()=>{"use strict"});function wh(t,e,r){t.prototype=e.prototype=r,r.constructor=t}function b0(t,e){var r=Object.create(t.prototype);for(var n in e)r[n]=e[n];return r}var m8=N(()=>{"use strict";o(wh,"default");o(b0,"extend")});function Th(){}function JV(){return this.rgb().formatHex()}function iwe(){return this.rgb().formatHex8()}function awe(){return sU(this).formatHsl()}function eU(){return this.rgb().formatRgb()}function pl(t){var e,r;return t=(t+"").trim().toLowerCase(),(e=Q5e.exec(t))?(r=e[1].length,e=parseInt(e[1],16),r===6?tU(e):r===3?new ua(e>>8&15|e>>4&240,e>>4&15|e&240,(e&15)<<4|e&15,1):r===8?V3(e>>24&255,e>>16&255,e>>8&255,(e&255)/255):r===4?V3(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|e&240,((e&15)<<4|e&15)/255):null):(e=Z5e.exec(t))?new ua(e[1],e[2],e[3],1):(e=J5e.exec(t))?new ua(e[1]*255/100,e[2]*255/100,e[3]*255/100,1):(e=ewe.exec(t))?V3(e[1],e[2],e[3],e[4]):(e=twe.exec(t))?V3(e[1]*255/100,e[2]*255/100,e[3]*255/100,e[4]):(e=rwe.exec(t))?iU(e[1],e[2]/100,e[3]/100,1):(e=nwe.exec(t))?iU(e[1],e[2]/100,e[3]/100,e[4]):ZV.hasOwnProperty(t)?tU(ZV[t]):t==="transparent"?new ua(NaN,NaN,NaN,0):null}function tU(t){return new ua(t>>16&255,t>>8&255,t&255,1)}function V3(t,e,r,n){return n<=0&&(t=e=r=NaN),new ua(t,e,r,n)}function y8(t){return t instanceof Th||(t=pl(t)),t?(t=t.rgb(),new ua(t.r,t.g,t.b,t.opacity)):new ua}function T0(t,e,r,n){return arguments.length===1?y8(t):new ua(t,e,r,n??1)}function ua(t,e,r,n){this.r=+t,this.g=+e,this.b=+r,this.opacity=+n}function rU(){return`#${ld(this.r)}${ld(this.g)}${ld(this.b)}`}function swe(){return`#${ld(this.r)}${ld(this.g)}${ld(this.b)}${ld((isNaN(this.opacity)?1:this.opacity)*255)}`}function nU(){let t=W3(this.opacity);return`${t===1?"rgb(":"rgba("}${cd(this.r)}, ${cd(this.g)}, ${cd(this.b)}${t===1?")":`, ${t})`}`}function W3(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function cd(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function ld(t){return t=cd(t),(t<16?"0":"")+t.toString(16)}function iU(t,e,r,n){return n<=0?t=e=r=NaN:r<=0||r>=1?t=e=NaN:e<=0&&(t=NaN),new dl(t,e,r,n)}function sU(t){if(t instanceof dl)return new dl(t.h,t.s,t.l,t.opacity);if(t instanceof Th||(t=pl(t)),!t)return new dl;if(t instanceof dl)return t;t=t.rgb();var e=t.r/255,r=t.g/255,n=t.b/255,i=Math.min(e,r,n),a=Math.max(e,r,n),s=NaN,l=a-i,u=(a+i)/2;return l?(e===a?s=(r-n)/l+(r0&&u<1?0:s,new dl(s,l,u,t.opacity)}function oU(t,e,r,n){return arguments.length===1?sU(t):new dl(t,e,r,n??1)}function dl(t,e,r,n){this.h=+t,this.s=+e,this.l=+r,this.opacity=+n}function aU(t){return t=(t||0)%360,t<0?t+360:t}function U3(t){return Math.max(0,Math.min(1,t||0))}function g8(t,e,r){return(t<60?e+(r-e)*t/60:t<180?r:t<240?e+(r-e)*(240-t)/60:e)*255}var iv,H3,w0,av,nc,Q5e,Z5e,J5e,ewe,twe,rwe,nwe,ZV,v8=N(()=>{"use strict";m8();o(Th,"Color");iv=.7,H3=1/iv,w0="\\s*([+-]?\\d+)\\s*",av="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",nc="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",Q5e=/^#([0-9a-f]{3,8})$/,Z5e=new RegExp(`^rgb\\(${w0},${w0},${w0}\\)$`),J5e=new RegExp(`^rgb\\(${nc},${nc},${nc}\\)$`),ewe=new RegExp(`^rgba\\(${w0},${w0},${w0},${av}\\)$`),twe=new RegExp(`^rgba\\(${nc},${nc},${nc},${av}\\)$`),rwe=new RegExp(`^hsl\\(${av},${nc},${nc}\\)$`),nwe=new RegExp(`^hsla\\(${av},${nc},${nc},${av}\\)$`),ZV={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};wh(Th,pl,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:JV,formatHex:JV,formatHex8:iwe,formatHsl:awe,formatRgb:eU,toString:eU});o(JV,"color_formatHex");o(iwe,"color_formatHex8");o(awe,"color_formatHsl");o(eU,"color_formatRgb");o(pl,"color");o(tU,"rgbn");o(V3,"rgba");o(y8,"rgbConvert");o(T0,"rgb");o(ua,"Rgb");wh(ua,T0,b0(Th,{brighter(t){return t=t==null?H3:Math.pow(H3,t),new ua(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?iv:Math.pow(iv,t),new ua(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new ua(cd(this.r),cd(this.g),cd(this.b),W3(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:rU,formatHex:rU,formatHex8:swe,formatRgb:nU,toString:nU}));o(rU,"rgb_formatHex");o(swe,"rgb_formatHex8");o(nU,"rgb_formatRgb");o(W3,"clampa");o(cd,"clampi");o(ld,"hex");o(iU,"hsla");o(sU,"hslConvert");o(oU,"hsl");o(dl,"Hsl");wh(dl,oU,b0(Th,{brighter(t){return t=t==null?H3:Math.pow(H3,t),new dl(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?iv:Math.pow(iv,t),new dl(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,e=isNaN(t)||isNaN(this.s)?0:this.s,r=this.l,n=r+(r<.5?r:1-r)*e,i=2*r-n;return new ua(g8(t>=240?t-240:t+120,i,n),g8(t,i,n),g8(t<120?t+240:t-120,i,n),this.opacity)},clamp(){return new dl(aU(this.h),U3(this.s),U3(this.l),W3(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let t=W3(this.opacity);return`${t===1?"hsl(":"hsla("}${aU(this.h)}, ${U3(this.s)*100}%, ${U3(this.l)*100}%${t===1?")":`, ${t})`}`}}));o(aU,"clamph");o(U3,"clampt");o(g8,"hsl2rgb")});var lU,cU,uU=N(()=>{"use strict";lU=Math.PI/180,cU=180/Math.PI});function gU(t){if(t instanceof ic)return new ic(t.l,t.a,t.b,t.opacity);if(t instanceof fu)return yU(t);t instanceof ua||(t=y8(t));var e=T8(t.r),r=T8(t.g),n=T8(t.b),i=x8((.2225045*e+.7168786*r+.0606169*n)/fU),a,s;return e===r&&r===n?a=s=i:(a=x8((.4360747*e+.3850649*r+.1430804*n)/hU),s=x8((.0139322*e+.0971045*r+.7141733*n)/dU)),new ic(116*i-16,500*(a-i),200*(i-s),t.opacity)}function k8(t,e,r,n){return arguments.length===1?gU(t):new ic(t,e,r,n??1)}function ic(t,e,r,n){this.l=+t,this.a=+e,this.b=+r,this.opacity=+n}function x8(t){return t>owe?Math.pow(t,1/3):t/mU+pU}function b8(t){return t>k0?t*t*t:mU*(t-pU)}function w8(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function T8(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function lwe(t){if(t instanceof fu)return new fu(t.h,t.c,t.l,t.opacity);if(t instanceof ic||(t=gU(t)),t.a===0&&t.b===0)return new fu(NaN,0{"use strict";m8();v8();uU();q3=18,hU=.96422,fU=1,dU=.82521,pU=4/29,k0=6/29,mU=3*k0*k0,owe=k0*k0*k0;o(gU,"labConvert");o(k8,"lab");o(ic,"Lab");wh(ic,k8,b0(Th,{brighter(t){return new ic(this.l+q3*(t??1),this.a,this.b,this.opacity)},darker(t){return new ic(this.l-q3*(t??1),this.a,this.b,this.opacity)},rgb(){var t=(this.l+16)/116,e=isNaN(this.a)?t:t+this.a/500,r=isNaN(this.b)?t:t-this.b/200;return e=hU*b8(e),t=fU*b8(t),r=dU*b8(r),new ua(w8(3.1338561*e-1.6168667*t-.4906146*r),w8(-.9787684*e+1.9161415*t+.033454*r),w8(.0719453*e-.2289914*t+1.4052427*r),this.opacity)}}));o(x8,"xyz2lab");o(b8,"lab2xyz");o(w8,"lrgb2rgb");o(T8,"rgb2lrgb");o(lwe,"hclConvert");o(sv,"hcl");o(fu,"Hcl");o(yU,"hcl2lab");wh(fu,sv,b0(Th,{brighter(t){return new fu(this.h,this.c,this.l+q3*(t??1),this.opacity)},darker(t){return new fu(this.h,this.c,this.l-q3*(t??1),this.opacity)},rgb(){return yU(this).rgb()}}))});var E0=N(()=>{"use strict";v8();vU()});function E8(t,e,r,n,i){var a=t*t,s=a*t;return((1-3*t+3*a-s)*e+(4-6*a+3*s)*r+(1+3*t+3*a-3*s)*n+s*i)/6}function S8(t){var e=t.length-1;return function(r){var n=r<=0?r=0:r>=1?(r=1,e-1):Math.floor(r*e),i=t[n],a=t[n+1],s=n>0?t[n-1]:2*i-a,l=n{"use strict";o(E8,"basis");o(S8,"default")});function A8(t){var e=t.length;return function(r){var n=Math.floor(((r%=1)<0?++r:r)*e),i=t[(n+e-1)%e],a=t[n%e],s=t[(n+1)%e],l=t[(n+2)%e];return E8((r-n/e)*e,i,a,s,l)}}var xU=N(()=>{"use strict";C8();o(A8,"default")});var S0,_8=N(()=>{"use strict";S0=o(t=>()=>t,"default")});function bU(t,e){return function(r){return t+r*e}}function cwe(t,e,r){return t=Math.pow(t,r),e=Math.pow(e,r)-t,r=1/r,function(n){return Math.pow(t+n*e,r)}}function wU(t,e){var r=e-t;return r?bU(t,r>180||r<-180?r-360*Math.round(r/360):r):S0(isNaN(t)?e:t)}function TU(t){return(t=+t)==1?du:function(e,r){return r-e?cwe(e,r,t):S0(isNaN(e)?r:e)}}function du(t,e){var r=e-t;return r?bU(t,r):S0(isNaN(t)?e:t)}var D8=N(()=>{"use strict";_8();o(bU,"linear");o(cwe,"exponential");o(wU,"hue");o(TU,"gamma");o(du,"nogamma")});function kU(t){return function(e){var r=e.length,n=new Array(r),i=new Array(r),a=new Array(r),s,l;for(s=0;s{"use strict";E0();C8();xU();D8();ud=o(function t(e){var r=TU(e);function n(i,a){var s=r((i=T0(i)).r,(a=T0(a)).r),l=r(i.g,a.g),u=r(i.b,a.b),h=du(i.opacity,a.opacity);return function(f){return i.r=s(f),i.g=l(f),i.b=u(f),i.opacity=h(f),i+""}}return o(n,"rgb"),n.gamma=t,n},"rgbGamma")(1);o(kU,"rgbSpline");uwe=kU(S8),hwe=kU(A8)});function R8(t,e){e||(e=[]);var r=t?Math.min(e.length,t.length):0,n=e.slice(),i;return function(a){for(i=0;i{"use strict";o(R8,"default");o(EU,"isNumberArray")});function CU(t,e){var r=e?e.length:0,n=t?Math.min(r,t.length):0,i=new Array(n),a=new Array(r),s;for(s=0;s{"use strict";Y3();o(CU,"genericArray")});function N8(t,e){var r=new Date;return t=+t,e=+e,function(n){return r.setTime(t*(1-n)+e*n),r}}var _U=N(()=>{"use strict";o(N8,"default")});function Ki(t,e){return t=+t,e=+e,function(r){return t*(1-r)+e*r}}var ov=N(()=>{"use strict";o(Ki,"default")});function M8(t,e){var r={},n={},i;(t===null||typeof t!="object")&&(t={}),(e===null||typeof e!="object")&&(e={});for(i in e)i in t?r[i]=kh(t[i],e[i]):n[i]=e[i];return function(a){for(i in r)n[i]=r[i](a);return n}}var DU=N(()=>{"use strict";Y3();o(M8,"default")});function fwe(t){return function(){return t}}function dwe(t){return function(e){return t(e)+""}}function C0(t,e){var r=O8.lastIndex=I8.lastIndex=0,n,i,a,s=-1,l=[],u=[];for(t=t+"",e=e+"";(n=O8.exec(t))&&(i=I8.exec(e));)(a=i.index)>r&&(a=e.slice(r,a),l[s]?l[s]+=a:l[++s]=a),(n=n[0])===(i=i[0])?l[s]?l[s]+=i:l[++s]=i:(l[++s]=null,u.push({i:s,x:Ki(n,i)})),r=I8.lastIndex;return r{"use strict";ov();O8=/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g,I8=new RegExp(O8.source,"g");o(fwe,"zero");o(dwe,"one");o(C0,"default")});function kh(t,e){var r=typeof e,n;return e==null||r==="boolean"?S0(e):(r==="number"?Ki:r==="string"?(n=pl(e))?(e=n,ud):C0:e instanceof pl?ud:e instanceof Date?N8:EU(e)?R8:Array.isArray(e)?CU:typeof e.valueOf!="function"&&typeof e.toString!="function"||isNaN(e)?M8:Ki)(t,e)}var Y3=N(()=>{"use strict";E0();L8();AU();_U();ov();DU();P8();_8();SU();o(kh,"default")});function X3(t,e){return t=+t,e=+e,function(r){return Math.round(t*(1-r)+e*r)}}var LU=N(()=>{"use strict";o(X3,"default")});function K3(t,e,r,n,i,a){var s,l,u;return(s=Math.sqrt(t*t+e*e))&&(t/=s,e/=s),(u=t*r+e*n)&&(r-=t*u,n-=e*u),(l=Math.sqrt(r*r+n*n))&&(r/=l,n/=l,u/=l),t*n{"use strict";RU=180/Math.PI,j3={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1};o(K3,"default")});function MU(t){let e=new(typeof DOMMatrix=="function"?DOMMatrix:WebKitCSSMatrix)(t+"");return e.isIdentity?j3:K3(e.a,e.b,e.c,e.d,e.e,e.f)}function IU(t){return t==null?j3:(Q3||(Q3=document.createElementNS("http://www.w3.org/2000/svg","g")),Q3.setAttribute("transform",t),(t=Q3.transform.baseVal.consolidate())?(t=t.matrix,K3(t.a,t.b,t.c,t.d,t.e,t.f)):j3)}var Q3,OU=N(()=>{"use strict";NU();o(MU,"parseCss");o(IU,"parseSvg")});function PU(t,e,r,n){function i(h){return h.length?h.pop()+" ":""}o(i,"pop");function a(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push("translate(",null,e,null,r);g.push({i:y-4,x:Ki(h,d)},{i:y-2,x:Ki(f,p)})}else(d||p)&&m.push("translate("+d+e+p+r)}o(a,"translate");function s(h,f,d,p){h!==f?(h-f>180?f+=360:f-h>180&&(h+=360),p.push({i:d.push(i(d)+"rotate(",null,n)-2,x:Ki(h,f)})):f&&d.push(i(d)+"rotate("+f+n)}o(s,"rotate");function l(h,f,d,p){h!==f?p.push({i:d.push(i(d)+"skewX(",null,n)-2,x:Ki(h,f)}):f&&d.push(i(d)+"skewX("+f+n)}o(l,"skewX");function u(h,f,d,p,m,g){if(h!==d||f!==p){var y=m.push(i(m)+"scale(",null,",",null,")");g.push({i:y-4,x:Ki(h,d)},{i:y-2,x:Ki(f,p)})}else(d!==1||p!==1)&&m.push(i(m)+"scale("+d+","+p+")")}return o(u,"scale"),function(h,f){var d=[],p=[];return h=t(h),f=t(f),a(h.translateX,h.translateY,f.translateX,f.translateY,d,p),s(h.rotate,f.rotate,d,p),l(h.skewX,f.skewX,d,p),u(h.scaleX,h.scaleY,f.scaleX,f.scaleY,d,p),h=f=null,function(m){for(var g=-1,y=p.length,v;++g{"use strict";ov();OU();o(PU,"interpolateTransform");B8=PU(MU,"px, ","px)","deg)"),F8=PU(IU,", ",")",")")});function FU(t){return function(e,r){var n=t((e=sv(e)).h,(r=sv(r)).h),i=du(e.c,r.c),a=du(e.l,r.l),s=du(e.opacity,r.opacity);return function(l){return e.h=n(l),e.c=i(l),e.l=a(l),e.opacity=s(l),e+""}}}var $8,pwe,$U=N(()=>{"use strict";E0();D8();o(FU,"hcl");$8=FU(wU),pwe=FU(du)});var A0=N(()=>{"use strict";Y3();ov();LU();P8();BU();L8();$U()});function dv(){return hd||(VU(mwe),hd=hv.now()+e5)}function mwe(){hd=0}function fv(){this._call=this._time=this._next=null}function t5(t,e,r){var n=new fv;return n.restart(t,e,r),n}function UU(){dv(),++_0;for(var t=Z3,e;t;)(e=hd-t._time)>=0&&t._call.call(void 0,e),t=t._next;--_0}function zU(){hd=(J3=hv.now())+e5,_0=cv=0;try{UU()}finally{_0=0,ywe(),hd=0}}function gwe(){var t=hv.now(),e=t-J3;e>GU&&(e5-=e,J3=t)}function ywe(){for(var t,e=Z3,r,n=1/0;e;)e._call?(n>e._time&&(n=e._time),t=e,e=e._next):(r=e._next,e._next=null,e=t?t._next=r:Z3=r);uv=t,z8(n)}function z8(t){if(!_0){cv&&(cv=clearTimeout(cv));var e=t-hd;e>24?(t<1/0&&(cv=setTimeout(zU,t-hv.now()-e5)),lv&&(lv=clearInterval(lv))):(lv||(J3=hv.now(),lv=setInterval(gwe,GU)),_0=1,VU(zU))}}var _0,cv,lv,GU,Z3,uv,J3,hd,e5,hv,VU,G8=N(()=>{"use strict";_0=0,cv=0,lv=0,GU=1e3,J3=0,hd=0,e5=0,hv=typeof performance=="object"&&performance.now?performance:Date,VU=typeof window=="object"&&window.requestAnimationFrame?window.requestAnimationFrame.bind(window):function(t){setTimeout(t,17)};o(dv,"now");o(mwe,"clearNow");o(fv,"Timer");fv.prototype=t5.prototype={constructor:fv,restart:o(function(t,e,r){if(typeof t!="function")throw new TypeError("callback is not a function");r=(r==null?dv():+r)+(e==null?0:+e),!this._next&&uv!==this&&(uv?uv._next=this:Z3=this,uv=this),this._call=t,this._time=r,z8()},"restart"),stop:o(function(){this._call&&(this._call=null,this._time=1/0,z8())},"stop")};o(t5,"timer");o(UU,"timerFlush");o(zU,"wake");o(gwe,"poke");o(ywe,"nap");o(z8,"sleep")});function pv(t,e,r){var n=new fv;return e=e==null?0:+e,n.restart(i=>{n.stop(),t(i+e)},e,r),n}var HU=N(()=>{"use strict";G8();o(pv,"default")});var r5=N(()=>{"use strict";G8();HU()});function pu(t,e,r,n,i,a){var s=t.__transition;if(!s)t.__transition={};else if(r in s)return;bwe(t,r,{name:e,index:n,group:i,on:vwe,tween:xwe,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:YU})}function gv(t,e){var r=Bi(t,e);if(r.state>YU)throw new Error("too late; already scheduled");return r}function ha(t,e){var r=Bi(t,e);if(r.state>n5)throw new Error("too late; already running");return r}function Bi(t,e){var r=t.__transition;if(!r||!(r=r[e]))throw new Error("transition not found");return r}function bwe(t,e,r){var n=t.__transition,i;n[e]=r,r.timer=t5(a,0,r.time);function a(h){r.state=WU,r.timer.restart(s,r.delay,r.time),r.delay<=h&&s(h-r.delay)}o(a,"schedule");function s(h){var f,d,p,m;if(r.state!==WU)return u();for(f in n)if(m=n[f],m.name===r.name){if(m.state===n5)return pv(s);m.state===qU?(m.state=mv,m.timer.stop(),m.on.call("interrupt",t,t.__data__,m.index,m.group),delete n[f]):+f{"use strict";TA();r5();vwe=wA("start","end","cancel","interrupt"),xwe=[],YU=0,WU=1,i5=2,n5=3,qU=4,a5=5,mv=6;o(pu,"default");o(gv,"init");o(ha,"set");o(Bi,"get");o(bwe,"create")});function yv(t,e){var r=t.__transition,n,i,a=!0,s;if(r){e=e==null?null:e+"";for(s in r){if((n=r[s]).name!==e){a=!1;continue}i=n.state>i5&&n.state{"use strict";Es();o(yv,"default")});function V8(t){return this.each(function(){yv(this,t)})}var jU=N(()=>{"use strict";XU();o(V8,"default")});function wwe(t,e){var r,n;return function(){var i=ha(this,t),a=i.tween;if(a!==r){n=r=a;for(var s=0,l=n.length;s{"use strict";Es();o(wwe,"tweenRemove");o(Twe,"tweenFunction");o(U8,"default");o(D0,"tweenValue")});function xv(t,e){var r;return(typeof e=="number"?Ki:e instanceof pl?ud:(r=pl(e))?(e=r,ud):C0)(t,e)}var H8=N(()=>{"use strict";E0();A0();o(xv,"default")});function kwe(t){return function(){this.removeAttribute(t)}}function Ewe(t){return function(){this.removeAttributeNS(t.space,t.local)}}function Swe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttribute(t);return s===i?null:s===n?a:a=e(n=s,r)}}function Cwe(t,e,r){var n,i=r+"",a;return function(){var s=this.getAttributeNS(t.space,t.local);return s===i?null:s===n?a:a=e(n=s,r)}}function Awe(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttribute(t):(s=this.getAttribute(t),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function _we(t,e,r){var n,i,a;return function(){var s,l=r(this),u;return l==null?void this.removeAttributeNS(t.space,t.local):(s=this.getAttributeNS(t.space,t.local),u=l+"",s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l)))}}function W8(t,e){var r=rc(t),n=r==="transform"?F8:xv;return this.attrTween(t,typeof e=="function"?(r.local?_we:Awe)(r,n,D0(this,"attr."+t,e)):e==null?(r.local?Ewe:kwe)(r):(r.local?Cwe:Swe)(r,n,e))}var KU=N(()=>{"use strict";A0();fl();vv();H8();o(kwe,"attrRemove");o(Ewe,"attrRemoveNS");o(Swe,"attrConstant");o(Cwe,"attrConstantNS");o(Awe,"attrFunction");o(_we,"attrFunctionNS");o(W8,"default")});function Dwe(t,e){return function(r){this.setAttribute(t,e.call(this,r))}}function Lwe(t,e){return function(r){this.setAttributeNS(t.space,t.local,e.call(this,r))}}function Rwe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&Lwe(t,a)),r}return o(i,"tween"),i._value=e,i}function Nwe(t,e){var r,n;function i(){var a=e.apply(this,arguments);return a!==n&&(r=(n=a)&&Dwe(t,a)),r}return o(i,"tween"),i._value=e,i}function q8(t,e){var r="attr."+t;if(arguments.length<2)return(r=this.tween(r))&&r._value;if(e==null)return this.tween(r,null);if(typeof e!="function")throw new Error;var n=rc(t);return this.tween(r,(n.local?Rwe:Nwe)(n,e))}var QU=N(()=>{"use strict";fl();o(Dwe,"attrInterpolate");o(Lwe,"attrInterpolateNS");o(Rwe,"attrTweenNS");o(Nwe,"attrTween");o(q8,"default")});function Mwe(t,e){return function(){gv(this,t).delay=+e.apply(this,arguments)}}function Iwe(t,e){return e=+e,function(){gv(this,t).delay=e}}function Y8(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?Mwe:Iwe)(e,t)):Bi(this.node(),e).delay}var ZU=N(()=>{"use strict";Es();o(Mwe,"delayFunction");o(Iwe,"delayConstant");o(Y8,"default")});function Owe(t,e){return function(){ha(this,t).duration=+e.apply(this,arguments)}}function Pwe(t,e){return e=+e,function(){ha(this,t).duration=e}}function X8(t){var e=this._id;return arguments.length?this.each((typeof t=="function"?Owe:Pwe)(e,t)):Bi(this.node(),e).duration}var JU=N(()=>{"use strict";Es();o(Owe,"durationFunction");o(Pwe,"durationConstant");o(X8,"default")});function Bwe(t,e){if(typeof e!="function")throw new Error;return function(){ha(this,t).ease=e}}function j8(t){var e=this._id;return arguments.length?this.each(Bwe(e,t)):Bi(this.node(),e).ease}var eH=N(()=>{"use strict";Es();o(Bwe,"easeConstant");o(j8,"default")});function Fwe(t,e){return function(){var r=e.apply(this,arguments);if(typeof r!="function")throw new Error;ha(this,t).ease=r}}function K8(t){if(typeof t!="function")throw new Error;return this.each(Fwe(this._id,t))}var tH=N(()=>{"use strict";Es();o(Fwe,"easeVarying");o(K8,"default")});function Q8(t){typeof t!="function"&&(t=x0(t));for(var e=this._groups,r=e.length,n=new Array(r),i=0;i{"use strict";fl();fd();o(Q8,"default")});function Z8(t){if(t._id!==this._id)throw new Error;for(var e=this._groups,r=t._groups,n=e.length,i=r.length,a=Math.min(n,i),s=new Array(n),l=0;l{"use strict";fd();o(Z8,"default")});function $we(t){return(t+"").trim().split(/^|\s+/).every(function(e){var r=e.indexOf(".");return r>=0&&(e=e.slice(0,r)),!e||e==="start"})}function zwe(t,e,r){var n,i,a=$we(e)?gv:ha;return function(){var s=a(this,t),l=s.on;l!==n&&(i=(n=l).copy()).on(e,r),s.on=i}}function J8(t,e){var r=this._id;return arguments.length<2?Bi(this.node(),r).on.on(t):this.each(zwe(r,t,e))}var iH=N(()=>{"use strict";Es();o($we,"start");o(zwe,"onFunction");o(J8,"default")});function Gwe(t){return function(){var e=this.parentNode;for(var r in this.__transition)if(+r!==t)return;e&&e.removeChild(this)}}function e_(){return this.on("end.remove",Gwe(this._id))}var aH=N(()=>{"use strict";o(Gwe,"removeFunction");o(e_,"default")});function t_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=xh(t));for(var n=this._groups,i=n.length,a=new Array(i),s=0;s{"use strict";fl();fd();Es();o(t_,"default")});function r_(t){var e=this._name,r=this._id;typeof t!="function"&&(t=v0(t));for(var n=this._groups,i=n.length,a=[],s=[],l=0;l{"use strict";fl();fd();Es();o(r_,"default")});function n_(){return new Vwe(this._groups,this._parents)}var Vwe,lH=N(()=>{"use strict";fl();Vwe=hu.prototype.constructor;o(n_,"default")});function Uwe(t,e){var r,n,i;return function(){var a=bh(this,t),s=(this.style.removeProperty(t),bh(this,t));return a===s?null:a===r&&s===n?i:i=e(r=a,n=s)}}function cH(t){return function(){this.style.removeProperty(t)}}function Hwe(t,e,r){var n,i=r+"",a;return function(){var s=bh(this,t);return s===i?null:s===n?a:a=e(n=s,r)}}function Wwe(t,e,r){var n,i,a;return function(){var s=bh(this,t),l=r(this),u=l+"";return l==null&&(u=l=(this.style.removeProperty(t),bh(this,t))),s===u?null:s===n&&u===i?a:(i=u,a=e(n=s,l))}}function qwe(t,e){var r,n,i,a="style."+e,s="end."+a,l;return function(){var u=ha(this,t),h=u.on,f=u.value[a]==null?l||(l=cH(e)):void 0;(h!==r||i!==f)&&(n=(r=h).copy()).on(s,i=f),u.on=n}}function i_(t,e,r){var n=(t+="")=="transform"?B8:xv;return e==null?this.styleTween(t,Uwe(t,n)).on("end.style."+t,cH(t)):typeof e=="function"?this.styleTween(t,Wwe(t,n,D0(this,"style."+t,e))).each(qwe(this._id,t)):this.styleTween(t,Hwe(t,n,e),r).on("end.style."+t,null)}var uH=N(()=>{"use strict";A0();fl();Es();vv();H8();o(Uwe,"styleNull");o(cH,"styleRemove");o(Hwe,"styleConstant");o(Wwe,"styleFunction");o(qwe,"styleMaybeRemove");o(i_,"default")});function Ywe(t,e,r){return function(n){this.style.setProperty(t,e.call(this,n),r)}}function Xwe(t,e,r){var n,i;function a(){var s=e.apply(this,arguments);return s!==i&&(n=(i=s)&&Ywe(t,s,r)),n}return o(a,"tween"),a._value=e,a}function a_(t,e,r){var n="style."+(t+="");if(arguments.length<2)return(n=this.tween(n))&&n._value;if(e==null)return this.tween(n,null);if(typeof e!="function")throw new Error;return this.tween(n,Xwe(t,e,r??""))}var hH=N(()=>{"use strict";o(Ywe,"styleInterpolate");o(Xwe,"styleTween");o(a_,"default")});function jwe(t){return function(){this.textContent=t}}function Kwe(t){return function(){var e=t(this);this.textContent=e??""}}function s_(t){return this.tween("text",typeof t=="function"?Kwe(D0(this,"text",t)):jwe(t==null?"":t+""))}var fH=N(()=>{"use strict";vv();o(jwe,"textConstant");o(Kwe,"textFunction");o(s_,"default")});function Qwe(t){return function(e){this.textContent=t.call(this,e)}}function Zwe(t){var e,r;function n(){var i=t.apply(this,arguments);return i!==r&&(e=(r=i)&&Qwe(i)),e}return o(n,"tween"),n._value=t,n}function o_(t){var e="text";if(arguments.length<1)return(e=this.tween(e))&&e._value;if(t==null)return this.tween(e,null);if(typeof t!="function")throw new Error;return this.tween(e,Zwe(t))}var dH=N(()=>{"use strict";o(Qwe,"textInterpolate");o(Zwe,"textTween");o(o_,"default")});function l_(){for(var t=this._name,e=this._id,r=s5(),n=this._groups,i=n.length,a=0;a{"use strict";fd();Es();o(l_,"default")});function c_(){var t,e,r=this,n=r._id,i=r.size();return new Promise(function(a,s){var l={value:s},u={value:o(function(){--i===0&&a()},"value")};r.each(function(){var h=ha(this,n),f=h.on;f!==t&&(e=(t=f).copy(),e._.cancel.push(l),e._.interrupt.push(l),e._.end.push(u)),h.on=e}),i===0&&a()})}var mH=N(()=>{"use strict";Es();o(c_,"default")});function es(t,e,r,n){this._groups=t,this._parents=e,this._name=r,this._id=n}function gH(t){return hu().transition(t)}function s5(){return++Jwe}var Jwe,mu,fd=N(()=>{"use strict";fl();KU();QU();ZU();JU();eH();tH();rH();nH();iH();aH();sH();oH();lH();uH();hH();fH();dH();pH();vv();mH();Jwe=0;o(es,"Transition");o(gH,"transition");o(s5,"newId");mu=hu.prototype;es.prototype=gH.prototype={constructor:es,select:t_,selectAll:r_,selectChild:mu.selectChild,selectChildren:mu.selectChildren,filter:Q8,merge:Z8,selection:n_,transition:l_,call:mu.call,nodes:mu.nodes,node:mu.node,size:mu.size,empty:mu.empty,each:mu.each,on:J8,attr:W8,attrTween:q8,style:i_,styleTween:a_,text:s_,textTween:o_,remove:e_,tween:U8,delay:Y8,duration:X8,ease:j8,easeVarying:K8,end:c_,[Symbol.iterator]:mu[Symbol.iterator]}});function o5(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var yH=N(()=>{"use strict";o(o5,"cubicInOut")});var u_=N(()=>{"use strict";yH()});function tTe(t,e){for(var r;!(r=t.__transition)||!(r=r[e]);)if(!(t=t.parentNode))throw new Error(`transition ${e} not found`);return r}function h_(t){var e,r;t instanceof es?(e=t._id,t=t._name):(e=s5(),(r=eTe).time=dv(),t=t==null?null:t+"");for(var n=this._groups,i=n.length,a=0;a{"use strict";fd();Es();u_();r5();eTe={time:null,delay:0,duration:250,ease:o5};o(tTe,"inherit");o(h_,"default")});var xH=N(()=>{"use strict";fl();jU();vH();hu.prototype.interrupt=V8;hu.prototype.transition=h_});var l5=N(()=>{"use strict";xH()});var bH=N(()=>{"use strict"});var wH=N(()=>{"use strict"});var TH=N(()=>{"use strict"});function kH(t){return[+t[0],+t[1]]}function rTe(t){return[kH(t[0]),kH(t[1])]}function f_(t){return{type:t}}var Z0t,J0t,emt,tmt,rmt,nmt,EH=N(()=>{"use strict";l5();bH();wH();TH();({abs:Z0t,max:J0t,min:emt}=Math);o(kH,"number1");o(rTe,"number2");tmt={name:"x",handles:["w","e"].map(f_),input:o(function(t,e){return t==null?null:[[+t[0],e[0][1]],[+t[1],e[1][1]]]},"input"),output:o(function(t){return t&&[t[0][0],t[1][0]]},"output")},rmt={name:"y",handles:["n","s"].map(f_),input:o(function(t,e){return t==null?null:[[e[0][0],+t[0]],[e[1][0],+t[1]]]},"input"),output:o(function(t){return t&&[t[0][1],t[1][1]]},"output")},nmt={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(f_),input:o(function(t){return t==null?null:rTe(t)},"input"),output:o(function(t){return t},"output")};o(f_,"type")});var SH=N(()=>{"use strict";EH()});function CH(t){this._+=t[0];for(let e=1,r=t.length;e=0))throw new Error(`invalid digits: ${t}`);if(e>15)return CH;let r=10**e;return function(n){this._+=n[0];for(let i=1,a=n.length;i{"use strict";d_=Math.PI,p_=2*d_,dd=1e-6,nTe=p_-dd;o(CH,"append");o(iTe,"appendRound");pd=class{static{o(this,"Path")}constructor(e){this._x0=this._y0=this._x1=this._y1=null,this._="",this._append=e==null?CH:iTe(e)}moveTo(e,r){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}`}closePath(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._append`Z`)}lineTo(e,r){this._append`L${this._x1=+e},${this._y1=+r}`}quadraticCurveTo(e,r,n,i){this._append`Q${+e},${+r},${this._x1=+n},${this._y1=+i}`}bezierCurveTo(e,r,n,i,a,s){this._append`C${+e},${+r},${+n},${+i},${this._x1=+a},${this._y1=+s}`}arcTo(e,r,n,i,a){if(e=+e,r=+r,n=+n,i=+i,a=+a,a<0)throw new Error(`negative radius: ${a}`);let s=this._x1,l=this._y1,u=n-e,h=i-r,f=s-e,d=l-r,p=f*f+d*d;if(this._x1===null)this._append`M${this._x1=e},${this._y1=r}`;else if(p>dd)if(!(Math.abs(d*u-h*f)>dd)||!a)this._append`L${this._x1=e},${this._y1=r}`;else{let m=n-s,g=i-l,y=u*u+h*h,v=m*m+g*g,x=Math.sqrt(y),b=Math.sqrt(p),w=a*Math.tan((d_-Math.acos((y+p-v)/(2*x*b)))/2),C=w/b,T=w/x;Math.abs(C-1)>dd&&this._append`L${e+C*f},${r+C*d}`,this._append`A${a},${a},0,0,${+(d*m>f*g)},${this._x1=e+T*u},${this._y1=r+T*h}`}}arc(e,r,n,i,a,s){if(e=+e,r=+r,n=+n,s=!!s,n<0)throw new Error(`negative radius: ${n}`);let l=n*Math.cos(i),u=n*Math.sin(i),h=e+l,f=r+u,d=1^s,p=s?i-a:a-i;this._x1===null?this._append`M${h},${f}`:(Math.abs(this._x1-h)>dd||Math.abs(this._y1-f)>dd)&&this._append`L${h},${f}`,n&&(p<0&&(p=p%p_+p_),p>nTe?this._append`A${n},${n},0,1,${d},${e-l},${r-u}A${n},${n},0,1,${d},${this._x1=h},${this._y1=f}`:p>dd&&this._append`A${n},${n},0,${+(p>=d_)},${d},${this._x1=e+n*Math.cos(a)},${this._y1=r+n*Math.sin(a)}`)}rect(e,r,n,i){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+r}h${n=+n}v${+i}h${-n}Z`}toString(){return this._}};o(AH,"path");AH.prototype=pd.prototype});var m_=N(()=>{"use strict";_H()});var DH=N(()=>{"use strict"});var LH=N(()=>{"use strict"});var RH=N(()=>{"use strict"});var NH=N(()=>{"use strict"});var MH=N(()=>{"use strict"});var IH=N(()=>{"use strict"});var OH=N(()=>{"use strict"});function g_(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)}function md(t,e){if((r=(t=e?t.toExponential(e-1):t.toExponential()).indexOf("e"))<0)return null;var r,n=t.slice(0,r);return[n.length>1?n[0]+n.slice(2):n,+t.slice(r+1)]}var bv=N(()=>{"use strict";o(g_,"default");o(md,"formatDecimalParts")});function ml(t){return t=md(Math.abs(t)),t?t[1]:NaN}var wv=N(()=>{"use strict";bv();o(ml,"default")});function y_(t,e){return function(r,n){for(var i=r.length,a=[],s=0,l=t[0],u=0;i>0&&l>0&&(u+l+1>n&&(l=Math.max(1,n-u)),a.push(r.substring(i-=l,i+l)),!((u+=l+1)>n));)l=t[s=(s+1)%t.length];return a.reverse().join(e)}}var PH=N(()=>{"use strict";o(y_,"default")});function v_(t){return function(e){return e.replace(/[0-9]/g,function(r){return t[+r]})}}var BH=N(()=>{"use strict";o(v_,"default")});function Eh(t){if(!(e=aTe.exec(t)))throw new Error("invalid format: "+t);var e;return new c5({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}function c5(t){this.fill=t.fill===void 0?" ":t.fill+"",this.align=t.align===void 0?">":t.align+"",this.sign=t.sign===void 0?"-":t.sign+"",this.symbol=t.symbol===void 0?"":t.symbol+"",this.zero=!!t.zero,this.width=t.width===void 0?void 0:+t.width,this.comma=!!t.comma,this.precision=t.precision===void 0?void 0:+t.precision,this.trim=!!t.trim,this.type=t.type===void 0?"":t.type+""}var aTe,x_=N(()=>{"use strict";aTe=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;o(Eh,"formatSpecifier");Eh.prototype=c5.prototype;o(c5,"FormatSpecifier");c5.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type}});function b_(t){e:for(var e=t.length,r=1,n=-1,i;r0&&(n=0);break}return n>0?t.slice(0,n)+t.slice(i+1):t}var FH=N(()=>{"use strict";o(b_,"default")});function T_(t,e){var r=md(t,e);if(!r)return t+"";var n=r[0],i=r[1],a=i-(w_=Math.max(-8,Math.min(8,Math.floor(i/3)))*3)+1,s=n.length;return a===s?n:a>s?n+new Array(a-s+1).join("0"):a>0?n.slice(0,a)+"."+n.slice(a):"0."+new Array(1-a).join("0")+md(t,Math.max(0,e+a-1))[0]}var w_,k_=N(()=>{"use strict";bv();o(T_,"default")});function u5(t,e){var r=md(t,e);if(!r)return t+"";var n=r[0],i=r[1];return i<0?"0."+new Array(-i).join("0")+n:n.length>i+1?n.slice(0,i+1)+"."+n.slice(i+1):n+new Array(i-n.length+2).join("0")}var $H=N(()=>{"use strict";bv();o(u5,"default")});var E_,zH=N(()=>{"use strict";bv();k_();$H();E_={"%":o((t,e)=>(t*100).toFixed(e),"%"),b:o(t=>Math.round(t).toString(2),"b"),c:o(t=>t+"","c"),d:g_,e:o((t,e)=>t.toExponential(e),"e"),f:o((t,e)=>t.toFixed(e),"f"),g:o((t,e)=>t.toPrecision(e),"g"),o:o(t=>Math.round(t).toString(8),"o"),p:o((t,e)=>u5(t*100,e),"p"),r:u5,s:T_,X:o(t=>Math.round(t).toString(16).toUpperCase(),"X"),x:o(t=>Math.round(t).toString(16),"x")}});function h5(t){return t}var GH=N(()=>{"use strict";o(h5,"default")});function S_(t){var e=t.grouping===void 0||t.thousands===void 0?h5:y_(VH.call(t.grouping,Number),t.thousands+""),r=t.currency===void 0?"":t.currency[0]+"",n=t.currency===void 0?"":t.currency[1]+"",i=t.decimal===void 0?".":t.decimal+"",a=t.numerals===void 0?h5:v_(VH.call(t.numerals,String)),s=t.percent===void 0?"%":t.percent+"",l=t.minus===void 0?"\u2212":t.minus+"",u=t.nan===void 0?"NaN":t.nan+"";function h(d){d=Eh(d);var p=d.fill,m=d.align,g=d.sign,y=d.symbol,v=d.zero,x=d.width,b=d.comma,w=d.precision,C=d.trim,T=d.type;T==="n"?(b=!0,T="g"):E_[T]||(w===void 0&&(w=12),C=!0,T="g"),(v||p==="0"&&m==="=")&&(v=!0,p="0",m="=");var E=y==="$"?r:y==="#"&&/[boxX]/.test(T)?"0"+T.toLowerCase():"",A=y==="$"?n:/[%p]/.test(T)?s:"",S=E_[T],_=/[defgprs%]/.test(T);w=w===void 0?6:/[gprs]/.test(T)?Math.max(1,Math.min(21,w)):Math.max(0,Math.min(20,w));function I(D){var k=E,L=A,R,O,M;if(T==="c")L=S(D)+L,D="";else{D=+D;var B=D<0||1/D<0;if(D=isNaN(D)?u:S(Math.abs(D),w),C&&(D=b_(D)),B&&+D==0&&g!=="+"&&(B=!1),k=(B?g==="("?g:l:g==="-"||g==="("?"":g)+k,L=(T==="s"?UH[8+w_/3]:"")+L+(B&&g==="("?")":""),_){for(R=-1,O=D.length;++RM||M>57){L=(M===46?i+D.slice(R+1):D.slice(R))+L,D=D.slice(0,R);break}}}b&&!v&&(D=e(D,1/0));var F=k.length+D.length+L.length,P=F>1)+k+D+L+P.slice(F);break;default:D=P+k+D+L;break}return a(D)}return o(I,"format"),I.toString=function(){return d+""},I}o(h,"newFormat");function f(d,p){var m=h((d=Eh(d),d.type="f",d)),g=Math.max(-8,Math.min(8,Math.floor(ml(p)/3)))*3,y=Math.pow(10,-g),v=UH[8+g/3];return function(x){return m(y*x)+v}}return o(f,"formatPrefix"),{format:h,formatPrefix:f}}var VH,UH,HH=N(()=>{"use strict";wv();PH();BH();x_();FH();zH();k_();GH();VH=Array.prototype.map,UH=["y","z","a","f","p","n","\xB5","m","","k","M","G","T","P","E","Z","Y"];o(S_,"default")});function C_(t){return f5=S_(t),d5=f5.format,p5=f5.formatPrefix,f5}var f5,d5,p5,WH=N(()=>{"use strict";HH();C_({thousands:",",grouping:[3],currency:["$",""]});o(C_,"defaultLocale")});function m5(t){return Math.max(0,-ml(Math.abs(t)))}var qH=N(()=>{"use strict";wv();o(m5,"default")});function g5(t,e){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(ml(e)/3)))*3-ml(Math.abs(t)))}var YH=N(()=>{"use strict";wv();o(g5,"default")});function y5(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,ml(e)-ml(t))+1}var XH=N(()=>{"use strict";wv();o(y5,"default")});var A_=N(()=>{"use strict";WH();x_();qH();YH();XH()});var jH=N(()=>{"use strict"});var KH=N(()=>{"use strict"});var QH=N(()=>{"use strict"});var ZH=N(()=>{"use strict"});function Sh(t,e){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(e).domain(t);break}return this}var Tv=N(()=>{"use strict";o(Sh,"initRange")});function gu(){var t=new g0,e=[],r=[],n=__;function i(a){let s=t.get(a);if(s===void 0){if(n!==__)return n;t.set(a,s=e.push(a)-1)}return r[s%r.length]}return o(i,"scale"),i.domain=function(a){if(!arguments.length)return e.slice();e=[],t=new g0;for(let s of a)t.has(s)||t.set(s,e.push(s)-1);return i},i.range=function(a){return arguments.length?(r=Array.from(a),i):r.slice()},i.unknown=function(a){return arguments.length?(n=a,i):n},i.copy=function(){return gu(e,r).unknown(n)},Sh.apply(i,arguments),i}var __,D_=N(()=>{"use strict";vh();Tv();__=Symbol("implicit");o(gu,"ordinal")});function L0(){var t=gu().unknown(void 0),e=t.domain,r=t.range,n=0,i=1,a,s,l=!1,u=0,h=0,f=.5;delete t.unknown;function d(){var p=e().length,m=i{"use strict";vh();Tv();D_();o(L0,"band")});function L_(t){return function(){return t}}var eW=N(()=>{"use strict";o(L_,"constants")});function R_(t){return+t}var tW=N(()=>{"use strict";o(R_,"number")});function R0(t){return t}function N_(t,e){return(e-=t=+t)?function(r){return(r-t)/e}:L_(isNaN(e)?NaN:.5)}function sTe(t,e){var r;return t>e&&(r=t,t=e,e=r),function(n){return Math.max(t,Math.min(e,n))}}function oTe(t,e,r){var n=t[0],i=t[1],a=e[0],s=e[1];return i2?lTe:oTe,u=h=null,d}o(f,"rescale");function d(p){return p==null||isNaN(p=+p)?a:(u||(u=l(t.map(n),e,r)))(n(s(p)))}return o(d,"scale"),d.invert=function(p){return s(i((h||(h=l(e,t.map(n),Ki)))(p)))},d.domain=function(p){return arguments.length?(t=Array.from(p,R_),f()):t.slice()},d.range=function(p){return arguments.length?(e=Array.from(p),f()):e.slice()},d.rangeRound=function(p){return e=Array.from(p),r=X3,f()},d.clamp=function(p){return arguments.length?(s=p?!0:R0,f()):s!==R0},d.interpolate=function(p){return arguments.length?(r=p,f()):r},d.unknown=function(p){return arguments.length?(a=p,d):a},function(p,m){return n=p,i=m,f()}}function kv(){return cTe()(R0,R0)}var rW,M_=N(()=>{"use strict";vh();A0();eW();tW();rW=[0,1];o(R0,"identity");o(N_,"normalize");o(sTe,"clamper");o(oTe,"bimap");o(lTe,"polymap");o(v5,"copy");o(cTe,"transformer");o(kv,"continuous")});function I_(t,e,r,n){var i=y0(t,e,r),a;switch(n=Eh(n??",f"),n.type){case"s":{var s=Math.max(Math.abs(t),Math.abs(e));return n.precision==null&&!isNaN(a=g5(i,s))&&(n.precision=a),p5(n,s)}case"":case"e":case"g":case"p":case"r":{n.precision==null&&!isNaN(a=y5(i,Math.max(Math.abs(t),Math.abs(e))))&&(n.precision=a-(n.type==="e"));break}case"f":case"%":{n.precision==null&&!isNaN(a=m5(i))&&(n.precision=a-(n.type==="%")*2);break}}return d5(n)}var nW=N(()=>{"use strict";vh();A_();o(I_,"tickFormat")});function uTe(t){var e=t.domain;return t.ticks=function(r){var n=e();return R3(n[0],n[n.length-1],r??10)},t.tickFormat=function(r,n){var i=e();return I_(i[0],i[i.length-1],r??10,n)},t.nice=function(r){r==null&&(r=10);var n=e(),i=0,a=n.length-1,s=n[i],l=n[a],u,h,f=10;for(l0;){if(h=Zy(s,l,r),h===u)return n[i]=s,n[a]=l,e(n);if(h>0)s=Math.floor(s/h)*h,l=Math.ceil(l/h)*h;else if(h<0)s=Math.ceil(s*h)/h,l=Math.floor(l*h)/h;else break;u=h}return t},t}function gl(){var t=kv();return t.copy=function(){return v5(t,gl())},Sh.apply(t,arguments),uTe(t)}var iW=N(()=>{"use strict";vh();M_();Tv();nW();o(uTe,"linearish");o(gl,"linear")});function O_(t,e){t=t.slice();var r=0,n=t.length-1,i=t[r],a=t[n],s;return a{"use strict";o(O_,"nice")});function xn(t,e,r,n){function i(a){return t(a=arguments.length===0?new Date:new Date(+a)),a}return o(i,"interval"),i.floor=a=>(t(a=new Date(+a)),a),i.ceil=a=>(t(a=new Date(a-1)),e(a,1),t(a),a),i.round=a=>{let s=i(a),l=i.ceil(a);return a-s(e(a=new Date(+a),s==null?1:Math.floor(s)),a),i.range=(a,s,l)=>{let u=[];if(a=i.ceil(a),l=l==null?1:Math.floor(l),!(a0))return u;let h;do u.push(h=new Date(+a)),e(a,l),t(a);while(hxn(s=>{if(s>=s)for(;t(s),!a(s);)s.setTime(s-1)},(s,l)=>{if(s>=s)if(l<0)for(;++l<=0;)for(;e(s,-1),!a(s););else for(;--l>=0;)for(;e(s,1),!a(s););}),r&&(i.count=(a,s)=>(P_.setTime(+a),B_.setTime(+s),t(P_),t(B_),Math.floor(r(P_,B_))),i.every=a=>(a=Math.floor(a),!isFinite(a)||!(a>0)?null:a>1?i.filter(n?s=>n(s)%a===0:s=>i.count(0,s)%a===0):i)),i}var P_,B_,yu=N(()=>{"use strict";P_=new Date,B_=new Date;o(xn,"timeInterval")});var ac,sW,F_=N(()=>{"use strict";yu();ac=xn(()=>{},(t,e)=>{t.setTime(+t+e)},(t,e)=>e-t);ac.every=t=>(t=Math.floor(t),!isFinite(t)||!(t>0)?null:t>1?xn(e=>{e.setTime(Math.floor(e/t)*t)},(e,r)=>{e.setTime(+e+r*t)},(e,r)=>(r-e)/t):ac);sW=ac.range});var Ks,oW,$_=N(()=>{"use strict";yu();Ks=xn(t=>{t.setTime(t-t.getMilliseconds())},(t,e)=>{t.setTime(+t+e*1e3)},(t,e)=>(e-t)/1e3,t=>t.getUTCSeconds()),oW=Ks.range});var vu,hTe,x5,fTe,z_=N(()=>{"use strict";yu();vu=xn(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getMinutes()),hTe=vu.range,x5=xn(t=>{t.setUTCSeconds(0,0)},(t,e)=>{t.setTime(+t+e*6e4)},(t,e)=>(e-t)/6e4,t=>t.getUTCMinutes()),fTe=x5.range});var xu,dTe,b5,pTe,G_=N(()=>{"use strict";yu();xu=xn(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*1e3-t.getMinutes()*6e4)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getHours()),dTe=xu.range,b5=xn(t=>{t.setUTCMinutes(0,0,0)},(t,e)=>{t.setTime(+t+e*36e5)},(t,e)=>(e-t)/36e5,t=>t.getUTCHours()),pTe=b5.range});var _o,mTe,Sv,gTe,w5,yTe,V_=N(()=>{"use strict";yu();_o=xn(t=>t.setHours(0,0,0,0),(t,e)=>t.setDate(t.getDate()+e),(t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*6e4)/864e5,t=>t.getDate()-1),mTe=_o.range,Sv=xn(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>t.getUTCDate()-1),gTe=Sv.range,w5=xn(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>Math.floor(t/864e5)),yTe=w5.range});function vd(t){return xn(e=>{e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)},(e,r)=>{e.setDate(e.getDate()+r*7)},(e,r)=>(r-e-(r.getTimezoneOffset()-e.getTimezoneOffset())*6e4)/6048e5)}function xd(t){return xn(e=>{e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCDate(e.getUTCDate()+r*7)},(e,r)=>(r-e)/6048e5)}var yl,Ch,T5,k5,oc,E5,S5,cW,vTe,xTe,bTe,wTe,TTe,kTe,bd,N0,uW,hW,Ah,fW,dW,pW,ETe,STe,CTe,ATe,_Te,DTe,U_=N(()=>{"use strict";yu();o(vd,"timeWeekday");yl=vd(0),Ch=vd(1),T5=vd(2),k5=vd(3),oc=vd(4),E5=vd(5),S5=vd(6),cW=yl.range,vTe=Ch.range,xTe=T5.range,bTe=k5.range,wTe=oc.range,TTe=E5.range,kTe=S5.range;o(xd,"utcWeekday");bd=xd(0),N0=xd(1),uW=xd(2),hW=xd(3),Ah=xd(4),fW=xd(5),dW=xd(6),pW=bd.range,ETe=N0.range,STe=uW.range,CTe=hW.range,ATe=Ah.range,_Te=fW.range,DTe=dW.range});var bu,LTe,C5,RTe,H_=N(()=>{"use strict";yu();bu=xn(t=>{t.setDate(1),t.setHours(0,0,0,0)},(t,e)=>{t.setMonth(t.getMonth()+e)},(t,e)=>e.getMonth()-t.getMonth()+(e.getFullYear()-t.getFullYear())*12,t=>t.getMonth()),LTe=bu.range,C5=xn(t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCMonth(t.getUTCMonth()+e)},(t,e)=>e.getUTCMonth()-t.getUTCMonth()+(e.getUTCFullYear()-t.getUTCFullYear())*12,t=>t.getUTCMonth()),RTe=C5.range});var Qs,NTe,vl,MTe,W_=N(()=>{"use strict";yu();Qs=xn(t=>{t.setMonth(0,1),t.setHours(0,0,0,0)},(t,e)=>{t.setFullYear(t.getFullYear()+e)},(t,e)=>e.getFullYear()-t.getFullYear(),t=>t.getFullYear());Qs.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:xn(e=>{e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)},(e,r)=>{e.setFullYear(e.getFullYear()+r*t)});NTe=Qs.range,vl=xn(t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCFullYear(t.getUTCFullYear()+e)},(t,e)=>e.getUTCFullYear()-t.getUTCFullYear(),t=>t.getUTCFullYear());vl.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:xn(e=>{e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCFullYear(e.getUTCFullYear()+r*t)});MTe=vl.range});function gW(t,e,r,n,i,a){let s=[[Ks,1,1e3],[Ks,5,5*1e3],[Ks,15,15*1e3],[Ks,30,30*1e3],[a,1,6e4],[a,5,5*6e4],[a,15,15*6e4],[a,30,30*6e4],[i,1,36e5],[i,3,3*36e5],[i,6,6*36e5],[i,12,12*36e5],[n,1,864e5],[n,2,2*864e5],[r,1,6048e5],[e,1,2592e6],[e,3,3*2592e6],[t,1,31536e6]];function l(h,f,d){let p=fv).right(s,p);if(m===s.length)return t.every(y0(h/31536e6,f/31536e6,d));if(m===0)return ac.every(Math.max(y0(h,f,d),1));let[g,y]=s[p/s[m-1][2]{"use strict";vh();F_();$_();z_();G_();V_();U_();H_();W_();o(gW,"ticker");[OTe,PTe]=gW(vl,C5,bd,w5,b5,x5),[q_,Y_]=gW(Qs,bu,yl,_o,xu,vu)});var A5=N(()=>{"use strict";F_();$_();z_();G_();V_();U_();H_();W_();yW()});function X_(t){if(0<=t.y&&t.y<100){var e=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return e.setFullYear(t.y),e}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function j_(t){if(0<=t.y&&t.y<100){var e=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return e.setUTCFullYear(t.y),e}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function Cv(t,e,r){return{y:t,m:e,d:r,H:0,M:0,S:0,L:0}}function K_(t){var e=t.dateTime,r=t.date,n=t.time,i=t.periods,a=t.days,s=t.shortDays,l=t.months,u=t.shortMonths,h=Av(i),f=_v(i),d=Av(a),p=_v(a),m=Av(s),g=_v(s),y=Av(l),v=_v(l),x=Av(u),b=_v(u),w={a:B,A:F,b:P,B:z,c:null,d:kW,e:kW,f:ake,g:mke,G:yke,H:rke,I:nke,j:ike,L:_W,m:ske,M:oke,p:$,q:H,Q:CW,s:AW,S:lke,u:cke,U:uke,V:hke,w:fke,W:dke,x:null,X:null,y:pke,Y:gke,Z:vke,"%":SW},C={a:Q,A:j,b:ie,B:ne,c:null,d:EW,e:EW,f:Tke,g:Nke,G:Ike,H:xke,I:bke,j:wke,L:LW,m:kke,M:Eke,p:le,q:he,Q:CW,s:AW,S:Ske,u:Cke,U:Ake,V:_ke,w:Dke,W:Lke,x:null,X:null,y:Rke,Y:Mke,Z:Oke,"%":SW},T={a:I,A:D,b:k,B:L,c:R,d:wW,e:wW,f:ZTe,g:bW,G:xW,H:TW,I:TW,j:XTe,L:QTe,m:YTe,M:jTe,p:_,q:qTe,Q:eke,s:tke,S:KTe,u:GTe,U:VTe,V:UTe,w:zTe,W:HTe,x:O,X:M,y:bW,Y:xW,Z:WTe,"%":JTe};w.x=E(r,w),w.X=E(n,w),w.c=E(e,w),C.x=E(r,C),C.X=E(n,C),C.c=E(e,C);function E(K,X){return function(te){var J=[],se=-1,ue=0,Z=K.length,Se,ce,ae;for(te instanceof Date||(te=new Date(+te));++se53)return null;"w"in J||(J.w=1),"Z"in J?(ue=j_(Cv(J.y,0,1)),Z=ue.getUTCDay(),ue=Z>4||Z===0?N0.ceil(ue):N0(ue),ue=Sv.offset(ue,(J.V-1)*7),J.y=ue.getUTCFullYear(),J.m=ue.getUTCMonth(),J.d=ue.getUTCDate()+(J.w+6)%7):(ue=X_(Cv(J.y,0,1)),Z=ue.getDay(),ue=Z>4||Z===0?Ch.ceil(ue):Ch(ue),ue=_o.offset(ue,(J.V-1)*7),J.y=ue.getFullYear(),J.m=ue.getMonth(),J.d=ue.getDate()+(J.w+6)%7)}else("W"in J||"U"in J)&&("w"in J||(J.w="u"in J?J.u%7:"W"in J?1:0),Z="Z"in J?j_(Cv(J.y,0,1)).getUTCDay():X_(Cv(J.y,0,1)).getDay(),J.m=0,J.d="W"in J?(J.w+6)%7+J.W*7-(Z+5)%7:J.w+J.U*7-(Z+6)%7);return"Z"in J?(J.H+=J.Z/100|0,J.M+=J.Z%100,j_(J)):X_(J)}}o(A,"newParse");function S(K,X,te,J){for(var se=0,ue=X.length,Z=te.length,Se,ce;se=Z)return-1;if(Se=X.charCodeAt(se++),Se===37){if(Se=X.charAt(se++),ce=T[Se in vW?X.charAt(se++):Se],!ce||(J=ce(K,te,J))<0)return-1}else if(Se!=te.charCodeAt(J++))return-1}return J}o(S,"parseSpecifier");function _(K,X,te){var J=h.exec(X.slice(te));return J?(K.p=f.get(J[0].toLowerCase()),te+J[0].length):-1}o(_,"parsePeriod");function I(K,X,te){var J=m.exec(X.slice(te));return J?(K.w=g.get(J[0].toLowerCase()),te+J[0].length):-1}o(I,"parseShortWeekday");function D(K,X,te){var J=d.exec(X.slice(te));return J?(K.w=p.get(J[0].toLowerCase()),te+J[0].length):-1}o(D,"parseWeekday");function k(K,X,te){var J=x.exec(X.slice(te));return J?(K.m=b.get(J[0].toLowerCase()),te+J[0].length):-1}o(k,"parseShortMonth");function L(K,X,te){var J=y.exec(X.slice(te));return J?(K.m=v.get(J[0].toLowerCase()),te+J[0].length):-1}o(L,"parseMonth");function R(K,X,te){return S(K,e,X,te)}o(R,"parseLocaleDateTime");function O(K,X,te){return S(K,r,X,te)}o(O,"parseLocaleDate");function M(K,X,te){return S(K,n,X,te)}o(M,"parseLocaleTime");function B(K){return s[K.getDay()]}o(B,"formatShortWeekday");function F(K){return a[K.getDay()]}o(F,"formatWeekday");function P(K){return u[K.getMonth()]}o(P,"formatShortMonth");function z(K){return l[K.getMonth()]}o(z,"formatMonth");function $(K){return i[+(K.getHours()>=12)]}o($,"formatPeriod");function H(K){return 1+~~(K.getMonth()/3)}o(H,"formatQuarter");function Q(K){return s[K.getUTCDay()]}o(Q,"formatUTCShortWeekday");function j(K){return a[K.getUTCDay()]}o(j,"formatUTCWeekday");function ie(K){return u[K.getUTCMonth()]}o(ie,"formatUTCShortMonth");function ne(K){return l[K.getUTCMonth()]}o(ne,"formatUTCMonth");function le(K){return i[+(K.getUTCHours()>=12)]}o(le,"formatUTCPeriod");function he(K){return 1+~~(K.getUTCMonth()/3)}return o(he,"formatUTCQuarter"),{format:o(function(K){var X=E(K+="",w);return X.toString=function(){return K},X},"format"),parse:o(function(K){var X=A(K+="",!1);return X.toString=function(){return K},X},"parse"),utcFormat:o(function(K){var X=E(K+="",C);return X.toString=function(){return K},X},"utcFormat"),utcParse:o(function(K){var X=A(K+="",!0);return X.toString=function(){return K},X},"utcParse")}}function Wr(t,e,r){var n=t<0?"-":"",i=(n?-t:t)+"",a=i.length;return n+(a[e.toLowerCase(),r]))}function zTe(t,e,r){var n=Qi.exec(e.slice(r,r+1));return n?(t.w=+n[0],r+n[0].length):-1}function GTe(t,e,r){var n=Qi.exec(e.slice(r,r+1));return n?(t.u=+n[0],r+n[0].length):-1}function VTe(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.U=+n[0],r+n[0].length):-1}function UTe(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.V=+n[0],r+n[0].length):-1}function HTe(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.W=+n[0],r+n[0].length):-1}function xW(t,e,r){var n=Qi.exec(e.slice(r,r+4));return n?(t.y=+n[0],r+n[0].length):-1}function bW(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.y=+n[0]+(+n[0]>68?1900:2e3),r+n[0].length):-1}function WTe(t,e,r){var n=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(e.slice(r,r+6));return n?(t.Z=n[1]?0:-(n[2]+(n[3]||"00")),r+n[0].length):-1}function qTe(t,e,r){var n=Qi.exec(e.slice(r,r+1));return n?(t.q=n[0]*3-3,r+n[0].length):-1}function YTe(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.m=n[0]-1,r+n[0].length):-1}function wW(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.d=+n[0],r+n[0].length):-1}function XTe(t,e,r){var n=Qi.exec(e.slice(r,r+3));return n?(t.m=0,t.d=+n[0],r+n[0].length):-1}function TW(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.H=+n[0],r+n[0].length):-1}function jTe(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.M=+n[0],r+n[0].length):-1}function KTe(t,e,r){var n=Qi.exec(e.slice(r,r+2));return n?(t.S=+n[0],r+n[0].length):-1}function QTe(t,e,r){var n=Qi.exec(e.slice(r,r+3));return n?(t.L=+n[0],r+n[0].length):-1}function ZTe(t,e,r){var n=Qi.exec(e.slice(r,r+6));return n?(t.L=Math.floor(n[0]/1e3),r+n[0].length):-1}function JTe(t,e,r){var n=BTe.exec(e.slice(r,r+1));return n?r+n[0].length:-1}function eke(t,e,r){var n=Qi.exec(e.slice(r));return n?(t.Q=+n[0],r+n[0].length):-1}function tke(t,e,r){var n=Qi.exec(e.slice(r));return n?(t.s=+n[0],r+n[0].length):-1}function kW(t,e){return Wr(t.getDate(),e,2)}function rke(t,e){return Wr(t.getHours(),e,2)}function nke(t,e){return Wr(t.getHours()%12||12,e,2)}function ike(t,e){return Wr(1+_o.count(Qs(t),t),e,3)}function _W(t,e){return Wr(t.getMilliseconds(),e,3)}function ake(t,e){return _W(t,e)+"000"}function ske(t,e){return Wr(t.getMonth()+1,e,2)}function oke(t,e){return Wr(t.getMinutes(),e,2)}function lke(t,e){return Wr(t.getSeconds(),e,2)}function cke(t){var e=t.getDay();return e===0?7:e}function uke(t,e){return Wr(yl.count(Qs(t)-1,t),e,2)}function DW(t){var e=t.getDay();return e>=4||e===0?oc(t):oc.ceil(t)}function hke(t,e){return t=DW(t),Wr(oc.count(Qs(t),t)+(Qs(t).getDay()===4),e,2)}function fke(t){return t.getDay()}function dke(t,e){return Wr(Ch.count(Qs(t)-1,t),e,2)}function pke(t,e){return Wr(t.getFullYear()%100,e,2)}function mke(t,e){return t=DW(t),Wr(t.getFullYear()%100,e,2)}function gke(t,e){return Wr(t.getFullYear()%1e4,e,4)}function yke(t,e){var r=t.getDay();return t=r>=4||r===0?oc(t):oc.ceil(t),Wr(t.getFullYear()%1e4,e,4)}function vke(t){var e=t.getTimezoneOffset();return(e>0?"-":(e*=-1,"+"))+Wr(e/60|0,"0",2)+Wr(e%60,"0",2)}function EW(t,e){return Wr(t.getUTCDate(),e,2)}function xke(t,e){return Wr(t.getUTCHours(),e,2)}function bke(t,e){return Wr(t.getUTCHours()%12||12,e,2)}function wke(t,e){return Wr(1+Sv.count(vl(t),t),e,3)}function LW(t,e){return Wr(t.getUTCMilliseconds(),e,3)}function Tke(t,e){return LW(t,e)+"000"}function kke(t,e){return Wr(t.getUTCMonth()+1,e,2)}function Eke(t,e){return Wr(t.getUTCMinutes(),e,2)}function Ske(t,e){return Wr(t.getUTCSeconds(),e,2)}function Cke(t){var e=t.getUTCDay();return e===0?7:e}function Ake(t,e){return Wr(bd.count(vl(t)-1,t),e,2)}function RW(t){var e=t.getUTCDay();return e>=4||e===0?Ah(t):Ah.ceil(t)}function _ke(t,e){return t=RW(t),Wr(Ah.count(vl(t),t)+(vl(t).getUTCDay()===4),e,2)}function Dke(t){return t.getUTCDay()}function Lke(t,e){return Wr(N0.count(vl(t)-1,t),e,2)}function Rke(t,e){return Wr(t.getUTCFullYear()%100,e,2)}function Nke(t,e){return t=RW(t),Wr(t.getUTCFullYear()%100,e,2)}function Mke(t,e){return Wr(t.getUTCFullYear()%1e4,e,4)}function Ike(t,e){var r=t.getUTCDay();return t=r>=4||r===0?Ah(t):Ah.ceil(t),Wr(t.getUTCFullYear()%1e4,e,4)}function Oke(){return"+0000"}function SW(){return"%"}function CW(t){return+t}function AW(t){return Math.floor(+t/1e3)}var vW,Qi,BTe,FTe,NW=N(()=>{"use strict";A5();o(X_,"localDate");o(j_,"utcDate");o(Cv,"newDate");o(K_,"formatLocale");vW={"-":"",_:" ",0:"0"},Qi=/^\s*\d+/,BTe=/^%/,FTe=/[\\^$*+?|[\]().{}]/g;o(Wr,"pad");o($Te,"requote");o(Av,"formatRe");o(_v,"formatLookup");o(zTe,"parseWeekdayNumberSunday");o(GTe,"parseWeekdayNumberMonday");o(VTe,"parseWeekNumberSunday");o(UTe,"parseWeekNumberISO");o(HTe,"parseWeekNumberMonday");o(xW,"parseFullYear");o(bW,"parseYear");o(WTe,"parseZone");o(qTe,"parseQuarter");o(YTe,"parseMonthNumber");o(wW,"parseDayOfMonth");o(XTe,"parseDayOfYear");o(TW,"parseHour24");o(jTe,"parseMinutes");o(KTe,"parseSeconds");o(QTe,"parseMilliseconds");o(ZTe,"parseMicroseconds");o(JTe,"parseLiteralPercent");o(eke,"parseUnixTimestamp");o(tke,"parseUnixTimestampSeconds");o(kW,"formatDayOfMonth");o(rke,"formatHour24");o(nke,"formatHour12");o(ike,"formatDayOfYear");o(_W,"formatMilliseconds");o(ake,"formatMicroseconds");o(ske,"formatMonthNumber");o(oke,"formatMinutes");o(lke,"formatSeconds");o(cke,"formatWeekdayNumberMonday");o(uke,"formatWeekNumberSunday");o(DW,"dISO");o(hke,"formatWeekNumberISO");o(fke,"formatWeekdayNumberSunday");o(dke,"formatWeekNumberMonday");o(pke,"formatYear");o(mke,"formatYearISO");o(gke,"formatFullYear");o(yke,"formatFullYearISO");o(vke,"formatZone");o(EW,"formatUTCDayOfMonth");o(xke,"formatUTCHour24");o(bke,"formatUTCHour12");o(wke,"formatUTCDayOfYear");o(LW,"formatUTCMilliseconds");o(Tke,"formatUTCMicroseconds");o(kke,"formatUTCMonthNumber");o(Eke,"formatUTCMinutes");o(Ske,"formatUTCSeconds");o(Cke,"formatUTCWeekdayNumberMonday");o(Ake,"formatUTCWeekNumberSunday");o(RW,"UTCdISO");o(_ke,"formatUTCWeekNumberISO");o(Dke,"formatUTCWeekdayNumberSunday");o(Lke,"formatUTCWeekNumberMonday");o(Rke,"formatUTCYear");o(Nke,"formatUTCYearISO");o(Mke,"formatUTCFullYear");o(Ike,"formatUTCFullYearISO");o(Oke,"formatUTCZone");o(SW,"formatLiteralPercent");o(CW,"formatUnixTimestamp");o(AW,"formatUnixTimestampSeconds")});function Q_(t){return M0=K_(t),wd=M0.format,MW=M0.parse,IW=M0.utcFormat,OW=M0.utcParse,M0}var M0,wd,MW,IW,OW,PW=N(()=>{"use strict";NW();Q_({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});o(Q_,"defaultLocale")});var Z_=N(()=>{"use strict";PW()});function Pke(t){return new Date(t)}function Bke(t){return t instanceof Date?+t:+new Date(+t)}function BW(t,e,r,n,i,a,s,l,u,h){var f=kv(),d=f.invert,p=f.domain,m=h(".%L"),g=h(":%S"),y=h("%I:%M"),v=h("%I %p"),x=h("%a %d"),b=h("%b %d"),w=h("%B"),C=h("%Y");function T(E){return(u(E){"use strict";A5();Z_();M_();Tv();aW();o(Pke,"date");o(Bke,"number");o(BW,"calendar");o(_5,"time")});var $W=N(()=>{"use strict";JH();iW();D_();FW()});function J_(t){for(var e=t.length/6|0,r=new Array(e),n=0;n{"use strict";o(J_,"default")});var e9,GW=N(()=>{"use strict";zW();e9=J_("4e79a7f28e2ce1575976b7b259a14fedc949af7aa1ff9da79c755fbab0ab")});var VW=N(()=>{"use strict";GW()});function Bn(t){return o(function(){return t},"constant")}var D5=N(()=>{"use strict";o(Bn,"default")});function HW(t){return t>1?0:t<-1?I0:Math.acos(t)}function r9(t){return t>=1?Dv:t<=-1?-Dv:Math.asin(t)}var t9,fa,_h,UW,L5,xl,Td,Zi,I0,Dv,O0,R5=N(()=>{"use strict";t9=Math.abs,fa=Math.atan2,_h=Math.cos,UW=Math.max,L5=Math.min,xl=Math.sin,Td=Math.sqrt,Zi=1e-12,I0=Math.PI,Dv=I0/2,O0=2*I0;o(HW,"acos");o(r9,"asin")});function N5(t){let e=3;return t.digits=function(r){if(!arguments.length)return e;if(r==null)e=null;else{let n=Math.floor(r);if(!(n>=0))throw new RangeError(`invalid digits: ${r}`);e=n}return t},()=>new pd(e)}var n9=N(()=>{"use strict";m_();o(N5,"withPath")});function Fke(t){return t.innerRadius}function $ke(t){return t.outerRadius}function zke(t){return t.startAngle}function Gke(t){return t.endAngle}function Vke(t){return t&&t.padAngle}function Uke(t,e,r,n,i,a,s,l){var u=r-t,h=n-e,f=s-i,d=l-a,p=d*u-f*h;if(!(p*pR*R+O*O&&(S=I,_=D),{cx:S,cy:_,x01:-f,y01:-d,x11:S*(i/T-1),y11:_*(i/T-1)}}function bl(){var t=Fke,e=$ke,r=Bn(0),n=null,i=zke,a=Gke,s=Vke,l=null,u=N5(h);function h(){var f,d,p=+t.apply(this,arguments),m=+e.apply(this,arguments),g=i.apply(this,arguments)-Dv,y=a.apply(this,arguments)-Dv,v=t9(y-g),x=y>g;if(l||(l=f=u()),mZi))l.moveTo(0,0);else if(v>O0-Zi)l.moveTo(m*_h(g),m*xl(g)),l.arc(0,0,m,g,y,!x),p>Zi&&(l.moveTo(p*_h(y),p*xl(y)),l.arc(0,0,p,y,g,x));else{var b=g,w=y,C=g,T=y,E=v,A=v,S=s.apply(this,arguments)/2,_=S>Zi&&(n?+n.apply(this,arguments):Td(p*p+m*m)),I=L5(t9(m-p)/2,+r.apply(this,arguments)),D=I,k=I,L,R;if(_>Zi){var O=r9(_/p*xl(S)),M=r9(_/m*xl(S));(E-=O*2)>Zi?(O*=x?1:-1,C+=O,T-=O):(E=0,C=T=(g+y)/2),(A-=M*2)>Zi?(M*=x?1:-1,b+=M,w-=M):(A=0,b=w=(g+y)/2)}var B=m*_h(b),F=m*xl(b),P=p*_h(T),z=p*xl(T);if(I>Zi){var $=m*_h(w),H=m*xl(w),Q=p*_h(C),j=p*xl(C),ie;if(vZi?k>Zi?(L=M5(Q,j,B,F,m,k,x),R=M5($,H,P,z,m,k,x),l.moveTo(L.cx+L.x01,L.cy+L.y01),kZi)||!(E>Zi)?l.lineTo(P,z):D>Zi?(L=M5(P,z,$,H,p,-D,x),R=M5(B,F,Q,j,p,-D,x),l.lineTo(L.cx+L.x01,L.cy+L.y01),D{"use strict";D5();R5();n9();o(Fke,"arcInnerRadius");o($ke,"arcOuterRadius");o(zke,"arcStartAngle");o(Gke,"arcEndAngle");o(Vke,"arcPadAngle");o(Uke,"intersect");o(M5,"cornerTangents");o(bl,"default")});function Lv(t){return typeof t=="object"&&"length"in t?t:Array.from(t)}var Nyt,i9=N(()=>{"use strict";Nyt=Array.prototype.slice;o(Lv,"default")});function qW(t){this._context=t}function wu(t){return new qW(t)}var a9=N(()=>{"use strict";o(qW,"Linear");qW.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:this._context.lineTo(t,e);break}},"point")};o(wu,"default")});function YW(t){return t[0]}function XW(t){return t[1]}var jW=N(()=>{"use strict";o(YW,"x");o(XW,"y")});function wl(t,e){var r=Bn(!0),n=null,i=wu,a=null,s=N5(l);t=typeof t=="function"?t:t===void 0?YW:Bn(t),e=typeof e=="function"?e:e===void 0?XW:Bn(e);function l(u){var h,f=(u=Lv(u)).length,d,p=!1,m;for(n==null&&(a=i(m=s())),h=0;h<=f;++h)!(h{"use strict";i9();D5();a9();n9();jW();o(wl,"default")});function s9(t,e){return et?1:e>=t?0:NaN}var QW=N(()=>{"use strict";o(s9,"default")});function o9(t){return t}var ZW=N(()=>{"use strict";o(o9,"default")});function I5(){var t=o9,e=s9,r=null,n=Bn(0),i=Bn(O0),a=Bn(0);function s(l){var u,h=(l=Lv(l)).length,f,d,p=0,m=new Array(h),g=new Array(h),y=+n.apply(this,arguments),v=Math.min(O0,Math.max(-O0,i.apply(this,arguments)-y)),x,b=Math.min(Math.abs(v)/h,a.apply(this,arguments)),w=b*(v<0?-1:1),C;for(u=0;u0&&(p+=C);for(e!=null?m.sort(function(T,E){return e(g[T],g[E])}):r!=null&&m.sort(function(T,E){return r(l[T],l[E])}),u=0,d=p?(v-h*w)/p:0;u0?C*d:0)+w,g[f]={data:l[f],index:u,value:C,startAngle:y,endAngle:x,padAngle:b};return g}return o(s,"pie"),s.value=function(l){return arguments.length?(t=typeof l=="function"?l:Bn(+l),s):t},s.sortValues=function(l){return arguments.length?(e=l,r=null,s):e},s.sort=function(l){return arguments.length?(r=l,e=null,s):r},s.startAngle=function(l){return arguments.length?(n=typeof l=="function"?l:Bn(+l),s):n},s.endAngle=function(l){return arguments.length?(i=typeof l=="function"?l:Bn(+l),s):i},s.padAngle=function(l){return arguments.length?(a=typeof l=="function"?l:Bn(+l),s):a},s}var JW=N(()=>{"use strict";i9();D5();QW();ZW();R5();o(I5,"default")});function Rv(t){return new O5(t,!0)}function Nv(t){return new O5(t,!1)}var O5,eq=N(()=>{"use strict";O5=class{static{o(this,"Bump")}constructor(e,r){this._context=e,this._x=r}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line}point(e,r){switch(e=+e,r=+r,this._point){case 0:{this._point=1,this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break}case 1:this._point=2;default:{this._x?this._context.bezierCurveTo(this._x0=(this._x0+e)/2,this._y0,this._x0,r,e,r):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+r)/2,e,this._y0,e,r);break}}this._x0=e,this._y0=r}};o(Rv,"bumpX");o(Nv,"bumpY")});function Zs(){}var Mv=N(()=>{"use strict";o(Zs,"default")});function P0(t,e,r){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+e)/6,(t._y0+4*t._y1+r)/6)}function Iv(t){this._context=t}function Do(t){return new Iv(t)}var Ov=N(()=>{"use strict";o(P0,"point");o(Iv,"Basis");Iv.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 3:P0(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:P0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(Do,"default")});function tq(t){this._context=t}function P5(t){return new tq(t)}var rq=N(()=>{"use strict";Mv();Ov();o(tq,"BasisClosed");tq.prototype={areaStart:Zs,areaEnd:Zs,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x2,this._y2),this._context.closePath();break}case 2:{this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break}case 3:{this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x2=t,this._y2=e;break;case 1:this._point=2,this._x3=t,this._y3=e;break;case 2:this._point=3,this._x4=t,this._y4=e,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+e)/6);break;default:P0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(P5,"default")});function nq(t){this._context=t}function B5(t){return new nq(t)}var iq=N(()=>{"use strict";Ov();o(nq,"BasisOpen");nq.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var r=(this._x0+4*this._x1+t)/6,n=(this._y0+4*this._y1+e)/6;this._line?this._context.lineTo(r,n):this._context.moveTo(r,n);break;case 3:this._point=4;default:P0(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e},"point")};o(B5,"default")});function aq(t,e){this._basis=new Iv(t),this._beta=e}var l9,sq=N(()=>{"use strict";Ov();o(aq,"Bundle");aq.prototype={lineStart:o(function(){this._x=[],this._y=[],this._basis.lineStart()},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length-1;if(r>0)for(var n=t[0],i=e[0],a=t[r]-n,s=e[r]-i,l=-1,u;++l<=r;)u=l/r,this._basis.point(this._beta*t[l]+(1-this._beta)*(n+u*a),this._beta*e[l]+(1-this._beta)*(i+u*s));this._x=this._y=null,this._basis.lineEnd()},"lineEnd"),point:o(function(t,e){this._x.push(+t),this._y.push(+e)},"point")};l9=o(function t(e){function r(n){return e===1?new Iv(n):new aq(n,e)}return o(r,"bundle"),r.beta=function(n){return t(+n)},r},"custom")(.85)});function B0(t,e,r){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-e),t._y2+t._k*(t._y1-r),t._x2,t._y2)}function F5(t,e){this._context=t,this._k=(1-e)/6}var Pv,Bv=N(()=>{"use strict";o(B0,"point");o(F5,"Cardinal");F5.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:B0(this,this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2,this._x1=t,this._y1=e;break;case 2:this._point=3;default:B0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};Pv=o(function t(e){function r(n){return new F5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r},"custom")(0)});function $5(t,e){this._context=t,this._k=(1-e)/6}var c9,u9=N(()=>{"use strict";Mv();Bv();o($5,"CardinalClosed");$5.prototype={areaStart:Zs,areaEnd:Zs,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:B0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};c9=o(function t(e){function r(n){return new $5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r},"custom")(0)});function z5(t,e){this._context=t,this._k=(1-e)/6}var h9,f9=N(()=>{"use strict";Bv();o(z5,"CardinalOpen");z5.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:B0(this,t,e);break}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};h9=o(function t(e){function r(n){return new z5(n,e)}return o(r,"cardinal"),r.tension=function(n){return t(+n)},r},"custom")(0)});function Fv(t,e,r){var n=t._x1,i=t._y1,a=t._x2,s=t._y2;if(t._l01_a>Zi){var l=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,u=3*t._l01_a*(t._l01_a+t._l12_a);n=(n*l-t._x0*t._l12_2a+t._x2*t._l01_2a)/u,i=(i*l-t._y0*t._l12_2a+t._y2*t._l01_2a)/u}if(t._l23_a>Zi){var h=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,f=3*t._l23_a*(t._l23_a+t._l12_a);a=(a*h+t._x1*t._l23_2a-e*t._l12_2a)/f,s=(s*h+t._y1*t._l23_2a-r*t._l12_2a)/f}t._context.bezierCurveTo(n,i,a,s,t._x2,t._y2)}function oq(t,e){this._context=t,this._alpha=e}var $v,G5=N(()=>{"use strict";R5();Bv();o(Fv,"point");o(oq,"CatmullRom");oq.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3;default:Fv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};$v=o(function t(e){function r(n){return e?new oq(n,e):new F5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r},"custom")(.5)});function lq(t,e){this._context=t,this._alpha=e}var d9,cq=N(()=>{"use strict";u9();Mv();G5();o(lq,"CatmullRomClosed");lq.prototype={areaStart:Zs,areaEnd:Zs,lineStart:o(function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 1:{this._context.moveTo(this._x3,this._y3),this._context.closePath();break}case 2:{this._context.lineTo(this._x3,this._y3),this._context.closePath();break}case 3:{this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5);break}}},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:Fv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};d9=o(function t(e){function r(n){return e?new lq(n,e):new $5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r},"custom")(.5)});function uq(t,e){this._context=t,this._alpha=e}var p9,hq=N(()=>{"use strict";f9();G5();o(uq,"CatmullRomOpen");uq.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},"lineStart"),lineEnd:o(function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){if(t=+t,e=+e,this._point){var r=this._x2-t,n=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(r*r+n*n,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Fv(this,t,e);break}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e},"point")};p9=o(function t(e){function r(n){return e?new uq(n,e):new z5(n,0)}return o(r,"catmullRom"),r.alpha=function(n){return t(+n)},r},"custom")(.5)});function fq(t){this._context=t}function V5(t){return new fq(t)}var dq=N(()=>{"use strict";Mv();o(fq,"LinearClosed");fq.prototype={areaStart:Zs,areaEnd:Zs,lineStart:o(function(){this._point=0},"lineStart"),lineEnd:o(function(){this._point&&this._context.closePath()},"lineEnd"),point:o(function(t,e){t=+t,e=+e,this._point?this._context.lineTo(t,e):(this._point=1,this._context.moveTo(t,e))},"point")};o(V5,"default")});function pq(t){return t<0?-1:1}function mq(t,e,r){var n=t._x1-t._x0,i=e-t._x1,a=(t._y1-t._y0)/(n||i<0&&-0),s=(r-t._y1)/(i||n<0&&-0),l=(a*i+s*n)/(n+i);return(pq(a)+pq(s))*Math.min(Math.abs(a),Math.abs(s),.5*Math.abs(l))||0}function gq(t,e){var r=t._x1-t._x0;return r?(3*(t._y1-t._y0)/r-e)/2:e}function m9(t,e,r){var n=t._x0,i=t._y0,a=t._x1,s=t._y1,l=(a-n)/3;t._context.bezierCurveTo(n+l,i+l*e,a-l,s-l*r,a,s)}function U5(t){this._context=t}function yq(t){this._context=new vq(t)}function vq(t){this._context=t}function zv(t){return new U5(t)}function Gv(t){return new yq(t)}var xq=N(()=>{"use strict";o(pq,"sign");o(mq,"slope3");o(gq,"slope2");o(m9,"point");o(U5,"MonotoneX");U5.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x0=this._x1=this._y0=this._y1=this._t0=NaN,this._point=0},"lineStart"),lineEnd:o(function(){switch(this._point){case 2:this._context.lineTo(this._x1,this._y1);break;case 3:m9(this,this._t0,gq(this,this._t0));break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},"lineEnd"),point:o(function(t,e){var r=NaN;if(t=+t,e=+e,!(t===this._x1&&e===this._y1)){switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,m9(this,gq(this,r=mq(this,t,e)),r);break;default:m9(this,this._t0,r=mq(this,t,e));break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e,this._t0=r}},"point")};o(yq,"MonotoneY");(yq.prototype=Object.create(U5.prototype)).point=function(t,e){U5.prototype.point.call(this,e,t)};o(vq,"ReflectContext");vq.prototype={moveTo:o(function(t,e){this._context.moveTo(e,t)},"moveTo"),closePath:o(function(){this._context.closePath()},"closePath"),lineTo:o(function(t,e){this._context.lineTo(e,t)},"lineTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._context.bezierCurveTo(e,t,n,r,a,i)},"bezierCurveTo")};o(zv,"monotoneX");o(Gv,"monotoneY")});function wq(t){this._context=t}function bq(t){var e,r=t.length-1,n,i=new Array(r),a=new Array(r),s=new Array(r);for(i[0]=0,a[0]=2,s[0]=t[0]+2*t[1],e=1;e=0;--e)i[e]=(s[e]-i[e+1])/a[e];for(a[r-1]=(t[r]+i[r-1])/2,e=0;e{"use strict";o(wq,"Natural");wq.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=[],this._y=[]},"lineStart"),lineEnd:o(function(){var t=this._x,e=this._y,r=t.length;if(r)if(this._line?this._context.lineTo(t[0],e[0]):this._context.moveTo(t[0],e[0]),r===2)this._context.lineTo(t[1],e[1]);else for(var n=bq(t),i=bq(e),a=0,s=1;s{"use strict";o(H5,"Step");H5.prototype={areaStart:o(function(){this._line=0},"areaStart"),areaEnd:o(function(){this._line=NaN},"areaEnd"),lineStart:o(function(){this._x=this._y=NaN,this._point=0},"lineStart"),lineEnd:o(function(){0=0&&(this._t=1-this._t,this._line=1-this._line)},"lineEnd"),point:o(function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:{if(this._t<=0)this._context.lineTo(this._x,e),this._context.lineTo(t,e);else{var r=this._x*(1-this._t)+t*this._t;this._context.lineTo(r,this._y),this._context.lineTo(r,e)}break}}this._x=t,this._y=e},"point")};o($0,"default");o(Vv,"stepBefore");o(Uv,"stepAfter")});var Eq=N(()=>{"use strict";WW();KW();JW();rq();iq();Ov();eq();sq();u9();f9();Bv();cq();hq();G5();dq();a9();xq();Tq();kq()});var Sq=N(()=>{"use strict"});var Cq=N(()=>{"use strict"});function Dh(t,e,r){this.k=t,this.x=e,this.y=r}function y9(t){for(;!t.__zoom;)if(!(t=t.parentNode))return g9;return t.__zoom}var g9,v9=N(()=>{"use strict";o(Dh,"Transform");Dh.prototype={constructor:Dh,scale:o(function(t){return t===1?this:new Dh(this.k*t,this.x,this.y)},"scale"),translate:o(function(t,e){return t===0&e===0?this:new Dh(this.k,this.x+this.k*t,this.y+this.k*e)},"translate"),apply:o(function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},"apply"),applyX:o(function(t){return t*this.k+this.x},"applyX"),applyY:o(function(t){return t*this.k+this.y},"applyY"),invert:o(function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},"invert"),invertX:o(function(t){return(t-this.x)/this.k},"invertX"),invertY:o(function(t){return(t-this.y)/this.k},"invertY"),rescaleX:o(function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},"rescaleX"),rescaleY:o(function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},"rescaleY"),toString:o(function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"},"toString")};g9=new Dh(1,0,0);y9.prototype=Dh.prototype;o(y9,"transform")});var Aq=N(()=>{"use strict"});var _q=N(()=>{"use strict";l5();Sq();Cq();v9();Aq()});var Dq=N(()=>{"use strict";_q();v9()});var dr=N(()=>{"use strict";vh();sV();SH();DH();E0();LH();RH();TA();QV();NH();u_();MH();OH();A_();jH();KH();A0();m_();QH();IH();ZH();$W();VW();fl();Eq();A5();Z_();r5();l5();Dq()});var Lq=Mi(Ji=>{"use strict";Object.defineProperty(Ji,"__esModule",{value:!0});Ji.BLANK_URL=Ji.relativeFirstCharacters=Ji.whitespaceEscapeCharsRegex=Ji.urlSchemeRegex=Ji.ctrlCharactersRegex=Ji.htmlCtrlEntityRegex=Ji.htmlEntitiesRegex=Ji.invalidProtocolRegex=void 0;Ji.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im;Ji.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g;Ji.htmlCtrlEntityRegex=/&(newline|tab);/gi;Ji.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;Ji.urlSchemeRegex=/^.+(:|:)/gim;Ji.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;Ji.relativeFirstCharacters=[".","/"];Ji.BLANK_URL="about:blank"});var z0=Mi(W5=>{"use strict";Object.defineProperty(W5,"__esModule",{value:!0});W5.sanitizeUrl=void 0;var Aa=Lq();function Hke(t){return Aa.relativeFirstCharacters.indexOf(t[0])>-1}o(Hke,"isRelativeUrlWithoutProtocol");function Wke(t){var e=t.replace(Aa.ctrlCharactersRegex,"");return e.replace(Aa.htmlEntitiesRegex,function(r,n){return String.fromCharCode(n)})}o(Wke,"decodeHtmlCharacters");function qke(t){return URL.canParse(t)}o(qke,"isValidUrl");function Rq(t){try{return decodeURIComponent(t)}catch{return t}}o(Rq,"decodeURI");function Yke(t){if(!t)return Aa.BLANK_URL;var e,r=Rq(t.trim());do r=Wke(r).replace(Aa.htmlCtrlEntityRegex,"").replace(Aa.ctrlCharactersRegex,"").replace(Aa.whitespaceEscapeCharsRegex,"").trim(),r=Rq(r),e=r.match(Aa.ctrlCharactersRegex)||r.match(Aa.htmlEntitiesRegex)||r.match(Aa.htmlCtrlEntityRegex)||r.match(Aa.whitespaceEscapeCharsRegex);while(e&&e.length>0);var n=r;if(!n)return Aa.BLANK_URL;if(Hke(n))return n;var i=n.trimStart(),a=i.match(Aa.urlSchemeRegex);if(!a)return n;var s=a[0].toLowerCase().trim();if(Aa.invalidProtocolRegex.test(s))return Aa.BLANK_URL;var l=i.replace(/\\/g,"/");if(s==="mailto:"||s.includes("://"))return l;if(s==="http:"||s==="https:"){if(!qke(l))return Aa.BLANK_URL;var u=new URL(l);return u.protocol=u.protocol.toLowerCase(),u.hostname=u.hostname.toLowerCase(),u.toString()}return l}o(Yke,"sanitizeUrl");W5.sanitizeUrl=Yke});var x9,kd,q5,Nq,Mq,Iq,Tl,Hv,Wv=N(()=>{"use strict";x9=Sa(z0(),1);gr();kd=o((t,e)=>{let r=t.append("rect");if(r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),e.name&&r.attr("name",e.name),e.rx&&r.attr("rx",e.rx),e.ry&&r.attr("ry",e.ry),e.attrs!==void 0)for(let n in e.attrs)r.attr(n,e.attrs[n]);return e.class&&r.attr("class",e.class),r},"drawRect"),q5=o((t,e)=>{let r={x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,stroke:e.stroke,class:"rect"};kd(t,r).lower()},"drawBackgroundRect"),Nq=o((t,e)=>{let r=e.text.replace(nd," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),Mq=o((t,e,r,n)=>{let i=t.append("image");i.attr("x",e),i.attr("y",r);let a=(0,x9.sanitizeUrl)(n);i.attr("xlink:href",a)},"drawImage"),Iq=o((t,e,r,n)=>{let i=t.append("use");i.attr("x",e),i.attr("y",r);let a=(0,x9.sanitizeUrl)(n);i.attr("xlink:href",`#${a}`)},"drawEmbeddedImage"),Tl=o(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),Hv=o(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj")});var Oq,b9,Pq,Xke,jke,Kke,Qke,Zke,Jke,eEe,tEe,rEe,nEe,iEe,aEe,Tu,kl,Bq=N(()=>{"use strict";gr();Wv();Oq=Sa(z0(),1),b9=o(function(t,e){return kd(t,e)},"drawRect"),Pq=o(function(t,e,r,n,i,a){let s=t.append("image");s.attr("width",e),s.attr("height",r),s.attr("x",n),s.attr("y",i);let l=a.startsWith("data:image/png;base64")?a:(0,Oq.sanitizeUrl)(a);s.attr("xlink:href",l)},"drawImage"),Xke=o((t,e,r)=>{let n=t.append("g"),i=0;for(let a of e){let s=a.textColor?a.textColor:"#444444",l=a.lineColor?a.lineColor:"#444444",u=a.offsetX?parseInt(a.offsetX):0,h=a.offsetY?parseInt(a.offsetY):0,f="";if(i===0){let p=n.append("line");p.attr("x1",a.startPoint.x),p.attr("y1",a.startPoint.y),p.attr("x2",a.endPoint.x),p.attr("y2",a.endPoint.y),p.attr("stroke-width","1"),p.attr("stroke",l),p.style("fill","none"),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)"),i=-1}else{let p=n.append("path");p.attr("fill","none").attr("stroke-width","1").attr("stroke",l).attr("d","Mstartx,starty Qcontrolx,controly stopx,stopy ".replaceAll("startx",a.startPoint.x).replaceAll("starty",a.startPoint.y).replaceAll("controlx",a.startPoint.x+(a.endPoint.x-a.startPoint.x)/2-(a.endPoint.x-a.startPoint.x)/4).replaceAll("controly",a.startPoint.y+(a.endPoint.y-a.startPoint.y)/2).replaceAll("stopx",a.endPoint.x).replaceAll("stopy",a.endPoint.y)),a.type!=="rel_b"&&p.attr("marker-end","url("+f+"#arrowhead)"),(a.type==="birel"||a.type==="rel_b")&&p.attr("marker-start","url("+f+"#arrowend)")}let d=r.messageFont();Tu(r)(a.label.text,n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+h,a.label.width,a.label.height,{fill:s},d),a.techn&&a.techn.text!==""&&(d=r.messageFont(),Tu(r)("["+a.techn.text+"]",n,Math.min(a.startPoint.x,a.endPoint.x)+Math.abs(a.endPoint.x-a.startPoint.x)/2+u,Math.min(a.startPoint.y,a.endPoint.y)+Math.abs(a.endPoint.y-a.startPoint.y)/2+r.messageFontSize+5+h,Math.max(a.label.width,a.techn.width),a.techn.height,{fill:s,"font-style":"italic"},d))}},"drawRels"),jke=o(function(t,e,r){let n=t.append("g"),i=e.bgColor?e.bgColor:"none",a=e.borderColor?e.borderColor:"#444444",s=e.fontColor?e.fontColor:"black",l={"stroke-width":1,"stroke-dasharray":"7.0,7.0"};e.nodeType&&(l={"stroke-width":1});let u={x:e.x,y:e.y,fill:i,stroke:a,width:e.width,height:e.height,rx:2.5,ry:2.5,attrs:l};b9(n,u);let h=r.boundaryFont();h.fontWeight="bold",h.fontSize=h.fontSize+2,h.fontColor=s,Tu(r)(e.label.text,n,e.x,e.y+e.label.Y,e.width,e.height,{fill:"#444444"},h),e.type&&e.type.text!==""&&(h=r.boundaryFont(),h.fontColor=s,Tu(r)(e.type.text,n,e.x,e.y+e.type.Y,e.width,e.height,{fill:"#444444"},h)),e.descr&&e.descr.text!==""&&(h=r.boundaryFont(),h.fontSize=h.fontSize-2,h.fontColor=s,Tu(r)(e.descr.text,n,e.x,e.y+e.descr.Y,e.width,e.height,{fill:"#444444"},h))},"drawBoundary"),Kke=o(function(t,e,r){let n=e.bgColor?e.bgColor:r[e.typeC4Shape.text+"_bg_color"],i=e.borderColor?e.borderColor:r[e.typeC4Shape.text+"_border_color"],a=e.fontColor?e.fontColor:"#FFFFFF",s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";switch(e.typeC4Shape.text){case"person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";break;case"external_person":s="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAB6ElEQVR4Xu2YLY+EMBCG9+dWr0aj0Wg0Go1Go0+j8Xdv2uTCvv1gpt0ebHKPuhDaeW4605Z9mJvx4AdXUyTUdd08z+u6flmWZRnHsWkafk9DptAwDPu+f0eAYtu2PEaGWuj5fCIZrBAC2eLBAnRCsEkkxmeaJp7iDJ2QMDdHsLg8SxKFEJaAo8lAXnmuOFIhTMpxxKATebo4UiFknuNo4OniSIXQyRxEA3YsnjGCVEjVXD7yLUAqxBGUyPv/Y4W2beMgGuS7kVQIBycH0fD+oi5pezQETxdHKmQKGk1eQEYldK+jw5GxPfZ9z7Mk0Qnhf1W1m3w//EUn5BDmSZsbR44QQLBEqrBHqOrmSKaQAxdnLArCrxZcM7A7ZKs4ioRq8LFC+NpC3WCBJsvpVw5edm9iEXFuyNfxXAgSwfrFQ1c0iNda8AdejvUgnktOtJQQxmcfFzGglc5WVCj7oDgFqU18boeFSs52CUh8LE8BIVQDT1ABrB0HtgSEYlX5doJnCwv9TXocKCaKbnwhdDKPq4lf3SwU3HLq4V/+WYhHVMa/3b4IlfyikAduCkcBc7mQ3/z/Qq/cTuikhkzB12Ae/mcJC9U+Vo8Ej1gWAtgbeGgFsAMHr50BIWOLCbezvhpBFUdY6EJuJ/QDW0XoMX60zZ0AAAAASUVORK5CYII=";break}let l=t.append("g");l.attr("class","person-man");let u=Tl();switch(e.typeC4Shape.text){case"person":case"external_person":case"system":case"external_system":case"container":case"external_container":case"component":case"external_component":u.x=e.x,u.y=e.y,u.fill=n,u.width=e.width,u.height=e.height,u.stroke=i,u.rx=2.5,u.ry=2.5,u.attrs={"stroke-width":.5},b9(l,u);break;case"system_db":case"external_system_db":case"container_db":case"external_container_db":case"component_db":case"external_component_db":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,-10 half,-10 half,-10c0,0 half,0 half,10l0,heightc0,10 -half,10 -half,10c0,0 -half,0 -half,-10l0,-height".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2).replaceAll("height",e.height)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc0,10 half,10 half,10c0,0 half,0 half,-10".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("half",e.width/2));break;case"system_queue":case"external_system_queue":case"container_queue":case"external_container_queue":case"component_queue":case"external_component_queue":l.append("path").attr("fill",n).attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startylwidth,0c5,0 5,half 5,halfc0,0 0,half -5,halfl-width,0c-5,0 -5,-half -5,-halfc0,0 0,-half 5,-half".replaceAll("startx",e.x).replaceAll("starty",e.y).replaceAll("width",e.width).replaceAll("half",e.height/2)),l.append("path").attr("fill","none").attr("stroke-width","0.5").attr("stroke",i).attr("d","Mstartx,startyc-5,0 -5,half -5,halfc0,half 5,half 5,half".replaceAll("startx",e.x+e.width).replaceAll("starty",e.y).replaceAll("half",e.height/2));break}let h=aEe(r,e.typeC4Shape.text);switch(l.append("text").attr("fill",a).attr("font-family",h.fontFamily).attr("font-size",h.fontSize-2).attr("font-style","italic").attr("lengthAdjust","spacing").attr("textLength",e.typeC4Shape.width).attr("x",e.x+e.width/2-e.typeC4Shape.width/2).attr("y",e.y+e.typeC4Shape.Y).text("<<"+e.typeC4Shape.text+">>"),e.typeC4Shape.text){case"person":case"external_person":Pq(l,48,48,e.x+e.width/2-24,e.y+e.image.Y,s);break}let f=r[e.typeC4Shape.text+"Font"]();return f.fontWeight="bold",f.fontSize=f.fontSize+2,f.fontColor=a,Tu(r)(e.label.text,l,e.x,e.y+e.label.Y,e.width,e.height,{fill:a},f),f=r[e.typeC4Shape.text+"Font"](),f.fontColor=a,e.techn&&e.techn?.text!==""?Tu(r)(e.techn.text,l,e.x,e.y+e.techn.Y,e.width,e.height,{fill:a,"font-style":"italic"},f):e.type&&e.type.text!==""&&Tu(r)(e.type.text,l,e.x,e.y+e.type.Y,e.width,e.height,{fill:a,"font-style":"italic"},f),e.descr&&e.descr.text!==""&&(f=r.personFont(),f.fontColor=a,Tu(r)(e.descr.text,l,e.x,e.y+e.descr.Y,e.width,e.height,{fill:a},f)),e.height},"drawC4Shape"),Qke=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),Zke=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),Jke=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),eEe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z")},"insertArrowHead"),tEe=o(function(t){t.append("defs").append("marker").attr("id","arrowend").attr("refX",1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 10 0 L 0 5 L 10 10 z")},"insertArrowEnd"),rEe=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",18).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),nEe=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertDynamicNumber"),iEe=o(function(t){let r=t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",16).attr("refY",4);r.append("path").attr("fill","black").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 9,2 V 6 L16,4 Z"),r.append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1px").attr("d","M 0,1 L 6,7 M 6,1 L 0,7")},"insertArrowCrossHead"),aEe=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"getC4ShapeFont"),Tu=function(){function t(i,a,s,l,u,h,f){let d=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("text-anchor","middle").text(i);n(d,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d){let{fontSize:p,fontFamily:m,fontWeight:g}=d,y=i.split(Ze.lineBreakRegex);for(let v=0;v{"use strict";sEe=typeof global=="object"&&global&&global.Object===Object&&global,X5=sEe});var oEe,lEe,li,Lo=N(()=>{"use strict";w9();oEe=typeof self=="object"&&self&&self.Object===Object&&self,lEe=X5||oEe||Function("return this")(),li=lEe});var cEe,ea,Ed=N(()=>{"use strict";Lo();cEe=li.Symbol,ea=cEe});function fEe(t){var e=uEe.call(t,qv),r=t[qv];try{t[qv]=void 0;var n=!0}catch{}var i=hEe.call(t);return n&&(e?t[qv]=r:delete t[qv]),i}var Fq,uEe,hEe,qv,$q,zq=N(()=>{"use strict";Ed();Fq=Object.prototype,uEe=Fq.hasOwnProperty,hEe=Fq.toString,qv=ea?ea.toStringTag:void 0;o(fEe,"getRawTag");$q=fEe});function mEe(t){return pEe.call(t)}var dEe,pEe,Gq,Vq=N(()=>{"use strict";dEe=Object.prototype,pEe=dEe.toString;o(mEe,"objectToString");Gq=mEe});function vEe(t){return t==null?t===void 0?yEe:gEe:Uq&&Uq in Object(t)?$q(t):Gq(t)}var gEe,yEe,Uq,da,ku=N(()=>{"use strict";Ed();zq();Vq();gEe="[object Null]",yEe="[object Undefined]",Uq=ea?ea.toStringTag:void 0;o(vEe,"baseGetTag");da=vEe});function xEe(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}var bn,Js=N(()=>{"use strict";o(xEe,"isObject");bn=xEe});function EEe(t){if(!bn(t))return!1;var e=da(t);return e==wEe||e==TEe||e==bEe||e==kEe}var bEe,wEe,TEe,kEe,Si,Yv=N(()=>{"use strict";ku();Js();bEe="[object AsyncFunction]",wEe="[object Function]",TEe="[object GeneratorFunction]",kEe="[object Proxy]";o(EEe,"isFunction");Si=EEe});var SEe,j5,Hq=N(()=>{"use strict";Lo();SEe=li["__core-js_shared__"],j5=SEe});function CEe(t){return!!Wq&&Wq in t}var Wq,qq,Yq=N(()=>{"use strict";Hq();Wq=function(){var t=/[^.]+$/.exec(j5&&j5.keys&&j5.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}();o(CEe,"isMasked");qq=CEe});function DEe(t){if(t!=null){try{return _Ee.call(t)}catch{}try{return t+""}catch{}}return""}var AEe,_Ee,Eu,T9=N(()=>{"use strict";AEe=Function.prototype,_Ee=AEe.toString;o(DEe,"toSource");Eu=DEe});function BEe(t){if(!bn(t)||qq(t))return!1;var e=Si(t)?PEe:REe;return e.test(Eu(t))}var LEe,REe,NEe,MEe,IEe,OEe,PEe,Xq,jq=N(()=>{"use strict";Yv();Yq();Js();T9();LEe=/[\\^$.*+?()[\]{}|]/g,REe=/^\[object .+?Constructor\]$/,NEe=Function.prototype,MEe=Object.prototype,IEe=NEe.toString,OEe=MEe.hasOwnProperty,PEe=RegExp("^"+IEe.call(OEe).replace(LEe,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");o(BEe,"baseIsNative");Xq=BEe});function FEe(t,e){return t?.[e]}var Kq,Qq=N(()=>{"use strict";o(FEe,"getValue");Kq=FEe});function $Ee(t,e){var r=Kq(t,e);return Xq(r)?r:void 0}var Ss,Lh=N(()=>{"use strict";jq();Qq();o($Ee,"getNative");Ss=$Ee});var zEe,Su,Xv=N(()=>{"use strict";Lh();zEe=Ss(Object,"create"),Su=zEe});function GEe(){this.__data__=Su?Su(null):{},this.size=0}var Zq,Jq=N(()=>{"use strict";Xv();o(GEe,"hashClear");Zq=GEe});function VEe(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}var eY,tY=N(()=>{"use strict";o(VEe,"hashDelete");eY=VEe});function qEe(t){var e=this.__data__;if(Su){var r=e[t];return r===UEe?void 0:r}return WEe.call(e,t)?e[t]:void 0}var UEe,HEe,WEe,rY,nY=N(()=>{"use strict";Xv();UEe="__lodash_hash_undefined__",HEe=Object.prototype,WEe=HEe.hasOwnProperty;o(qEe,"hashGet");rY=qEe});function jEe(t){var e=this.__data__;return Su?e[t]!==void 0:XEe.call(e,t)}var YEe,XEe,iY,aY=N(()=>{"use strict";Xv();YEe=Object.prototype,XEe=YEe.hasOwnProperty;o(jEe,"hashHas");iY=jEe});function QEe(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=Su&&e===void 0?KEe:e,this}var KEe,sY,oY=N(()=>{"use strict";Xv();KEe="__lodash_hash_undefined__";o(QEe,"hashSet");sY=QEe});function G0(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";Jq();tY();nY();aY();oY();o(G0,"Hash");G0.prototype.clear=Zq;G0.prototype.delete=eY;G0.prototype.get=rY;G0.prototype.has=iY;G0.prototype.set=sY;k9=G0});function ZEe(){this.__data__=[],this.size=0}var cY,uY=N(()=>{"use strict";o(ZEe,"listCacheClear");cY=ZEe});function JEe(t,e){return t===e||t!==t&&e!==e}var Ro,Sd=N(()=>{"use strict";o(JEe,"eq");Ro=JEe});function e6e(t,e){for(var r=t.length;r--;)if(Ro(t[r][0],e))return r;return-1}var Rh,jv=N(()=>{"use strict";Sd();o(e6e,"assocIndexOf");Rh=e6e});function n6e(t){var e=this.__data__,r=Rh(e,t);if(r<0)return!1;var n=e.length-1;return r==n?e.pop():r6e.call(e,r,1),--this.size,!0}var t6e,r6e,hY,fY=N(()=>{"use strict";jv();t6e=Array.prototype,r6e=t6e.splice;o(n6e,"listCacheDelete");hY=n6e});function i6e(t){var e=this.__data__,r=Rh(e,t);return r<0?void 0:e[r][1]}var dY,pY=N(()=>{"use strict";jv();o(i6e,"listCacheGet");dY=i6e});function a6e(t){return Rh(this.__data__,t)>-1}var mY,gY=N(()=>{"use strict";jv();o(a6e,"listCacheHas");mY=a6e});function s6e(t,e){var r=this.__data__,n=Rh(r,t);return n<0?(++this.size,r.push([t,e])):r[n][1]=e,this}var yY,vY=N(()=>{"use strict";jv();o(s6e,"listCacheSet");yY=s6e});function V0(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";uY();fY();pY();gY();vY();o(V0,"ListCache");V0.prototype.clear=cY;V0.prototype.delete=hY;V0.prototype.get=dY;V0.prototype.has=mY;V0.prototype.set=yY;Nh=V0});var o6e,Mh,K5=N(()=>{"use strict";Lh();Lo();o6e=Ss(li,"Map"),Mh=o6e});function l6e(){this.size=0,this.__data__={hash:new k9,map:new(Mh||Nh),string:new k9}}var xY,bY=N(()=>{"use strict";lY();Kv();K5();o(l6e,"mapCacheClear");xY=l6e});function c6e(t){var e=typeof t;return e=="string"||e=="number"||e=="symbol"||e=="boolean"?t!=="__proto__":t===null}var wY,TY=N(()=>{"use strict";o(c6e,"isKeyable");wY=c6e});function u6e(t,e){var r=t.__data__;return wY(e)?r[typeof e=="string"?"string":"hash"]:r.map}var Ih,Qv=N(()=>{"use strict";TY();o(u6e,"getMapData");Ih=u6e});function h6e(t){var e=Ih(this,t).delete(t);return this.size-=e?1:0,e}var kY,EY=N(()=>{"use strict";Qv();o(h6e,"mapCacheDelete");kY=h6e});function f6e(t){return Ih(this,t).get(t)}var SY,CY=N(()=>{"use strict";Qv();o(f6e,"mapCacheGet");SY=f6e});function d6e(t){return Ih(this,t).has(t)}var AY,_Y=N(()=>{"use strict";Qv();o(d6e,"mapCacheHas");AY=d6e});function p6e(t,e){var r=Ih(this,t),n=r.size;return r.set(t,e),this.size+=r.size==n?0:1,this}var DY,LY=N(()=>{"use strict";Qv();o(p6e,"mapCacheSet");DY=p6e});function U0(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e{"use strict";bY();EY();CY();_Y();LY();o(U0,"MapCache");U0.prototype.clear=xY;U0.prototype.delete=kY;U0.prototype.get=SY;U0.prototype.has=AY;U0.prototype.set=DY;Cd=U0});function E9(t,e){if(typeof t!="function"||e!=null&&typeof e!="function")throw new TypeError(m6e);var r=o(function(){var n=arguments,i=e?e.apply(this,n):n[0],a=r.cache;if(a.has(i))return a.get(i);var s=t.apply(this,n);return r.cache=a.set(i,s)||a,s},"memoized");return r.cache=new(E9.Cache||Cd),r}var m6e,H0,S9=N(()=>{"use strict";Q5();m6e="Expected a function";o(E9,"memoize");E9.Cache=Cd;H0=E9});function g6e(){this.__data__=new Nh,this.size=0}var RY,NY=N(()=>{"use strict";Kv();o(g6e,"stackClear");RY=g6e});function y6e(t){var e=this.__data__,r=e.delete(t);return this.size=e.size,r}var MY,IY=N(()=>{"use strict";o(y6e,"stackDelete");MY=y6e});function v6e(t){return this.__data__.get(t)}var OY,PY=N(()=>{"use strict";o(v6e,"stackGet");OY=v6e});function x6e(t){return this.__data__.has(t)}var BY,FY=N(()=>{"use strict";o(x6e,"stackHas");BY=x6e});function w6e(t,e){var r=this.__data__;if(r instanceof Nh){var n=r.__data__;if(!Mh||n.length{"use strict";Kv();K5();Q5();b6e=200;o(w6e,"stackSet");$Y=w6e});function W0(t){var e=this.__data__=new Nh(t);this.size=e.size}var lc,Zv=N(()=>{"use strict";Kv();NY();IY();PY();FY();zY();o(W0,"Stack");W0.prototype.clear=RY;W0.prototype.delete=MY;W0.prototype.get=OY;W0.prototype.has=BY;W0.prototype.set=$Y;lc=W0});var T6e,q0,C9=N(()=>{"use strict";Lh();T6e=function(){try{var t=Ss(Object,"defineProperty");return t({},"",{}),t}catch{}}(),q0=T6e});function k6e(t,e,r){e=="__proto__"&&q0?q0(t,e,{configurable:!0,enumerable:!0,value:r,writable:!0}):t[e]=r}var cc,Y0=N(()=>{"use strict";C9();o(k6e,"baseAssignValue");cc=k6e});function E6e(t,e,r){(r!==void 0&&!Ro(t[e],r)||r===void 0&&!(e in t))&&cc(t,e,r)}var Jv,A9=N(()=>{"use strict";Y0();Sd();o(E6e,"assignMergeValue");Jv=E6e});function S6e(t){return function(e,r,n){for(var i=-1,a=Object(e),s=n(e),l=s.length;l--;){var u=s[t?l:++i];if(r(a[u],u,a)===!1)break}return e}}var GY,VY=N(()=>{"use strict";o(S6e,"createBaseFor");GY=S6e});var C6e,X0,Z5=N(()=>{"use strict";VY();C6e=GY(),X0=C6e});function _6e(t,e){if(e)return t.slice();var r=t.length,n=WY?WY(r):new t.constructor(r);return t.copy(n),n}var qY,UY,A6e,HY,WY,J5,_9=N(()=>{"use strict";Lo();qY=typeof exports=="object"&&exports&&!exports.nodeType&&exports,UY=qY&&typeof module=="object"&&module&&!module.nodeType&&module,A6e=UY&&UY.exports===qY,HY=A6e?li.Buffer:void 0,WY=HY?HY.allocUnsafe:void 0;o(_6e,"cloneBuffer");J5=_6e});var D6e,j0,D9=N(()=>{"use strict";Lo();D6e=li.Uint8Array,j0=D6e});function L6e(t){var e=new t.constructor(t.byteLength);return new j0(e).set(new j0(t)),e}var K0,ew=N(()=>{"use strict";D9();o(L6e,"cloneArrayBuffer");K0=L6e});function R6e(t,e){var r=e?K0(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.length)}var tw,L9=N(()=>{"use strict";ew();o(R6e,"cloneTypedArray");tw=R6e});function N6e(t,e){var r=-1,n=t.length;for(e||(e=Array(n));++r{"use strict";o(N6e,"copyArray");rw=N6e});var YY,M6e,XY,jY=N(()=>{"use strict";Js();YY=Object.create,M6e=function(){function t(){}return o(t,"object"),function(e){if(!bn(e))return{};if(YY)return YY(e);t.prototype=e;var r=new t;return t.prototype=void 0,r}}(),XY=M6e});function I6e(t,e){return function(r){return t(e(r))}}var nw,N9=N(()=>{"use strict";o(I6e,"overArg");nw=I6e});var O6e,Q0,iw=N(()=>{"use strict";N9();O6e=nw(Object.getPrototypeOf,Object),Q0=O6e});function B6e(t){var e=t&&t.constructor,r=typeof e=="function"&&e.prototype||P6e;return t===r}var P6e,uc,Z0=N(()=>{"use strict";P6e=Object.prototype;o(B6e,"isPrototype");uc=B6e});function F6e(t){return typeof t.constructor=="function"&&!uc(t)?XY(Q0(t)):{}}var aw,M9=N(()=>{"use strict";jY();iw();Z0();o(F6e,"initCloneObject");aw=F6e});function $6e(t){return t!=null&&typeof t=="object"}var ri,No=N(()=>{"use strict";o($6e,"isObjectLike");ri=$6e});function G6e(t){return ri(t)&&da(t)==z6e}var z6e,I9,KY=N(()=>{"use strict";ku();No();z6e="[object Arguments]";o(G6e,"baseIsArguments");I9=G6e});var QY,V6e,U6e,H6e,El,J0=N(()=>{"use strict";KY();No();QY=Object.prototype,V6e=QY.hasOwnProperty,U6e=QY.propertyIsEnumerable,H6e=I9(function(){return arguments}())?I9:function(t){return ri(t)&&V6e.call(t,"callee")&&!U6e.call(t,"callee")},El=H6e});var W6e,Pt,Un=N(()=>{"use strict";W6e=Array.isArray,Pt=W6e});function Y6e(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=q6e}var q6e,em,sw=N(()=>{"use strict";q6e=9007199254740991;o(Y6e,"isLength");em=Y6e});function X6e(t){return t!=null&&em(t.length)&&!Si(t)}var ci,Mo=N(()=>{"use strict";Yv();sw();o(X6e,"isArrayLike");ci=X6e});function j6e(t){return ri(t)&&ci(t)}var Ad,ow=N(()=>{"use strict";Mo();No();o(j6e,"isArrayLikeObject");Ad=j6e});function K6e(){return!1}var ZY,JY=N(()=>{"use strict";o(K6e,"stubFalse");ZY=K6e});var rX,eX,Q6e,tX,Z6e,J6e,Sl,tm=N(()=>{"use strict";Lo();JY();rX=typeof exports=="object"&&exports&&!exports.nodeType&&exports,eX=rX&&typeof module=="object"&&module&&!module.nodeType&&module,Q6e=eX&&eX.exports===rX,tX=Q6e?li.Buffer:void 0,Z6e=tX?tX.isBuffer:void 0,J6e=Z6e||ZY,Sl=J6e});function aSe(t){if(!ri(t)||da(t)!=eSe)return!1;var e=Q0(t);if(e===null)return!0;var r=nSe.call(e,"constructor")&&e.constructor;return typeof r=="function"&&r instanceof r&&nX.call(r)==iSe}var eSe,tSe,rSe,nX,nSe,iSe,iX,aX=N(()=>{"use strict";ku();iw();No();eSe="[object Object]",tSe=Function.prototype,rSe=Object.prototype,nX=tSe.toString,nSe=rSe.hasOwnProperty,iSe=nX.call(Object);o(aSe,"isPlainObject");iX=aSe});function LSe(t){return ri(t)&&em(t.length)&&!!Fn[da(t)]}var sSe,oSe,lSe,cSe,uSe,hSe,fSe,dSe,pSe,mSe,gSe,ySe,vSe,xSe,bSe,wSe,TSe,kSe,ESe,SSe,CSe,ASe,_Se,DSe,Fn,sX,oX=N(()=>{"use strict";ku();sw();No();sSe="[object Arguments]",oSe="[object Array]",lSe="[object Boolean]",cSe="[object Date]",uSe="[object Error]",hSe="[object Function]",fSe="[object Map]",dSe="[object Number]",pSe="[object Object]",mSe="[object RegExp]",gSe="[object Set]",ySe="[object String]",vSe="[object WeakMap]",xSe="[object ArrayBuffer]",bSe="[object DataView]",wSe="[object Float32Array]",TSe="[object Float64Array]",kSe="[object Int8Array]",ESe="[object Int16Array]",SSe="[object Int32Array]",CSe="[object Uint8Array]",ASe="[object Uint8ClampedArray]",_Se="[object Uint16Array]",DSe="[object Uint32Array]",Fn={};Fn[wSe]=Fn[TSe]=Fn[kSe]=Fn[ESe]=Fn[SSe]=Fn[CSe]=Fn[ASe]=Fn[_Se]=Fn[DSe]=!0;Fn[sSe]=Fn[oSe]=Fn[xSe]=Fn[lSe]=Fn[bSe]=Fn[cSe]=Fn[uSe]=Fn[hSe]=Fn[fSe]=Fn[dSe]=Fn[pSe]=Fn[mSe]=Fn[gSe]=Fn[ySe]=Fn[vSe]=!1;o(LSe,"baseIsTypedArray");sX=LSe});function RSe(t){return function(e){return t(e)}}var Io,_d=N(()=>{"use strict";o(RSe,"baseUnary");Io=RSe});var lX,e2,NSe,O9,MSe,Oo,t2=N(()=>{"use strict";w9();lX=typeof exports=="object"&&exports&&!exports.nodeType&&exports,e2=lX&&typeof module=="object"&&module&&!module.nodeType&&module,NSe=e2&&e2.exports===lX,O9=NSe&&X5.process,MSe=function(){try{var t=e2&&e2.require&&e2.require("util").types;return t||O9&&O9.binding&&O9.binding("util")}catch{}}(),Oo=MSe});var cX,ISe,Oh,r2=N(()=>{"use strict";oX();_d();t2();cX=Oo&&Oo.isTypedArray,ISe=cX?Io(cX):sX,Oh=ISe});function OSe(t,e){if(!(e==="constructor"&&typeof t[e]=="function")&&e!="__proto__")return t[e]}var n2,P9=N(()=>{"use strict";o(OSe,"safeGet");n2=OSe});function FSe(t,e,r){var n=t[e];(!(BSe.call(t,e)&&Ro(n,r))||r===void 0&&!(e in t))&&cc(t,e,r)}var PSe,BSe,hc,rm=N(()=>{"use strict";Y0();Sd();PSe=Object.prototype,BSe=PSe.hasOwnProperty;o(FSe,"assignValue");hc=FSe});function $Se(t,e,r,n){var i=!r;r||(r={});for(var a=-1,s=e.length;++a{"use strict";rm();Y0();o($Se,"copyObject");Po=$Se});function zSe(t,e){for(var r=-1,n=Array(t);++r{"use strict";o(zSe,"baseTimes");uX=zSe});function USe(t,e){var r=typeof t;return e=e??GSe,!!e&&(r=="number"||r!="symbol"&&VSe.test(t))&&t>-1&&t%1==0&&t{"use strict";GSe=9007199254740991,VSe=/^(?:0|[1-9]\d*)$/;o(USe,"isIndex");Ph=USe});function qSe(t,e){var r=Pt(t),n=!r&&El(t),i=!r&&!n&&Sl(t),a=!r&&!n&&!i&&Oh(t),s=r||n||i||a,l=s?uX(t.length,String):[],u=l.length;for(var h in t)(e||WSe.call(t,h))&&!(s&&(h=="length"||i&&(h=="offset"||h=="parent")||a&&(h=="buffer"||h=="byteLength"||h=="byteOffset")||Ph(h,u)))&&l.push(h);return l}var HSe,WSe,lw,B9=N(()=>{"use strict";hX();J0();Un();tm();i2();r2();HSe=Object.prototype,WSe=HSe.hasOwnProperty;o(qSe,"arrayLikeKeys");lw=qSe});function YSe(t){var e=[];if(t!=null)for(var r in Object(t))e.push(r);return e}var fX,dX=N(()=>{"use strict";o(YSe,"nativeKeysIn");fX=YSe});function KSe(t){if(!bn(t))return fX(t);var e=uc(t),r=[];for(var n in t)n=="constructor"&&(e||!jSe.call(t,n))||r.push(n);return r}var XSe,jSe,pX,mX=N(()=>{"use strict";Js();Z0();dX();XSe=Object.prototype,jSe=XSe.hasOwnProperty;o(KSe,"baseKeysIn");pX=KSe});function QSe(t){return ci(t)?lw(t,!0):pX(t)}var Cs,Bh=N(()=>{"use strict";B9();mX();Mo();o(QSe,"keysIn");Cs=QSe});function ZSe(t){return Po(t,Cs(t))}var gX,yX=N(()=>{"use strict";Dd();Bh();o(ZSe,"toPlainObject");gX=ZSe});function JSe(t,e,r,n,i,a,s){var l=n2(t,r),u=n2(e,r),h=s.get(u);if(h){Jv(t,r,h);return}var f=a?a(l,u,r+"",t,e,s):void 0,d=f===void 0;if(d){var p=Pt(u),m=!p&&Sl(u),g=!p&&!m&&Oh(u);f=u,p||m||g?Pt(l)?f=l:Ad(l)?f=rw(l):m?(d=!1,f=J5(u,!0)):g?(d=!1,f=tw(u,!0)):f=[]:iX(u)||El(u)?(f=l,El(l)?f=gX(l):(!bn(l)||Si(l))&&(f=aw(u))):d=!1}d&&(s.set(u,f),i(f,u,n,a,s),s.delete(u)),Jv(t,r,f)}var vX,xX=N(()=>{"use strict";A9();_9();L9();R9();M9();J0();Un();ow();tm();Yv();Js();aX();r2();P9();yX();o(JSe,"baseMergeDeep");vX=JSe});function bX(t,e,r,n,i){t!==e&&X0(e,function(a,s){if(i||(i=new lc),bn(a))vX(t,e,s,r,bX,n,i);else{var l=n?n(n2(t,s),a,s+"",t,e,i):void 0;l===void 0&&(l=a),Jv(t,s,l)}},Cs)}var wX,TX=N(()=>{"use strict";Zv();A9();Z5();xX();Js();Bh();P9();o(bX,"baseMerge");wX=bX});function eCe(t){return t}var ta,Cu=N(()=>{"use strict";o(eCe,"identity");ta=eCe});function tCe(t,e,r){switch(r.length){case 0:return t.call(e);case 1:return t.call(e,r[0]);case 2:return t.call(e,r[0],r[1]);case 3:return t.call(e,r[0],r[1],r[2])}return t.apply(e,r)}var kX,EX=N(()=>{"use strict";o(tCe,"apply");kX=tCe});function rCe(t,e,r){return e=SX(e===void 0?t.length-1:e,0),function(){for(var n=arguments,i=-1,a=SX(n.length-e,0),s=Array(a);++i{"use strict";EX();SX=Math.max;o(rCe,"overRest");cw=rCe});function nCe(t){return function(){return t}}var As,$9=N(()=>{"use strict";o(nCe,"constant");As=nCe});var iCe,CX,AX=N(()=>{"use strict";$9();C9();Cu();iCe=q0?function(t,e){return q0(t,"toString",{configurable:!0,enumerable:!1,value:As(e),writable:!0})}:ta,CX=iCe});function lCe(t){var e=0,r=0;return function(){var n=oCe(),i=sCe-(n-r);if(r=n,i>0){if(++e>=aCe)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}var aCe,sCe,oCe,_X,DX=N(()=>{"use strict";aCe=800,sCe=16,oCe=Date.now;o(lCe,"shortOut");_X=lCe});var cCe,uw,z9=N(()=>{"use strict";AX();DX();cCe=_X(CX),uw=cCe});function uCe(t,e){return uw(cw(t,e,ta),t+"")}var fc,nm=N(()=>{"use strict";Cu();F9();z9();o(uCe,"baseRest");fc=uCe});function hCe(t,e,r){if(!bn(r))return!1;var n=typeof e;return(n=="number"?ci(r)&&Ph(e,r.length):n=="string"&&e in r)?Ro(r[e],t):!1}var eo,Ld=N(()=>{"use strict";Sd();Mo();i2();Js();o(hCe,"isIterateeCall");eo=hCe});function fCe(t){return fc(function(e,r){var n=-1,i=r.length,a=i>1?r[i-1]:void 0,s=i>2?r[2]:void 0;for(a=t.length>3&&typeof a=="function"?(i--,a):void 0,s&&eo(r[0],r[1],s)&&(a=i<3?void 0:a,i=1),e=Object(e);++n{"use strict";nm();Ld();o(fCe,"createAssigner");hw=fCe});var dCe,Fh,V9=N(()=>{"use strict";TX();G9();dCe=hw(function(t,e,r){wX(t,e,r)}),Fh=dCe});function W9(t,e){if(!t)return e;let r=`curve${t.charAt(0).toUpperCase()+t.slice(1)}`;return pCe[r]??e}function vCe(t,e){let r=t.trim();if(r)return e.securityLevel!=="loose"?(0,NX.sanitizeUrl)(r):r}function OX(t,e){return!t||!e?0:Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function bCe(t){let e,r=0;t.forEach(i=>{r+=OX(i,e),e=i});let n=r/2;return q9(t,n)}function wCe(t){return t.length===1?t[0]:bCe(t)}function kCe(t,e,r){let n=structuredClone(r);Y.info("our points",n),e!=="start_left"&&e!=="start_right"&&n.reverse();let i=25+t,a=q9(n,i),s=10+t*.5,l=Math.atan2(n[0].y-a.y,n[0].x-a.x),u={x:0,y:0};return e==="start_left"?(u.x=Math.sin(l+Math.PI)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l+Math.PI)*s+(n[0].y+a.y)/2):e==="end_right"?(u.x=Math.sin(l-Math.PI)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l-Math.PI)*s+(n[0].y+a.y)/2-5):e==="end_left"?(u.x=Math.sin(l)*s+(n[0].x+a.x)/2-5,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2-5):(u.x=Math.sin(l)*s+(n[0].x+a.x)/2,u.y=-Math.cos(l)*s+(n[0].y+a.y)/2),u}function Y9(t){let e="",r="";for(let n of t)n!==void 0&&(n.startsWith("color:")||n.startsWith("text-align:")?r=r+n+";":e=e+n+";");return{style:e,labelStyle:r}}function ECe(t){let e="",r="0123456789abcdef",n=r.length;for(let i=0;i{"use strict";NX=Sa(z0(),1);dr();gr();e7();vt();Xf();s0();S9();V9();$4();H9="\u200B",pCe={curveBasis:Do,curveBasisClosed:P5,curveBasisOpen:B5,curveBumpX:Rv,curveBumpY:Nv,curveBundle:l9,curveCardinalClosed:c9,curveCardinalOpen:h9,curveCardinal:Pv,curveCatmullRomClosed:d9,curveCatmullRomOpen:p9,curveCatmullRom:$v,curveLinear:wu,curveLinearClosed:V5,curveMonotoneX:zv,curveMonotoneY:Gv,curveNatural:F0,curveStep:$0,curveStepAfter:Uv,curveStepBefore:Vv},mCe=/\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi,gCe=o(function(t,e){let r=MX(t,/(?:init\b)|(?:initialize\b)/),n={};if(Array.isArray(r)){let s=r.map(l=>l.args);l0(s),n=Gn(n,[...s])}else n=r.args;if(!n)return;let i=a0(t,e),a="config";return n[a]!==void 0&&(i==="flowchart-v2"&&(i="flowchart"),n[i]=n[a],delete n[a]),n},"detectInit"),MX=o(function(t,e=null){try{let r=new RegExp(`[%]{2}(?![{]${mCe.source})(?=[}][%]{2}).* +`,"ig");t=t.trim().replace(r,"").replace(/'/gm,'"'),Y.debug(`Detecting diagram directive${e!==null?" type:"+e:""} based on the text:${t}`);let n,i=[];for(;(n=qf.exec(t))!==null;)if(n.index===qf.lastIndex&&qf.lastIndex++,n&&!e||e&&n[1]?.match(e)||e&&n[2]?.match(e)){let a=n[1]?n[1]:n[2],s=n[3]?n[3].trim():n[4]?JSON.parse(n[4].trim()):null;i.push({type:a,args:s})}return i.length===0?{type:t,args:null}:i.length===1?i[0]:i}catch(r){return Y.error(`ERROR: ${r.message} - Unable to parse directive type: '${e}' based on the text: '${t}'`),{type:void 0,args:null}}},"detectDirective"),IX=o(function(t){return t.replace(qf,"")},"removeDirectives"),yCe=o(function(t,e){for(let[r,n]of e.entries())if(n.match(t))return r;return-1},"isSubstringInArray");o(W9,"interpolateToCurve");o(vCe,"formatUrl");xCe=o((t,...e)=>{let r=t.split("."),n=r.length-1,i=r[n],a=window;for(let s=0;s{let r=Math.pow(10,e);return Math.round(t*r)/r},"roundNumber"),q9=o((t,e)=>{let r,n=e;for(let i of t){if(r){let a=OX(i,r);if(a===0)return r;if(a=1)return{x:i.x,y:i.y};if(s>0&&s<1)return{x:LX((1-s)*r.x+s*i.x,5),y:LX((1-s)*r.y+s*i.y,5)}}}r=i}throw new Error("Could not find a suitable point for the given distance")},"calculatePoint"),TCe=o((t,e,r)=>{Y.info(`our points ${JSON.stringify(e)}`),e[0]!==r&&(e=e.reverse());let i=q9(e,25),a=t?10:5,s=Math.atan2(e[0].y-i.y,e[0].x-i.x),l={x:0,y:0};return l.x=Math.sin(s)*a+(e[0].x+i.x)/2,l.y=-Math.cos(s)*a+(e[0].y+i.y)/2,l},"calcCardinalityPosition");o(kCe,"calcTerminalLabelPosition");o(Y9,"getStylesFromArray");RX=0,X9=o(()=>(RX++,"id-"+Math.random().toString(36).substr(2,12)+"-"+RX),"generateId");o(ECe,"makeRandomHex");j9=o(t=>ECe(t.length),"random"),SCe=o(function(){return{x:0,y:0,fill:void 0,anchor:"start",style:"#666",width:100,height:100,textMargin:0,rx:0,ry:0,valign:void 0,text:""}},"getTextObj"),CCe=o(function(t,e){let r=e.text.replace(Ze.lineBreakRegex," "),[,n]=Bo(e.fontSize),i=t.append("text");i.attr("x",e.x),i.attr("y",e.y),i.style("text-anchor",e.anchor),i.style("font-family",e.fontFamily),i.style("font-size",n),i.style("font-weight",e.fontWeight),i.attr("fill",e.fill),e.class!==void 0&&i.attr("class",e.class);let a=i.append("tspan");return a.attr("x",e.x+e.textMargin*2),a.attr("fill",e.fill),a.text(r),i},"drawSimpleText"),K9=H0((t,e,r)=>{if(!t||(r=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",joinWith:"
"},r),Ze.lineBreakRegex.test(t)))return t;let n=t.split(" ").filter(Boolean),i=[],a="";return n.forEach((s,l)=>{let u=ra(`${s} `,r),h=ra(a,r);if(u>e){let{hyphenatedStrings:p,remainingWord:m}=ACe(s,e,"-",r);i.push(a,...p),a=m}else h+u>=e?(i.push(a),a=s):a=[a,s].filter(Boolean).join(" ");l+1===n.length&&i.push(a)}),i.filter(s=>s!=="").join(r.joinWith)},(t,e,r)=>`${t}${e}${r.fontSize}${r.fontWeight}${r.fontFamily}${r.joinWith}`),ACe=H0((t,e,r="-",n)=>{n=Object.assign({fontSize:12,fontWeight:400,fontFamily:"Arial",margin:0},n);let i=[...t],a=[],s="";return i.forEach((l,u)=>{let h=`${s}${l}`;if(ra(h,n)>=e){let d=u+1,p=i.length===d,m=`${h}${r}`;a.push(p?h:m),s=""}else s=h}),{hyphenatedStrings:a,remainingWord:s}},(t,e,r="-",n)=>`${t}${e}${r}${n.fontSize}${n.fontWeight}${n.fontFamily}`);o(dw,"calculateTextHeight");o(ra,"calculateTextWidth");Q9=H0((t,e)=>{let{fontSize:r=12,fontFamily:n="Arial",fontWeight:i=400}=e;if(!t)return{width:0,height:0};let[,a]=Bo(r),s=["sans-serif",n],l=t.split(Ze.lineBreakRegex),u=[],h=Ge("body");if(!h.remove)return{width:0,height:0,lineHeight:0};let f=h.append("svg");for(let p of s){let m=0,g={width:0,height:0,lineHeight:0};for(let y of l){let v=SCe();v.text=y||H9;let x=CCe(f,v).style("font-size",a).style("font-weight",i).style("font-family",p),b=(x._groups||x)[0][0].getBBox();if(b.width===0&&b.height===0)throw new Error("svg element not in render tree");g.width=Math.round(Math.max(g.width,b.width)),m=Math.round(b.height),g.height+=m,g.lineHeight=Math.round(Math.max(g.lineHeight,m))}u.push(g)}f.remove();let d=isNaN(u[1].height)||isNaN(u[1].width)||isNaN(u[1].lineHeight)||u[0].height>u[1].height&&u[0].width>u[1].width&&u[0].lineHeight>u[1].lineHeight?0:1;return u[d]},(t,e)=>`${t}${e.fontSize}${e.fontWeight}${e.fontFamily}`),U9=class{constructor(e=!1,r){this.count=0;this.count=r?r.length:0,this.next=e?()=>this.count++:()=>Date.now()}static{o(this,"InitIDGenerator")}},_Ce=o(function(t){return fw=fw||document.createElement("div"),t=escape(t).replace(/%26/g,"&").replace(/%23/g,"#").replace(/%3B/g,";"),fw.innerHTML=t,unescape(fw.textContent)},"entityDecode");o(Z9,"isDetailedError");DCe=o((t,e,r,n)=>{if(!n)return;let i=t.node()?.getBBox();i&&t.append("text").text(n).attr("text-anchor","middle").attr("x",i.x+i.width/2).attr("y",-r).attr("class",e)},"insertTitle"),Bo=o(t=>{if(typeof t=="number")return[t,t+"px"];let e=parseInt(t??"",10);return Number.isNaN(e)?[void 0,void 0]:t===String(e)?[e,t+"px"]:[e,t]},"parseFontSize");o(Fi,"cleanAndMerge");Gt={assignWithDepth:Gn,wrapLabel:K9,calculateTextHeight:dw,calculateTextWidth:ra,calculateTextDimensions:Q9,cleanAndMerge:Fi,detectInit:gCe,detectDirective:MX,isSubstringInArray:yCe,interpolateToCurve:W9,calcLabelPosition:wCe,calcCardinalityPosition:TCe,calcTerminalLabelPosition:kCe,formatUrl:vCe,getStylesFromArray:Y9,generateId:X9,random:j9,runFunc:xCe,entityDecode:_Ce,insertTitle:DCe,parseFontSize:Bo,InitIDGenerator:U9},PX=o(function(t){let e=t;return e=e.replace(/style.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/classDef.*:\S*#.*;/g,function(r){return r.substring(0,r.length-1)}),e=e.replace(/#\w+;/g,function(r){let n=r.substring(1,r.length-1);return/^\+?\d+$/.test(n)?"\uFB02\xB0\xB0"+n+"\xB6\xDF":"\uFB02\xB0"+n+"\xB6\xDF"}),e},"encodeEntities"),na=o(function(t){return t.replace(/fl°°/g,"&#").replace(/fl°/g,"&").replace(/¶ß/g,";")},"decodeEntities"),$h=o((t,e,{counter:r=0,prefix:n,suffix:i},a)=>a||`${n?`${n}_`:""}${t}_${e}_${r}${i?`_${i}`:""}`,"getEdgeId");o($n,"handleUndefinedAttr")});function Cl(t,e,r,n,i){if(!e[t].width)if(r)e[t].text=K9(e[t].text,i,n),e[t].textLines=e[t].text.split(Ze.lineBreakRegex).length,e[t].width=i,e[t].height=dw(e[t].text,n);else{let a=e[t].text.split(Ze.lineBreakRegex);e[t].textLines=a.length;let s=0;e[t].height=0,e[t].width=0;for(let l of a)e[t].width=Math.max(ra(l,n),e[t].width),s=dw(l,n),e[t].height=e[t].height+s}}function GX(t,e,r,n,i){let a=new yw(i);a.data.widthLimit=r.data.widthLimit/Math.min(J9,n.length);for(let[s,l]of n.entries()){let u=0;l.image={width:0,height:0,Y:0},l.sprite&&(l.image.width=48,l.image.height=48,l.image.Y=u,u=l.image.Y+l.image.height);let h=l.wrap&&Vt.wrap,f=pw(Vt);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Cl("label",l,h,f,a.data.widthLimit),l.label.Y=u+8,u=l.label.Y+l.label.height,l.type&&l.type.text!==""){l.type.text="["+l.type.text+"]";let g=pw(Vt);Cl("type",l,h,g,a.data.widthLimit),l.type.Y=u+5,u=l.type.Y+l.type.height}if(l.descr&&l.descr.text!==""){let g=pw(Vt);g.fontSize=g.fontSize-2,Cl("descr",l,h,g,a.data.widthLimit),l.descr.Y=u+20,u=l.descr.Y+l.descr.height}if(s==0||s%J9===0){let g=r.data.startx+Vt.diagramMarginX,y=r.data.stopy+Vt.diagramMarginY+u;a.setData(g,g,y,y)}else{let g=a.data.stopx!==a.data.startx?a.data.stopx+Vt.diagramMarginX:a.data.startx,y=a.data.starty;a.setData(g,g,y,y)}a.name=l.alias;let d=i.db.getC4ShapeArray(l.alias),p=i.db.getC4ShapeKeys(l.alias);p.length>0&&zX(a,t,d,p),e=l.alias;let m=i.db.getBoundarys(e);m.length>0&&GX(t,e,a,m,i),l.alias!=="global"&&$X(t,l,a),r.data.stopy=Math.max(a.data.stopy+Vt.c4ShapeMargin,r.data.stopy),r.data.stopx=Math.max(a.data.stopx+Vt.c4ShapeMargin,r.data.stopx),mw=Math.max(mw,r.data.stopx),gw=Math.max(gw,r.data.stopy)}}var mw,gw,FX,J9,Vt,yw,eD,a2,pw,LCe,$X,zX,_s,BX,RCe,NCe,MCe,tD,VX=N(()=>{"use strict";dr();Bq();vt();$C();gr();uA();zt();s0();ir();Ei();mw=0,gw=0,FX=4,J9=2;Ty.yy=Qy;Vt={},yw=class{static{o(this,"Bounds")}constructor(e){this.name="",this.data={},this.data.startx=void 0,this.data.stopx=void 0,this.data.starty=void 0,this.data.stopy=void 0,this.data.widthLimit=void 0,this.nextData={},this.nextData.startx=void 0,this.nextData.stopx=void 0,this.nextData.starty=void 0,this.nextData.stopy=void 0,this.nextData.cnt=0,eD(e.db.getConfig())}setData(e,r,n,i){this.nextData.startx=this.data.startx=e,this.nextData.stopx=this.data.stopx=r,this.nextData.starty=this.data.starty=n,this.nextData.stopy=this.data.stopy=i}updateVal(e,r,n,i){e[r]===void 0?e[r]=n:e[r]=i(n,e[r])}insert(e){this.nextData.cnt=this.nextData.cnt+1;let r=this.nextData.startx===this.nextData.stopx?this.nextData.stopx+e.margin:this.nextData.stopx+e.margin*2,n=r+e.width,i=this.nextData.starty+e.margin*2,a=i+e.height;(r>=this.data.widthLimit||n>=this.data.widthLimit||this.nextData.cnt>FX)&&(r=this.nextData.startx+e.margin+Vt.nextLinePaddingX,i=this.nextData.stopy+e.margin*2,this.nextData.stopx=n=r+e.width,this.nextData.starty=this.nextData.stopy,this.nextData.stopy=a=i+e.height,this.nextData.cnt=1),e.x=r,e.y=i,this.updateVal(this.data,"startx",r,Math.min),this.updateVal(this.data,"starty",i,Math.min),this.updateVal(this.data,"stopx",n,Math.max),this.updateVal(this.data,"stopy",a,Math.max),this.updateVal(this.nextData,"startx",r,Math.min),this.updateVal(this.nextData,"starty",i,Math.min),this.updateVal(this.nextData,"stopx",n,Math.max),this.updateVal(this.nextData,"stopy",a,Math.max)}init(e){this.name="",this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,widthLimit:void 0},this.nextData={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0,cnt:0},eD(e.db.getConfig())}bumpLastMargin(e){this.data.stopx+=e,this.data.stopy+=e}},eD=o(function(t){Gn(Vt,t),t.fontFamily&&(Vt.personFontFamily=Vt.systemFontFamily=Vt.messageFontFamily=t.fontFamily),t.fontSize&&(Vt.personFontSize=Vt.systemFontSize=Vt.messageFontSize=t.fontSize),t.fontWeight&&(Vt.personFontWeight=Vt.systemFontWeight=Vt.messageFontWeight=t.fontWeight)},"setConf"),a2=o((t,e)=>({fontFamily:t[e+"FontFamily"],fontSize:t[e+"FontSize"],fontWeight:t[e+"FontWeight"]}),"c4ShapeFont"),pw=o(t=>({fontFamily:t.boundaryFontFamily,fontSize:t.boundaryFontSize,fontWeight:t.boundaryFontWeight}),"boundaryFont"),LCe=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont");o(Cl,"calcC4ShapeTextWH");$X=o(function(t,e,r){e.x=r.data.startx,e.y=r.data.starty,e.width=r.data.stopx-r.data.startx,e.height=r.data.stopy-r.data.starty,e.label.y=Vt.c4ShapeMargin-35;let n=e.wrap&&Vt.wrap,i=pw(Vt);i.fontSize=i.fontSize+2,i.fontWeight="bold";let a=ra(e.label.text,i);Cl("label",e,n,i,a),kl.drawBoundary(t,e,Vt)},"drawBoundary"),zX=o(function(t,e,r,n){let i=0;for(let a of n){i=0;let s=r[a],l=a2(Vt,s.typeC4Shape.text);switch(l.fontSize=l.fontSize-2,s.typeC4Shape.width=ra("\xAB"+s.typeC4Shape.text+"\xBB",l),s.typeC4Shape.height=l.fontSize+2,s.typeC4Shape.Y=Vt.c4ShapePadding,i=s.typeC4Shape.Y+s.typeC4Shape.height-4,s.image={width:0,height:0,Y:0},s.typeC4Shape.text){case"person":case"external_person":s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height;break}s.sprite&&(s.image.width=48,s.image.height=48,s.image.Y=i,i=s.image.Y+s.image.height);let u=s.wrap&&Vt.wrap,h=Vt.width-Vt.c4ShapePadding*2,f=a2(Vt,s.typeC4Shape.text);if(f.fontSize=f.fontSize+2,f.fontWeight="bold",Cl("label",s,u,f,h),s.label.Y=i+8,i=s.label.Y+s.label.height,s.type&&s.type.text!==""){s.type.text="["+s.type.text+"]";let m=a2(Vt,s.typeC4Shape.text);Cl("type",s,u,m,h),s.type.Y=i+5,i=s.type.Y+s.type.height}else if(s.techn&&s.techn.text!==""){s.techn.text="["+s.techn.text+"]";let m=a2(Vt,s.techn.text);Cl("techn",s,u,m,h),s.techn.Y=i+5,i=s.techn.Y+s.techn.height}let d=i,p=s.label.width;if(s.descr&&s.descr.text!==""){let m=a2(Vt,s.typeC4Shape.text);Cl("descr",s,u,m,h),s.descr.Y=i+20,i=s.descr.Y+s.descr.height,p=Math.max(s.label.width,s.descr.width),d=i-s.descr.textLines*5}p=p+Vt.c4ShapePadding,s.width=Math.max(s.width||Vt.width,p,Vt.width),s.height=Math.max(s.height||Vt.height,d,Vt.height),s.margin=s.margin||Vt.c4ShapeMargin,t.insert(s),kl.drawC4Shape(e,s,Vt)}t.bumpLastMargin(Vt.c4ShapeMargin)},"drawC4ShapeArray"),_s=class{static{o(this,"Point")}constructor(e,r){this.x=e,this.y=r}},BX=o(function(t,e){let r=t.x,n=t.y,i=e.x,a=e.y,s=r+t.width/2,l=n+t.height/2,u=Math.abs(r-i),h=Math.abs(n-a),f=h/u,d=t.height/t.width,p=null;return n==a&&ri?p=new _s(r,l):r==i&&na&&(p=new _s(s,n)),r>i&&n=f?p=new _s(r,l+f*t.width/2):p=new _s(s-u/h*t.height/2,n+t.height):r=f?p=new _s(r+t.width,l+f*t.width/2):p=new _s(s+u/h*t.height/2,n+t.height):ra?d>=f?p=new _s(r+t.width,l-f*t.width/2):p=new _s(s+t.height/2*u/h,n):r>i&&n>a&&(d>=f?p=new _s(r,l-t.width/2*f):p=new _s(s-t.height/2*u/h,n)),p},"getIntersectPoint"),RCe=o(function(t,e){let r={x:0,y:0};r.x=e.x+e.width/2,r.y=e.y+e.height/2;let n=BX(t,r);r.x=t.x+t.width/2,r.y=t.y+t.height/2;let i=BX(e,r);return{startPoint:n,endPoint:i}},"getIntersectPoints"),NCe=o(function(t,e,r,n){let i=0;for(let a of e){i=i+1;let s=a.wrap&&Vt.wrap,l=LCe(Vt);n.db.getC4Type()==="C4Dynamic"&&(a.label.text=i+": "+a.label.text);let h=ra(a.label.text,l);Cl("label",a,s,l,h),a.techn&&a.techn.text!==""&&(h=ra(a.techn.text,l),Cl("techn",a,s,l,h)),a.descr&&a.descr.text!==""&&(h=ra(a.descr.text,l),Cl("descr",a,s,l,h));let f=r(a.from),d=r(a.to),p=RCe(f,d);a.startPoint=p.startPoint,a.endPoint=p.endPoint}kl.drawRels(t,e,Vt)},"drawRels");o(GX,"drawInsideBoundary");MCe=o(function(t,e,r,n){Vt=me().c4;let i=me().securityLevel,a;i==="sandbox"&&(a=Ge("#i"+e));let s=i==="sandbox"?Ge(a.nodes()[0].contentDocument.body):Ge("body"),l=n.db;n.db.setWrap(Vt.wrap),FX=l.getC4ShapeInRow(),J9=l.getC4BoundaryInRow(),Y.debug(`C:${JSON.stringify(Vt,null,2)}`);let u=i==="sandbox"?s.select(`[id="${e}"]`):Ge(`[id="${e}"]`);kl.insertComputerIcon(u),kl.insertDatabaseIcon(u),kl.insertClockIcon(u);let h=new yw(n);h.setData(Vt.diagramMarginX,Vt.diagramMarginX,Vt.diagramMarginY,Vt.diagramMarginY),h.data.widthLimit=screen.availWidth,mw=Vt.diagramMarginX,gw=Vt.diagramMarginY;let f=n.db.getTitle(),d=n.db.getBoundarys("");GX(u,"",h,d,n),kl.insertArrowHead(u),kl.insertArrowEnd(u),kl.insertArrowCrossHead(u),kl.insertArrowFilledHead(u),NCe(u,n.db.getRels(),n.db.getC4Shape,n),h.data.stopx=mw,h.data.stopy=gw;let p=h.data,g=p.stopy-p.starty+2*Vt.diagramMarginY,v=p.stopx-p.startx+2*Vt.diagramMarginX;f&&u.append("text").text(f).attr("x",(p.stopx-p.startx)/2-4*Vt.diagramMarginX).attr("y",p.starty+Vt.diagramMarginY),vn(u,g,v,Vt.useMaxWidth);let x=f?60:0;u.attr("viewBox",p.startx-Vt.diagramMarginX+" -"+(Vt.diagramMarginY+x)+" "+v+" "+(g+x)),Y.debug("models:",p)},"draw"),tD={drawPersonOrSystemArray:zX,drawBoundary:$X,setConf:eD,draw:MCe}});var ICe,UX,HX=N(()=>{"use strict";ICe=o(t=>`.person { + stroke: ${t.personBorder}; + fill: ${t.personBkg}; + } +`,"getStyles"),UX=ICe});var WX={};hr(WX,{diagram:()=>OCe});var OCe,qX=N(()=>{"use strict";$C();uA();VX();HX();OCe={parser:JF,db:Qy,renderer:tD,styles:UX,init:o(({c4:t,wrap:e})=>{tD.setConf(t),Qy.setWrap(e)},"init")}});function uj(t){return typeof t>"u"||t===null}function $Ce(t){return typeof t=="object"&&t!==null}function zCe(t){return Array.isArray(t)?t:uj(t)?[]:[t]}function GCe(t,e){var r,n,i,a;if(e)for(a=Object.keys(e),r=0,n=a.length;rl&&(a=" ... ",e=n-l+a.length),r-n>l&&(s=" ...",r=n+l-s.length),{str:a+t.slice(e,r).replace(/\t/g,"\u2192")+s,pos:n-e+a.length}}function nD(t,e){return $i.repeat(" ",e-t.length)+t}function KCe(t,e){if(e=Object.create(e||null),!t.buffer)return null;e.maxLength||(e.maxLength=79),typeof e.indent!="number"&&(e.indent=1),typeof e.linesBefore!="number"&&(e.linesBefore=3),typeof e.linesAfter!="number"&&(e.linesAfter=2);for(var r=/\r?\n|\r|\0/g,n=[0],i=[],a,s=-1;a=r.exec(t.buffer);)i.push(a.index),n.push(a.index+a[0].length),t.position<=a.index&&s<0&&(s=n.length-2);s<0&&(s=n.length-1);var l="",u,h,f=Math.min(t.line+e.linesAfter,i.length).toString().length,d=e.maxLength-(e.indent+f+3);for(u=1;u<=e.linesBefore&&!(s-u<0);u++)h=rD(t.buffer,n[s-u],i[s-u],t.position-(n[s]-n[s-u]),d),l=$i.repeat(" ",e.indent)+nD((t.line-u+1).toString(),f)+" | "+h.str+` +`+l;for(h=rD(t.buffer,n[s],i[s],t.position,d),l+=$i.repeat(" ",e.indent)+nD((t.line+1).toString(),f)+" | "+h.str+` +`,l+=$i.repeat("-",e.indent+f+3+h.pos)+`^ +`,u=1;u<=e.linesAfter&&!(s+u>=i.length);u++)h=rD(t.buffer,n[s+u],i[s+u],t.position-(n[s]-n[s+u]),d),l+=$i.repeat(" ",e.indent)+nD((t.line+u+1).toString(),f)+" | "+h.str+` +`;return l.replace(/\n$/,"")}function e7e(t){var e={};return t!==null&&Object.keys(t).forEach(function(r){t[r].forEach(function(n){e[String(n)]=r})}),e}function t7e(t,e){if(e=e||{},Object.keys(e).forEach(function(r){if(ZCe.indexOf(r)===-1)throw new Ds('Unknown option "'+r+'" is met in definition of "'+t+'" YAML type.')}),this.options=e,this.tag=t,this.kind=e.kind||null,this.resolve=e.resolve||function(){return!0},this.construct=e.construct||function(r){return r},this.instanceOf=e.instanceOf||null,this.predicate=e.predicate||null,this.represent=e.represent||null,this.representName=e.representName||null,this.defaultStyle=e.defaultStyle||null,this.multi=e.multi||!1,this.styleAliases=e7e(e.styleAliases||null),JCe.indexOf(this.kind)===-1)throw new Ds('Unknown kind "'+this.kind+'" is specified for "'+t+'" YAML type.')}function jX(t,e){var r=[];return t[e].forEach(function(n){var i=r.length;r.forEach(function(a,s){a.tag===n.tag&&a.kind===n.kind&&a.multi===n.multi&&(i=s)}),r[i]=n}),r}function r7e(){var t={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}},e,r;function n(i){i.multi?(t.multi[i.kind].push(i),t.multi.fallback.push(i)):t[i.kind][i.tag]=t.fallback[i.tag]=i}for(o(n,"collectType"),e=0,r=arguments.length;e=0&&(e=e.slice(1)),e===".inf"?r===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:e===".nan"?NaN:r*parseFloat(e,10)}function A7e(t,e){var r;if(isNaN(t))switch(e){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===t)switch(e){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===t)switch(e){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if($i.isNegativeZero(t))return"-0.0";return r=t.toString(10),C7e.test(r)?r.replace("e",".e"):r}function _7e(t){return Object.prototype.toString.call(t)==="[object Number]"&&(t%1!==0||$i.isNegativeZero(t))}function R7e(t){return t===null?!1:dj.exec(t)!==null||pj.exec(t)!==null}function N7e(t){var e,r,n,i,a,s,l,u=0,h=null,f,d,p;if(e=dj.exec(t),e===null&&(e=pj.exec(t)),e===null)throw new Error("Date resolve error");if(r=+e[1],n=+e[2]-1,i=+e[3],!e[4])return new Date(Date.UTC(r,n,i));if(a=+e[4],s=+e[5],l=+e[6],e[7]){for(u=e[7].slice(0,3);u.length<3;)u+="0";u=+u}return e[9]&&(f=+e[10],d=+(e[11]||0),h=(f*60+d)*6e4,e[9]==="-"&&(h=-h)),p=new Date(Date.UTC(r,n,i,a,s,l,u)),h&&p.setTime(p.getTime()-h),p}function M7e(t){return t.toISOString()}function O7e(t){return t==="<<"||t===null}function B7e(t){if(t===null)return!1;var e,r,n=0,i=t.length,a=uD;for(r=0;r64)){if(e<0)return!1;n+=6}return n%8===0}function F7e(t){var e,r,n=t.replace(/[\r\n=]/g,""),i=n.length,a=uD,s=0,l=[];for(e=0;e>16&255),l.push(s>>8&255),l.push(s&255)),s=s<<6|a.indexOf(n.charAt(e));return r=i%4*6,r===0?(l.push(s>>16&255),l.push(s>>8&255),l.push(s&255)):r===18?(l.push(s>>10&255),l.push(s>>2&255)):r===12&&l.push(s>>4&255),new Uint8Array(l)}function $7e(t){var e="",r=0,n,i,a=t.length,s=uD;for(n=0;n>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]),r=(r<<8)+t[n];return i=a%3,i===0?(e+=s[r>>18&63],e+=s[r>>12&63],e+=s[r>>6&63],e+=s[r&63]):i===2?(e+=s[r>>10&63],e+=s[r>>4&63],e+=s[r<<2&63],e+=s[64]):i===1&&(e+=s[r>>2&63],e+=s[r<<4&63],e+=s[64],e+=s[64]),e}function z7e(t){return Object.prototype.toString.call(t)==="[object Uint8Array]"}function H7e(t){if(t===null)return!0;var e=[],r,n,i,a,s,l=t;for(r=0,n=l.length;r>10)+55296,(t-65536&1023)+56320)}function cAe(t,e){this.input=t,this.filename=e.filename||null,this.schema=e.schema||mj,this.onWarning=e.onWarning||null,this.legacy=e.legacy||!1,this.json=e.json||!1,this.listener=e.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=t.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function Tj(t,e){var r={name:t.filename,buffer:t.input.slice(0,-1),position:t.position,line:t.line,column:t.position-t.lineStart};return r.snippet=QCe(r),new Ds(e,r)}function Qt(t,e){throw Tj(t,e)}function bw(t,e){t.onWarning&&t.onWarning.call(null,Tj(t,e))}function zh(t,e,r,n){var i,a,s,l;if(e1&&(t.result+=$i.repeat(` +`,e-1))}function uAe(t,e,r){var n,i,a,s,l,u,h,f,d=t.kind,p=t.result,m;if(m=t.input.charCodeAt(t.position),Ls(m)||am(m)||m===35||m===38||m===42||m===33||m===124||m===62||m===39||m===34||m===37||m===64||m===96||(m===63||m===45)&&(i=t.input.charCodeAt(t.position+1),Ls(i)||r&&am(i)))return!1;for(t.kind="scalar",t.result="",a=s=t.position,l=!1;m!==0;){if(m===58){if(i=t.input.charCodeAt(t.position+1),Ls(i)||r&&am(i))break}else if(m===35){if(n=t.input.charCodeAt(t.position-1),Ls(n))break}else{if(t.position===t.lineStart&&kw(t)||r&&am(m))break;if(dc(m))if(u=t.line,h=t.lineStart,f=t.lineIndent,Ci(t,!1,-1),t.lineIndent>=e){l=!0,m=t.input.charCodeAt(t.position);continue}else{t.position=s,t.line=u,t.lineStart=h,t.lineIndent=f;break}}l&&(zh(t,a,s,!1),fD(t,t.line-u),a=s=t.position,l=!1),Nd(m)||(s=t.position+1),m=t.input.charCodeAt(++t.position)}return zh(t,a,s,!1),t.result?!0:(t.kind=d,t.result=p,!1)}function hAe(t,e){var r,n,i;if(r=t.input.charCodeAt(t.position),r!==39)return!1;for(t.kind="scalar",t.result="",t.position++,n=i=t.position;(r=t.input.charCodeAt(t.position))!==0;)if(r===39)if(zh(t,n,t.position,!0),r=t.input.charCodeAt(++t.position),r===39)n=t.position,t.position++,i=t.position;else return!0;else dc(r)?(zh(t,n,i,!0),fD(t,Ci(t,!1,e)),n=i=t.position):t.position===t.lineStart&&kw(t)?Qt(t,"unexpected end of the document within a single quoted scalar"):(t.position++,i=t.position);Qt(t,"unexpected end of the stream within a single quoted scalar")}function fAe(t,e){var r,n,i,a,s,l;if(l=t.input.charCodeAt(t.position),l!==34)return!1;for(t.kind="scalar",t.result="",t.position++,r=n=t.position;(l=t.input.charCodeAt(t.position))!==0;){if(l===34)return zh(t,r,t.position,!0),t.position++,!0;if(l===92){if(zh(t,r,t.position,!0),l=t.input.charCodeAt(++t.position),dc(l))Ci(t,!1,e);else if(l<256&&bj[l])t.result+=wj[l],t.position++;else if((s=sAe(l))>0){for(i=s,a=0;i>0;i--)l=t.input.charCodeAt(++t.position),(s=aAe(l))>=0?a=(a<<4)+s:Qt(t,"expected hexadecimal character");t.result+=lAe(a),t.position++}else Qt(t,"unknown escape sequence");r=n=t.position}else dc(l)?(zh(t,r,n,!0),fD(t,Ci(t,!1,e)),r=n=t.position):t.position===t.lineStart&&kw(t)?Qt(t,"unexpected end of the document within a double quoted scalar"):(t.position++,n=t.position)}Qt(t,"unexpected end of the stream within a double quoted scalar")}function dAe(t,e){var r=!0,n,i,a,s=t.tag,l,u=t.anchor,h,f,d,p,m,g=Object.create(null),y,v,x,b;if(b=t.input.charCodeAt(t.position),b===91)f=93,m=!1,l=[];else if(b===123)f=125,m=!0,l={};else return!1;for(t.anchor!==null&&(t.anchorMap[t.anchor]=l),b=t.input.charCodeAt(++t.position);b!==0;){if(Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===f)return t.position++,t.tag=s,t.anchor=u,t.kind=m?"mapping":"sequence",t.result=l,!0;r?b===44&&Qt(t,"expected the node content, but found ','"):Qt(t,"missed comma between flow collection entries"),v=y=x=null,d=p=!1,b===63&&(h=t.input.charCodeAt(t.position+1),Ls(h)&&(d=p=!0,t.position++,Ci(t,!0,e))),n=t.line,i=t.lineStart,a=t.position,om(t,e,vw,!1,!0),v=t.tag,y=t.result,Ci(t,!0,e),b=t.input.charCodeAt(t.position),(p||t.line===n)&&b===58&&(d=!0,b=t.input.charCodeAt(++t.position),Ci(t,!0,e),om(t,e,vw,!1,!0),x=t.result),m?sm(t,l,g,v,y,x,n,i,a):d?l.push(sm(t,null,g,v,y,x,n,i,a)):l.push(y),Ci(t,!0,e),b=t.input.charCodeAt(t.position),b===44?(r=!0,b=t.input.charCodeAt(++t.position)):r=!1}Qt(t,"unexpected end of the stream within a flow collection")}function pAe(t,e){var r,n,i=iD,a=!1,s=!1,l=e,u=0,h=!1,f,d;if(d=t.input.charCodeAt(t.position),d===124)n=!1;else if(d===62)n=!0;else return!1;for(t.kind="scalar",t.result="";d!==0;)if(d=t.input.charCodeAt(++t.position),d===43||d===45)iD===i?i=d===43?KX:tAe:Qt(t,"repeat of a chomping mode identifier");else if((f=oAe(d))>=0)f===0?Qt(t,"bad explicit indentation width of a block scalar; it cannot be less than one"):s?Qt(t,"repeat of an indentation width identifier"):(l=e+f-1,s=!0);else break;if(Nd(d)){do d=t.input.charCodeAt(++t.position);while(Nd(d));if(d===35)do d=t.input.charCodeAt(++t.position);while(!dc(d)&&d!==0)}for(;d!==0;){for(hD(t),t.lineIndent=0,d=t.input.charCodeAt(t.position);(!s||t.lineIndentl&&(l=t.lineIndent),dc(d)){u++;continue}if(t.lineIndente)&&u!==0)Qt(t,"bad indentation of a sequence entry");else if(t.lineIndente)&&(v&&(s=t.line,l=t.lineStart,u=t.position),om(t,e,xw,!0,i)&&(v?g=t.result:y=t.result),v||(sm(t,d,p,m,g,y,s,l,u),m=g=y=null),Ci(t,!0,-1),b=t.input.charCodeAt(t.position)),(t.line===a||t.lineIndent>e)&&b!==0)Qt(t,"bad indentation of a mapping entry");else if(t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndente?u=1:t.lineIndent===e?u=0:t.lineIndent tag; it should be "scalar", not "'+t.kind+'"'),d=0,p=t.implicitTypes.length;d"),t.result!==null&&g.kind!==t.kind&&Qt(t,"unacceptable node kind for !<"+t.tag+'> tag; it should be "'+g.kind+'", not "'+t.kind+'"'),g.resolve(t.result,t.tag)?(t.result=g.construct(t.result,t.tag),t.anchor!==null&&(t.anchorMap[t.anchor]=t.result)):Qt(t,"cannot resolve a node with !<"+t.tag+"> explicit tag")}return t.listener!==null&&t.listener("close",t),t.tag!==null||t.anchor!==null||f}function xAe(t){var e=t.position,r,n,i,a=!1,s;for(t.version=null,t.checkLineBreaks=t.legacy,t.tagMap=Object.create(null),t.anchorMap=Object.create(null);(s=t.input.charCodeAt(t.position))!==0&&(Ci(t,!0,-1),s=t.input.charCodeAt(t.position),!(t.lineIndent>0||s!==37));){for(a=!0,s=t.input.charCodeAt(++t.position),r=t.position;s!==0&&!Ls(s);)s=t.input.charCodeAt(++t.position);for(n=t.input.slice(r,t.position),i=[],n.length<1&&Qt(t,"directive name must not be less than one character in length");s!==0;){for(;Nd(s);)s=t.input.charCodeAt(++t.position);if(s===35){do s=t.input.charCodeAt(++t.position);while(s!==0&&!dc(s));break}if(dc(s))break;for(r=t.position;s!==0&&!Ls(s);)s=t.input.charCodeAt(++t.position);i.push(t.input.slice(r,t.position))}s!==0&&hD(t),Gh.call(JX,n)?JX[n](t,n,i):bw(t,'unknown document directive "'+n+'"')}if(Ci(t,!0,-1),t.lineIndent===0&&t.input.charCodeAt(t.position)===45&&t.input.charCodeAt(t.position+1)===45&&t.input.charCodeAt(t.position+2)===45?(t.position+=3,Ci(t,!0,-1)):a&&Qt(t,"directives end mark is expected"),om(t,t.lineIndent-1,xw,!1,!0),Ci(t,!0,-1),t.checkLineBreaks&&nAe.test(t.input.slice(e,t.position))&&bw(t,"non-ASCII line breaks are interpreted as content"),t.documents.push(t.result),t.position===t.lineStart&&kw(t)){t.input.charCodeAt(t.position)===46&&(t.position+=3,Ci(t,!0,-1));return}if(t.position"u"&&(r=e,e=null);var n=kj(t,r);if(typeof e!="function")return n;for(var i=0,a=n.length;i=55296&&r<=56319&&e+1=56320&&n<=57343)?(r-55296)*1024+n-56320+65536:r}function Nj(t){var e=/^\n* /;return e.test(t)}function jAe(t,e,r,n,i,a,s,l){var u,h=0,f=null,d=!1,p=!1,m=n!==-1,g=-1,y=YAe(s2(t,0))&&XAe(s2(t,t.length-1));if(e||s)for(u=0;u=65536?u+=2:u++){if(h=s2(t,u),!u2(h))return im;y=y&&ij(h,f,l),f=h}else{for(u=0;u=65536?u+=2:u++){if(h=s2(t,u),h===l2)d=!0,m&&(p=p||u-g-1>n&&t[g+1]!==" ",g=u);else if(!u2(h))return im;y=y&&ij(h,f,l),f=h}p=p||m&&u-g-1>n&&t[g+1]!==" "}return!d&&!p?y&&!s&&!i(t)?Mj:a===c2?im:lD:r>9&&Nj(t)?im:s?a===c2?im:lD:p?Oj:Ij}function KAe(t,e,r,n,i){t.dump=function(){if(e.length===0)return t.quotingType===c2?'""':"''";if(!t.noCompatMode&&(zAe.indexOf(e)!==-1||GAe.test(e)))return t.quotingType===c2?'"'+e+'"':"'"+e+"'";var a=t.indent*Math.max(1,r),s=t.lineWidth===-1?-1:Math.max(Math.min(t.lineWidth,40),t.lineWidth-a),l=n||t.flowLevel>-1&&r>=t.flowLevel;function u(h){return qAe(t,h)}switch(o(u,"testAmbiguity"),jAe(e,l,t.indent,s,u,t.quotingType,t.forceQuotes&&!n,i)){case Mj:return e;case lD:return"'"+e.replace(/'/g,"''")+"'";case Ij:return"|"+aj(e,t.indent)+sj(rj(e,a));case Oj:return">"+aj(e,t.indent)+sj(rj(QAe(e,s),a));case im:return'"'+ZAe(e)+'"';default:throw new Ds("impossible error: invalid scalar style")}}()}function aj(t,e){var r=Nj(t)?String(e):"",n=t[t.length-1]===` +`,i=n&&(t[t.length-2]===` +`||t===` +`),a=i?"+":n?"":"-";return r+a+` +`}function sj(t){return t[t.length-1]===` +`?t.slice(0,-1):t}function QAe(t,e){for(var r=/(\n+)([^\n]*)/g,n=function(){var h=t.indexOf(` +`);return h=h!==-1?h:t.length,r.lastIndex=h,oj(t.slice(0,h),e)}(),i=t[0]===` +`||t[0]===" ",a,s;s=r.exec(t);){var l=s[1],u=s[2];a=u[0]===" ",n+=l+(!i&&!a&&u!==""?` +`:"")+oj(u,e),i=a}return n}function oj(t,e){if(t===""||t[0]===" ")return t;for(var r=/ [^ ]/g,n,i=0,a,s=0,l=0,u="";n=r.exec(t);)l=n.index,l-i>e&&(a=s>i?s:l,u+=` +`+t.slice(i,a),i=a+1),s=l;return u+=` +`,t.length-i>e&&s>i?u+=t.slice(i,s)+` +`+t.slice(s+1):u+=t.slice(i),u.slice(1)}function ZAe(t){for(var e="",r=0,n,i=0;i=65536?i+=2:i++)r=s2(t,i),n=Da[r],!n&&u2(r)?(e+=t[i],r>=65536&&(e+=t[i+1])):e+=n||UAe(r);return e}function JAe(t,e,r){var n="",i=t.tag,a,s,l;for(a=0,s=r.length;a"u"&&Au(t,e,null,!1,!1))&&(n!==""&&(n+=","+(t.condenseFlow?"":" ")),n+=t.dump);t.tag=i,t.dump="["+n+"]"}function lj(t,e,r,n){var i="",a=t.tag,s,l,u;for(s=0,l=r.length;s"u"&&Au(t,e+1,null,!0,!0,!1,!0))&&((!n||i!=="")&&(i+=oD(t,e)),t.dump&&l2===t.dump.charCodeAt(0)?i+="-":i+="- ",i+=t.dump);t.tag=a,t.dump=i||"[]"}function e8e(t,e,r){var n="",i=t.tag,a=Object.keys(r),s,l,u,h,f;for(s=0,l=a.length;s1024&&(f+="? "),f+=t.dump+(t.condenseFlow?'"':"")+":"+(t.condenseFlow?"":" "),Au(t,e,h,!1,!1)&&(f+=t.dump,n+=f));t.tag=i,t.dump="{"+n+"}"}function t8e(t,e,r,n){var i="",a=t.tag,s=Object.keys(r),l,u,h,f,d,p;if(t.sortKeys===!0)s.sort();else if(typeof t.sortKeys=="function")s.sort(t.sortKeys);else if(t.sortKeys)throw new Ds("sortKeys must be a boolean or a function");for(l=0,u=s.length;l1024,d&&(t.dump&&l2===t.dump.charCodeAt(0)?p+="?":p+="? "),p+=t.dump,d&&(p+=oD(t,e)),Au(t,e+1,f,!0,d)&&(t.dump&&l2===t.dump.charCodeAt(0)?p+=":":p+=": ",p+=t.dump,i+=p));t.tag=a,t.dump=i||"{}"}function cj(t,e,r){var n,i,a,s,l,u;for(i=r?t.explicitTypes:t.implicitTypes,a=0,s=i.length;a tag resolver accepts not "'+u+'" style');t.dump=n}return!0}return!1}function Au(t,e,r,n,i,a,s){t.tag=null,t.dump=r,cj(t,r,!1)||cj(t,r,!0);var l=Sj.call(t.dump),u=n,h;n&&(n=t.flowLevel<0||t.flowLevel>e);var f=l==="[object Object]"||l==="[object Array]",d,p;if(f&&(d=t.duplicates.indexOf(r),p=d!==-1),(t.tag!==null&&t.tag!=="?"||p||t.indent!==2&&e>0)&&(i=!1),p&&t.usedDuplicates[d])t.dump="*ref_"+d;else{if(f&&p&&!t.usedDuplicates[d]&&(t.usedDuplicates[d]=!0),l==="[object Object]")n&&Object.keys(t.dump).length!==0?(t8e(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(e8e(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object Array]")n&&t.dump.length!==0?(t.noArrayIndent&&!s&&e>0?lj(t,e-1,t.dump,i):lj(t,e,t.dump,i),p&&(t.dump="&ref_"+d+t.dump)):(JAe(t,e,t.dump),p&&(t.dump="&ref_"+d+" "+t.dump));else if(l==="[object String]")t.tag!=="?"&&KAe(t,t.dump,e,a,u);else{if(l==="[object Undefined]")return!1;if(t.skipInvalid)return!1;throw new Ds("unacceptable kind of an object to dump "+l)}t.tag!==null&&t.tag!=="?"&&(h=encodeURI(t.tag[0]==="!"?t.tag.slice(1):t.tag).replace(/!/g,"%21"),t.tag[0]==="!"?h="!"+h:h.slice(0,18)==="tag:yaml.org,2002:"?h="!!"+h.slice(18):h="!<"+h+">",t.dump=h+" "+t.dump)}return!0}function r8e(t,e){var r=[],n=[],i,a;for(cD(t,r,n),i=0,a=n.length;i{"use strict";o(uj,"isNothing");o($Ce,"isObject");o(zCe,"toArray");o(GCe,"extend");o(VCe,"repeat");o(UCe,"isNegativeZero");HCe=uj,WCe=$Ce,qCe=zCe,YCe=VCe,XCe=UCe,jCe=GCe,$i={isNothing:HCe,isObject:WCe,toArray:qCe,repeat:YCe,isNegativeZero:XCe,extend:jCe};o(hj,"formatError");o(o2,"YAMLException$1");o2.prototype=Object.create(Error.prototype);o2.prototype.constructor=o2;o2.prototype.toString=o(function(e){return this.name+": "+hj(this,e)},"toString");Ds=o2;o(rD,"getLine");o(nD,"padStart");o(KCe,"makeSnippet");QCe=KCe,ZCe=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],JCe=["scalar","sequence","mapping"];o(e7e,"compileStyleAliases");o(t7e,"Type$1");_a=t7e;o(jX,"compileList");o(r7e,"compileMap");o(aD,"Schema$1");aD.prototype.extend=o(function(e){var r=[],n=[];if(e instanceof _a)n.push(e);else if(Array.isArray(e))n=n.concat(e);else if(e&&(Array.isArray(e.implicit)||Array.isArray(e.explicit)))e.implicit&&(r=r.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit));else throw new Ds("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");r.forEach(function(a){if(!(a instanceof _a))throw new Ds("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(a.loadKind&&a.loadKind!=="scalar")throw new Ds("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(a.multi)throw new Ds("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")}),n.forEach(function(a){if(!(a instanceof _a))throw new Ds("Specified list of YAML types (or a single Type object) contains a non-Type object.")});var i=Object.create(aD.prototype);return i.implicit=(this.implicit||[]).concat(r),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=jX(i,"implicit"),i.compiledExplicit=jX(i,"explicit"),i.compiledTypeMap=r7e(i.compiledImplicit,i.compiledExplicit),i},"extend");n7e=aD,i7e=new _a("tag:yaml.org,2002:str",{kind:"scalar",construct:o(function(t){return t!==null?t:""},"construct")}),a7e=new _a("tag:yaml.org,2002:seq",{kind:"sequence",construct:o(function(t){return t!==null?t:[]},"construct")}),s7e=new _a("tag:yaml.org,2002:map",{kind:"mapping",construct:o(function(t){return t!==null?t:{}},"construct")}),o7e=new n7e({explicit:[i7e,a7e,s7e]});o(l7e,"resolveYamlNull");o(c7e,"constructYamlNull");o(u7e,"isNull");h7e=new _a("tag:yaml.org,2002:null",{kind:"scalar",resolve:l7e,construct:c7e,predicate:u7e,represent:{canonical:o(function(){return"~"},"canonical"),lowercase:o(function(){return"null"},"lowercase"),uppercase:o(function(){return"NULL"},"uppercase"),camelcase:o(function(){return"Null"},"camelcase"),empty:o(function(){return""},"empty")},defaultStyle:"lowercase"});o(f7e,"resolveYamlBoolean");o(d7e,"constructYamlBoolean");o(p7e,"isBoolean");m7e=new _a("tag:yaml.org,2002:bool",{kind:"scalar",resolve:f7e,construct:d7e,predicate:p7e,represent:{lowercase:o(function(t){return t?"true":"false"},"lowercase"),uppercase:o(function(t){return t?"TRUE":"FALSE"},"uppercase"),camelcase:o(function(t){return t?"True":"False"},"camelcase")},defaultStyle:"lowercase"});o(g7e,"isHexCode");o(y7e,"isOctCode");o(v7e,"isDecCode");o(x7e,"resolveYamlInteger");o(b7e,"constructYamlInteger");o(w7e,"isInteger");T7e=new _a("tag:yaml.org,2002:int",{kind:"scalar",resolve:x7e,construct:b7e,predicate:w7e,represent:{binary:o(function(t){return t>=0?"0b"+t.toString(2):"-0b"+t.toString(2).slice(1)},"binary"),octal:o(function(t){return t>=0?"0o"+t.toString(8):"-0o"+t.toString(8).slice(1)},"octal"),decimal:o(function(t){return t.toString(10)},"decimal"),hexadecimal:o(function(t){return t>=0?"0x"+t.toString(16).toUpperCase():"-0x"+t.toString(16).toUpperCase().slice(1)},"hexadecimal")},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),k7e=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");o(E7e,"resolveYamlFloat");o(S7e,"constructYamlFloat");C7e=/^[-+]?[0-9]+e/;o(A7e,"representYamlFloat");o(_7e,"isFloat");D7e=new _a("tag:yaml.org,2002:float",{kind:"scalar",resolve:E7e,construct:S7e,predicate:_7e,represent:A7e,defaultStyle:"lowercase"}),fj=o7e.extend({implicit:[h7e,m7e,T7e,D7e]}),L7e=fj,dj=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),pj=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");o(R7e,"resolveYamlTimestamp");o(N7e,"constructYamlTimestamp");o(M7e,"representYamlTimestamp");I7e=new _a("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:R7e,construct:N7e,instanceOf:Date,represent:M7e});o(O7e,"resolveYamlMerge");P7e=new _a("tag:yaml.org,2002:merge",{kind:"scalar",resolve:O7e}),uD=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= +\r`;o(B7e,"resolveYamlBinary");o(F7e,"constructYamlBinary");o($7e,"representYamlBinary");o(z7e,"isBinary");G7e=new _a("tag:yaml.org,2002:binary",{kind:"scalar",resolve:B7e,construct:F7e,predicate:z7e,represent:$7e}),V7e=Object.prototype.hasOwnProperty,U7e=Object.prototype.toString;o(H7e,"resolveYamlOmap");o(W7e,"constructYamlOmap");q7e=new _a("tag:yaml.org,2002:omap",{kind:"sequence",resolve:H7e,construct:W7e}),Y7e=Object.prototype.toString;o(X7e,"resolveYamlPairs");o(j7e,"constructYamlPairs");K7e=new _a("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:X7e,construct:j7e}),Q7e=Object.prototype.hasOwnProperty;o(Z7e,"resolveYamlSet");o(J7e,"constructYamlSet");eAe=new _a("tag:yaml.org,2002:set",{kind:"mapping",resolve:Z7e,construct:J7e}),mj=L7e.extend({implicit:[I7e,P7e],explicit:[G7e,q7e,K7e,eAe]}),Gh=Object.prototype.hasOwnProperty,vw=1,gj=2,yj=3,xw=4,iD=1,tAe=2,KX=3,rAe=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,nAe=/[\x85\u2028\u2029]/,iAe=/[,\[\]\{\}]/,vj=/^(?:!|!!|![a-z\-]+!)$/i,xj=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;o(QX,"_class");o(dc,"is_EOL");o(Nd,"is_WHITE_SPACE");o(Ls,"is_WS_OR_EOL");o(am,"is_FLOW_INDICATOR");o(aAe,"fromHexCode");o(sAe,"escapedHexLen");o(oAe,"fromDecimalCode");o(ZX,"simpleEscapeSequence");o(lAe,"charFromCodepoint");bj=new Array(256),wj=new Array(256);for(Rd=0;Rd<256;Rd++)bj[Rd]=ZX(Rd)?1:0,wj[Rd]=ZX(Rd);o(cAe,"State$1");o(Tj,"generateError");o(Qt,"throwError");o(bw,"throwWarning");JX={YAML:o(function(e,r,n){var i,a,s;e.version!==null&&Qt(e,"duplication of %YAML directive"),n.length!==1&&Qt(e,"YAML directive accepts exactly one argument"),i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]),i===null&&Qt(e,"ill-formed argument of the YAML directive"),a=parseInt(i[1],10),s=parseInt(i[2],10),a!==1&&Qt(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=s<2,s!==1&&s!==2&&bw(e,"unsupported YAML version of the document")},"handleYamlDirective"),TAG:o(function(e,r,n){var i,a;n.length!==2&&Qt(e,"TAG directive accepts exactly two arguments"),i=n[0],a=n[1],vj.test(i)||Qt(e,"ill-formed tag handle (first argument) of the TAG directive"),Gh.call(e.tagMap,i)&&Qt(e,'there is a previously declared suffix for "'+i+'" tag handle'),xj.test(a)||Qt(e,"ill-formed tag prefix (second argument) of the TAG directive");try{a=decodeURIComponent(a)}catch{Qt(e,"tag prefix is malformed: "+a)}e.tagMap[i]=a},"handleTagDirective")};o(zh,"captureSegment");o(ej,"mergeMappings");o(sm,"storeMappingPair");o(hD,"readLineBreak");o(Ci,"skipSeparationSpace");o(kw,"testDocumentSeparator");o(fD,"writeFoldedLines");o(uAe,"readPlainScalar");o(hAe,"readSingleQuotedScalar");o(fAe,"readDoubleQuotedScalar");o(dAe,"readFlowCollection");o(pAe,"readBlockScalar");o(tj,"readBlockSequence");o(mAe,"readBlockMapping");o(gAe,"readTagProperty");o(yAe,"readAnchorProperty");o(vAe,"readAlias");o(om,"composeNode");o(xAe,"readDocument");o(kj,"loadDocuments");o(bAe,"loadAll$1");o(wAe,"load$1");TAe=bAe,kAe=wAe,Ej={loadAll:TAe,load:kAe},Sj=Object.prototype.toString,Cj=Object.prototype.hasOwnProperty,dD=65279,EAe=9,l2=10,SAe=13,CAe=32,AAe=33,_Ae=34,sD=35,DAe=37,LAe=38,RAe=39,NAe=42,Aj=44,MAe=45,ww=58,IAe=61,OAe=62,PAe=63,BAe=64,_j=91,Dj=93,FAe=96,Lj=123,$Ae=124,Rj=125,Da={};Da[0]="\\0";Da[7]="\\a";Da[8]="\\b";Da[9]="\\t";Da[10]="\\n";Da[11]="\\v";Da[12]="\\f";Da[13]="\\r";Da[27]="\\e";Da[34]='\\"';Da[92]="\\\\";Da[133]="\\N";Da[160]="\\_";Da[8232]="\\L";Da[8233]="\\P";zAe=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"],GAe=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;o(VAe,"compileStyleMap");o(UAe,"encodeHex");HAe=1,c2=2;o(WAe,"State");o(rj,"indentString");o(oD,"generateNextLine");o(qAe,"testImplicitResolving");o(Tw,"isWhitespace");o(u2,"isPrintable");o(nj,"isNsCharOrWhitespace");o(ij,"isPlainSafe");o(YAe,"isPlainSafeFirst");o(XAe,"isPlainSafeLast");o(s2,"codePointAt");o(Nj,"needIndentIndicator");Mj=1,lD=2,Ij=3,Oj=4,im=5;o(jAe,"chooseScalarStyle");o(KAe,"writeScalar");o(aj,"blockHeader");o(sj,"dropEndingNewline");o(QAe,"foldString");o(oj,"foldLine");o(ZAe,"escapeString");o(JAe,"writeFlowSequence");o(lj,"writeBlockSequence");o(e8e,"writeFlowMapping");o(t8e,"writeBlockMapping");o(cj,"detectType");o(Au,"writeNode");o(r8e,"getDuplicateReferences");o(cD,"inspectNode");o(n8e,"dump$1");i8e=n8e,a8e={dump:i8e};o(pD,"renamed");lm=fj,cm=Ej.load,okt=Ej.loadAll,lkt=a8e.dump,ckt=pD("safeLoad","load"),ukt=pD("safeLoadAll","loadAll"),hkt=pD("safeDump","dump")});function vD(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function Gj(t){Id=t}function nn(t,e=""){let r=typeof t=="string"?t:t.source,n={replace:o((i,a)=>{let s=typeof a=="string"?a:a.source;return s=s.replace(ts.caret,"$1"),r=r.replace(i,s),n},"replace"),getRegex:o(()=>new RegExp(r,e),"getRegex")};return n}function pc(t,e){if(e){if(ts.escapeTest.test(t))return t.replace(ts.escapeReplace,Bj)}else if(ts.escapeTestNoEncode.test(t))return t.replace(ts.escapeReplaceNoEncode,Bj);return t}function Fj(t){try{t=encodeURI(t).replace(ts.percentDecode,"%")}catch{return null}return t}function $j(t,e){let r=t.replace(ts.findPipe,(a,s,l)=>{let u=!1,h=s;for(;--h>=0&&l[h]==="\\";)u=!u;return u?"|":" |"}),n=r.split(ts.splitPipe),i=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length{let s=a.match(r.other.beginningSpace);if(s===null)return a;let[l]=s;return l.length>=i.length?a.slice(i.length):a}).join(` +`)}function Jr(t,e){return Md.parse(t,e)}var Id,d2,ts,s8e,o8e,l8e,m2,c8e,xD,Vj,Uj,u8e,bD,h8e,wD,f8e,d8e,Aw,TD,p8e,Hj,m8e,kD,Pj,g8e,y8e,v8e,x8e,Wj,b8e,_w,ED,qj,w8e,Yj,T8e,k8e,E8e,Xj,S8e,C8e,jj,A8e,_8e,D8e,L8e,R8e,N8e,M8e,Cw,I8e,Kj,Qj,O8e,SD,P8e,gD,B8e,Sw,h2,F8e,Bj,hm,Al,fm,p2,_l,um,yD,Md,dkt,pkt,mkt,gkt,ykt,vkt,xkt,Zj=N(()=>{"use strict";o(vD,"_getDefaults");Id=vD();o(Gj,"changeDefaults");d2={exec:o(()=>null,"exec")};o(nn,"edit");ts={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:o(t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),"listItemRegex"),nextBulletRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),"nextBulletRegex"),hrRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),"hrRegex"),fencesBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),"fencesBeginRegex"),headingBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),"headingBeginRegex"),htmlBeginRegex:o(t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i"),"htmlBeginRegex")},s8e=/^(?:[ \t]*(?:\n|$))+/,o8e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,l8e=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,m2=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,c8e=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,xD=/(?:[*+-]|\d{1,9}[.)])/,Vj=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,Uj=nn(Vj).replace(/bull/g,xD).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),u8e=nn(Vj).replace(/bull/g,xD).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),bD=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,h8e=/^[^\n]+/,wD=/(?!\s*\])(?:\\.|[^\[\]\\])+/,f8e=nn(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",wD).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),d8e=nn(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,xD).getRegex(),Aw="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",TD=/|$))/,p8e=nn("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",TD).replace("tag",Aw).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),Hj=nn(bD).replace("hr",m2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Aw).getRegex(),m8e=nn(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",Hj).getRegex(),kD={blockquote:m8e,code:o8e,def:f8e,fences:l8e,heading:c8e,hr:m2,html:p8e,lheading:Uj,list:d8e,newline:s8e,paragraph:Hj,table:d2,text:h8e},Pj=nn("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Aw).getRegex(),g8e={...kD,lheading:u8e,table:Pj,paragraph:nn(bD).replace("hr",m2).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Pj).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Aw).getRegex()},y8e={...kD,html:nn(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",TD).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:d2,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:nn(bD).replace("hr",m2).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",Uj).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},v8e=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,x8e=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Wj=/^( {2,}|\\)\n(?!\s*$)/,b8e=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,Xj=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,S8e=nn(Xj,"u").replace(/punct/g,_w).getRegex(),C8e=nn(Xj,"u").replace(/punct/g,Yj).getRegex(),jj="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",A8e=nn(jj,"gu").replace(/notPunctSpace/g,qj).replace(/punctSpace/g,ED).replace(/punct/g,_w).getRegex(),_8e=nn(jj,"gu").replace(/notPunctSpace/g,k8e).replace(/punctSpace/g,T8e).replace(/punct/g,Yj).getRegex(),D8e=nn("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,qj).replace(/punctSpace/g,ED).replace(/punct/g,_w).getRegex(),L8e=nn(/\\(punct)/,"gu").replace(/punct/g,_w).getRegex(),R8e=nn(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),N8e=nn(TD).replace("(?:-->|$)","-->").getRegex(),M8e=nn("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",N8e).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Cw=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,I8e=nn(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",Cw).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Kj=nn(/^!?\[(label)\]\[(ref)\]/).replace("label",Cw).replace("ref",wD).getRegex(),Qj=nn(/^!?\[(ref)\](?:\[\])?/).replace("ref",wD).getRegex(),O8e=nn("reflink|nolink(?!\\()","g").replace("reflink",Kj).replace("nolink",Qj).getRegex(),SD={_backpedal:d2,anyPunctuation:L8e,autolink:R8e,blockSkip:E8e,br:Wj,code:x8e,del:d2,emStrongLDelim:S8e,emStrongRDelimAst:A8e,emStrongRDelimUnd:D8e,escape:v8e,link:I8e,nolink:Qj,punctuation:w8e,reflink:Kj,reflinkSearch:O8e,tag:M8e,text:b8e,url:d2},P8e={...SD,link:nn(/^!?\[(label)\]\((.*?)\)/).replace("label",Cw).getRegex(),reflink:nn(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Cw).getRegex()},gD={...SD,emStrongRDelimAst:_8e,emStrongLDelim:C8e,url:nn(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},Bj=o(t=>F8e[t],"getEscapeReplacement");o(pc,"escape");o(Fj,"cleanUrl");o($j,"splitCells");o(f2,"rtrim");o($8e,"findClosingBracket");o(zj,"outputLink");o(z8e,"indentCodeCompensation");hm=class{static{o(this,"_Tokenizer")}options;rules;lexer;constructor(e){this.options=e||Id}space(e){let r=this.rules.block.newline.exec(e);if(r&&r[0].length>0)return{type:"space",raw:r[0]}}code(e){let r=this.rules.block.code.exec(e);if(r){let n=r[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:r[0],codeBlockStyle:"indented",text:this.options.pedantic?n:f2(n,` +`)}}}fences(e){let r=this.rules.block.fences.exec(e);if(r){let n=r[0],i=z8e(n,r[3]||"",this.rules);return{type:"code",raw:n,lang:r[2]?r[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):r[2],text:i}}}heading(e){let r=this.rules.block.heading.exec(e);if(r){let n=r[2].trim();if(this.rules.other.endingHash.test(n)){let i=f2(n,"#");(this.options.pedantic||!i||this.rules.other.endingSpaceChar.test(i))&&(n=i.trim())}return{type:"heading",raw:r[0],depth:r[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let r=this.rules.block.hr.exec(e);if(r)return{type:"hr",raw:f2(r[0],` +`)}}blockquote(e){let r=this.rules.block.blockquote.exec(e);if(r){let n=f2(r[0],` +`).split(` +`),i="",a="",s=[];for(;n.length>0;){let l=!1,u=[],h;for(h=0;h1,a={type:"list",raw:"",ordered:i,start:i?+n.slice(0,-1):"",loose:!1,items:[]};n=i?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=i?n:"[*+-]");let s=this.rules.other.listItemRegex(n),l=!1;for(;e;){let h=!1,f="",d="";if(!(r=s.exec(e))||this.rules.block.hr.test(e))break;f=r[0],e=e.substring(f.length);let p=r[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,b=>" ".repeat(3*b.length)),m=e.split(` +`,1)[0],g=!p.trim(),y=0;if(this.options.pedantic?(y=2,d=p.trimStart()):g?y=r[1].length+1:(y=r[2].search(this.rules.other.nonSpaceChar),y=y>4?1:y,d=p.slice(y),y+=r[1].length),g&&this.rules.other.blankLine.test(m)&&(f+=m+` +`,e=e.substring(m.length+1),h=!0),!h){let b=this.rules.other.nextBulletRegex(y),w=this.rules.other.hrRegex(y),C=this.rules.other.fencesBeginRegex(y),T=this.rules.other.headingBeginRegex(y),E=this.rules.other.htmlBeginRegex(y);for(;e;){let A=e.split(` +`,1)[0],S;if(m=A,this.options.pedantic?(m=m.replace(this.rules.other.listReplaceNesting," "),S=m):S=m.replace(this.rules.other.tabCharGlobal," "),C.test(m)||T.test(m)||E.test(m)||b.test(m)||w.test(m))break;if(S.search(this.rules.other.nonSpaceChar)>=y||!m.trim())d+=` +`+S.slice(y);else{if(g||p.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||C.test(p)||T.test(p)||w.test(p))break;d+=` +`+m}!g&&!m.trim()&&(g=!0),f+=A+` +`,e=e.substring(A.length+1),p=S.slice(y)}}a.loose||(l?a.loose=!0:this.rules.other.doubleBlankLine.test(f)&&(l=!0));let v=null,x;this.options.gfm&&(v=this.rules.other.listIsTask.exec(d),v&&(x=v[0]!=="[ ] ",d=d.replace(this.rules.other.listReplaceTask,""))),a.items.push({type:"list_item",raw:f,task:!!v,checked:x,loose:!1,text:d,tokens:[]}),a.raw+=f}let u=a.items.at(-1);if(u)u.raw=u.raw.trimEnd(),u.text=u.text.trimEnd();else return;a.raw=a.raw.trimEnd();for(let h=0;hp.type==="space"),d=f.length>0&&f.some(p=>this.rules.other.anyLine.test(p.raw));a.loose=d}if(a.loose)for(let h=0;h({text:u,tokens:this.lexer.inline(u),header:!1,align:s.align[h]})));return s}}lheading(e){let r=this.rules.block.lheading.exec(e);if(r)return{type:"heading",raw:r[0],depth:r[2].charAt(0)==="="?1:2,text:r[1],tokens:this.lexer.inline(r[1])}}paragraph(e){let r=this.rules.block.paragraph.exec(e);if(r){let n=r[1].charAt(r[1].length-1)===` +`?r[1].slice(0,-1):r[1];return{type:"paragraph",raw:r[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let r=this.rules.block.text.exec(e);if(r)return{type:"text",raw:r[0],text:r[0],tokens:this.lexer.inline(r[0])}}escape(e){let r=this.rules.inline.escape.exec(e);if(r)return{type:"escape",raw:r[0],text:r[1]}}tag(e){let r=this.rules.inline.tag.exec(e);if(r)return!this.lexer.state.inLink&&this.rules.other.startATag.test(r[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(r[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(r[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(r[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:r[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:r[0]}}link(e){let r=this.rules.inline.link.exec(e);if(r){let n=r[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let s=f2(n.slice(0,-1),"\\");if((n.length-s.length)%2===0)return}else{let s=$8e(r[2],"()");if(s>-1){let u=(r[0].indexOf("!")===0?5:4)+r[1].length+s;r[2]=r[2].substring(0,s),r[0]=r[0].substring(0,u).trim(),r[3]=""}}let i=r[2],a="";if(this.options.pedantic){let s=this.rules.other.pedanticHrefTitle.exec(i);s&&(i=s[1],a=s[3])}else a=r[3]?r[3].slice(1,-1):"";return i=i.trim(),this.rules.other.startAngleBracket.test(i)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?i=i.slice(1):i=i.slice(1,-1)),zj(r,{href:i&&i.replace(this.rules.inline.anyPunctuation,"$1"),title:a&&a.replace(this.rules.inline.anyPunctuation,"$1")},r[0],this.lexer,this.rules)}}reflink(e,r){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let i=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),a=r[i.toLowerCase()];if(!a){let s=n[0].charAt(0);return{type:"text",raw:s,text:s}}return zj(n,a,n[0],this.lexer,this.rules)}}emStrong(e,r,n=""){let i=this.rules.inline.emStrongLDelim.exec(e);if(!i||i[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(i[1]||i[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let s=[...i[0]].length-1,l,u,h=s,f=0,d=i[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(d.lastIndex=0,r=r.slice(-1*e.length+s);(i=d.exec(r))!=null;){if(l=i[1]||i[2]||i[3]||i[4]||i[5]||i[6],!l)continue;if(u=[...l].length,i[3]||i[4]){h+=u;continue}else if((i[5]||i[6])&&s%3&&!((s+u)%3)){f+=u;continue}if(h-=u,h>0)continue;u=Math.min(u,u+h+f);let p=[...i[0]][0].length,m=e.slice(0,s+i.index+p+u);if(Math.min(s,u)%2){let y=m.slice(1,-1);return{type:"em",raw:m,text:y,tokens:this.lexer.inlineTokens(y)}}let g=m.slice(2,-2);return{type:"strong",raw:m,text:g,tokens:this.lexer.inlineTokens(g)}}}}codespan(e){let r=this.rules.inline.code.exec(e);if(r){let n=r[2].replace(this.rules.other.newLineCharGlobal," "),i=this.rules.other.nonSpaceChar.test(n),a=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return i&&a&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:r[0],text:n}}}br(e){let r=this.rules.inline.br.exec(e);if(r)return{type:"br",raw:r[0]}}del(e){let r=this.rules.inline.del.exec(e);if(r)return{type:"del",raw:r[0],text:r[2],tokens:this.lexer.inlineTokens(r[2])}}autolink(e){let r=this.rules.inline.autolink.exec(e);if(r){let n,i;return r[2]==="@"?(n=r[1],i="mailto:"+n):(n=r[1],i=n),{type:"link",raw:r[0],text:n,href:i,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let r;if(r=this.rules.inline.url.exec(e)){let n,i;if(r[2]==="@")n=r[0],i="mailto:"+n;else{let a;do a=r[0],r[0]=this.rules.inline._backpedal.exec(r[0])?.[0]??"";while(a!==r[0]);n=r[0],r[1]==="www."?i="http://"+r[0]:i=r[0]}return{type:"link",raw:r[0],text:n,href:i,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let r=this.rules.inline.text.exec(e);if(r){let n=this.lexer.state.inRawBlock;return{type:"text",raw:r[0],text:r[0],escaped:n}}}},Al=class t{static{o(this,"_Lexer")}tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||Id,this.options.tokenizer=this.options.tokenizer||new hm,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let r={other:ts,block:Sw.normal,inline:h2.normal};this.options.pedantic?(r.block=Sw.pedantic,r.inline=h2.pedantic):this.options.gfm&&(r.block=Sw.gfm,this.options.breaks?r.inline=h2.breaks:r.inline=h2.gfm),this.tokenizer.rules=r}static get rules(){return{block:Sw,inline:h2}}static lex(e,r){return new t(r).lex(e)}static lexInline(e,r){return new t(r).inlineTokens(e)}lex(e){e=e.replace(ts.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let r=0;r(i=s.call({lexer:this},e,r))?(e=e.substring(i.raw.length),r.push(i),!0):!1))continue;if(i=this.tokenizer.space(e)){e=e.substring(i.raw.length);let s=r.at(-1);i.raw.length===1&&s!==void 0?s.raw+=` +`:r.push(i);continue}if(i=this.tokenizer.code(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=` +`+i.raw,s.text+=` +`+i.text,this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(i=this.tokenizer.fences(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.heading(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.hr(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.blockquote(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.list(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.html(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.def(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=` +`+i.raw,s.text+=` +`+i.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[i.tag]||(this.tokens.links[i.tag]={href:i.href,title:i.title});continue}if(i=this.tokenizer.table(e)){e=e.substring(i.raw.length),r.push(i);continue}if(i=this.tokenizer.lheading(e)){e=e.substring(i.raw.length),r.push(i);continue}let a=e;if(this.options.extensions?.startBlock){let s=1/0,l=e.slice(1),u;this.options.extensions.startBlock.forEach(h=>{u=h.call({lexer:this},l),typeof u=="number"&&u>=0&&(s=Math.min(s,u))}),s<1/0&&s>=0&&(a=e.substring(0,s+1))}if(this.state.top&&(i=this.tokenizer.paragraph(a))){let s=r.at(-1);n&&s?.type==="paragraph"?(s.raw+=` +`+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i),n=a.length!==e.length,e=e.substring(i.raw.length);continue}if(i=this.tokenizer.text(e)){e=e.substring(i.raw.length);let s=r.at(-1);s?.type==="text"?(s.raw+=` +`+i.raw,s.text+=` +`+i.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):r.push(i);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,r}inline(e,r=[]){return this.inlineQueue.push({src:e,tokens:r}),r}inlineTokens(e,r=[]){let n=e,i=null;if(this.tokens.links){let l=Object.keys(this.tokens.links);if(l.length>0)for(;(i=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)l.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(i=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;(i=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,i.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let a=!1,s="";for(;e;){a||(s=""),a=!1;let l;if(this.options.extensions?.inline?.some(h=>(l=h.call({lexer:this},e,r))?(e=e.substring(l.raw.length),r.push(l),!0):!1))continue;if(l=this.tokenizer.escape(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.tag(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.link(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(l.raw.length);let h=r.at(-1);l.type==="text"&&h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(l=this.tokenizer.emStrong(e,n,s)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.codespan(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.br(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.del(e)){e=e.substring(l.raw.length),r.push(l);continue}if(l=this.tokenizer.autolink(e)){e=e.substring(l.raw.length),r.push(l);continue}if(!this.state.inLink&&(l=this.tokenizer.url(e))){e=e.substring(l.raw.length),r.push(l);continue}let u=e;if(this.options.extensions?.startInline){let h=1/0,f=e.slice(1),d;this.options.extensions.startInline.forEach(p=>{d=p.call({lexer:this},f),typeof d=="number"&&d>=0&&(h=Math.min(h,d))}),h<1/0&&h>=0&&(u=e.substring(0,h+1))}if(l=this.tokenizer.inlineText(u)){e=e.substring(l.raw.length),l.raw.slice(-1)!=="_"&&(s=l.raw.slice(-1)),a=!0;let h=r.at(-1);h?.type==="text"?(h.raw+=l.raw,h.text+=l.text):r.push(l);continue}if(e){let h="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(h);break}else throw new Error(h)}}return r}},fm=class{static{o(this,"_Renderer")}options;parser;constructor(e){this.options=e||Id}space(e){return""}code({text:e,lang:r,escaped:n}){let i=(r||"").match(ts.notSpaceStart)?.[0],a=e.replace(ts.endingNewline,"")+` +`;return i?'
'+(n?a:pc(a,!0))+`
+`:"
"+(n?a:pc(a,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:r}){return`${this.parser.parseInline(e)} +`}hr(e){return`
+`}list(e){let r=e.ordered,n=e.start,i="";for(let l=0;l +`+i+" +`}listitem(e){let r="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+pc(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):r+=n+" "}return r+=this.parser.parse(e.tokens,!!e.loose),`
  • ${r}
  • +`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let r="",n="";for(let a=0;a${i}`),` + +`+r+` +`+i+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let r=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+r+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${pc(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:r,tokens:n}){let i=this.parser.parseInline(n),a=Fj(e);if(a===null)return i;e=a;let s='
    ",s}image({href:e,title:r,text:n}){let i=Fj(e);if(i===null)return pc(n);e=i;let a=`${n}{let l=a[s].flat(1/0);n=n.concat(this.walkTokens(l,r))}):a.tokens&&(n=n.concat(this.walkTokens(a.tokens,r)))}}return n}use(...e){let r=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let i={...n};if(i.async=this.defaults.async||i.async||!1,n.extensions&&(n.extensions.forEach(a=>{if(!a.name)throw new Error("extension name required");if("renderer"in a){let s=r.renderers[a.name];s?r.renderers[a.name]=function(...l){let u=a.renderer.apply(this,l);return u===!1&&(u=s.apply(this,l)),u}:r.renderers[a.name]=a.renderer}if("tokenizer"in a){if(!a.level||a.level!=="block"&&a.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let s=r[a.level];s?s.unshift(a.tokenizer):r[a.level]=[a.tokenizer],a.start&&(a.level==="block"?r.startBlock?r.startBlock.push(a.start):r.startBlock=[a.start]:a.level==="inline"&&(r.startInline?r.startInline.push(a.start):r.startInline=[a.start]))}"childTokens"in a&&a.childTokens&&(r.childTokens[a.name]=a.childTokens)}),i.extensions=r),n.renderer){let a=this.defaults.renderer||new fm(this.defaults);for(let s in n.renderer){if(!(s in a))throw new Error(`renderer '${s}' does not exist`);if(["options","parser"].includes(s))continue;let l=s,u=n.renderer[l],h=a[l];a[l]=(...f)=>{let d=u.apply(a,f);return d===!1&&(d=h.apply(a,f)),d||""}}i.renderer=a}if(n.tokenizer){let a=this.defaults.tokenizer||new hm(this.defaults);for(let s in n.tokenizer){if(!(s in a))throw new Error(`tokenizer '${s}' does not exist`);if(["options","rules","lexer"].includes(s))continue;let l=s,u=n.tokenizer[l],h=a[l];a[l]=(...f)=>{let d=u.apply(a,f);return d===!1&&(d=h.apply(a,f)),d}}i.tokenizer=a}if(n.hooks){let a=this.defaults.hooks||new um;for(let s in n.hooks){if(!(s in a))throw new Error(`hook '${s}' does not exist`);if(["options","block"].includes(s))continue;let l=s,u=n.hooks[l],h=a[l];um.passThroughHooks.has(s)?a[l]=f=>{if(this.defaults.async)return Promise.resolve(u.call(a,f)).then(p=>h.call(a,p));let d=u.call(a,f);return h.call(a,d)}:a[l]=(...f)=>{let d=u.apply(a,f);return d===!1&&(d=h.apply(a,f)),d}}i.hooks=a}if(n.walkTokens){let a=this.defaults.walkTokens,s=n.walkTokens;i.walkTokens=function(l){let u=[];return u.push(s.call(this,l)),a&&(u=u.concat(a.call(this,l))),u}}this.defaults={...this.defaults,...i}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,r){return Al.lex(e,r??this.defaults)}parser(e,r){return _l.parse(e,r??this.defaults)}parseMarkdown(e){return o((n,i)=>{let a={...i},s={...this.defaults,...a},l=this.onError(!!s.silent,!!s.async);if(this.defaults.async===!0&&a.async===!1)return l(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return l(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));s.hooks&&(s.hooks.options=s,s.hooks.block=e);let u=s.hooks?s.hooks.provideLexer():e?Al.lex:Al.lexInline,h=s.hooks?s.hooks.provideParser():e?_l.parse:_l.parseInline;if(s.async)return Promise.resolve(s.hooks?s.hooks.preprocess(n):n).then(f=>u(f,s)).then(f=>s.hooks?s.hooks.processAllTokens(f):f).then(f=>s.walkTokens?Promise.all(this.walkTokens(f,s.walkTokens)).then(()=>f):f).then(f=>h(f,s)).then(f=>s.hooks?s.hooks.postprocess(f):f).catch(l);try{s.hooks&&(n=s.hooks.preprocess(n));let f=u(n,s);s.hooks&&(f=s.hooks.processAllTokens(f)),s.walkTokens&&this.walkTokens(f,s.walkTokens);let d=h(f,s);return s.hooks&&(d=s.hooks.postprocess(d)),d}catch(f){return l(f)}},"parse")}onError(e,r){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let i="

    An error occurred:

    "+pc(n.message+"",!0)+"
    ";return r?Promise.resolve(i):i}if(r)return Promise.reject(n);throw n}}},Md=new yD;o(Jr,"marked");Jr.options=Jr.setOptions=function(t){return Md.setOptions(t),Jr.defaults=Md.defaults,Gj(Jr.defaults),Jr};Jr.getDefaults=vD;Jr.defaults=Id;Jr.use=function(...t){return Md.use(...t),Jr.defaults=Md.defaults,Gj(Jr.defaults),Jr};Jr.walkTokens=function(t,e){return Md.walkTokens(t,e)};Jr.parseInline=Md.parseInline;Jr.Parser=_l;Jr.parser=_l.parse;Jr.Renderer=fm;Jr.TextRenderer=p2;Jr.Lexer=Al;Jr.lexer=Al.lex;Jr.Tokenizer=hm;Jr.Hooks=um;Jr.parse=Jr;dkt=Jr.options,pkt=Jr.setOptions,mkt=Jr.use,gkt=Jr.walkTokens,ykt=Jr.parseInline,vkt=_l.parse,xkt=Al.lex});function G8e(t,{markdownAutoWrap:e}){let n=t.replace(//g,` +`).replace(/\n{2,}/g,` +`),i=B4(n);return e===!1?i.replace(/ /g," "):i}function Jj(t,e={}){let r=G8e(t,e),n=Jr.lexer(r),i=[[]],a=0;function s(l,u="normal"){l.type==="text"?l.text.split(` +`).forEach((f,d)=>{d!==0&&(a++,i.push([])),f.split(" ").forEach(p=>{p=p.replace(/'/g,"'"),p&&i[a].push({content:p,type:u})})}):l.type==="strong"||l.type==="em"?l.tokens.forEach(h=>{s(h,l.type)}):l.type==="html"&&i[a].push({content:l.text,type:"normal"})}return o(s,"processNode"),n.forEach(l=>{l.type==="paragraph"?l.tokens?.forEach(u=>{s(u)}):l.type==="html"&&i[a].push({content:l.text,type:"normal"})}),i}function eK(t,{markdownAutoWrap:e}={}){let r=Jr.lexer(t);function n(i){return i.type==="text"?e===!1?i.text.replace(/\n */g,"
    ").replace(/ /g," "):i.text.replace(/\n */g,"
    "):i.type==="strong"?`${i.tokens?.map(n).join("")}`:i.type==="em"?`${i.tokens?.map(n).join("")}`:i.type==="paragraph"?`

    ${i.tokens?.map(n).join("")}

    `:i.type==="space"?"":i.type==="html"?`${i.text}`:i.type==="escape"?i.text:`Unsupported markdown: ${i.type}`}return o(n,"output"),r.map(n).join("")}var tK=N(()=>{"use strict";Zj();PC();o(G8e,"preprocessMarkdown");o(Jj,"markdownToLines");o(eK,"markdownToHTML")});function V8e(t){return Intl.Segmenter?[...new Intl.Segmenter().segment(t)].map(e=>e.segment):[...t]}function U8e(t,e){let r=V8e(e.content);return rK(t,[],r,e.type)}function rK(t,e,r,n){if(r.length===0)return[{content:e.join(""),type:n},{content:"",type:n}];let[i,...a]=r,s=[...e,i];return t([{content:s.join(""),type:n}])?rK(t,s,a,n):(e.length===0&&i&&(e.push(i),r.shift()),[{content:e.join(""),type:n},{content:r.join(""),type:n}])}function nK(t,e){if(t.some(({content:r})=>r.includes(` +`)))throw new Error("splitLineToFitWidth does not support newlines in the line");return CD(t,e)}function CD(t,e,r=[],n=[]){if(t.length===0)return n.length>0&&r.push(n),r.length>0?r:[];let i="";t[0].content===" "&&(i=" ",t.shift());let a=t.shift()??{content:" ",type:"normal"},s=[...n];if(i!==""&&s.push({content:i,type:"normal"}),s.push(a),e(s))return CD(t,e,r,s);if(n.length>0)r.push(n),t.unshift(a);else if(a.content){let[l,u]=U8e(e,a);r.push([l]),u.content&&t.unshift(u)}return CD(t,e,r)}var iK=N(()=>{"use strict";o(V8e,"splitTextToChars");o(U8e,"splitWordToFitWidth");o(rK,"splitWordToFitWidthRecursion");o(nK,"splitLineToFitWidth");o(CD,"splitLineToFitWidthRecursion")});function aK(t,e){e&&t.attr("style",e)}async function H8e(t,e,r,n,i=!1){let a=t.append("foreignObject");a.attr("width",`${10*r}px`),a.attr("height",`${10*r}px`);let s=a.append("xhtml:div"),l=e.label;e.label&&pi(e.label)&&(l=await mh(e.label.replace(Ze.lineBreakRegex,` +`),me()));let u=e.isNode?"nodeLabel":"edgeLabel",h=s.append("span");h.html(l),aK(h,e.labelStyle),h.attr("class",`${u} ${n}`),aK(s,e.labelStyle),s.style("display","table-cell"),s.style("white-space","nowrap"),s.style("line-height","1.5"),s.style("max-width",r+"px"),s.style("text-align","center"),s.attr("xmlns","http://www.w3.org/1999/xhtml"),i&&s.attr("class","labelBkg");let f=s.node().getBoundingClientRect();return f.width===r&&(s.style("display","table"),s.style("white-space","break-spaces"),s.style("width",r+"px"),f=s.node().getBoundingClientRect()),a.node()}function AD(t,e,r){return t.append("tspan").attr("class","text-outer-tspan").attr("x",0).attr("y",e*r-.1+"em").attr("dy",r+"em")}function W8e(t,e,r){let n=t.append("text"),i=AD(n,1,e);_D(i,r);let a=i.node().getComputedTextLength();return n.remove(),a}function sK(t,e,r){let n=t.append("text"),i=AD(n,1,e);_D(i,[{content:r,type:"normal"}]);let a=i.node()?.getBoundingClientRect();return a&&n.remove(),a}function q8e(t,e,r,n=!1){let a=e.append("g"),s=a.insert("rect").attr("class","background").attr("style","stroke: none"),l=a.append("text").attr("y","-10.1"),u=0;for(let h of r){let f=o(p=>W8e(a,1.1,p)<=t,"checkWidth"),d=f(h)?[h]:nK(h,f);for(let p of d){let m=AD(l,u,1.1);_D(m,p),u++}}if(n){let h=l.node().getBBox(),f=2;return s.attr("x",h.x-f).attr("y",h.y-f).attr("width",h.width+2*f).attr("height",h.height+2*f),a.node()}else return l.node()}function _D(t,e){t.text(""),e.forEach((r,n)=>{let i=t.append("tspan").attr("font-style",r.type==="em"?"italic":"normal").attr("class","text-inner-tspan").attr("font-weight",r.type==="strong"?"bold":"normal");n===0?i.text(r.content):i.text(" "+r.content)})}function DD(t){return t.replace(/fa[bklrs]?:fa-[\w-]+/g,e=>``)}var Hn,to=N(()=>{"use strict";zt();gr();dr();vt();tK();ir();iK();o(aK,"applyStyle");o(H8e,"addHtmlSpan");o(AD,"createTspan");o(W8e,"computeWidthOfText");o(sK,"computeDimensionOfText");o(q8e,"createFormattedText");o(_D,"updateTextContentAndStyles");o(DD,"replaceIconSubstring");Hn=o(async(t,e="",{style:r="",isTitle:n=!1,classes:i="",useHtmlLabels:a=!0,isNode:s=!0,width:l=200,addSvgBackground:u=!1}={},h)=>{if(Y.debug("XYZ createText",e,r,n,i,a,s,"addSvgBackground: ",u),a){let f=eK(e,h),d=DD(na(f)),p=e.replace(/\\\\/g,"\\"),m={isNode:s,label:pi(e)?p:d,labelStyle:r.replace("fill:","color:")};return await H8e(t,m,l,i,u)}else{let f=e.replace(//g,"
    "),d=Jj(f.replace("
    ","
    "),h),p=q8e(l,t,d,e?u:!1);if(s){/stroke:/.exec(r)&&(r=r.replace("stroke:","lineColor:"));let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");Ge(p).attr("style",m)}else{let m=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/background:/g,"fill:");Ge(p).select("rect").attr("style",m.replace(/background:/g,"fill:"));let g=r.replace(/stroke:[^;]+;?/g,"").replace(/stroke-width:[^;]+;?/g,"").replace(/fill:[^;]+;?/g,"").replace(/color:/g,"fill:");Ge(p).select("text").attr("style",g)}return p}},"createText")});function Xt(t){let e=t.map((r,n)=>`${n===0?"M":"L"}${r.x},${r.y}`);return e.push("Z"),e.join(" ")}function Fo(t,e,r,n,i,a){let s=[],u=r-t,h=n-e,f=u/a,d=2*Math.PI/f,p=e+h/2;for(let m=0;m<=50;m++){let g=m/50,y=t+g*u,v=p+i*Math.sin(d*(y-t));s.push({x:y,y:v})}return s}function Lw(t,e,r,n,i,a){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;d{"use strict";to();zt();dr();Ya();gr();ir();pt=o(async(t,e,r)=>{let n,i=e.useHtmlLabels||fr(me()?.htmlLabels);r?n=r:n="node default";let a=t.insert("g").attr("class",n).attr("id",e.domId||e.id),s=a.insert("g").attr("class","label").attr("style",$n(e.labelStyle)),l;e.label===void 0?l="":l=typeof e.label=="string"?e.label:e.label[0];let u=await Hn(s,Tr(na(l),me()),{useHtmlLabels:i,width:e.width||me().flowchart?.wrappingWidth,cssClasses:"markdown-node-label",style:e.labelStyle,addSvgBackground:!!e.icon||!!e.img}),h=u.getBBox(),f=(e?.padding??0)/2;if(i){let d=u.children[0],p=Ge(u),m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=me().fontSize?me().fontSize:window.getComputedStyle(document.body).fontSize,w=5,[C=or.fontSize]=Bo(b),T=C*w+"px";y.style.minWidth=T,y.style.maxWidth=T}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}return i?s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"):s.attr("transform","translate(0, "+-h.height/2+")"),e.centerLabel&&s.attr("transform","translate("+-h.width/2+", "+-h.height/2+")"),s.insert("rect",":first-child"),{shapeSvg:a,bbox:h,halfPadding:f,label:s}},"labelHelper"),Dw=o(async(t,e,r)=>{let n=r.useHtmlLabels||fr(me()?.flowchart?.htmlLabels),i=t.insert("g").attr("class","label").attr("style",r.labelStyle||""),a=await Hn(i,Tr(na(e),me()),{useHtmlLabels:n,width:r.width||me()?.flowchart?.wrappingWidth,style:r.labelStyle,addSvgBackground:!!r.icon||!!r.img}),s=a.getBBox(),l=r.padding/2;if(fr(me()?.flowchart?.htmlLabels)){let u=a.children[0],h=Ge(a);s=u.getBoundingClientRect(),h.attr("width",s.width),h.attr("height",s.height)}return n?i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"):i.attr("transform","translate(0, "+-s.height/2+")"),r.centerLabel&&i.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"),i.insert("rect",":first-child"),{shapeSvg:t,bbox:s,halfPadding:l,label:i}},"insertLabel"),je=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds"),ht=o((t,e)=>(t.look==="handDrawn"?"rough-node":"node")+" "+t.cssClasses+" "+(e||""),"getNodeClasses");o(Xt,"createPathFromPoints");o(Fo,"generateFullSineWavePoints");o(Lw,"generateCirclePoints")});function Y8e(t,e){return t.intersect(e)}var oK,lK=N(()=>{"use strict";o(Y8e,"intersectNode");oK=Y8e});function X8e(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(X8e,"intersectEllipse");Rw=X8e});function j8e(t,e,r){return Rw(t,e,e,r)}var cK,uK=N(()=>{"use strict";LD();o(j8e,"intersectCircle");cK=j8e});function K8e(t,e,r,n){var i,a,s,l,u,h,f,d,p,m,g,y,v,x,b;if(i=e.y-t.y,s=t.x-e.x,u=e.x*t.y-t.x*e.y,p=i*r.x+s*r.y+u,m=i*n.x+s*n.y+u,!(p!==0&&m!==0&&hK(p,m))&&(a=n.y-r.y,l=r.x-n.x,h=n.x*r.y-r.x*n.y,f=a*t.x+l*t.y+h,d=a*e.x+l*e.y+h,!(f!==0&&d!==0&&hK(f,d))&&(g=i*l-a*s,g!==0)))return y=Math.abs(g/2),v=s*h-l*u,x=v<0?(v-y)/g:(v+y)/g,v=a*u-i*h,b=v<0?(v-y)/g:(v+y)/g,{x,y:b}}function hK(t,e){return t*e>0}var fK,dK=N(()=>{"use strict";o(K8e,"intersectLine");o(hK,"sameSign");fK=K8e});function Q8e(t,e,r){let n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(f){s=Math.min(s,f.x),l=Math.min(l,f.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));let u=n-t.width/2-s,h=i-t.height/2-l;for(let f=0;f1&&a.sort(function(f,d){let p=f.x-r.x,m=f.y-r.y,g=Math.sqrt(p*p+m*m),y=d.x-r.x,v=d.y-r.y,x=Math.sqrt(y*y+v*v);return g{"use strict";dK();o(Q8e,"intersectPolygon");pK=Q8e});var Z8e,Vh,RD=N(()=>{"use strict";Z8e=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),Vh=Z8e});var Ye,Ht=N(()=>{"use strict";lK();uK();LD();mK();RD();Ye={node:oK,circle:cK,ellipse:Rw,polygon:pK,rect:Vh}});var gK,mc,J8e,ND,Qe,Ke,Ut=N(()=>{"use strict";zt();gK=o(t=>{let{handDrawnSeed:e}=me();return{fill:t,hachureAngle:120,hachureGap:4,fillWeight:2,roughness:.7,stroke:t,seed:e}},"solidStateFill"),mc=o(t=>{let e=J8e([...t.cssCompiledStyles||[],...t.cssStyles||[]]);return{stylesMap:e,stylesArray:[...e]}},"compileStyles"),J8e=o(t=>{let e=new Map;return t.forEach(r=>{let[n,i]=r.split(":");e.set(n.trim(),i?.trim())}),e},"styles2Map"),ND=o(t=>t==="color"||t==="font-size"||t==="font-family"||t==="font-weight"||t==="font-style"||t==="text-decoration"||t==="text-align"||t==="text-transform"||t==="line-height"||t==="letter-spacing"||t==="word-spacing"||t==="text-shadow"||t==="text-overflow"||t==="white-space"||t==="word-wrap"||t==="word-break"||t==="overflow-wrap"||t==="hyphens","isLabelStyle"),Qe=o(t=>{let{stylesArray:e}=mc(t),r=[],n=[],i=[],a=[];return e.forEach(s=>{let l=s[0];ND(l)?r.push(s.join(":")+" !important"):(n.push(s.join(":")+" !important"),l.includes("stroke")&&i.push(s.join(":")+" !important"),l==="fill"&&a.push(s.join(":")+" !important"))}),{labelStyles:r.join(";"),nodeStyles:n.join(";"),stylesArray:e,borderStyles:i,backgroundStyles:a}},"styles2String"),Ke=o((t,e)=>{let{themeVariables:r,handDrawnSeed:n}=me(),{nodeBorder:i,mainBkg:a}=r,{stylesMap:s}=mc(t);return Object.assign({roughness:.7,fill:s.get("fill")||a,fillStyle:"hachure",fillWeight:4,hachureGap:5.2,stroke:s.get("stroke")||i,seed:n,strokeWidth:s.get("stroke-width")?.replace("px","")||1.3,fillLineDash:[0,0]},e)},"userNodeOverrides")});function MD(t,e,r){if(t&&t.length){let[n,i]=e,a=Math.PI/180*r,s=Math.cos(a),l=Math.sin(a);for(let u of t){let[h,f]=u;u[0]=(h-n)*s-(f-i)*l+n,u[1]=(h-n)*l+(f-i)*s+i}}}function e_e(t,e){return t[0]===e[0]&&t[1]===e[1]}function t_e(t,e,r,n=1){let i=r,a=Math.max(e,.1),s=t[0]&&t[0][0]&&typeof t[0][0]=="number"?[t]:t,l=[0,0];if(i)for(let h of s)MD(h,l,i);let u=function(h,f,d){let p=[];for(let b of h){let w=[...b];e_e(w[0],w[w.length-1])||w.push([w[0][0],w[0][1]]),w.length>2&&p.push(w)}let m=[];f=Math.max(f,.1);let g=[];for(let b of p)for(let w=0;wb.yminw.ymin?1:b.xw.x?1:b.ymax===w.ymax?0:(b.ymax-w.ymax)/Math.abs(b.ymax-w.ymax)),!g.length)return m;let y=[],v=g[0].ymin,x=0;for(;y.length||g.length;){if(g.length){let b=-1;for(let w=0;wv);w++)b=w;g.splice(0,b+1).forEach(w=>{y.push({s:v,edge:w})})}if(y=y.filter(b=>!(b.edge.ymax<=v)),y.sort((b,w)=>b.edge.x===w.edge.x?0:(b.edge.x-w.edge.x)/Math.abs(b.edge.x-w.edge.x)),(d!==1||x%f==0)&&y.length>1)for(let b=0;b=y.length)break;let C=y[b].edge,T=y[w].edge;m.push([[Math.round(C.x),v],[Math.round(T.x),v]])}v+=d,y.forEach(b=>{b.edge.x=b.edge.x+d*b.edge.islope}),x++}return m}(s,a,n);if(i){for(let h of s)MD(h,l,-i);(function(h,f,d){let p=[];h.forEach(m=>p.push(...m)),MD(p,f,d)})(u,l,-i)}return u}function x2(t,e){var r;let n=e.hachureAngle+90,i=e.hachureGap;i<0&&(i=4*e.strokeWidth),i=Math.round(Math.max(i,.1));let a=1;return e.roughness>=1&&(((r=e.randomizer)===null||r===void 0?void 0:r.next())||Math.random())>.7&&(a=i),t_e(t,i,n,a||1)}function zw(t){let e=t[0],r=t[1];return Math.sqrt(Math.pow(e[0]-r[0],2)+Math.pow(e[1]-r[1],2))}function OD(t,e){return t.type===e}function jD(t){let e=[],r=function(s){let l=new Array;for(;s!=="";)if(s.match(/^([ \t\r\n,]+)/))s=s.substr(RegExp.$1.length);else if(s.match(/^([aAcChHlLmMqQsStTvVzZ])/))l[l.length]={type:r_e,text:RegExp.$1},s=s.substr(RegExp.$1.length);else{if(!s.match(/^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/))return[];l[l.length]={type:ID,text:`${parseFloat(RegExp.$1)}`},s=s.substr(RegExp.$1.length)}return l[l.length]={type:yK,text:""},l}(t),n="BOD",i=0,a=r[i];for(;!OD(a,yK);){let s=0,l=[];if(n==="BOD"){if(a.text!=="M"&&a.text!=="m")return jD("M0,0"+t);i++,s=Nw[a.text],n=a.text}else OD(a,ID)?s=Nw[n]:(i++,s=Nw[a.text],n=a.text);if(!(i+sf%2?h+r:h+e);a.push({key:"C",data:u}),e=u[4],r=u[5];break}case"Q":a.push({key:"Q",data:[...l]}),e=l[2],r=l[3];break;case"q":{let u=l.map((h,f)=>f%2?h+r:h+e);a.push({key:"Q",data:u}),e=u[2],r=u[3];break}case"A":a.push({key:"A",data:[...l]}),e=l[5],r=l[6];break;case"a":e+=l[5],r+=l[6],a.push({key:"A",data:[l[0],l[1],l[2],l[3],l[4],e,r]});break;case"H":a.push({key:"H",data:[...l]}),e=l[0];break;case"h":e+=l[0],a.push({key:"H",data:[e]});break;case"V":a.push({key:"V",data:[...l]}),r=l[0];break;case"v":r+=l[0],a.push({key:"V",data:[r]});break;case"S":a.push({key:"S",data:[...l]}),e=l[2],r=l[3];break;case"s":{let u=l.map((h,f)=>f%2?h+r:h+e);a.push({key:"S",data:u}),e=u[2],r=u[3];break}case"T":a.push({key:"T",data:[...l]}),e=l[0],r=l[1];break;case"t":e+=l[0],r+=l[1],a.push({key:"T",data:[e,r]});break;case"Z":case"z":a.push({key:"Z",data:[]}),e=n,r=i}return a}function CK(t){let e=[],r="",n=0,i=0,a=0,s=0,l=0,u=0;for(let{key:h,data:f}of t){switch(h){case"M":e.push({key:"M",data:[...f]}),[n,i]=f,[a,s]=f;break;case"C":e.push({key:"C",data:[...f]}),n=f[4],i=f[5],l=f[2],u=f[3];break;case"L":e.push({key:"L",data:[...f]}),[n,i]=f;break;case"H":n=f[0],e.push({key:"L",data:[n,i]});break;case"V":i=f[0],e.push({key:"L",data:[n,i]});break;case"S":{let d=0,p=0;r==="C"||r==="S"?(d=n+(n-l),p=i+(i-u)):(d=n,p=i),e.push({key:"C",data:[d,p,...f]}),l=f[0],u=f[1],n=f[2],i=f[3];break}case"T":{let[d,p]=f,m=0,g=0;r==="Q"||r==="T"?(m=n+(n-l),g=i+(i-u)):(m=n,g=i);let y=n+2*(m-n)/3,v=i+2*(g-i)/3,x=d+2*(m-d)/3,b=p+2*(g-p)/3;e.push({key:"C",data:[y,v,x,b,d,p]}),l=m,u=g,n=d,i=p;break}case"Q":{let[d,p,m,g]=f,y=n+2*(d-n)/3,v=i+2*(p-i)/3,x=m+2*(d-m)/3,b=g+2*(p-g)/3;e.push({key:"C",data:[y,v,x,b,m,g]}),l=d,u=p,n=m,i=g;break}case"A":{let d=Math.abs(f[0]),p=Math.abs(f[1]),m=f[2],g=f[3],y=f[4],v=f[5],x=f[6];d===0||p===0?(e.push({key:"C",data:[n,i,v,x,v,x]}),n=v,i=x):(n!==v||i!==x)&&(AK(n,i,v,x,d,p,m,g,y).forEach(function(b){e.push({key:"C",data:b})}),n=v,i=x);break}case"Z":e.push({key:"Z",data:[]}),n=a,i=s}r=h}return e}function g2(t,e,r){return[t*Math.cos(r)-e*Math.sin(r),t*Math.sin(r)+e*Math.cos(r)]}function AK(t,e,r,n,i,a,s,l,u,h){let f=(d=s,Math.PI*d/180);var d;let p=[],m=0,g=0,y=0,v=0;if(h)[m,g,y,v]=h;else{[t,e]=g2(t,e,-f),[r,n]=g2(r,n,-f);let L=(t-r)/2,R=(e-n)/2,O=L*L/(i*i)+R*R/(a*a);O>1&&(O=Math.sqrt(O),i*=O,a*=O);let M=i*i,B=a*a,F=M*B-M*R*R-B*L*L,P=M*R*R+B*L*L,z=(l===u?-1:1)*Math.sqrt(Math.abs(F/P));y=z*i*R/a+(t+r)/2,v=z*-a*L/i+(e+n)/2,m=Math.asin(parseFloat(((e-v)/a).toFixed(9))),g=Math.asin(parseFloat(((n-v)/a).toFixed(9))),tg&&(m-=2*Math.PI),!u&&g>m&&(g-=2*Math.PI)}let x=g-m;if(Math.abs(x)>120*Math.PI/180){let L=g,R=r,O=n;g=u&&g>m?m+120*Math.PI/180*1:m+120*Math.PI/180*-1,p=AK(r=y+i*Math.cos(g),n=v+a*Math.sin(g),R,O,i,a,s,0,u,[g,L,y,v])}x=g-m;let b=Math.cos(m),w=Math.sin(m),C=Math.cos(g),T=Math.sin(g),E=Math.tan(x/4),A=4/3*i*E,S=4/3*a*E,_=[t,e],I=[t+A*w,e-S*b],D=[r+A*T,n-S*C],k=[r,n];if(I[0]=2*_[0]-I[0],I[1]=2*_[1]-I[1],h)return[I,D,k].concat(p);{p=[I,D,k].concat(p);let L=[];for(let R=0;R2){let i=[];for(let a=0;a2*Math.PI&&(m=0,g=2*Math.PI);let y=2*Math.PI/u.curveStepCount,v=Math.min(y/2,(g-m)/2),x=kK(v,h,f,d,p,m,g,1,u);if(!u.disableMultiStroke){let b=kK(v,h,f,d,p,m,g,1.5,u);x.push(...b)}return s&&(l?x.push(...Uh(h,f,h+d*Math.cos(m),f+p*Math.sin(m),u),...Uh(h,f,h+d*Math.cos(g),f+p*Math.sin(g),u)):x.push({op:"lineTo",data:[h,f]},{op:"lineTo",data:[h+d*Math.cos(m),f+p*Math.sin(m)]})),{type:"path",ops:x}}function bK(t,e){let r=CK(SK(jD(t))),n=[],i=[0,0],a=[0,0];for(let{key:s,data:l}of r)switch(s){case"M":a=[l[0],l[1]],i=[l[0],l[1]];break;case"L":n.push(...Uh(a[0],a[1],l[0],l[1],e)),a=[l[0],l[1]];break;case"C":{let[u,h,f,d,p,m]=l;n.push(...a_e(u,h,f,d,p,m,a,e)),a=[p,m];break}case"Z":n.push(...Uh(a[0],a[1],i[0],i[1],e)),a=[i[0],i[1]]}return{type:"path",ops:n}}function PD(t,e){let r=[];for(let n of t)if(n.length){let i=e.maxRandomnessOffset||0,a=n.length;if(a>2){r.push({op:"move",data:[n[0][0]+nr(i,e),n[0][1]+nr(i,e)]});for(let s=1;s500?.4:-.0016668*u+1.233334;let f=i.maxRandomnessOffset||0;f*f*100>l&&(f=u/10);let d=f/2,p=.2+.2*LK(i),m=i.bowing*i.maxRandomnessOffset*(n-e)/200,g=i.bowing*i.maxRandomnessOffset*(t-r)/200;m=nr(m,i,h),g=nr(g,i,h);let y=[],v=o(()=>nr(d,i,h),"M"),x=o(()=>nr(f,i,h),"k"),b=i.preserveVertices;return a&&(s?y.push({op:"move",data:[t+(b?0:v()),e+(b?0:v())]}):y.push({op:"move",data:[t+(b?0:nr(f,i,h)),e+(b?0:nr(f,i,h))]})),s?y.push({op:"bcurveTo",data:[m+t+(r-t)*p+v(),g+e+(n-e)*p+v(),m+t+2*(r-t)*p+v(),g+e+2*(n-e)*p+v(),r+(b?0:v()),n+(b?0:v())]}):y.push({op:"bcurveTo",data:[m+t+(r-t)*p+x(),g+e+(n-e)*p+x(),m+t+2*(r-t)*p+x(),g+e+2*(n-e)*p+x(),r+(b?0:x()),n+(b?0:x())]}),y}function Mw(t,e,r){if(!t.length)return[];let n=[];n.push([t[0][0]+nr(e,r),t[0][1]+nr(e,r)]),n.push([t[0][0]+nr(e,r),t[0][1]+nr(e,r)]);for(let i=1;i3){let a=[],s=1-r.curveTightness;i.push({op:"move",data:[t[1][0],t[1][1]]});for(let l=1;l+21&&i.push(l)):i.push(l),i.push(t[e+3])}else{let u=t[e+0],h=t[e+1],f=t[e+2],d=t[e+3],p=Od(u,h,.5),m=Od(h,f,.5),g=Od(f,d,.5),y=Od(p,m,.5),v=Od(m,g,.5),x=Od(y,v,.5);qD([u,p,y,x],0,r,i),qD([x,v,g,d],0,r,i)}var a,s;return i}function o_e(t,e){return $w(t,0,t.length,e)}function $w(t,e,r,n,i){let a=i||[],s=t[e],l=t[r-1],u=0,h=1;for(let f=e+1;fu&&(u=d,h=f)}return Math.sqrt(u)>n?($w(t,e,h+1,n,a),$w(t,h,r,n,a)):(a.length||a.push(s),a.push(l)),a}function BD(t,e=.15,r){let n=[],i=(t.length-1)/3;for(let a=0;a0?$w(n,0,n.length,r):n}var v2,FD,$D,zD,GD,VD,Rs,UD,r_e,ID,yK,Nw,n_e,ro,pm,YD,Iw,XD,Xe,Wt=N(()=>{"use strict";o(MD,"t");o(e_e,"e");o(t_e,"s");o(x2,"n");v2=class{static{o(this,"o")}constructor(e){this.helper=e}fillPolygons(e,r){return this._fillPolygons(e,r)}_fillPolygons(e,r){let n=x2(e,r);return{type:"fillSketch",ops:this.renderLines(n,r)}}renderLines(e,r){let n=[];for(let i of e)n.push(...this.helper.doubleLineOps(i[0][0],i[0][1],i[1][0],i[1][1],r));return n}};o(zw,"a");FD=class extends v2{static{o(this,"h")}fillPolygons(e,r){let n=r.hachureGap;n<0&&(n=4*r.strokeWidth),n=Math.max(n,.1);let i=x2(e,Object.assign({},r,{hachureGap:n})),a=Math.PI/180*r.hachureAngle,s=[],l=.5*n*Math.cos(a),u=.5*n*Math.sin(a);for(let[h,f]of i)zw([h,f])&&s.push([[h[0]-l,h[1]+u],[...f]],[[h[0]+l,h[1]-u],[...f]]);return{type:"fillSketch",ops:this.renderLines(s,r)}}},$D=class extends v2{static{o(this,"r")}fillPolygons(e,r){let n=this._fillPolygons(e,r),i=Object.assign({},r,{hachureAngle:r.hachureAngle+90}),a=this._fillPolygons(e,i);return n.ops=n.ops.concat(a.ops),n}},zD=class{static{o(this,"i")}constructor(e){this.helper=e}fillPolygons(e,r){let n=x2(e,r=Object.assign({},r,{hachureAngle:0}));return this.dotsOnLines(n,r)}dotsOnLines(e,r){let n=[],i=r.hachureGap;i<0&&(i=4*r.strokeWidth),i=Math.max(i,.1);let a=r.fillWeight;a<0&&(a=r.strokeWidth/2);let s=i/4;for(let l of e){let u=zw(l),h=u/i,f=Math.ceil(h)-1,d=u-f*i,p=(l[0][0]+l[1][0])/2-i/4,m=Math.min(l[0][1],l[1][1]);for(let g=0;g{let l=zw(s),u=Math.floor(l/(n+i)),h=(l+i-u*(n+i))/2,f=s[0],d=s[1];f[0]>d[0]&&(f=s[1],d=s[0]);let p=Math.atan((d[1]-f[1])/(d[0]-f[0]));for(let m=0;m{let s=zw(a),l=Math.round(s/(2*r)),u=a[0],h=a[1];u[0]>h[0]&&(u=a[1],h=a[0]);let f=Math.atan((h[1]-u[1])/(h[0]-u[0]));for(let d=0;d2*Math.PI&&(A=0,S=2*Math.PI);let _=(S-A)/b.curveStepCount,I=[];for(let D=A;D<=S;D+=_)I.push([w+T*Math.cos(D),C+E*Math.sin(D)]);return I.push([w+T*Math.cos(S),C+E*Math.sin(S)]),I.push([w,C]),dm([I],b)}(e,r,n,i,a,s,h));return h.stroke!==ro&&f.push(d),this._d("arc",f,h)}curve(e,r){let n=this._o(r),i=[],a=vK(e,n);if(n.fill&&n.fill!==ro)if(n.fillStyle==="solid"){let s=vK(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(s.ops)})}else{let s=[],l=e;if(l.length){let u=typeof l[0][0]=="number"?[l]:l;for(let h of u)h.length<3?s.push(...h):h.length===3?s.push(...BD(EK([h[0],h[0],h[1],h[2]]),10,(1+n.roughness)/2)):s.push(...BD(EK(h),10,(1+n.roughness)/2))}s.length&&i.push(dm([s],n))}return n.stroke!==ro&&i.push(a),this._d("curve",i,n)}polygon(e,r){let n=this._o(r),i=[],a=Ow(e,!0,n);return n.fill&&(n.fillStyle==="solid"?i.push(PD([e],n)):i.push(dm([e],n))),n.stroke!==ro&&i.push(a),this._d("polygon",i,n)}path(e,r){let n=this._o(r),i=[];if(!e)return this._d("path",i,n);e=(e||"").replace(/\n/g," ").replace(/(-\s)/g,"-").replace("/(ss)/g"," ");let a=n.fill&&n.fill!=="transparent"&&n.fill!==ro,s=n.stroke!==ro,l=!!(n.simplification&&n.simplification<1),u=function(f,d,p){let m=CK(SK(jD(f))),g=[],y=[],v=[0,0],x=[],b=o(()=>{x.length>=4&&y.push(...BD(x,d)),x=[]},"i"),w=o(()=>{b(),y.length&&(g.push(y),y=[])},"c");for(let{key:T,data:E}of m)switch(T){case"M":w(),v=[E[0],E[1]],y.push(v);break;case"L":b(),y.push([E[0],E[1]]);break;case"C":if(!x.length){let A=y.length?y[y.length-1]:v;x.push([A[0],A[1]])}x.push([E[0],E[1]]),x.push([E[2],E[3]]),x.push([E[4],E[5]]);break;case"Z":b(),y.push([v[0],v[1]])}if(w(),!p)return g;let C=[];for(let T of g){let E=o_e(T,p);E.length&&C.push(E)}return C}(e,1,l?4-4*(n.simplification||1):(1+n.roughness)/2),h=bK(e,n);if(a)if(n.fillStyle==="solid")if(u.length===1){let f=bK(e,Object.assign(Object.assign({},n),{disableMultiStroke:!0,roughness:n.roughness?n.roughness+n.fillShapeRoughnessGain:0}));i.push({type:"fillPath",ops:this._mergedShape(f.ops)})}else i.push(PD(u,n));else i.push(dm(u,n));return s&&(l?u.forEach(f=>{i.push(Ow(f,!1,n))}):i.push(h)),this._d("path",i,n)}opsToPath(e,r){let n="";for(let i of e.ops){let a=typeof r=="number"&&r>=0?i.data.map(s=>+s.toFixed(r)):i.data;switch(i.op){case"move":n+=`M${a[0]} ${a[1]} `;break;case"bcurveTo":n+=`C${a[0]} ${a[1]}, ${a[2]} ${a[3]}, ${a[4]} ${a[5]} `;break;case"lineTo":n+=`L${a[0]} ${a[1]} `}}return n.trim()}toPaths(e){let r=e.sets||[],n=e.options||this.defaultOptions,i=[];for(let a of r){let s=null;switch(a.type){case"path":s={d:this.opsToPath(a),stroke:n.stroke,strokeWidth:n.strokeWidth,fill:ro};break;case"fillPath":s={d:this.opsToPath(a),stroke:ro,strokeWidth:0,fill:n.fill||ro};break;case"fillSketch":s=this.fillSketch(a,n)}s&&i.push(s)}return i}fillSketch(e,r){let n=r.fillWeight;return n<0&&(n=r.strokeWidth/2),{d:this.opsToPath(e),stroke:r.fill||ro,strokeWidth:n,fill:ro}}_mergedShape(e){return e.filter((r,n)=>n===0||r.op!=="move")}},YD=class{static{o(this,"st")}constructor(e,r){this.canvas=e,this.ctx=this.canvas.getContext("2d"),this.gen=new pm(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.ctx,a=e.options.fixedDecimalPlaceDigits;for(let s of r)switch(s.type){case"path":i.save(),i.strokeStyle=n.stroke==="none"?"transparent":n.stroke,i.lineWidth=n.strokeWidth,n.strokeLineDash&&i.setLineDash(n.strokeLineDash),n.strokeLineDashOffset&&(i.lineDashOffset=n.strokeLineDashOffset),this._drawToContext(i,s,a),i.restore();break;case"fillPath":{i.save(),i.fillStyle=n.fill||"";let l=e.shape==="curve"||e.shape==="polygon"||e.shape==="path"?"evenodd":"nonzero";this._drawToContext(i,s,a,l),i.restore();break}case"fillSketch":this.fillSketch(i,s,n)}}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2),e.save(),n.fillLineDash&&e.setLineDash(n.fillLineDash),n.fillLineDashOffset&&(e.lineDashOffset=n.fillLineDashOffset),e.strokeStyle=n.fill||"",e.lineWidth=i,this._drawToContext(e,r,n.fixedDecimalPlaceDigits),e.restore()}_drawToContext(e,r,n,i="nonzero"){e.beginPath();for(let a of r.ops){let s=typeof n=="number"&&n>=0?a.data.map(l=>+l.toFixed(n)):a.data;switch(a.op){case"move":e.moveTo(s[0],s[1]);break;case"bcurveTo":e.bezierCurveTo(s[0],s[1],s[2],s[3],s[4],s[5]);break;case"lineTo":e.lineTo(s[0],s[1])}}r.type==="fillPath"?e.fill(i):e.stroke()}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s),s}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s),s}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s),s}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a),a}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n),n}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n),n}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h),h}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n),n}path(e,r){let n=this.gen.path(e,r);return this.draw(n),n}},Iw="http://www.w3.org/2000/svg",XD=class{static{o(this,"ot")}constructor(e,r){this.svg=e,this.gen=new pm(r)}draw(e){let r=e.sets||[],n=e.options||this.getDefaultOptions(),i=this.svg.ownerDocument||window.document,a=i.createElementNS(Iw,"g"),s=e.options.fixedDecimalPlaceDigits;for(let l of r){let u=null;switch(l.type){case"path":u=i.createElementNS(Iw,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke",n.stroke),u.setAttribute("stroke-width",n.strokeWidth+""),u.setAttribute("fill","none"),n.strokeLineDash&&u.setAttribute("stroke-dasharray",n.strokeLineDash.join(" ").trim()),n.strokeLineDashOffset&&u.setAttribute("stroke-dashoffset",`${n.strokeLineDashOffset}`);break;case"fillPath":u=i.createElementNS(Iw,"path"),u.setAttribute("d",this.opsToPath(l,s)),u.setAttribute("stroke","none"),u.setAttribute("stroke-width","0"),u.setAttribute("fill",n.fill||""),e.shape!=="curve"&&e.shape!=="polygon"||u.setAttribute("fill-rule","evenodd");break;case"fillSketch":u=this.fillSketch(i,l,n)}u&&a.appendChild(u)}return a}fillSketch(e,r,n){let i=n.fillWeight;i<0&&(i=n.strokeWidth/2);let a=e.createElementNS(Iw,"path");return a.setAttribute("d",this.opsToPath(r,n.fixedDecimalPlaceDigits)),a.setAttribute("stroke",n.fill||""),a.setAttribute("stroke-width",i+""),a.setAttribute("fill","none"),n.fillLineDash&&a.setAttribute("stroke-dasharray",n.fillLineDash.join(" ").trim()),n.fillLineDashOffset&&a.setAttribute("stroke-dashoffset",`${n.fillLineDashOffset}`),a}get generator(){return this.gen}getDefaultOptions(){return this.gen.defaultOptions}opsToPath(e,r){return this.gen.opsToPath(e,r)}line(e,r,n,i,a){let s=this.gen.line(e,r,n,i,a);return this.draw(s)}rectangle(e,r,n,i,a){let s=this.gen.rectangle(e,r,n,i,a);return this.draw(s)}ellipse(e,r,n,i,a){let s=this.gen.ellipse(e,r,n,i,a);return this.draw(s)}circle(e,r,n,i){let a=this.gen.circle(e,r,n,i);return this.draw(a)}linearPath(e,r){let n=this.gen.linearPath(e,r);return this.draw(n)}polygon(e,r){let n=this.gen.polygon(e,r);return this.draw(n)}arc(e,r,n,i,a,s,l=!1,u){let h=this.gen.arc(e,r,n,i,a,s,l,u);return this.draw(h)}curve(e,r){let n=this.gen.curve(e,r);return this.draw(n)}path(e,r){let n=this.gen.path(e,r);return this.draw(n)}},Xe={canvas:o((t,e)=>new YD(t,e),"canvas"),svg:o((t,e)=>new XD(t,e),"svg"),generator:o(t=>new pm(t),"generator"),newSeed:o(()=>pm.newSeed(),"newSeed")}});function RK(t,e){let{labelStyles:r}=Qe(e);e.labelStyle=r;let n=ht(e),i=n;n||(i="anchor");let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=1,{cssStyles:l}=e,u=Xe.svg(a),h=Ke(e,{fill:"black",stroke:"none",fillStyle:"solid"});e.look!=="handDrawn"&&(h.roughness=0);let f=u.circle(0,0,s*2,h),d=a.insert(()=>f,":first-child");return d.attr("class","anchor").attr("style",$n(l)),je(e,d),e.intersect=function(p){return Y.info("Circle intersect",e,s,p),Ye.circle(e,s,p)},a}var NK=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();ir();o(RK,"anchor")});function MK(t,e,r,n,i,a,s){let u=(t+r)/2,h=(e+n)/2,f=Math.atan2(n-e,r-t),d=(r-t)/2,p=(n-e)/2,m=d/i,g=p/a,y=Math.sqrt(m**2+g**2);if(y>1)throw new Error("The given radii are too small to create an arc between the points.");let v=Math.sqrt(1-y**2),x=u+v*a*Math.sin(f)*(s?-1:1),b=h-v*i*Math.cos(f)*(s?-1:1),w=Math.atan2((e-b)/a,(t-x)/i),T=Math.atan2((n-b)/a,(r-x)/i)-w;s&&T<0&&(T+=2*Math.PI),!s&&T>0&&(T-=2*Math.PI);let E=[];for(let A=0;A<20;A++){let S=A/19,_=w+S*T,I=x+i*Math.cos(_),D=b+a*Math.sin(_);E.push({x:I,y:D})}return E}async function IK(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=a.width+e.padding+20,l=a.height+e.padding,u=l/2,h=u/(2.5+l/50),{cssStyles:f}=e,d=[{x:s/2,y:-l/2},{x:-s/2,y:-l/2},...MK(-s/2,-l/2,-s/2,l/2,h,u,!1),{x:s/2,y:l/2},...MK(s/2,l/2,s/2,-l/2,h,u,!0)],p=Xe.svg(i),m=Ke(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Xt(d),y=p.path(g,m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(${h/2}, 0)`),je(e,v),e.intersect=function(x){return Ye.polygon(e,d,x)},i}var OK=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(MK,"generateArcPoints");o(IK,"bowTieRect")});function La(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var _u=N(()=>{"use strict";o(La,"insertPolygonShape")});async function PK(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=a.height+e.padding,l=12,u=a.width+e.padding+l,h=0,f=u,d=-s,p=0,m=[{x:h+l,y:d},{x:f,y:d},{x:f,y:p},{x:h,y:p},{x:h,y:d+l},{x:h+l,y:d}],g,{cssStyles:y}=e;if(e.look==="handDrawn"){let v=Xe.svg(i),x=Ke(e,{}),b=Xt(m),w=v.path(b,x);g=i.insert(()=>w,":first-child").attr("transform",`translate(${-u/2}, ${s/2})`),y&&g.attr("style",y)}else g=La(i,u,s,m);return n&&g.attr("style",n),je(e,g),e.intersect=function(v){return Ye.polygon(e,m,v)},i}var BK=N(()=>{"use strict";Ft();Ht();Ut();Wt();_u();Ft();o(PK,"card")});function FK(t,e){let{nodeStyles:r}=Qe(e);e.label="";let n=t.insert("g").attr("class",ht(e)).attr("id",e.domId??e.id),{cssStyles:i}=e,a=Math.max(28,e.width??0),s=[{x:0,y:a/2},{x:a/2,y:0},{x:0,y:-a/2},{x:-a/2,y:0}],l=Xe.svg(n),u=Ke(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=Xt(s),f=l.path(h,u),d=n.insert(()=>f,":first-child");return i&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",i),r&&e.look!=="handDrawn"&&d.selectAll("path").attr("style",r),e.width=28,e.height=28,e.intersect=function(p){return Ye.polygon(e,s,p)},n}var $K=N(()=>{"use strict";Ht();Wt();Ut();Ft();o(FK,"choice")});async function zK(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s}=await pt(t,e,ht(e)),l=a.width/2+s,u,{cssStyles:h}=e;if(e.look==="handDrawn"){let f=Xe.svg(i),d=Ke(e,{}),p=f.circle(0,0,l*2,d);u=i.insert(()=>p,":first-child"),u.attr("class","basic label-container").attr("style",$n(h))}else u=i.insert("circle",":first-child").attr("class","basic label-container").attr("style",n).attr("r",l).attr("cx",0).attr("cy",0);return je(e,u),e.intersect=function(f){return Y.info("Circle intersect",e,l,f),Ye.circle(e,l,f)},i}var GK=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();ir();o(zK,"circle")});function l_e(t){let e=Math.cos(Math.PI/4),r=Math.sin(Math.PI/4),n=t*2,i={x:n/2*e,y:n/2*r},a={x:-(n/2)*e,y:n/2*r},s={x:-(n/2)*e,y:-(n/2)*r},l={x:n/2*e,y:-(n/2)*r};return`M ${a.x},${a.y} L ${l.x},${l.y} + M ${i.x},${i.y} L ${s.x},${s.y}`}function VK(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r,e.label="";let i=t.insert("g").attr("class",ht(e)).attr("id",e.domId??e.id),a=Math.max(30,e?.width??0),{cssStyles:s}=e,l=Xe.svg(i),u=Ke(e,{});e.look!=="handDrawn"&&(u.roughness=0,u.fillStyle="solid");let h=l.circle(0,0,a*2,u),f=l_e(a),d=l.path(f,u),p=i.insert(()=>h,":first-child");return p.insert(()=>d),s&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",s),n&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",n),je(e,p),e.intersect=function(m){return Y.info("crossedCircle intersect",e,{radius:a,point:m}),Ye.circle(e,a,m)},i}var UK=N(()=>{"use strict";vt();Ft();Ut();Wt();Ht();o(l_e,"createLine");o(VK,"crossedCircle")});function Hh(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dw,":first-child").attr("stroke-opacity",0),C.insert(()=>x,":first-child"),C.attr("class","text"),f&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(${h}, 0)`),s.attr("transform",`translate(${-l/2+h-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),je(e,C),e.intersect=function(T){return Ye.polygon(e,p,T)},i}var WK=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(Hh,"generateCirclePoints");o(HK,"curlyBraceLeft")});function Wh(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dw,":first-child").attr("stroke-opacity",0),C.insert(()=>x,":first-child"),C.attr("class","text"),f&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(${-h}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),je(e,C),e.intersect=function(T){return Ye.polygon(e,p,T)},i}var YK=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(Wh,"generateCirclePoints");o(qK,"curlyBraceRight")});function Ra(t,e,r,n=100,i=0,a=180){let s=[],l=i*Math.PI/180,f=(a*Math.PI/180-l)/(n-1);for(let d=0;dA,":first-child").attr("stroke-opacity",0),S.insert(()=>b,":first-child"),S.insert(()=>T,":first-child"),S.attr("class","text"),f&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",f),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(${h-h/4}, 0)`),s.attr("transform",`translate(${-l/2+(e.padding??0)/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)/2-(a.y-(a.top??0))})`),je(e,S),e.intersect=function(_){return Ye.polygon(e,m,_)},i}var jK=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(Ra,"generateCirclePoints");o(XK,"curlyBraces")});async function KK(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=80,l=20,u=Math.max(s,(a.width+(e.padding??0)*2)*1.25,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Xe.svg(i),m=Ke(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=u,y=h,v=g-f,x=y/4,b=[{x:v,y:0},{x,y:0},{x:0,y:y/2},{x,y},{x:v,y},...Lw(-v,-y/2,f,50,270,90)],w=Xt(b),C=p.path(w,m),T=i.insert(()=>C,":first-child");return T.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&T.selectChildren("path").attr("style",n),T.attr("transform",`translate(${-u/2}, ${-h/2})`),je(e,T),e.intersect=function(E){return Ye.polygon(e,b,E)},i}var QK=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(KK,"curvedTrapezoid")});async function ZK(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+e.padding,e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+e.padding,e.height??0),d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Xe.svg(i),g=u_e(0,0,l,f,u,h),y=h_e(0,h,l,f,u,h),v=m.path(g,Ke(e,{})),x=m.path(y,Ke(e,{fill:"none"}));d=i.insert(()=>x,":first-child"),d=i.insert(()=>v,":first-child"),d.attr("class","basic label-container"),p&&d.attr("style",p)}else{let m=c_e(0,0,l,f,u,h);d=i.insert("path",":first-child").attr("d",m).attr("class","basic label-container").attr("style",$n(p)).attr("style",n)}return d.attr("label-offset-y",h),d.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),je(e,d),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+(e.padding??0)/1.5-(a.y-(a.top??0))})`),e.intersect=function(m){let g=Ye.rect(e,m),y=g.x-(e.x??0);if(u!=0&&(Math.abs(y)<(e.width??0)/2||Math.abs(y)==(e.width??0)/2&&Math.abs(g.y-(e.y??0))>(e.height??0)/2-h)){let v=h*h*(1-y*y/(u*u));v>0&&(v=Math.sqrt(v)),v=h-v,m.y-(e.y??0)>0&&(v=-v),g.y+=v}return g},i}var c_e,u_e,h_e,JK=N(()=>{"use strict";Ft();Ht();Ut();Wt();ir();c_e=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createCylinderPathD"),u_e=o((t,e,r,n,i,a)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`].join(" "),"createOuterCylinderPathD"),h_e=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(ZK,"cylinder")});async function eQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=a.width+e.padding,u=a.height+e.padding,h=u*.2,f=-l/2,d=-u/2-h/2,{cssStyles:p}=e,m=Xe.svg(i),g=Ke(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d+h},{x:-f,y:d+h},{x:-f,y:-d},{x:f,y:-d},{x:f,y:d},{x:-f,y:d},{x:-f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${f+(e.padding??0)/2-(a.x-(a.left??0))}, ${d+h+(e.padding??0)/2-(a.y-(a.top??0))})`),je(e,x),e.intersect=function(b){return Ye.rect(e,b)},i}var tQ=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(eQ,"dividedRectangle")});async function rQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,halfPadding:s}=await pt(t,e,ht(e)),u=a.width/2+s+5,h=a.width/2+s,f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Xe.svg(i),m=Ke(e,{roughness:.2,strokeWidth:2.5}),g=Ke(e,{roughness:.2,strokeWidth:1.5}),y=p.circle(0,0,u*2,m),v=p.circle(0,0,h*2,g);f=i.insert("g",":first-child"),f.attr("class",$n(e.cssClasses)).attr("style",$n(d)),f.node()?.appendChild(y),f.node()?.appendChild(v)}else{f=i.insert("g",":first-child");let p=f.insert("circle",":first-child"),m=f.insert("circle");f.attr("class","basic label-container").attr("style",n),p.attr("class","outer-circle").attr("style",n).attr("r",u).attr("cx",0).attr("cy",0),m.attr("class","inner-circle").attr("style",n).attr("r",h).attr("cx",0).attr("cy",0)}return je(e,f),e.intersect=function(p){return Y.info("DoubleCircle intersect",e,u,p),Ye.circle(e,u,p)},i}var nQ=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();ir();o(rQ,"doublecircle")});function iQ(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=Qe(e);e.label="",e.labelStyle=n;let a=t.insert("g").attr("class",ht(e)).attr("id",e.domId??e.id),s=7,{cssStyles:l}=e,u=Xe.svg(a),{nodeBorder:h}=r,f=Ke(e,{fillStyle:"solid"});e.look!=="handDrawn"&&(f.roughness=0);let d=u.circle(0,0,s*2,f),p=a.insert(()=>d,":first-child");return p.selectAll("path").attr("style",`fill: ${h} !important;`),l&&l.length>0&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",l),i&&e.look!=="handDrawn"&&p.selectAll("path").attr("style",i),je(e,p),e.intersect=function(m){return Y.info("filledCircle intersect",e,{radius:s,point:m}),Ye.circle(e,s,m)},a}var aQ=N(()=>{"use strict";Wt();vt();Ht();Ut();Ft();o(iQ,"filledCircle")});async function sQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=a.width+(e.padding??0),u=l+a.height,h=l+a.height,f=[{x:0,y:-u},{x:h,y:-u},{x:h/2,y:0}],{cssStyles:d}=e,p=Xe.svg(i),m=Ke(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=Xt(f),y=p.path(g,m),v=i.insert(()=>y,":first-child").attr("transform",`translate(${-u/2}, ${u/2})`);return d&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),e.width=l,e.height=u,je(e,v),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${-u/2+(e.padding??0)/2+(a.y-(a.top??0))})`),e.intersect=function(x){return Y.info("Triangle intersect",e,f,x),Ye.polygon(e,f,x)},i}var oQ=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();Ft();o(sQ,"flippedTriangle")});function lQ(t,e,{dir:r,config:{state:n,themeVariables:i}}){let{nodeStyles:a}=Qe(e);e.label="";let s=t.insert("g").attr("class",ht(e)).attr("id",e.domId??e.id),{cssStyles:l}=e,u=Math.max(70,e?.width??0),h=Math.max(10,e?.height??0);r==="LR"&&(u=Math.max(10,e?.width??0),h=Math.max(70,e?.height??0));let f=-1*u/2,d=-1*h/2,p=Xe.svg(s),m=Ke(e,{stroke:i.lineColor,fill:i.lineColor});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=p.rectangle(f,d,u,h,m),y=s.insert(()=>g,":first-child");l&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",l),a&&e.look!=="handDrawn"&&y.selectAll("path").attr("style",a),je(e,y);let v=n?.padding??0;return e.width&&e.height&&(e.width+=v/2||0,e.height+=v/2||0),e.intersect=function(x){return Ye.rect(e,x)},s}var cQ=N(()=>{"use strict";Wt();Ht();Ut();Ft();o(lQ,"forkJoin")});async function uQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let i=80,a=50,{shapeSvg:s,bbox:l}=await pt(t,e,ht(e)),u=Math.max(i,l.width+(e.padding??0)*2,e?.width??0),h=Math.max(a,l.height+(e.padding??0)*2,e?.height??0),f=h/2,{cssStyles:d}=e,p=Xe.svg(s),m=Ke(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-u/2,y:-h/2},{x:u/2-f,y:-h/2},...Lw(-u/2+f,0,f,50,90,270),{x:u/2-f,y:h/2},{x:-u/2,y:h/2}],y=Xt(g),v=p.path(y,m),x=s.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),je(e,x),e.intersect=function(b){return Y.info("Pill intersect",e,{radius:f,point:b}),Ye.polygon(e,g,b)},s}var hQ=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();o(uQ,"halfRoundedRectangle")});async function fQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=4,l=a.height+e.padding,u=l/s,h=a.width+2*u+e.padding,f=[{x:u,y:0},{x:h-u,y:0},{x:h,y:-l/2},{x:h-u,y:-l},{x:u,y:-l},{x:0,y:-l/2}],d,{cssStyles:p}=e;if(e.look==="handDrawn"){let m=Xe.svg(i),g=Ke(e,{}),y=f_e(0,0,h,l,u),v=m.path(y,g);d=i.insert(()=>v,":first-child").attr("transform",`translate(${-h/2}, ${l/2})`),p&&d.attr("style",p)}else d=La(i,h,l,f);return n&&d.attr("style",n),e.width=h,e.height=l,je(e,d),e.intersect=function(m){return Ye.polygon(e,f,m)},i}var f_e,dQ=N(()=>{"use strict";Ft();Ht();Ut();Wt();_u();f_e=o((t,e,r,n,i)=>[`M${t+i},${e}`,`L${t+r-i},${e}`,`L${t+r},${e-n/2}`,`L${t+r-i},${e-n}`,`L${t+i},${e-n}`,`L${t},${e-n/2}`,"Z"].join(" "),"createHexagonPathD");o(fQ,"hexagon")});async function pQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.label="",e.labelStyle=r;let{shapeSvg:i}=await pt(t,e,ht(e)),a=Math.max(30,e?.width??0),s=Math.max(30,e?.height??0),{cssStyles:l}=e,u=Xe.svg(i),h=Ke(e,{});e.look!=="handDrawn"&&(h.roughness=0,h.fillStyle="solid");let f=[{x:0,y:0},{x:a,y:0},{x:0,y:s},{x:a,y:s}],d=Xt(f),p=u.path(d,h),m=i.insert(()=>p,":first-child");return m.attr("class","basic label-container"),l&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",l),n&&e.look!=="handDrawn"&&m.selectChildren("path").attr("style",n),m.attr("transform",`translate(${-a/2}, ${-s/2})`),je(e,m),e.intersect=function(g){return Y.info("Pill intersect",e,{points:f}),Ye.polygon(e,f,g)},i}var mQ=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();o(pQ,"hourglass")});async function gQ(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=Qe(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await pt(t,e,"icon-shape default"),p=e.pos==="t",m=l,g=l,{nodeBorder:y}=r,{stylesMap:v}=mc(e),x=-g/2,b=-m/2,w=e.label?8:0,C=Xe.svg(h),T=Ke(e,{stroke:"none",fill:"none"});e.look!=="handDrawn"&&(T.roughness=0,T.fillStyle="solid");let E=C.rectangle(x,b,g,m,T),A=Math.max(g,f.width),S=m+f.height+w,_=C.rectangle(-A/2,-S/2,A,S,{...T,fill:"transparent",stroke:"none"}),I=h.insert(()=>E,":first-child"),D=h.insert(()=>_);if(e.icon){let k=h.append("g");k.html(`${await wo(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let L=k.node().getBBox(),R=L.width,O=L.height,M=L.x,B=L.y;k.attr("transform",`translate(${-R/2-M},${p?f.height/2+w/2-O/2-B:-f.height/2-w/2-O/2-B})`),k.attr("style",`color: ${v.get("stroke")??y};`)}return d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${p?-S/2:S/2-f.height})`),I.attr("transform",`translate(0,${p?f.height/2+w/2:-f.height/2-w/2})`),je(e,D),e.intersect=function(k){if(Y.info("iconSquare intersect",e,k),!e.label)return Ye.rect(e,k);let L=e.x??0,R=e.y??0,O=e.height??0,M=[];return p?M=[{x:L-f.width/2,y:R-O/2},{x:L+f.width/2,y:R-O/2},{x:L+f.width/2,y:R-O/2+f.height+w},{x:L+g/2,y:R-O/2+f.height+w},{x:L+g/2,y:R+O/2},{x:L-g/2,y:R+O/2},{x:L-g/2,y:R-O/2+f.height+w},{x:L-f.width/2,y:R-O/2+f.height+w}]:M=[{x:L-g/2,y:R-O/2},{x:L+g/2,y:R-O/2},{x:L+g/2,y:R-O/2+m},{x:L+f.width/2,y:R-O/2+m},{x:L+f.width/2/2,y:R+O/2},{x:L-f.width/2,y:R+O/2},{x:L-f.width/2,y:R-O/2+m},{x:L-g/2,y:R-O/2+m}],Ye.polygon(e,M,k)},h}var yQ=N(()=>{"use strict";Wt();vt();tu();Ht();Ut();Ft();o(gQ,"icon")});async function vQ(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=Qe(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,label:d}=await pt(t,e,"icon-shape default"),p=20,m=e.label?8:0,g=e.pos==="t",{nodeBorder:y,mainBkg:v}=r,{stylesMap:x}=mc(e),b=Xe.svg(h),w=Ke(e,{});e.look!=="handDrawn"&&(w.roughness=0,w.fillStyle="solid");let C=x.get("fill");w.stroke=C??v;let T=h.append("g");e.icon&&T.html(`${await wo(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let E=T.node().getBBox(),A=E.width,S=E.height,_=E.x,I=E.y,D=Math.max(A,S)*Math.SQRT2+p*2,k=b.circle(0,0,D,w),L=Math.max(D,f.width),R=D+f.height+m,O=b.rectangle(-L/2,-R/2,L,R,{...w,fill:"transparent",stroke:"none"}),M=h.insert(()=>k,":first-child"),B=h.insert(()=>O);return T.attr("transform",`translate(${-A/2-_},${g?f.height/2+m/2-S/2-I:-f.height/2-m/2-S/2-I})`),T.attr("style",`color: ${x.get("stroke")??y};`),d.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${g?-R/2:R/2-f.height})`),M.attr("transform",`translate(0,${g?f.height/2+m/2:-f.height/2-m/2})`),je(e,B),e.intersect=function(F){return Y.info("iconSquare intersect",e,F),Ye.rect(e,F)},h}var xQ=N(()=>{"use strict";Wt();vt();tu();Ht();Ut();Ft();o(vQ,"iconCircle")});var Na,qh=N(()=>{"use strict";Na=o((t,e,r,n,i)=>["M",t+i,e,"H",t+r-i,"A",i,i,0,0,1,t+r,e+i,"V",e+n-i,"A",i,i,0,0,1,t+r-i,e+n,"H",t+i,"A",i,i,0,0,1,t,e+n-i,"V",e+i,"A",i,i,0,0,1,t+i,e,"Z"].join(" "),"createRoundedRectPathD")});async function bQ(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=Qe(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await pt(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=mc(e),w=-y/2,C=-g/2,T=e.label?8:0,E=Xe.svg(h),A=Ke(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let S=b.get("fill");A.stroke=S??x;let _=E.path(Na(w,C,y,g,5),A),I=Math.max(y,f.width),D=g+f.height+T,k=E.rectangle(-I/2,-D/2,I,D,{...A,fill:"transparent",stroke:"none"}),L=h.insert(()=>_,":first-child").attr("class","icon-shape2"),R=h.insert(()=>k);if(e.icon){let O=h.append("g");O.html(`${await wo(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),B=M.width,F=M.height,P=M.x,z=M.y;O.attr("transform",`translate(${-B/2-P},${m?f.height/2+T/2-F/2-z:-f.height/2-T/2-F/2-z})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-D/2:D/2-f.height})`),L.attr("transform",`translate(0,${m?f.height/2+T/2:-f.height/2-T/2})`),je(e,R),e.intersect=function(O){if(Y.info("iconSquare intersect",e,O),!e.label)return Ye.rect(e,O);let M=e.x??0,B=e.y??0,F=e.height??0,P=[];return m?P=[{x:M-f.width/2,y:B-F/2},{x:M+f.width/2,y:B-F/2},{x:M+f.width/2,y:B-F/2+f.height+T},{x:M+y/2,y:B-F/2+f.height+T},{x:M+y/2,y:B+F/2},{x:M-y/2,y:B+F/2},{x:M-y/2,y:B-F/2+f.height+T},{x:M-f.width/2,y:B-F/2+f.height+T}]:P=[{x:M-y/2,y:B-F/2},{x:M+y/2,y:B-F/2},{x:M+y/2,y:B-F/2+g},{x:M+f.width/2,y:B-F/2+g},{x:M+f.width/2/2,y:B+F/2},{x:M-f.width/2,y:B+F/2},{x:M-f.width/2,y:B-F/2+g},{x:M-y/2,y:B-F/2+g}],Ye.polygon(e,P,O)},h}var wQ=N(()=>{"use strict";Wt();vt();tu();Ht();Ut();qh();Ft();o(bQ,"iconRounded")});async function TQ(t,e,{config:{themeVariables:r,flowchart:n}}){let{labelStyles:i}=Qe(e);e.labelStyle=i;let a=e.assetHeight??48,s=e.assetWidth??48,l=Math.max(a,s),u=n?.wrappingWidth;e.width=Math.max(l,u??0);let{shapeSvg:h,bbox:f,halfPadding:d,label:p}=await pt(t,e,"icon-shape default"),m=e.pos==="t",g=l+d*2,y=l+d*2,{nodeBorder:v,mainBkg:x}=r,{stylesMap:b}=mc(e),w=-y/2,C=-g/2,T=e.label?8:0,E=Xe.svg(h),A=Ke(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let S=b.get("fill");A.stroke=S??x;let _=E.path(Na(w,C,y,g,.1),A),I=Math.max(y,f.width),D=g+f.height+T,k=E.rectangle(-I/2,-D/2,I,D,{...A,fill:"transparent",stroke:"none"}),L=h.insert(()=>_,":first-child"),R=h.insert(()=>k);if(e.icon){let O=h.append("g");O.html(`${await wo(e.icon,{height:l,width:l,fallbackPrefix:""})}`);let M=O.node().getBBox(),B=M.width,F=M.height,P=M.x,z=M.y;O.attr("transform",`translate(${-B/2-P},${m?f.height/2+T/2-F/2-z:-f.height/2-T/2-F/2-z})`),O.attr("style",`color: ${b.get("stroke")??v};`)}return p.attr("transform",`translate(${-f.width/2-(f.x-(f.left??0))},${m?-D/2:D/2-f.height})`),L.attr("transform",`translate(0,${m?f.height/2+T/2:-f.height/2-T/2})`),je(e,R),e.intersect=function(O){if(Y.info("iconSquare intersect",e,O),!e.label)return Ye.rect(e,O);let M=e.x??0,B=e.y??0,F=e.height??0,P=[];return m?P=[{x:M-f.width/2,y:B-F/2},{x:M+f.width/2,y:B-F/2},{x:M+f.width/2,y:B-F/2+f.height+T},{x:M+y/2,y:B-F/2+f.height+T},{x:M+y/2,y:B+F/2},{x:M-y/2,y:B+F/2},{x:M-y/2,y:B-F/2+f.height+T},{x:M-f.width/2,y:B-F/2+f.height+T}]:P=[{x:M-y/2,y:B-F/2},{x:M+y/2,y:B-F/2},{x:M+y/2,y:B-F/2+g},{x:M+f.width/2,y:B-F/2+g},{x:M+f.width/2/2,y:B+F/2},{x:M-f.width/2,y:B+F/2},{x:M-f.width/2,y:B-F/2+g},{x:M-y/2,y:B-F/2+g}],Ye.polygon(e,P,O)},h}var kQ=N(()=>{"use strict";Wt();vt();tu();Ht();qh();Ut();Ft();o(TQ,"iconSquare")});async function EQ(t,e,{config:{flowchart:r}}){let n=new Image;n.src=e?.img??"",await n.decode();let i=Number(n.naturalWidth.toString().replace("px","")),a=Number(n.naturalHeight.toString().replace("px",""));e.imageAspectRatio=i/a;let{labelStyles:s}=Qe(e);e.labelStyle=s;let l=r?.wrappingWidth;e.defaultWidth=r?.wrappingWidth;let u=Math.max(e.label?l??0:0,e?.assetWidth??i),h=e.constraint==="on"&&e?.assetHeight?e.assetHeight*e.imageAspectRatio:u,f=e.constraint==="on"?h/e.imageAspectRatio:e?.assetHeight??a;e.width=Math.max(h,l??0);let{shapeSvg:d,bbox:p,label:m}=await pt(t,e,"image-shape default"),g=e.pos==="t",y=-h/2,v=-f/2,x=e.label?8:0,b=Xe.svg(d),w=Ke(e,{});e.look!=="handDrawn"&&(w.roughness=0,w.fillStyle="solid");let C=b.rectangle(y,v,h,f,w),T=Math.max(h,p.width),E=f+p.height+x,A=b.rectangle(-T/2,-E/2,T,E,{...w,fill:"none",stroke:"none"}),S=d.insert(()=>C,":first-child"),_=d.insert(()=>A);if(e.img){let I=d.append("image");I.attr("href",e.img),I.attr("width",h),I.attr("height",f),I.attr("preserveAspectRatio","none"),I.attr("transform",`translate(${-h/2},${g?E/2-f:-E/2})`)}return m.attr("transform",`translate(${-p.width/2-(p.x-(p.left??0))},${g?-f/2-p.height/2-x/2:f/2-p.height/2+x/2})`),S.attr("transform",`translate(0,${g?p.height/2+x/2:-p.height/2-x/2})`),je(e,_),e.intersect=function(I){if(Y.info("iconSquare intersect",e,I),!e.label)return Ye.rect(e,I);let D=e.x??0,k=e.y??0,L=e.height??0,R=[];return g?R=[{x:D-p.width/2,y:k-L/2},{x:D+p.width/2,y:k-L/2},{x:D+p.width/2,y:k-L/2+p.height+x},{x:D+h/2,y:k-L/2+p.height+x},{x:D+h/2,y:k+L/2},{x:D-h/2,y:k+L/2},{x:D-h/2,y:k-L/2+p.height+x},{x:D-p.width/2,y:k-L/2+p.height+x}]:R=[{x:D-h/2,y:k-L/2},{x:D+h/2,y:k-L/2},{x:D+h/2,y:k-L/2+f},{x:D+p.width/2,y:k-L/2+f},{x:D+p.width/2/2,y:k+L/2},{x:D-p.width/2,y:k+L/2},{x:D-p.width/2,y:k-L/2+f},{x:D-h/2,y:k-L/2+f}],Ye.polygon(e,R,I)},d}var SQ=N(()=>{"use strict";Wt();vt();Ht();Ut();Ft();o(EQ,"imageSquare")});async function CQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=[{x:0,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:-3*l/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Xe.svg(i),p=Ke(e,{}),m=Xt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=La(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,je(e,h),e.intersect=function(d){return Ye.polygon(e,u,d)},i}var AQ=N(()=>{"use strict";Ft();Ht();Ut();Wt();_u();o(CQ,"inv_trapezoid")});async function Du(t,e,r){let{labelStyles:n,nodeStyles:i}=Qe(e);e.labelStyle=n;let{shapeSvg:a,bbox:s}=await pt(t,e,ht(e)),l=Math.max(s.width+r.labelPaddingX*2,e?.width||0),u=Math.max(s.height+r.labelPaddingY*2,e?.height||0),h=-l/2,f=-u/2,d,{rx:p,ry:m}=e,{cssStyles:g}=e;if(r?.rx&&r.ry&&(p=r.rx,m=r.ry),e.look==="handDrawn"){let y=Xe.svg(a),v=Ke(e,{}),x=p||m?y.path(Na(h,f,l,u,p||0),v):y.rectangle(h,f,l,u,v);d=a.insert(()=>x,":first-child"),d.attr("class","basic label-container").attr("style",$n(g))}else d=a.insert("rect",":first-child"),d.attr("class","basic label-container").attr("style",i).attr("rx",$n(p)).attr("ry",$n(m)).attr("x",h).attr("y",f).attr("width",l).attr("height",u);return je(e,d),e.intersect=function(y){return Ye.rect(e,y)},a}var mm=N(()=>{"use strict";Ft();Ht();qh();Ut();Wt();ir();o(Du,"drawRect")});async function _Q(t,e){let{shapeSvg:r,bbox:n,label:i}=await pt(t,e,"label"),a=r.insert("rect",":first-child");return a.attr("width",.1).attr("height",.1),r.attr("class","label edgeLabel"),i.attr("transform",`translate(${-(n.width/2)-(n.x-(n.left??0))}, ${-(n.height/2)-(n.y-(n.top??0))})`),je(e,a),e.intersect=function(u){return Ye.rect(e,u)},r}var DQ=N(()=>{"use strict";mm();Ft();Ht();o(_Q,"labelRect")});async function LQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:0,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:-(3*l)/6,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Xe.svg(i),p=Ke(e,{}),m=Xt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=La(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,je(e,h),e.intersect=function(d){return Ye.polygon(e,u,d)},i}var RQ=N(()=>{"use strict";Ft();Ht();Ut();Wt();_u();o(LQ,"lean_left")});async function NQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=Math.max(a.width+(e.padding??0),e?.width??0),l=Math.max(a.height+(e.padding??0),e?.height??0),u=[{x:-3*l/6,y:0},{x:s,y:0},{x:s+3*l/6,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Xe.svg(i),p=Ke(e,{}),m=Xt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=La(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,je(e,h),e.intersect=function(d){return Ye.polygon(e,u,d)},i}var MQ=N(()=>{"use strict";Ft();Ht();Ut();Wt();_u();o(NQ,"lean_right")});function IQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.label="",e.labelStyle=r;let i=t.insert("g").attr("class",ht(e)).attr("id",e.domId??e.id),{cssStyles:a}=e,s=Math.max(35,e?.width??0),l=Math.max(35,e?.height??0),u=7,h=[{x:s,y:0},{x:0,y:l+u/2},{x:s-2*u,y:l+u/2},{x:0,y:2*l},{x:s,y:l-u/2},{x:2*u,y:l-u/2}],f=Xe.svg(i),d=Ke(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=Xt(h),m=f.path(p,d),g=i.insert(()=>m,":first-child");return a&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",a),n&&e.look!=="handDrawn"&&g.selectAll("path").attr("style",n),g.attr("transform",`translate(-${s/2},${-l})`),je(e,g),e.intersect=function(y){return Y.info("lightningBolt intersect",e,y),Ye.polygon(e,h,y)},i}var OQ=N(()=>{"use strict";vt();Ft();Ut();Wt();Ht();Ft();o(IQ,"lightningBolt")});async function PQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0),e.width??0),u=l/2,h=u/(2.5+l/50),f=Math.max(a.height+h+(e.padding??0),e.height??0),d=f*.1,p,{cssStyles:m}=e;if(e.look==="handDrawn"){let g=Xe.svg(i),y=p_e(0,0,l,f,u,h,d),v=m_e(0,h,l,f,u,h),x=Ke(e,{}),b=g.path(y,x),w=g.path(v,x);i.insert(()=>w,":first-child").attr("class","line"),p=i.insert(()=>b,":first-child"),p.attr("class","basic label-container"),m&&p.attr("style",m)}else{let g=d_e(0,0,l,f,u,h,d);p=i.insert("path",":first-child").attr("d",g).attr("class","basic label-container").attr("style",$n(m)).attr("style",n)}return p.attr("label-offset-y",h),p.attr("transform",`translate(${-l/2}, ${-(f/2+h)})`),je(e,p),s.attr("transform",`translate(${-(a.width/2)-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),e.intersect=function(g){let y=Ye.rect(e,g),v=y.x-(e.x??0);if(u!=0&&(Math.abs(v)<(e.width??0)/2||Math.abs(v)==(e.width??0)/2&&Math.abs(y.y-(e.y??0))>(e.height??0)/2-h)){let x=h*h*(1-v*v/(u*u));x>0&&(x=Math.sqrt(x)),x=h-x,g.y-(e.y??0)>0&&(x=-x),y.y+=x}return y},i}var d_e,p_e,m_e,BQ=N(()=>{"use strict";Ft();Ht();Ut();Wt();ir();d_e=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`a${i},${a} 0,0,0 ${r},0`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createCylinderPathD"),p_e=o((t,e,r,n,i,a,s)=>[`M${t},${e+a}`,`M${t+r},${e+a}`,`a${i},${a} 0,0,0 ${-r},0`,`l0,${n}`,`a${i},${a} 0,0,0 ${r},0`,`l0,${-n}`,`M${t},${e+a+s}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createOuterCylinderPathD"),m_e=o((t,e,r,n,i,a)=>[`M${t-r/2},${-n/2}`,`a${i},${a} 0,0,0 ${r},0`].join(" "),"createInnerCylinderPathD");o(PQ,"linedCylinder")});async function FQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,{cssStyles:d}=e,p=Xe.svg(i),m=Ke(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:-l/2-l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:f/2},...Fo(-l/2-l/2*.1,f/2,l/2+l/2*.1,f/2,h,.8),{x:l/2+l/2*.1,y:-f/2},{x:-l/2-l/2*.1,y:-f/2},{x:-l/2,y:-f/2},{x:-l/2,y:f/2*1.1},{x:-l/2,y:-f/2}],y=p.polygon(g.map(x=>[x.x,x.y]),m),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",n),v.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)+l/2*.1/2-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),je(e,v),e.intersect=function(x){return Ye.polygon(e,g,x)},i}var $Q=N(()=>{"use strict";Ft();Ht();Wt();Ut();o(FQ,"linedWaveEdgedRect")});async function zQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Xe.svg(i),g=Ke(e,{}),y=[{x:f-h,y:d+h},{x:f-h,y:d+u+h},{x:f+l-h,y:d+u+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d+u-h},{x:f+l+h,y:d+u-h},{x:f+l+h,y:d-h},{x:f+h,y:d-h},{x:f+h,y:d},{x:f,y:d},{x:f,y:d+h}],v=[{x:f,y:d+h},{x:f+l-h,y:d+h},{x:f+l-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d},{x:f,y:d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Xt(y),b=m.path(x,g),w=Xt(v),C=m.path(w,{...g,fill:"none"}),T=i.insert(()=>C,":first-child");return T.insert(()=>b,":first-child"),T.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&T.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&T.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)-h-(a.x-(a.left??0))}, ${-(a.height/2)+h-(a.y-(a.top??0))})`),je(e,T),e.intersect=function(E){return Ye.polygon(e,y,E)},i}var GQ=N(()=>{"use strict";Ft();Ut();Wt();Ht();o(zQ,"multiRect")});async function VQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=u+h,d=-l/2,p=-f/2,m=5,{cssStyles:g}=e,y=Fo(d-m,p+f+m,d+l-m,p+f+m,h,.8),v=y?.[y.length-1],x=[{x:d-m,y:p+m},{x:d-m,y:p+f+m},...y,{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:v.y-2*m},{x:d+l+m,y:v.y-2*m},{x:d+l+m,y:p-m},{x:d+m,y:p-m},{x:d+m,y:p},{x:d,y:p},{x:d,y:p+m}],b=[{x:d,y:p+m},{x:d+l-m,y:p+m},{x:d+l-m,y:v.y-m},{x:d+l,y:v.y-m},{x:d+l,y:p},{x:d,y:p}],w=Xe.svg(i),C=Ke(e,{});e.look!=="handDrawn"&&(C.roughness=0,C.fillStyle="solid");let T=Xt(x),E=w.path(T,C),A=Xt(b),S=w.path(A,C),_=i.insert(()=>E,":first-child");return _.insert(()=>S),_.attr("class","basic label-container"),g&&e.look!=="handDrawn"&&_.selectAll("path").attr("style",g),n&&e.look!=="handDrawn"&&_.selectAll("path").attr("style",n),_.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-(a.width/2)-m-(a.x-(a.left??0))}, ${-(a.height/2)+m-h/2-(a.y-(a.top??0))})`),je(e,_),e.intersect=function(I){return Ye.polygon(e,x,I)},i}var UQ=N(()=>{"use strict";Ft();Ht();Wt();Ut();o(VQ,"multiWaveEdgedRectangle")});async function HQ(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=Qe(e);e.labelStyle=n,e.useHtmlLabels||cr().flowchart?.htmlLabels!==!1||(e.centerLabel=!0);let{shapeSvg:s,bbox:l}=await pt(t,e,ht(e)),u=Math.max(l.width+(e.padding??0)*2,e?.width??0),h=Math.max(l.height+(e.padding??0)*2,e?.height??0),f=-u/2,d=-h/2,{cssStyles:p}=e,m=Xe.svg(s),g=Ke(e,{fill:r.noteBkgColor,stroke:r.noteBorderColor});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=m.rectangle(f,d,u,h,g),v=s.insert(()=>y,":first-child");return v.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",p),i&&e.look!=="handDrawn"&&v.selectAll("path").attr("style",i),je(e,v),e.intersect=function(x){return Ye.rect(e,x)},s}var WQ=N(()=>{"use strict";Wt();Ht();Ut();Ft();ji();o(HQ,"note")});async function qQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=a.width+e.padding,l=a.height+e.padding,u=s+l,h=[{x:u/2,y:0},{x:u,y:-u/2},{x:u/2,y:-u},{x:0,y:-u/2}],f,{cssStyles:d}=e;if(e.look==="handDrawn"){let p=Xe.svg(i),m=Ke(e,{}),g=g_e(0,0,u),y=p.path(g,m);f=i.insert(()=>y,":first-child").attr("transform",`translate(${-u/2}, ${u/2})`),d&&f.attr("style",d)}else f=La(i,u,u,h);return n&&f.attr("style",n),je(e,f),e.intersect=function(p){return Y.debug(`APA12 Intersect called SPLIT +point:`,p,` +node: +`,e,` +res:`,Ye.polygon(e,h,p)),Ye.polygon(e,h,p)},i}var g_e,YQ=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();_u();g_e=o((t,e,r)=>[`M${t+r/2},${e}`,`L${t+r},${e-r/2}`,`L${t+r/2},${e-r}`,`L${t},${e-r/2}`,"Z"].join(" "),"createDecisionBoxPathD");o(qQ,"question")});async function XQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0),e?.width??0),u=Math.max(a.height+(e.padding??0),e?.height??0),h=-l/2,f=-u/2,d=f/2,p=[{x:h+d,y:f},{x:h,y:0},{x:h+d,y:-f},{x:-h,y:-f},{x:-h,y:f}],{cssStyles:m}=e,g=Xe.svg(i),y=Ke(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=Xt(p),x=g.path(v,y),b=i.insert(()=>x,":first-child");return b.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),b.attr("transform",`translate(${-d/2},0)`),s.attr("transform",`translate(${-d/2-a.width/2-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),je(e,b),e.intersect=function(w){return Ye.polygon(e,p,w)},i}var jQ=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(XQ,"rect_left_inv_arrow")});function y_e(t,e){e&&t.attr("style",e)}async function v_e(t){let e=Ge(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),r=e.append("xhtml:div"),n=t.label;t.label&&pi(t.label)&&(n=await mh(t.label.replace(Ze.lineBreakRegex,` +`),me()));let i=t.isNode?"nodeLabel":"edgeLabel";return r.html('"+n+""),y_e(r,t.labelStyle),r.style("display","inline-block"),r.style("padding-right","1px"),r.style("white-space","nowrap"),r.attr("xmlns","http://www.w3.org/1999/xhtml"),e.node()}var x_e,gc,Gw=N(()=>{"use strict";dr();vt();zt();gr();ir();o(y_e,"applyStyle");o(v_e,"addHtmlLabel");x_e=o(async(t,e,r,n)=>{let i=t||"";if(typeof i=="object"&&(i=i[0]),fr(me().flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),Y.info("vertexText"+i);let a={isNode:n,label:na(i).replace(/fa[blrs]?:fa-[\w-]+/g,l=>``),labelStyle:e&&e.replace("fill:","color:")};return await v_e(a)}else{let a=document.createElementNS("http://www.w3.org/2000/svg","text");a.setAttribute("style",e.replace("color:","fill:"));let s=[];typeof i=="string"?s=i.split(/\\n|\n|/gi):Array.isArray(i)?s=i:s=[];for(let l of s){let u=document.createElementNS("http://www.w3.org/2000/svg","tspan");u.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),u.setAttribute("dy","1em"),u.setAttribute("x","0"),r?u.setAttribute("class","title-row"):u.setAttribute("class","row"),u.textContent=l.trim(),a.appendChild(u)}return a}},"createLabel"),gc=x_e});async function KQ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let i;e.cssClasses?i="node "+e.cssClasses:i="node default";let a=t.insert("g").attr("class",i).attr("id",e.domId||e.id),s=a.insert("g"),l=a.insert("g").attr("class","label").attr("style",n),u=e.description,h=e.label,f=l.node().appendChild(await gc(h,e.labelStyle,!0,!0)),d={width:0,height:0};if(fr(me()?.flowchart?.htmlLabels)){let S=f.children[0],_=Ge(f);d=S.getBoundingClientRect(),_.attr("width",d.width),_.attr("height",d.height)}Y.info("Text 2",u);let p=u||[],m=f.getBBox(),g=l.node().appendChild(await gc(p.join?p.join("
    "):p,e.labelStyle,!0,!0)),y=g.children[0],v=Ge(g);d=y.getBoundingClientRect(),v.attr("width",d.width),v.attr("height",d.height);let x=(e.padding||0)/2;Ge(g).attr("transform","translate( "+(d.width>m.width?0:(m.width-d.width)/2)+", "+(m.height+x+5)+")"),Ge(f).attr("transform","translate( "+(d.width(Y.debug("Rough node insert CXC",I),D),":first-child"),E=a.insert(()=>(Y.debug("Rough node insert CXC",I),I),":first-child")}else E=s.insert("rect",":first-child"),A=s.insert("line"),E.attr("class","outer title-state").attr("style",n).attr("x",-d.width/2-x).attr("y",-d.height/2-x).attr("width",d.width+(e.padding||0)).attr("height",d.height+(e.padding||0)),A.attr("class","divider").attr("x1",-d.width/2-x).attr("x2",d.width/2+x).attr("y1",-d.height/2-x+m.height+x).attr("y2",-d.height/2-x+m.height+x);return je(e,E),e.intersect=function(S){return Ye.rect(e,S)},a}var QQ=N(()=>{"use strict";dr();gr();Ft();Gw();Ht();Ut();Wt();zt();qh();vt();o(KQ,"rectWithTitle")});async function ZQ(t,e){let r={rx:5,ry:5,classes:"",labelPaddingX:(e?.padding||0)*1,labelPaddingY:(e?.padding||0)*1};return Du(t,e,r)}var JQ=N(()=>{"use strict";mm();o(ZQ,"roundedRect")});async function eZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=e?.padding??0,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=-a.width/2-l,d=-a.height/2-l,{cssStyles:p}=e,m=Xe.svg(i),g=Ke(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=[{x:f,y:d},{x:f+u+8,y:d},{x:f+u+8,y:d+h},{x:f-8,y:d+h},{x:f-8,y:d},{x:f,y:d},{x:f,y:d+h}],v=m.polygon(y.map(b=>[b.x,b.y]),g),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container").attr("style",$n(p)),n&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),p&&e.look!=="handDrawn"&&x.selectAll("path").attr("style",n),s.attr("transform",`translate(${-u/2+4+(e.padding??0)-(a.x-(a.left??0))},${-h/2+(e.padding??0)-(a.y-(a.top??0))})`),je(e,x),e.intersect=function(b){return Ye.rect(e,b)},i}var tZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();ir();o(eZ,"shadedProcess")});async function rZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=-l/2,f=-u/2,{cssStyles:d}=e,p=Xe.svg(i),m=Ke(e,{});e.look!=="handDrawn"&&(m.roughness=0,m.fillStyle="solid");let g=[{x:h,y:f},{x:h,y:f+u},{x:h+l,y:f+u},{x:h+l,y:f-u/2}],y=Xt(g),v=p.path(y,m),x=i.insert(()=>v,":first-child");return x.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",d),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),x.attr("transform",`translate(0, ${u/4})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))}, ${-u/4+(e.padding??0)-(a.y-(a.top??0))})`),je(e,x),e.intersect=function(b){return Ye.polygon(e,g,b)},i}var nZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(rZ,"slopedRect")});async function iZ(t,e){let r={rx:0,ry:0,classes:"",labelPaddingX:(e?.padding||0)*2,labelPaddingY:(e?.padding||0)*1};return Du(t,e,r)}var aZ=N(()=>{"use strict";mm();o(iZ,"squareRect")});async function sZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=a.height+e.padding,l=a.width+s/4+e.padding,u,{cssStyles:h}=e;if(e.look==="handDrawn"){let f=Xe.svg(i),d=Ke(e,{}),p=Na(-l/2,-s/2,l,s,s/2),m=f.path(p,d);u=i.insert(()=>m,":first-child"),u.attr("class","basic label-container").attr("style",$n(h))}else u=i.insert("rect",":first-child"),u.attr("class","basic label-container").attr("style",n).attr("rx",s/2).attr("ry",s/2).attr("x",-l/2).attr("y",-s/2).attr("width",l).attr("height",s);return je(e,u),e.intersect=function(f){return Ye.rect(e,f)},i}var oZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();qh();ir();o(sZ,"stadium")});async function lZ(t,e){return Du(t,e,{rx:5,ry:5,classes:"flowchart-node"})}var cZ=N(()=>{"use strict";mm();o(lZ,"state")});function uZ(t,e,{config:{themeVariables:r}}){let{labelStyles:n,nodeStyles:i}=Qe(e);e.labelStyle=n;let{cssStyles:a}=e,{lineColor:s,stateBorder:l,nodeBorder:u}=r,h=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),f=Xe.svg(h),d=Ke(e,{});e.look!=="handDrawn"&&(d.roughness=0,d.fillStyle="solid");let p=f.circle(0,0,14,{...d,stroke:s,strokeWidth:2}),m=l??u,g=f.circle(0,0,5,{...d,fill:m,stroke:m,strokeWidth:2,fillStyle:"solid"}),y=h.insert(()=>p,":first-child");return y.insert(()=>g),a&&y.selectAll("path").attr("style",a),i&&y.selectAll("path").attr("style",i),je(e,y),e.intersect=function(v){return Ye.circle(e,7,v)},h}var hZ=N(()=>{"use strict";Wt();Ht();Ut();Ft();o(uZ,"stateEnd")});function fZ(t,e,{config:{themeVariables:r}}){let{lineColor:n}=r,i=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),a;if(e.look==="handDrawn"){let l=Xe.svg(i).circle(0,0,14,gK(n));a=i.insert(()=>l),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14)}else a=i.insert("circle",":first-child"),a.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14);return je(e,a),e.intersect=function(s){return Ye.circle(e,7,s)},i}var dZ=N(()=>{"use strict";Wt();Ht();Ut();Ft();o(fZ,"stateStart")});async function pZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=(e?.padding||0)/2,l=a.width+e.padding,u=a.height+e.padding,h=-a.width/2-s,f=-a.height/2-s,d=[{x:0,y:0},{x:l,y:0},{x:l,y:-u},{x:0,y:-u},{x:0,y:0},{x:-8,y:0},{x:l+8,y:0},{x:l+8,y:-u},{x:-8,y:-u},{x:-8,y:0}];if(e.look==="handDrawn"){let p=Xe.svg(i),m=Ke(e,{}),g=p.rectangle(h-8,f,l+16,u,m),y=p.line(h,f,h,f+u,m),v=p.line(h+l,f,h+l,f+u,m);i.insert(()=>y,":first-child"),i.insert(()=>v,":first-child");let x=i.insert(()=>g,":first-child"),{cssStyles:b}=e;x.attr("class","basic label-container").attr("style",$n(b)),je(e,x)}else{let p=La(i,l,u,d);n&&p.attr("style",n),je(e,p)}return e.intersect=function(p){return Ye.polygon(e,d,p)},i}var mZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();_u();ir();o(pZ,"subroutine")});async function gZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=Math.max(a.width+(e.padding??0)*2,e?.width??0),l=Math.max(a.height+(e.padding??0)*2,e?.height??0),u=-s/2,h=-l/2,f=.2*l,d=.2*l,{cssStyles:p}=e,m=Xe.svg(i),g=Ke(e,{}),y=[{x:u-f/2,y:h},{x:u+s+f/2,y:h},{x:u+s+f/2,y:h+l},{x:u-f/2,y:h+l}],v=[{x:u+s-f/2,y:h+l},{x:u+s+f/2,y:h+l},{x:u+s+f/2,y:h+l-d}];e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=Xt(y),b=m.path(x,g),w=Xt(v),C=m.path(w,{...g,fillStyle:"solid"}),T=i.insert(()=>C,":first-child");return T.insert(()=>b,":first-child"),T.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&T.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&T.selectAll("path").attr("style",n),je(e,T),e.intersect=function(E){return Ye.polygon(e,y,E)},i}var yZ=N(()=>{"use strict";Ft();Ut();Wt();Ht();o(gZ,"taggedRect")});async function vZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/4,f=.2*l,d=.2*u,p=u+h,{cssStyles:m}=e,g=Xe.svg(i),y=Ke(e,{});e.look!=="handDrawn"&&(y.roughness=0,y.fillStyle="solid");let v=[{x:-l/2-l/2*.1,y:p/2},...Fo(-l/2-l/2*.1,p/2,l/2+l/2*.1,p/2,h,.8),{x:l/2+l/2*.1,y:-p/2},{x:-l/2-l/2*.1,y:-p/2}],x=-l/2+l/2*.1,b=-p/2-d*.4,w=[{x:x+l-f,y:(b+u)*1.4},{x:x+l,y:b+u-d},{x:x+l,y:(b+u)*.9},...Fo(x+l,(b+u)*1.3,x+l-f,(b+u)*1.5,-u*.03,.5)],C=Xt(v),T=g.path(C,y),E=Xt(w),A=g.path(E,{...y,fillStyle:"solid"}),S=i.insert(()=>A,":first-child");return S.insert(()=>T,":first-child"),S.attr("class","basic label-container"),m&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",m),n&&e.look!=="handDrawn"&&S.selectAll("path").attr("style",n),S.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h/2-(a.y-(a.top??0))})`),je(e,S),e.intersect=function(_){return Ye.polygon(e,v,_)},i}var xZ=N(()=>{"use strict";Ft();Ht();Wt();Ut();o(vZ,"taggedWaveEdgedRectangle")});async function bZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=Math.max(a.width+e.padding,e?.width||0),l=Math.max(a.height+e.padding,e?.height||0),u=-s/2,h=-l/2,f=i.insert("rect",":first-child");return f.attr("class","text").attr("style",n).attr("rx",0).attr("ry",0).attr("x",u).attr("y",h).attr("width",s).attr("height",l),je(e,f),e.intersect=function(d){return Ye.rect(e,d)},i}var wZ=N(()=>{"use strict";Ft();Ht();Ut();o(bZ,"text")});async function TZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s,halfPadding:l}=await pt(t,e,ht(e)),u=e.look==="neo"?l*2:l,h=a.height+u,f=h/2,d=f/(2.5+h/50),p=a.width+d+u,{cssStyles:m}=e,g;if(e.look==="handDrawn"){let y=Xe.svg(i),v=w_e(0,0,p,h,d,f),x=T_e(0,0,p,h,d,f),b=y.path(v,Ke(e,{})),w=y.path(x,Ke(e,{fill:"none"}));g=i.insert(()=>w,":first-child"),g=i.insert(()=>b,":first-child"),g.attr("class","basic label-container"),m&&g.attr("style",m)}else{let y=b_e(0,0,p,h,d,f);g=i.insert("path",":first-child").attr("d",y).attr("class","basic label-container").attr("style",$n(m)).attr("style",n),g.attr("class","basic label-container"),m&&g.selectAll("path").attr("style",m),n&&g.selectAll("path").attr("style",n)}return g.attr("label-offset-x",d),g.attr("transform",`translate(${-p/2}, ${h/2} )`),s.attr("transform",`translate(${-(a.width/2)-d-(a.x-(a.left??0))}, ${-(a.height/2)-(a.y-(a.top??0))})`),je(e,g),e.intersect=function(y){let v=Ye.rect(e,y),x=v.y-(e.y??0);if(f!=0&&(Math.abs(x)<(e.height??0)/2||Math.abs(x)==(e.height??0)/2&&Math.abs(v.x-(e.x??0))>(e.width??0)/2-d)){let b=d*d*(1-x*x/(f*f));b!=0&&(b=Math.sqrt(Math.abs(b))),b=d-b,y.x-(e.x??0)>0&&(b=-b),v.x+=b}return v},i}var b_e,w_e,T_e,kZ=N(()=>{"use strict";Ft();Ut();Wt();Ht();ir();b_e=o((t,e,r,n,i,a)=>`M${t},${e} + a${i},${a} 0,0,1 0,${-n} + l${r},0 + a${i},${a} 0,0,1 0,${n} + M${r},${-n} + a${i},${a} 0,0,0 0,${n} + l${-r},0`,"createCylinderPathD"),w_e=o((t,e,r,n,i,a)=>[`M${t},${e}`,`M${t+r},${e}`,`a${i},${a} 0,0,0 0,${-n}`,`l${-r},0`,`a${i},${a} 0,0,0 0,${n}`,`l${r},0`].join(" "),"createOuterCylinderPathD"),T_e=o((t,e,r,n,i,a)=>[`M${t+r/2},${-n/2}`,`a${i},${a} 0,0,0 0,${n}`].join(" "),"createInnerCylinderPathD");o(TZ,"tiltedCylinder")});async function EZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=a.width+e.padding,l=a.height+e.padding,u=[{x:-3*l/6,y:0},{x:s+3*l/6,y:0},{x:s,y:-l},{x:0,y:-l}],h,{cssStyles:f}=e;if(e.look==="handDrawn"){let d=Xe.svg(i),p=Ke(e,{}),m=Xt(u),g=d.path(m,p);h=i.insert(()=>g,":first-child").attr("transform",`translate(${-s/2}, ${l/2})`),f&&h.attr("style",f)}else h=La(i,s,l,u);return n&&h.attr("style",n),e.width=s,e.height=l,je(e,h),e.intersect=function(d){return Ye.polygon(e,u,d)},i}var SZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();_u();o(EZ,"trapezoid")});async function CZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=60,l=20,u=Math.max(s,a.width+(e.padding??0)*2,e?.width??0),h=Math.max(l,a.height+(e.padding??0)*2,e?.height??0),{cssStyles:f}=e,d=Xe.svg(i),p=Ke(e,{});e.look!=="handDrawn"&&(p.roughness=0,p.fillStyle="solid");let m=[{x:-u/2*.8,y:-h/2},{x:u/2*.8,y:-h/2},{x:u/2,y:-h/2*.6},{x:u/2,y:h/2},{x:-u/2,y:h/2},{x:-u/2,y:-h/2*.6}],g=Xt(m),y=d.path(g,p),v=i.insert(()=>y,":first-child");return v.attr("class","basic label-container"),f&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",f),n&&e.look!=="handDrawn"&&v.selectChildren("path").attr("style",n),je(e,v),e.intersect=function(x){return Ye.polygon(e,m,x)},i}var AZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(CZ,"trapezoidalPentagon")});async function _Z(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=fr(me().flowchart?.htmlLabels),u=a.width+(e.padding??0),h=u+a.height,f=u+a.height,d=[{x:0,y:0},{x:f,y:0},{x:f/2,y:-h}],{cssStyles:p}=e,m=Xe.svg(i),g=Ke(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=Xt(d),v=m.path(y,g),x=i.insert(()=>v,":first-child").attr("transform",`translate(${-h/2}, ${h/2})`);return p&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",p),n&&e.look!=="handDrawn"&&x.selectChildren("path").attr("style",n),e.width=u,e.height=h,je(e,x),s.attr("transform",`translate(${-a.width/2-(a.x-(a.left??0))}, ${h/2-(a.height+(e.padding??0)/(l?2:1)-(a.y-(a.top??0)))})`),e.intersect=function(b){return Y.info("Triangle intersect",e,d,b),Ye.polygon(e,d,b)},i}var DZ=N(()=>{"use strict";vt();Ft();Ht();Ut();Wt();Ft();gr();zt();o(_Z,"triangle")});async function LZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=u/8,f=u+h,{cssStyles:d}=e,m=70-l,g=m>0?m/2:0,y=Xe.svg(i),v=Ke(e,{});e.look!=="handDrawn"&&(v.roughness=0,v.fillStyle="solid");let x=[{x:-l/2-g,y:f/2},...Fo(-l/2-g,f/2,l/2+g,f/2,h,.8),{x:l/2+g,y:-f/2},{x:-l/2-g,y:-f/2}],b=Xt(x),w=y.path(b,v),C=i.insert(()=>w,":first-child");return C.attr("class","basic label-container"),d&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",d),n&&e.look!=="handDrawn"&&C.selectAll("path").attr("style",n),C.attr("transform",`translate(0,${-h/2})`),s.attr("transform",`translate(${-l/2+(e.padding??0)-(a.x-(a.left??0))},${-u/2+(e.padding??0)-h-(a.y-(a.top??0))})`),je(e,C),e.intersect=function(T){return Ye.polygon(e,x,T)},i}var RZ=N(()=>{"use strict";Ft();Ht();Wt();Ut();o(LZ,"waveEdgedRectangle")});async function NZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a}=await pt(t,e,ht(e)),s=100,l=50,u=Math.max(a.width+(e.padding??0)*2,e?.width??0),h=Math.max(a.height+(e.padding??0)*2,e?.height??0),f=u/h,d=u,p=h;d>p*f?p=d/f:d=p*f,d=Math.max(d,s),p=Math.max(p,l);let m=Math.min(p*.2,p/4),g=p+m*2,{cssStyles:y}=e,v=Xe.svg(i),x=Ke(e,{});e.look!=="handDrawn"&&(x.roughness=0,x.fillStyle="solid");let b=[{x:-d/2,y:g/2},...Fo(-d/2,g/2,d/2,g/2,m,1),{x:d/2,y:-g/2},...Fo(d/2,-g/2,-d/2,-g/2,m,-1)],w=Xt(b),C=v.path(w,x),T=i.insert(()=>C,":first-child");return T.attr("class","basic label-container"),y&&e.look!=="handDrawn"&&T.selectAll("path").attr("style",y),n&&e.look!=="handDrawn"&&T.selectAll("path").attr("style",n),je(e,T),e.intersect=function(E){return Ye.polygon(e,b,E)},i}var MZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();o(NZ,"waveRectangle")});async function IZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let{shapeSvg:i,bbox:a,label:s}=await pt(t,e,ht(e)),l=Math.max(a.width+(e.padding??0)*2,e?.width??0),u=Math.max(a.height+(e.padding??0)*2,e?.height??0),h=5,f=-l/2,d=-u/2,{cssStyles:p}=e,m=Xe.svg(i),g=Ke(e,{}),y=[{x:f-h,y:d-h},{x:f-h,y:d+u},{x:f+l,y:d+u},{x:f+l,y:d-h}],v=`M${f-h},${d-h} L${f+l},${d-h} L${f+l},${d+u} L${f-h},${d+u} L${f-h},${d-h} + M${f-h},${d} L${f+l},${d} + M${f},${d-h} L${f},${d+u}`;e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let x=m.path(v,g),b=i.insert(()=>x,":first-child");return b.attr("transform",`translate(${h/2}, ${h/2})`),b.attr("class","basic label-container"),p&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",p),n&&e.look!=="handDrawn"&&b.selectAll("path").attr("style",n),s.attr("transform",`translate(${-(a.width/2)+h/2-(a.x-(a.left??0))}, ${-(a.height/2)+h/2-(a.y-(a.top??0))})`),je(e,b),e.intersect=function(w){return Ye.polygon(e,y,w)},i}var OZ=N(()=>{"use strict";Ft();Ut();Wt();Ht();o(IZ,"windowPane")});async function KD(t,e){let r=e;if(r.alias&&(e.label=r.alias),e.look==="handDrawn"){let{themeVariables:P}=cr(),{background:z}=P,$={...e,id:e.id+"-background",look:"default",cssStyles:["stroke: none",`fill: ${z}`]};await KD(t,$)}let n=cr();e.useHtmlLabels=n.htmlLabels;let i=n.er?.diagramPadding??10,a=n.er?.entityPadding??6,{cssStyles:s}=e,{labelStyles:l}=Qe(e);if(r.attributes.length===0&&e.label){let P={rx:0,ry:0,labelPaddingX:i,labelPaddingY:i*1.5,classes:""};ra(e.label,n)+P.labelPaddingX*20){let P=f.width+i*2-(m+g+y+v);m+=P/w,g+=P/w,y>0&&(y+=P/w),v>0&&(v+=P/w)}let T=m+g+y+v,E=Xe.svg(h),A=Ke(e,{});e.look!=="handDrawn"&&(A.roughness=0,A.fillStyle="solid");let S=Math.max(C.width+i*2,e?.width||0,T),_=Math.max(C.height+(p[0]||d)+a,e?.height||0),I=-S/2,D=-_/2;h.selectAll("g:not(:first-child)").each((P,z,$)=>{let H=Ge($[z]),Q=H.attr("transform"),j=0,ie=0;if(Q){let le=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(Q);le&&(j=parseFloat(le[1]),ie=parseFloat(le[2]),H.attr("class").includes("attribute-name")?j+=m:H.attr("class").includes("attribute-keys")?j+=m+g:H.attr("class").includes("attribute-comment")&&(j+=m+g+y))}H.attr("transform",`translate(${I+i/2+j}, ${ie+D+f.height+a/2})`)}),h.select(".name").attr("transform","translate("+-f.width/2+", "+(D+a/2)+")");let k=E.rectangle(I,D,S,_,A),L=h.insert(()=>k,":first-child").attr("style",s.join("")),{themeVariables:R}=cr(),{rowEven:O,rowOdd:M,nodeBorder:B}=R;p.push(0);for(let[P,z]of p.entries()){if(P===0&&p.length>1)continue;let $=P%2===0&&z!==0,H=E.rectangle(I,f.height+D+z,S,f.height,{...A,fill:$?O:M,stroke:B});h.insert(()=>H,"g.label").attr("style",s.join("")).attr("class",`row-rect-${P%2===0?"even":"odd"}`)}let F=E.line(I,f.height+D,S+I,f.height+D,A);h.insert(()=>F).attr("class","divider"),F=E.line(m+I,f.height+D,m+I,_+D,A),h.insert(()=>F).attr("class","divider"),x&&(F=E.line(m+g+I,f.height+D,m+g+I,_+D,A),h.insert(()=>F).attr("class","divider")),b&&(F=E.line(m+g+y+I,f.height+D,m+g+y+I,_+D,A),h.insert(()=>F).attr("class","divider"));for(let P of p)F=E.line(I,f.height+D+P,S+I,f.height+D+P,A),h.insert(()=>F).attr("class","divider");return je(e,L),e.intersect=function(P){return Ye.rect(e,P)},h}async function b2(t,e,r,n=0,i=0,a=[],s=""){let l=t.insert("g").attr("class",`label ${a.join(" ")}`).attr("transform",`translate(${n}, ${i})`).attr("style",s);e!==ec(e)&&(e=ec(e),e=e.replaceAll("<","<").replaceAll(">",">"));let u=l.node().appendChild(await Hn(l,e,{width:ra(e,r)+100,style:s,useHtmlLabels:r.htmlLabels},r));if(e.includes("<")||e.includes(">")){let f=u.children[0];for(f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">");f.childNodes[0];)f=f.childNodes[0],f.textContent=f.textContent.replaceAll("<","<").replaceAll(">",">")}let h=u.getBBox();if(fr(r.htmlLabels)){let f=u.children[0];f.style.textAlign="start";let d=Ge(u);h=f.getBoundingClientRect(),d.attr("width",h.width),d.attr("height",h.height)}return h}var PZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();mm();ji();to();gr();dr();ir();o(KD,"erBox");o(b2,"addText")});async function BZ(t,e,r,n,i=r.class.padding??12){let a=n?0:3,s=t.insert("g").attr("class",ht(e)).attr("id",e.domId||e.id),l=null,u=null,h=null,f=null,d=0,p=0,m=0;if(l=s.insert("g").attr("class","annotation-group text"),e.annotations.length>0){let b=e.annotations[0];await Vw(l,{text:`\xAB${b}\xBB`},0),d=l.node().getBBox().height}u=s.insert("g").attr("class","label-group text"),await Vw(u,e,0,["font-weight: bolder"]);let g=u.node().getBBox();p=g.height,h=s.insert("g").attr("class","members-group text");let y=0;for(let b of e.members){let w=await Vw(h,b,y,[b.parseClassifier()]);y+=w+a}m=h.node().getBBox().height,m<=0&&(m=i/2),f=s.insert("g").attr("class","methods-group text");let v=0;for(let b of e.methods){let w=await Vw(f,b,v,[b.parseClassifier()]);v+=w+a}let x=s.node().getBBox();if(l!==null){let b=l.node().getBBox();l.attr("transform",`translate(${-b.width/2})`)}return u.attr("transform",`translate(${-g.width/2}, ${d})`),x=s.node().getBBox(),h.attr("transform",`translate(0, ${d+p+i*2})`),x=s.node().getBBox(),f.attr("transform",`translate(0, ${d+p+(m?m+i*4:i*2)})`),x=s.node().getBBox(),{shapeSvg:s,bbox:x}}async function Vw(t,e,r,n=[]){let i=t.insert("g").attr("class","label").attr("style",n.join("; ")),a=cr(),s="useHtmlLabels"in e?e.useHtmlLabels:fr(a.htmlLabels)??!0,l="";"text"in e?l=e.text:l=e.label,!s&&l.startsWith("\\")&&(l=l.substring(1)),pi(l)&&(s=!0);let u=await Hn(i,Xy(na(l)),{width:ra(l,a)+50,classes:"markdown-node-label",useHtmlLabels:s},a),h,f=1;if(s){let d=u.children[0],p=Ge(u);f=d.innerHTML.split("
    ").length,d.innerHTML.includes("")&&(f+=d.innerHTML.split("").length-1);let m=d.getElementsByTagName("img");if(m){let g=l.replace(/]*>/g,"").trim()==="";await Promise.all([...m].map(y=>new Promise(v=>{function x(){if(y.style.display="flex",y.style.flexDirection="column",g){let b=a.fontSize?.toString()??window.getComputedStyle(document.body).fontSize,C=parseInt(b,10)*5+"px";y.style.minWidth=C,y.style.maxWidth=C}else y.style.width="100%";v(y)}o(x,"setupImage"),setTimeout(()=>{y.complete&&x()}),y.addEventListener("error",x),y.addEventListener("load",x)})))}h=d.getBoundingClientRect(),p.attr("width",h.width),p.attr("height",h.height)}else{n.includes("font-weight: bolder")&&Ge(u).selectAll("tspan").attr("font-weight",""),f=u.children.length;let d=u.children[0];(u.textContent===""||u.textContent.includes(">"))&&(d.textContent=l[0]+l.substring(1).replaceAll(">",">").replaceAll("<","<").trim(),l[1]===" "&&(d.textContent=d.textContent[0]+" "+d.textContent.substring(1))),d.textContent==="undefined"&&(d.textContent=""),h=u.getBBox()}return i.attr("transform","translate(0,"+(-h.height/(2*f)+r)+")"),h.height}var FZ=N(()=>{"use strict";dr();ji();Ft();ir();zt();to();gr();o(BZ,"textHelper");o(Vw,"addText")});async function $Z(t,e){let r=me(),n=r.class.padding??12,i=n,a=e.useHtmlLabels??fr(r.htmlLabels)??!0,s=e;s.annotations=s.annotations??[],s.members=s.members??[],s.methods=s.methods??[];let{shapeSvg:l,bbox:u}=await BZ(t,e,r,a,i),{labelStyles:h,nodeStyles:f}=Qe(e);e.labelStyle=h,e.cssStyles=s.styles||"";let d=s.styles?.join(";")||f||"";e.cssStyles||(e.cssStyles=d.replaceAll("!important","").split(";"));let p=s.members.length===0&&s.methods.length===0&&!r.class?.hideEmptyMembersBox,m=Xe.svg(l),g=Ke(e,{});e.look!=="handDrawn"&&(g.roughness=0,g.fillStyle="solid");let y=u.width,v=u.height;s.members.length===0&&s.methods.length===0?v+=i:s.members.length>0&&s.methods.length===0&&(v+=i*2);let x=-y/2,b=-v/2,w=m.rectangle(x-n,b-n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0),y+2*n,v+2*n+(p?n*2:s.members.length===0&&s.methods.length===0?-n:0),g),C=l.insert(()=>w,":first-child");C.attr("class","basic label-container");let T=C.node().getBBox();l.selectAll(".text").each((_,I,D)=>{let k=Ge(D[I]),L=k.attr("transform"),R=0;if(L){let F=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(L);F&&(R=parseFloat(F[2]))}let O=R+b+n-(p?n:s.members.length===0&&s.methods.length===0?-n/2:0);a||(O-=4);let M=x;(k.attr("class").includes("label-group")||k.attr("class").includes("annotation-group"))&&(M=-k.node()?.getBBox().width/2||0,l.selectAll("text").each(function(B,F,P){window.getComputedStyle(P[F]).textAnchor==="middle"&&(M=0)})),k.attr("transform",`translate(${M}, ${O})`)});let E=l.select(".annotation-group").node().getBBox().height-(p?n/2:0)||0,A=l.select(".label-group").node().getBBox().height-(p?n/2:0)||0,S=l.select(".members-group").node().getBBox().height-(p?n/2:0)||0;if(s.members.length>0||s.methods.length>0||p){let _=m.line(T.x,E+A+b+n,T.x+T.width,E+A+b+n,g);l.insert(()=>_).attr("class","divider").attr("style",d)}if(p||s.members.length>0||s.methods.length>0){let _=m.line(T.x,E+A+S+b+i*2+n,T.x+T.width,E+A+S+b+n+i*2,g);l.insert(()=>_).attr("class","divider").attr("style",d)}if(s.look!=="handDrawn"&&l.selectAll("path").attr("style",d),C.select(":nth-child(2)").attr("style",d),l.selectAll(".divider").select("path").attr("style",d),e.labelStyle?l.selectAll("span").attr("style",e.labelStyle):l.selectAll("span").attr("style",d),!a){let _=RegExp(/color\s*:\s*([^;]*)/),I=_.exec(d);if(I){let D=I[0].replace("color","fill");l.selectAll("tspan").attr("style",D)}else if(h){let D=_.exec(h);if(D){let k=D[0].replace("color","fill");l.selectAll("tspan").attr("style",k)}}}return je(e,C),e.intersect=function(_){return Ye.rect(e,_)},l}var zZ=N(()=>{"use strict";Ft();zt();dr();Wt();Ut();Ht();FZ();gr();o($Z,"classBox")});async function GZ(t,e){let{labelStyles:r,nodeStyles:n}=Qe(e);e.labelStyle=r;let i=e,a=e,s=20,l=20,u="verifyMethod"in e,h=ht(e),f=t.insert("g").attr("class",h).attr("id",e.domId??e.id),d;u?d=await Lu(f,`<<${i.type}>>`,0,e.labelStyle):d=await Lu(f,"<<Element>>",0,e.labelStyle);let p=d,m=await Lu(f,i.name,p,e.labelStyle+"; font-weight: bold;");if(p+=m+l,u){let E=await Lu(f,`${i.requirementId?`Id: ${i.requirementId}`:""}`,p,e.labelStyle);p+=E;let A=await Lu(f,`${i.text?`Text: ${i.text}`:""}`,p,e.labelStyle);p+=A;let S=await Lu(f,`${i.risk?`Risk: ${i.risk}`:""}`,p,e.labelStyle);p+=S,await Lu(f,`${i.verifyMethod?`Verification: ${i.verifyMethod}`:""}`,p,e.labelStyle)}else{let E=await Lu(f,`${a.type?`Type: ${a.type}`:""}`,p,e.labelStyle);p+=E,await Lu(f,`${a.docRef?`Doc Ref: ${a.docRef}`:""}`,p,e.labelStyle)}let g=(f.node()?.getBBox().width??200)+s,y=(f.node()?.getBBox().height??200)+s,v=-g/2,x=-y/2,b=Xe.svg(f),w=Ke(e,{});e.look!=="handDrawn"&&(w.roughness=0,w.fillStyle="solid");let C=b.rectangle(v,x,g,y,w),T=f.insert(()=>C,":first-child");if(T.attr("class","basic label-container").attr("style",n),f.selectAll(".label").each((E,A,S)=>{let _=Ge(S[A]),I=_.attr("transform"),D=0,k=0;if(I){let M=RegExp(/translate\(([^,]+),([^)]+)\)/).exec(I);M&&(D=parseFloat(M[1]),k=parseFloat(M[2]))}let L=k-y/2,R=v+s/2;(A===0||A===1)&&(R=D),_.attr("transform",`translate(${R}, ${L+s})`)}),p>d+m+l){let E=b.line(v,x+d+m+l,v+g,x+d+m+l,w);f.insert(()=>E).attr("style",n)}return je(e,T),e.intersect=function(E){return Ye.rect(e,E)},f}async function Lu(t,e,r,n=""){if(e==="")return 0;let i=t.insert("g").attr("class","label").attr("style",n),a=me(),s=a.htmlLabels??!0,l=await Hn(i,Xy(na(e)),{width:ra(e,a)+50,classes:"markdown-node-label",useHtmlLabels:s,style:n},a),u;if(s){let h=l.children[0],f=Ge(l);u=h.getBoundingClientRect(),f.attr("width",u.width),f.attr("height",u.height)}else{let h=l.children[0];for(let f of h.children)f.textContent=f.textContent.replaceAll(">",">").replaceAll("<","<"),n&&f.setAttribute("style",n);u=l.getBBox(),u.height+=6}return i.attr("transform",`translate(${-u.width/2},${-u.height/2+r})`),u.height}var VZ=N(()=>{"use strict";Ft();Ht();Ut();Wt();ir();zt();to();dr();o(GZ,"requirementBox");o(Lu,"addText")});async function UZ(t,e,{config:r}){let{labelStyles:n,nodeStyles:i}=Qe(e);e.labelStyle=n||"";let a=10,s=e.width;e.width=(e.width??200)-10;let{shapeSvg:l,bbox:u,label:h}=await pt(t,e,ht(e)),f=e.padding||10,d="",p;"ticket"in e&&e.ticket&&r?.kanban?.ticketBaseUrl&&(d=r?.kanban?.ticketBaseUrl.replace("#TICKET#",e.ticket),p=l.insert("svg:a",":first-child").attr("class","kanban-ticket-link").attr("xlink:href",d).attr("target","_blank"));let m={useHtmlLabels:e.useHtmlLabels,labelStyle:e.labelStyle||"",width:e.width,img:e.img,padding:e.padding||8,centerLabel:!1},g,y;p?{label:g,bbox:y}=await Dw(p,"ticket"in e&&e.ticket||"",m):{label:g,bbox:y}=await Dw(l,"ticket"in e&&e.ticket||"",m);let{label:v,bbox:x}=await Dw(l,"assigned"in e&&e.assigned||"",m);e.width=s;let b=10,w=e?.width||0,C=Math.max(y.height,x.height)/2,T=Math.max(u.height+b*2,e?.height||0)+C,E=-w/2,A=-T/2;h.attr("transform","translate("+(f-w/2)+", "+(-C-u.height/2)+")"),g.attr("transform","translate("+(f-w/2)+", "+(-C+u.height/2)+")"),v.attr("transform","translate("+(f+w/2-x.width-2*a)+", "+(-C+u.height/2)+")");let S,{rx:_,ry:I}=e,{cssStyles:D}=e;if(e.look==="handDrawn"){let k=Xe.svg(l),L=Ke(e,{}),R=_||I?k.path(Na(E,A,w,T,_||0),L):k.rectangle(E,A,w,T,L);S=l.insert(()=>R,":first-child"),S.attr("class","basic label-container").attr("style",D||null)}else{S=l.insert("rect",":first-child"),S.attr("class","basic label-container __APA__").attr("style",i).attr("rx",_??5).attr("ry",I??5).attr("x",E).attr("y",A).attr("width",w).attr("height",T);let k="priority"in e&&e.priority;if(k){let L=l.append("line"),R=E+2,O=A+Math.floor((_??0)/2),M=A+T-Math.floor((_??0)/2);L.attr("x1",R).attr("y1",O).attr("x2",R).attr("y2",M).attr("stroke-width","4").attr("stroke",k_e(k))}}return je(e,S),e.height=T,e.intersect=function(k){return Ye.rect(e,k)},l}var k_e,HZ=N(()=>{"use strict";Ft();Ht();qh();Ut();Wt();k_e=o(t=>{switch(t){case"Very High":return"red";case"High":return"orange";case"Medium":return null;case"Low":return"blue";case"Very Low":return"lightblue"}},"colorFromPriority");o(UZ,"kanbanItem")});function WZ(t){return t in QD}var E_e,S_e,QD,ZD=N(()=>{"use strict";NK();OK();BK();$K();GK();UK();WK();YK();jK();QK();JK();tQ();nQ();aQ();oQ();cQ();hQ();dQ();mQ();yQ();xQ();wQ();kQ();SQ();AQ();DQ();RQ();MQ();OQ();BQ();$Q();GQ();UQ();WQ();YQ();jQ();QQ();JQ();tZ();nZ();aZ();oZ();cZ();hZ();dZ();mZ();yZ();xZ();wZ();kZ();SZ();AZ();DZ();RZ();MZ();OZ();PZ();zZ();VZ();HZ();E_e=[{semanticName:"Process",name:"Rectangle",shortName:"rect",description:"Standard process shape",aliases:["proc","process","rectangle"],internalAliases:["squareRect"],handler:iZ},{semanticName:"Event",name:"Rounded Rectangle",shortName:"rounded",description:"Represents an event",aliases:["event"],internalAliases:["roundedRect"],handler:ZQ},{semanticName:"Terminal Point",name:"Stadium",shortName:"stadium",description:"Terminal point",aliases:["terminal","pill"],handler:sZ},{semanticName:"Subprocess",name:"Framed Rectangle",shortName:"fr-rect",description:"Subprocess",aliases:["subprocess","subproc","framed-rectangle","subroutine"],handler:pZ},{semanticName:"Database",name:"Cylinder",shortName:"cyl",description:"Database storage",aliases:["db","database","cylinder"],handler:ZK},{semanticName:"Start",name:"Circle",shortName:"circle",description:"Starting point",aliases:["circ"],handler:zK},{semanticName:"Decision",name:"Diamond",shortName:"diam",description:"Decision-making step",aliases:["decision","diamond","question"],handler:qQ},{semanticName:"Prepare Conditional",name:"Hexagon",shortName:"hex",description:"Preparation or condition step",aliases:["hexagon","prepare"],handler:fQ},{semanticName:"Data Input/Output",name:"Lean Right",shortName:"lean-r",description:"Represents input or output",aliases:["lean-right","in-out"],internalAliases:["lean_right"],handler:NQ},{semanticName:"Data Input/Output",name:"Lean Left",shortName:"lean-l",description:"Represents output or input",aliases:["lean-left","out-in"],internalAliases:["lean_left"],handler:LQ},{semanticName:"Priority Action",name:"Trapezoid Base Bottom",shortName:"trap-b",description:"Priority action",aliases:["priority","trapezoid-bottom","trapezoid"],handler:EZ},{semanticName:"Manual Operation",name:"Trapezoid Base Top",shortName:"trap-t",description:"Represents a manual task",aliases:["manual","trapezoid-top","inv-trapezoid"],internalAliases:["inv_trapezoid"],handler:CQ},{semanticName:"Stop",name:"Double Circle",shortName:"dbl-circ",description:"Represents a stop point",aliases:["double-circle"],internalAliases:["doublecircle"],handler:rQ},{semanticName:"Text Block",name:"Text Block",shortName:"text",description:"Text block",handler:bZ},{semanticName:"Card",name:"Notched Rectangle",shortName:"notch-rect",description:"Represents a card",aliases:["card","notched-rectangle"],handler:PK},{semanticName:"Lined/Shaded Process",name:"Lined Rectangle",shortName:"lin-rect",description:"Lined process shape",aliases:["lined-rectangle","lined-process","lin-proc","shaded-process"],handler:eZ},{semanticName:"Start",name:"Small Circle",shortName:"sm-circ",description:"Small starting point",aliases:["start","small-circle"],internalAliases:["stateStart"],handler:fZ},{semanticName:"Stop",name:"Framed Circle",shortName:"fr-circ",description:"Stop point",aliases:["stop","framed-circle"],internalAliases:["stateEnd"],handler:uZ},{semanticName:"Fork/Join",name:"Filled Rectangle",shortName:"fork",description:"Fork or join in process flow",aliases:["join"],internalAliases:["forkJoin"],handler:lQ},{semanticName:"Collate",name:"Hourglass",shortName:"hourglass",description:"Represents a collate operation",aliases:["hourglass","collate"],handler:pQ},{semanticName:"Comment",name:"Curly Brace",shortName:"brace",description:"Adds a comment",aliases:["comment","brace-l"],handler:HK},{semanticName:"Comment Right",name:"Curly Brace",shortName:"brace-r",description:"Adds a comment",handler:qK},{semanticName:"Comment with braces on both sides",name:"Curly Braces",shortName:"braces",description:"Adds a comment",handler:XK},{semanticName:"Com Link",name:"Lightning Bolt",shortName:"bolt",description:"Communication link",aliases:["com-link","lightning-bolt"],handler:IQ},{semanticName:"Document",name:"Document",shortName:"doc",description:"Represents a document",aliases:["doc","document"],handler:LZ},{semanticName:"Delay",name:"Half-Rounded Rectangle",shortName:"delay",description:"Represents a delay",aliases:["half-rounded-rectangle"],handler:uQ},{semanticName:"Direct Access Storage",name:"Horizontal Cylinder",shortName:"h-cyl",description:"Direct access storage",aliases:["das","horizontal-cylinder"],handler:TZ},{semanticName:"Disk Storage",name:"Lined Cylinder",shortName:"lin-cyl",description:"Disk storage",aliases:["disk","lined-cylinder"],handler:PQ},{semanticName:"Display",name:"Curved Trapezoid",shortName:"curv-trap",description:"Represents a display",aliases:["curved-trapezoid","display"],handler:KK},{semanticName:"Divided Process",name:"Divided Rectangle",shortName:"div-rect",description:"Divided process shape",aliases:["div-proc","divided-rectangle","divided-process"],handler:eQ},{semanticName:"Extract",name:"Triangle",shortName:"tri",description:"Extraction process",aliases:["extract","triangle"],handler:_Z},{semanticName:"Internal Storage",name:"Window Pane",shortName:"win-pane",description:"Internal storage",aliases:["internal-storage","window-pane"],handler:IZ},{semanticName:"Junction",name:"Filled Circle",shortName:"f-circ",description:"Junction point",aliases:["junction","filled-circle"],handler:iQ},{semanticName:"Loop Limit",name:"Trapezoidal Pentagon",shortName:"notch-pent",description:"Loop limit step",aliases:["loop-limit","notched-pentagon"],handler:CZ},{semanticName:"Manual File",name:"Flipped Triangle",shortName:"flip-tri",description:"Manual file operation",aliases:["manual-file","flipped-triangle"],handler:sQ},{semanticName:"Manual Input",name:"Sloped Rectangle",shortName:"sl-rect",description:"Manual input step",aliases:["manual-input","sloped-rectangle"],handler:rZ},{semanticName:"Multi-Document",name:"Stacked Document",shortName:"docs",description:"Multiple documents",aliases:["documents","st-doc","stacked-document"],handler:VQ},{semanticName:"Multi-Process",name:"Stacked Rectangle",shortName:"st-rect",description:"Multiple processes",aliases:["procs","processes","stacked-rectangle"],handler:zQ},{semanticName:"Stored Data",name:"Bow Tie Rectangle",shortName:"bow-rect",description:"Stored data",aliases:["stored-data","bow-tie-rectangle"],handler:IK},{semanticName:"Summary",name:"Crossed Circle",shortName:"cross-circ",description:"Summary",aliases:["summary","crossed-circle"],handler:VK},{semanticName:"Tagged Document",name:"Tagged Document",shortName:"tag-doc",description:"Tagged document",aliases:["tag-doc","tagged-document"],handler:vZ},{semanticName:"Tagged Process",name:"Tagged Rectangle",shortName:"tag-rect",description:"Tagged process",aliases:["tagged-rectangle","tag-proc","tagged-process"],handler:gZ},{semanticName:"Paper Tape",name:"Flag",shortName:"flag",description:"Paper tape",aliases:["paper-tape"],handler:NZ},{semanticName:"Odd",name:"Odd",shortName:"odd",description:"Odd shape",internalAliases:["rect_left_inv_arrow"],handler:XQ},{semanticName:"Lined Document",name:"Lined Document",shortName:"lin-doc",description:"Lined document",aliases:["lined-document"],handler:FQ}],S_e=o(()=>{let e=[...Object.entries({state:lZ,choice:FK,note:HQ,rectWithTitle:KQ,labelRect:_Q,iconSquare:TQ,iconCircle:vQ,icon:gQ,iconRounded:bQ,imageSquare:EQ,anchor:RK,kanbanItem:UZ,classBox:$Z,erBox:KD,requirementBox:GZ}),...E_e.flatMap(r=>[r.shortName,..."aliases"in r?r.aliases:[],..."internalAliases"in r?r.internalAliases:[]].map(i=>[i,r.handler]))];return Object.fromEntries(e)},"generateShapeMap"),QD=S_e();o(WZ,"isValidShape")});var C_e,Uw,qZ=N(()=>{"use strict";dr();Ew();zt();vt();ZD();ir();gr();mi();C_e="flowchart-",Uw=class{constructor(){this.vertexCounter=0;this.config=me();this.vertices=new Map;this.edges=[];this.classes=new Map;this.subGraphs=[];this.subGraphLookup=new Map;this.tooltips=new Map;this.subCount=0;this.firstGraphFlag=!0;this.secCount=-1;this.posCrossRef=[];this.funs=[];this.setAccTitle=Lr;this.setAccDescription=Nr;this.setDiagramTitle=$r;this.getAccTitle=Rr;this.getAccDescription=Mr;this.getDiagramTitle=Ir;this.funs.push(this.setupToolTips.bind(this)),this.addVertex=this.addVertex.bind(this),this.firstGraph=this.firstGraph.bind(this),this.setDirection=this.setDirection.bind(this),this.addSubGraph=this.addSubGraph.bind(this),this.addLink=this.addLink.bind(this),this.setLink=this.setLink.bind(this),this.updateLink=this.updateLink.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.destructLink=this.destructLink.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setTooltip=this.setTooltip.bind(this),this.updateLinkInterpolate=this.updateLinkInterpolate.bind(this),this.setClickFun=this.setClickFun.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.lex={firstGraph:this.firstGraph.bind(this)},this.clear(),this.setGen("gen-2")}static{o(this,"FlowDB")}sanitizeText(e){return Ze.sanitizeText(e,this.config)}lookUpDomId(e){for(let r of this.vertices.values())if(r.id===e)return r.domId;return e}addVertex(e,r,n,i,a,s,l={},u){if(!e||e.trim().length===0)return;let h;if(u!==void 0){let m;u.includes(` +`)?m=u+` +`:m=`{ +`+u+` +}`,h=cm(m,{schema:lm})}let f=this.edges.find(m=>m.id===e);if(f){let m=h;m?.animate!==void 0&&(f.animate=m.animate),m?.animation!==void 0&&(f.animation=m.animation);return}let d,p=this.vertices.get(e);if(p===void 0&&(p={id:e,labelType:"text",domId:C_e+e+"-"+this.vertexCounter,styles:[],classes:[]},this.vertices.set(e,p)),this.vertexCounter++,r!==void 0?(this.config=me(),d=this.sanitizeText(r.text.trim()),p.labelType=r.type,d.startsWith('"')&&d.endsWith('"')&&(d=d.substring(1,d.length-1)),p.text=d):p.text===void 0&&(p.text=e),n!==void 0&&(p.type=n),i?.forEach(m=>{p.styles.push(m)}),a?.forEach(m=>{p.classes.push(m)}),s!==void 0&&(p.dir=s),p.props===void 0?p.props=l:l!==void 0&&Object.assign(p.props,l),h!==void 0){if(h.shape){if(h.shape!==h.shape.toLowerCase()||h.shape.includes("_"))throw new Error(`No such shape: ${h.shape}. Shape names should be lowercase.`);if(!WZ(h.shape))throw new Error(`No such shape: ${h.shape}.`);p.type=h?.shape}h?.label&&(p.text=h?.label),h?.icon&&(p.icon=h?.icon,!h.label?.trim()&&p.text===e&&(p.text="")),h?.form&&(p.form=h?.form),h?.pos&&(p.pos=h?.pos),h?.img&&(p.img=h?.img,!h.label?.trim()&&p.text===e&&(p.text="")),h?.constraint&&(p.constraint=h.constraint),h.w&&(p.assetWidth=Number(h.w)),h.h&&(p.assetHeight=Number(h.h))}}addSingleLink(e,r,n,i){let l={start:e,end:r,type:void 0,text:"",labelType:"text",classes:[],isUserDefinedId:!1,interpolate:this.edges.defaultInterpolate};Y.info("abc78 Got edge...",l);let u=n.text;if(u!==void 0&&(l.text=this.sanitizeText(u.text.trim()),l.text.startsWith('"')&&l.text.endsWith('"')&&(l.text=l.text.substring(1,l.text.length-1)),l.labelType=u.type),n!==void 0&&(l.type=n.type,l.stroke=n.stroke,l.length=n.length>10?10:n.length),i&&!this.edges.some(h=>h.id===i))l.id=i,l.isUserDefinedId=!0;else{let h=this.edges.filter(f=>f.start===l.start&&f.end===l.end);h.length===0?l.id=$h(l.start,l.end,{counter:0,prefix:"L"}):l.id=$h(l.start,l.end,{counter:h.length+1,prefix:"L"})}if(this.edges.length<(this.config.maxEdges??500))Y.info("Pushing edge..."),this.edges.push(l);else throw new Error(`Edge limit exceeded. ${this.edges.length} edges found, but the limit is ${this.config.maxEdges}. + +Initialize mermaid with maxEdges set to a higher number to allow more edges. +You cannot set this config via configuration inside the diagram as it is a secure config. +You have to call mermaid.initialize.`)}isLinkData(e){return e!==null&&typeof e=="object"&&"id"in e&&typeof e.id=="string"}addLink(e,r,n){let i=this.isLinkData(n)?n.id.replace("@",""):void 0;Y.info("addLink",e,r,i);for(let a of e)for(let s of r){let l=a===e[e.length-1],u=s===r[0];l&&u?this.addSingleLink(a,s,n,i):this.addSingleLink(a,s,n,void 0)}}updateLinkInterpolate(e,r){e.forEach(n=>{n==="default"?this.edges.defaultInterpolate=r:this.edges[n].interpolate=r})}updateLink(e,r){e.forEach(n=>{if(typeof n=="number"&&n>=this.edges.length)throw new Error(`The index ${n} for linkStyle is out of bounds. Valid indices for linkStyle are between 0 and ${this.edges.length-1}. (Help: Ensure that the index is within the range of existing edges.)`);n==="default"?this.edges.defaultStyle=r:(this.edges[n].style=r,(this.edges[n]?.style?.length??0)>0&&!this.edges[n]?.style?.some(i=>i?.startsWith("fill"))&&this.edges[n]?.style?.push("fill:none"))})}addClass(e,r){let n=r.join().replace(/\\,/g,"\xA7\xA7\xA7").replace(/,/g,";").replace(/§§§/g,",").split(";");e.split(",").forEach(i=>{let a=this.classes.get(i);a===void 0&&(a={id:i,styles:[],textStyles:[]},this.classes.set(i,a)),n?.forEach(s=>{if(/color/.exec(s)){let l=s.replace("fill","bgFill");a.textStyles.push(l)}a.styles.push(s)})})}setDirection(e){this.direction=e,/.*/.exec(this.direction)&&(this.direction="LR"),/.*v/.exec(this.direction)&&(this.direction="TB"),this.direction==="TD"&&(this.direction="TB")}setClass(e,r){for(let n of e.split(",")){let i=this.vertices.get(n);i&&i.classes.push(r);let a=this.edges.find(l=>l.id===n);a&&a.classes.push(r);let s=this.subGraphLookup.get(n);s&&s.classes.push(r)}}setTooltip(e,r){if(r!==void 0){r=this.sanitizeText(r);for(let n of e.split(","))this.tooltips.set(this.version==="gen-1"?this.lookUpDomId(n):n,r)}}setClickFun(e,r,n){let i=this.lookUpDomId(e);if(me().securityLevel!=="loose"||r===void 0)return;let a=[];if(typeof n=="string"){a=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let l=0;l{let l=document.querySelector(`[id="${i}"]`);l!==null&&l.addEventListener("click",()=>{Gt.runFunc(r,...a)},!1)}))}setLink(e,r,n){e.split(",").forEach(i=>{let a=this.vertices.get(i);a!==void 0&&(a.link=Gt.formatUrl(r,this.config),a.linkTarget=n)}),this.setClass(e,"clickable")}getTooltip(e){return this.tooltips.get(e)}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFun(i,r,n)}),this.setClass(e,"clickable")}bindFunctions(e){this.funs.forEach(r=>{r(e)})}getDirection(){return this.direction?.trim()}getVertices(){return this.vertices}getEdges(){return this.edges}getClasses(){return this.classes}setupToolTips(e){let r=Ge(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=Ge("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),Ge(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=Ge(a.currentTarget);if(s.attr("title")===null)return;let u=a.currentTarget?.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.bottom+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),Ge(a.currentTarget).classed("hover",!1)})}clear(e="gen-2"){this.vertices=new Map,this.classes=new Map,this.edges=[],this.funs=[this.setupToolTips.bind(this)],this.subGraphs=[],this.subGraphLookup=new Map,this.subCount=0,this.tooltips=new Map,this.firstGraphFlag=!0,this.version=e,this.config=me(),Ar()}setGen(e){this.version=e||"gen-2"}defaultStyle(){return"fill:#ffa;stroke: #f66; stroke-width: 3px; stroke-dasharray: 5, 5;fill:#ffa;stroke: #666;"}addSubGraph(e,r,n){let i=e.text.trim(),a=n.text;e===n&&/\s/.exec(n.text)&&(i=void 0);let s=o(f=>{let d={boolean:{},number:{},string:{}},p=[],m;return{nodeList:f.filter(function(y){let v=typeof y;return y.stmt&&y.stmt==="dir"?(m=y.value,!1):y.trim()===""?!1:v in d?d[v].hasOwnProperty(y)?!1:d[v][y]=!0:p.includes(y)?!1:p.push(y)}),dir:m}},"uniq"),{nodeList:l,dir:u}=s(r.flat());if(this.version==="gen-1")for(let f=0;f2e3)return{result:!1,count:0};if(this.posCrossRef[this.secCount]=r,this.subGraphs[r].id===e)return{result:!0,count:0};let i=0,a=1;for(;i=0){let l=this.indexNodes2(e,s);if(l.result)return{result:!0,count:a+l.count};a=a+l.count}i=i+1}return{result:!1,count:a}}getDepthFirstPos(e){return this.posCrossRef[e]}indexNodes(){this.secCount=-1,this.subGraphs.length>0&&this.indexNodes2("none",this.subGraphs.length-1)}getSubGraphs(){return this.subGraphs}firstGraph(){return this.firstGraphFlag?(this.firstGraphFlag=!1,!0):!1}destructStartLink(e){let r=e.trim(),n="arrow_open";switch(r[0]){case"<":n="arrow_point",r=r.slice(1);break;case"x":n="arrow_cross",r=r.slice(1);break;case"o":n="arrow_circle",r=r.slice(1);break}let i="normal";return r.includes("=")&&(i="thick"),r.includes(".")&&(i="dotted"),{type:n,stroke:i}}countChar(e,r){let n=r.length,i=0;for(let a=0;a":i="arrow_point",r.startsWith("<")&&(i="double_"+i,n=n.slice(1));break;case"o":i="arrow_circle",r.startsWith("o")&&(i="double_"+i,n=n.slice(1));break}let a="normal",s=n.length-1;n.startsWith("=")&&(a="thick"),n.startsWith("~")&&(a="invisible");let l=this.countChar(".",n);return l&&(a="dotted",s=l),{type:i,stroke:a,length:s}}destructLink(e,r){let n=this.destructEndLink(e),i;if(r){if(i=this.destructStartLink(r),i.stroke!==n.stroke)return{type:"INVALID",stroke:"INVALID"};if(i.type==="arrow_open")i.type=n.type;else{if(i.type!==n.type)return{type:"INVALID",stroke:"INVALID"};i.type="double_"+i.type}return i.type==="double_arrow"&&(i.type="double_arrow_point"),i.length=n.length,i}return n}exists(e,r){for(let n of e)if(n.nodes.includes(r))return!0;return!1}makeUniq(e,r){let n=[];return e.nodes.forEach((i,a)=>{this.exists(r,i)||n.push(e.nodes[a])}),{nodes:n}}getTypeFromVertex(e){if(e.img)return"imageSquare";if(e.icon)return e.form==="circle"?"iconCircle":e.form==="square"?"iconSquare":e.form==="rounded"?"iconRounded":"icon";switch(e.type){case"square":case void 0:return"squareRect";case"round":return"roundedRect";case"ellipse":return"ellipse";default:return e.type}}findNode(e,r){return e.find(n=>n.id===r)}destructEdgeType(e){let r="none",n="arrow_point";switch(e){case"arrow_point":case"arrow_circle":case"arrow_cross":n=e;break;case"double_arrow_point":case"double_arrow_circle":case"double_arrow_cross":r=e.replace("double_",""),n=r;break}return{arrowTypeStart:r,arrowTypeEnd:n}}addNodeFromVertex(e,r,n,i,a,s){let l=n.get(e.id),u=i.get(e.id)??!1,h=this.findNode(r,e.id);if(h)h.cssStyles=e.styles,h.cssCompiledStyles=this.getCompiledStyles(e.classes),h.cssClasses=e.classes.join(" ");else{let f={id:e.id,label:e.text,labelStyle:"",parentId:l,padding:a.flowchart?.padding||8,cssStyles:e.styles,cssCompiledStyles:this.getCompiledStyles(["default","node",...e.classes]),cssClasses:"default "+e.classes.join(" "),dir:e.dir,domId:e.domId,look:s,link:e.link,linkTarget:e.linkTarget,tooltip:this.getTooltip(e.id),icon:e.icon,pos:e.pos,img:e.img,assetWidth:e.assetWidth,assetHeight:e.assetHeight,constraint:e.constraint};u?r.push({...f,isGroup:!0,shape:"rect"}):r.push({...f,isGroup:!1,shape:this.getTypeFromVertex(e)})}}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}getData(){let e=me(),r=[],n=[],i=this.getSubGraphs(),a=new Map,s=new Map;for(let h=i.length-1;h>=0;h--){let f=i[h];f.nodes.length>0&&s.set(f.id,!0);for(let d of f.nodes)a.set(d,f.id)}for(let h=i.length-1;h>=0;h--){let f=i[h];r.push({id:f.id,label:f.title,labelStyle:"",parentId:a.get(f.id),padding:8,cssCompiledStyles:this.getCompiledStyles(f.classes),cssClasses:f.classes.join(" "),shape:"rect",dir:f.dir,isGroup:!0,look:e.look})}this.getVertices().forEach(h=>{this.addNodeFromVertex(h,r,a,s,e,e.look||"classic")});let u=this.getEdges();return u.forEach((h,f)=>{let{arrowTypeStart:d,arrowTypeEnd:p}=this.destructEdgeType(h.type),m=[...u.defaultStyle??[]];h.style&&m.push(...h.style);let g={id:$h(h.start,h.end,{counter:f,prefix:"L"},h.id),isUserDefinedId:h.isUserDefinedId,start:h.start,end:h.end,type:h.type??"normal",label:h.text,labelpos:"c",thickness:h.stroke,minlen:h.length,classes:h?.stroke==="invisible"?"":"edge-thickness-normal edge-pattern-solid flowchart-link",arrowTypeStart:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":d,arrowTypeEnd:h?.stroke==="invisible"||h?.type==="arrow_open"?"none":p,arrowheadStyle:"fill: #333",cssCompiledStyles:this.getCompiledStyles(h.classes),labelStyle:m,style:m,pattern:h.stroke,look:e.look,animate:h.animate,animation:h.animation,curve:h.interpolate||this.edges.defaultInterpolate||e.flowchart?.curve};n.push(g)}),{nodes:r,edges:n,other:{},config:e}}defaultConfig(){return A3.flowchart}}});var yc,gm=N(()=>{"use strict";dr();yc=o((t,e)=>{let r;return e==="sandbox"&&(r=Ge("#i"+t)),(e==="sandbox"?Ge(r.nodes()[0].contentDocument.body):Ge("body")).select(`[id="${t}"]`)},"getDiagramElement")});var Ru,w2=N(()=>{"use strict";Ru=o(({flowchart:t})=>{let e=t?.subGraphTitleMargin?.top??0,r=t?.subGraphTitleMargin?.bottom??0,n=e+r;return{subGraphTitleTopMargin:e,subGraphTitleBottomMargin:r,subGraphTitleTotalMargin:n}},"getSubGraphTitleMargins")});var YZ,A_e,__e,D_e,L_e,R_e,N_e,XZ,ym,jZ,Hw=N(()=>{"use strict";zt();gr();vt();w2();dr();Wt();to();RD();Gw();qh();Ut();YZ=o(async(t,e)=>{Y.info("Creating subgraph rect for ",e.id,e);let r=me(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=Qe(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=fr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await Hn(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0}),y=g.getBBox();if(fr(r.flowchart.htmlLabels)){let A=g.children[0],S=Ge(g);y=A.getBoundingClientRect(),S.attr("width",y.width),S.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,w=e.y-x/2;Y.trace("Data ",e,JSON.stringify(e));let C;if(e.look==="handDrawn"){let A=Xe.svg(d),S=Ke(e,{roughness:.7,fill:a,stroke:s,fillWeight:3,seed:i}),_=A.path(Na(b,w,v,x,0),S);C=d.insert(()=>(Y.debug("Rough node insert CXC",_),_),":first-child"),C.select("path:nth-child(2)").attr("style",h.join(";")),C.select("path").attr("style",f.join(";").replace("fill","stroke"))}else C=d.insert("rect",":first-child"),C.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",w).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:T}=Ru(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+T})`),l){let A=m.select("span");A&&A.attr("style",l)}let E=C.node().getBBox();return e.offsetX=0,e.width=E.width,e.height=E.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Vh(e,A)},{cluster:d,labelBBox:y}},"rect"),A_e=o((t,e)=>{let r=t.insert("g").attr("class","note-cluster").attr("id",e.id),n=r.insert("rect",":first-child"),i=0*e.padding,a=i/2;n.attr("rx",e.rx).attr("ry",e.ry).attr("x",e.x-e.width/2-a).attr("y",e.y-e.height/2-a).attr("width",e.width+i).attr("height",e.height+i).attr("fill","none");let s=n.node().getBBox();return e.width=s.width,e.height=s.height,e.intersect=function(l){return Vh(e,l)},{cluster:r,labelBBox:{width:0,height:0}}},"noteGroup"),__e=o(async(t,e)=>{let r=me(),{themeVariables:n,handDrawnSeed:i}=r,{altBackground:a,compositeBackground:s,compositeTitleBackground:l,nodeBorder:u}=n,h=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-id",e.id).attr("data-look",e.look),f=h.insert("g",":first-child"),d=h.insert("g").attr("class","cluster-label"),p=h.append("rect"),m=d.node().appendChild(await gc(e.label,e.labelStyle,void 0,!0)),g=m.getBBox();if(fr(r.flowchart.htmlLabels)){let _=m.children[0],I=Ge(m);g=_.getBoundingClientRect(),I.attr("width",g.width),I.attr("height",g.height)}let y=0*e.padding,v=y/2,x=(e.width<=g.width+e.padding?g.width+e.padding:e.width)+y;e.width<=g.width+e.padding?e.diff=(x-e.width)/2-e.padding:e.diff=-e.padding;let b=e.height+y,w=e.height+y-g.height-6,C=e.x-x/2,T=e.y-b/2;e.width=x;let E=e.y-e.height/2-v+g.height+2,A;if(e.look==="handDrawn"){let _=e.cssClasses.includes("statediagram-cluster-alt"),I=Xe.svg(h),D=e.rx||e.ry?I.path(Na(C,T,x,b,10),{roughness:.7,fill:l,fillStyle:"solid",stroke:u,seed:i}):I.rectangle(C,T,x,b,{seed:i});A=h.insert(()=>D,":first-child");let k=I.rectangle(C,E,x,w,{fill:_?a:s,fillStyle:_?"hachure":"solid",stroke:u,seed:i});A=h.insert(()=>D,":first-child"),p=h.insert(()=>k)}else A=f.insert("rect",":first-child"),A.attr("class","outer").attr("x",C).attr("y",T).attr("width",x).attr("height",b).attr("data-look",e.look),p.attr("class","inner").attr("x",C).attr("y",E).attr("width",x).attr("height",w);d.attr("transform",`translate(${e.x-g.width/2}, ${T+1-(fr(r.flowchart.htmlLabels)?0:3)})`);let S=A.node().getBBox();return e.height=S.height,e.offsetX=0,e.offsetY=g.height-e.padding/2,e.labelBBox=g,e.intersect=function(_){return Vh(e,_)},{cluster:h,labelBBox:g}},"roundedWithTitle"),D_e=o(async(t,e)=>{Y.info("Creating subgraph rect for ",e.id,e);let r=me(),{themeVariables:n,handDrawnSeed:i}=r,{clusterBkg:a,clusterBorder:s}=n,{labelStyles:l,nodeStyles:u,borderStyles:h,backgroundStyles:f}=Qe(e),d=t.insert("g").attr("class","cluster "+e.cssClasses).attr("id",e.id).attr("data-look",e.look),p=fr(r.flowchart.htmlLabels),m=d.insert("g").attr("class","cluster-label "),g=await Hn(m,e.label,{style:e.labelStyle,useHtmlLabels:p,isNode:!0,width:e.width}),y=g.getBBox();if(fr(r.flowchart.htmlLabels)){let A=g.children[0],S=Ge(g);y=A.getBoundingClientRect(),S.attr("width",y.width),S.attr("height",y.height)}let v=e.width<=y.width+e.padding?y.width+e.padding:e.width;e.width<=y.width+e.padding?e.diff=(v-e.width)/2-e.padding:e.diff=-e.padding;let x=e.height,b=e.x-v/2,w=e.y-x/2;Y.trace("Data ",e,JSON.stringify(e));let C;if(e.look==="handDrawn"){let A=Xe.svg(d),S=Ke(e,{roughness:.7,fill:a,stroke:s,fillWeight:4,seed:i}),_=A.path(Na(b,w,v,x,e.rx),S);C=d.insert(()=>(Y.debug("Rough node insert CXC",_),_),":first-child"),C.select("path:nth-child(2)").attr("style",h.join(";")),C.select("path").attr("style",f.join(";").replace("fill","stroke"))}else C=d.insert("rect",":first-child"),C.attr("style",u).attr("rx",e.rx).attr("ry",e.ry).attr("x",b).attr("y",w).attr("width",v).attr("height",x);let{subGraphTitleTopMargin:T}=Ru(r);if(m.attr("transform",`translate(${e.x-y.width/2}, ${e.y-e.height/2+T})`),l){let A=m.select("span");A&&A.attr("style",l)}let E=C.node().getBBox();return e.offsetX=0,e.width=E.width,e.height=E.height,e.offsetY=y.height-e.padding/2,e.intersect=function(A){return Vh(e,A)},{cluster:d,labelBBox:y}},"kanbanSection"),L_e=o((t,e)=>{let r=me(),{themeVariables:n,handDrawnSeed:i}=r,{nodeBorder:a}=n,s=t.insert("g").attr("class",e.cssClasses).attr("id",e.id).attr("data-look",e.look),l=s.insert("g",":first-child"),u=0*e.padding,h=e.width+u;e.diff=-e.padding;let f=e.height+u,d=e.x-h/2,p=e.y-f/2;e.width=h;let m;if(e.look==="handDrawn"){let v=Xe.svg(s).rectangle(d,p,h,f,{fill:"lightgrey",roughness:.5,strokeLineDash:[5],stroke:a,seed:i});m=s.insert(()=>v,":first-child")}else m=l.insert("rect",":first-child"),m.attr("class","divider").attr("x",d).attr("y",p).attr("width",h).attr("height",f).attr("data-look",e.look);let g=m.node().getBBox();return e.height=g.height,e.offsetX=0,e.offsetY=0,e.intersect=function(y){return Vh(e,y)},{cluster:s,labelBBox:{}}},"divider"),R_e=YZ,N_e={rect:YZ,squareRect:R_e,roundedWithTitle:__e,noteGroup:A_e,divider:L_e,kanbanSection:D_e},XZ=new Map,ym=o(async(t,e)=>{let r=e.shape||"rect",n=await N_e[r](t,e);return XZ.set(e.id,n),n},"insertCluster"),jZ=o(()=>{XZ=new Map},"clear")});function Ww(t,e){if(t===void 0||e===void 0)return{angle:0,deltaX:0,deltaY:0};t=Wn(t),e=Wn(e);let[r,n]=[t.x,t.y],[i,a]=[e.x,e.y],s=i-r,l=a-n;return{angle:Math.atan(l/s),deltaX:s,deltaY:l}}var $o,Wn,qw,JD=N(()=>{"use strict";$o={aggregation:18,extension:18,composition:18,dependency:6,lollipop:13.5,arrow_point:4};o(Ww,"calculateDeltaAndAngle");Wn=o(t=>Array.isArray(t)?{x:t[0],y:t[1]}:t,"pointTransformer"),qw=o(t=>({x:o(function(e,r,n){let i=0,a=Wn(n[0]).x=0?1:-1)}else if(r===n.length-1&&Object.hasOwn($o,t.arrowTypeEnd)){let{angle:m,deltaX:g}=Ww(n[n.length-1],n[n.length-2]);i=$o[t.arrowTypeEnd]*Math.cos(m)*(g>=0?1:-1)}let s=Math.abs(Wn(e).x-Wn(n[n.length-1]).x),l=Math.abs(Wn(e).y-Wn(n[n.length-1]).y),u=Math.abs(Wn(e).x-Wn(n[0]).x),h=Math.abs(Wn(e).y-Wn(n[0]).y),f=$o[t.arrowTypeStart],d=$o[t.arrowTypeEnd],p=1;if(s0&&l0&&h=0?1:-1)}else if(r===n.length-1&&Object.hasOwn($o,t.arrowTypeEnd)){let{angle:m,deltaY:g}=Ww(n[n.length-1],n[n.length-2]);i=$o[t.arrowTypeEnd]*Math.abs(Math.sin(m))*(g>=0?1:-1)}let s=Math.abs(Wn(e).y-Wn(n[n.length-1]).y),l=Math.abs(Wn(e).x-Wn(n[n.length-1]).x),u=Math.abs(Wn(e).y-Wn(n[0]).y),h=Math.abs(Wn(e).x-Wn(n[0]).x),f=$o[t.arrowTypeStart],d=$o[t.arrowTypeEnd],p=1;if(s0&&l0&&h{"use strict";vt();QZ=o((t,e,r,n,i,a)=>{e.arrowTypeStart&&KZ(t,"start",e.arrowTypeStart,r,n,i,a),e.arrowTypeEnd&&KZ(t,"end",e.arrowTypeEnd,r,n,i,a)},"addEdgeMarkers"),M_e={arrow_cross:{type:"cross",fill:!1},arrow_point:{type:"point",fill:!0},arrow_barb:{type:"barb",fill:!0},arrow_circle:{type:"circle",fill:!1},aggregation:{type:"aggregation",fill:!1},extension:{type:"extension",fill:!1},composition:{type:"composition",fill:!0},dependency:{type:"dependency",fill:!0},lollipop:{type:"lollipop",fill:!1},only_one:{type:"onlyOne",fill:!1},zero_or_one:{type:"zeroOrOne",fill:!1},one_or_more:{type:"oneOrMore",fill:!1},zero_or_more:{type:"zeroOrMore",fill:!1},requirement_arrow:{type:"requirement_arrow",fill:!1},requirement_contains:{type:"requirement_contains",fill:!1}},KZ=o((t,e,r,n,i,a,s)=>{let l=M_e[r];if(!l){Y.warn(`Unknown arrow type: ${r}`);return}let u=l.type,f=`${i}_${a}-${u}${e==="start"?"Start":"End"}`;if(s&&s.trim()!==""){let d=s.replace(/[^\dA-Za-z]/g,"_"),p=`${f}_${d}`;if(!document.getElementById(p)){let m=document.getElementById(f);if(m){let g=m.cloneNode(!0);g.id=p,g.querySelectorAll("path, circle, line").forEach(v=>{v.setAttribute("stroke",s),l.fill&&v.setAttribute("fill",s)}),m.parentNode?.appendChild(g)}}t.attr(`marker-${e}`,`url(${n}#${p})`)}else t.attr(`marker-${e}`,`url(${n}#${f})`)},"addEdgeMarker")});function Yw(t,e){me().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}function P_e(t){let e=[],r=[];for(let n=1;n5&&Math.abs(a.y-i.y)>5||i.y===a.y&&a.x===s.x&&Math.abs(a.x-i.x)>5&&Math.abs(a.y-s.y)>5)&&(e.push(a),r.push(n))}return{cornerPoints:e,cornerPointPositions:r}}var Xw,pa,tJ,T2,jw,Kw,I_e,O_e,JZ,eJ,B_e,Qw,eL=N(()=>{"use strict";zt();gr();vt();to();ir();JD();w2();dr();Wt();Gw();ZZ();Ut();Xw=new Map,pa=new Map,tJ=o(()=>{Xw.clear(),pa.clear()},"clear"),T2=o(t=>t?t.reduce((r,n)=>r+";"+n,""):"","getLabelStyles"),jw=o(async(t,e)=>{let r=fr(me().flowchart.htmlLabels),n=await Hn(t,e.label,{style:T2(e.labelStyle),useHtmlLabels:r,addSvgBackground:!0,isNode:!1});Y.info("abc82",e,e.labelType);let i=t.insert("g").attr("class","edgeLabel"),a=i.insert("g").attr("class","label");a.node().appendChild(n);let s=n.getBBox();if(r){let u=n.children[0],h=Ge(n);s=u.getBoundingClientRect(),h.attr("width",s.width),h.attr("height",s.height)}a.attr("transform","translate("+-s.width/2+", "+-s.height/2+")"),Xw.set(e.id,i),e.width=s.width,e.height=s.height;let l;if(e.startLabelLeft){let u=await gc(e.startLabelLeft,T2(e.labelStyle)),h=t.insert("g").attr("class","edgeTerminals"),f=h.insert("g").attr("class","inner");l=f.node().appendChild(u);let d=u.getBBox();f.attr("transform","translate("+-d.width/2+", "+-d.height/2+")"),pa.get(e.id)||pa.set(e.id,{}),pa.get(e.id).startLeft=h,Yw(l,e.startLabelLeft)}if(e.startLabelRight){let u=await gc(e.startLabelRight,T2(e.labelStyle)),h=t.insert("g").attr("class","edgeTerminals"),f=h.insert("g").attr("class","inner");l=h.node().appendChild(u),f.node().appendChild(u);let d=u.getBBox();f.attr("transform","translate("+-d.width/2+", "+-d.height/2+")"),pa.get(e.id)||pa.set(e.id,{}),pa.get(e.id).startRight=h,Yw(l,e.startLabelRight)}if(e.endLabelLeft){let u=await gc(e.endLabelLeft,T2(e.labelStyle)),h=t.insert("g").attr("class","edgeTerminals"),f=h.insert("g").attr("class","inner");l=f.node().appendChild(u);let d=u.getBBox();f.attr("transform","translate("+-d.width/2+", "+-d.height/2+")"),h.node().appendChild(u),pa.get(e.id)||pa.set(e.id,{}),pa.get(e.id).endLeft=h,Yw(l,e.endLabelLeft)}if(e.endLabelRight){let u=await gc(e.endLabelRight,T2(e.labelStyle)),h=t.insert("g").attr("class","edgeTerminals"),f=h.insert("g").attr("class","inner");l=f.node().appendChild(u);let d=u.getBBox();f.attr("transform","translate("+-d.width/2+", "+-d.height/2+")"),h.node().appendChild(u),pa.get(e.id)||pa.set(e.id,{}),pa.get(e.id).endRight=h,Yw(l,e.endLabelRight)}return n},"insertEdgeLabel");o(Yw,"setTerminalWidth");Kw=o((t,e)=>{Y.debug("Moving label abc88 ",t.id,t.label,Xw.get(t.id),e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=me(),{subGraphTitleTotalMargin:i}=Ru(n);if(t.label){let a=Xw.get(t.id),s=t.x,l=t.y;if(r){let u=Gt.calcLabelPosition(r);Y.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=pa.get(t.id).startLeft,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=pa.get(t.id).startRight,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=pa.get(t.id).endLeft,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=pa.get(t.id).endRight,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),I_e=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),O_e=o((t,e,r)=>{Y.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{Y.warn("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(Y.info("abc88 checking point",a,e),!I_e(e,a)&&!i){let s=O_e(e,n,a);Y.debug("abc88 inside",a,n,s),Y.debug("abc88 intersection",s,e);let l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)?Y.warn("abc88 no intersect",s,r):r.push(s),i=!0}else Y.warn("abc88 outside",a,n),n=a,i||r.push(a)}),Y.debug("returning points",r),r},"cutPathAtIntersect");o(P_e,"extractCornerPoints");eJ=o(function(t,e,r){let n=e.x-t.x,i=e.y-t.y,a=Math.sqrt(n*n+i*i),s=r/a;return{x:e.x-s*n,y:e.y-s*i}},"findAdjacentPoint"),B_e=o(function(t){let{cornerPointPositions:e}=P_e(t),r=[];for(let n=0;n10&&Math.abs(a.y-i.y)>=10){Y.debug("Corner point fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));let m=5;s.x===l.x?p={x:h<0?l.x-m+d:l.x+m-d,y:f<0?l.y-d:l.y+d}:p={x:h<0?l.x-d:l.x+d,y:f<0?l.y-m+d:l.y+m-d}}else Y.debug("Corner point skipping fixing",Math.abs(a.x-i.x),Math.abs(a.y-i.y));r.push(p,u)}else r.push(t[n]);return r},"fixCorners"),Qw=o(function(t,e,r,n,i,a,s){let{handDrawnSeed:l}=me(),u=e.points,h=!1,f=i;var d=a;let p=[];for(let _ in e.cssCompiledStyles)ND(_)||p.push(e.cssCompiledStyles[_]);d.intersect&&f.intersect&&(u=u.slice(1,e.points.length-1),u.unshift(f.intersect(u[0])),Y.debug("Last point APA12",e.start,"-->",e.end,u[u.length-1],d,d.intersect(u[u.length-1])),u.push(d.intersect(u[u.length-1]))),e.toCluster&&(Y.info("to cluster abc88",r.get(e.toCluster)),u=JZ(e.points,r.get(e.toCluster).node),h=!0),e.fromCluster&&(Y.debug("from cluster abc88",r.get(e.fromCluster),JSON.stringify(u,null,2)),u=JZ(u.reverse(),r.get(e.fromCluster).node).reverse(),h=!0);let m=u.filter(_=>!Number.isNaN(_.y));m=B_e(m);let g=Do;switch(g=wu,e.curve){case"linear":g=wu;break;case"basis":g=Do;break;case"cardinal":g=Pv;break;case"bumpX":g=Rv;break;case"bumpY":g=Nv;break;case"catmullRom":g=$v;break;case"monotoneX":g=zv;break;case"monotoneY":g=Gv;break;case"natural":g=F0;break;case"step":g=$0;break;case"stepAfter":g=Uv;break;case"stepBefore":g=Vv;break;default:g=Do}let{x:y,y:v}=qw(e),x=wl().x(y).y(v).curve(g),b;switch(e.thickness){case"normal":b="edge-thickness-normal";break;case"thick":b="edge-thickness-thick";break;case"invisible":b="edge-thickness-invisible";break;default:b="edge-thickness-normal"}switch(e.pattern){case"solid":b+=" edge-pattern-solid";break;case"dotted":b+=" edge-pattern-dotted";break;case"dashed":b+=" edge-pattern-dashed";break;default:b+=" edge-pattern-solid"}let w,C=x(m),T=Array.isArray(e.style)?e.style:[e.style],E=T.find(_=>_?.startsWith("stroke:"));if(e.look==="handDrawn"){let _=Xe.svg(t);Object.assign([],m);let I=_.path(C,{roughness:.3,seed:l});b+=" transition",w=Ge(I).select("path").attr("id",e.id).attr("class"," "+b+(e.classes?" "+e.classes:"")).attr("style",T?T.reduce((k,L)=>k+";"+L,""):"");let D=w.attr("d");w.attr("d",D),t.node().appendChild(w.node())}else{let _=p.join(";"),I=T?T.reduce((L,R)=>L+R+";",""):"",D="";e.animate&&(D=" edge-animation-fast"),e.animation&&(D=" edge-animation-"+e.animation);let k=_?_+";"+I+";":I;w=t.append("path").attr("d",C).attr("id",e.id).attr("class"," "+b+(e.classes?" "+e.classes:"")+(D??"")).attr("style",k),E=k.match(/stroke:([^;]+)/)?.[1]}let A="";(me().flowchart.arrowMarkerAbsolute||me().state.arrowMarkerAbsolute)&&(A=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,A=A.replace(/\(/g,"\\(").replace(/\)/g,"\\)")),Y.info("arrowTypeStart",e.arrowTypeStart),Y.info("arrowTypeEnd",e.arrowTypeEnd),QZ(w,e,A,s,n,E);let S={};return h&&(S.updatedPath=u),S.originalPath=e.points,S},"insertEdge")});var F_e,$_e,z_e,G_e,V_e,U_e,H_e,W_e,q_e,Y_e,X_e,j_e,K_e,Q_e,Z_e,J_e,e9e,Zw,tL=N(()=>{"use strict";vt();F_e=o((t,e,r,n)=>{e.forEach(i=>{e9e[i](t,r,n)})},"insertMarkers"),$_e=o((t,e,r)=>{Y.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),z_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),G_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),V_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),U_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),H_e=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),W_e=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),q_e=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),Y_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","userSpaceOnUse").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),X_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneStart").attr("class","marker onlyOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M9,0 L9,18 M15,0 L15,18"),t.append("defs").append("marker").attr("id",r+"_"+e+"-onlyOneEnd").attr("class","marker onlyOne "+e).attr("refX",18).attr("refY",9).attr("markerWidth",18).attr("markerHeight",18).attr("orient","auto").append("path").attr("d","M3,0 L3,18 M9,0 L9,18")},"only_one"),j_e=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneStart").attr("class","marker zeroOrOne "+e).attr("refX",0).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",21).attr("cy",9).attr("r",6),n.append("path").attr("d","M9,0 L9,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrOneEnd").attr("class","marker zeroOrOne "+e).attr("refX",30).attr("refY",9).attr("markerWidth",30).attr("markerHeight",18).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",9).attr("r",6),i.append("path").attr("d","M21,0 L21,18")},"zero_or_one"),K_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreStart").attr("class","marker oneOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27"),t.append("defs").append("marker").attr("id",r+"_"+e+"-oneOrMoreEnd").attr("class","marker oneOrMore "+e).attr("refX",27).attr("refY",18).attr("markerWidth",45).attr("markerHeight",36).attr("orient","auto").append("path").attr("d","M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18")},"one_or_more"),Q_e=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreStart").attr("class","marker zeroOrMore "+e).attr("refX",18).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");n.append("circle").attr("fill","white").attr("cx",48).attr("cy",18).attr("r",6),n.append("path").attr("d","M0,18 Q18,0 36,18 Q18,36 0,18");let i=t.append("defs").append("marker").attr("id",r+"_"+e+"-zeroOrMoreEnd").attr("class","marker zeroOrMore "+e).attr("refX",39).attr("refY",18).attr("markerWidth",57).attr("markerHeight",36).attr("orient","auto");i.append("circle").attr("fill","white").attr("cx",9).attr("cy",18).attr("r",6),i.append("path").attr("d","M21,18 Q39,0 57,18 Q39,36 21,18")},"zero_or_more"),Z_e=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_arrowEnd").attr("refX",20).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("path").attr("d",`M0,0 + L20,10 + M20,10 + L0,20`)},"requirement_arrow"),J_e=o((t,e,r)=>{let n=t.append("defs").append("marker").attr("id",r+"_"+e+"-requirement_containsStart").attr("refX",0).attr("refY",10).attr("markerWidth",20).attr("markerHeight",20).attr("orient","auto").append("g");n.append("circle").attr("cx",10).attr("cy",10).attr("r",9).attr("fill","none"),n.append("line").attr("x1",1).attr("x2",19).attr("y1",10).attr("y2",10),n.append("line").attr("y1",1).attr("y2",19).attr("x1",10).attr("x2",10)},"requirement_contains"),e9e={extension:$_e,composition:z_e,aggregation:G_e,dependency:V_e,lollipop:U_e,point:H_e,circle:W_e,cross:q_e,barb:Y_e,only_one:X_e,zero_or_one:j_e,one_or_more:K_e,zero_or_more:Q_e,requirement_arrow:Z_e,requirement_contains:J_e},Zw=F_e});async function vm(t,e,r){let n,i;e.shape==="rect"&&(e.rx&&e.ry?e.shape="roundedRect":e.shape="squareRect");let a=e.shape?QD[e.shape]:void 0;if(!a)throw new Error(`No such shape: ${e.shape}. Please check your syntax.`);if(e.link){let s;r.config.securityLevel==="sandbox"?s="_top":e.linkTarget&&(s=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",s??null),i=await a(n,e,r)}else i=await a(t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),Jw.set(e.id,n),e.haveCallback&&n.attr("class",n.attr("class")+" clickable"),n}var Jw,rJ,nJ,k2,eT=N(()=>{"use strict";vt();ZD();Jw=new Map;o(vm,"insertNode");rJ=o((t,e)=>{Jw.set(e.id,t)},"setNodeElem"),nJ=o(()=>{Jw.clear()},"clear"),k2=o(t=>{let e=Jw.get(t.id);Y.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});var iJ,aJ=N(()=>{"use strict";ji();gr();vt();Hw();eL();tL();eT();Ft();ir();iJ={common:Ze,getConfig:cr,insertCluster:ym,insertEdge:Qw,insertEdgeLabel:jw,insertMarkers:Zw,insertNode:vm,interpolateToCurve:W9,labelHelper:pt,log:Y,positionEdgeLabel:Kw}});function r9e(t){return typeof t=="symbol"||ri(t)&&da(t)==t9e}var t9e,no,Pd=N(()=>{"use strict";ku();No();t9e="[object Symbol]";o(r9e,"isSymbol");no=r9e});function n9e(t,e){for(var r=-1,n=t==null?0:t.length,i=Array(n);++r{"use strict";o(n9e,"arrayMap");Ns=n9e});function lJ(t){if(typeof t=="string")return t;if(Pt(t))return Ns(t,lJ)+"";if(no(t))return oJ?oJ.call(t):"";var e=t+"";return e=="0"&&1/t==-i9e?"-0":e}var i9e,sJ,oJ,cJ,uJ=N(()=>{"use strict";Ed();Bd();Un();Pd();i9e=1/0,sJ=ea?ea.prototype:void 0,oJ=sJ?sJ.toString:void 0;o(lJ,"baseToString");cJ=lJ});function s9e(t){for(var e=t.length;e--&&a9e.test(t.charAt(e)););return e}var a9e,hJ,fJ=N(()=>{"use strict";a9e=/\s/;o(s9e,"trimmedEndIndex");hJ=s9e});function l9e(t){return t&&t.slice(0,hJ(t)+1).replace(o9e,"")}var o9e,dJ,pJ=N(()=>{"use strict";fJ();o9e=/^\s+/;o(l9e,"baseTrim");dJ=l9e});function d9e(t){if(typeof t=="number")return t;if(no(t))return mJ;if(bn(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=bn(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=dJ(t);var r=u9e.test(t);return r||h9e.test(t)?f9e(t.slice(2),r?2:8):c9e.test(t)?mJ:+t}var mJ,c9e,u9e,h9e,f9e,gJ,yJ=N(()=>{"use strict";pJ();Js();Pd();mJ=NaN,c9e=/^[-+]0x[0-9a-f]+$/i,u9e=/^0b[01]+$/i,h9e=/^0o[0-7]+$/i,f9e=parseInt;o(d9e,"toNumber");gJ=d9e});function m9e(t){if(!t)return t===0?t:0;if(t=gJ(t),t===vJ||t===-vJ){var e=t<0?-1:1;return e*p9e}return t===t?t:0}var vJ,p9e,xm,rL=N(()=>{"use strict";yJ();vJ=1/0,p9e=17976931348623157e292;o(m9e,"toFinite");xm=m9e});function g9e(t){var e=xm(t),r=e%1;return e===e?r?e-r:e:0}var vc,bm=N(()=>{"use strict";rL();o(g9e,"toInteger");vc=g9e});var y9e,tT,xJ=N(()=>{"use strict";Lh();Lo();y9e=Ss(li,"WeakMap"),tT=y9e});function v9e(){}var ni,nL=N(()=>{"use strict";o(v9e,"noop");ni=v9e});function x9e(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(x9e,"arrayEach");rT=x9e});function b9e(t,e,r,n){for(var i=t.length,a=r+(n?1:-1);n?a--:++a{"use strict";o(b9e,"baseFindIndex");nT=b9e});function w9e(t){return t!==t}var bJ,wJ=N(()=>{"use strict";o(w9e,"baseIsNaN");bJ=w9e});function T9e(t,e,r){for(var n=r-1,i=t.length;++n{"use strict";o(T9e,"strictIndexOf");TJ=T9e});function k9e(t,e,r){return e===e?TJ(t,e,r):nT(t,bJ,r)}var wm,iT=N(()=>{"use strict";aL();wJ();kJ();o(k9e,"baseIndexOf");wm=k9e});function E9e(t,e){var r=t==null?0:t.length;return!!r&&wm(t,e,0)>-1}var aT,sL=N(()=>{"use strict";iT();o(E9e,"arrayIncludes");aT=E9e});var S9e,EJ,SJ=N(()=>{"use strict";N9();S9e=nw(Object.keys,Object),EJ=S9e});function _9e(t){if(!uc(t))return EJ(t);var e=[];for(var r in Object(t))A9e.call(t,r)&&r!="constructor"&&e.push(r);return e}var C9e,A9e,Tm,sT=N(()=>{"use strict";Z0();SJ();C9e=Object.prototype,A9e=C9e.hasOwnProperty;o(_9e,"baseKeys");Tm=_9e});function D9e(t){return ci(t)?lw(t):Tm(t)}var zr,xc=N(()=>{"use strict";B9();sT();Mo();o(D9e,"keys");zr=D9e});var L9e,R9e,N9e,ma,CJ=N(()=>{"use strict";rm();Dd();G9();Mo();Z0();xc();L9e=Object.prototype,R9e=L9e.hasOwnProperty,N9e=hw(function(t,e){if(uc(e)||ci(e)){Po(e,zr(e),t);return}for(var r in e)R9e.call(e,r)&&hc(t,r,e[r])}),ma=N9e});function O9e(t,e){if(Pt(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||no(t)?!0:I9e.test(t)||!M9e.test(t)||e!=null&&t in Object(e)}var M9e,I9e,km,oT=N(()=>{"use strict";Un();Pd();M9e=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,I9e=/^\w*$/;o(O9e,"isKey");km=O9e});function B9e(t){var e=H0(t,function(n){return r.size===P9e&&r.clear(),n}),r=e.cache;return e}var P9e,AJ,_J=N(()=>{"use strict";S9();P9e=500;o(B9e,"memoizeCapped");AJ=B9e});var F9e,$9e,z9e,DJ,LJ=N(()=>{"use strict";_J();F9e=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,$9e=/\\(\\)?/g,z9e=AJ(function(t){var e=[];return t.charCodeAt(0)===46&&e.push(""),t.replace(F9e,function(r,n,i,a){e.push(i?a.replace($9e,"$1"):n||r)}),e}),DJ=z9e});function G9e(t){return t==null?"":cJ(t)}var lT,oL=N(()=>{"use strict";uJ();o(G9e,"toString");lT=G9e});function V9e(t,e){return Pt(t)?t:km(t,e)?[t]:DJ(lT(t))}var Yh,E2=N(()=>{"use strict";Un();oT();LJ();oL();o(V9e,"castPath");Yh=V9e});function H9e(t){if(typeof t=="string"||no(t))return t;var e=t+"";return e=="0"&&1/t==-U9e?"-0":e}var U9e,bc,Em=N(()=>{"use strict";Pd();U9e=1/0;o(H9e,"toKey");bc=H9e});function W9e(t,e){e=Yh(e,t);for(var r=0,n=e.length;t!=null&&r{"use strict";E2();Em();o(W9e,"baseGet");Xh=W9e});function q9e(t,e,r){var n=t==null?void 0:Xh(t,e);return n===void 0?r:n}var RJ,NJ=N(()=>{"use strict";S2();o(q9e,"get");RJ=q9e});function Y9e(t,e){for(var r=-1,n=e.length,i=t.length;++r{"use strict";o(Y9e,"arrayPush");Sm=Y9e});function X9e(t){return Pt(t)||El(t)||!!(MJ&&t&&t[MJ])}var MJ,IJ,OJ=N(()=>{"use strict";Ed();J0();Un();MJ=ea?ea.isConcatSpreadable:void 0;o(X9e,"isFlattenable");IJ=X9e});function PJ(t,e,r,n,i){var a=-1,s=t.length;for(r||(r=IJ),i||(i=[]);++a0&&r(l)?e>1?PJ(l,e-1,r,n,i):Sm(i,l):n||(i[i.length]=l)}return i}var wc,Cm=N(()=>{"use strict";cT();OJ();o(PJ,"baseFlatten");wc=PJ});function j9e(t){var e=t==null?0:t.length;return e?wc(t,1):[]}var qr,uT=N(()=>{"use strict";Cm();o(j9e,"flatten");qr=j9e});function K9e(t){return uw(cw(t,void 0,qr),t+"")}var BJ,FJ=N(()=>{"use strict";uT();F9();z9();o(K9e,"flatRest");BJ=K9e});function Q9e(t,e,r){var n=-1,i=t.length;e<0&&(e=-e>i?0:i+e),r=r>i?i:r,r<0&&(r+=i),i=e>r?0:r-e>>>0,e>>>=0;for(var a=Array(i);++n{"use strict";o(Q9e,"baseSlice");hT=Q9e});function sDe(t){return aDe.test(t)}var Z9e,J9e,eDe,tDe,rDe,nDe,iDe,aDe,$J,zJ=N(()=>{"use strict";Z9e="\\ud800-\\udfff",J9e="\\u0300-\\u036f",eDe="\\ufe20-\\ufe2f",tDe="\\u20d0-\\u20ff",rDe=J9e+eDe+tDe,nDe="\\ufe0e\\ufe0f",iDe="\\u200d",aDe=RegExp("["+iDe+Z9e+rDe+nDe+"]");o(sDe,"hasUnicode");$J=sDe});function oDe(t,e,r,n){var i=-1,a=t==null?0:t.length;for(n&&a&&(r=t[++i]);++i{"use strict";o(oDe,"arrayReduce");GJ=oDe});function lDe(t,e){return t&&Po(e,zr(e),t)}var UJ,HJ=N(()=>{"use strict";Dd();xc();o(lDe,"baseAssign");UJ=lDe});function cDe(t,e){return t&&Po(e,Cs(e),t)}var WJ,qJ=N(()=>{"use strict";Dd();Bh();o(cDe,"baseAssignIn");WJ=cDe});function uDe(t,e){for(var r=-1,n=t==null?0:t.length,i=0,a=[];++r{"use strict";o(uDe,"arrayFilter");Am=uDe});function hDe(){return[]}var dT,cL=N(()=>{"use strict";o(hDe,"stubArray");dT=hDe});var fDe,dDe,YJ,pDe,_m,pT=N(()=>{"use strict";fT();cL();fDe=Object.prototype,dDe=fDe.propertyIsEnumerable,YJ=Object.getOwnPropertySymbols,pDe=YJ?function(t){return t==null?[]:(t=Object(t),Am(YJ(t),function(e){return dDe.call(t,e)}))}:dT,_m=pDe});function mDe(t,e){return Po(t,_m(t),e)}var XJ,jJ=N(()=>{"use strict";Dd();pT();o(mDe,"copySymbols");XJ=mDe});var gDe,yDe,mT,uL=N(()=>{"use strict";cT();iw();pT();cL();gDe=Object.getOwnPropertySymbols,yDe=gDe?function(t){for(var e=[];t;)Sm(e,_m(t)),t=Q0(t);return e}:dT,mT=yDe});function vDe(t,e){return Po(t,mT(t),e)}var KJ,QJ=N(()=>{"use strict";Dd();uL();o(vDe,"copySymbolsIn");KJ=vDe});function xDe(t,e,r){var n=e(t);return Pt(t)?n:Sm(n,r(t))}var gT,hL=N(()=>{"use strict";cT();Un();o(xDe,"baseGetAllKeys");gT=xDe});function bDe(t){return gT(t,zr,_m)}var C2,fL=N(()=>{"use strict";hL();pT();xc();o(bDe,"getAllKeys");C2=bDe});function wDe(t){return gT(t,Cs,mT)}var yT,dL=N(()=>{"use strict";hL();uL();Bh();o(wDe,"getAllKeysIn");yT=wDe});var TDe,vT,ZJ=N(()=>{"use strict";Lh();Lo();TDe=Ss(li,"DataView"),vT=TDe});var kDe,xT,JJ=N(()=>{"use strict";Lh();Lo();kDe=Ss(li,"Promise"),xT=kDe});var EDe,jh,pL=N(()=>{"use strict";Lh();Lo();EDe=Ss(li,"Set"),jh=EDe});var eee,SDe,tee,ree,nee,iee,CDe,ADe,_De,DDe,LDe,Fd,io,$d=N(()=>{"use strict";ZJ();K5();JJ();pL();xJ();ku();T9();eee="[object Map]",SDe="[object Object]",tee="[object Promise]",ree="[object Set]",nee="[object WeakMap]",iee="[object DataView]",CDe=Eu(vT),ADe=Eu(Mh),_De=Eu(xT),DDe=Eu(jh),LDe=Eu(tT),Fd=da;(vT&&Fd(new vT(new ArrayBuffer(1)))!=iee||Mh&&Fd(new Mh)!=eee||xT&&Fd(xT.resolve())!=tee||jh&&Fd(new jh)!=ree||tT&&Fd(new tT)!=nee)&&(Fd=o(function(t){var e=da(t),r=e==SDe?t.constructor:void 0,n=r?Eu(r):"";if(n)switch(n){case CDe:return iee;case ADe:return eee;case _De:return tee;case DDe:return ree;case LDe:return nee}return e},"getTag"));io=Fd});function MDe(t){var e=t.length,r=new t.constructor(e);return e&&typeof t[0]=="string"&&NDe.call(t,"index")&&(r.index=t.index,r.input=t.input),r}var RDe,NDe,aee,see=N(()=>{"use strict";RDe=Object.prototype,NDe=RDe.hasOwnProperty;o(MDe,"initCloneArray");aee=MDe});function IDe(t,e){var r=e?K0(t.buffer):t.buffer;return new t.constructor(r,t.byteOffset,t.byteLength)}var oee,lee=N(()=>{"use strict";ew();o(IDe,"cloneDataView");oee=IDe});function PDe(t){var e=new t.constructor(t.source,ODe.exec(t));return e.lastIndex=t.lastIndex,e}var ODe,cee,uee=N(()=>{"use strict";ODe=/\w*$/;o(PDe,"cloneRegExp");cee=PDe});function BDe(t){return fee?Object(fee.call(t)):{}}var hee,fee,dee,pee=N(()=>{"use strict";Ed();hee=ea?ea.prototype:void 0,fee=hee?hee.valueOf:void 0;o(BDe,"cloneSymbol");dee=BDe});function nLe(t,e,r){var n=t.constructor;switch(e){case qDe:return K0(t);case FDe:case $De:return new n(+t);case YDe:return oee(t,r);case XDe:case jDe:case KDe:case QDe:case ZDe:case JDe:case eLe:case tLe:case rLe:return tw(t,r);case zDe:return new n;case GDe:case HDe:return new n(t);case VDe:return cee(t);case UDe:return new n;case WDe:return dee(t)}}var FDe,$De,zDe,GDe,VDe,UDe,HDe,WDe,qDe,YDe,XDe,jDe,KDe,QDe,ZDe,JDe,eLe,tLe,rLe,mee,gee=N(()=>{"use strict";ew();lee();uee();pee();L9();FDe="[object Boolean]",$De="[object Date]",zDe="[object Map]",GDe="[object Number]",VDe="[object RegExp]",UDe="[object Set]",HDe="[object String]",WDe="[object Symbol]",qDe="[object ArrayBuffer]",YDe="[object DataView]",XDe="[object Float32Array]",jDe="[object Float64Array]",KDe="[object Int8Array]",QDe="[object Int16Array]",ZDe="[object Int32Array]",JDe="[object Uint8Array]",eLe="[object Uint8ClampedArray]",tLe="[object Uint16Array]",rLe="[object Uint32Array]";o(nLe,"initCloneByTag");mee=nLe});function aLe(t){return ri(t)&&io(t)==iLe}var iLe,yee,vee=N(()=>{"use strict";$d();No();iLe="[object Map]";o(aLe,"baseIsMap");yee=aLe});var xee,sLe,bee,wee=N(()=>{"use strict";vee();_d();t2();xee=Oo&&Oo.isMap,sLe=xee?Io(xee):yee,bee=sLe});function lLe(t){return ri(t)&&io(t)==oLe}var oLe,Tee,kee=N(()=>{"use strict";$d();No();oLe="[object Set]";o(lLe,"baseIsSet");Tee=lLe});var Eee,cLe,See,Cee=N(()=>{"use strict";kee();_d();t2();Eee=Oo&&Oo.isSet,cLe=Eee?Io(Eee):Tee,See=cLe});function bT(t,e,r,n,i,a){var s,l=e&uLe,u=e&hLe,h=e&fLe;if(r&&(s=i?r(t,n,i,a):r(t)),s!==void 0)return s;if(!bn(t))return t;var f=Pt(t);if(f){if(s=aee(t),!l)return rw(t,s)}else{var d=io(t),p=d==_ee||d==yLe;if(Sl(t))return J5(t,l);if(d==Dee||d==Aee||p&&!i){if(s=u||p?{}:aw(t),!l)return u?KJ(t,WJ(s,t)):XJ(t,UJ(s,t))}else{if(!_n[d])return i?t:{};s=mee(t,d,l)}}a||(a=new lc);var m=a.get(t);if(m)return m;a.set(t,s),See(t)?t.forEach(function(v){s.add(bT(v,e,r,v,t,a))}):bee(t)&&t.forEach(function(v,x){s.set(x,bT(v,e,r,x,t,a))});var g=h?u?yT:C2:u?Cs:zr,y=f?void 0:g(t);return rT(y||t,function(v,x){y&&(x=v,v=t[x]),hc(s,x,bT(v,e,r,x,t,a))}),s}var uLe,hLe,fLe,Aee,dLe,pLe,mLe,gLe,_ee,yLe,vLe,xLe,Dee,bLe,wLe,TLe,kLe,ELe,SLe,CLe,ALe,_Le,DLe,LLe,RLe,NLe,MLe,ILe,OLe,_n,wT,mL=N(()=>{"use strict";Zv();iL();rm();HJ();qJ();_9();R9();jJ();QJ();fL();dL();$d();see();gee();M9();Un();tm();wee();Js();Cee();xc();Bh();uLe=1,hLe=2,fLe=4,Aee="[object Arguments]",dLe="[object Array]",pLe="[object Boolean]",mLe="[object Date]",gLe="[object Error]",_ee="[object Function]",yLe="[object GeneratorFunction]",vLe="[object Map]",xLe="[object Number]",Dee="[object Object]",bLe="[object RegExp]",wLe="[object Set]",TLe="[object String]",kLe="[object Symbol]",ELe="[object WeakMap]",SLe="[object ArrayBuffer]",CLe="[object DataView]",ALe="[object Float32Array]",_Le="[object Float64Array]",DLe="[object Int8Array]",LLe="[object Int16Array]",RLe="[object Int32Array]",NLe="[object Uint8Array]",MLe="[object Uint8ClampedArray]",ILe="[object Uint16Array]",OLe="[object Uint32Array]",_n={};_n[Aee]=_n[dLe]=_n[SLe]=_n[CLe]=_n[pLe]=_n[mLe]=_n[ALe]=_n[_Le]=_n[DLe]=_n[LLe]=_n[RLe]=_n[vLe]=_n[xLe]=_n[Dee]=_n[bLe]=_n[wLe]=_n[TLe]=_n[kLe]=_n[NLe]=_n[MLe]=_n[ILe]=_n[OLe]=!0;_n[gLe]=_n[_ee]=_n[ELe]=!1;o(bT,"baseClone");wT=bT});function BLe(t){return wT(t,PLe)}var PLe,an,gL=N(()=>{"use strict";mL();PLe=4;o(BLe,"clone");an=BLe});function zLe(t){return wT(t,FLe|$Le)}var FLe,$Le,yL,Lee=N(()=>{"use strict";mL();FLe=1,$Le=4;o(zLe,"cloneDeep");yL=zLe});function GLe(t){for(var e=-1,r=t==null?0:t.length,n=0,i=[];++e{"use strict";o(GLe,"compact");Tc=GLe});function ULe(t){return this.__data__.set(t,VLe),this}var VLe,Nee,Mee=N(()=>{"use strict";VLe="__lodash_hash_undefined__";o(ULe,"setCacheAdd");Nee=ULe});function HLe(t){return this.__data__.has(t)}var Iee,Oee=N(()=>{"use strict";o(HLe,"setCacheHas");Iee=HLe});function TT(t){var e=-1,r=t==null?0:t.length;for(this.__data__=new Cd;++e{"use strict";Q5();Mee();Oee();o(TT,"SetCache");TT.prototype.add=TT.prototype.push=Nee;TT.prototype.has=Iee;Dm=TT});function WLe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(WLe,"arraySome");ET=WLe});function qLe(t,e){return t.has(e)}var Lm,ST=N(()=>{"use strict";o(qLe,"cacheHas");Lm=qLe});function jLe(t,e,r,n,i,a){var s=r&YLe,l=t.length,u=e.length;if(l!=u&&!(s&&u>l))return!1;var h=a.get(t),f=a.get(e);if(h&&f)return h==e&&f==t;var d=-1,p=!0,m=r&XLe?new Dm:void 0;for(a.set(t,e),a.set(e,t);++d{"use strict";kT();vL();ST();YLe=1,XLe=2;o(jLe,"equalArrays");CT=jLe});function KLe(t){var e=-1,r=Array(t.size);return t.forEach(function(n,i){r[++e]=[i,n]}),r}var Pee,Bee=N(()=>{"use strict";o(KLe,"mapToArray");Pee=KLe});function QLe(t){var e=-1,r=Array(t.size);return t.forEach(function(n){r[++e]=n}),r}var Rm,AT=N(()=>{"use strict";o(QLe,"setToArray");Rm=QLe});function hRe(t,e,r,n,i,a,s){switch(r){case uRe:if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)return!1;t=t.buffer,e=e.buffer;case cRe:return!(t.byteLength!=e.byteLength||!a(new j0(t),new j0(e)));case eRe:case tRe:case iRe:return Ro(+t,+e);case rRe:return t.name==e.name&&t.message==e.message;case aRe:case oRe:return t==e+"";case nRe:var l=Pee;case sRe:var u=n&ZLe;if(l||(l=Rm),t.size!=e.size&&!u)return!1;var h=s.get(t);if(h)return h==e;n|=JLe,s.set(t,e);var f=CT(l(t),l(e),n,i,a,s);return s.delete(t),f;case lRe:if(bL)return bL.call(t)==bL.call(e)}return!1}var ZLe,JLe,eRe,tRe,rRe,nRe,iRe,aRe,sRe,oRe,lRe,cRe,uRe,Fee,bL,$ee,zee=N(()=>{"use strict";Ed();D9();Sd();xL();Bee();AT();ZLe=1,JLe=2,eRe="[object Boolean]",tRe="[object Date]",rRe="[object Error]",nRe="[object Map]",iRe="[object Number]",aRe="[object RegExp]",sRe="[object Set]",oRe="[object String]",lRe="[object Symbol]",cRe="[object ArrayBuffer]",uRe="[object DataView]",Fee=ea?ea.prototype:void 0,bL=Fee?Fee.valueOf:void 0;o(hRe,"equalByTag");$ee=hRe});function mRe(t,e,r,n,i,a){var s=r&fRe,l=C2(t),u=l.length,h=C2(e),f=h.length;if(u!=f&&!s)return!1;for(var d=u;d--;){var p=l[d];if(!(s?p in e:pRe.call(e,p)))return!1}var m=a.get(t),g=a.get(e);if(m&&g)return m==e&&g==t;var y=!0;a.set(t,e),a.set(e,t);for(var v=s;++d{"use strict";fL();fRe=1,dRe=Object.prototype,pRe=dRe.hasOwnProperty;o(mRe,"equalObjects");Gee=mRe});function vRe(t,e,r,n,i,a){var s=Pt(t),l=Pt(e),u=s?Hee:io(t),h=l?Hee:io(e);u=u==Uee?_T:u,h=h==Uee?_T:h;var f=u==_T,d=h==_T,p=u==h;if(p&&Sl(t)){if(!Sl(e))return!1;s=!0,f=!1}if(p&&!f)return a||(a=new lc),s||Oh(t)?CT(t,e,r,n,i,a):$ee(t,e,u,r,n,i,a);if(!(r&gRe)){var m=f&&Wee.call(t,"__wrapped__"),g=d&&Wee.call(e,"__wrapped__");if(m||g){var y=m?t.value():t,v=g?e.value():e;return a||(a=new lc),i(y,v,r,n,a)}}return p?(a||(a=new lc),Gee(t,e,r,n,i,a)):!1}var gRe,Uee,Hee,_T,yRe,Wee,qee,Yee=N(()=>{"use strict";Zv();xL();zee();Vee();$d();Un();tm();r2();gRe=1,Uee="[object Arguments]",Hee="[object Array]",_T="[object Object]",yRe=Object.prototype,Wee=yRe.hasOwnProperty;o(vRe,"baseIsEqualDeep");qee=vRe});function Xee(t,e,r,n,i){return t===e?!0:t==null||e==null||!ri(t)&&!ri(e)?t!==t&&e!==e:qee(t,e,r,n,Xee,i)}var DT,wL=N(()=>{"use strict";Yee();No();o(Xee,"baseIsEqual");DT=Xee});function wRe(t,e,r,n){var i=r.length,a=i,s=!n;if(t==null)return!a;for(t=Object(t);i--;){var l=r[i];if(s&&l[2]?l[1]!==t[l[0]]:!(l[0]in t))return!1}for(;++i{"use strict";Zv();wL();xRe=1,bRe=2;o(wRe,"baseIsMatch");jee=wRe});function TRe(t){return t===t&&!bn(t)}var LT,TL=N(()=>{"use strict";Js();o(TRe,"isStrictComparable");LT=TRe});function kRe(t){for(var e=zr(t),r=e.length;r--;){var n=e[r],i=t[n];e[r]=[n,i,LT(i)]}return e}var Qee,Zee=N(()=>{"use strict";TL();xc();o(kRe,"getMatchData");Qee=kRe});function ERe(t,e){return function(r){return r==null?!1:r[t]===e&&(e!==void 0||t in Object(r))}}var RT,kL=N(()=>{"use strict";o(ERe,"matchesStrictComparable");RT=ERe});function SRe(t){var e=Qee(t);return e.length==1&&e[0][2]?RT(e[0][0],e[0][1]):function(r){return r===t||jee(r,t,e)}}var Jee,ete=N(()=>{"use strict";Kee();Zee();kL();o(SRe,"baseMatches");Jee=SRe});function CRe(t,e){return t!=null&&e in Object(t)}var tte,rte=N(()=>{"use strict";o(CRe,"baseHasIn");tte=CRe});function ARe(t,e,r){e=Yh(e,t);for(var n=-1,i=e.length,a=!1;++n{"use strict";E2();J0();Un();i2();sw();Em();o(ARe,"hasPath");NT=ARe});function _Re(t,e){return t!=null&&NT(t,e,tte)}var MT,SL=N(()=>{"use strict";rte();EL();o(_Re,"hasIn");MT=_Re});function RRe(t,e){return km(t)&<(e)?RT(bc(t),e):function(r){var n=RJ(r,t);return n===void 0&&n===e?MT(r,t):DT(e,n,DRe|LRe)}}var DRe,LRe,nte,ite=N(()=>{"use strict";wL();NJ();SL();oT();TL();kL();Em();DRe=1,LRe=2;o(RRe,"baseMatchesProperty");nte=RRe});function NRe(t){return function(e){return e?.[t]}}var IT,CL=N(()=>{"use strict";o(NRe,"baseProperty");IT=NRe});function MRe(t){return function(e){return Xh(e,t)}}var ate,ste=N(()=>{"use strict";S2();o(MRe,"basePropertyDeep");ate=MRe});function IRe(t){return km(t)?IT(bc(t)):ate(t)}var ote,lte=N(()=>{"use strict";CL();ste();oT();Em();o(IRe,"property");ote=IRe});function ORe(t){return typeof t=="function"?t:t==null?ta:typeof t=="object"?Pt(t)?nte(t[0],t[1]):Jee(t):ote(t)}var pn,rs=N(()=>{"use strict";ete();ite();Cu();Un();lte();o(ORe,"baseIteratee");pn=ORe});function PRe(t,e,r,n){for(var i=-1,a=t==null?0:t.length;++i{"use strict";o(PRe,"arrayAggregator");cte=PRe});function BRe(t,e){return t&&X0(t,e,zr)}var Nm,OT=N(()=>{"use strict";Z5();xc();o(BRe,"baseForOwn");Nm=BRe});function FRe(t,e){return function(r,n){if(r==null)return r;if(!ci(r))return t(r,n);for(var i=r.length,a=e?i:-1,s=Object(r);(e?a--:++a{"use strict";Mo();o(FRe,"createBaseEach");hte=FRe});var $Re,Ms,Kh=N(()=>{"use strict";OT();fte();$Re=hte(Nm),Ms=$Re});function zRe(t,e,r,n){return Ms(t,function(i,a,s){e(n,i,r(i),s)}),n}var dte,pte=N(()=>{"use strict";Kh();o(zRe,"baseAggregator");dte=zRe});function GRe(t,e){return function(r,n){var i=Pt(r)?cte:dte,a=e?e():{};return i(r,t,pn(n,2),a)}}var mte,gte=N(()=>{"use strict";ute();pte();rs();Un();o(GRe,"createAggregator");mte=GRe});var VRe,PT,yte=N(()=>{"use strict";Lo();VRe=o(function(){return li.Date.now()},"now"),PT=VRe});var vte,URe,HRe,Qh,xte=N(()=>{"use strict";nm();Sd();Ld();Bh();vte=Object.prototype,URe=vte.hasOwnProperty,HRe=fc(function(t,e){t=Object(t);var r=-1,n=e.length,i=n>2?e[2]:void 0;for(i&&eo(e[0],e[1],i)&&(n=1);++r{"use strict";o(WRe,"arrayIncludesWith");BT=WRe});function YRe(t,e,r,n){var i=-1,a=aT,s=!0,l=t.length,u=[],h=e.length;if(!l)return u;r&&(e=Ns(e,Io(r))),n?(a=BT,s=!1):e.length>=qRe&&(a=Lm,s=!1,e=new Dm(e));e:for(;++i{"use strict";kT();sL();AL();Bd();_d();ST();qRe=200;o(YRe,"baseDifference");bte=YRe});var XRe,Zh,Tte=N(()=>{"use strict";wte();Cm();nm();ow();XRe=fc(function(t,e){return Ad(t)?bte(t,wc(e,1,Ad,!0)):[]}),Zh=XRe});function jRe(t){var e=t==null?0:t.length;return e?t[e-1]:void 0}var ga,kte=N(()=>{"use strict";o(jRe,"last");ga=jRe});function KRe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:vc(e),hT(t,e<0?0:e,n)):[]}var gi,Ete=N(()=>{"use strict";lL();bm();o(KRe,"drop");gi=KRe});function QRe(t,e,r){var n=t==null?0:t.length;return n?(e=r||e===void 0?1:vc(e),e=n-e,hT(t,0,e<0?0:e)):[]}var Nu,Ste=N(()=>{"use strict";lL();bm();o(QRe,"dropRight");Nu=QRe});function ZRe(t){return typeof t=="function"?t:ta}var Mm,FT=N(()=>{"use strict";Cu();o(ZRe,"castFunction");Mm=ZRe});function JRe(t,e){var r=Pt(t)?rT:Ms;return r(t,Mm(e))}var Ae,$T=N(()=>{"use strict";iL();Kh();FT();Un();o(JRe,"forEach");Ae=JRe});var Cte=N(()=>{"use strict";$T()});function eNe(t,e){for(var r=-1,n=t==null?0:t.length;++r{"use strict";o(eNe,"arrayEvery");Ate=eNe});function tNe(t,e){var r=!0;return Ms(t,function(n,i,a){return r=!!e(n,i,a),r}),r}var Dte,Lte=N(()=>{"use strict";Kh();o(tNe,"baseEvery");Dte=tNe});function rNe(t,e,r){var n=Pt(t)?Ate:Dte;return r&&eo(t,e,r)&&(e=void 0),n(t,pn(e,3))}var Ma,Rte=N(()=>{"use strict";_te();Lte();rs();Un();Ld();o(rNe,"every");Ma=rNe});function nNe(t,e){var r=[];return Ms(t,function(n,i,a){e(n,i,a)&&r.push(n)}),r}var zT,_L=N(()=>{"use strict";Kh();o(nNe,"baseFilter");zT=nNe});function iNe(t,e){var r=Pt(t)?Am:zT;return r(t,pn(e,3))}var Yr,DL=N(()=>{"use strict";fT();_L();rs();Un();o(iNe,"filter");Yr=iNe});function aNe(t){return function(e,r,n){var i=Object(e);if(!ci(e)){var a=pn(r,3);e=zr(e),r=o(function(l){return a(i[l],l,i)},"predicate")}var s=t(e,r,n);return s>-1?i[a?e[s]:s]:void 0}}var Nte,Mte=N(()=>{"use strict";rs();Mo();xc();o(aNe,"createFind");Nte=aNe});function oNe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:vc(r);return i<0&&(i=sNe(n+i,0)),nT(t,pn(e,3),i)}var sNe,Ite,Ote=N(()=>{"use strict";aL();rs();bm();sNe=Math.max;o(oNe,"findIndex");Ite=oNe});var lNe,ns,Pte=N(()=>{"use strict";Mte();Ote();lNe=Nte(Ite),ns=lNe});function cNe(t){return t&&t.length?t[0]:void 0}var ia,Bte=N(()=>{"use strict";o(cNe,"head");ia=cNe});var Fte=N(()=>{"use strict";Bte()});function uNe(t,e){var r=-1,n=ci(t)?Array(t.length):[];return Ms(t,function(i,a,s){n[++r]=e(i,a,s)}),n}var GT,LL=N(()=>{"use strict";Kh();Mo();o(uNe,"baseMap");GT=uNe});function hNe(t,e){var r=Pt(t)?Ns:GT;return r(t,pn(e,3))}var Je,Im=N(()=>{"use strict";Bd();rs();LL();Un();o(hNe,"map");Je=hNe});function fNe(t,e){return wc(Je(t,e),1)}var ya,RL=N(()=>{"use strict";Cm();Im();o(fNe,"flatMap");ya=fNe});function dNe(t,e){return t==null?t:X0(t,Mm(e),Cs)}var NL,$te=N(()=>{"use strict";Z5();FT();Bh();o(dNe,"forIn");NL=dNe});function pNe(t,e){return t&&Nm(t,Mm(e))}var ML,zte=N(()=>{"use strict";OT();FT();o(pNe,"forOwn");ML=pNe});var mNe,gNe,yNe,IL,Gte=N(()=>{"use strict";Y0();gte();mNe=Object.prototype,gNe=mNe.hasOwnProperty,yNe=mte(function(t,e,r){gNe.call(t,r)?t[r].push(e):cc(t,r,[e])}),IL=yNe});function vNe(t,e){return t>e}var Vte,Ute=N(()=>{"use strict";o(vNe,"baseGt");Vte=vNe});function wNe(t,e){return t!=null&&bNe.call(t,e)}var xNe,bNe,Hte,Wte=N(()=>{"use strict";xNe=Object.prototype,bNe=xNe.hasOwnProperty;o(wNe,"baseHas");Hte=wNe});function TNe(t,e){return t!=null&&NT(t,e,Hte)}var Bt,qte=N(()=>{"use strict";Wte();EL();o(TNe,"has");Bt=TNe});function ENe(t){return typeof t=="string"||!Pt(t)&&ri(t)&&da(t)==kNe}var kNe,yi,VT=N(()=>{"use strict";ku();Un();No();kNe="[object String]";o(ENe,"isString");yi=ENe});function SNe(t,e){return Ns(e,function(r){return t[r]})}var Yte,Xte=N(()=>{"use strict";Bd();o(SNe,"baseValues");Yte=SNe});function CNe(t){return t==null?[]:Yte(t,zr(t))}var br,OL=N(()=>{"use strict";Xte();xc();o(CNe,"values");br=CNe});function _Ne(t,e,r,n){t=ci(t)?t:br(t),r=r&&!n?vc(r):0;var i=t.length;return r<0&&(r=ANe(i+r,0)),yi(t)?r<=i&&t.indexOf(e,r)>-1:!!i&&wm(t,e,r)>-1}var ANe,qn,jte=N(()=>{"use strict";iT();Mo();VT();bm();OL();ANe=Math.max;o(_Ne,"includes");qn=_Ne});function LNe(t,e,r){var n=t==null?0:t.length;if(!n)return-1;var i=r==null?0:vc(r);return i<0&&(i=DNe(n+i,0)),wm(t,e,i)}var DNe,UT,Kte=N(()=>{"use strict";iT();bm();DNe=Math.max;o(LNe,"indexOf");UT=LNe});function ONe(t){if(t==null)return!0;if(ci(t)&&(Pt(t)||typeof t=="string"||typeof t.splice=="function"||Sl(t)||Oh(t)||El(t)))return!t.length;var e=io(t);if(e==RNe||e==NNe)return!t.size;if(uc(t))return!Tm(t).length;for(var r in t)if(INe.call(t,r))return!1;return!0}var RNe,NNe,MNe,INe,ur,HT=N(()=>{"use strict";sT();$d();J0();Un();Mo();tm();Z0();r2();RNe="[object Map]",NNe="[object Set]",MNe=Object.prototype,INe=MNe.hasOwnProperty;o(ONe,"isEmpty");ur=ONe});function BNe(t){return ri(t)&&da(t)==PNe}var PNe,Qte,Zte=N(()=>{"use strict";ku();No();PNe="[object RegExp]";o(BNe,"baseIsRegExp");Qte=BNe});var Jte,FNe,zo,ere=N(()=>{"use strict";Zte();_d();t2();Jte=Oo&&Oo.isRegExp,FNe=Jte?Io(Jte):Qte,zo=FNe});function $Ne(t){return t===void 0}var pr,tre=N(()=>{"use strict";o($Ne,"isUndefined");pr=$Ne});function zNe(t,e){return t{"use strict";o(zNe,"baseLt");WT=zNe});function GNe(t,e){var r={};return e=pn(e,3),Nm(t,function(n,i,a){cc(r,i,e(n,i,a))}),r}var zd,rre=N(()=>{"use strict";Y0();OT();rs();o(GNe,"mapValues");zd=GNe});function VNe(t,e,r){for(var n=-1,i=t.length;++n{"use strict";Pd();o(VNe,"baseExtremum");Om=VNe});function UNe(t){return t&&t.length?Om(t,ta,Vte):void 0}var Is,nre=N(()=>{"use strict";qT();Ute();Cu();o(UNe,"max");Is=UNe});function HNe(t){return t&&t.length?Om(t,ta,WT):void 0}var Dl,BL=N(()=>{"use strict";qT();PL();Cu();o(HNe,"min");Dl=HNe});function WNe(t,e){return t&&t.length?Om(t,pn(e,2),WT):void 0}var Gd,ire=N(()=>{"use strict";qT();rs();PL();o(WNe,"minBy");Gd=WNe});function YNe(t){if(typeof t!="function")throw new TypeError(qNe);return function(){var e=arguments;switch(e.length){case 0:return!t.call(this);case 1:return!t.call(this,e[0]);case 2:return!t.call(this,e[0],e[1]);case 3:return!t.call(this,e[0],e[1],e[2])}return!t.apply(this,e)}}var qNe,are,sre=N(()=>{"use strict";qNe="Expected a function";o(YNe,"negate");are=YNe});function XNe(t,e,r,n){if(!bn(t))return t;e=Yh(e,t);for(var i=-1,a=e.length,s=a-1,l=t;l!=null&&++i{"use strict";rm();E2();i2();Js();Em();o(XNe,"baseSet");ore=XNe});function jNe(t,e,r){for(var n=-1,i=e.length,a={};++n{"use strict";S2();lre();E2();o(jNe,"basePickBy");YT=jNe});function KNe(t,e){if(t==null)return{};var r=Ns(yT(t),function(n){return[n]});return e=pn(e),YT(t,r,function(n,i){return e(n,i[0])})}var Os,cre=N(()=>{"use strict";Bd();rs();FL();dL();o(KNe,"pickBy");Os=KNe});function QNe(t,e){var r=t.length;for(t.sort(e);r--;)t[r]=t[r].value;return t}var ure,hre=N(()=>{"use strict";o(QNe,"baseSortBy");ure=QNe});function ZNe(t,e){if(t!==e){var r=t!==void 0,n=t===null,i=t===t,a=no(t),s=e!==void 0,l=e===null,u=e===e,h=no(e);if(!l&&!h&&!a&&t>e||a&&s&&u&&!l&&!h||n&&s&&u||!r&&u||!i)return 1;if(!n&&!a&&!h&&t{"use strict";Pd();o(ZNe,"compareAscending");fre=ZNe});function JNe(t,e,r){for(var n=-1,i=t.criteria,a=e.criteria,s=i.length,l=r.length;++n=l)return u;var h=r[n];return u*(h=="desc"?-1:1)}}return t.index-e.index}var pre,mre=N(()=>{"use strict";dre();o(JNe,"compareMultiple");pre=JNe});function eMe(t,e,r){e.length?e=Ns(e,function(a){return Pt(a)?function(s){return Xh(s,a.length===1?a[0]:a)}:a}):e=[ta];var n=-1;e=Ns(e,Io(pn));var i=GT(t,function(a,s,l){var u=Ns(e,function(h){return h(a)});return{criteria:u,index:++n,value:a}});return ure(i,function(a,s){return pre(a,s,r)})}var gre,yre=N(()=>{"use strict";Bd();S2();rs();LL();hre();_d();mre();Cu();Un();o(eMe,"baseOrderBy");gre=eMe});var tMe,vre,xre=N(()=>{"use strict";CL();tMe=IT("length"),vre=tMe});function dMe(t){for(var e=bre.lastIndex=0;bre.test(t);)++e;return e}var wre,rMe,nMe,iMe,aMe,sMe,oMe,$L,zL,lMe,Tre,kre,Ere,cMe,Sre,Cre,uMe,hMe,fMe,bre,Are,_re=N(()=>{"use strict";wre="\\ud800-\\udfff",rMe="\\u0300-\\u036f",nMe="\\ufe20-\\ufe2f",iMe="\\u20d0-\\u20ff",aMe=rMe+nMe+iMe,sMe="\\ufe0e\\ufe0f",oMe="["+wre+"]",$L="["+aMe+"]",zL="\\ud83c[\\udffb-\\udfff]",lMe="(?:"+$L+"|"+zL+")",Tre="[^"+wre+"]",kre="(?:\\ud83c[\\udde6-\\uddff]){2}",Ere="[\\ud800-\\udbff][\\udc00-\\udfff]",cMe="\\u200d",Sre=lMe+"?",Cre="["+sMe+"]?",uMe="(?:"+cMe+"(?:"+[Tre,kre,Ere].join("|")+")"+Cre+Sre+")*",hMe=Cre+Sre+uMe,fMe="(?:"+[Tre+$L+"?",$L,kre,Ere,oMe].join("|")+")",bre=RegExp(zL+"(?="+zL+")|"+fMe+hMe,"g");o(dMe,"unicodeSize");Are=dMe});function pMe(t){return $J(t)?Are(t):vre(t)}var Dre,Lre=N(()=>{"use strict";xre();zJ();_re();o(pMe,"stringSize");Dre=pMe});function mMe(t,e){return YT(t,e,function(r,n){return MT(t,n)})}var Rre,Nre=N(()=>{"use strict";FL();SL();o(mMe,"basePick");Rre=mMe});var gMe,Vd,Mre=N(()=>{"use strict";Nre();FJ();gMe=BJ(function(t,e){return t==null?{}:Rre(t,e)}),Vd=gMe});function xMe(t,e,r,n){for(var i=-1,a=vMe(yMe((e-t)/(r||1)),0),s=Array(a);a--;)s[n?a:++i]=t,t+=r;return s}var yMe,vMe,Ire,Ore=N(()=>{"use strict";yMe=Math.ceil,vMe=Math.max;o(xMe,"baseRange");Ire=xMe});function bMe(t){return function(e,r,n){return n&&typeof n!="number"&&eo(e,r,n)&&(r=n=void 0),e=xm(e),r===void 0?(r=e,e=0):r=xm(r),n=n===void 0?e{"use strict";Ore();Ld();rL();o(bMe,"createRange");Pre=bMe});var wMe,Go,Fre=N(()=>{"use strict";Bre();wMe=Pre(),Go=wMe});function TMe(t,e,r,n,i){return i(t,function(a,s,l){r=n?(n=!1,a):e(r,a,s,l)}),r}var $re,zre=N(()=>{"use strict";o(TMe,"baseReduce");$re=TMe});function kMe(t,e,r){var n=Pt(t)?GJ:$re,i=arguments.length<3;return n(t,pn(e,4),r,i,Ms)}var Xr,GL=N(()=>{"use strict";VJ();Kh();rs();zre();Un();o(kMe,"reduce");Xr=kMe});function EMe(t,e){var r=Pt(t)?Am:zT;return r(t,are(pn(e,3)))}var Jh,Gre=N(()=>{"use strict";fT();_L();rs();Un();sre();o(EMe,"reject");Jh=EMe});function AMe(t){if(t==null)return 0;if(ci(t))return yi(t)?Dre(t):t.length;var e=io(t);return e==SMe||e==CMe?t.size:Tm(t).length}var SMe,CMe,VL,Vre=N(()=>{"use strict";sT();$d();Mo();VT();Lre();SMe="[object Map]",CMe="[object Set]";o(AMe,"size");VL=AMe});function _Me(t,e){var r;return Ms(t,function(n,i,a){return r=e(n,i,a),!r}),!!r}var Ure,Hre=N(()=>{"use strict";Kh();o(_Me,"baseSome");Ure=_Me});function DMe(t,e,r){var n=Pt(t)?ET:Ure;return r&&eo(t,e,r)&&(e=void 0),n(t,pn(e,3))}var A2,Wre=N(()=>{"use strict";vL();rs();Hre();Un();Ld();o(DMe,"some");A2=DMe});var LMe,kc,qre=N(()=>{"use strict";Cm();yre();nm();Ld();LMe=fc(function(t,e){if(t==null)return[];var r=e.length;return r>1&&eo(t,e[0],e[1])?e=[]:r>2&&eo(e[0],e[1],e[2])&&(e=[e[0]]),gre(t,wc(e,1),[])}),kc=LMe});var RMe,NMe,Yre,Xre=N(()=>{"use strict";pL();nL();AT();RMe=1/0,NMe=jh&&1/Rm(new jh([,-0]))[1]==RMe?function(t){return new jh(t)}:ni,Yre=NMe});function IMe(t,e,r){var n=-1,i=aT,a=t.length,s=!0,l=[],u=l;if(r)s=!1,i=BT;else if(a>=MMe){var h=e?null:Yre(t);if(h)return Rm(h);s=!1,i=Lm,u=new Dm}else u=e?[]:l;e:for(;++n{"use strict";kT();sL();AL();ST();Xre();AT();MMe=200;o(IMe,"baseUniq");Pm=IMe});var OMe,UL,jre=N(()=>{"use strict";Cm();nm();XT();ow();OMe=fc(function(t){return Pm(wc(t,1,Ad,!0))}),UL=OMe});function PMe(t){return t&&t.length?Pm(t):[]}var Bm,Kre=N(()=>{"use strict";XT();o(PMe,"uniq");Bm=PMe});function BMe(t,e){return t&&t.length?Pm(t,pn(e,2)):[]}var Qre,Zre=N(()=>{"use strict";rs();XT();o(BMe,"uniqBy");Qre=BMe});function $Me(t){var e=++FMe;return lT(t)+e}var FMe,Ud,Jre=N(()=>{"use strict";oL();FMe=0;o($Me,"uniqueId");Ud=$Me});function zMe(t,e,r){for(var n=-1,i=t.length,a=e.length,s={};++n{"use strict";o(zMe,"baseZipObject");ene=zMe});function GMe(t,e){return ene(t||[],e||[],hc)}var jT,rne=N(()=>{"use strict";rm();tne();o(GMe,"zipObject");jT=GMe});var qt=N(()=>{"use strict";CJ();gL();Lee();Ree();$9();xte();Tte();Ete();Ste();Cte();Rte();DL();Pte();Fte();RL();uT();$T();$te();zte();Gte();qte();Cu();jte();Kte();Un();HT();Yv();Js();ere();VT();tre();xc();kte();Im();rre();nre();V9();BL();ire();nL();yte();Mre();cre();Fre();GL();Gre();Vre();Wre();qre();jre();Kre();Jre();OL();rne();});function ine(t,e){t[e]?t[e]++:t[e]=1}function ane(t,e){--t[e]||delete t[e]}function _2(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}return i+nne+a+nne+(pr(n)?VMe:n)}function UMe(t,e,r,n){var i=""+e,a=""+r;if(!t&&i>a){var s=i;i=a,a=s}var l={v:i,w:a};return n&&(l.name=n),l}function HL(t,e){return _2(t,e.v,e.w,e.name)}var VMe,Hd,nne,sn,KT=N(()=>{"use strict";qt();VMe="\0",Hd="\0",nne="",sn=class{static{o(this,"Graph")}constructor(e={}){this._isDirected=Object.prototype.hasOwnProperty.call(e,"directed")?e.directed:!0,this._isMultigraph=Object.prototype.hasOwnProperty.call(e,"multigraph")?e.multigraph:!1,this._isCompound=Object.prototype.hasOwnProperty.call(e,"compound")?e.compound:!1,this._label=void 0,this._defaultNodeLabelFn=As(void 0),this._defaultEdgeLabelFn=As(void 0),this._nodes={},this._isCompound&&(this._parent={},this._children={},this._children[Hd]={}),this._in={},this._preds={},this._out={},this._sucs={},this._edgeObjs={},this._edgeLabels={}}isDirected(){return this._isDirected}isMultigraph(){return this._isMultigraph}isCompound(){return this._isCompound}setGraph(e){return this._label=e,this}graph(){return this._label}setDefaultNodeLabel(e){return Si(e)||(e=As(e)),this._defaultNodeLabelFn=e,this}nodeCount(){return this._nodeCount}nodes(){return zr(this._nodes)}sources(){var e=this;return Yr(this.nodes(),function(r){return ur(e._in[r])})}sinks(){var e=this;return Yr(this.nodes(),function(r){return ur(e._out[r])})}setNodes(e,r){var n=arguments,i=this;return Ae(e,function(a){n.length>1?i.setNode(a,r):i.setNode(a)}),this}setNode(e,r){return Object.prototype.hasOwnProperty.call(this._nodes,e)?(arguments.length>1&&(this._nodes[e]=r),this):(this._nodes[e]=arguments.length>1?r:this._defaultNodeLabelFn(e),this._isCompound&&(this._parent[e]=Hd,this._children[e]={},this._children[Hd][e]=!0),this._in[e]={},this._preds[e]={},this._out[e]={},this._sucs[e]={},++this._nodeCount,this)}node(e){return this._nodes[e]}hasNode(e){return Object.prototype.hasOwnProperty.call(this._nodes,e)}removeNode(e){if(Object.prototype.hasOwnProperty.call(this._nodes,e)){var r=o(n=>this.removeEdge(this._edgeObjs[n]),"removeEdge");delete this._nodes[e],this._isCompound&&(this._removeFromParentsChildList(e),delete this._parent[e],Ae(this.children(e),n=>{this.setParent(n)}),delete this._children[e]),Ae(zr(this._in[e]),r),delete this._in[e],delete this._preds[e],Ae(zr(this._out[e]),r),delete this._out[e],delete this._sucs[e],--this._nodeCount}return this}setParent(e,r){if(!this._isCompound)throw new Error("Cannot set parent in a non-compound graph");if(pr(r))r=Hd;else{r+="";for(var n=r;!pr(n);n=this.parent(n))if(n===e)throw new Error("Setting "+r+" as parent of "+e+" would create a cycle");this.setNode(r)}return this.setNode(e),this._removeFromParentsChildList(e),this._parent[e]=r,this._children[r][e]=!0,this}_removeFromParentsChildList(e){delete this._children[this._parent[e]][e]}parent(e){if(this._isCompound){var r=this._parent[e];if(r!==Hd)return r}}children(e){if(pr(e)&&(e=Hd),this._isCompound){var r=this._children[e];if(r)return zr(r)}else{if(e===Hd)return this.nodes();if(this.hasNode(e))return[]}}predecessors(e){var r=this._preds[e];if(r)return zr(r)}successors(e){var r=this._sucs[e];if(r)return zr(r)}neighbors(e){var r=this.predecessors(e);if(r)return UL(r,this.successors(e))}isLeaf(e){var r;return this.isDirected()?r=this.successors(e):r=this.neighbors(e),r.length===0}filterNodes(e){var r=new this.constructor({directed:this._isDirected,multigraph:this._isMultigraph,compound:this._isCompound});r.setGraph(this.graph());var n=this;Ae(this._nodes,function(s,l){e(l)&&r.setNode(l,s)}),Ae(this._edgeObjs,function(s){r.hasNode(s.v)&&r.hasNode(s.w)&&r.setEdge(s,n.edge(s))});var i={};function a(s){var l=n.parent(s);return l===void 0||r.hasNode(l)?(i[s]=l,l):l in i?i[l]:a(l)}return o(a,"findParent"),this._isCompound&&Ae(r.nodes(),function(s){r.setParent(s,a(s))}),r}setDefaultEdgeLabel(e){return Si(e)||(e=As(e)),this._defaultEdgeLabelFn=e,this}edgeCount(){return this._edgeCount}edges(){return br(this._edgeObjs)}setPath(e,r){var n=this,i=arguments;return Xr(e,function(a,s){return i.length>1?n.setEdge(a,s,r):n.setEdge(a,s),s}),this}setEdge(){var e,r,n,i,a=!1,s=arguments[0];typeof s=="object"&&s!==null&&"v"in s?(e=s.v,r=s.w,n=s.name,arguments.length===2&&(i=arguments[1],a=!0)):(e=s,r=arguments[1],n=arguments[3],arguments.length>2&&(i=arguments[2],a=!0)),e=""+e,r=""+r,pr(n)||(n=""+n);var l=_2(this._isDirected,e,r,n);if(Object.prototype.hasOwnProperty.call(this._edgeLabels,l))return a&&(this._edgeLabels[l]=i),this;if(!pr(n)&&!this._isMultigraph)throw new Error("Cannot set a named edge when isMultigraph = false");this.setNode(e),this.setNode(r),this._edgeLabels[l]=a?i:this._defaultEdgeLabelFn(e,r,n);var u=UMe(this._isDirected,e,r,n);return e=u.v,r=u.w,Object.freeze(u),this._edgeObjs[l]=u,ine(this._preds[r],e),ine(this._sucs[e],r),this._in[r][l]=u,this._out[e][l]=u,this._edgeCount++,this}edge(e,r,n){var i=arguments.length===1?HL(this._isDirected,arguments[0]):_2(this._isDirected,e,r,n);return this._edgeLabels[i]}hasEdge(e,r,n){var i=arguments.length===1?HL(this._isDirected,arguments[0]):_2(this._isDirected,e,r,n);return Object.prototype.hasOwnProperty.call(this._edgeLabels,i)}removeEdge(e,r,n){var i=arguments.length===1?HL(this._isDirected,arguments[0]):_2(this._isDirected,e,r,n),a=this._edgeObjs[i];return a&&(e=a.v,r=a.w,delete this._edgeLabels[i],delete this._edgeObjs[i],ane(this._preds[r],e),ane(this._sucs[e],r),delete this._in[r][i],delete this._out[e][i],this._edgeCount--),this}inEdges(e,r){var n=this._in[e];if(n){var i=br(n);return r?Yr(i,function(a){return a.v===r}):i}}outEdges(e,r){var n=this._out[e];if(n){var i=br(n);return r?Yr(i,function(a){return a.w===r}):i}}nodeEdges(e,r){var n=this.inEdges(e,r);if(n)return n.concat(this.outEdges(e,r))}};sn.prototype._nodeCount=0;sn.prototype._edgeCount=0;o(ine,"incrementOrInitEntry");o(ane,"decrementOrRemoveEntry");o(_2,"edgeArgsToId");o(UMe,"edgeArgsToObj");o(HL,"edgeObjToId")});var Vo=N(()=>{"use strict";KT()});function sne(t){t._prev._next=t._next,t._next._prev=t._prev,delete t._next,delete t._prev}function HMe(t,e){if(t!=="_next"&&t!=="_prev")return e}var ZT,one=N(()=>{"use strict";ZT=class{static{o(this,"List")}constructor(){var e={};e._next=e._prev=e,this._sentinel=e}dequeue(){var e=this._sentinel,r=e._prev;if(r!==e)return sne(r),r}enqueue(e){var r=this._sentinel;e._prev&&e._next&&sne(e),e._next=r._next,r._next._prev=e,r._next=e,e._prev=r}toString(){for(var e=[],r=this._sentinel,n=r._prev;n!==r;)e.push(JSON.stringify(n,HMe)),n=n._prev;return"["+e.join(", ")+"]"}};o(sne,"unlink");o(HMe,"filterOutLinks")});function lne(t,e){if(t.nodeCount()<=1)return[];var r=YMe(t,e||WMe),n=qMe(r.graph,r.buckets,r.zeroIdx);return qr(Je(n,function(i){return t.outEdges(i.v,i.w)}))}function qMe(t,e,r){for(var n=[],i=e[e.length-1],a=e[0],s;t.nodeCount();){for(;s=a.dequeue();)WL(t,e,r,s);for(;s=i.dequeue();)WL(t,e,r,s);if(t.nodeCount()){for(var l=e.length-2;l>0;--l)if(s=e[l].dequeue(),s){n=n.concat(WL(t,e,r,s,!0));break}}}return n}function WL(t,e,r,n,i){var a=i?[]:void 0;return Ae(t.inEdges(n.v),function(s){var l=t.edge(s),u=t.node(s.v);i&&a.push({v:s.v,w:s.w}),u.out-=l,qL(e,r,u)}),Ae(t.outEdges(n.v),function(s){var l=t.edge(s),u=s.w,h=t.node(u);h.in-=l,qL(e,r,h)}),t.removeNode(n.v),a}function YMe(t,e){var r=new sn,n=0,i=0;Ae(t.nodes(),function(l){r.setNode(l,{v:l,in:0,out:0})}),Ae(t.edges(),function(l){var u=r.edge(l.v,l.w)||0,h=e(l),f=u+h;r.setEdge(l.v,l.w,f),i=Math.max(i,r.node(l.v).out+=h),n=Math.max(n,r.node(l.w).in+=h)});var a=Go(i+n+3).map(function(){return new ZT}),s=n+1;return Ae(r.nodes(),function(l){qL(a,s,r.node(l))}),{graph:r,buckets:a,zeroIdx:s}}function qL(t,e,r){r.out?r.in?t[r.out-r.in+e].enqueue(r):t[t.length-1].enqueue(r):t[0].enqueue(r)}var WMe,cne=N(()=>{"use strict";qt();Vo();one();WMe=As(1);o(lne,"greedyFAS");o(qMe,"doGreedyFAS");o(WL,"removeNode");o(YMe,"buildState");o(qL,"assignBucket")});function une(t){var e=t.graph().acyclicer==="greedy"?lne(t,r(t)):XMe(t);Ae(e,function(n){var i=t.edge(n);t.removeEdge(n),i.forwardName=n.name,i.reversed=!0,t.setEdge(n.w,n.v,i,Ud("rev"))});function r(n){return function(i){return n.edge(i).weight}}o(r,"weightFn")}function XMe(t){var e=[],r={},n={};function i(a){Object.prototype.hasOwnProperty.call(n,a)||(n[a]=!0,r[a]=!0,Ae(t.outEdges(a),function(s){Object.prototype.hasOwnProperty.call(r,s.w)?e.push(s):i(s.w)}),delete r[a])}return o(i,"dfs"),Ae(t.nodes(),i),e}function hne(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.reversed){t.removeEdge(e);var n=r.forwardName;delete r.reversed,delete r.forwardName,t.setEdge(e.w,e.v,r,n)}})}var YL=N(()=>{"use strict";qt();cne();o(une,"run");o(XMe,"dfsFAS");o(hne,"undo")});function Ec(t,e,r,n){var i;do i=Ud(n);while(t.hasNode(i));return r.dummy=e,t.setNode(i,r),i}function dne(t){var e=new sn().setGraph(t.graph());return Ae(t.nodes(),function(r){e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){var n=e.edge(r.v,r.w)||{weight:0,minlen:1},i=t.edge(r);e.setEdge(r.v,r.w,{weight:n.weight+i.weight,minlen:Math.max(n.minlen,i.minlen)})}),e}function JT(t){var e=new sn({multigraph:t.isMultigraph()}).setGraph(t.graph());return Ae(t.nodes(),function(r){t.children(r).length||e.setNode(r,t.node(r))}),Ae(t.edges(),function(r){e.setEdge(r,t.edge(r))}),e}function XL(t,e){var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2;if(!i&&!a)throw new Error("Not possible to find intersection inside of the rectangle");var u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=l*i/a,h=l):(i<0&&(s=-s),u=s,h=s*a/i),{x:r+u,y:n+h}}function ef(t){var e=Je(Go(KL(t)+1),function(){return[]});return Ae(t.nodes(),function(r){var n=t.node(r),i=n.rank;pr(i)||(e[i][n.order]=r)}),e}function pne(t){var e=Dl(Je(t.nodes(),function(r){return t.node(r).rank}));Ae(t.nodes(),function(r){var n=t.node(r);Bt(n,"rank")&&(n.rank-=e)})}function mne(t){var e=Dl(Je(t.nodes(),function(a){return t.node(a).rank})),r=[];Ae(t.nodes(),function(a){var s=t.node(a).rank-e;r[s]||(r[s]=[]),r[s].push(a)});var n=0,i=t.graph().nodeRankFactor;Ae(r,function(a,s){pr(a)&&s%i!==0?--n:n&&Ae(a,function(l){t.node(l).rank+=n})})}function jL(t,e,r,n){var i={width:0,height:0};return arguments.length>=4&&(i.rank=r,i.order=n),Ec(t,"border",i,e)}function KL(t){return Is(Je(t.nodes(),function(e){var r=t.node(e).rank;if(!pr(r))return r}))}function gne(t,e){var r={lhs:[],rhs:[]};return Ae(t,function(n){e(n)?r.lhs.push(n):r.rhs.push(n)}),r}function yne(t,e){var r=PT();try{return e()}finally{console.log(t+" time: "+(PT()-r)+"ms")}}function vne(t,e){return e()}var Sc=N(()=>{"use strict";qt();Vo();o(Ec,"addDummyNode");o(dne,"simplify");o(JT,"asNonCompoundGraph");o(XL,"intersectRect");o(ef,"buildLayerMatrix");o(pne,"normalizeRanks");o(mne,"removeEmptyRanks");o(jL,"addBorderNode");o(KL,"maxRank");o(gne,"partition");o(yne,"time");o(vne,"notime")});function bne(t){function e(r){var n=t.children(r),i=t.node(r);if(n.length&&Ae(n,e),Object.prototype.hasOwnProperty.call(i,"minRank")){i.borderLeft=[],i.borderRight=[];for(var a=i.minRank,s=i.maxRank+1;a{"use strict";qt();Sc();o(bne,"addBorderSegments");o(xne,"addBorderNode")});function kne(t){var e=t.graph().rankdir.toLowerCase();(e==="lr"||e==="rl")&&Sne(t)}function Ene(t){var e=t.graph().rankdir.toLowerCase();(e==="bt"||e==="rl")&&jMe(t),(e==="lr"||e==="rl")&&(KMe(t),Sne(t))}function Sne(t){Ae(t.nodes(),function(e){Tne(t.node(e))}),Ae(t.edges(),function(e){Tne(t.edge(e))})}function Tne(t){var e=t.width;t.width=t.height,t.height=e}function jMe(t){Ae(t.nodes(),function(e){QL(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,QL),Object.prototype.hasOwnProperty.call(r,"y")&&QL(r)})}function QL(t){t.y=-t.y}function KMe(t){Ae(t.nodes(),function(e){ZL(t.node(e))}),Ae(t.edges(),function(e){var r=t.edge(e);Ae(r.points,ZL),Object.prototype.hasOwnProperty.call(r,"x")&&ZL(r)})}function ZL(t){var e=t.x;t.x=t.y,t.y=e}var Cne=N(()=>{"use strict";qt();o(kne,"adjust");o(Ene,"undo");o(Sne,"swapWidthHeight");o(Tne,"swapWidthHeightOne");o(jMe,"reverseY");o(QL,"reverseYOne");o(KMe,"swapXY");o(ZL,"swapXYOne")});function Ane(t){t.graph().dummyChains=[],Ae(t.edges(),function(e){ZMe(t,e)})}function ZMe(t,e){var r=e.v,n=t.node(r).rank,i=e.w,a=t.node(i).rank,s=e.name,l=t.edge(e),u=l.labelRank;if(a!==n+1){t.removeEdge(e);var h=void 0,f,d;for(d=0,++n;n{"use strict";qt();Sc();o(Ane,"run");o(ZMe,"normalizeEdge");o(_ne,"undo")});function D2(t){var e={};function r(n){var i=t.node(n);if(Object.prototype.hasOwnProperty.call(e,n))return i.rank;e[n]=!0;var a=Dl(Je(t.outEdges(n),function(s){return r(s.w)-t.edge(s).minlen}));return(a===Number.POSITIVE_INFINITY||a===void 0||a===null)&&(a=0),i.rank=a}o(r,"dfs"),Ae(t.sources(),r)}function Wd(t,e){return t.node(e.w).rank-t.node(e.v).rank-t.edge(e).minlen}var ek=N(()=>{"use strict";qt();o(D2,"longestPath");o(Wd,"slack")});function tk(t){var e=new sn({directed:!1}),r=t.nodes()[0],n=t.nodeCount();e.setNode(r,{});for(var i,a;JMe(e,t){"use strict";qt();Vo();ek();o(tk,"feasibleTree");o(JMe,"tightTree");o(eIe,"findMinSlackEdge");o(tIe,"shiftRanks")});var Lne=N(()=>{"use strict"});var tR=N(()=>{"use strict"});var cWt,rR=N(()=>{"use strict";qt();tR();cWt=As(1)});var Rne=N(()=>{"use strict";rR()});var nR=N(()=>{"use strict"});var Nne=N(()=>{"use strict";nR()});var bWt,Mne=N(()=>{"use strict";qt();bWt=As(1)});function iR(t){var e={},r={},n=[];function i(a){if(Object.prototype.hasOwnProperty.call(r,a))throw new L2;Object.prototype.hasOwnProperty.call(e,a)||(r[a]=!0,e[a]=!0,Ae(t.predecessors(a),i),delete r[a],n.push(a))}if(o(i,"visit"),Ae(t.sinks(),i),VL(e)!==t.nodeCount())throw new L2;return n}function L2(){}var aR=N(()=>{"use strict";qt();iR.CycleException=L2;o(iR,"topsort");o(L2,"CycleException");L2.prototype=new Error});var Ine=N(()=>{"use strict";aR()});function rk(t,e,r){Pt(e)||(e=[e]);var n=(t.isDirected()?t.successors:t.neighbors).bind(t),i=[],a={};return Ae(e,function(s){if(!t.hasNode(s))throw new Error("Graph does not have node: "+s);One(t,s,r==="post",a,n,i)}),i}function One(t,e,r,n,i,a){Object.prototype.hasOwnProperty.call(n,e)||(n[e]=!0,r||a.push(e),Ae(i(e),function(s){One(t,s,r,n,i,a)}),r&&a.push(e))}var sR=N(()=>{"use strict";qt();o(rk,"dfs");o(One,"doDfs")});function oR(t,e){return rk(t,e,"post")}var Pne=N(()=>{"use strict";sR();o(oR,"postorder")});function lR(t,e){return rk(t,e,"pre")}var Bne=N(()=>{"use strict";sR();o(lR,"preorder")});var Fne=N(()=>{"use strict";tR();KT()});var $ne=N(()=>{"use strict";Lne();rR();Rne();Nne();Mne();Ine();Pne();Bne();Fne();nR();aR()});function rf(t){t=dne(t),D2(t);var e=tk(t);uR(e),cR(e,t);for(var r,n;r=Une(e);)n=Hne(e,t,r),Wne(e,t,r,n)}function cR(t,e){var r=oR(t,t.nodes());r=r.slice(0,r.length-1),Ae(r,function(n){sIe(t,e,n)})}function sIe(t,e,r){var n=t.node(r),i=n.parent;t.edge(r,i).cutvalue=Gne(t,e,r)}function Gne(t,e,r){var n=t.node(r),i=n.parent,a=!0,s=e.edge(r,i),l=0;return s||(a=!1,s=e.edge(i,r)),l=s.weight,Ae(e.nodeEdges(r),function(u){var h=u.v===r,f=h?u.w:u.v;if(f!==i){var d=h===a,p=e.edge(u).weight;if(l+=d?p:-p,lIe(t,r,f)){var m=t.edge(r,f).cutvalue;l+=d?-m:m}}}),l}function uR(t,e){arguments.length<2&&(e=t.nodes()[0]),Vne(t,{},1,e)}function Vne(t,e,r,n,i){var a=r,s=t.node(n);return e[n]=!0,Ae(t.neighbors(n),function(l){Object.prototype.hasOwnProperty.call(e,l)||(r=Vne(t,e,r,l,n))}),s.low=a,s.lim=r++,i?s.parent=i:delete s.parent,r}function Une(t){return ns(t.edges(),function(e){return t.edge(e).cutvalue<0})}function Hne(t,e,r){var n=r.v,i=r.w;e.hasEdge(n,i)||(n=r.w,i=r.v);var a=t.node(n),s=t.node(i),l=a,u=!1;a.lim>s.lim&&(l=s,u=!0);var h=Yr(e.edges(),function(f){return u===zne(t,t.node(f.v),l)&&u!==zne(t,t.node(f.w),l)});return Gd(h,function(f){return Wd(e,f)})}function Wne(t,e,r,n){var i=r.v,a=r.w;t.removeEdge(i,a),t.setEdge(n.v,n.w,{}),uR(t),cR(t,e),oIe(t,e)}function oIe(t,e){var r=ns(t.nodes(),function(i){return!e.node(i).parent}),n=lR(t,r);n=n.slice(1),Ae(n,function(i){var a=t.node(i).parent,s=e.edge(i,a),l=!1;s||(s=e.edge(a,i),l=!0),e.node(i).rank=e.node(a).rank+(l?s.minlen:-s.minlen)})}function lIe(t,e,r){return t.hasEdge(e,r)}function zne(t,e,r){return r.low<=e.lim&&e.lim<=r.lim}var qne=N(()=>{"use strict";qt();$ne();Sc();eR();ek();rf.initLowLimValues=uR;rf.initCutValues=cR;rf.calcCutValue=Gne;rf.leaveEdge=Une;rf.enterEdge=Hne;rf.exchangeEdges=Wne;o(rf,"networkSimplex");o(cR,"initCutValues");o(sIe,"assignCutValue");o(Gne,"calcCutValue");o(uR,"initLowLimValues");o(Vne,"dfsAssignLowLim");o(Une,"leaveEdge");o(Hne,"enterEdge");o(Wne,"exchangeEdges");o(oIe,"updateRanks");o(lIe,"isTreeEdge");o(zne,"isDescendant")});function hR(t){switch(t.graph().ranker){case"network-simplex":Yne(t);break;case"tight-tree":uIe(t);break;case"longest-path":cIe(t);break;default:Yne(t)}}function uIe(t){D2(t),tk(t)}function Yne(t){rf(t)}var cIe,fR=N(()=>{"use strict";eR();qne();ek();o(hR,"rank");cIe=D2;o(uIe,"tightTreeRanker");o(Yne,"networkSimplexRanker")});function Xne(t){var e=Ec(t,"root",{},"_root"),r=hIe(t),n=Is(br(r))-1,i=2*n+1;t.graph().nestingRoot=e,Ae(t.edges(),function(s){t.edge(s).minlen*=i});var a=fIe(t)+1;Ae(t.children(),function(s){jne(t,e,i,a,n,r,s)}),t.graph().nodeRankFactor=i}function jne(t,e,r,n,i,a,s){var l=t.children(s);if(!l.length){s!==e&&t.setEdge(e,s,{weight:0,minlen:r});return}var u=jL(t,"_bt"),h=jL(t,"_bb"),f=t.node(s);t.setParent(u,s),f.borderTop=u,t.setParent(h,s),f.borderBottom=h,Ae(l,function(d){jne(t,e,r,n,i,a,d);var p=t.node(d),m=p.borderTop?p.borderTop:d,g=p.borderBottom?p.borderBottom:d,y=p.borderTop?n:2*n,v=m!==g?1:i-a[s]+1;t.setEdge(u,m,{weight:y,minlen:v,nestingEdge:!0}),t.setEdge(g,h,{weight:y,minlen:v,nestingEdge:!0})}),t.parent(s)||t.setEdge(e,u,{weight:0,minlen:i+a[s]})}function hIe(t){var e={};function r(n,i){var a=t.children(n);a&&a.length&&Ae(a,function(s){r(s,i+1)}),e[n]=i}return o(r,"dfs"),Ae(t.children(),function(n){r(n,1)}),e}function fIe(t){return Xr(t.edges(),function(e,r){return e+t.edge(r).weight},0)}function Kne(t){var e=t.graph();t.removeNode(e.nestingRoot),delete e.nestingRoot,Ae(t.edges(),function(r){var n=t.edge(r);n.nestingEdge&&t.removeEdge(r)})}var Qne=N(()=>{"use strict";qt();Sc();o(Xne,"run");o(jne,"dfs");o(hIe,"treeDepths");o(fIe,"sumWeights");o(Kne,"cleanup")});function Zne(t,e,r){var n={},i;Ae(r,function(a){for(var s=t.parent(a),l,u;s;){if(l=t.parent(s),l?(u=n[l],n[l]=s):(u=i,i=s),u&&u!==s){e.setEdge(u,s);return}s=l}})}var Jne=N(()=>{"use strict";qt();o(Zne,"addSubgraphConstraints")});function eie(t,e,r){var n=pIe(t),i=new sn({compound:!0}).setGraph({root:n}).setDefaultNodeLabel(function(a){return t.node(a)});return Ae(t.nodes(),function(a){var s=t.node(a),l=t.parent(a);(s.rank===e||s.minRank<=e&&e<=s.maxRank)&&(i.setNode(a),i.setParent(a,l||n),Ae(t[r](a),function(u){var h=u.v===a?u.w:u.v,f=i.edge(h,a),d=pr(f)?0:f.weight;i.setEdge(h,a,{weight:t.edge(u).weight+d})}),Object.prototype.hasOwnProperty.call(s,"minRank")&&i.setNode(a,{borderLeft:s.borderLeft[e],borderRight:s.borderRight[e]}))}),i}function pIe(t){for(var e;t.hasNode(e=Ud("_root")););return e}var tie=N(()=>{"use strict";qt();Vo();o(eie,"buildLayerGraph");o(pIe,"createRootNode")});function rie(t,e){for(var r=0,n=1;n0;)f%2&&(d+=l[f+1]),f=f-1>>1,l[f]+=h.weight;u+=h.weight*d})),u}var nie=N(()=>{"use strict";qt();o(rie,"crossCount");o(mIe,"twoLayerCrossCount")});function iie(t){var e={},r=Yr(t.nodes(),function(l){return!t.children(l).length}),n=Is(Je(r,function(l){return t.node(l).rank})),i=Je(Go(n+1),function(){return[]});function a(l){if(!Bt(e,l)){e[l]=!0;var u=t.node(l);i[u.rank].push(l),Ae(t.successors(l),a)}}o(a,"dfs");var s=kc(r,function(l){return t.node(l).rank});return Ae(s,a),i}var aie=N(()=>{"use strict";qt();o(iie,"initOrder")});function sie(t,e){return Je(e,function(r){var n=t.inEdges(r);if(n.length){var i=Xr(n,function(a,s){var l=t.edge(s),u=t.node(s.v);return{sum:a.sum+l.weight*u.order,weight:a.weight+l.weight}},{sum:0,weight:0});return{v:r,barycenter:i.sum/i.weight,weight:i.weight}}else return{v:r}})}var oie=N(()=>{"use strict";qt();o(sie,"barycenter")});function lie(t,e){var r={};Ae(t,function(i,a){var s=r[i.v]={indegree:0,in:[],out:[],vs:[i.v],i:a};pr(i.barycenter)||(s.barycenter=i.barycenter,s.weight=i.weight)}),Ae(e.edges(),function(i){var a=r[i.v],s=r[i.w];!pr(a)&&!pr(s)&&(s.indegree++,a.out.push(r[i.w]))});var n=Yr(r,function(i){return!i.indegree});return gIe(n)}function gIe(t){var e=[];function r(a){return function(s){s.merged||(pr(s.barycenter)||pr(a.barycenter)||s.barycenter>=a.barycenter)&&yIe(a,s)}}o(r,"handleIn");function n(a){return function(s){s.in.push(a),--s.indegree===0&&t.push(s)}}for(o(n,"handleOut");t.length;){var i=t.pop();e.push(i),Ae(i.in.reverse(),r(i)),Ae(i.out,n(i))}return Je(Yr(e,function(a){return!a.merged}),function(a){return Vd(a,["vs","i","barycenter","weight"])})}function yIe(t,e){var r=0,n=0;t.weight&&(r+=t.barycenter*t.weight,n+=t.weight),e.weight&&(r+=e.barycenter*e.weight,n+=e.weight),t.vs=e.vs.concat(t.vs),t.barycenter=r/n,t.weight=n,t.i=Math.min(e.i,t.i),e.merged=!0}var cie=N(()=>{"use strict";qt();o(lie,"resolveConflicts");o(gIe,"doResolveConflicts");o(yIe,"mergeEntries")});function hie(t,e){var r=gne(t,function(f){return Object.prototype.hasOwnProperty.call(f,"barycenter")}),n=r.lhs,i=kc(r.rhs,function(f){return-f.i}),a=[],s=0,l=0,u=0;n.sort(vIe(!!e)),u=uie(a,i,u),Ae(n,function(f){u+=f.vs.length,a.push(f.vs),s+=f.barycenter*f.weight,l+=f.weight,u=uie(a,i,u)});var h={vs:qr(a)};return l&&(h.barycenter=s/l,h.weight=l),h}function uie(t,e,r){for(var n;e.length&&(n=ga(e)).i<=r;)e.pop(),t.push(n.vs),r++;return r}function vIe(t){return function(e,r){return e.barycenterr.barycenter?1:t?r.i-e.i:e.i-r.i}}var fie=N(()=>{"use strict";qt();Sc();o(hie,"sort");o(uie,"consumeUnsortable");o(vIe,"compareWithBias")});function dR(t,e,r,n){var i=t.children(e),a=t.node(e),s=a?a.borderLeft:void 0,l=a?a.borderRight:void 0,u={};s&&(i=Yr(i,function(g){return g!==s&&g!==l}));var h=sie(t,i);Ae(h,function(g){if(t.children(g.v).length){var y=dR(t,g.v,r,n);u[g.v]=y,Object.prototype.hasOwnProperty.call(y,"barycenter")&&bIe(g,y)}});var f=lie(h,r);xIe(f,u);var d=hie(f,n);if(s&&(d.vs=qr([s,d.vs,l]),t.predecessors(s).length)){var p=t.node(t.predecessors(s)[0]),m=t.node(t.predecessors(l)[0]);Object.prototype.hasOwnProperty.call(d,"barycenter")||(d.barycenter=0,d.weight=0),d.barycenter=(d.barycenter*d.weight+p.order+m.order)/(d.weight+2),d.weight+=2}return d}function xIe(t,e){Ae(t,function(r){r.vs=qr(r.vs.map(function(n){return e[n]?e[n].vs:n}))})}function bIe(t,e){pr(t.barycenter)?(t.barycenter=e.barycenter,t.weight=e.weight):(t.barycenter=(t.barycenter*t.weight+e.barycenter*e.weight)/(t.weight+e.weight),t.weight+=e.weight)}var die=N(()=>{"use strict";qt();oie();cie();fie();o(dR,"sortSubgraph");o(xIe,"expandSubgraphs");o(bIe,"mergeBarycenters")});function gie(t){var e=KL(t),r=pie(t,Go(1,e+1),"inEdges"),n=pie(t,Go(e-1,-1,-1),"outEdges"),i=iie(t);mie(t,i);for(var a=Number.POSITIVE_INFINITY,s,l=0,u=0;u<4;++l,++u){wIe(l%2?r:n,l%4>=2),i=ef(t);var h=rie(t,i);h{"use strict";qt();Vo();Sc();Jne();tie();nie();aie();die();o(gie,"order");o(pie,"buildLayerGraphs");o(wIe,"sweepLayerGraphs");o(mie,"assignOrder")});function vie(t){var e=kIe(t);Ae(t.graph().dummyChains,function(r){for(var n=t.node(r),i=n.edgeObj,a=TIe(t,e,i.v,i.w),s=a.path,l=a.lca,u=0,h=s[u],f=!0;r!==i.w;){if(n=t.node(r),f){for(;(h=s[u])!==l&&t.node(h).maxRanks||l>e[u].lim));for(h=u,u=n;(u=t.parent(u))!==h;)a.push(u);return{path:i.concat(a.reverse()),lca:h}}function kIe(t){var e={},r=0;function n(i){var a=r;Ae(t.children(i),n),e[i]={low:a,lim:r++}}return o(n,"dfs"),Ae(t.children(),n),e}var xie=N(()=>{"use strict";qt();o(vie,"parentDummyChains");o(TIe,"findPath");o(kIe,"postorder")});function EIe(t,e){var r={};function n(i,a){var s=0,l=0,u=i.length,h=ga(a);return Ae(a,function(f,d){var p=CIe(t,f),m=p?t.node(p).order:u;(p||f===h)&&(Ae(a.slice(l,d+1),function(g){Ae(t.predecessors(g),function(y){var v=t.node(y),x=v.order;(xh)&&bie(r,p,f)})})}o(n,"scan");function i(a,s){var l=-1,u,h=0;return Ae(s,function(f,d){if(t.node(f).dummy==="border"){var p=t.predecessors(f);p.length&&(u=t.node(p[0]).order,n(s,h,d,l,u),h=d,l=u)}n(s,h,s.length,u,a.length)}),s}return o(i,"visitLayer"),Xr(e,i),r}function CIe(t,e){if(t.node(e).dummy)return ns(t.predecessors(e),function(r){return t.node(r).dummy})}function bie(t,e,r){if(e>r){var n=e;e=r,r=n}var i=t[e];i||(t[e]=i={}),i[r]=!0}function AIe(t,e,r){if(e>r){var n=e;e=r,r=n}return!!t[e]&&Object.prototype.hasOwnProperty.call(t[e],r)}function _Ie(t,e,r,n){var i={},a={},s={};return Ae(e,function(l){Ae(l,function(u,h){i[u]=u,a[u]=u,s[u]=h})}),Ae(e,function(l){var u=-1;Ae(l,function(h){var f=n(h);if(f.length){f=kc(f,function(y){return s[y]});for(var d=(f.length-1)/2,p=Math.floor(d),m=Math.ceil(d);p<=m;++p){var g=f[p];a[h]===h&&u{"use strict";qt();Vo();Sc();o(EIe,"findType1Conflicts");o(SIe,"findType2Conflicts");o(CIe,"findOtherInnerSegmentNode");o(bie,"addConflict");o(AIe,"hasConflict");o(_Ie,"verticalAlignment");o(DIe,"horizontalCompaction");o(LIe,"buildBlockGraph");o(RIe,"findSmallestWidthAlignment");o(NIe,"alignCoordinates");o(MIe,"balance");o(wie,"positionX");o(IIe,"sep");o(OIe,"width")});function kie(t){t=JT(t),PIe(t),ML(wie(t),function(e,r){t.node(r).x=e})}function PIe(t){var e=ef(t),r=t.graph().ranksep,n=0;Ae(e,function(i){var a=Is(Je(i,function(s){return t.node(s).height}));Ae(i,function(s){t.node(s).y=n+a/2}),n+=a+r})}var Eie=N(()=>{"use strict";qt();Sc();Tie();o(kie,"position");o(PIe,"positionY")});function R2(t,e){var r=e&&e.debugTiming?yne:vne;r("layout",()=>{var n=r(" buildLayoutGraph",()=>YIe(t));r(" runLayout",()=>BIe(n,r)),r(" updateInputGraph",()=>FIe(t,n))})}function BIe(t,e){e(" makeSpaceForEdgeLabels",()=>XIe(t)),e(" removeSelfEdges",()=>nOe(t)),e(" acyclic",()=>une(t)),e(" nestingGraph.run",()=>Xne(t)),e(" rank",()=>hR(JT(t))),e(" injectEdgeLabelProxies",()=>jIe(t)),e(" removeEmptyRanks",()=>mne(t)),e(" nestingGraph.cleanup",()=>Kne(t)),e(" normalizeRanks",()=>pne(t)),e(" assignRankMinMax",()=>KIe(t)),e(" removeEdgeLabelProxies",()=>QIe(t)),e(" normalize.run",()=>Ane(t)),e(" parentDummyChains",()=>vie(t)),e(" addBorderSegments",()=>bne(t)),e(" order",()=>gie(t)),e(" insertSelfEdges",()=>iOe(t)),e(" adjustCoordinateSystem",()=>kne(t)),e(" position",()=>kie(t)),e(" positionSelfEdges",()=>aOe(t)),e(" removeBorderNodes",()=>rOe(t)),e(" normalize.undo",()=>_ne(t)),e(" fixupEdgeLabelCoords",()=>eOe(t)),e(" undoCoordinateSystem",()=>Ene(t)),e(" translateGraph",()=>ZIe(t)),e(" assignNodeIntersects",()=>JIe(t)),e(" reversePoints",()=>tOe(t)),e(" acyclic.undo",()=>hne(t))}function FIe(t,e){Ae(t.nodes(),function(r){var n=t.node(r),i=e.node(r);n&&(n.x=i.x,n.y=i.y,e.children(r).length&&(n.width=i.width,n.height=i.height))}),Ae(t.edges(),function(r){var n=t.edge(r),i=e.edge(r);n.points=i.points,Object.prototype.hasOwnProperty.call(i,"x")&&(n.x=i.x,n.y=i.y)}),t.graph().width=e.graph().width,t.graph().height=e.graph().height}function YIe(t){var e=new sn({multigraph:!0,compound:!0}),r=mR(t.graph());return e.setGraph(Fh({},zIe,pR(r,$Ie),Vd(r,GIe))),Ae(t.nodes(),function(n){var i=mR(t.node(n));e.setNode(n,Qh(pR(i,VIe),UIe)),e.setParent(n,t.parent(n))}),Ae(t.edges(),function(n){var i=mR(t.edge(n));e.setEdge(n,Fh({},WIe,pR(i,HIe),Vd(i,qIe)))}),e}function XIe(t){var e=t.graph();e.ranksep/=2,Ae(t.edges(),function(r){var n=t.edge(r);n.minlen*=2,n.labelpos.toLowerCase()!=="c"&&(e.rankdir==="TB"||e.rankdir==="BT"?n.width+=n.labeloffset:n.height+=n.labeloffset)})}function jIe(t){Ae(t.edges(),function(e){var r=t.edge(e);if(r.width&&r.height){var n=t.node(e.v),i=t.node(e.w),a={rank:(i.rank-n.rank)/2+n.rank,e};Ec(t,"edge-proxy",a,"_ep")}})}function KIe(t){var e=0;Ae(t.nodes(),function(r){var n=t.node(r);n.borderTop&&(n.minRank=t.node(n.borderTop).rank,n.maxRank=t.node(n.borderBottom).rank,e=Is(e,n.maxRank))}),t.graph().maxRank=e}function QIe(t){Ae(t.nodes(),function(e){var r=t.node(e);r.dummy==="edge-proxy"&&(t.edge(r.e).labelRank=r.rank,t.removeNode(e))})}function ZIe(t){var e=Number.POSITIVE_INFINITY,r=0,n=Number.POSITIVE_INFINITY,i=0,a=t.graph(),s=a.marginx||0,l=a.marginy||0;function u(h){var f=h.x,d=h.y,p=h.width,m=h.height;e=Math.min(e,f-p/2),r=Math.max(r,f+p/2),n=Math.min(n,d-m/2),i=Math.max(i,d+m/2)}o(u,"getExtremes"),Ae(t.nodes(),function(h){u(t.node(h))}),Ae(t.edges(),function(h){var f=t.edge(h);Object.prototype.hasOwnProperty.call(f,"x")&&u(f)}),e-=s,n-=l,Ae(t.nodes(),function(h){var f=t.node(h);f.x-=e,f.y-=n}),Ae(t.edges(),function(h){var f=t.edge(h);Ae(f.points,function(d){d.x-=e,d.y-=n}),Object.prototype.hasOwnProperty.call(f,"x")&&(f.x-=e),Object.prototype.hasOwnProperty.call(f,"y")&&(f.y-=n)}),a.width=r-e+s,a.height=i-n+l}function JIe(t){Ae(t.edges(),function(e){var r=t.edge(e),n=t.node(e.v),i=t.node(e.w),a,s;r.points?(a=r.points[0],s=r.points[r.points.length-1]):(r.points=[],a=i,s=n),r.points.unshift(XL(n,a)),r.points.push(XL(i,s))})}function eOe(t){Ae(t.edges(),function(e){var r=t.edge(e);if(Object.prototype.hasOwnProperty.call(r,"x"))switch((r.labelpos==="l"||r.labelpos==="r")&&(r.width-=r.labeloffset),r.labelpos){case"l":r.x-=r.width/2+r.labeloffset;break;case"r":r.x+=r.width/2+r.labeloffset;break}})}function tOe(t){Ae(t.edges(),function(e){var r=t.edge(e);r.reversed&&r.points.reverse()})}function rOe(t){Ae(t.nodes(),function(e){if(t.children(e).length){var r=t.node(e),n=t.node(r.borderTop),i=t.node(r.borderBottom),a=t.node(ga(r.borderLeft)),s=t.node(ga(r.borderRight));r.width=Math.abs(s.x-a.x),r.height=Math.abs(i.y-n.y),r.x=a.x+r.width/2,r.y=n.y+r.height/2}}),Ae(t.nodes(),function(e){t.node(e).dummy==="border"&&t.removeNode(e)})}function nOe(t){Ae(t.edges(),function(e){if(e.v===e.w){var r=t.node(e.v);r.selfEdges||(r.selfEdges=[]),r.selfEdges.push({e,label:t.edge(e)}),t.removeEdge(e)}})}function iOe(t){var e=ef(t);Ae(e,function(r){var n=0;Ae(r,function(i,a){var s=t.node(i);s.order=a+n,Ae(s.selfEdges,function(l){Ec(t,"selfedge",{width:l.label.width,height:l.label.height,rank:s.rank,order:a+ ++n,e:l.e,label:l.label},"_se")}),delete s.selfEdges})})}function aOe(t){Ae(t.nodes(),function(e){var r=t.node(e);if(r.dummy==="selfedge"){var n=t.node(r.e.v),i=n.x+n.width/2,a=n.y,s=r.x-i,l=n.height/2;t.setEdge(r.e,r.label),t.removeNode(e),r.label.points=[{x:i+2*s/3,y:a-l},{x:i+5*s/6,y:a-l},{x:i+s,y:a},{x:i+5*s/6,y:a+l},{x:i+2*s/3,y:a+l}],r.label.x=r.x,r.label.y=r.y}})}function pR(t,e){return zd(Vd(t,e),Number)}function mR(t){var e={};return Ae(t,function(r,n){e[n.toLowerCase()]=r}),e}var $Ie,zIe,GIe,VIe,UIe,HIe,WIe,qIe,Sie=N(()=>{"use strict";qt();Vo();wne();Cne();YL();JL();fR();Qne();yie();xie();Eie();Sc();o(R2,"layout");o(BIe,"runLayout");o(FIe,"updateInputGraph");$Ie=["nodesep","edgesep","ranksep","marginx","marginy"],zIe={ranksep:50,edgesep:20,nodesep:50,rankdir:"tb"},GIe=["acyclicer","ranker","rankdir","align"],VIe=["width","height"],UIe={width:0,height:0},HIe=["minlen","weight","width","height","labeloffset"],WIe={minlen:1,weight:1,width:0,height:0,labeloffset:10,labelpos:"r"},qIe=["labelpos"];o(YIe,"buildLayoutGraph");o(XIe,"makeSpaceForEdgeLabels");o(jIe,"injectEdgeLabelProxies");o(KIe,"assignRankMinMax");o(QIe,"removeEdgeLabelProxies");o(ZIe,"translateGraph");o(JIe,"assignNodeIntersects");o(eOe,"fixupEdgeLabelCoords");o(tOe,"reversePointsForReversedEdges");o(rOe,"removeBorderNodes");o(nOe,"removeSelfEdges");o(iOe,"insertSelfEdges");o(aOe,"positionSelfEdges");o(pR,"selectNumberAttrs");o(mR,"canonicalize")});var gR=N(()=>{"use strict";YL();Sie();JL();fR()});function Uo(t){var e={options:{directed:t.isDirected(),multigraph:t.isMultigraph(),compound:t.isCompound()},nodes:sOe(t),edges:oOe(t)};return pr(t.graph())||(e.value=an(t.graph())),e}function sOe(t){return Je(t.nodes(),function(e){var r=t.node(e),n=t.parent(e),i={v:e};return pr(r)||(i.value=r),pr(n)||(i.parent=n),i})}function oOe(t){return Je(t.edges(),function(e){var r=t.edge(e),n={v:e.v,w:e.w};return pr(e.name)||(n.name=e.name),pr(r)||(n.value=r),n})}var yR=N(()=>{"use strict";qt();KT();o(Uo,"write");o(sOe,"writeNodes");o(oOe,"writeEdges")});var wr,qd,_ie,Die,nk,lOe,Lie,Rie,cOe,Fm,Aie,Nie,Mie,Iie,Oie,Pie=N(()=>{"use strict";vt();Vo();yR();wr=new Map,qd=new Map,_ie=new Map,Die=o(()=>{qd.clear(),_ie.clear(),wr.clear()},"clear"),nk=o((t,e)=>{let r=qd.get(e)||[];return Y.trace("In isDescendant",e," ",t," = ",r.includes(t)),r.includes(t)},"isDescendant"),lOe=o((t,e)=>{let r=qd.get(e)||[];return Y.info("Descendants of ",e," is ",r),Y.info("Edge is ",t),t.v===e||t.w===e?!1:r?r.includes(t.v)||nk(t.v,e)||nk(t.w,e)||r.includes(t.w):(Y.debug("Tilt, ",e,",not in descendants"),!1)},"edgeInCluster"),Lie=o((t,e,r,n)=>{Y.warn("Copying children of ",t,"root",n,"data",e.node(t),n);let i=e.children(t)||[];t!==n&&i.push(t),Y.warn("Copying (nodes) clusterId",t,"nodes",i),i.forEach(a=>{if(e.children(a).length>0)Lie(a,e,r,n);else{let s=e.node(a);Y.info("cp ",a," to ",n," with parent ",t),r.setNode(a,s),n!==e.parent(a)&&(Y.warn("Setting parent",a,e.parent(a)),r.setParent(a,e.parent(a))),t!==n&&a!==t?(Y.debug("Setting parent",a,t),r.setParent(a,t)):(Y.info("In copy ",t,"root",n,"data",e.node(t),n),Y.debug("Not Setting parent for node=",a,"cluster!==rootId",t!==n,"node!==clusterId",a!==t));let l=e.edges(a);Y.debug("Copying Edges",l),l.forEach(u=>{Y.info("Edge",u);let h=e.edge(u.v,u.w,u.name);Y.info("Edge data",h,n);try{lOe(u,n)?(Y.info("Copying as ",u.v,u.w,h,u.name),r.setEdge(u.v,u.w,h,u.name),Y.info("newGraph edges ",r.edges(),r.edge(r.edges()[0]))):Y.info("Skipping copy of edge ",u.v,"-->",u.w," rootId: ",n," clusterId:",t)}catch(f){Y.error(f)}})}Y.debug("Removing node",a),e.removeNode(a)})},"copy"),Rie=o((t,e)=>{let r=e.children(t),n=[...r];for(let i of r)_ie.set(i,t),n=[...n,...Rie(i,e)];return n},"extractDescendants"),cOe=o((t,e,r)=>{let n=t.edges().filter(u=>u.v===e||u.w===e),i=t.edges().filter(u=>u.v===r||u.w===r),a=n.map(u=>({v:u.v===e?r:u.v,w:u.w===e?e:u.w})),s=i.map(u=>({v:u.v,w:u.w}));return a.filter(u=>s.some(h=>u.v===h.v&&u.w===h.w))},"findCommonEdges"),Fm=o((t,e,r)=>{let n=e.children(t);if(Y.trace("Searching children of id ",t,n),n.length<1)return t;let i;for(let a of n){let s=Fm(a,e,r),l=cOe(e,r,s);if(s)if(l.length>0)i=s;else return s}return i},"findNonClusterChild"),Aie=o(t=>!wr.has(t)||!wr.get(t).externalConnections?t:wr.has(t)?wr.get(t).id:t,"getAnchorId"),Nie=o((t,e)=>{if(!t||e>10){Y.debug("Opting out, no graph ");return}else Y.debug("Opting in, graph ");t.nodes().forEach(function(r){t.children(r).length>0&&(Y.warn("Cluster identified",r," Replacement id in edges: ",Fm(r,t,r)),qd.set(r,Rie(r,t)),wr.set(r,{id:Fm(r,t,r),clusterData:t.node(r)}))}),t.nodes().forEach(function(r){let n=t.children(r),i=t.edges();n.length>0?(Y.debug("Cluster identified",r,qd),i.forEach(a=>{let s=nk(a.v,r),l=nk(a.w,r);s^l&&(Y.warn("Edge: ",a," leaves cluster ",r),Y.warn("Descendants of XXX ",r,": ",qd.get(r)),wr.get(r).externalConnections=!0)})):Y.debug("Not a cluster ",r,qd)});for(let r of wr.keys()){let n=wr.get(r).id,i=t.parent(n);i!==r&&wr.has(i)&&!wr.get(i).externalConnections&&(wr.get(r).id=i)}t.edges().forEach(function(r){let n=t.edge(r);Y.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(r)),Y.warn("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(t.edge(r)));let i=r.v,a=r.w;if(Y.warn("Fix XXX",wr,"ids:",r.v,r.w,"Translating: ",wr.get(r.v)," --- ",wr.get(r.w)),wr.get(r.v)||wr.get(r.w)){if(Y.warn("Fixing and trying - removing XXX",r.v,r.w,r.name),i=Aie(r.v),a=Aie(r.w),t.removeEdge(r.v,r.w,r.name),i!==r.v){let s=t.parent(i);wr.get(s).externalConnections=!0,n.fromCluster=r.v}if(a!==r.w){let s=t.parent(a);wr.get(s).externalConnections=!0,n.toCluster=r.w}Y.warn("Fix Replacing with XXX",i,a,r.name),t.setEdge(i,a,n,r.name)}}),Y.warn("Adjusted Graph",Uo(t)),Mie(t,0),Y.trace(wr)},"adjustClustersAndEdges"),Mie=o((t,e)=>{if(Y.warn("extractor - ",e,Uo(t),t.children("D")),e>10){Y.error("Bailing out");return}let r=t.nodes(),n=!1;for(let i of r){let a=t.children(i);n=n||a.length>0}if(!n){Y.debug("Done, no node has children",t.nodes());return}Y.debug("Nodes = ",r,e);for(let i of r)if(Y.debug("Extracting node",i,wr,wr.has(i)&&!wr.get(i).externalConnections,!t.parent(i),t.node(i),t.children("D")," Depth ",e),!wr.has(i))Y.debug("Not a cluster",i,e);else if(!wr.get(i).externalConnections&&t.children(i)&&t.children(i).length>0){Y.warn("Cluster without external connections, without a parent and with children",i,e);let s=t.graph().rankdir==="TB"?"LR":"TB";wr.get(i)?.clusterData?.dir&&(s=wr.get(i).clusterData.dir,Y.warn("Fixing dir",wr.get(i).clusterData.dir,s));let l=new sn({multigraph:!0,compound:!0}).setGraph({rankdir:s,nodesep:50,ranksep:50,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}});Y.warn("Old graph before copy",Uo(t)),Lie(i,t,l,i),t.setNode(i,{clusterNode:!0,id:i,clusterData:wr.get(i).clusterData,label:wr.get(i).label,graph:l}),Y.warn("New graph after copy node: (",i,")",Uo(l)),Y.debug("Old graph after copy",Uo(t))}else Y.warn("Cluster ** ",i," **not meeting the criteria !externalConnections:",!wr.get(i).externalConnections," no parent: ",!t.parent(i)," children ",t.children(i)&&t.children(i).length>0,t.children("D"),e),Y.debug(wr);r=t.nodes(),Y.warn("New list of nodes",r);for(let i of r){let a=t.node(i);Y.warn(" Now next level",i,a),a?.clusterNode&&Mie(a.graph,e+1)}},"extractor"),Iie=o((t,e)=>{if(e.length===0)return[];let r=Object.assign([],e);return e.forEach(n=>{let i=t.children(n),a=Iie(t,i);r=[...r,...a]}),r},"sorter"),Oie=o(t=>Iie(t,t.children()),"sortNodesByHierarchy")});var Fie={};hr(Fie,{render:()=>uOe});var Bie,uOe,$ie=N(()=>{"use strict";gR();yR();Vo();tL();Ft();Pie();eT();Hw();eL();vt();w2();zt();Bie=o(async(t,e,r,n,i,a)=>{Y.warn("Graph in recursive render:XAX",Uo(e),i);let s=e.graph().rankdir;Y.trace("Dir in recursive render - dir:",s);let l=t.insert("g").attr("class","root");e.nodes()?Y.info("Recursive render XXX",e.nodes()):Y.info("No nodes found for",e),e.edges().length>0&&Y.info("Recursive edges",e.edge(e.edges()[0]));let u=l.insert("g").attr("class","clusters"),h=l.insert("g").attr("class","edgePaths"),f=l.insert("g").attr("class","edgeLabels"),d=l.insert("g").attr("class","nodes");await Promise.all(e.nodes().map(async function(y){let v=e.node(y);if(i!==void 0){let x=JSON.parse(JSON.stringify(i.clusterData));Y.trace(`Setting data for parent cluster XXX + Node.id = `,y,` + data=`,x.height,` +Parent cluster`,i.height),e.setNode(i.id,x),e.parent(y)||(Y.trace("Setting parent",y,i.id),e.setParent(y,i.id,x))}if(Y.info("(Insert) Node XXX"+y+": "+JSON.stringify(e.node(y))),v?.clusterNode){Y.info("Cluster identified XBX",y,v.width,e.node(y));let{ranksep:x,nodesep:b}=e.graph();v.graph.setGraph({...v.graph.graph(),ranksep:x+25,nodesep:b});let w=await Bie(d,v.graph,r,n,e.node(y),a),C=w.elem;je(v,C),v.diff=w.diff||0,Y.info("New compound node after recursive render XAX",y,"width",v.width,"height",v.height),rJ(C,v)}else e.children(y).length>0?(Y.trace("Cluster - the non recursive path XBX",y,v.id,v,v.width,"Graph:",e),Y.trace(Fm(v.id,e)),wr.set(v.id,{id:Fm(v.id,e),node:v})):(Y.trace("Node - the non recursive path XAX",y,d,e.node(y),s),await vm(d,e.node(y),{config:a,dir:s}))})),await o(async()=>{let y=e.edges().map(async function(v){let x=e.edge(v.v,v.w,v.name);Y.info("Edge "+v.v+" -> "+v.w+": "+JSON.stringify(v)),Y.info("Edge "+v.v+" -> "+v.w+": ",v," ",JSON.stringify(e.edge(v))),Y.info("Fix",wr,"ids:",v.v,v.w,"Translating: ",wr.get(v.v),wr.get(v.w)),await jw(f,x)});await Promise.all(y)},"processEdges")(),Y.info("Graph before layout:",JSON.stringify(Uo(e))),Y.info("############################################# XXX"),Y.info("### Layout ### XXX"),Y.info("############################################# XXX"),R2(e),Y.info("Graph after layout:",JSON.stringify(Uo(e)));let m=0,{subGraphTitleTotalMargin:g}=Ru(a);return await Promise.all(Oie(e).map(async function(y){let v=e.node(y);if(Y.info("Position XBX => "+y+": ("+v.x,","+v.y,") width: ",v.width," height: ",v.height),v?.clusterNode)v.y+=g,Y.info("A tainted cluster node XBX1",y,v.id,v.width,v.height,v.x,v.y,e.parent(y)),wr.get(v.id).node=v,k2(v);else if(e.children(y).length>0){Y.info("A pure cluster node XBX1",y,v.id,v.x,v.y,v.width,v.height,e.parent(y)),v.height+=g,e.node(v.parentId);let x=v?.padding/2||0,b=v?.labelBBox?.height||0,w=b-x||0;Y.debug("OffsetY",w,"labelHeight",b,"halfPadding",x),await ym(u,v),wr.get(v.id).node=v}else{let x=e.node(v.parentId);v.y+=g/2,Y.info("A regular node XBX1 - using the padding",v.id,"parent",v.parentId,v.width,v.height,v.x,v.y,"offsetY",v.offsetY,"parent",x,x?.offsetY,v),k2(v)}})),e.edges().forEach(function(y){let v=e.edge(y);Y.info("Edge "+y.v+" -> "+y.w+": "+JSON.stringify(v),v),v.points.forEach(C=>C.y+=g/2);let x=e.node(y.v);var b=e.node(y.w);let w=Qw(h,v,wr,r,x,b,n);Kw(v,w)}),e.nodes().forEach(function(y){let v=e.node(y);Y.info(y,v.type,v.diff),v.isGroup&&(m=v.diff)}),Y.warn("Returning from recursive render XAX",l,m),{elem:l,diff:m}},"recursiveRender"),uOe=o(async(t,e)=>{let r=new sn({multigraph:!0,compound:!0}).setGraph({rankdir:t.direction,nodesep:t.config?.nodeSpacing||t.config?.flowchart?.nodeSpacing||t.nodeSpacing,ranksep:t.config?.rankSpacing||t.config?.flowchart?.rankSpacing||t.rankSpacing,marginx:8,marginy:8}).setDefaultEdgeLabel(function(){return{}}),n=e.select("g");Zw(n,t.markers,t.type,t.diagramId),nJ(),tJ(),jZ(),Die(),t.nodes.forEach(a=>{r.setNode(a.id,{...a}),a.parentId&&r.setParent(a.id,a.parentId)}),Y.debug("Edges:",t.edges),t.edges.forEach(a=>{if(a.start===a.end){let s=a.start,l=s+"---"+s+"---1",u=s+"---"+s+"---2",h=r.node(s);r.setNode(l,{domId:l,id:l,parentId:h.parentId,labelStyle:"",label:"",padding:0,shape:"labelRect",style:"",width:10,height:10}),r.setParent(l,h.parentId),r.setNode(u,{domId:u,id:u,parentId:h.parentId,labelStyle:"",padding:0,shape:"labelRect",label:"",style:"",width:10,height:10}),r.setParent(u,h.parentId);let f=structuredClone(a),d=structuredClone(a),p=structuredClone(a);f.label="",f.arrowTypeEnd="none",f.id=s+"-cyclic-special-1",d.arrowTypeStart="none",d.arrowTypeEnd="none",d.id=s+"-cyclic-special-mid",p.label="",h.isGroup&&(f.fromCluster=s,p.toCluster=s),p.id=s+"-cyclic-special-2",p.arrowTypeStart="none",r.setEdge(s,l,f,s+"-cyclic-special-0"),r.setEdge(l,u,d,s+"-cyclic-special-1"),r.setEdge(u,s,p,s+"-cyc{"use strict";aJ();vt();N2={},vR=o(t=>{for(let e of t)N2[e.name]=e},"registerLayoutLoaders"),hOe=o(()=>{vR([{name:"dagre",loader:o(async()=>await Promise.resolve().then(()=>($ie(),Fie)),"loader")}])},"registerDefaultLayoutLoaders");hOe();Cc=o(async(t,e)=>{if(!(t.layoutAlgorithm in N2))throw new Error(`Unknown layout algorithm: ${t.layoutAlgorithm}`);let r=N2[t.layoutAlgorithm];return(await r.loader()).render(t,e,iJ,{algorithm:r.algorithm})},"render"),nf=o((t="",{fallback:e="dagre"}={})=>{if(t in N2)return t;if(e in N2)return Y.warn(`Layout algorithm ${t} is not registered. Using ${e} as fallback.`),e;throw new Error(`Both layout algorithms ${t} and ${e} are not registered.`)},"getRegisteredLayoutAlgorithm")});var Ac,fOe,dOe,$m=N(()=>{"use strict";Ei();vt();Ac=o((t,e,r,n)=>{t.attr("class",r);let{width:i,height:a,x:s,y:l}=fOe(t,e);vn(t,a,i,n);let u=dOe(s,l,i,a,e);t.attr("viewBox",u),Y.debug(`viewBox configured: ${u} with padding: ${e}`)},"setupViewPortForSVG"),fOe=o((t,e)=>{let r=t.node()?.getBBox()||{width:0,height:0,x:0,y:0};return{width:r.width+e*2,height:r.height+e*2,x:r.x,y:r.y}},"calculateDimensionsWithPadding"),dOe=o((t,e,r,n,i)=>`${t-i} ${e-i} ${r} ${n}`,"createViewBox")});var pOe,mOe,zie,Gie=N(()=>{"use strict";dr();zt();vt();gm();Yd();$m();ir();pOe=o(function(t,e){return e.db.getClasses()},"getClasses"),mOe=o(async function(t,e,r,n){Y.info("REF0:"),Y.info("Drawing state diagram (v2)",e);let{securityLevel:i,flowchart:a,layout:s}=me(),l;i==="sandbox"&&(l=Ge("#i"+e));let u=i==="sandbox"?l.nodes()[0].contentDocument:document;Y.debug("Before getData: ");let h=n.db.getData();Y.debug("Data: ",h);let f=yc(e,i),d=n.db.getDirection();h.type=n.type,h.layoutAlgorithm=nf(s),h.layoutAlgorithm==="dagre"&&s==="elk"&&Y.warn("flowchart-elk was moved to an external package in Mermaid v11. Please refer [release notes](https://github.com/mermaid-js/mermaid/releases/tag/v11.0.0) for more details. This diagram will be rendered using `dagre` layout as a fallback."),h.direction=d,h.nodeSpacing=a?.nodeSpacing||50,h.rankSpacing=a?.rankSpacing||50,h.markers=["point","circle","cross"],h.diagramId=e,Y.debug("REF1:",h),await Cc(h,f);let p=h.config.flowchart?.diagramPadding??8;Gt.insertTitle(f,"flowchartTitleText",a?.titleTopMargin||0,n.db.getDiagramTitle()),Ac(f,p,"flowchart",a?.useMaxWidth||!1);for(let m of h.nodes){let g=Ge(`#${e} [id="${m.id}"]`);if(!g||!m.link)continue;let y=u.createElementNS("http://www.w3.org/2000/svg","a");y.setAttributeNS("http://www.w3.org/2000/svg","class",m.cssClasses),y.setAttributeNS("http://www.w3.org/2000/svg","rel","noopener"),i==="sandbox"?y.setAttributeNS("http://www.w3.org/2000/svg","target","_top"):m.linkTarget&&y.setAttributeNS("http://www.w3.org/2000/svg","target",m.linkTarget);let v=g.insert(function(){return y},":first-child"),x=g.select(".label-container");x&&v.append(function(){return x.node()});let b=g.select(".label");b&&v.append(function(){return b.node()})}},"draw"),zie={getClasses:pOe,draw:mOe}});var xR,bR,Vie=N(()=>{"use strict";xR=function(){var t=o(function(Hr,et,mt,Kt){for(mt=mt||{},Kt=Hr.length;Kt--;mt[Hr[Kt]]=et);return mt},"o"),e=[1,4],r=[1,3],n=[1,5],i=[1,8,9,10,11,27,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],a=[2,2],s=[1,13],l=[1,14],u=[1,15],h=[1,16],f=[1,23],d=[1,25],p=[1,26],m=[1,27],g=[1,49],y=[1,48],v=[1,29],x=[1,30],b=[1,31],w=[1,32],C=[1,33],T=[1,44],E=[1,46],A=[1,42],S=[1,47],_=[1,43],I=[1,50],D=[1,45],k=[1,51],L=[1,52],R=[1,34],O=[1,35],M=[1,36],B=[1,37],F=[1,57],P=[1,8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],z=[1,61],$=[1,60],H=[1,62],Q=[8,9,11,75,77,78],j=[1,78],ie=[1,91],ne=[1,96],le=[1,95],he=[1,92],K=[1,88],X=[1,94],te=[1,90],J=[1,97],se=[1,93],ue=[1,98],Z=[1,89],Se=[8,9,10,11,40,75,77,78],ce=[8,9,10,11,40,46,75,77,78],ae=[8,9,10,11,29,40,44,46,48,50,52,54,56,58,60,63,65,67,68,70,75,77,78,89,102,105,106,109,111,114,115,116],Oe=[8,9,11,44,60,75,77,78,89,102,105,106,109,111,114,115,116],ge=[44,60,89,102,105,106,109,111,114,115,116],ze=[1,121],He=[1,122],$e=[1,124],Re=[1,123],Ie=[44,60,62,74,89,102,105,106,109,111,114,115,116],be=[1,133],W=[1,147],de=[1,148],re=[1,149],oe=[1,150],V=[1,135],xe=[1,137],q=[1,141],pe=[1,142],ve=[1,143],Pe=[1,144],_e=[1,145],we=[1,146],Ve=[1,151],De=[1,152],qe=[1,131],at=[1,132],Rt=[1,139],st=[1,134],Ue=[1,138],ct=[1,136],We=[8,9,10,11,27,32,34,36,38,44,60,84,85,86,87,88,89,102,105,106,109,111,114,115,116,121,122,123,124],ot=[1,154],Yt=[1,156],bt=[8,9,11],Mt=[8,9,10,11,14,44,60,89,105,106,109,111,114,115,116],xt=[1,176],ut=[1,172],Et=[1,173],ft=[1,177],yt=[1,174],nt=[1,175],dn=[77,116,119],Tt=[8,9,10,11,12,14,27,29,32,44,60,75,84,85,86,87,88,89,90,105,109,111,114,115,116],On=[10,106],tn=[31,49,51,53,55,57,62,64,66,67,69,71,116,117,118],_r=[1,247],Dr=[1,245],Pn=[1,249],At=[1,243],Ce=[1,244],tt=[1,246],St=[1,248],mr=[1,250],rn=[1,268],gn=[8,9,11,106],Zr=[8,9,10,11,60,84,105,106,109,110,111,112],Ni={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,graphConfig:4,document:5,line:6,statement:7,SEMI:8,NEWLINE:9,SPACE:10,EOF:11,GRAPH:12,NODIR:13,DIR:14,FirstStmtSeparator:15,ending:16,endToken:17,spaceList:18,spaceListNewline:19,vertexStatement:20,separator:21,styleStatement:22,linkStyleStatement:23,classDefStatement:24,classStatement:25,clickStatement:26,subgraph:27,textNoTags:28,SQS:29,text:30,SQE:31,end:32,direction:33,acc_title:34,acc_title_value:35,acc_descr:36,acc_descr_value:37,acc_descr_multiline_value:38,shapeData:39,SHAPE_DATA:40,link:41,node:42,styledVertex:43,AMP:44,vertex:45,STYLE_SEPARATOR:46,idString:47,DOUBLECIRCLESTART:48,DOUBLECIRCLEEND:49,PS:50,PE:51,"(-":52,"-)":53,STADIUMSTART:54,STADIUMEND:55,SUBROUTINESTART:56,SUBROUTINEEND:57,VERTEX_WITH_PROPS_START:58,"NODE_STRING[field]":59,COLON:60,"NODE_STRING[value]":61,PIPE:62,CYLINDERSTART:63,CYLINDEREND:64,DIAMOND_START:65,DIAMOND_STOP:66,TAGEND:67,TRAPSTART:68,TRAPEND:69,INVTRAPSTART:70,INVTRAPEND:71,linkStatement:72,arrowText:73,TESTSTR:74,START_LINK:75,edgeText:76,LINK:77,LINK_ID:78,edgeTextToken:79,STR:80,MD_STR:81,textToken:82,keywords:83,STYLE:84,LINKSTYLE:85,CLASSDEF:86,CLASS:87,CLICK:88,DOWN:89,UP:90,textNoTagsToken:91,stylesOpt:92,"idString[vertex]":93,"idString[class]":94,CALLBACKNAME:95,CALLBACKARGS:96,HREF:97,LINK_TARGET:98,"STR[link]":99,"STR[tooltip]":100,alphaNum:101,DEFAULT:102,numList:103,INTERPOLATE:104,NUM:105,COMMA:106,style:107,styleComponent:108,NODE_STRING:109,UNIT:110,BRKT:111,PCT:112,idStringToken:113,MINUS:114,MULT:115,UNICODE_TEXT:116,TEXT:117,TAGSTART:118,EDGE_TEXT:119,alphaNumToken:120,direction_tb:121,direction_bt:122,direction_rl:123,direction_lr:124,$accept:0,$end:1},terminals_:{2:"error",8:"SEMI",9:"NEWLINE",10:"SPACE",11:"EOF",12:"GRAPH",13:"NODIR",14:"DIR",27:"subgraph",29:"SQS",31:"SQE",32:"end",34:"acc_title",35:"acc_title_value",36:"acc_descr",37:"acc_descr_value",38:"acc_descr_multiline_value",40:"SHAPE_DATA",44:"AMP",46:"STYLE_SEPARATOR",48:"DOUBLECIRCLESTART",49:"DOUBLECIRCLEEND",50:"PS",51:"PE",52:"(-",53:"-)",54:"STADIUMSTART",55:"STADIUMEND",56:"SUBROUTINESTART",57:"SUBROUTINEEND",58:"VERTEX_WITH_PROPS_START",59:"NODE_STRING[field]",60:"COLON",61:"NODE_STRING[value]",62:"PIPE",63:"CYLINDERSTART",64:"CYLINDEREND",65:"DIAMOND_START",66:"DIAMOND_STOP",67:"TAGEND",68:"TRAPSTART",69:"TRAPEND",70:"INVTRAPSTART",71:"INVTRAPEND",74:"TESTSTR",75:"START_LINK",77:"LINK",78:"LINK_ID",80:"STR",81:"MD_STR",84:"STYLE",85:"LINKSTYLE",86:"CLASSDEF",87:"CLASS",88:"CLICK",89:"DOWN",90:"UP",93:"idString[vertex]",94:"idString[class]",95:"CALLBACKNAME",96:"CALLBACKARGS",97:"HREF",98:"LINK_TARGET",99:"STR[link]",100:"STR[tooltip]",102:"DEFAULT",104:"INTERPOLATE",105:"NUM",106:"COMMA",109:"NODE_STRING",110:"UNIT",111:"BRKT",112:"PCT",114:"MINUS",115:"MULT",116:"UNICODE_TEXT",117:"TEXT",118:"TAGSTART",119:"EDGE_TEXT",121:"direction_tb",122:"direction_bt",123:"direction_rl",124:"direction_lr"},productions_:[0,[3,2],[5,0],[5,2],[6,1],[6,1],[6,1],[6,1],[6,1],[4,2],[4,2],[4,2],[4,3],[16,2],[16,1],[17,1],[17,1],[17,1],[15,1],[15,1],[15,2],[19,2],[19,2],[19,1],[19,1],[18,2],[18,1],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,9],[7,6],[7,4],[7,1],[7,2],[7,2],[7,1],[21,1],[21,1],[21,1],[39,2],[39,1],[20,4],[20,3],[20,4],[20,2],[20,2],[20,1],[42,1],[42,6],[42,5],[43,1],[43,3],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,8],[45,4],[45,4],[45,4],[45,6],[45,4],[45,4],[45,4],[45,4],[45,4],[45,1],[41,2],[41,3],[41,3],[41,1],[41,3],[41,4],[76,1],[76,2],[76,1],[76,1],[72,1],[72,2],[73,3],[30,1],[30,2],[30,1],[30,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[28,1],[28,2],[28,1],[28,1],[24,5],[25,5],[26,2],[26,4],[26,3],[26,5],[26,3],[26,5],[26,5],[26,7],[26,2],[26,4],[26,2],[26,4],[26,4],[26,6],[22,5],[23,5],[23,5],[23,9],[23,9],[23,7],[23,7],[103,1],[103,3],[92,1],[92,3],[107,1],[107,2],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[108,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[113,1],[82,1],[82,1],[82,1],[82,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[79,1],[79,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[120,1],[47,1],[47,2],[101,1],[101,2],[33,1],[33,1],[33,1],[33,1]],performAction:o(function(et,mt,Kt,lt,Cn,ye,Vf){var Te=ye.length-1;switch(Cn){case 2:this.$=[];break;case 3:(!Array.isArray(ye[Te])||ye[Te].length>0)&&ye[Te-1].push(ye[Te]),this.$=ye[Te-1];break;case 4:case 183:this.$=ye[Te];break;case 11:lt.setDirection("TB"),this.$="TB";break;case 12:lt.setDirection(ye[Te-1]),this.$=ye[Te-1];break;case 27:this.$=ye[Te-1].nodes;break;case 28:case 29:case 30:case 31:case 32:this.$=[];break;case 33:this.$=lt.addSubGraph(ye[Te-6],ye[Te-1],ye[Te-4]);break;case 34:this.$=lt.addSubGraph(ye[Te-3],ye[Te-1],ye[Te-3]);break;case 35:this.$=lt.addSubGraph(void 0,ye[Te-1],void 0);break;case 37:this.$=ye[Te].trim(),lt.setAccTitle(this.$);break;case 38:case 39:this.$=ye[Te].trim(),lt.setAccDescription(this.$);break;case 43:this.$=ye[Te-1]+ye[Te];break;case 44:this.$=ye[Te];break;case 45:lt.addVertex(ye[Te-1][ye[Te-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,ye[Te]),lt.addLink(ye[Te-3].stmt,ye[Te-1],ye[Te-2]),this.$={stmt:ye[Te-1],nodes:ye[Te-1].concat(ye[Te-3].nodes)};break;case 46:lt.addLink(ye[Te-2].stmt,ye[Te],ye[Te-1]),this.$={stmt:ye[Te],nodes:ye[Te].concat(ye[Te-2].nodes)};break;case 47:lt.addLink(ye[Te-3].stmt,ye[Te-1],ye[Te-2]),this.$={stmt:ye[Te-1],nodes:ye[Te-1].concat(ye[Te-3].nodes)};break;case 48:this.$={stmt:ye[Te-1],nodes:ye[Te-1]};break;case 49:lt.addVertex(ye[Te-1][ye[Te-1].length-1],void 0,void 0,void 0,void 0,void 0,void 0,ye[Te]),this.$={stmt:ye[Te-1],nodes:ye[Te-1],shapeData:ye[Te]};break;case 50:this.$={stmt:ye[Te],nodes:ye[Te]};break;case 51:this.$=[ye[Te]];break;case 52:lt.addVertex(ye[Te-5][ye[Te-5].length-1],void 0,void 0,void 0,void 0,void 0,void 0,ye[Te-4]),this.$=ye[Te-5].concat(ye[Te]);break;case 53:this.$=ye[Te-4].concat(ye[Te]);break;case 54:this.$=ye[Te];break;case 55:this.$=ye[Te-2],lt.setClass(ye[Te-2],ye[Te]);break;case 56:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"square");break;case 57:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"doublecircle");break;case 58:this.$=ye[Te-5],lt.addVertex(ye[Te-5],ye[Te-2],"circle");break;case 59:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"ellipse");break;case 60:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"stadium");break;case 61:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"subroutine");break;case 62:this.$=ye[Te-7],lt.addVertex(ye[Te-7],ye[Te-1],"rect",void 0,void 0,void 0,Object.fromEntries([[ye[Te-5],ye[Te-3]]]));break;case 63:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"cylinder");break;case 64:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"round");break;case 65:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"diamond");break;case 66:this.$=ye[Te-5],lt.addVertex(ye[Te-5],ye[Te-2],"hexagon");break;case 67:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"odd");break;case 68:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"trapezoid");break;case 69:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"inv_trapezoid");break;case 70:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"lean_right");break;case 71:this.$=ye[Te-3],lt.addVertex(ye[Te-3],ye[Te-1],"lean_left");break;case 72:this.$=ye[Te],lt.addVertex(ye[Te]);break;case 73:ye[Te-1].text=ye[Te],this.$=ye[Te-1];break;case 74:case 75:ye[Te-2].text=ye[Te-1],this.$=ye[Te-2];break;case 76:this.$=ye[Te];break;case 77:var wi=lt.destructLink(ye[Te],ye[Te-2]);this.$={type:wi.type,stroke:wi.stroke,length:wi.length,text:ye[Te-1]};break;case 78:var wi=lt.destructLink(ye[Te],ye[Te-2]);this.$={type:wi.type,stroke:wi.stroke,length:wi.length,text:ye[Te-1],id:ye[Te-3]};break;case 79:this.$={text:ye[Te],type:"text"};break;case 80:this.$={text:ye[Te-1].text+""+ye[Te],type:ye[Te-1].type};break;case 81:this.$={text:ye[Te],type:"string"};break;case 82:this.$={text:ye[Te],type:"markdown"};break;case 83:var wi=lt.destructLink(ye[Te]);this.$={type:wi.type,stroke:wi.stroke,length:wi.length};break;case 84:var wi=lt.destructLink(ye[Te]);this.$={type:wi.type,stroke:wi.stroke,length:wi.length,id:ye[Te-1]};break;case 85:this.$=ye[Te-1];break;case 86:this.$={text:ye[Te],type:"text"};break;case 87:this.$={text:ye[Te-1].text+""+ye[Te],type:ye[Te-1].type};break;case 88:this.$={text:ye[Te],type:"string"};break;case 89:case 104:this.$={text:ye[Te],type:"markdown"};break;case 101:this.$={text:ye[Te],type:"text"};break;case 102:this.$={text:ye[Te-1].text+""+ye[Te],type:ye[Te-1].type};break;case 103:this.$={text:ye[Te],type:"text"};break;case 105:this.$=ye[Te-4],lt.addClass(ye[Te-2],ye[Te]);break;case 106:this.$=ye[Te-4],lt.setClass(ye[Te-2],ye[Te]);break;case 107:case 115:this.$=ye[Te-1],lt.setClickEvent(ye[Te-1],ye[Te]);break;case 108:case 116:this.$=ye[Te-3],lt.setClickEvent(ye[Te-3],ye[Te-2]),lt.setTooltip(ye[Te-3],ye[Te]);break;case 109:this.$=ye[Te-2],lt.setClickEvent(ye[Te-2],ye[Te-1],ye[Te]);break;case 110:this.$=ye[Te-4],lt.setClickEvent(ye[Te-4],ye[Te-3],ye[Te-2]),lt.setTooltip(ye[Te-4],ye[Te]);break;case 111:this.$=ye[Te-2],lt.setLink(ye[Te-2],ye[Te]);break;case 112:this.$=ye[Te-4],lt.setLink(ye[Te-4],ye[Te-2]),lt.setTooltip(ye[Te-4],ye[Te]);break;case 113:this.$=ye[Te-4],lt.setLink(ye[Te-4],ye[Te-2],ye[Te]);break;case 114:this.$=ye[Te-6],lt.setLink(ye[Te-6],ye[Te-4],ye[Te]),lt.setTooltip(ye[Te-6],ye[Te-2]);break;case 117:this.$=ye[Te-1],lt.setLink(ye[Te-1],ye[Te]);break;case 118:this.$=ye[Te-3],lt.setLink(ye[Te-3],ye[Te-2]),lt.setTooltip(ye[Te-3],ye[Te]);break;case 119:this.$=ye[Te-3],lt.setLink(ye[Te-3],ye[Te-2],ye[Te]);break;case 120:this.$=ye[Te-5],lt.setLink(ye[Te-5],ye[Te-4],ye[Te]),lt.setTooltip(ye[Te-5],ye[Te-2]);break;case 121:this.$=ye[Te-4],lt.addVertex(ye[Te-2],void 0,void 0,ye[Te]);break;case 122:this.$=ye[Te-4],lt.updateLink([ye[Te-2]],ye[Te]);break;case 123:this.$=ye[Te-4],lt.updateLink(ye[Te-2],ye[Te]);break;case 124:this.$=ye[Te-8],lt.updateLinkInterpolate([ye[Te-6]],ye[Te-2]),lt.updateLink([ye[Te-6]],ye[Te]);break;case 125:this.$=ye[Te-8],lt.updateLinkInterpolate(ye[Te-6],ye[Te-2]),lt.updateLink(ye[Te-6],ye[Te]);break;case 126:this.$=ye[Te-6],lt.updateLinkInterpolate([ye[Te-4]],ye[Te]);break;case 127:this.$=ye[Te-6],lt.updateLinkInterpolate(ye[Te-4],ye[Te]);break;case 128:case 130:this.$=[ye[Te]];break;case 129:case 131:ye[Te-2].push(ye[Te]),this.$=ye[Te-2];break;case 133:this.$=ye[Te-1]+ye[Te];break;case 181:this.$=ye[Te];break;case 182:this.$=ye[Te-1]+""+ye[Te];break;case 184:this.$=ye[Te-1]+""+ye[Te];break;case 185:this.$={stmt:"dir",value:"TB"};break;case 186:this.$={stmt:"dir",value:"BT"};break;case 187:this.$={stmt:"dir",value:"RL"};break;case 188:this.$={stmt:"dir",value:"LR"};break}},"anonymous"),table:[{3:1,4:2,9:e,10:r,12:n},{1:[3]},t(i,a,{5:6}),{4:7,9:e,10:r,12:n},{4:8,9:e,10:r,12:n},{13:[1,9],14:[1,10]},{1:[2,1],6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:w,88:C,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L,121:R,122:O,123:M,124:B},t(i,[2,9]),t(i,[2,10]),t(i,[2,11]),{8:[1,54],9:[1,55],10:F,15:53,18:56},t(P,[2,3]),t(P,[2,4]),t(P,[2,5]),t(P,[2,6]),t(P,[2,7]),t(P,[2,8]),{8:z,9:$,11:H,21:58,41:59,72:63,75:[1,64],77:[1,66],78:[1,65]},{8:z,9:$,11:H,21:67},{8:z,9:$,11:H,21:68},{8:z,9:$,11:H,21:69},{8:z,9:$,11:H,21:70},{8:z,9:$,11:H,21:71},{8:z,9:$,10:[1,72],11:H,21:73},t(P,[2,36]),{35:[1,74]},{37:[1,75]},t(P,[2,39]),t(Q,[2,50],{18:76,39:77,10:F,40:j}),{10:[1,79]},{10:[1,80]},{10:[1,81]},{10:[1,82]},{14:ie,44:ne,60:le,80:[1,86],89:he,95:[1,83],97:[1,84],101:85,105:K,106:X,109:te,111:J,114:se,115:ue,116:Z,120:87},t(P,[2,185]),t(P,[2,186]),t(P,[2,187]),t(P,[2,188]),t(Se,[2,51]),t(Se,[2,54],{46:[1,99]}),t(ce,[2,72],{113:112,29:[1,100],44:g,48:[1,101],50:[1,102],52:[1,103],54:[1,104],56:[1,105],58:[1,106],60:y,63:[1,107],65:[1,108],67:[1,109],68:[1,110],70:[1,111],89:T,102:E,105:A,106:S,109:_,111:I,114:D,115:k,116:L}),t(ae,[2,181]),t(ae,[2,142]),t(ae,[2,143]),t(ae,[2,144]),t(ae,[2,145]),t(ae,[2,146]),t(ae,[2,147]),t(ae,[2,148]),t(ae,[2,149]),t(ae,[2,150]),t(ae,[2,151]),t(ae,[2,152]),t(i,[2,12]),t(i,[2,18]),t(i,[2,19]),{9:[1,113]},t(Oe,[2,26],{18:114,10:F}),t(P,[2,27]),{42:115,43:38,44:g,45:39,47:40,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},t(P,[2,40]),t(P,[2,41]),t(P,[2,42]),t(ge,[2,76],{73:116,62:[1,118],74:[1,117]}),{76:119,79:120,80:ze,81:He,116:$e,119:Re},{75:[1,125],77:[1,126]},t(Ie,[2,83]),t(P,[2,28]),t(P,[2,29]),t(P,[2,30]),t(P,[2,31]),t(P,[2,32]),{10:be,12:W,14:de,27:re,28:127,32:oe,44:V,60:xe,75:q,80:[1,129],81:[1,130],83:140,84:pe,85:ve,86:Pe,87:_e,88:we,89:Ve,90:De,91:128,105:qe,109:at,111:Rt,114:st,115:Ue,116:ct},t(We,a,{5:153}),t(P,[2,37]),t(P,[2,38]),t(Q,[2,48],{44:ot}),t(Q,[2,49],{18:155,10:F,40:Yt}),t(Se,[2,44]),{44:g,47:157,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},{102:[1,158],103:159,105:[1,160]},{44:g,47:161,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},{44:g,47:162,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},t(bt,[2,107],{10:[1,163],96:[1,164]}),{80:[1,165]},t(bt,[2,115],{120:167,10:[1,166],14:ie,44:ne,60:le,89:he,105:K,106:X,109:te,111:J,114:se,115:ue,116:Z}),t(bt,[2,117],{10:[1,168]}),t(Mt,[2,183]),t(Mt,[2,170]),t(Mt,[2,171]),t(Mt,[2,172]),t(Mt,[2,173]),t(Mt,[2,174]),t(Mt,[2,175]),t(Mt,[2,176]),t(Mt,[2,177]),t(Mt,[2,178]),t(Mt,[2,179]),t(Mt,[2,180]),{44:g,47:169,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},{30:170,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:178,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:180,50:[1,179],67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:181,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:182,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:183,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{109:[1,184]},{30:185,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:186,65:[1,187],67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:188,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:189,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{30:190,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},t(ae,[2,182]),t(i,[2,20]),t(Oe,[2,25]),t(Q,[2,46],{39:191,18:192,10:F,40:j}),t(ge,[2,73],{10:[1,193]}),{10:[1,194]},{30:195,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{77:[1,196],79:197,116:$e,119:Re},t(dn,[2,79]),t(dn,[2,81]),t(dn,[2,82]),t(dn,[2,168]),t(dn,[2,169]),{76:198,79:120,80:ze,81:He,116:$e,119:Re},t(Ie,[2,84]),{8:z,9:$,10:be,11:H,12:W,14:de,21:200,27:re,29:[1,199],32:oe,44:V,60:xe,75:q,83:140,84:pe,85:ve,86:Pe,87:_e,88:we,89:Ve,90:De,91:201,105:qe,109:at,111:Rt,114:st,115:Ue,116:ct},t(Tt,[2,101]),t(Tt,[2,103]),t(Tt,[2,104]),t(Tt,[2,157]),t(Tt,[2,158]),t(Tt,[2,159]),t(Tt,[2,160]),t(Tt,[2,161]),t(Tt,[2,162]),t(Tt,[2,163]),t(Tt,[2,164]),t(Tt,[2,165]),t(Tt,[2,166]),t(Tt,[2,167]),t(Tt,[2,90]),t(Tt,[2,91]),t(Tt,[2,92]),t(Tt,[2,93]),t(Tt,[2,94]),t(Tt,[2,95]),t(Tt,[2,96]),t(Tt,[2,97]),t(Tt,[2,98]),t(Tt,[2,99]),t(Tt,[2,100]),{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,202],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:w,88:C,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L,121:R,122:O,123:M,124:B},{10:F,18:203},{44:[1,204]},t(Se,[2,43]),{10:[1,205],44:g,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:112,114:D,115:k,116:L},{10:[1,206]},{10:[1,207],106:[1,208]},t(On,[2,128]),{10:[1,209],44:g,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:112,114:D,115:k,116:L},{10:[1,210],44:g,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:112,114:D,115:k,116:L},{80:[1,211]},t(bt,[2,109],{10:[1,212]}),t(bt,[2,111],{10:[1,213]}),{80:[1,214]},t(Mt,[2,184]),{80:[1,215],98:[1,216]},t(Se,[2,55],{113:112,44:g,60:y,89:T,102:E,105:A,106:S,109:_,111:I,114:D,115:k,116:L}),{31:[1,217],67:xt,82:218,116:ft,117:yt,118:nt},t(tn,[2,86]),t(tn,[2,88]),t(tn,[2,89]),t(tn,[2,153]),t(tn,[2,154]),t(tn,[2,155]),t(tn,[2,156]),{49:[1,219],67:xt,82:218,116:ft,117:yt,118:nt},{30:220,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{51:[1,221],67:xt,82:218,116:ft,117:yt,118:nt},{53:[1,222],67:xt,82:218,116:ft,117:yt,118:nt},{55:[1,223],67:xt,82:218,116:ft,117:yt,118:nt},{57:[1,224],67:xt,82:218,116:ft,117:yt,118:nt},{60:[1,225]},{64:[1,226],67:xt,82:218,116:ft,117:yt,118:nt},{66:[1,227],67:xt,82:218,116:ft,117:yt,118:nt},{30:228,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},{31:[1,229],67:xt,82:218,116:ft,117:yt,118:nt},{67:xt,69:[1,230],71:[1,231],82:218,116:ft,117:yt,118:nt},{67:xt,69:[1,233],71:[1,232],82:218,116:ft,117:yt,118:nt},t(Q,[2,45],{18:155,10:F,40:Yt}),t(Q,[2,47],{44:ot}),t(ge,[2,75]),t(ge,[2,74]),{62:[1,234],67:xt,82:218,116:ft,117:yt,118:nt},t(ge,[2,77]),t(dn,[2,80]),{77:[1,235],79:197,116:$e,119:Re},{30:236,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},t(We,a,{5:237}),t(Tt,[2,102]),t(P,[2,35]),{43:238,44:g,45:39,47:40,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},{10:F,18:239},{10:_r,60:Dr,84:Pn,92:240,105:At,107:241,108:242,109:Ce,110:tt,111:St,112:mr},{10:_r,60:Dr,84:Pn,92:251,104:[1,252],105:At,107:241,108:242,109:Ce,110:tt,111:St,112:mr},{10:_r,60:Dr,84:Pn,92:253,104:[1,254],105:At,107:241,108:242,109:Ce,110:tt,111:St,112:mr},{105:[1,255]},{10:_r,60:Dr,84:Pn,92:256,105:At,107:241,108:242,109:Ce,110:tt,111:St,112:mr},{44:g,47:257,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},t(bt,[2,108]),{80:[1,258]},{80:[1,259],98:[1,260]},t(bt,[2,116]),t(bt,[2,118],{10:[1,261]}),t(bt,[2,119]),t(ce,[2,56]),t(tn,[2,87]),t(ce,[2,57]),{51:[1,262],67:xt,82:218,116:ft,117:yt,118:nt},t(ce,[2,64]),t(ce,[2,59]),t(ce,[2,60]),t(ce,[2,61]),{109:[1,263]},t(ce,[2,63]),t(ce,[2,65]),{66:[1,264],67:xt,82:218,116:ft,117:yt,118:nt},t(ce,[2,67]),t(ce,[2,68]),t(ce,[2,70]),t(ce,[2,69]),t(ce,[2,71]),t([10,44,60,89,102,105,106,109,111,114,115,116],[2,85]),t(ge,[2,78]),{31:[1,265],67:xt,82:218,116:ft,117:yt,118:nt},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,266],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:w,88:C,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L,121:R,122:O,123:M,124:B},t(Se,[2,53]),{43:267,44:g,45:39,47:40,60:y,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L},t(bt,[2,121],{106:rn}),t(gn,[2,130],{108:269,10:_r,60:Dr,84:Pn,105:At,109:Ce,110:tt,111:St,112:mr}),t(Zr,[2,132]),t(Zr,[2,134]),t(Zr,[2,135]),t(Zr,[2,136]),t(Zr,[2,137]),t(Zr,[2,138]),t(Zr,[2,139]),t(Zr,[2,140]),t(Zr,[2,141]),t(bt,[2,122],{106:rn}),{10:[1,270]},t(bt,[2,123],{106:rn}),{10:[1,271]},t(On,[2,129]),t(bt,[2,105],{106:rn}),t(bt,[2,106],{113:112,44:g,60:y,89:T,102:E,105:A,106:S,109:_,111:I,114:D,115:k,116:L}),t(bt,[2,110]),t(bt,[2,112],{10:[1,272]}),t(bt,[2,113]),{98:[1,273]},{51:[1,274]},{62:[1,275]},{66:[1,276]},{8:z,9:$,11:H,21:277},t(P,[2,34]),t(Se,[2,52]),{10:_r,60:Dr,84:Pn,105:At,107:278,108:242,109:Ce,110:tt,111:St,112:mr},t(Zr,[2,133]),{14:ie,44:ne,60:le,89:he,101:279,105:K,106:X,109:te,111:J,114:se,115:ue,116:Z,120:87},{14:ie,44:ne,60:le,89:he,101:280,105:K,106:X,109:te,111:J,114:se,115:ue,116:Z,120:87},{98:[1,281]},t(bt,[2,120]),t(ce,[2,58]),{30:282,67:xt,80:ut,81:Et,82:171,116:ft,117:yt,118:nt},t(ce,[2,66]),t(We,a,{5:283}),t(gn,[2,131],{108:269,10:_r,60:Dr,84:Pn,105:At,109:Ce,110:tt,111:St,112:mr}),t(bt,[2,126],{120:167,10:[1,284],14:ie,44:ne,60:le,89:he,105:K,106:X,109:te,111:J,114:se,115:ue,116:Z}),t(bt,[2,127],{120:167,10:[1,285],14:ie,44:ne,60:le,89:he,105:K,106:X,109:te,111:J,114:se,115:ue,116:Z}),t(bt,[2,114]),{31:[1,286],67:xt,82:218,116:ft,117:yt,118:nt},{6:11,7:12,8:s,9:l,10:u,11:h,20:17,22:18,23:19,24:20,25:21,26:22,27:f,32:[1,287],33:24,34:d,36:p,38:m,42:28,43:38,44:g,45:39,47:40,60:y,84:v,85:x,86:b,87:w,88:C,89:T,102:E,105:A,106:S,109:_,111:I,113:41,114:D,115:k,116:L,121:R,122:O,123:M,124:B},{10:_r,60:Dr,84:Pn,92:288,105:At,107:241,108:242,109:Ce,110:tt,111:St,112:mr},{10:_r,60:Dr,84:Pn,92:289,105:At,107:241,108:242,109:Ce,110:tt,111:St,112:mr},t(ce,[2,62]),t(P,[2,33]),t(bt,[2,124],{106:rn}),t(bt,[2,125],{106:rn})],defaultActions:{},parseError:o(function(et,mt){if(mt.recoverable)this.trace(et);else{var Kt=new Error(et);throw Kt.hash=mt,Kt}},"parseError"),parse:o(function(et){var mt=this,Kt=[0],lt=[],Cn=[null],ye=[],Vf=this.table,Te="",wi=0,TF=0,kF=0,M2e=2,EF=1,I2e=ye.slice.call(arguments,1),Xi=Object.create(this.lexer),Uf={yy:{}};for(var xC in this.yy)Object.prototype.hasOwnProperty.call(this.yy,xC)&&(Uf.yy[xC]=this.yy[xC]);Xi.setInput(et,Uf.yy),Uf.yy.lexer=Xi,Uf.yy.parser=this,typeof Xi.yylloc>"u"&&(Xi.yylloc={});var bC=Xi.yylloc;ye.push(bC);var O2e=Xi.options&&Xi.options.ranges;typeof Uf.yy.parseError=="function"?this.parseError=Uf.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function wnt(Ws){Kt.length=Kt.length-2*Ws,Cn.length=Cn.length-Ws,ye.length=ye.length-Ws}o(wnt,"popStack");function P2e(){var Ws;return Ws=lt.pop()||Xi.lex()||EF,typeof Ws!="number"&&(Ws instanceof Array&&(lt=Ws,Ws=lt.pop()),Ws=mt.symbols_[Ws]||Ws),Ws}o(P2e,"lex");for(var Wa,wC,Hf,xo,Tnt,TC,Jp={},_4,Jc,SF,D4;;){if(Hf=Kt[Kt.length-1],this.defaultActions[Hf]?xo=this.defaultActions[Hf]:((Wa===null||typeof Wa>"u")&&(Wa=P2e()),xo=Vf[Hf]&&Vf[Hf][Wa]),typeof xo>"u"||!xo.length||!xo[0]){var kC="";D4=[];for(_4 in Vf[Hf])this.terminals_[_4]&&_4>M2e&&D4.push("'"+this.terminals_[_4]+"'");Xi.showPosition?kC="Parse error on line "+(wi+1)+`: +`+Xi.showPosition()+` +Expecting `+D4.join(", ")+", got '"+(this.terminals_[Wa]||Wa)+"'":kC="Parse error on line "+(wi+1)+": Unexpected "+(Wa==EF?"end of input":"'"+(this.terminals_[Wa]||Wa)+"'"),this.parseError(kC,{text:Xi.match,token:this.terminals_[Wa]||Wa,line:Xi.yylineno,loc:bC,expected:D4})}if(xo[0]instanceof Array&&xo.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Hf+", token: "+Wa);switch(xo[0]){case 1:Kt.push(Wa),Cn.push(Xi.yytext),ye.push(Xi.yylloc),Kt.push(xo[1]),Wa=null,wC?(Wa=wC,wC=null):(TF=Xi.yyleng,Te=Xi.yytext,wi=Xi.yylineno,bC=Xi.yylloc,kF>0&&kF--);break;case 2:if(Jc=this.productions_[xo[1]][1],Jp.$=Cn[Cn.length-Jc],Jp._$={first_line:ye[ye.length-(Jc||1)].first_line,last_line:ye[ye.length-1].last_line,first_column:ye[ye.length-(Jc||1)].first_column,last_column:ye[ye.length-1].last_column},O2e&&(Jp._$.range=[ye[ye.length-(Jc||1)].range[0],ye[ye.length-1].range[1]]),TC=this.performAction.apply(Jp,[Te,TF,wi,Uf.yy,xo[1],Cn,ye].concat(I2e)),typeof TC<"u")return TC;Jc&&(Kt=Kt.slice(0,-1*Jc*2),Cn=Cn.slice(0,-1*Jc),ye=ye.slice(0,-1*Jc)),Kt.push(this.productions_[xo[1]][0]),Cn.push(Jp.$),ye.push(Jp._$),SF=Vf[Kt[Kt.length-2]][Kt[Kt.length-1]],Kt.push(SF);break;case 3:return!0}}return!0},"parse")},Zn=function(){var Hr={EOF:1,parseError:o(function(mt,Kt){if(this.yy.parser)this.yy.parser.parseError(mt,Kt);else throw new Error(mt)},"parseError"),setInput:o(function(et,mt){return this.yy=mt||this.yy||{},this._input=et,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var et=this._input[0];this.yytext+=et,this.yyleng++,this.offset++,this.match+=et,this.matched+=et;var mt=et.match(/(?:\r\n?|\n).*/g);return mt?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),et},"input"),unput:o(function(et){var mt=et.length,Kt=et.split(/(?:\r\n?|\n)/g);this._input=et+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-mt),this.offset-=mt;var lt=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Kt.length-1&&(this.yylineno-=Kt.length-1);var Cn=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Kt?(Kt.length===lt.length?this.yylloc.first_column:0)+lt[lt.length-Kt.length].length-Kt[0].length:this.yylloc.first_column-mt},this.options.ranges&&(this.yylloc.range=[Cn[0],Cn[0]+this.yyleng-mt]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(et){this.unput(this.match.slice(et))},"less"),pastInput:o(function(){var et=this.matched.substr(0,this.matched.length-this.match.length);return(et.length>20?"...":"")+et.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var et=this.match;return et.length<20&&(et+=this._input.substr(0,20-et.length)),(et.substr(0,20)+(et.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var et=this.pastInput(),mt=new Array(et.length+1).join("-");return et+this.upcomingInput()+` +`+mt+"^"},"showPosition"),test_match:o(function(et,mt){var Kt,lt,Cn;if(this.options.backtrack_lexer&&(Cn={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Cn.yylloc.range=this.yylloc.range.slice(0))),lt=et[0].match(/(?:\r\n?|\n).*/g),lt&&(this.yylineno+=lt.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:lt?lt[lt.length-1].length-lt[lt.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+et[0].length},this.yytext+=et[0],this.match+=et[0],this.matches=et,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(et[0].length),this.matched+=et[0],Kt=this.performAction.call(this,this.yy,this,mt,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Kt)return Kt;if(this._backtrack){for(var ye in Cn)this[ye]=Cn[ye];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var et,mt,Kt,lt;this._more||(this.yytext="",this.match="");for(var Cn=this._currentRules(),ye=0;yemt[0].length)){if(mt=Kt,lt=ye,this.options.backtrack_lexer){if(et=this.test_match(Kt,Cn[ye]),et!==!1)return et;if(this._backtrack){mt=!1;continue}else return!1}else if(!this.options.flex)break}return mt?(et=this.test_match(mt,Cn[lt]),et!==!1?et:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var mt=this.next();return mt||this.lex()},"lex"),begin:o(function(mt){this.conditionStack.push(mt)},"begin"),popState:o(function(){var mt=this.conditionStack.length-1;return mt>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(mt){return mt=this.conditionStack.length-1-Math.abs(mt||0),mt>=0?this.conditionStack[mt]:"INITIAL"},"topState"),pushState:o(function(mt){this.begin(mt)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(mt,Kt,lt,Cn){var ye=Cn;switch(lt){case 0:return this.begin("acc_title"),34;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),36;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return this.pushState("shapeData"),Kt.yytext="",40;break;case 8:return this.pushState("shapeDataStr"),40;break;case 9:return this.popState(),40;break;case 10:let Vf=/\n\s*/g;return Kt.yytext=Kt.yytext.replace(Vf,"
    "),40;break;case 11:return 40;case 12:this.popState();break;case 13:this.begin("callbackname");break;case 14:this.popState();break;case 15:this.popState(),this.begin("callbackargs");break;case 16:return 95;case 17:this.popState();break;case 18:return 96;case 19:return"MD_STR";case 20:this.popState();break;case 21:this.begin("md_string");break;case 22:return"STR";case 23:this.popState();break;case 24:this.pushState("string");break;case 25:return 84;case 26:return 102;case 27:return 85;case 28:return 104;case 29:return 86;case 30:return 87;case 31:return 97;case 32:this.begin("click");break;case 33:this.popState();break;case 34:return 88;case 35:return mt.lex.firstGraph()&&this.begin("dir"),12;break;case 36:return mt.lex.firstGraph()&&this.begin("dir"),12;break;case 37:return mt.lex.firstGraph()&&this.begin("dir"),12;break;case 38:return 27;case 39:return 32;case 40:return 98;case 41:return 98;case 42:return 98;case 43:return 98;case 44:return this.popState(),13;break;case 45:return this.popState(),14;break;case 46:return this.popState(),14;break;case 47:return this.popState(),14;break;case 48:return this.popState(),14;break;case 49:return this.popState(),14;break;case 50:return this.popState(),14;break;case 51:return this.popState(),14;break;case 52:return this.popState(),14;break;case 53:return this.popState(),14;break;case 54:return this.popState(),14;break;case 55:return 121;case 56:return 122;case 57:return 123;case 58:return 124;case 59:return 78;case 60:return 105;case 61:return 111;case 62:return 46;case 63:return 60;case 64:return 44;case 65:return 8;case 66:return 106;case 67:return 115;case 68:return this.popState(),77;break;case 69:return this.pushState("edgeText"),75;break;case 70:return 119;case 71:return this.popState(),77;break;case 72:return this.pushState("thickEdgeText"),75;break;case 73:return 119;case 74:return this.popState(),77;break;case 75:return this.pushState("dottedEdgeText"),75;break;case 76:return 119;case 77:return 77;case 78:return this.popState(),53;break;case 79:return"TEXT";case 80:return this.pushState("ellipseText"),52;break;case 81:return this.popState(),55;break;case 82:return this.pushState("text"),54;break;case 83:return this.popState(),57;break;case 84:return this.pushState("text"),56;break;case 85:return 58;case 86:return this.pushState("text"),67;break;case 87:return this.popState(),64;break;case 88:return this.pushState("text"),63;break;case 89:return this.popState(),49;break;case 90:return this.pushState("text"),48;break;case 91:return this.popState(),69;break;case 92:return this.popState(),71;break;case 93:return 117;case 94:return this.pushState("trapText"),68;break;case 95:return this.pushState("trapText"),70;break;case 96:return 118;case 97:return 67;case 98:return 90;case 99:return"SEP";case 100:return 89;case 101:return 115;case 102:return 111;case 103:return 44;case 104:return 109;case 105:return 114;case 106:return 116;case 107:return this.popState(),62;break;case 108:return this.pushState("text"),62;break;case 109:return this.popState(),51;break;case 110:return this.pushState("text"),50;break;case 111:return this.popState(),31;break;case 112:return this.pushState("text"),29;break;case 113:return this.popState(),66;break;case 114:return this.pushState("text"),65;break;case 115:return"TEXT";case 116:return"QUOTE";case 117:return 9;case 118:return 10;case 119:return 11}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:@\{)/,/^(?:["])/,/^(?:["])/,/^(?:[^\"]+)/,/^(?:[^}^"]+)/,/^(?:\})/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["][`])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:["])/,/^(?:style\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\b)/,/^(?:class\b)/,/^(?:href[\s])/,/^(?:click[\s]+)/,/^(?:[\s\n])/,/^(?:[^\s\n]*)/,/^(?:flowchart-elk\b)/,/^(?:graph\b)/,/^(?:flowchart\b)/,/^(?:subgraph\b)/,/^(?:end\b\s*)/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:(\r?\n)*\s*\n)/,/^(?:\s*LR\b)/,/^(?:\s*RL\b)/,/^(?:\s*TB\b)/,/^(?:\s*BT\b)/,/^(?:\s*TD\b)/,/^(?:\s*BR\b)/,/^(?:\s*<)/,/^(?:\s*>)/,/^(?:\s*\^)/,/^(?:\s*v\b)/,/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:[^\s\"]+@(?=[^\{\"]))/,/^(?:[0-9]+)/,/^(?:#)/,/^(?::::)/,/^(?::)/,/^(?:&)/,/^(?:;)/,/^(?:,)/,/^(?:\*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:[^-]|-(?!-)+)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:[^=]|=(?!))/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:[^\.]|\.(?!))/,/^(?:\s*~~[\~]+\s*)/,/^(?:[-/\)][\)])/,/^(?:[^\(\)\[\]\{\}]|!\)+)/,/^(?:\(-)/,/^(?:\]\))/,/^(?:\(\[)/,/^(?:\]\])/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:>)/,/^(?:\)\])/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\(\(\()/,/^(?:[\\(?=\])][\]])/,/^(?:\/(?=\])\])/,/^(?:\/(?!\])|\\(?!\])|[^\\\[\]\(\)\{\}\/]+)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:<)/,/^(?:>)/,/^(?:\^)/,/^(?:\\\|)/,/^(?:v\b)/,/^(?:\*)/,/^(?:#)/,/^(?:&)/,/^(?:([A-Za-z0-9!"\#$%&'*+\.`?\\_\/]|-(?=[^\>\-\.])|(?!))+)/,/^(?:-)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\|)/,/^(?:\|)/,/^(?:\))/,/^(?:\()/,/^(?:\])/,/^(?:\[)/,/^(?:(\}))/,/^(?:\{)/,/^(?:[^\[\]\(\)\{\}\|\"]+)/,/^(?:")/,/^(?:(\r?\n)+)/,/^(?:\s)/,/^(?:$)/],conditions:{shapeDataEndBracket:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeDataStr:{rules:[9,10,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},shapeData:{rules:[8,11,12,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackargs:{rules:[17,18,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},callbackname:{rules:[14,15,16,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},href:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},click:{rules:[21,24,33,34,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dottedEdgeText:{rules:[21,24,74,76,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},thickEdgeText:{rules:[21,24,71,73,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},edgeText:{rules:[21,24,68,70,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},trapText:{rules:[21,24,77,80,82,84,88,90,91,92,93,94,95,108,110,112,114],inclusive:!1},ellipseText:{rules:[21,24,77,78,79,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},text:{rules:[21,24,77,80,81,82,83,84,87,88,89,90,94,95,107,108,109,110,111,112,113,114,115],inclusive:!1},vertex:{rules:[21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},dir:{rules:[21,24,44,45,46,47,48,49,50,51,52,53,54,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr_multiline:{rules:[5,6,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_descr:{rules:[3,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},acc_title:{rules:[1,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},md_string:{rules:[19,20,21,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},string:{rules:[21,22,23,24,77,80,82,84,88,90,94,95,108,110,112,114],inclusive:!1},INITIAL:{rules:[0,2,4,7,13,21,24,25,26,27,28,29,30,31,32,35,36,37,38,39,40,41,42,43,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,71,72,74,75,77,80,82,84,85,86,88,90,94,95,96,97,98,99,100,101,102,103,104,105,106,108,110,112,114,116,117,118,119],inclusive:!0}}};return Hr}();Ni.lexer=Zn;function Sn(){this.yy={}}return o(Sn,"Parser"),Sn.prototype=Ni,Ni.Parser=Sn,new Sn}();xR.parser=xR;bR=xR});var Uie,Hie,Wie=N(()=>{"use strict";Vie();Uie=Object.assign({},bR);Uie.parse=t=>{let e=t.replace(/}\s*\n/g,`} +`);return bR.parse(e)};Hie=Uie});var gOe,yOe,qie,Yie=N(()=>{"use strict";Ys();gOe=o((t,e)=>{let r=Kf,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return qa(n,i,a,e)},"fade"),yOe=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span { + color: ${t.titleColor}; + } + .cluster-label span p { + background-color: transparent; + } + + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .rough-node .label text , .node .label text, .image-shape .label, .icon-shape .label { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .katex path { + fill: #000; + stroke: #000; + stroke-width: 1px; + } + + .rough-node .label,.node .label, .image-shape .label, .icon-shape .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + + .root .anchor path { + fill: ${t.lineColor} !important; + stroke-width: 0; + stroke: ${t.lineColor}; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${gOe(t.edgeLabelBackground,.5)}; + // background-color: + } + + .cluster rect { + fill: ${t.clusterBkg}; + stroke: ${t.clusterBorder}; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } + + rect.text { + fill: none; + stroke-width: 0; + } + + .icon-shape, .image-shape { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + padding: 2px; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } +`,"getStyles"),qie=yOe});var ik={};hr(ik,{diagram:()=>vOe});var vOe,ak=N(()=>{"use strict";zt();qZ();Gie();Wie();Yie();vOe={parser:Hie,get db(){return new Uw},renderer:zie,styles:qie,init:o(t=>{t.flowchart||(t.flowchart={}),t.layout&&Yy({layout:t.layout}),t.flowchart.arrowMarkerAbsolute=t.arrowMarkerAbsolute,Yy({flowchart:{arrowMarkerAbsolute:t.arrowMarkerAbsolute}})},"init")}});var wR,Zie,Jie=N(()=>{"use strict";wR=function(){var t=o(function(J,se,ue,Z){for(ue=ue||{},Z=J.length;Z--;ue[J[Z]]=se);return ue},"o"),e=[6,8,10,22,24,26,28,33,34,35,36,37,40,43,44,50],r=[1,10],n=[1,11],i=[1,12],a=[1,13],s=[1,20],l=[1,21],u=[1,22],h=[1,23],f=[1,24],d=[1,19],p=[1,25],m=[1,26],g=[1,18],y=[1,33],v=[1,34],x=[1,35],b=[1,36],w=[1,37],C=[6,8,10,13,15,17,20,21,22,24,26,28,33,34,35,36,37,40,43,44,50,63,64,65,66,67],T=[1,42],E=[1,43],A=[1,52],S=[40,50,68,69],_=[1,63],I=[1,61],D=[1,58],k=[1,62],L=[1,64],R=[6,8,10,13,17,22,24,26,28,33,34,35,36,37,40,41,42,43,44,48,49,50,63,64,65,66,67],O=[63,64,65,66,67],M=[1,81],B=[1,80],F=[1,78],P=[1,79],z=[6,10,42,47],$=[6,10,13,41,42,47,48,49],H=[1,89],Q=[1,88],j=[1,87],ie=[19,56],ne=[1,98],le=[1,97],he=[19,56,58,60],K={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,ER_DIAGRAM:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,entityName:11,relSpec:12,COLON:13,role:14,STYLE_SEPARATOR:15,idList:16,BLOCK_START:17,attributes:18,BLOCK_STOP:19,SQS:20,SQE:21,title:22,title_value:23,acc_title:24,acc_title_value:25,acc_descr:26,acc_descr_value:27,acc_descr_multiline_value:28,direction:29,classDefStatement:30,classStatement:31,styleStatement:32,direction_tb:33,direction_bt:34,direction_rl:35,direction_lr:36,CLASSDEF:37,stylesOpt:38,separator:39,UNICODE_TEXT:40,STYLE_TEXT:41,COMMA:42,CLASS:43,STYLE:44,style:45,styleComponent:46,SEMI:47,NUM:48,BRKT:49,ENTITY_NAME:50,attribute:51,attributeType:52,attributeName:53,attributeKeyTypeList:54,attributeComment:55,ATTRIBUTE_WORD:56,attributeKeyType:57,",":58,ATTRIBUTE_KEY:59,COMMENT:60,cardinality:61,relType:62,ZERO_OR_ONE:63,ZERO_OR_MORE:64,ONE_OR_MORE:65,ONLY_ONE:66,MD_PARENT:67,NON_IDENTIFYING:68,IDENTIFYING:69,WORD:70,$accept:0,$end:1},terminals_:{2:"error",4:"ER_DIAGRAM",6:"EOF",8:"SPACE",10:"NEWLINE",13:"COLON",15:"STYLE_SEPARATOR",17:"BLOCK_START",19:"BLOCK_STOP",20:"SQS",21:"SQE",22:"title",23:"title_value",24:"acc_title",25:"acc_title_value",26:"acc_descr",27:"acc_descr_value",28:"acc_descr_multiline_value",33:"direction_tb",34:"direction_bt",35:"direction_rl",36:"direction_lr",37:"CLASSDEF",40:"UNICODE_TEXT",41:"STYLE_TEXT",42:"COMMA",43:"CLASS",44:"STYLE",47:"SEMI",48:"NUM",49:"BRKT",50:"ENTITY_NAME",56:"ATTRIBUTE_WORD",58:",",59:"ATTRIBUTE_KEY",60:"COMMENT",63:"ZERO_OR_ONE",64:"ZERO_OR_MORE",65:"ONE_OR_MORE",66:"ONLY_ONE",67:"MD_PARENT",68:"NON_IDENTIFYING",69:"IDENTIFYING",70:"WORD"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,5],[9,9],[9,7],[9,7],[9,4],[9,6],[9,3],[9,5],[9,1],[9,3],[9,7],[9,9],[9,6],[9,8],[9,4],[9,6],[9,2],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[9,1],[29,1],[29,1],[29,1],[29,1],[30,4],[16,1],[16,1],[16,3],[16,3],[31,3],[32,4],[38,1],[38,3],[45,1],[45,2],[39,1],[39,1],[39,1],[46,1],[46,1],[46,1],[46,1],[11,1],[11,1],[18,1],[18,2],[51,2],[51,3],[51,3],[51,4],[52,1],[53,1],[54,1],[54,3],[57,1],[55,1],[12,3],[61,1],[61,1],[61,1],[61,1],[61,1],[62,1],[62,1],[14,1],[14,1],[14,1]],performAction:o(function(se,ue,Z,Se,ce,ae,Oe){var ge=ae.length-1;switch(ce){case 1:break;case 2:this.$=[];break;case 3:ae[ge-1].push(ae[ge]),this.$=ae[ge-1];break;case 4:case 5:this.$=ae[ge];break;case 6:case 7:this.$=[];break;case 8:Se.addEntity(ae[ge-4]),Se.addEntity(ae[ge-2]),Se.addRelationship(ae[ge-4],ae[ge],ae[ge-2],ae[ge-3]);break;case 9:Se.addEntity(ae[ge-8]),Se.addEntity(ae[ge-4]),Se.addRelationship(ae[ge-8],ae[ge],ae[ge-4],ae[ge-5]),Se.setClass([ae[ge-8]],ae[ge-6]),Se.setClass([ae[ge-4]],ae[ge-2]);break;case 10:Se.addEntity(ae[ge-6]),Se.addEntity(ae[ge-2]),Se.addRelationship(ae[ge-6],ae[ge],ae[ge-2],ae[ge-3]),Se.setClass([ae[ge-6]],ae[ge-4]);break;case 11:Se.addEntity(ae[ge-6]),Se.addEntity(ae[ge-4]),Se.addRelationship(ae[ge-6],ae[ge],ae[ge-4],ae[ge-5]),Se.setClass([ae[ge-4]],ae[ge-2]);break;case 12:Se.addEntity(ae[ge-3]),Se.addAttributes(ae[ge-3],ae[ge-1]);break;case 13:Se.addEntity(ae[ge-5]),Se.addAttributes(ae[ge-5],ae[ge-1]),Se.setClass([ae[ge-5]],ae[ge-3]);break;case 14:Se.addEntity(ae[ge-2]);break;case 15:Se.addEntity(ae[ge-4]),Se.setClass([ae[ge-4]],ae[ge-2]);break;case 16:Se.addEntity(ae[ge]);break;case 17:Se.addEntity(ae[ge-2]),Se.setClass([ae[ge-2]],ae[ge]);break;case 18:Se.addEntity(ae[ge-6],ae[ge-4]),Se.addAttributes(ae[ge-6],ae[ge-1]);break;case 19:Se.addEntity(ae[ge-8],ae[ge-6]),Se.addAttributes(ae[ge-8],ae[ge-1]),Se.setClass([ae[ge-8]],ae[ge-3]);break;case 20:Se.addEntity(ae[ge-5],ae[ge-3]);break;case 21:Se.addEntity(ae[ge-7],ae[ge-5]),Se.setClass([ae[ge-7]],ae[ge-2]);break;case 22:Se.addEntity(ae[ge-3],ae[ge-1]);break;case 23:Se.addEntity(ae[ge-5],ae[ge-3]),Se.setClass([ae[ge-5]],ae[ge]);break;case 24:case 25:this.$=ae[ge].trim(),Se.setAccTitle(this.$);break;case 26:case 27:this.$=ae[ge].trim(),Se.setAccDescription(this.$);break;case 32:Se.setDirection("TB");break;case 33:Se.setDirection("BT");break;case 34:Se.setDirection("RL");break;case 35:Se.setDirection("LR");break;case 36:this.$=ae[ge-3],Se.addClass(ae[ge-2],ae[ge-1]);break;case 37:case 38:case 56:case 64:this.$=[ae[ge]];break;case 39:case 40:this.$=ae[ge-2].concat([ae[ge]]);break;case 41:this.$=ae[ge-2],Se.setClass(ae[ge-1],ae[ge]);break;case 42:this.$=ae[ge-3],Se.addCssStyles(ae[ge-2],ae[ge-1]);break;case 43:this.$=[ae[ge]];break;case 44:ae[ge-2].push(ae[ge]),this.$=ae[ge-2];break;case 46:this.$=ae[ge-1]+ae[ge];break;case 54:case 76:case 77:this.$=ae[ge].replace(/"/g,"");break;case 55:case 78:this.$=ae[ge];break;case 57:ae[ge].push(ae[ge-1]),this.$=ae[ge];break;case 58:this.$={type:ae[ge-1],name:ae[ge]};break;case 59:this.$={type:ae[ge-2],name:ae[ge-1],keys:ae[ge]};break;case 60:this.$={type:ae[ge-2],name:ae[ge-1],comment:ae[ge]};break;case 61:this.$={type:ae[ge-3],name:ae[ge-2],keys:ae[ge-1],comment:ae[ge]};break;case 62:case 63:case 66:this.$=ae[ge];break;case 65:ae[ge-2].push(ae[ge]),this.$=ae[ge-2];break;case 67:this.$=ae[ge].replace(/"/g,"");break;case 68:this.$={cardA:ae[ge],relType:ae[ge-1],cardB:ae[ge-2]};break;case 69:this.$=Se.Cardinality.ZERO_OR_ONE;break;case 70:this.$=Se.Cardinality.ZERO_OR_MORE;break;case 71:this.$=Se.Cardinality.ONE_OR_MORE;break;case 72:this.$=Se.Cardinality.ONLY_ONE;break;case 73:this.$=Se.Cardinality.MD_PARENT;break;case 74:this.$=Se.Identification.NON_IDENTIFYING;break;case 75:this.$=Se.Identification.IDENTIFYING;break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:27,11:9,22:r,24:n,26:i,28:a,29:14,30:15,31:16,32:17,33:s,34:l,35:u,36:h,37:f,40:d,43:p,44:m,50:g},t(e,[2,5]),t(e,[2,6]),t(e,[2,16],{12:28,61:32,15:[1,29],17:[1,30],20:[1,31],63:y,64:v,65:x,66:b,67:w}),{23:[1,38]},{25:[1,39]},{27:[1,40]},t(e,[2,27]),t(e,[2,28]),t(e,[2,29]),t(e,[2,30]),t(e,[2,31]),t(C,[2,54]),t(C,[2,55]),t(e,[2,32]),t(e,[2,33]),t(e,[2,34]),t(e,[2,35]),{16:41,40:T,41:E},{16:44,40:T,41:E},{16:45,40:T,41:E},t(e,[2,4]),{11:46,40:d,50:g},{16:47,40:T,41:E},{18:48,19:[1,49],51:50,52:51,56:A},{11:53,40:d,50:g},{62:54,68:[1,55],69:[1,56]},t(S,[2,69]),t(S,[2,70]),t(S,[2,71]),t(S,[2,72]),t(S,[2,73]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),{13:_,38:57,41:I,42:D,45:59,46:60,48:k,49:L},t(R,[2,37]),t(R,[2,38]),{16:65,40:T,41:E,42:D},{13:_,38:66,41:I,42:D,45:59,46:60,48:k,49:L},{13:[1,67],15:[1,68]},t(e,[2,17],{61:32,12:69,17:[1,70],42:D,63:y,64:v,65:x,66:b,67:w}),{19:[1,71]},t(e,[2,14]),{18:72,19:[2,56],51:50,52:51,56:A},{53:73,56:[1,74]},{56:[2,62]},{21:[1,75]},{61:76,63:y,64:v,65:x,66:b,67:w},t(O,[2,74]),t(O,[2,75]),{6:M,10:B,39:77,42:F,47:P},{40:[1,82],41:[1,83]},t(z,[2,43],{46:84,13:_,41:I,48:k,49:L}),t($,[2,45]),t($,[2,50]),t($,[2,51]),t($,[2,52]),t($,[2,53]),t(e,[2,41],{42:D}),{6:M,10:B,39:85,42:F,47:P},{14:86,40:H,50:Q,70:j},{16:90,40:T,41:E},{11:91,40:d,50:g},{18:92,19:[1,93],51:50,52:51,56:A},t(e,[2,12]),{19:[2,57]},t(ie,[2,58],{54:94,55:95,57:96,59:ne,60:le}),t([19,56,59,60],[2,63]),t(e,[2,22],{15:[1,100],17:[1,99]}),t([40,50],[2,68]),t(e,[2,36]),{13:_,41:I,45:101,46:60,48:k,49:L},t(e,[2,47]),t(e,[2,48]),t(e,[2,49]),t(R,[2,39]),t(R,[2,40]),t($,[2,46]),t(e,[2,42]),t(e,[2,8]),t(e,[2,76]),t(e,[2,77]),t(e,[2,78]),{13:[1,102],42:D},{13:[1,104],15:[1,103]},{19:[1,105]},t(e,[2,15]),t(ie,[2,59],{55:106,58:[1,107],60:le}),t(ie,[2,60]),t(he,[2,64]),t(ie,[2,67]),t(he,[2,66]),{18:108,19:[1,109],51:50,52:51,56:A},{16:110,40:T,41:E},t(z,[2,44],{46:84,13:_,41:I,48:k,49:L}),{14:111,40:H,50:Q,70:j},{16:112,40:T,41:E},{14:113,40:H,50:Q,70:j},t(e,[2,13]),t(ie,[2,61]),{57:114,59:ne},{19:[1,115]},t(e,[2,20]),t(e,[2,23],{17:[1,116],42:D}),t(e,[2,11]),{13:[1,117],42:D},t(e,[2,10]),t(he,[2,65]),t(e,[2,18]),{18:118,19:[1,119],51:50,52:51,56:A},{14:120,40:H,50:Q,70:j},{19:[1,121]},t(e,[2,21]),t(e,[2,9]),t(e,[2,19])],defaultActions:{52:[2,62],72:[2,57]},parseError:o(function(se,ue){if(ue.recoverable)this.trace(se);else{var Z=new Error(se);throw Z.hash=ue,Z}},"parseError"),parse:o(function(se){var ue=this,Z=[0],Se=[],ce=[null],ae=[],Oe=this.table,ge="",ze=0,He=0,$e=0,Re=2,Ie=1,be=ae.slice.call(arguments,1),W=Object.create(this.lexer),de={yy:{}};for(var re in this.yy)Object.prototype.hasOwnProperty.call(this.yy,re)&&(de.yy[re]=this.yy[re]);W.setInput(se,de.yy),de.yy.lexer=W,de.yy.parser=this,typeof W.yylloc>"u"&&(W.yylloc={});var oe=W.yylloc;ae.push(oe);var V=W.options&&W.options.ranges;typeof de.yy.parseError=="function"?this.parseError=de.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xe(ct){Z.length=Z.length-2*ct,ce.length=ce.length-ct,ae.length=ae.length-ct}o(xe,"popStack");function q(){var ct;return ct=Se.pop()||W.lex()||Ie,typeof ct!="number"&&(ct instanceof Array&&(Se=ct,ct=Se.pop()),ct=ue.symbols_[ct]||ct),ct}o(q,"lex");for(var pe,ve,Pe,_e,we,Ve,De={},qe,at,Rt,st;;){if(Pe=Z[Z.length-1],this.defaultActions[Pe]?_e=this.defaultActions[Pe]:((pe===null||typeof pe>"u")&&(pe=q()),_e=Oe[Pe]&&Oe[Pe][pe]),typeof _e>"u"||!_e.length||!_e[0]){var Ue="";st=[];for(qe in Oe[Pe])this.terminals_[qe]&&qe>Re&&st.push("'"+this.terminals_[qe]+"'");W.showPosition?Ue="Parse error on line "+(ze+1)+`: +`+W.showPosition()+` +Expecting `+st.join(", ")+", got '"+(this.terminals_[pe]||pe)+"'":Ue="Parse error on line "+(ze+1)+": Unexpected "+(pe==Ie?"end of input":"'"+(this.terminals_[pe]||pe)+"'"),this.parseError(Ue,{text:W.match,token:this.terminals_[pe]||pe,line:W.yylineno,loc:oe,expected:st})}if(_e[0]instanceof Array&&_e.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Pe+", token: "+pe);switch(_e[0]){case 1:Z.push(pe),ce.push(W.yytext),ae.push(W.yylloc),Z.push(_e[1]),pe=null,ve?(pe=ve,ve=null):(He=W.yyleng,ge=W.yytext,ze=W.yylineno,oe=W.yylloc,$e>0&&$e--);break;case 2:if(at=this.productions_[_e[1]][1],De.$=ce[ce.length-at],De._$={first_line:ae[ae.length-(at||1)].first_line,last_line:ae[ae.length-1].last_line,first_column:ae[ae.length-(at||1)].first_column,last_column:ae[ae.length-1].last_column},V&&(De._$.range=[ae[ae.length-(at||1)].range[0],ae[ae.length-1].range[1]]),Ve=this.performAction.apply(De,[ge,He,ze,de.yy,_e[1],ce,ae].concat(be)),typeof Ve<"u")return Ve;at&&(Z=Z.slice(0,-1*at*2),ce=ce.slice(0,-1*at),ae=ae.slice(0,-1*at)),Z.push(this.productions_[_e[1]][0]),ce.push(De.$),ae.push(De._$),Rt=Oe[Z[Z.length-2]][Z[Z.length-1]],Z.push(Rt);break;case 3:return!0}}return!0},"parse")},X=function(){var J={EOF:1,parseError:o(function(ue,Z){if(this.yy.parser)this.yy.parser.parseError(ue,Z);else throw new Error(ue)},"parseError"),setInput:o(function(se,ue){return this.yy=ue||this.yy||{},this._input=se,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var se=this._input[0];this.yytext+=se,this.yyleng++,this.offset++,this.match+=se,this.matched+=se;var ue=se.match(/(?:\r\n?|\n).*/g);return ue?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),se},"input"),unput:o(function(se){var ue=se.length,Z=se.split(/(?:\r\n?|\n)/g);this._input=se+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-ue),this.offset-=ue;var Se=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),Z.length-1&&(this.yylineno-=Z.length-1);var ce=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:Z?(Z.length===Se.length?this.yylloc.first_column:0)+Se[Se.length-Z.length].length-Z[0].length:this.yylloc.first_column-ue},this.options.ranges&&(this.yylloc.range=[ce[0],ce[0]+this.yyleng-ue]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(se){this.unput(this.match.slice(se))},"less"),pastInput:o(function(){var se=this.matched.substr(0,this.matched.length-this.match.length);return(se.length>20?"...":"")+se.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var se=this.match;return se.length<20&&(se+=this._input.substr(0,20-se.length)),(se.substr(0,20)+(se.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var se=this.pastInput(),ue=new Array(se.length+1).join("-");return se+this.upcomingInput()+` +`+ue+"^"},"showPosition"),test_match:o(function(se,ue){var Z,Se,ce;if(this.options.backtrack_lexer&&(ce={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(ce.yylloc.range=this.yylloc.range.slice(0))),Se=se[0].match(/(?:\r\n?|\n).*/g),Se&&(this.yylineno+=Se.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Se?Se[Se.length-1].length-Se[Se.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+se[0].length},this.yytext+=se[0],this.match+=se[0],this.matches=se,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(se[0].length),this.matched+=se[0],Z=this.performAction.call(this,this.yy,this,ue,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),Z)return Z;if(this._backtrack){for(var ae in ce)this[ae]=ce[ae];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var se,ue,Z,Se;this._more||(this.yytext="",this.match="");for(var ce=this._currentRules(),ae=0;aeue[0].length)){if(ue=Z,Se=ae,this.options.backtrack_lexer){if(se=this.test_match(Z,ce[ae]),se!==!1)return se;if(this._backtrack){ue=!1;continue}else return!1}else if(!this.options.flex)break}return ue?(se=this.test_match(ue,ce[Se]),se!==!1?se:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var ue=this.next();return ue||this.lex()},"lex"),begin:o(function(ue){this.conditionStack.push(ue)},"begin"),popState:o(function(){var ue=this.conditionStack.length-1;return ue>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(ue){return ue=this.conditionStack.length-1-Math.abs(ue||0),ue>=0?this.conditionStack[ue]:"INITIAL"},"topState"),pushState:o(function(ue){this.begin(ue)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(ue,Z,Se,ce){var ae=ce;switch(Se){case 0:return this.begin("acc_title"),24;break;case 1:return this.popState(),"acc_title_value";break;case 2:return this.begin("acc_descr"),26;break;case 3:return this.popState(),"acc_descr_value";break;case 4:this.begin("acc_descr_multiline");break;case 5:this.popState();break;case 6:return"acc_descr_multiline_value";case 7:return 33;case 8:return 34;case 9:return 35;case 10:return 36;case 11:return 10;case 12:break;case 13:return 8;case 14:return 50;case 15:return 70;case 16:return 4;case 17:return this.begin("block"),17;break;case 18:return 49;case 19:return 49;case 20:return 42;case 21:return 15;case 22:return 13;case 23:break;case 24:return 59;case 25:return 56;case 26:return 56;case 27:return 60;case 28:break;case 29:return this.popState(),19;break;case 30:return Z.yytext[0];case 31:return 20;case 32:return 21;case 33:return this.begin("style"),44;break;case 34:return this.popState(),10;break;case 35:break;case 36:return 13;case 37:return 42;case 38:return 49;case 39:return this.begin("style"),37;break;case 40:return 43;case 41:return 63;case 42:return 65;case 43:return 65;case 44:return 65;case 45:return 63;case 46:return 63;case 47:return 64;case 48:return 64;case 49:return 64;case 50:return 64;case 51:return 64;case 52:return 65;case 53:return 64;case 54:return 65;case 55:return 66;case 56:return 66;case 57:return 66;case 58:return 66;case 59:return 63;case 60:return 64;case 61:return 65;case 62:return 67;case 63:return 68;case 64:return 69;case 65:return 69;case 66:return 68;case 67:return 68;case 68:return 68;case 69:return 41;case 70:return 47;case 71:return 40;case 72:return 48;case 73:return Z.yytext[0];case 74:return 6}},"anonymous"),rules:[/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:[\s]+)/i,/^(?:"[^"%\r\n\v\b\\]+")/i,/^(?:"[^"]*")/i,/^(?:erDiagram\b)/i,/^(?:\{)/i,/^(?:#)/i,/^(?:#)/i,/^(?:,)/i,/^(?::::)/i,/^(?::)/i,/^(?:\s+)/i,/^(?:\b((?:PK)|(?:FK)|(?:UK))\b)/i,/^(?:([^\s]*)[~].*[~]([^\s]*))/i,/^(?:([\*A-Za-z_\u00C0-\uFFFF][A-Za-z0-9\-\_\[\]\(\)\u00C0-\uFFFF\*]*))/i,/^(?:"[^"]*")/i,/^(?:[\n]+)/i,/^(?:\})/i,/^(?:.)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:style\b)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?::)/i,/^(?:,)/i,/^(?:#)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:one or zero\b)/i,/^(?:one or more\b)/i,/^(?:one or many\b)/i,/^(?:1\+)/i,/^(?:\|o\b)/i,/^(?:zero or one\b)/i,/^(?:zero or more\b)/i,/^(?:zero or many\b)/i,/^(?:0\+)/i,/^(?:\}o\b)/i,/^(?:many\(0\))/i,/^(?:many\(1\))/i,/^(?:many\b)/i,/^(?:\}\|)/i,/^(?:one\b)/i,/^(?:only one\b)/i,/^(?:1\b)/i,/^(?:\|\|)/i,/^(?:o\|)/i,/^(?:o\{)/i,/^(?:\|\{)/i,/^(?:\s*u\b)/i,/^(?:\.\.)/i,/^(?:--)/i,/^(?:to\b)/i,/^(?:optionally to\b)/i,/^(?:\.-)/i,/^(?:-\.)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:;)/i,/^(?:([^\x00-\x7F]|\w|-|\*)+)/i,/^(?:[0-9])/i,/^(?:.)/i,/^(?:$)/i],conditions:{style:{rules:[34,35,36,37,38,69,70],inclusive:!1},acc_descr_multiline:{rules:[5,6],inclusive:!1},acc_descr:{rules:[3],inclusive:!1},acc_title:{rules:[1],inclusive:!1},block:{rules:[23,24,25,26,27,28,29,30],inclusive:!1},INITIAL:{rules:[0,2,4,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,31,32,33,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,71,72,73,74],inclusive:!0}}};return J}();K.lexer=X;function te(){this.yy={}}return o(te,"Parser"),te.prototype=K,K.Parser=te,new te}();wR.parser=wR;Zie=wR});var sk,eae=N(()=>{"use strict";vt();zt();mi();ir();sk=class{constructor(){this.entities=new Map;this.relationships=[];this.classes=new Map;this.direction="TB";this.Cardinality={ZERO_OR_ONE:"ZERO_OR_ONE",ZERO_OR_MORE:"ZERO_OR_MORE",ONE_OR_MORE:"ONE_OR_MORE",ONLY_ONE:"ONLY_ONE",MD_PARENT:"MD_PARENT"};this.Identification={NON_IDENTIFYING:"NON_IDENTIFYING",IDENTIFYING:"IDENTIFYING"};this.setAccTitle=Lr;this.getAccTitle=Rr;this.setAccDescription=Nr;this.getAccDescription=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Ir;this.getConfig=o(()=>me().er,"getConfig");this.clear(),this.addEntity=this.addEntity.bind(this),this.addAttributes=this.addAttributes.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setDirection=this.setDirection.bind(this),this.addCssStyles=this.addCssStyles.bind(this),this.addClass=this.addClass.bind(this),this.setClass=this.setClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"ErDB")}addEntity(e,r=""){return this.entities.has(e)?!this.entities.get(e)?.alias&&r&&(this.entities.get(e).alias=r,Y.info(`Add alias '${r}' to entity '${e}'`)):(this.entities.set(e,{id:`entity-${e}-${this.entities.size}`,label:e,attributes:[],alias:r,shape:"erBox",look:me().look??"default",cssClasses:"default",cssStyles:[]}),Y.info("Added new entity :",e)),this.entities.get(e)}getEntity(e){return this.entities.get(e)}getEntities(){return this.entities}getClasses(){return this.classes}addAttributes(e,r){let n=this.addEntity(e),i;for(i=r.length-1;i>=0;i--)r[i].keys||(r[i].keys=[]),r[i].comment||(r[i].comment=""),n.attributes.push(r[i]),Y.debug("Added attribute ",r[i].name)}addRelationship(e,r,n,i){let a=this.entities.get(e),s=this.entities.get(n);if(!a||!s)return;let l={entityA:a.id,roleA:r,entityB:s.id,relSpec:i};this.relationships.push(l),Y.debug("Added new relationship :",l)}getRelationships(){return this.relationships}getDirection(){return this.direction}setDirection(e){this.direction=e}getCompiledStyles(e){let r=[];for(let n of e){let i=this.classes.get(n);i?.styles&&(r=[...r,...i.styles??[]].map(a=>a.trim())),i?.textStyles&&(r=[...r,...i.textStyles??[]].map(a=>a.trim()))}return r}addCssStyles(e,r){for(let n of e){let i=this.entities.get(n);if(!r||!i)return;for(let a of r)i.cssStyles.push(a)}}addClass(e,r){e.forEach(n=>{let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)})})}setClass(e,r){for(let n of e){let i=this.entities.get(n);if(i)for(let a of r)i.cssClasses+=" "+a}}clear(){this.entities=new Map,this.classes=new Map,this.relationships=[],Ar()}getData(){let e=[],r=[],n=me();for(let a of this.entities.keys()){let s=this.entities.get(a);s&&(s.cssCompiledStyles=this.getCompiledStyles(s.cssClasses.split(" ")),e.push(s))}let i=0;for(let a of this.relationships){let s={id:$h(a.entityA,a.entityB,{prefix:"id",counter:i++}),type:"normal",curve:"basis",start:a.entityA,end:a.entityB,label:a.roleA,labelpos:"c",thickness:"normal",classes:"relationshipLine",arrowTypeStart:a.relSpec.cardB.toLowerCase(),arrowTypeEnd:a.relSpec.cardA.toLowerCase(),pattern:a.relSpec.relType=="IDENTIFYING"?"solid":"dashed",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:"TB"}}}});var TR={};hr(TR,{draw:()=>SOe});var SOe,tae=N(()=>{"use strict";zt();vt();gm();Yd();$m();ir();dr();SOe=o(async function(t,e,r,n){Y.info("REF0:"),Y.info("Drawing er diagram (unified)",e);let{securityLevel:i,er:a,layout:s}=me(),l=n.db.getData(),u=yc(e,i);l.type=n.type,l.layoutAlgorithm=nf(s),l.config.flowchart.nodeSpacing=a?.nodeSpacing||140,l.config.flowchart.rankSpacing=a?.rankSpacing||80,l.direction=n.db.getDirection(),l.markers=["only_one","zero_or_one","one_or_more","zero_or_more"],l.diagramId=e,await Cc(l,u),l.layoutAlgorithm==="elk"&&u.select(".edges").lower();let h=u.selectAll('[id*="-background"]');Array.from(h).length>0&&h.each(function(){let d=Ge(this),m=d.attr("id").replace("-background",""),g=u.select(`#${CSS.escape(m)}`);if(!g.empty()){let y=g.attr("transform");d.attr("transform",y)}});let f=8;Gt.insertTitle(u,"erDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ac(u,f,"erDiagram",a?.useMaxWidth??!0)},"draw")});var COe,AOe,rae,nae=N(()=>{"use strict";Ys();COe=o((t,e)=>{let r=Kf,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return qa(n,i,a,e)},"fade"),AOe=o(t=>` + .entityBox { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + } + + .relationshipLabelBox { + fill: ${t.tertiaryColor}; + opacity: 0.7; + background-color: ${t.tertiaryColor}; + rect { + opacity: 0.5; + } + } + + .labelBkg { + background-color: ${COe(t.tertiaryColor,.5)}; + } + + .edgeLabel .label { + fill: ${t.nodeBorder}; + font-size: 14px; + } + + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + + .edge-pattern-dashed { + stroke-dasharray: 8,8; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon + { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .relationshipLine { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; + } + + .marker { + fill: none !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; + } +`,"getStyles"),rae=AOe});var iae={};hr(iae,{diagram:()=>_Oe});var _Oe,aae=N(()=>{"use strict";Jie();eae();tae();nae();_Oe={parser:Zie,get db(){return new sk},renderer:TR,styles:rae}});function ii(t){return typeof t=="object"&&t!==null&&typeof t.$type=="string"}function va(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"}function kR(t){return typeof t=="object"&&t!==null&&typeof t.name=="string"&&typeof t.type=="string"&&typeof t.path=="string"}function jd(t){return typeof t=="object"&&t!==null&&ii(t.container)&&va(t.reference)&&typeof t.message=="string"}function Ll(t){return typeof t=="object"&&t!==null&&Array.isArray(t.content)}function af(t){return typeof t=="object"&&t!==null&&typeof t.tokenType=="object"}function M2(t){return Ll(t)&&typeof t.fullText=="string"}var Xd,Rl=N(()=>{"use strict";o(ii,"isAstNode");o(va,"isReference");o(kR,"isAstNodeDescription");o(jd,"isLinkingError");Xd=class{static{o(this,"AbstractAstReflection")}constructor(){this.subtypes={},this.allSubtypes={}}isInstance(e,r){return ii(e)&&this.isSubtype(e.$type,r)}isSubtype(e,r){if(e===r)return!0;let n=this.subtypes[e];n||(n=this.subtypes[e]={});let i=n[r];if(i!==void 0)return i;{let a=this.computeIsSubtype(e,r);return n[r]=a,a}}getAllSubTypes(e){let r=this.allSubtypes[e];if(r)return r;{let n=this.getAllTypes(),i=[];for(let a of n)this.isSubtype(a,e)&&i.push(a);return this.allSubtypes[e]=i,i}}};o(Ll,"isCompositeCstNode");o(af,"isLeafCstNode");o(M2,"isRootCstNode")});function NOe(t){return typeof t=="string"?t:typeof t>"u"?"undefined":typeof t.toString=="function"?t.toString():Object.prototype.toString.call(t)}function ok(t){return!!t&&typeof t[Symbol.iterator]=="function"}function en(...t){if(t.length===1){let e=t[0];if(e instanceof ao)return e;if(ok(e))return new ao(()=>e[Symbol.iterator](),r=>r.next());if(typeof e.length=="number")return new ao(()=>({index:0}),r=>r.index1?new ao(()=>({collIndex:0,arrIndex:0}),e=>{do{if(e.iterator){let r=e.iterator.next();if(!r.done)return r;e.iterator=void 0}if(e.array){if(e.arrIndex{"use strict";ao=class t{static{o(this,"StreamImpl")}constructor(e,r){this.startFn=e,this.nextFn=r}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),[Symbol.iterator]:()=>e};return e}[Symbol.iterator](){return this.iterator()}isEmpty(){return!!this.iterator().next().done}count(){let e=this.iterator(),r=0,n=e.next();for(;!n.done;)r++,n=e.next();return r}toArray(){let e=[],r=this.iterator(),n;do n=r.next(),n.value!==void 0&&e.push(n.value);while(!n.done);return e}toSet(){return new Set(this)}toMap(e,r){let n=this.map(i=>[e?e(i):i,r?r(i):i]);return new Map(n)}toString(){return this.join()}concat(e){return new t(()=>({first:this.startFn(),firstDone:!1,iterator:e[Symbol.iterator]()}),r=>{let n;if(!r.firstDone){do if(n=this.nextFn(r.first),!n.done)return n;while(!n.done);r.firstDone=!0}do if(n=r.iterator.next(),!n.done)return n;while(!n.done);return Ia})}join(e=","){let r=this.iterator(),n="",i,a=!1;do i=r.next(),i.done||(a&&(n+=e),n+=NOe(i.value)),a=!0;while(!i.done);return n}indexOf(e,r=0){let n=this.iterator(),i=0,a=n.next();for(;!a.done;){if(i>=r&&a.value===e)return i;a=n.next(),i++}return-1}every(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(!e(n.value))return!1;n=r.next()}return!0}some(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return!0;n=r.next()}return!1}forEach(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;)e(i.value,n),i=r.next(),n++}map(e){return new t(this.startFn,r=>{let{done:n,value:i}=this.nextFn(r);return n?Ia:{done:!1,value:e(i)}})}filter(e){return new t(this.startFn,r=>{let n;do if(n=this.nextFn(r),!n.done&&e(n.value))return n;while(!n.done);return Ia})}nonNullable(){return this.filter(e=>e!=null)}reduce(e,r){let n=this.iterator(),i=r,a=n.next();for(;!a.done;)i===void 0?i=a.value:i=e(i,a.value),a=n.next();return i}reduceRight(e,r){return this.recursiveReduce(this.iterator(),e,r)}recursiveReduce(e,r,n){let i=e.next();if(i.done)return n;let a=this.recursiveReduce(e,r,n);return a===void 0?i.value:r(a,i.value)}find(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(e(n.value))return n.value;n=r.next()}}findIndex(e){let r=this.iterator(),n=0,i=r.next();for(;!i.done;){if(e(i.value))return n;i=r.next(),n++}return-1}includes(e){let r=this.iterator(),n=r.next();for(;!n.done;){if(n.value===e)return!0;n=r.next()}return!1}flatMap(e){return new t(()=>({this:this.startFn()}),r=>{do{if(r.iterator){let a=r.iterator.next();if(a.done)r.iterator=void 0;else return a}let{done:n,value:i}=this.nextFn(r.this);if(!n){let a=e(i);if(ok(a))r.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}}while(r.iterator);return Ia})}flat(e){if(e===void 0&&(e=1),e<=0)return this;let r=e>1?this.flat(e-1):this;return new t(()=>({this:r.startFn()}),n=>{do{if(n.iterator){let s=n.iterator.next();if(s.done)n.iterator=void 0;else return s}let{done:i,value:a}=r.nextFn(n.this);if(!i)if(ok(a))n.iterator=a[Symbol.iterator]();else return{done:!1,value:a}}while(n.iterator);return Ia})}head(){let r=this.iterator().next();if(!r.done)return r.value}tail(e=1){return new t(()=>{let r=this.startFn();for(let n=0;n({size:0,state:this.startFn()}),r=>(r.size++,r.size>e?Ia:this.nextFn(r.state)))}distinct(e){return new t(()=>({set:new Set,internalState:this.startFn()}),r=>{let n;do if(n=this.nextFn(r.internalState),!n.done){let i=e?e(n.value):n.value;if(!r.set.has(i))return r.set.add(i),n}while(!n.done);return Ia})}exclude(e,r){let n=new Set;for(let i of e){let a=r?r(i):i;n.add(a)}return this.filter(i=>{let a=r?r(i):i;return!n.has(a)})}};o(NOe,"toString");o(ok,"isIterable");I2=new ao(()=>{},()=>Ia),Ia=Object.freeze({done:!0,value:void 0});o(en,"stream");_c=class extends ao{static{o(this,"TreeStreamImpl")}constructor(e,r,n){super(()=>({iterators:n?.includeRoot?[[e][Symbol.iterator]()]:[r(e)[Symbol.iterator]()],pruned:!1}),i=>{for(i.pruned&&(i.iterators.pop(),i.pruned=!1);i.iterators.length>0;){let s=i.iterators[i.iterators.length-1].next();if(s.done)i.iterators.pop();else return i.iterators.push(r(s.value)[Symbol.iterator]()),s}return Ia})}iterator(){let e={state:this.startFn(),next:o(()=>this.nextFn(e.state),"next"),prune:o(()=>{e.state.pruned=!0},"prune"),[Symbol.iterator]:()=>e};return e}};(function(t){function e(a){return a.reduce((s,l)=>s+l,0)}o(e,"sum"),t.sum=e;function r(a){return a.reduce((s,l)=>s*l,0)}o(r,"product"),t.product=r;function n(a){return a.reduce((s,l)=>Math.min(s,l))}o(n,"min"),t.min=n;function i(a){return a.reduce((s,l)=>Math.max(s,l))}o(i,"max"),t.max=i})(zm||(zm={}))});var ck={};hr(ck,{DefaultNameRegexp:()=>lk,RangeComparison:()=>Dc,compareRange:()=>cae,findCommentNode:()=>AR,findDeclarationNodeAtOffset:()=>IOe,findLeafNodeAtOffset:()=>_R,findLeafNodeBeforeOffset:()=>uae,flattenCst:()=>MOe,getInteriorNodes:()=>BOe,getNextNode:()=>OOe,getPreviousNode:()=>fae,getStartlineNode:()=>POe,inRange:()=>CR,isChildNode:()=>SR,isCommentNode:()=>ER,streamCst:()=>Kd,toDocumentSegment:()=>Qd,tokenToRange:()=>Gm});function Kd(t){return new _c(t,e=>Ll(e)?e.content:[],{includeRoot:!0})}function MOe(t){return Kd(t).filter(af)}function SR(t,e){for(;t.container;)if(t=t.container,t===e)return!0;return!1}function Gm(t){return{start:{character:t.startColumn-1,line:t.startLine-1},end:{character:t.endColumn,line:t.endLine-1}}}function Qd(t){if(!t)return;let{offset:e,end:r,range:n}=t;return{range:n,offset:e,end:r,length:r-e}}function cae(t,e){if(t.end.linee.end.line||t.start.line===e.end.line&&t.start.character>=e.end.character)return Dc.After;let r=t.start.line>e.start.line||t.start.line===e.start.line&&t.start.character>=e.start.character,n=t.end.lineDc.After}function IOe(t,e,r=lk){if(t){if(e>0){let n=e-t.offset,i=t.text.charAt(n);r.test(i)||e--}return _R(t,e)}}function AR(t,e){if(t){let r=fae(t,!0);if(r&&ER(r,e))return r;if(M2(t)){let n=t.content.findIndex(i=>!i.hidden);for(let i=n-1;i>=0;i--){let a=t.content[i];if(ER(a,e))return a}}}}function ER(t,e){return af(t)&&e.includes(t.tokenType.name)}function _R(t,e){if(af(t))return t;if(Ll(t)){let r=hae(t,e,!1);if(r)return _R(r,e)}}function uae(t,e){if(af(t))return t;if(Ll(t)){let r=hae(t,e,!0);if(r)return uae(r,e)}}function hae(t,e,r){let n=0,i=t.content.length-1,a;for(;n<=i;){let s=Math.floor((n+i)/2),l=t.content[s];if(l.offset<=e&&l.end>e)return l;l.end<=e?(a=r?l:void 0,n=s+1):i=s-1}return a}function fae(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t);for(;n>0;){n--;let i=r.content[n];if(e||!i.hidden)return i}t=r}}function OOe(t,e=!0){for(;t.container;){let r=t.container,n=r.content.indexOf(t),i=r.content.length-1;for(;n{"use strict";Rl();Ps();o(Kd,"streamCst");o(MOe,"flattenCst");o(SR,"isChildNode");o(Gm,"tokenToRange");o(Qd,"toDocumentSegment");(function(t){t[t.Before=0]="Before",t[t.After=1]="After",t[t.OverlapFront=2]="OverlapFront",t[t.OverlapBack=3]="OverlapBack",t[t.Inside=4]="Inside",t[t.Outside=5]="Outside"})(Dc||(Dc={}));o(cae,"compareRange");o(CR,"inRange");lk=/^[\w\p{L}]$/u;o(IOe,"findDeclarationNodeAtOffset");o(AR,"findCommentNode");o(ER,"isCommentNode");o(_R,"findLeafNodeAtOffset");o(uae,"findLeafNodeBeforeOffset");o(hae,"binarySearch");o(fae,"getPreviousNode");o(OOe,"getNextNode");o(POe,"getStartlineNode");o(BOe,"getInteriorNodes");o(FOe,"getCommonParent");o(lae,"getParentChain")});function Lc(t){throw new Error("Error! The input value was not handled.")}var Zd,uk=N(()=>{"use strict";Zd=class extends Error{static{o(this,"ErrorWithLocation")}constructor(e,r){super(e?`${r} at ${e.range.start.line}:${e.range.start.character}`:r)}};o(Lc,"assertUnreachable")});var U2={};hr(U2,{AbstractElement:()=>Hm,AbstractRule:()=>Vm,AbstractType:()=>Um,Action:()=>cg,Alternatives:()=>ug,ArrayLiteral:()=>Wm,ArrayType:()=>qm,Assignment:()=>hg,BooleanLiteral:()=>Ym,CharacterRange:()=>fg,Condition:()=>O2,Conjunction:()=>Xm,CrossReference:()=>dg,Disjunction:()=>jm,EndOfFile:()=>pg,Grammar:()=>Km,GrammarImport:()=>B2,Group:()=>mg,InferredType:()=>Qm,Interface:()=>Zm,Keyword:()=>gg,LangiumGrammarAstReflection:()=>Cg,LangiumGrammarTerminals:()=>$Oe,NamedArgument:()=>F2,NegatedToken:()=>yg,Negation:()=>Jm,NumberLiteral:()=>eg,Parameter:()=>tg,ParameterReference:()=>rg,ParserRule:()=>ng,ReferenceType:()=>ig,RegexToken:()=>vg,ReturnType:()=>$2,RuleCall:()=>xg,SimpleType:()=>ag,StringLiteral:()=>sg,TerminalAlternatives:()=>bg,TerminalGroup:()=>wg,TerminalRule:()=>Jd,TerminalRuleCall:()=>Tg,Type:()=>og,TypeAttribute:()=>z2,TypeDefinition:()=>hk,UnionType:()=>lg,UnorderedGroup:()=>kg,UntilToken:()=>Eg,ValueLiteral:()=>P2,Wildcard:()=>Sg,isAbstractElement:()=>G2,isAbstractRule:()=>zOe,isAbstractType:()=>GOe,isAction:()=>Mu,isAlternatives:()=>mk,isArrayLiteral:()=>qOe,isArrayType:()=>DR,isAssignment:()=>Ml,isBooleanLiteral:()=>LR,isCharacterRange:()=>FR,isCondition:()=>VOe,isConjunction:()=>RR,isCrossReference:()=>ep,isDisjunction:()=>NR,isEndOfFile:()=>$R,isFeatureName:()=>UOe,isGrammar:()=>YOe,isGrammarImport:()=>XOe,isGroup:()=>sf,isInferredType:()=>fk,isInterface:()=>dk,isKeyword:()=>Ho,isNamedArgument:()=>jOe,isNegatedToken:()=>zR,isNegation:()=>MR,isNumberLiteral:()=>KOe,isParameter:()=>QOe,isParameterReference:()=>IR,isParserRule:()=>Oa,isPrimitiveType:()=>dae,isReferenceType:()=>OR,isRegexToken:()=>GR,isReturnType:()=>PR,isRuleCall:()=>Il,isSimpleType:()=>pk,isStringLiteral:()=>ZOe,isTerminalAlternatives:()=>VR,isTerminalGroup:()=>UR,isTerminalRule:()=>so,isTerminalRuleCall:()=>gk,isType:()=>V2,isTypeAttribute:()=>JOe,isTypeDefinition:()=>HOe,isUnionType:()=>BR,isUnorderedGroup:()=>yk,isUntilToken:()=>HR,isValueLiteral:()=>WOe,isWildcard:()=>WR,reflection:()=>lr});function zOe(t){return lr.isInstance(t,Vm)}function GOe(t){return lr.isInstance(t,Um)}function VOe(t){return lr.isInstance(t,O2)}function UOe(t){return dae(t)||t==="current"||t==="entry"||t==="extends"||t==="false"||t==="fragment"||t==="grammar"||t==="hidden"||t==="import"||t==="interface"||t==="returns"||t==="terminal"||t==="true"||t==="type"||t==="infer"||t==="infers"||t==="with"||typeof t=="string"&&/\^?[_a-zA-Z][\w_]*/.test(t)}function dae(t){return t==="string"||t==="number"||t==="boolean"||t==="Date"||t==="bigint"}function HOe(t){return lr.isInstance(t,hk)}function WOe(t){return lr.isInstance(t,P2)}function G2(t){return lr.isInstance(t,Hm)}function qOe(t){return lr.isInstance(t,Wm)}function DR(t){return lr.isInstance(t,qm)}function LR(t){return lr.isInstance(t,Ym)}function RR(t){return lr.isInstance(t,Xm)}function NR(t){return lr.isInstance(t,jm)}function YOe(t){return lr.isInstance(t,Km)}function XOe(t){return lr.isInstance(t,B2)}function fk(t){return lr.isInstance(t,Qm)}function dk(t){return lr.isInstance(t,Zm)}function jOe(t){return lr.isInstance(t,F2)}function MR(t){return lr.isInstance(t,Jm)}function KOe(t){return lr.isInstance(t,eg)}function QOe(t){return lr.isInstance(t,tg)}function IR(t){return lr.isInstance(t,rg)}function Oa(t){return lr.isInstance(t,ng)}function OR(t){return lr.isInstance(t,ig)}function PR(t){return lr.isInstance(t,$2)}function pk(t){return lr.isInstance(t,ag)}function ZOe(t){return lr.isInstance(t,sg)}function so(t){return lr.isInstance(t,Jd)}function V2(t){return lr.isInstance(t,og)}function JOe(t){return lr.isInstance(t,z2)}function BR(t){return lr.isInstance(t,lg)}function Mu(t){return lr.isInstance(t,cg)}function mk(t){return lr.isInstance(t,ug)}function Ml(t){return lr.isInstance(t,hg)}function FR(t){return lr.isInstance(t,fg)}function ep(t){return lr.isInstance(t,dg)}function $R(t){return lr.isInstance(t,pg)}function sf(t){return lr.isInstance(t,mg)}function Ho(t){return lr.isInstance(t,gg)}function zR(t){return lr.isInstance(t,yg)}function GR(t){return lr.isInstance(t,vg)}function Il(t){return lr.isInstance(t,xg)}function VR(t){return lr.isInstance(t,bg)}function UR(t){return lr.isInstance(t,wg)}function gk(t){return lr.isInstance(t,Tg)}function yk(t){return lr.isInstance(t,kg)}function HR(t){return lr.isInstance(t,Eg)}function WR(t){return lr.isInstance(t,Sg)}var $Oe,Vm,Um,O2,hk,P2,Hm,Wm,qm,Ym,Xm,jm,Km,B2,Qm,Zm,F2,Jm,eg,tg,rg,ng,ig,$2,ag,sg,Jd,og,z2,lg,cg,ug,hg,fg,dg,pg,mg,gg,yg,vg,xg,bg,wg,Tg,kg,Eg,Sg,Cg,lr,Rc=N(()=>{"use strict";Rl();$Oe={ID:/\^?[_a-zA-Z][\w_]*/,STRING:/"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/,NUMBER:/NaN|-?((\d*\.\d+|\d+)([Ee][+-]?\d+)?|Infinity)/,RegexLiteral:/\/(?![*+?])(?:[^\r\n\[/\\]|\\.|\[(?:[^\r\n\]\\]|\\.)*\])+\/[a-z]*/,WS:/\s+/,ML_COMMENT:/\/\*[\s\S]*?\*\//,SL_COMMENT:/\/\/[^\n\r]*/},Vm="AbstractRule";o(zOe,"isAbstractRule");Um="AbstractType";o(GOe,"isAbstractType");O2="Condition";o(VOe,"isCondition");o(UOe,"isFeatureName");o(dae,"isPrimitiveType");hk="TypeDefinition";o(HOe,"isTypeDefinition");P2="ValueLiteral";o(WOe,"isValueLiteral");Hm="AbstractElement";o(G2,"isAbstractElement");Wm="ArrayLiteral";o(qOe,"isArrayLiteral");qm="ArrayType";o(DR,"isArrayType");Ym="BooleanLiteral";o(LR,"isBooleanLiteral");Xm="Conjunction";o(RR,"isConjunction");jm="Disjunction";o(NR,"isDisjunction");Km="Grammar";o(YOe,"isGrammar");B2="GrammarImport";o(XOe,"isGrammarImport");Qm="InferredType";o(fk,"isInferredType");Zm="Interface";o(dk,"isInterface");F2="NamedArgument";o(jOe,"isNamedArgument");Jm="Negation";o(MR,"isNegation");eg="NumberLiteral";o(KOe,"isNumberLiteral");tg="Parameter";o(QOe,"isParameter");rg="ParameterReference";o(IR,"isParameterReference");ng="ParserRule";o(Oa,"isParserRule");ig="ReferenceType";o(OR,"isReferenceType");$2="ReturnType";o(PR,"isReturnType");ag="SimpleType";o(pk,"isSimpleType");sg="StringLiteral";o(ZOe,"isStringLiteral");Jd="TerminalRule";o(so,"isTerminalRule");og="Type";o(V2,"isType");z2="TypeAttribute";o(JOe,"isTypeAttribute");lg="UnionType";o(BR,"isUnionType");cg="Action";o(Mu,"isAction");ug="Alternatives";o(mk,"isAlternatives");hg="Assignment";o(Ml,"isAssignment");fg="CharacterRange";o(FR,"isCharacterRange");dg="CrossReference";o(ep,"isCrossReference");pg="EndOfFile";o($R,"isEndOfFile");mg="Group";o(sf,"isGroup");gg="Keyword";o(Ho,"isKeyword");yg="NegatedToken";o(zR,"isNegatedToken");vg="RegexToken";o(GR,"isRegexToken");xg="RuleCall";o(Il,"isRuleCall");bg="TerminalAlternatives";o(VR,"isTerminalAlternatives");wg="TerminalGroup";o(UR,"isTerminalGroup");Tg="TerminalRuleCall";o(gk,"isTerminalRuleCall");kg="UnorderedGroup";o(yk,"isUnorderedGroup");Eg="UntilToken";o(HR,"isUntilToken");Sg="Wildcard";o(WR,"isWildcard");Cg=class extends Xd{static{o(this,"LangiumGrammarAstReflection")}getAllTypes(){return[Hm,Vm,Um,cg,ug,Wm,qm,hg,Ym,fg,O2,Xm,dg,jm,pg,Km,B2,mg,Qm,Zm,gg,F2,yg,Jm,eg,tg,rg,ng,ig,vg,$2,xg,ag,sg,bg,wg,Jd,Tg,og,z2,hk,lg,kg,Eg,P2,Sg]}computeIsSubtype(e,r){switch(e){case cg:case ug:case hg:case fg:case dg:case pg:case mg:case gg:case yg:case vg:case xg:case bg:case wg:case Tg:case kg:case Eg:case Sg:return this.isSubtype(Hm,r);case Wm:case eg:case sg:return this.isSubtype(P2,r);case qm:case ig:case ag:case lg:return this.isSubtype(hk,r);case Ym:return this.isSubtype(O2,r)||this.isSubtype(P2,r);case Xm:case jm:case Jm:case rg:return this.isSubtype(O2,r);case Qm:case Zm:case og:return this.isSubtype(Um,r);case ng:return this.isSubtype(Vm,r)||this.isSubtype(Um,r);case Jd:return this.isSubtype(Vm,r);default:return!1}}getReferenceType(e){let r=`${e.container.$type}:${e.property}`;switch(r){case"Action:type":case"CrossReference:type":case"Interface:superTypes":case"ParserRule:returnType":case"SimpleType:typeRef":return Um;case"Grammar:hiddenTokens":case"ParserRule:hiddenTokens":case"RuleCall:rule":return Vm;case"Grammar:usedGrammars":return Km;case"NamedArgument:parameter":case"ParameterReference:parameter":return tg;case"TerminalRuleCall:rule":return Jd;default:throw new Error(`${r} is not a valid reference id.`)}}getTypeMetaData(e){switch(e){case Hm:return{name:Hm,properties:[{name:"cardinality"},{name:"lookahead"}]};case Wm:return{name:Wm,properties:[{name:"elements",defaultValue:[]}]};case qm:return{name:qm,properties:[{name:"elementType"}]};case Ym:return{name:Ym,properties:[{name:"true",defaultValue:!1}]};case Xm:return{name:Xm,properties:[{name:"left"},{name:"right"}]};case jm:return{name:jm,properties:[{name:"left"},{name:"right"}]};case Km:return{name:Km,properties:[{name:"definesHiddenTokens",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"imports",defaultValue:[]},{name:"interfaces",defaultValue:[]},{name:"isDeclared",defaultValue:!1},{name:"name"},{name:"rules",defaultValue:[]},{name:"types",defaultValue:[]},{name:"usedGrammars",defaultValue:[]}]};case B2:return{name:B2,properties:[{name:"path"}]};case Qm:return{name:Qm,properties:[{name:"name"}]};case Zm:return{name:Zm,properties:[{name:"attributes",defaultValue:[]},{name:"name"},{name:"superTypes",defaultValue:[]}]};case F2:return{name:F2,properties:[{name:"calledByName",defaultValue:!1},{name:"parameter"},{name:"value"}]};case Jm:return{name:Jm,properties:[{name:"value"}]};case eg:return{name:eg,properties:[{name:"value"}]};case tg:return{name:tg,properties:[{name:"name"}]};case rg:return{name:rg,properties:[{name:"parameter"}]};case ng:return{name:ng,properties:[{name:"dataType"},{name:"definesHiddenTokens",defaultValue:!1},{name:"definition"},{name:"entry",defaultValue:!1},{name:"fragment",defaultValue:!1},{name:"hiddenTokens",defaultValue:[]},{name:"inferredType"},{name:"name"},{name:"parameters",defaultValue:[]},{name:"returnType"},{name:"wildcard",defaultValue:!1}]};case ig:return{name:ig,properties:[{name:"referenceType"}]};case $2:return{name:$2,properties:[{name:"name"}]};case ag:return{name:ag,properties:[{name:"primitiveType"},{name:"stringType"},{name:"typeRef"}]};case sg:return{name:sg,properties:[{name:"value"}]};case Jd:return{name:Jd,properties:[{name:"definition"},{name:"fragment",defaultValue:!1},{name:"hidden",defaultValue:!1},{name:"name"},{name:"type"}]};case og:return{name:og,properties:[{name:"name"},{name:"type"}]};case z2:return{name:z2,properties:[{name:"defaultValue"},{name:"isOptional",defaultValue:!1},{name:"name"},{name:"type"}]};case lg:return{name:lg,properties:[{name:"types",defaultValue:[]}]};case cg:return{name:cg,properties:[{name:"cardinality"},{name:"feature"},{name:"inferredType"},{name:"lookahead"},{name:"operator"},{name:"type"}]};case ug:return{name:ug,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case hg:return{name:hg,properties:[{name:"cardinality"},{name:"feature"},{name:"lookahead"},{name:"operator"},{name:"terminal"}]};case fg:return{name:fg,properties:[{name:"cardinality"},{name:"left"},{name:"lookahead"},{name:"right"}]};case dg:return{name:dg,properties:[{name:"cardinality"},{name:"deprecatedSyntax",defaultValue:!1},{name:"lookahead"},{name:"terminal"},{name:"type"}]};case pg:return{name:pg,properties:[{name:"cardinality"},{name:"lookahead"}]};case mg:return{name:mg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"guardCondition"},{name:"lookahead"}]};case gg:return{name:gg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"value"}]};case yg:return{name:yg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case vg:return{name:vg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"regex"}]};case xg:return{name:xg,properties:[{name:"arguments",defaultValue:[]},{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case bg:return{name:bg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case wg:return{name:wg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Tg:return{name:Tg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"rule"}]};case kg:return{name:kg,properties:[{name:"cardinality"},{name:"elements",defaultValue:[]},{name:"lookahead"}]};case Eg:return{name:Eg,properties:[{name:"cardinality"},{name:"lookahead"},{name:"terminal"}]};case Sg:return{name:Sg,properties:[{name:"cardinality"},{name:"lookahead"}]};default:return{name:e,properties:[]}}}},lr=new Cg});var xk={};hr(xk,{assignMandatoryProperties:()=>XR,copyAstNode:()=>YR,findLocalReferences:()=>tPe,findRootNode:()=>H2,getContainerOfType:()=>tp,getDocument:()=>Pa,hasContainerOfType:()=>ePe,linkContentToContainer:()=>vk,streamAllContents:()=>Nc,streamAst:()=>Wo,streamContents:()=>W2,streamReferences:()=>Ag});function vk(t){for(let[e,r]of Object.entries(t))e.startsWith("$")||(Array.isArray(r)?r.forEach((n,i)=>{ii(n)&&(n.$container=t,n.$containerProperty=e,n.$containerIndex=i)}):ii(r)&&(r.$container=t,r.$containerProperty=e))}function tp(t,e){let r=t;for(;r;){if(e(r))return r;r=r.$container}}function ePe(t,e){let r=t;for(;r;){if(e(r))return!0;r=r.$container}return!1}function Pa(t){let r=H2(t).$document;if(!r)throw new Error("AST node has no document.");return r}function H2(t){for(;t.$container;)t=t.$container;return t}function W2(t,e){if(!t)throw new Error("Node must be an AstNode.");let r=e?.range;return new ao(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),n=>{for(;n.keyIndexW2(r,e))}function Wo(t,e){if(t){if(e?.range&&!qR(t,e.range))return new _c(t,()=>[])}else throw new Error("Root node must be an AstNode.");return new _c(t,r=>W2(r,e),{includeRoot:!0})}function qR(t,e){var r;if(!e)return!0;let n=(r=t.$cstNode)===null||r===void 0?void 0:r.range;return n?CR(n,e):!1}function Ag(t){return new ao(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),e=>{for(;e.keyIndex{Ag(n).forEach(i=>{i.reference.ref===t&&r.push(i.reference)})}),en(r)}function XR(t,e){let r=t.getTypeMetaData(e.$type),n=e;for(let i of r.properties)i.defaultValue!==void 0&&n[i.name]===void 0&&(n[i.name]=pae(i.defaultValue))}function pae(t){return Array.isArray(t)?[...t.map(pae)]:t}function YR(t,e){let r={$type:t.$type};for(let[n,i]of Object.entries(t))if(!n.startsWith("$"))if(ii(i))r[n]=YR(i,e);else if(va(i))r[n]=e(r,n,i.$refNode,i.$refText);else if(Array.isArray(i)){let a=[];for(let s of i)ii(s)?a.push(YR(s,e)):va(s)?a.push(e(r,n,s.$refNode,s.$refText)):a.push(s);r[n]=a}else r[n]=i;return vk(r),r}var is=N(()=>{"use strict";Rl();Ps();Nl();o(vk,"linkContentToContainer");o(tp,"getContainerOfType");o(ePe,"hasContainerOfType");o(Pa,"getDocument");o(H2,"findRootNode");o(W2,"streamContents");o(Nc,"streamAllContents");o(Wo,"streamAst");o(qR,"isAstNodeInRange");o(Ag,"streamReferences");o(tPe,"findLocalReferences");o(XR,"assignMandatoryProperties");o(pae,"copyDefaultValue");o(YR,"copyAstNode")});function ar(t){return t.charCodeAt(0)}function bk(t,e){Array.isArray(t)?t.forEach(function(r){e.push(r)}):e.push(t)}function _g(t,e){if(t[e]===!0)throw"duplicate flag "+e;let r=t[e];t[e]=!0}function rp(t){if(t===void 0)throw Error("Internal Error - Should never get here!");return!0}function q2(){throw Error("Internal Error - Should never get here!")}function jR(t){return t.type==="Character"}var KR=N(()=>{"use strict";o(ar,"cc");o(bk,"insertToSet");o(_g,"addFlag");o(rp,"ASSERT_EXISTS");o(q2,"ASSERT_NEVER_REACH_HERE");o(jR,"isCharacter")});var Y2,X2,QR,mae=N(()=>{"use strict";KR();Y2=[];for(let t=ar("0");t<=ar("9");t++)Y2.push(t);X2=[ar("_")].concat(Y2);for(let t=ar("a");t<=ar("z");t++)X2.push(t);for(let t=ar("A");t<=ar("Z");t++)X2.push(t);QR=[ar(" "),ar("\f"),ar(` +`),ar("\r"),ar(" "),ar("\v"),ar(" "),ar("\xA0"),ar("\u1680"),ar("\u2000"),ar("\u2001"),ar("\u2002"),ar("\u2003"),ar("\u2004"),ar("\u2005"),ar("\u2006"),ar("\u2007"),ar("\u2008"),ar("\u2009"),ar("\u200A"),ar("\u2028"),ar("\u2029"),ar("\u202F"),ar("\u205F"),ar("\u3000"),ar("\uFEFF")]});var rPe,wk,nPe,np,gae=N(()=>{"use strict";KR();mae();rPe=/[0-9a-fA-F]/,wk=/[0-9]/,nPe=/[1-9]/,np=class{static{o(this,"RegExpParser")}constructor(){this.idx=0,this.input="",this.groupIdx=0}saveState(){return{idx:this.idx,input:this.input,groupIdx:this.groupIdx}}restoreState(e){this.idx=e.idx,this.input=e.input,this.groupIdx=e.groupIdx}pattern(e){this.idx=0,this.input=e,this.groupIdx=0,this.consumeChar("/");let r=this.disjunction();this.consumeChar("/");let n={type:"Flags",loc:{begin:this.idx,end:e.length},global:!1,ignoreCase:!1,multiLine:!1,unicode:!1,sticky:!1};for(;this.isRegExpFlag();)switch(this.popChar()){case"g":_g(n,"global");break;case"i":_g(n,"ignoreCase");break;case"m":_g(n,"multiLine");break;case"u":_g(n,"unicode");break;case"y":_g(n,"sticky");break}if(this.idx!==this.input.length)throw Error("Redundant input: "+this.input.substring(this.idx));return{type:"Pattern",flags:n,value:r,loc:this.loc(0)}}disjunction(){let e=[],r=this.idx;for(e.push(this.alternative());this.peekChar()==="|";)this.consumeChar("|"),e.push(this.alternative());return{type:"Disjunction",value:e,loc:this.loc(r)}}alternative(){let e=[],r=this.idx;for(;this.isTerm();)e.push(this.term());return{type:"Alternative",value:e,loc:this.loc(r)}}term(){return this.isAssertion()?this.assertion():this.atom()}assertion(){let e=this.idx;switch(this.popChar()){case"^":return{type:"StartAnchor",loc:this.loc(e)};case"$":return{type:"EndAnchor",loc:this.loc(e)};case"\\":switch(this.popChar()){case"b":return{type:"WordBoundary",loc:this.loc(e)};case"B":return{type:"NonWordBoundary",loc:this.loc(e)}}throw Error("Invalid Assertion Escape");case"(":this.consumeChar("?");let r;switch(this.popChar()){case"=":r="Lookahead";break;case"!":r="NegativeLookahead";break}rp(r);let n=this.disjunction();return this.consumeChar(")"),{type:r,value:n,loc:this.loc(e)}}return q2()}quantifier(e=!1){let r,n=this.idx;switch(this.popChar()){case"*":r={atLeast:0,atMost:1/0};break;case"+":r={atLeast:1,atMost:1/0};break;case"?":r={atLeast:0,atMost:1};break;case"{":let i=this.integerIncludingZero();switch(this.popChar()){case"}":r={atLeast:i,atMost:i};break;case",":let a;this.isDigit()?(a=this.integerIncludingZero(),r={atLeast:i,atMost:a}):r={atLeast:i,atMost:1/0},this.consumeChar("}");break}if(e===!0&&r===void 0)return;rp(r);break}if(!(e===!0&&r===void 0)&&rp(r))return this.peekChar(0)==="?"?(this.consumeChar("?"),r.greedy=!1):r.greedy=!0,r.type="Quantifier",r.loc=this.loc(n),r}atom(){let e,r=this.idx;switch(this.peekChar()){case".":e=this.dotAll();break;case"\\":e=this.atomEscape();break;case"[":e=this.characterClass();break;case"(":e=this.group();break}return e===void 0&&this.isPatternCharacter()&&(e=this.patternCharacter()),rp(e)?(e.loc=this.loc(r),this.isQuantifier()&&(e.quantifier=this.quantifier()),e):q2()}dotAll(){return this.consumeChar("."),{type:"Set",complement:!0,value:[ar(` +`),ar("\r"),ar("\u2028"),ar("\u2029")]}}atomEscape(){switch(this.consumeChar("\\"),this.peekChar()){case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":return this.decimalEscapeAtom();case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}}decimalEscapeAtom(){return{type:"GroupBackReference",value:this.positiveInteger()}}characterClassEscape(){let e,r=!1;switch(this.popChar()){case"d":e=Y2;break;case"D":e=Y2,r=!0;break;case"s":e=QR;break;case"S":e=QR,r=!0;break;case"w":e=X2;break;case"W":e=X2,r=!0;break}return rp(e)?{type:"Set",value:e,complement:r}:q2()}controlEscapeAtom(){let e;switch(this.popChar()){case"f":e=ar("\f");break;case"n":e=ar(` +`);break;case"r":e=ar("\r");break;case"t":e=ar(" ");break;case"v":e=ar("\v");break}return rp(e)?{type:"Character",value:e}:q2()}controlLetterEscapeAtom(){this.consumeChar("c");let e=this.popChar();if(/[a-zA-Z]/.test(e)===!1)throw Error("Invalid ");return{type:"Character",value:e.toUpperCase().charCodeAt(0)-64}}nulCharacterAtom(){return this.consumeChar("0"),{type:"Character",value:ar("\0")}}hexEscapeSequenceAtom(){return this.consumeChar("x"),this.parseHexDigits(2)}regExpUnicodeEscapeSequenceAtom(){return this.consumeChar("u"),this.parseHexDigits(4)}identityEscapeAtom(){let e=this.popChar();return{type:"Character",value:ar(e)}}classPatternCharacterAtom(){switch(this.peekChar()){case` +`:case"\r":case"\u2028":case"\u2029":case"\\":case"]":throw Error("TBD");default:let e=this.popChar();return{type:"Character",value:ar(e)}}}characterClass(){let e=[],r=!1;for(this.consumeChar("["),this.peekChar(0)==="^"&&(this.consumeChar("^"),r=!0);this.isClassAtom();){let n=this.classAtom(),i=n.type==="Character";if(jR(n)&&this.isRangeDash()){this.consumeChar("-");let a=this.classAtom(),s=a.type==="Character";if(jR(a)){if(a.value=this.input.length)throw Error("Unexpected end of input");this.idx++}loc(e){return{begin:e,end:this.idx}}}});var Mc,yae=N(()=>{"use strict";Mc=class{static{o(this,"BaseRegExpVisitor")}visitChildren(e){for(let r in e){let n=e[r];e.hasOwnProperty(r)&&(n.type!==void 0?this.visit(n):Array.isArray(n)&&n.forEach(i=>{this.visit(i)},this))}}visit(e){switch(e.type){case"Pattern":this.visitPattern(e);break;case"Flags":this.visitFlags(e);break;case"Disjunction":this.visitDisjunction(e);break;case"Alternative":this.visitAlternative(e);break;case"StartAnchor":this.visitStartAnchor(e);break;case"EndAnchor":this.visitEndAnchor(e);break;case"WordBoundary":this.visitWordBoundary(e);break;case"NonWordBoundary":this.visitNonWordBoundary(e);break;case"Lookahead":this.visitLookahead(e);break;case"NegativeLookahead":this.visitNegativeLookahead(e);break;case"Character":this.visitCharacter(e);break;case"Set":this.visitSet(e);break;case"Group":this.visitGroup(e);break;case"GroupBackReference":this.visitGroupBackReference(e);break;case"Quantifier":this.visitQuantifier(e);break}this.visitChildren(e)}visitPattern(e){}visitFlags(e){}visitDisjunction(e){}visitAlternative(e){}visitStartAnchor(e){}visitEndAnchor(e){}visitWordBoundary(e){}visitNonWordBoundary(e){}visitLookahead(e){}visitNegativeLookahead(e){}visitCharacter(e){}visitSet(e){}visitGroup(e){}visitGroupBackReference(e){}visitQuantifier(e){}}});var j2=N(()=>{"use strict";gae();yae()});var Tk={};hr(Tk,{NEWLINE_REGEXP:()=>JR,escapeRegExp:()=>ap,getCaseInsensitivePattern:()=>tN,getTerminalParts:()=>iPe,isMultilineComment:()=>eN,isWhitespace:()=>Dg,partialMatches:()=>rN,partialRegExp:()=>bae,whitespaceCharacters:()=>xae});function iPe(t){try{typeof t!="string"&&(t=t.source),t=`/${t}/`;let e=vae.pattern(t),r=[];for(let n of e.value.value)ip.reset(t),ip.visit(n),r.push({start:ip.startRegexp,end:ip.endRegex});return r}catch{return[]}}function eN(t){try{return typeof t=="string"&&(t=new RegExp(t)),t=t.toString(),ip.reset(t),ip.visit(vae.pattern(t)),ip.multiline}catch{return!1}}function Dg(t){let e=typeof t=="string"?new RegExp(t):t;return xae.some(r=>e.test(r))}function ap(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function tN(t){return Array.prototype.map.call(t,e=>/\w/.test(e)?`[${e.toLowerCase()}${e.toUpperCase()}]`:ap(e)).join("")}function rN(t,e){let r=bae(t),n=e.match(r);return!!n&&n[0].length>0}function bae(t){typeof t=="string"&&(t=new RegExp(t));let e=t,r=t.source,n=0;function i(){let a="",s;function l(h){a+=r.substr(n,h),n+=h}o(l,"appendRaw");function u(h){a+="(?:"+r.substr(n,h)+"|$)",n+=h}for(o(u,"appendOptional");n",n)-n+1);break;default:u(2);break}break;case"[":s=/\[(?:\\.|.)*?\]/g,s.lastIndex=n,s=s.exec(r)||[],u(s[0].length);break;case"|":case"^":case"$":case"*":case"+":case"?":l(1);break;case"{":s=/\{\d+,?\d*\}/g,s.lastIndex=n,s=s.exec(r),s?l(s[0].length):u(1);break;case"(":if(r[n+1]==="?")switch(r[n+2]){case":":a+="(?:",n+=3,a+=i()+"|$)";break;case"=":a+="(?=",n+=3,a+=i()+")";break;case"!":s=n,n+=3,i(),a+=r.substr(s,n-s);break;case"<":switch(r[n+3]){case"=":case"!":s=n,n+=4,i(),a+=r.substr(s,n-s);break;default:l(r.indexOf(">",n)-n+1),a+=i()+"|$)";break}break}else l(1),a+=i()+"|$)";break;case")":return++n,a;default:u(1);break}return a}return o(i,"process"),new RegExp(i(),t.flags)}var JR,vae,ZR,ip,xae,Lg=N(()=>{"use strict";j2();JR=/\r?\n/gm,vae=new np,ZR=class extends Mc{static{o(this,"TerminalRegExpVisitor")}constructor(){super(...arguments),this.isStarting=!0,this.endRegexpStack=[],this.multiline=!1}get endRegex(){return this.endRegexpStack.join("")}reset(e){this.multiline=!1,this.regex=e,this.startRegexp="",this.isStarting=!0,this.endRegexpStack=[]}visitGroup(e){e.quantifier&&(this.isStarting=!1,this.endRegexpStack=[])}visitCharacter(e){let r=String.fromCharCode(e.value);if(!this.multiline&&r===` +`&&(this.multiline=!0),e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let n=ap(r);this.endRegexpStack.push(n),this.isStarting&&(this.startRegexp+=n)}}visitSet(e){if(!this.multiline){let r=this.regex.substring(e.loc.begin,e.loc.end),n=new RegExp(r);this.multiline=!!` +`.match(n)}if(e.quantifier)this.isStarting=!1,this.endRegexpStack=[];else{let r=this.regex.substring(e.loc.begin,e.loc.end);this.endRegexpStack.push(r),this.isStarting&&(this.startRegexp+=r)}}visitChildren(e){e.type==="Group"&&e.quantifier||super.visitChildren(e)}},ip=new ZR;o(iPe,"getTerminalParts");o(eN,"isMultilineComment");xae=`\f +\r \v \xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF`.split("");o(Dg,"isWhitespace");o(ap,"escapeRegExp");o(tN,"getCaseInsensitivePattern");o(rN,"partialMatches");o(bae,"partialRegExp")});var Ek={};hr(Ek,{findAssignment:()=>hN,findNameAssignment:()=>kk,findNodeForKeyword:()=>cN,findNodeForProperty:()=>Q2,findNodesForKeyword:()=>aPe,findNodesForKeywordInternal:()=>uN,findNodesForProperty:()=>oN,getActionAtElement:()=>Sae,getActionType:()=>Aae,getAllReachableRules:()=>K2,getCrossReferenceTerminal:()=>aN,getEntryRule:()=>wae,getExplicitRuleType:()=>Rg,getHiddenRules:()=>Tae,getRuleType:()=>fN,getRuleTypeName:()=>uPe,getTypeName:()=>J2,isArrayCardinality:()=>oPe,isArrayOperator:()=>lPe,isCommentTerminal:()=>sN,isDataType:()=>cPe,isDataTypeRule:()=>Z2,isOptionalCardinality:()=>sPe,terminalRegex:()=>Ng});function wae(t){return t.rules.find(e=>Oa(e)&&e.entry)}function Tae(t){return t.rules.filter(e=>so(e)&&e.hidden)}function K2(t,e){let r=new Set,n=wae(t);if(!n)return new Set(t.rules);let i=[n].concat(Tae(t));for(let s of i)kae(s,r,e);let a=new Set;for(let s of t.rules)(r.has(s.name)||so(s)&&s.hidden)&&a.add(s);return a}function kae(t,e,r){e.add(t.name),Nc(t).forEach(n=>{if(Il(n)||r&&gk(n)){let i=n.rule.ref;i&&!e.has(i.name)&&kae(i,e,r)}})}function aN(t){if(t.terminal)return t.terminal;if(t.type.ref){let e=kk(t.type.ref);return e?.terminal}}function sN(t){return t.hidden&&!Dg(Ng(t))}function oN(t,e){return!t||!e?[]:lN(t,e,t.astNode,!0)}function Q2(t,e,r){if(!t||!e)return;let n=lN(t,e,t.astNode,!0);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function lN(t,e,r,n){if(!n){let i=tp(t.grammarSource,Ml);if(i&&i.feature===e)return[t]}return Ll(t)&&t.astNode===r?t.content.flatMap(i=>lN(i,e,r,!1)):[]}function aPe(t,e){return t?uN(t,e,t?.astNode):[]}function cN(t,e,r){if(!t)return;let n=uN(t,e,t?.astNode);if(n.length!==0)return r!==void 0?r=Math.max(0,Math.min(r,n.length-1)):r=0,n[r]}function uN(t,e,r){if(t.astNode!==r)return[];if(Ho(t.grammarSource)&&t.grammarSource.value===e)return[t];let n=Kd(t).iterator(),i,a=[];do if(i=n.next(),!i.done){let s=i.value;s.astNode===r?Ho(s.grammarSource)&&s.grammarSource.value===e&&a.push(s):n.prune()}while(!i.done);return a}function hN(t){var e;let r=t.astNode;for(;r===((e=t.container)===null||e===void 0?void 0:e.astNode);){let n=tp(t.grammarSource,Ml);if(n)return n;t=t.container}}function kk(t){let e=t;return fk(e)&&(Mu(e.$container)?e=e.$container.$container:Oa(e.$container)?e=e.$container:Lc(e.$container)),Eae(t,e,new Map)}function Eae(t,e,r){var n;function i(a,s){let l;return tp(a,Ml)||(l=Eae(s,s,r)),r.set(t,l),l}if(o(i,"go"),r.has(t))return r.get(t);r.set(t,void 0);for(let a of Nc(e)){if(Ml(a)&&a.feature.toLowerCase()==="name")return r.set(t,a),a;if(Il(a)&&Oa(a.rule.ref))return i(a,a.rule.ref);if(pk(a)&&(!((n=a.typeRef)===null||n===void 0)&&n.ref))return i(a,a.typeRef.ref)}}function Sae(t){let e=t.$container;if(sf(e)){let r=e.elements,n=r.indexOf(t);for(let i=n-1;i>=0;i--){let a=r[i];if(Mu(a))return a;{let s=Nc(r[i]).find(Mu);if(s)return s}}}if(G2(e))return Sae(e)}function sPe(t,e){return t==="?"||t==="*"||sf(e)&&!!e.guardCondition}function oPe(t){return t==="*"||t==="+"}function lPe(t){return t==="+="}function Z2(t){return Cae(t,new Set)}function Cae(t,e){if(e.has(t))return!0;e.add(t);for(let r of Nc(t))if(Il(r)){if(!r.rule.ref||Oa(r.rule.ref)&&!Cae(r.rule.ref,e))return!1}else{if(Ml(r))return!1;if(Mu(r))return!1}return!!t.definition}function cPe(t){return iN(t.type,new Set)}function iN(t,e){if(e.has(t))return!0;if(e.add(t),DR(t))return!1;if(OR(t))return!1;if(BR(t))return t.types.every(r=>iN(r,e));if(pk(t)){if(t.primitiveType!==void 0)return!0;if(t.stringType!==void 0)return!0;if(t.typeRef!==void 0){let r=t.typeRef.ref;return V2(r)?iN(r.type,e):!1}else return!1}else return!1}function Rg(t){if(t.inferredType)return t.inferredType.name;if(t.dataType)return t.dataType;if(t.returnType){let e=t.returnType.ref;if(e){if(Oa(e))return e.name;if(dk(e)||V2(e))return e.name}}}function J2(t){var e;if(Oa(t))return Z2(t)?t.name:(e=Rg(t))!==null&&e!==void 0?e:t.name;if(dk(t)||V2(t)||PR(t))return t.name;if(Mu(t)){let r=Aae(t);if(r)return r}else if(fk(t))return t.name;throw new Error("Cannot get name of Unknown Type")}function Aae(t){var e;if(t.inferredType)return t.inferredType.name;if(!((e=t.type)===null||e===void 0)&&e.ref)return J2(t.type.ref)}function uPe(t){var e,r,n;return so(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":Z2(t)?t.name:(n=Rg(t))!==null&&n!==void 0?n:t.name}function fN(t){var e,r,n;return so(t)?(r=(e=t.type)===null||e===void 0?void 0:e.name)!==null&&r!==void 0?r:"string":(n=Rg(t))!==null&&n!==void 0?n:t.name}function Ng(t){let e={s:!1,i:!1,u:!1},r=Mg(t.definition,e),n=Object.entries(e).filter(([,i])=>i).map(([i])=>i).join("");return new RegExp(r,n)}function Mg(t,e){if(VR(t))return hPe(t);if(UR(t))return fPe(t);if(FR(t))return mPe(t);if(gk(t)){let r=t.rule.ref;if(!r)throw new Error("Missing rule reference.");return Iu(Mg(r.definition),{cardinality:t.cardinality,lookahead:t.lookahead})}else{if(zR(t))return pPe(t);if(HR(t))return dPe(t);if(GR(t)){let r=t.regex.lastIndexOf("/"),n=t.regex.substring(1,r),i=t.regex.substring(r+1);return e&&(e.i=i.includes("i"),e.s=i.includes("s"),e.u=i.includes("u")),Iu(n,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}else{if(WR(t))return Iu(dN,{cardinality:t.cardinality,lookahead:t.lookahead});throw new Error(`Invalid terminal element: ${t?.$type}`)}}}function hPe(t){return Iu(t.elements.map(e=>Mg(e)).join("|"),{cardinality:t.cardinality,lookahead:t.lookahead})}function fPe(t){return Iu(t.elements.map(e=>Mg(e)).join(""),{cardinality:t.cardinality,lookahead:t.lookahead})}function dPe(t){return Iu(`${dN}*?${Mg(t.terminal)}`,{cardinality:t.cardinality,lookahead:t.lookahead})}function pPe(t){return Iu(`(?!${Mg(t.terminal)})${dN}*?`,{cardinality:t.cardinality,lookahead:t.lookahead})}function mPe(t){return t.right?Iu(`[${nN(t.left)}-${nN(t.right)}]`,{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1}):Iu(nN(t.left),{cardinality:t.cardinality,lookahead:t.lookahead,wrap:!1})}function nN(t){return ap(t.value)}function Iu(t,e){var r;return(e.wrap!==!1||e.lookahead)&&(t=`(${(r=e.lookahead)!==null&&r!==void 0?r:""}${t})`),e.cardinality?`${t}${e.cardinality}`:t}var dN,Ol=N(()=>{"use strict";uk();Rc();Rl();is();Nl();Lg();o(wae,"getEntryRule");o(Tae,"getHiddenRules");o(K2,"getAllReachableRules");o(kae,"ruleDfs");o(aN,"getCrossReferenceTerminal");o(sN,"isCommentTerminal");o(oN,"findNodesForProperty");o(Q2,"findNodeForProperty");o(lN,"findNodesForPropertyInternal");o(aPe,"findNodesForKeyword");o(cN,"findNodeForKeyword");o(uN,"findNodesForKeywordInternal");o(hN,"findAssignment");o(kk,"findNameAssignment");o(Eae,"findNameAssignmentInternal");o(Sae,"getActionAtElement");o(sPe,"isOptionalCardinality");o(oPe,"isArrayCardinality");o(lPe,"isArrayOperator");o(Z2,"isDataTypeRule");o(Cae,"isDataTypeRuleInternal");o(cPe,"isDataType");o(iN,"isDataTypeInternal");o(Rg,"getExplicitRuleType");o(J2,"getTypeName");o(Aae,"getActionType");o(uPe,"getRuleTypeName");o(fN,"getRuleType");o(Ng,"terminalRegex");dN=/[\s\S]/.source;o(Mg,"abstractElementToRegex");o(hPe,"terminalAlternativesToRegex");o(fPe,"terminalGroupToRegex");o(dPe,"untilTokenToRegex");o(pPe,"negateTokenToRegex");o(mPe,"characterRangeToRegex");o(nN,"keywordToRegex");o(Iu,"withCardinality")});function pN(t){let e=[],r=t.Grammar;for(let n of r.rules)so(n)&&sN(n)&&eN(Ng(n))&&e.push(n.name);return{multilineCommentRules:e,nameRegexp:lk}}var mN=N(()=>{"use strict";Nl();Ol();Lg();Rc();o(pN,"createGrammarConfig")});var gN=N(()=>{"use strict"});function Ig(t){console&&console.error&&console.error(`Error: ${t}`)}function ex(t){console&&console.warn&&console.warn(`Warning: ${t}`)}var _ae=N(()=>{"use strict";o(Ig,"PRINT_ERROR");o(ex,"PRINT_WARNING")});function tx(t){let e=new Date().getTime(),r=t();return{time:new Date().getTime()-e,value:r}}var Dae=N(()=>{"use strict";o(tx,"timer")});function rx(t){function e(){}o(e,"FakeConstructor"),e.prototype=t;let r=new e;function n(){return typeof r.bar}return o(n,"fakeAccess"),n(),n(),t;(0,eval)(t)}var Lae=N(()=>{"use strict";o(rx,"toFastProperties")});var Og=N(()=>{"use strict";_ae();Dae();Lae()});function gPe(t){return yPe(t)?t.LABEL:t.name}function yPe(t){return yi(t.LABEL)&&t.LABEL!==""}function Sk(t){return Je(t,Pg)}function Pg(t){function e(r){return Je(r,Pg)}if(o(e,"convertDefinition"),t instanceof on){let r={type:"NonTerminal",name:t.nonTerminalName,idx:t.idx};return yi(t.label)&&(r.label=t.label),r}else{if(t instanceof Dn)return{type:"Alternative",definition:e(t.definition)};if(t instanceof ln)return{type:"Option",idx:t.idx,definition:e(t.definition)};if(t instanceof Ln)return{type:"RepetitionMandatory",idx:t.idx,definition:e(t.definition)};if(t instanceof Rn)return{type:"RepetitionMandatoryWithSeparator",idx:t.idx,separator:Pg(new kr({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof wn)return{type:"RepetitionWithSeparator",idx:t.idx,separator:Pg(new kr({terminalType:t.separator})),definition:e(t.definition)};if(t instanceof Or)return{type:"Repetition",idx:t.idx,definition:e(t.definition)};if(t instanceof Tn)return{type:"Alternation",idx:t.idx,definition:e(t.definition)};if(t instanceof kr){let r={type:"Terminal",name:t.terminalType.name,label:gPe(t.terminalType),idx:t.idx};yi(t.label)&&(r.terminalLabel=t.label);let n=t.terminalType.PATTERN;return t.terminalType.PATTERN&&(r.pattern=zo(n)?n.source:n),r}else{if(t instanceof as)return{type:"Rule",name:t.name,orgText:t.orgText,definition:e(t.definition)};throw Error("non exhaustive match")}}}var oo,on,as,Dn,ln,Ln,Rn,Or,wn,Tn,kr,Ck=N(()=>{"use strict";qt();o(gPe,"tokenLabel");o(yPe,"hasTokenLabel");oo=class{static{o(this,"AbstractProduction")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){this._definition=e}accept(e){e.visit(this),Ae(this.definition,r=>{r.accept(e)})}},on=class extends oo{static{o(this,"NonTerminal")}constructor(e){super([]),this.idx=1,ma(this,Os(e,r=>r!==void 0))}set definition(e){}get definition(){return this.referencedRule!==void 0?this.referencedRule.definition:[]}accept(e){e.visit(this)}},as=class extends oo{static{o(this,"Rule")}constructor(e){super(e.definition),this.orgText="",ma(this,Os(e,r=>r!==void 0))}},Dn=class extends oo{static{o(this,"Alternative")}constructor(e){super(e.definition),this.ignoreAmbiguities=!1,ma(this,Os(e,r=>r!==void 0))}},ln=class extends oo{static{o(this,"Option")}constructor(e){super(e.definition),this.idx=1,ma(this,Os(e,r=>r!==void 0))}},Ln=class extends oo{static{o(this,"RepetitionMandatory")}constructor(e){super(e.definition),this.idx=1,ma(this,Os(e,r=>r!==void 0))}},Rn=class extends oo{static{o(this,"RepetitionMandatoryWithSeparator")}constructor(e){super(e.definition),this.idx=1,ma(this,Os(e,r=>r!==void 0))}},Or=class extends oo{static{o(this,"Repetition")}constructor(e){super(e.definition),this.idx=1,ma(this,Os(e,r=>r!==void 0))}},wn=class extends oo{static{o(this,"RepetitionWithSeparator")}constructor(e){super(e.definition),this.idx=1,ma(this,Os(e,r=>r!==void 0))}},Tn=class extends oo{static{o(this,"Alternation")}get definition(){return this._definition}set definition(e){this._definition=e}constructor(e){super(e.definition),this.idx=1,this.ignoreAmbiguities=!1,this.hasPredicates=!1,ma(this,Os(e,r=>r!==void 0))}},kr=class{static{o(this,"Terminal")}constructor(e){this.idx=1,ma(this,Os(e,r=>r!==void 0))}accept(e){e.visit(this)}};o(Sk,"serializeGrammar");o(Pg,"serializeProduction")});var ss,Rae=N(()=>{"use strict";Ck();ss=class{static{o(this,"GAstVisitor")}visit(e){let r=e;switch(r.constructor){case on:return this.visitNonTerminal(r);case Dn:return this.visitAlternative(r);case ln:return this.visitOption(r);case Ln:return this.visitRepetitionMandatory(r);case Rn:return this.visitRepetitionMandatoryWithSeparator(r);case wn:return this.visitRepetitionWithSeparator(r);case Or:return this.visitRepetition(r);case Tn:return this.visitAlternation(r);case kr:return this.visitTerminal(r);case as:return this.visitRule(r);default:throw Error("non exhaustive match")}}visitNonTerminal(e){}visitAlternative(e){}visitOption(e){}visitRepetition(e){}visitRepetitionMandatory(e){}visitRepetitionMandatoryWithSeparator(e){}visitRepetitionWithSeparator(e){}visitAlternation(e){}visitTerminal(e){}visitRule(e){}}});function yN(t){return t instanceof Dn||t instanceof ln||t instanceof Or||t instanceof Ln||t instanceof Rn||t instanceof wn||t instanceof kr||t instanceof as}function sp(t,e=[]){return t instanceof ln||t instanceof Or||t instanceof wn?!0:t instanceof Tn?A2(t.definition,n=>sp(n,e)):t instanceof on&&qn(e,t)?!1:t instanceof oo?(t instanceof on&&e.push(t),Ma(t.definition,n=>sp(n,e))):!1}function vN(t){return t instanceof Tn}function Bs(t){if(t instanceof on)return"SUBRULE";if(t instanceof ln)return"OPTION";if(t instanceof Tn)return"OR";if(t instanceof Ln)return"AT_LEAST_ONE";if(t instanceof Rn)return"AT_LEAST_ONE_SEP";if(t instanceof wn)return"MANY_SEP";if(t instanceof Or)return"MANY";if(t instanceof kr)return"CONSUME";throw Error("non exhaustive match")}var Nae=N(()=>{"use strict";qt();Ck();o(yN,"isSequenceProd");o(sp,"isOptionalProd");o(vN,"isBranchingProd");o(Bs,"getProductionDslName")});var os=N(()=>{"use strict";Ck();Rae();Nae()});function Mae(t,e,r){return[new ln({definition:[new kr({terminalType:t.separator})].concat(t.definition)})].concat(e,r)}var Ou,Ak=N(()=>{"use strict";qt();os();Ou=class{static{o(this,"RestWalker")}walk(e,r=[]){Ae(e.definition,(n,i)=>{let a=gi(e.definition,i+1);if(n instanceof on)this.walkProdRef(n,a,r);else if(n instanceof kr)this.walkTerminal(n,a,r);else if(n instanceof Dn)this.walkFlat(n,a,r);else if(n instanceof ln)this.walkOption(n,a,r);else if(n instanceof Ln)this.walkAtLeastOne(n,a,r);else if(n instanceof Rn)this.walkAtLeastOneSep(n,a,r);else if(n instanceof wn)this.walkManySep(n,a,r);else if(n instanceof Or)this.walkMany(n,a,r);else if(n instanceof Tn)this.walkOr(n,a,r);else throw Error("non exhaustive match")})}walkTerminal(e,r,n){}walkProdRef(e,r,n){}walkFlat(e,r,n){let i=r.concat(n);this.walk(e,i)}walkOption(e,r,n){let i=r.concat(n);this.walk(e,i)}walkAtLeastOne(e,r,n){let i=[new ln({definition:e.definition})].concat(r,n);this.walk(e,i)}walkAtLeastOneSep(e,r,n){let i=Mae(e,r,n);this.walk(e,i)}walkMany(e,r,n){let i=[new ln({definition:e.definition})].concat(r,n);this.walk(e,i)}walkManySep(e,r,n){let i=Mae(e,r,n);this.walk(e,i)}walkOr(e,r,n){let i=r.concat(n);Ae(e.definition,a=>{let s=new Dn({definition:[a]});this.walk(s,i)})}};o(Mae,"restForRepetitionWithSeparator")});function op(t){if(t instanceof on)return op(t.referencedRule);if(t instanceof kr)return bPe(t);if(yN(t))return vPe(t);if(vN(t))return xPe(t);throw Error("non exhaustive match")}function vPe(t){let e=[],r=t.definition,n=0,i=r.length>n,a,s=!0;for(;i&&s;)a=r[n],s=sp(a),e=e.concat(op(a)),n=n+1,i=r.length>n;return Bm(e)}function xPe(t){let e=Je(t.definition,r=>op(r));return Bm(qr(e))}function bPe(t){return[t.terminalType]}var xN=N(()=>{"use strict";qt();os();o(op,"first");o(vPe,"firstForSequence");o(xPe,"firstForBranching");o(bPe,"firstForTerminal")});var _k,bN=N(()=>{"use strict";_k="_~IN~_"});function Iae(t){let e={};return Ae(t,r=>{let n=new wN(r).startWalking();ma(e,n)}),e}function wPe(t,e){return t.name+e+_k}var wN,Oae=N(()=>{"use strict";Ak();xN();qt();bN();os();wN=class extends Ou{static{o(this,"ResyncFollowsWalker")}constructor(e){super(),this.topProd=e,this.follows={}}startWalking(){return this.walk(this.topProd),this.follows}walkTerminal(e,r,n){}walkProdRef(e,r,n){let i=wPe(e.referencedRule,e.idx)+this.topProd.name,a=r.concat(n),s=new Dn({definition:a}),l=op(s);this.follows[i]=l}};o(Iae,"computeAllProdsFollows");o(wPe,"buildBetweenProdsFollowPrefix")});function Bg(t){let e=t.toString();if(Dk.hasOwnProperty(e))return Dk[e];{let r=TPe.pattern(e);return Dk[e]=r,r}}function Pae(){Dk={}}var Dk,TPe,Lk=N(()=>{"use strict";j2();Dk={},TPe=new np;o(Bg,"getRegExpAst");o(Pae,"clearRegExpParserCache")});function $ae(t,e=!1){try{let r=Bg(t);return TN(r.value,{},r.flags.ignoreCase)}catch(r){if(r.message===Fae)e&&ex(`${nx} Unable to optimize: < ${t.toString()} > + Complement Sets cannot be automatically optimized. + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#COMPLEMENT for details.`);else{let n="";e&&(n=` + This will disable the lexer's first char optimizations. + See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#REGEXP_PARSING for details.`),Ig(`${nx} + Failed parsing: < ${t.toString()} > + Using the @chevrotain/regexp-to-ast library + Please open an issue at: https://github.com/chevrotain/chevrotain/issues`+n)}}return[]}function TN(t,e,r){switch(t.type){case"Disjunction":for(let i=0;i{if(typeof u=="number")Rk(u,e,r);else{let h=u;if(r===!0)for(let f=h.from;f<=h.to;f++)Rk(f,e,r);else{for(let f=h.from;f<=h.to&&f=Fg){let f=h.from>=Fg?h.from:Fg,d=h.to,p=Ic(f),m=Ic(d);for(let g=p;g<=m;g++)e[g]=g}}}});break;case"Group":TN(s.value,e,r);break;default:throw Error("Non Exhaustive Match")}let l=s.quantifier!==void 0&&s.quantifier.atLeast===0;if(s.type==="Group"&&kN(s)===!1||s.type!=="Group"&&l===!1)break}break;default:throw Error("non exhaustive match!")}return br(e)}function Rk(t,e,r){let n=Ic(t);e[n]=n,r===!0&&kPe(t,e)}function kPe(t,e){let r=String.fromCharCode(t),n=r.toUpperCase();if(n!==r){let i=Ic(n.charCodeAt(0));e[i]=i}else{let i=r.toLowerCase();if(i!==r){let a=Ic(i.charCodeAt(0));e[a]=a}}}function Bae(t,e){return ns(t.value,r=>{if(typeof r=="number")return qn(e,r);{let n=r;return ns(e,i=>n.from<=i&&i<=n.to)!==void 0}})}function kN(t){let e=t.quantifier;return e&&e.atLeast===0?!0:t.value?Pt(t.value)?Ma(t.value,kN):kN(t.value):!1}function Nk(t,e){if(e instanceof RegExp){let r=Bg(e),n=new EN(t);return n.visit(r),n.found}else return ns(e,r=>qn(t,r.charCodeAt(0)))!==void 0}var Fae,nx,EN,zae=N(()=>{"use strict";j2();qt();Og();Lk();SN();Fae="Complement Sets are not supported for first char optimization",nx=`Unable to use "first char" lexer optimizations: +`;o($ae,"getOptimizedStartCodesIndices");o(TN,"firstCharOptimizedIndices");o(Rk,"addOptimizedIdxToResult");o(kPe,"handleIgnoreCase");o(Bae,"findCode");o(kN,"isWholeOptional");EN=class extends Mc{static{o(this,"CharCodeFinder")}constructor(e){super(),this.targetCharCodes=e,this.found=!1}visitChildren(e){if(this.found!==!0){switch(e.type){case"Lookahead":this.visitLookahead(e);return;case"NegativeLookahead":this.visitNegativeLookahead(e);return}super.visitChildren(e)}}visitCharacter(e){qn(this.targetCharCodes,e.value)&&(this.found=!0)}visitSet(e){e.complement?Bae(e,this.targetCharCodes)===void 0&&(this.found=!0):Bae(e,this.targetCharCodes)!==void 0&&(this.found=!0)}};o(Nk,"canMatchCharCode")});function Uae(t,e){e=Qh(e,{useSticky:AN,debug:!1,safeMode:!1,positionTracking:"full",lineTerminatorCharacters:["\r",` +`],tracer:o((b,w)=>w(),"tracer")});let r=e.tracer;r("initCharCodeToOptimizedIndexMap",()=>{GPe()});let n;r("Reject Lexer.NA",()=>{n=Jh(t,b=>b[lp]===Xn.NA)});let i=!1,a;r("Transform Patterns",()=>{i=!1,a=Je(n,b=>{let w=b[lp];if(zo(w)){let C=w.source;return C.length===1&&C!=="^"&&C!=="$"&&C!=="."&&!w.ignoreCase?C:C.length===2&&C[0]==="\\"&&!qn(["d","D","s","S","t","r","n","t","0","c","b","B","f","v","w","W"],C[1])?C[1]:e.useSticky?Vae(w):Gae(w)}else{if(Si(w))return i=!0,{exec:w};if(typeof w=="object")return i=!0,w;if(typeof w=="string"){if(w.length===1)return w;{let C=w.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&"),T=new RegExp(C);return e.useSticky?Vae(T):Gae(T)}}else throw Error("non exhaustive match")}})});let s,l,u,h,f;r("misc mapping",()=>{s=Je(n,b=>b.tokenTypeIdx),l=Je(n,b=>{let w=b.GROUP;if(w!==Xn.SKIPPED){if(yi(w))return w;if(pr(w))return!1;throw Error("non exhaustive match")}}),u=Je(n,b=>{let w=b.LONGER_ALT;if(w)return Pt(w)?Je(w,T=>UT(n,T)):[UT(n,w)]}),h=Je(n,b=>b.PUSH_MODE),f=Je(n,b=>Bt(b,"POP_MODE"))});let d;r("Line Terminator Handling",()=>{let b=Qae(e.lineTerminatorCharacters);d=Je(n,w=>!1),e.positionTracking!=="onlyOffset"&&(d=Je(n,w=>Bt(w,"LINE_BREAKS")?!!w.LINE_BREAKS:Kae(w,b)===!1&&Nk(b,w.PATTERN)))});let p,m,g,y;r("Misc Mapping #2",()=>{p=Je(n,Xae),m=Je(a,$Pe),g=Xr(n,(b,w)=>{let C=w.GROUP;return yi(C)&&C!==Xn.SKIPPED&&(b[C]=[]),b},{}),y=Je(a,(b,w)=>({pattern:a[w],longerAlt:u[w],canLineTerminator:d[w],isCustom:p[w],short:m[w],group:l[w],push:h[w],pop:f[w],tokenTypeIdx:s[w],tokenType:n[w]}))});let v=!0,x=[];return e.safeMode||r("First Char Optimization",()=>{x=Xr(n,(b,w,C)=>{if(typeof w.PATTERN=="string"){let T=w.PATTERN.charCodeAt(0),E=Ic(T);CN(b,E,y[C])}else if(Pt(w.START_CHARS_HINT)){let T;Ae(w.START_CHARS_HINT,E=>{let A=typeof E=="string"?E.charCodeAt(0):E,S=Ic(A);T!==S&&(T=S,CN(b,S,y[C]))})}else if(zo(w.PATTERN))if(w.PATTERN.unicode)v=!1,e.ensureOptimizations&&Ig(`${nx} Unable to analyze < ${w.PATTERN.toString()} > pattern. + The regexp unicode flag is not currently supported by the regexp-to-ast library. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNICODE_OPTIMIZE`);else{let T=$ae(w.PATTERN,e.ensureOptimizations);ur(T)&&(v=!1),Ae(T,E=>{CN(b,E,y[C])})}else e.ensureOptimizations&&Ig(`${nx} TokenType: <${w.name}> is using a custom token pattern without providing parameter. + This will disable the lexer's first char optimizations. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_OPTIMIZE`),v=!1;return b},[])}),{emptyGroups:g,patternIdxToConfig:y,charCodeToPatternIdxToConfig:x,hasCustom:i,canBeOptimized:v}}function Hae(t,e){let r=[],n=SPe(t);r=r.concat(n.errors);let i=CPe(n.valid),a=i.valid;return r=r.concat(i.errors),r=r.concat(EPe(a)),r=r.concat(IPe(a)),r=r.concat(OPe(a,e)),r=r.concat(PPe(a)),r}function EPe(t){let e=[],r=Yr(t,n=>zo(n[lp]));return e=e.concat(_Pe(r)),e=e.concat(RPe(r)),e=e.concat(NPe(r)),e=e.concat(MPe(r)),e=e.concat(DPe(r)),e}function SPe(t){let e=Yr(t,i=>!Bt(i,lp)),r=Je(e,i=>({message:"Token Type: ->"+i.name+"<- missing static 'PATTERN' property",type:Yn.MISSING_PATTERN,tokenTypes:[i]})),n=Zh(t,e);return{errors:r,valid:n}}function CPe(t){let e=Yr(t,i=>{let a=i[lp];return!zo(a)&&!Si(a)&&!Bt(a,"exec")&&!yi(a)}),r=Je(e,i=>({message:"Token Type: ->"+i.name+"<- static 'PATTERN' can only be a RegExp, a Function matching the {CustomPatternMatcherFunc} type or an Object matching the {ICustomPattern} interface.",type:Yn.INVALID_PATTERN,tokenTypes:[i]})),n=Zh(t,e);return{errors:r,valid:n}}function _Pe(t){class e extends Mc{static{o(this,"EndAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitEndAnchor(a){this.found=!0}}let r=Yr(t,i=>{let a=i.PATTERN;try{let s=Bg(a),l=new e;return l.visit(s),l.found}catch{return APe.test(a.source)}});return Je(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain end of input anchor '$' + See chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Yn.EOI_ANCHOR_FOUND,tokenTypes:[i]}))}function DPe(t){let e=Yr(t,n=>n.PATTERN.test(""));return Je(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' must not match an empty string",type:Yn.EMPTY_MATCH_PATTERN,tokenTypes:[n]}))}function RPe(t){class e extends Mc{static{o(this,"StartAnchorFinder")}constructor(){super(...arguments),this.found=!1}visitStartAnchor(a){this.found=!0}}let r=Yr(t,i=>{let a=i.PATTERN;try{let s=Bg(a),l=new e;return l.visit(s),l.found}catch{return LPe.test(a.source)}});return Je(r,i=>({message:`Unexpected RegExp Anchor Error: + Token Type: ->`+i.name+`<- static 'PATTERN' cannot contain start of input anchor '^' + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#ANCHORS for details.`,type:Yn.SOI_ANCHOR_FOUND,tokenTypes:[i]}))}function NPe(t){let e=Yr(t,n=>{let i=n[lp];return i instanceof RegExp&&(i.multiline||i.global)});return Je(e,n=>({message:"Token Type: ->"+n.name+"<- static 'PATTERN' may NOT contain global('g') or multiline('m')",type:Yn.UNSUPPORTED_FLAGS_FOUND,tokenTypes:[n]}))}function MPe(t){let e=[],r=Je(t,a=>Xr(t,(s,l)=>(a.PATTERN.source===l.PATTERN.source&&!qn(e,l)&&l.PATTERN!==Xn.NA&&(e.push(l),s.push(l)),s),[]));r=Tc(r);let n=Yr(r,a=>a.length>1);return Je(n,a=>{let s=Je(a,u=>u.name);return{message:`The same RegExp pattern ->${ia(a).PATTERN}<-has been used in all of the following Token Types: ${s.join(", ")} <-`,type:Yn.DUPLICATE_PATTERNS_FOUND,tokenTypes:a}})}function IPe(t){let e=Yr(t,n=>{if(!Bt(n,"GROUP"))return!1;let i=n.GROUP;return i!==Xn.SKIPPED&&i!==Xn.NA&&!yi(i)});return Je(e,n=>({message:"Token Type: ->"+n.name+"<- static 'GROUP' can only be Lexer.SKIPPED/Lexer.NA/A String",type:Yn.INVALID_GROUP_TYPE_FOUND,tokenTypes:[n]}))}function OPe(t,e){let r=Yr(t,i=>i.PUSH_MODE!==void 0&&!qn(e,i.PUSH_MODE));return Je(r,i=>({message:`Token Type: ->${i.name}<- static 'PUSH_MODE' value cannot refer to a Lexer Mode ->${i.PUSH_MODE}<-which does not exist`,type:Yn.PUSH_MODE_DOES_NOT_EXIST,tokenTypes:[i]}))}function PPe(t){let e=[],r=Xr(t,(n,i,a)=>{let s=i.PATTERN;return s===Xn.NA||(yi(s)?n.push({str:s,idx:a,tokenType:i}):zo(s)&&FPe(s)&&n.push({str:s.source,idx:a,tokenType:i})),n},[]);return Ae(t,(n,i)=>{Ae(r,({str:a,idx:s,tokenType:l})=>{if(i${l.name}<- can never be matched. +Because it appears AFTER the Token Type ->${n.name}<-in the lexer's definition. +See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#UNREACHABLE`;e.push({message:u,type:Yn.UNREACHABLE_PATTERN,tokenTypes:[n,l]})}})}),e}function BPe(t,e){if(zo(e)){let r=e.exec(t);return r!==null&&r.index===0}else{if(Si(e))return e(t,0,[],{});if(Bt(e,"exec"))return e.exec(t,0,[],{});if(typeof e=="string")return e===t;throw Error("non exhaustive match")}}function FPe(t){return ns([".","\\","[","]","|","^","$","(",")","?","*","+","{"],r=>t.source.indexOf(r)!==-1)===void 0}function Gae(t){let e=t.ignoreCase?"i":"";return new RegExp(`^(?:${t.source})`,e)}function Vae(t){let e=t.ignoreCase?"iy":"y";return new RegExp(`${t.source}`,e)}function Wae(t,e,r){let n=[];return Bt(t,$g)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+$g+`> property in its definition +`,type:Yn.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE}),Bt(t,Mk)||n.push({message:"A MultiMode Lexer cannot be initialized without a <"+Mk+`> property in its definition +`,type:Yn.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY}),Bt(t,Mk)&&Bt(t,$g)&&!Bt(t.modes,t.defaultMode)&&n.push({message:`A MultiMode Lexer cannot be initialized with a ${$g}: <${t.defaultMode}>which does not exist +`,type:Yn.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST}),Bt(t,Mk)&&Ae(t.modes,(i,a)=>{Ae(i,(s,l)=>{if(pr(s))n.push({message:`A Lexer cannot be initialized using an undefined Token Type. Mode:<${a}> at index: <${l}> +`,type:Yn.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED});else if(Bt(s,"LONGER_ALT")){let u=Pt(s.LONGER_ALT)?s.LONGER_ALT:[s.LONGER_ALT];Ae(u,h=>{!pr(h)&&!qn(i,h)&&n.push({message:`A MultiMode Lexer cannot be initialized with a longer_alt <${h.name}> on token <${s.name}> outside of mode <${a}> +`,type:Yn.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE})})}})}),n}function qae(t,e,r){let n=[],i=!1,a=Tc(qr(br(t.modes))),s=Jh(a,u=>u[lp]===Xn.NA),l=Qae(r);return e&&Ae(s,u=>{let h=Kae(u,l);if(h!==!1){let d={message:zPe(u,h),type:h.issue,tokenType:u};n.push(d)}else Bt(u,"LINE_BREAKS")?u.LINE_BREAKS===!0&&(i=!0):Nk(l,u.PATTERN)&&(i=!0)}),e&&!i&&n.push({message:`Warning: No LINE_BREAKS Found. + This Lexer has been defined to track line and column information, + But none of the Token Types can be identified as matching a line terminator. + See https://chevrotain.io/docs/guide/resolving_lexer_errors.html#LINE_BREAKS + for details.`,type:Yn.NO_LINE_BREAKS_FLAGS}),n}function Yae(t){let e={},r=zr(t);return Ae(r,n=>{let i=t[n];if(Pt(i))e[n]=[];else throw Error("non exhaustive match")}),e}function Xae(t){let e=t.PATTERN;if(zo(e))return!1;if(Si(e))return!0;if(Bt(e,"exec"))return!0;if(yi(e))return!1;throw Error("non exhaustive match")}function $Pe(t){return yi(t)&&t.length===1?t.charCodeAt(0):!1}function Kae(t,e){if(Bt(t,"LINE_BREAKS"))return!1;if(zo(t.PATTERN)){try{Nk(e,t.PATTERN)}catch(r){return{issue:Yn.IDENTIFY_TERMINATOR,errMsg:r.message}}return!1}else{if(yi(t.PATTERN))return!1;if(Xae(t))return{issue:Yn.CUSTOM_LINE_BREAK};throw Error("non exhaustive match")}}function zPe(t,e){if(e.issue===Yn.IDENTIFY_TERMINATOR)return`Warning: unable to identify line terminator usage in pattern. + The problem is in the <${t.name}> Token Type + Root cause: ${e.errMsg}. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#IDENTIFY_TERMINATOR`;if(e.issue===Yn.CUSTOM_LINE_BREAK)return`Warning: A Custom Token Pattern should specify the option. + The problem is in the <${t.name}> Token Type + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#CUSTOM_LINE_BREAK`;throw Error("non exhaustive match")}function Qae(t){return Je(t,r=>yi(r)?r.charCodeAt(0):r)}function CN(t,e,r){t[e]===void 0?t[e]=[r]:t[e].push(r)}function Ic(t){return t255?255+~~(t/255):t}}var lp,$g,Mk,AN,APe,LPe,jae,Fg,Ik,SN=N(()=>{"use strict";j2();ix();qt();Og();zae();Lk();lp="PATTERN",$g="defaultMode",Mk="modes",AN=typeof new RegExp("(?:)").sticky=="boolean";o(Uae,"analyzeTokenTypes");o(Hae,"validatePatterns");o(EPe,"validateRegExpPattern");o(SPe,"findMissingPatterns");o(CPe,"findInvalidPatterns");APe=/[^\\][$]/;o(_Pe,"findEndOfInputAnchor");o(DPe,"findEmptyMatchRegExps");LPe=/[^\\[][\^]|^\^/;o(RPe,"findStartOfInputAnchor");o(NPe,"findUnsupportedFlags");o(MPe,"findDuplicatePatterns");o(IPe,"findInvalidGroupType");o(OPe,"findModesThatDoNotExist");o(PPe,"findUnreachablePatterns");o(BPe,"testTokenType");o(FPe,"noMetaChar");o(Gae,"addStartOfInput");o(Vae,"addStickyFlag");o(Wae,"performRuntimeChecks");o(qae,"performWarningRuntimeChecks");o(Yae,"cloneEmptyGroups");o(Xae,"isCustomPattern");o($Pe,"isShortPattern");jae={test:o(function(t){let e=t.length;for(let r=this.lastIndex;r{r.isParent=r.categoryMatches.length>0})}function VPe(t){let e=an(t),r=t,n=!0;for(;n;){r=Tc(qr(Je(r,a=>a.CATEGORIES)));let i=Zh(r,e);e=e.concat(i),ur(i)?n=!1:r=i}return e}function UPe(t){Ae(t,e=>{_N(e)||(ese[Zae]=e,e.tokenTypeIdx=Zae++),Jae(e)&&!Pt(e.CATEGORIES)&&(e.CATEGORIES=[e.CATEGORIES]),Jae(e)||(e.CATEGORIES=[]),qPe(e)||(e.categoryMatches=[]),YPe(e)||(e.categoryMatchesMap={})})}function HPe(t){Ae(t,e=>{e.categoryMatches=[],Ae(e.categoryMatchesMap,(r,n)=>{e.categoryMatches.push(ese[n].tokenTypeIdx)})})}function WPe(t){Ae(t,e=>{tse([],e)})}function tse(t,e){Ae(t,r=>{e.categoryMatchesMap[r.tokenTypeIdx]=!0}),Ae(e.CATEGORIES,r=>{let n=t.concat(e);qn(n,r)||tse(n,r)})}function _N(t){return Bt(t,"tokenTypeIdx")}function Jae(t){return Bt(t,"CATEGORIES")}function qPe(t){return Bt(t,"categoryMatches")}function YPe(t){return Bt(t,"categoryMatchesMap")}function rse(t){return Bt(t,"tokenTypeIdx")}var Zae,ese,cp=N(()=>{"use strict";qt();o(Pu,"tokenStructuredMatcher");o(zg,"tokenStructuredMatcherNoCategories");Zae=1,ese={};o(Bu,"augmentTokenTypes");o(VPe,"expandCategories");o(UPe,"assignTokenDefaultProps");o(HPe,"assignCategoriesTokensProp");o(WPe,"assignCategoriesMapProp");o(tse,"singleAssignCategoriesToksMap");o(_N,"hasShortKeyProperty");o(Jae,"hasCategoriesProperty");o(qPe,"hasExtendingTokensTypesProperty");o(YPe,"hasExtendingTokensTypesMapProperty");o(rse,"isTokenType")});var Gg,DN=N(()=>{"use strict";Gg={buildUnableToPopLexerModeMessage(t){return`Unable to pop Lexer Mode after encountering Token ->${t.image}<- The Mode Stack is empty`},buildUnexpectedCharactersMessage(t,e,r,n,i){return`unexpected character: ->${t.charAt(e)}<- at offset: ${e}, skipped ${r} characters.`}}});var Yn,ax,Xn,ix=N(()=>{"use strict";SN();qt();Og();cp();DN();Lk();(function(t){t[t.MISSING_PATTERN=0]="MISSING_PATTERN",t[t.INVALID_PATTERN=1]="INVALID_PATTERN",t[t.EOI_ANCHOR_FOUND=2]="EOI_ANCHOR_FOUND",t[t.UNSUPPORTED_FLAGS_FOUND=3]="UNSUPPORTED_FLAGS_FOUND",t[t.DUPLICATE_PATTERNS_FOUND=4]="DUPLICATE_PATTERNS_FOUND",t[t.INVALID_GROUP_TYPE_FOUND=5]="INVALID_GROUP_TYPE_FOUND",t[t.PUSH_MODE_DOES_NOT_EXIST=6]="PUSH_MODE_DOES_NOT_EXIST",t[t.MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE=7]="MULTI_MODE_LEXER_WITHOUT_DEFAULT_MODE",t[t.MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY=8]="MULTI_MODE_LEXER_WITHOUT_MODES_PROPERTY",t[t.MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST=9]="MULTI_MODE_LEXER_DEFAULT_MODE_VALUE_DOES_NOT_EXIST",t[t.LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED=10]="LEXER_DEFINITION_CANNOT_CONTAIN_UNDEFINED",t[t.SOI_ANCHOR_FOUND=11]="SOI_ANCHOR_FOUND",t[t.EMPTY_MATCH_PATTERN=12]="EMPTY_MATCH_PATTERN",t[t.NO_LINE_BREAKS_FLAGS=13]="NO_LINE_BREAKS_FLAGS",t[t.UNREACHABLE_PATTERN=14]="UNREACHABLE_PATTERN",t[t.IDENTIFY_TERMINATOR=15]="IDENTIFY_TERMINATOR",t[t.CUSTOM_LINE_BREAK=16]="CUSTOM_LINE_BREAK",t[t.MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE=17]="MULTI_MODE_LEXER_LONGER_ALT_NOT_IN_CURRENT_MODE"})(Yn||(Yn={}));ax={deferDefinitionErrorsHandling:!1,positionTracking:"full",lineTerminatorsPattern:/\n|\r\n?/g,lineTerminatorCharacters:[` +`,"\r"],ensureOptimizations:!1,safeMode:!1,errorMessageProvider:Gg,traceInitPerf:!1,skipValidations:!1,recoveryEnabled:!0};Object.freeze(ax);Xn=class{static{o(this,"Lexer")}constructor(e,r=ax){if(this.lexerDefinition=e,this.lexerDefinitionErrors=[],this.lexerDefinitionWarning=[],this.patternIdxToConfig={},this.charCodeToPatternIdxToConfig={},this.modes=[],this.emptyGroups={},this.trackStartLines=!0,this.trackEndLines=!0,this.hasCustom=!1,this.canModeBeOptimized={},this.TRACE_INIT=(i,a)=>{if(this.traceInitPerf===!0){this.traceInitIndent++;let s=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${i}>`);let{time:l,value:u}=tx(a),h=l>10?console.warn:console.log;return this.traceInitIndent time: ${l}ms`),this.traceInitIndent--,u}else return a()},typeof r=="boolean")throw Error(`The second argument to the Lexer constructor is now an ILexerConfig Object. +a boolean 2nd argument is no longer supported`);this.config=ma({},ax,r);let n=this.config.traceInitPerf;n===!0?(this.traceInitMaxIdent=1/0,this.traceInitPerf=!0):typeof n=="number"&&(this.traceInitMaxIdent=n,this.traceInitPerf=!0),this.traceInitIndent=-1,this.TRACE_INIT("Lexer Constructor",()=>{let i,a=!0;this.TRACE_INIT("Lexer Config handling",()=>{if(this.config.lineTerminatorsPattern===ax.lineTerminatorsPattern)this.config.lineTerminatorsPattern=jae;else if(this.config.lineTerminatorCharacters===ax.lineTerminatorCharacters)throw Error(`Error: Missing property on the Lexer config. + For details See: https://chevrotain.io/docs/guide/resolving_lexer_errors.html#MISSING_LINE_TERM_CHARS`);if(r.safeMode&&r.ensureOptimizations)throw Error('"safeMode" and "ensureOptimizations" flags are mutually exclusive.');this.trackStartLines=/full|onlyStart/i.test(this.config.positionTracking),this.trackEndLines=/full/i.test(this.config.positionTracking),Pt(e)?i={modes:{defaultMode:an(e)},defaultMode:$g}:(a=!1,i=an(e))}),this.config.skipValidations===!1&&(this.TRACE_INIT("performRuntimeChecks",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(Wae(i,this.trackStartLines,this.config.lineTerminatorCharacters))}),this.TRACE_INIT("performWarningRuntimeChecks",()=>{this.lexerDefinitionWarning=this.lexerDefinitionWarning.concat(qae(i,this.trackStartLines,this.config.lineTerminatorCharacters))})),i.modes=i.modes?i.modes:{},Ae(i.modes,(l,u)=>{i.modes[u]=Jh(l,h=>pr(h))});let s=zr(i.modes);if(Ae(i.modes,(l,u)=>{this.TRACE_INIT(`Mode: <${u}> processing`,()=>{if(this.modes.push(u),this.config.skipValidations===!1&&this.TRACE_INIT("validatePatterns",()=>{this.lexerDefinitionErrors=this.lexerDefinitionErrors.concat(Hae(l,s))}),ur(this.lexerDefinitionErrors)){Bu(l);let h;this.TRACE_INIT("analyzeTokenTypes",()=>{h=Uae(l,{lineTerminatorCharacters:this.config.lineTerminatorCharacters,positionTracking:r.positionTracking,ensureOptimizations:r.ensureOptimizations,safeMode:r.safeMode,tracer:this.TRACE_INIT})}),this.patternIdxToConfig[u]=h.patternIdxToConfig,this.charCodeToPatternIdxToConfig[u]=h.charCodeToPatternIdxToConfig,this.emptyGroups=ma({},this.emptyGroups,h.emptyGroups),this.hasCustom=h.hasCustom||this.hasCustom,this.canModeBeOptimized[u]=h.canBeOptimized}})}),this.defaultMode=i.defaultMode,!ur(this.lexerDefinitionErrors)&&!this.config.deferDefinitionErrorsHandling){let u=Je(this.lexerDefinitionErrors,h=>h.message).join(`----------------------- +`);throw new Error(`Errors detected in definition of Lexer: +`+u)}Ae(this.lexerDefinitionWarning,l=>{ex(l.message)}),this.TRACE_INIT("Choosing sub-methods implementations",()=>{if(AN?(this.chopInput=ta,this.match=this.matchWithTest):(this.updateLastIndex=ni,this.match=this.matchWithExec),a&&(this.handleModes=ni),this.trackStartLines===!1&&(this.computeNewColumn=ta),this.trackEndLines===!1&&(this.updateTokenEndLineColumnLocation=ni),/full/i.test(this.config.positionTracking))this.createTokenInstance=this.createFullToken;else if(/onlyStart/i.test(this.config.positionTracking))this.createTokenInstance=this.createStartOnlyToken;else if(/onlyOffset/i.test(this.config.positionTracking))this.createTokenInstance=this.createOffsetOnlyToken;else throw Error(`Invalid config option: "${this.config.positionTracking}"`);this.hasCustom?(this.addToken=this.addTokenUsingPush,this.handlePayload=this.handlePayloadWithCustom):(this.addToken=this.addTokenUsingMemberAccess,this.handlePayload=this.handlePayloadNoCustom)}),this.TRACE_INIT("Failed Optimization Warnings",()=>{let l=Xr(this.canModeBeOptimized,(u,h,f)=>(h===!1&&u.push(f),u),[]);if(r.ensureOptimizations&&!ur(l))throw Error(`Lexer Modes: < ${l.join(", ")} > cannot be optimized. + Disable the "ensureOptimizations" lexer config flag to silently ignore this and run the lexer in an un-optimized mode. + Or inspect the console log for details on how to resolve these issues.`)}),this.TRACE_INIT("clearRegExpParserCache",()=>{Pae()}),this.TRACE_INIT("toFastProperties",()=>{rx(this)})})}tokenize(e,r=this.defaultMode){if(!ur(this.lexerDefinitionErrors)){let i=Je(this.lexerDefinitionErrors,a=>a.message).join(`----------------------- +`);throw new Error(`Unable to Tokenize because Errors detected in definition of Lexer: +`+i)}return this.tokenizeInternal(e,r)}tokenizeInternal(e,r){let n,i,a,s,l,u,h,f,d,p,m,g,y,v,x,b,w=e,C=w.length,T=0,E=0,A=this.hasCustom?0:Math.floor(e.length/10),S=new Array(A),_=[],I=this.trackStartLines?1:void 0,D=this.trackStartLines?1:void 0,k=Yae(this.emptyGroups),L=this.trackStartLines,R=this.config.lineTerminatorsPattern,O=0,M=[],B=[],F=[],P=[];Object.freeze(P);let z;function $(){return M}o($,"getPossiblePatternsSlow");function H(le){let he=Ic(le),K=B[he];return K===void 0?P:K}o(H,"getPossiblePatternsOptimized");let Q=o(le=>{if(F.length===1&&le.tokenType.PUSH_MODE===void 0){let he=this.config.errorMessageProvider.buildUnableToPopLexerModeMessage(le);_.push({offset:le.startOffset,line:le.startLine,column:le.startColumn,length:le.image.length,message:he})}else{F.pop();let he=ga(F);M=this.patternIdxToConfig[he],B=this.charCodeToPatternIdxToConfig[he],O=M.length;let K=this.canModeBeOptimized[he]&&this.config.safeMode===!1;B&&K?z=H:z=$}},"pop_mode");function j(le){F.push(le),B=this.charCodeToPatternIdxToConfig[le],M=this.patternIdxToConfig[le],O=M.length,O=M.length;let he=this.canModeBeOptimized[le]&&this.config.safeMode===!1;B&&he?z=H:z=$}o(j,"push_mode"),j.call(this,r);let ie,ne=this.config.recoveryEnabled;for(;Tu.length){u=s,h=f,ie=se;break}}}break}}if(u!==null){if(d=u.length,p=ie.group,p!==void 0&&(m=ie.tokenTypeIdx,g=this.createTokenInstance(u,T,m,ie.tokenType,I,D,d),this.handlePayload(g,h),p===!1?E=this.addToken(S,E,g):k[p].push(g)),e=this.chopInput(e,d),T=T+d,D=this.computeNewColumn(D,d),L===!0&&ie.canLineTerminator===!0){let X=0,te,J;R.lastIndex=0;do te=R.test(u),te===!0&&(J=R.lastIndex-1,X++);while(te===!0);X!==0&&(I=I+X,D=d-J,this.updateTokenEndLineColumnLocation(g,p,J,X,I,D,d))}this.handleModes(ie,Q,j,g)}else{let X=T,te=I,J=D,se=ne===!1;for(;se===!1&&T{"use strict";qt();ix();cp();o(Fu,"tokenLabel");o(LN,"hasTokenLabel");XPe="parent",nse="categories",ise="label",ase="group",sse="push_mode",ose="pop_mode",lse="longer_alt",cse="line_breaks",use="start_chars_hint";o(of,"createToken");o(jPe,"createTokenInternal");lo=of({name:"EOF",pattern:Xn.NA});Bu([lo]);o($u,"createTokenInstance");o(sx,"tokenMatcher")});var zu,hse,Pl,Vg=N(()=>{"use strict";up();qt();os();zu={buildMismatchTokenMessage({expected:t,actual:e,previous:r,ruleName:n}){return`Expecting ${LN(t)?`--> ${Fu(t)} <--`:`token of type --> ${t.name} <--`} but found --> '${e.image}' <--`},buildNotAllInputParsedMessage({firstRedundant:t,ruleName:e}){return"Redundant input, expecting EOF but found: "+t.image},buildNoViableAltMessage({expectedPathsPerAlt:t,actual:e,previous:r,customUserDescription:n,ruleName:i}){let a="Expecting: ",l=` +but found: '`+ia(e).image+"'";if(n)return a+n+l;{let u=Xr(t,(p,m)=>p.concat(m),[]),h=Je(u,p=>`[${Je(p,m=>Fu(m)).join(", ")}]`),d=`one of these possible Token sequences: +${Je(h,(p,m)=>` ${m+1}. ${p}`).join(` +`)}`;return a+d+l}},buildEarlyExitMessage({expectedIterationPaths:t,actual:e,customUserDescription:r,ruleName:n}){let i="Expecting: ",s=` +but found: '`+ia(e).image+"'";if(r)return i+r+s;{let u=`expecting at least one iteration which starts with one of these possible Token sequences:: + <${Je(t,h=>`[${Je(h,f=>Fu(f)).join(",")}]`).join(" ,")}>`;return i+u+s}}};Object.freeze(zu);hse={buildRuleNotFoundError(t,e){return"Invalid grammar, reference to a rule which is not defined: ->"+e.nonTerminalName+`<- +inside top level rule: ->`+t.name+"<-"}},Pl={buildDuplicateFoundError(t,e){function r(f){return f instanceof kr?f.terminalType.name:f instanceof on?f.nonTerminalName:""}o(r,"getExtraProductionArgument");let n=t.name,i=ia(e),a=i.idx,s=Bs(i),l=r(i),u=a>0,h=`->${s}${u?a:""}<- ${l?`with argument: ->${l}<-`:""} + appears more than once (${e.length} times) in the top level rule: ->${n}<-. + For further details see: https://chevrotain.io/docs/FAQ.html#NUMERICAL_SUFFIXES + `;return h=h.replace(/[ \t]+/g," "),h=h.replace(/\s\s+/g,` +`),h},buildNamespaceConflictError(t){return`Namespace conflict found in grammar. +The grammar has both a Terminal(Token) and a Non-Terminal(Rule) named: <${t.name}>. +To resolve this make sure each Terminal and Non-Terminal names are unique +This is easy to accomplish by using the convention that Terminal names start with an uppercase letter +and Non-Terminal names start with a lower case letter.`},buildAlternationPrefixAmbiguityError(t){let e=Je(t.prefixPath,i=>Fu(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx;return`Ambiguous alternatives: <${t.ambiguityIndices.join(" ,")}> due to common lookahead prefix +in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#COMMON_PREFIX +For Further details.`},buildAlternationAmbiguityError(t){let e=Je(t.prefixPath,i=>Fu(i)).join(", "),r=t.alternation.idx===0?"":t.alternation.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(" ,")}> in inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n},buildEmptyRepetitionError(t){let e=Bs(t.repetition);return t.repetition.idx!==0&&(e+=t.repetition.idx),`The repetition <${e}> within Rule <${t.topLevelRule.name}> can never consume any tokens. +This could lead to an infinite loop.`},buildTokenNameError(t){return"deprecated"},buildEmptyAlternationError(t){return`Ambiguous empty alternative: <${t.emptyChoiceIdx+1}> in inside <${t.topLevelRule.name}> Rule. +Only the last alternative may be an empty alternative.`},buildTooManyAlternativesError(t){return`An Alternation cannot have more than 256 alternatives: + inside <${t.topLevelRule.name}> Rule. + has ${t.alternation.definition.length+1} alternatives.`},buildLeftRecursionError(t){let e=t.topLevelRule.name,r=Je(t.leftRecursionPath,a=>a.name),n=`${e} --> ${r.concat([e]).join(" --> ")}`;return`Left Recursion found in grammar. +rule: <${e}> can be invoked from itself (directly or indirectly) +without consuming any Tokens. The grammar path that causes this is: + ${n} + To fix this refactor your grammar to remove the left recursion. +see: https://en.wikipedia.org/wiki/LL_parser#Left_factoring.`},buildInvalidRuleNameError(t){return"deprecated"},buildDuplicateRuleNameError(t){let e;return t.topLevelRule instanceof as?e=t.topLevelRule.name:e=t.topLevelRule,`Duplicate definition, rule: ->${e}<- is already defined in the grammar: ->${t.grammarName}<-`}}});function fse(t,e){let r=new RN(t,e);return r.resolveRefs(),r.errors}var RN,dse=N(()=>{"use strict";Fs();qt();os();o(fse,"resolveGrammar");RN=class extends ss{static{o(this,"GastRefResolverVisitor")}constructor(e,r){super(),this.nameToTopRule=e,this.errMsgProvider=r,this.errors=[]}resolveRefs(){Ae(br(this.nameToTopRule),e=>{this.currTopLevel=e,e.accept(this)})}visitNonTerminal(e){let r=this.nameToTopRule[e.nonTerminalName];if(r)e.referencedRule=r;else{let n=this.errMsgProvider.buildRuleNotFoundError(this.currTopLevel,e);this.errors.push({message:n,type:zi.UNRESOLVED_SUBRULE_REF,ruleName:this.currTopLevel.name,unresolvedRefName:e.nonTerminalName})}}}});function Fk(t,e,r=[]){r=an(r);let n=[],i=0;function a(l){return l.concat(gi(t,i+1))}o(a,"remainingPathWith");function s(l){let u=Fk(a(l),e,r);return n.concat(u)}for(o(s,"getAlternativesForProd");r.length{ur(u.definition)===!1&&(n=s(u.definition))}),n;if(l instanceof kr)r.push(l.terminalType);else throw Error("non exhaustive match")}i++}return n.push({partialPath:r,suffixDef:gi(t,i)}),n}function $k(t,e,r,n){let i="EXIT_NONE_TERMINAL",a=[i],s="EXIT_ALTERNATIVE",l=!1,u=e.length,h=u-n-1,f=[],d=[];for(d.push({idx:-1,def:t,ruleStack:[],occurrenceStack:[]});!ur(d);){let p=d.pop();if(p===s){l&&ga(d).idx<=h&&d.pop();continue}let m=p.def,g=p.idx,y=p.ruleStack,v=p.occurrenceStack;if(ur(m))continue;let x=m[0];if(x===i){let b={idx:g,def:gi(m),ruleStack:Nu(y),occurrenceStack:Nu(v)};d.push(b)}else if(x instanceof kr)if(g=0;b--){let w=x.definition[b],C={idx:g,def:w.definition.concat(gi(m)),ruleStack:y,occurrenceStack:v};d.push(C),d.push(s)}else if(x instanceof Dn)d.push({idx:g,def:x.definition.concat(gi(m)),ruleStack:y,occurrenceStack:v});else if(x instanceof as)d.push(KPe(x,g,y,v));else throw Error("non exhaustive match")}return f}function KPe(t,e,r,n){let i=an(r);i.push(t.name);let a=an(n);return a.push(1),{idx:e,def:t.definition,ruleStack:i,occurrenceStack:a}}var NN,Ok,Ug,Pk,ox,Bk,lx,cx=N(()=>{"use strict";qt();xN();Ak();os();NN=class extends Ou{static{o(this,"AbstractNextPossibleTokensWalker")}constructor(e,r){super(),this.topProd=e,this.path=r,this.possibleTokTypes=[],this.nextProductionName="",this.nextProductionOccurrence=0,this.found=!1,this.isAtEndOfPath=!1}startWalking(){if(this.found=!1,this.path.ruleStack[0]!==this.topProd.name)throw Error("The path does not start with the walker's top Rule!");return this.ruleStack=an(this.path.ruleStack).reverse(),this.occurrenceStack=an(this.path.occurrenceStack).reverse(),this.ruleStack.pop(),this.occurrenceStack.pop(),this.updateExpectedNext(),this.walk(this.topProd),this.possibleTokTypes}walk(e,r=[]){this.found||super.walk(e,r)}walkProdRef(e,r,n){if(e.referencedRule.name===this.nextProductionName&&e.idx===this.nextProductionOccurrence){let i=r.concat(n);this.updateExpectedNext(),this.walk(e.referencedRule,i)}}updateExpectedNext(){ur(this.ruleStack)?(this.nextProductionName="",this.nextProductionOccurrence=0,this.isAtEndOfPath=!0):(this.nextProductionName=this.ruleStack.pop(),this.nextProductionOccurrence=this.occurrenceStack.pop())}},Ok=class extends NN{static{o(this,"NextAfterTokenWalker")}constructor(e,r){super(e,r),this.path=r,this.nextTerminalName="",this.nextTerminalOccurrence=0,this.nextTerminalName=this.path.lastTok.name,this.nextTerminalOccurrence=this.path.lastTokOccurrence}walkTerminal(e,r,n){if(this.isAtEndOfPath&&e.terminalType.name===this.nextTerminalName&&e.idx===this.nextTerminalOccurrence&&!this.found){let i=r.concat(n),a=new Dn({definition:i});this.possibleTokTypes=op(a),this.found=!0}}},Ug=class extends Ou{static{o(this,"AbstractNextTerminalAfterProductionWalker")}constructor(e,r){super(),this.topRule=e,this.occurrence=r,this.result={token:void 0,occurrence:void 0,isEndOfRule:void 0}}startWalking(){return this.walk(this.topRule),this.result}},Pk=class extends Ug{static{o(this,"NextTerminalAfterManyWalker")}walkMany(e,r,n){if(e.idx===this.occurrence){let i=ia(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof kr&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkMany(e,r,n)}},ox=class extends Ug{static{o(this,"NextTerminalAfterManySepWalker")}walkManySep(e,r,n){if(e.idx===this.occurrence){let i=ia(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof kr&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkManySep(e,r,n)}},Bk=class extends Ug{static{o(this,"NextTerminalAfterAtLeastOneWalker")}walkAtLeastOne(e,r,n){if(e.idx===this.occurrence){let i=ia(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof kr&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOne(e,r,n)}},lx=class extends Ug{static{o(this,"NextTerminalAfterAtLeastOneSepWalker")}walkAtLeastOneSep(e,r,n){if(e.idx===this.occurrence){let i=ia(r.concat(n));this.result.isEndOfRule=i===void 0,i instanceof kr&&(this.result.token=i.terminalType,this.result.occurrence=i.idx)}else super.walkAtLeastOneSep(e,r,n)}};o(Fk,"possiblePathsFrom");o($k,"nextPossibleTokensAfter");o(KPe,"expandTopLevelRule")});function ux(t){if(t instanceof ln||t==="Option")return jn.OPTION;if(t instanceof Or||t==="Repetition")return jn.REPETITION;if(t instanceof Ln||t==="RepetitionMandatory")return jn.REPETITION_MANDATORY;if(t instanceof Rn||t==="RepetitionMandatoryWithSeparator")return jn.REPETITION_MANDATORY_WITH_SEPARATOR;if(t instanceof wn||t==="RepetitionWithSeparator")return jn.REPETITION_WITH_SEPARATOR;if(t instanceof Tn||t==="Alternation")return jn.ALTERNATION;throw Error("non exhaustive match")}function Gk(t){let{occurrence:e,rule:r,prodType:n,maxLookahead:i}=t,a=ux(n);return a===jn.ALTERNATION?Hg(e,r,i):Wg(e,r,a,i)}function mse(t,e,r,n,i,a){let s=Hg(t,e,r),l=wse(s)?zg:Pu;return a(s,n,l,i)}function gse(t,e,r,n,i,a){let s=Wg(t,e,i,r),l=wse(s)?zg:Pu;return a(s[0],l,n)}function yse(t,e,r,n){let i=t.length,a=Ma(t,s=>Ma(s,l=>l.length===1));if(e)return function(s){let l=Je(s,u=>u.GATE);for(let u=0;uqr(u)),l=Xr(s,(u,h,f)=>(Ae(h,d=>{Bt(u,d.tokenTypeIdx)||(u[d.tokenTypeIdx]=f),Ae(d.categoryMatches,p=>{Bt(u,p)||(u[p]=f)})}),u),{});return function(){let u=this.LA(1);return l[u.tokenTypeIdx]}}else return function(){for(let s=0;sa.length===1),i=t.length;if(n&&!r){let a=qr(t);if(a.length===1&&ur(a[0].categoryMatches)){let l=a[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===l}}else{let s=Xr(a,(l,u,h)=>(l[u.tokenTypeIdx]=!0,Ae(u.categoryMatches,f=>{l[f]=!0}),l),[]);return function(){let l=this.LA(1);return s[l.tokenTypeIdx]===!0}}}else return function(){e:for(let a=0;aFk([s],1)),n=pse(r.length),i=Je(r,s=>{let l={};return Ae(s,u=>{let h=MN(u.partialPath);Ae(h,f=>{l[f]=!0})}),l}),a=r;for(let s=1;s<=e;s++){let l=a;a=pse(l.length);for(let u=0;u{let x=MN(v.partialPath);Ae(x,b=>{i[u][b]=!0})})}}}}return n}function Hg(t,e,r,n){let i=new zk(t,jn.ALTERNATION,n);return e.accept(i),xse(i.result,r)}function Wg(t,e,r,n){let i=new zk(t,r);e.accept(i);let a=i.result,l=new IN(e,t,r).startWalking(),u=new Dn({definition:a}),h=new Dn({definition:l});return xse([u,h],n)}function Vk(t,e){e:for(let r=0;r{let i=e[n];return r===i||i.categoryMatchesMap[r.tokenTypeIdx]})}function wse(t){return Ma(t,e=>Ma(e,r=>Ma(r,n=>ur(n.categoryMatches))))}var jn,IN,zk,qg=N(()=>{"use strict";qt();cx();Ak();cp();os();(function(t){t[t.OPTION=0]="OPTION",t[t.REPETITION=1]="REPETITION",t[t.REPETITION_MANDATORY=2]="REPETITION_MANDATORY",t[t.REPETITION_MANDATORY_WITH_SEPARATOR=3]="REPETITION_MANDATORY_WITH_SEPARATOR",t[t.REPETITION_WITH_SEPARATOR=4]="REPETITION_WITH_SEPARATOR",t[t.ALTERNATION=5]="ALTERNATION"})(jn||(jn={}));o(ux,"getProdType");o(Gk,"getLookaheadPaths");o(mse,"buildLookaheadFuncForOr");o(gse,"buildLookaheadFuncForOptionalProd");o(yse,"buildAlternativesLookAheadFunc");o(vse,"buildSingleAlternativeLookaheadFunction");IN=class extends Ou{static{o(this,"RestDefinitionFinderWalker")}constructor(e,r,n){super(),this.topProd=e,this.targetOccurrence=r,this.targetProdType=n}startWalking(){return this.walk(this.topProd),this.restDef}checkIsTarget(e,r,n,i){return e.idx===this.targetOccurrence&&this.targetProdType===r?(this.restDef=n.concat(i),!0):!1}walkOption(e,r,n){this.checkIsTarget(e,jn.OPTION,r,n)||super.walkOption(e,r,n)}walkAtLeastOne(e,r,n){this.checkIsTarget(e,jn.REPETITION_MANDATORY,r,n)||super.walkOption(e,r,n)}walkAtLeastOneSep(e,r,n){this.checkIsTarget(e,jn.REPETITION_MANDATORY_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}walkMany(e,r,n){this.checkIsTarget(e,jn.REPETITION,r,n)||super.walkOption(e,r,n)}walkManySep(e,r,n){this.checkIsTarget(e,jn.REPETITION_WITH_SEPARATOR,r,n)||super.walkOption(e,r,n)}},zk=class extends ss{static{o(this,"InsideDefinitionFinderVisitor")}constructor(e,r,n){super(),this.targetOccurrence=e,this.targetProdType=r,this.targetRef=n,this.result=[]}checkIsTarget(e,r){e.idx===this.targetOccurrence&&this.targetProdType===r&&(this.targetRef===void 0||e===this.targetRef)&&(this.result=e.definition)}visitOption(e){this.checkIsTarget(e,jn.OPTION)}visitRepetition(e){this.checkIsTarget(e,jn.REPETITION)}visitRepetitionMandatory(e){this.checkIsTarget(e,jn.REPETITION_MANDATORY)}visitRepetitionMandatoryWithSeparator(e){this.checkIsTarget(e,jn.REPETITION_MANDATORY_WITH_SEPARATOR)}visitRepetitionWithSeparator(e){this.checkIsTarget(e,jn.REPETITION_WITH_SEPARATOR)}visitAlternation(e){this.checkIsTarget(e,jn.ALTERNATION)}};o(pse,"initializeArrayOfArrays");o(MN,"pathToHashKeys");o(QPe,"isUniquePrefixHash");o(xse,"lookAheadSequenceFromAlternatives");o(Hg,"getLookaheadPathsForOr");o(Wg,"getLookaheadPathsForOptionalProd");o(Vk,"containsPath");o(bse,"isStrictPrefixOfPath");o(wse,"areTokenCategoriesNotUsed")});function Tse(t){let e=t.lookaheadStrategy.validate({rules:t.rules,tokenTypes:t.tokenTypes,grammarName:t.grammarName});return Je(e,r=>Object.assign({type:zi.CUSTOM_LOOKAHEAD_VALIDATION},r))}function kse(t,e,r,n){let i=ya(t,u=>ZPe(u,r)),a=iBe(t,e,r),s=ya(t,u=>tBe(u,r)),l=ya(t,u=>eBe(u,t,n,r));return i.concat(a,s,l)}function ZPe(t,e){let r=new ON;t.accept(r);let n=r.allProductions,i=IL(n,JPe),a=Os(i,l=>l.length>1);return Je(br(a),l=>{let u=ia(l),h=e.buildDuplicateFoundError(t,l),f=Bs(u),d={message:h,type:zi.DUPLICATE_PRODUCTIONS,ruleName:t.name,dslName:f,occurrence:u.idx},p=Ese(u);return p&&(d.parameter=p),d})}function JPe(t){return`${Bs(t)}_#_${t.idx}_#_${Ese(t)}`}function Ese(t){return t instanceof kr?t.terminalType.name:t instanceof on?t.nonTerminalName:""}function eBe(t,e,r,n){let i=[];if(Xr(e,(s,l)=>l.name===t.name?s+1:s,0)>1){let s=n.buildDuplicateRuleNameError({topLevelRule:t,grammarName:r});i.push({message:s,type:zi.DUPLICATE_RULE_NAME,ruleName:t.name})}return i}function Sse(t,e,r){let n=[],i;return qn(e,t)||(i=`Invalid rule override, rule: ->${t}<- cannot be overridden in the grammar: ->${r}<-as it is not defined in any of the super grammars `,n.push({message:i,type:zi.INVALID_RULE_OVERRIDE,ruleName:t})),n}function BN(t,e,r,n=[]){let i=[],a=Uk(e.definition);if(ur(a))return[];{let s=t.name;qn(a,t)&&i.push({message:r.buildLeftRecursionError({topLevelRule:t,leftRecursionPath:n}),type:zi.LEFT_RECURSION,ruleName:s});let u=Zh(a,n.concat([t])),h=ya(u,f=>{let d=an(n);return d.push(f),BN(t,f,r,d)});return i.concat(h)}}function Uk(t){let e=[];if(ur(t))return e;let r=ia(t);if(r instanceof on)e.push(r.referencedRule);else if(r instanceof Dn||r instanceof ln||r instanceof Ln||r instanceof Rn||r instanceof wn||r instanceof Or)e=e.concat(Uk(r.definition));else if(r instanceof Tn)e=qr(Je(r.definition,a=>Uk(a.definition)));else if(!(r instanceof kr))throw Error("non exhaustive match");let n=sp(r),i=t.length>1;if(n&&i){let a=gi(t);return e.concat(Uk(a))}else return e}function Cse(t,e){let r=new hx;t.accept(r);let n=r.alternations;return ya(n,a=>{let s=Nu(a.definition);return ya(s,(l,u)=>{let h=$k([l],[],Pu,1);return ur(h)?[{message:e.buildEmptyAlternationError({topLevelRule:t,alternation:a,emptyChoiceIdx:u}),type:zi.NONE_LAST_EMPTY_ALT,ruleName:t.name,occurrence:a.idx,alternative:u+1}]:[]})})}function Ase(t,e,r){let n=new hx;t.accept(n);let i=n.alternations;return i=Jh(i,s=>s.ignoreAmbiguities===!0),ya(i,s=>{let l=s.idx,u=s.maxLookahead||e,h=Hg(l,t,u,s),f=rBe(h,s,t,r),d=nBe(h,s,t,r);return f.concat(d)})}function tBe(t,e){let r=new hx;t.accept(r);let n=r.alternations;return ya(n,a=>a.definition.length>255?[{message:e.buildTooManyAlternativesError({topLevelRule:t,alternation:a}),type:zi.TOO_MANY_ALTS,ruleName:t.name,occurrence:a.idx}]:[])}function _se(t,e,r){let n=[];return Ae(t,i=>{let a=new PN;i.accept(a);let s=a.allProductions;Ae(s,l=>{let u=ux(l),h=l.maxLookahead||e,f=l.idx,p=Wg(f,i,u,h)[0];if(ur(qr(p))){let m=r.buildEmptyRepetitionError({topLevelRule:i,repetition:l});n.push({message:m,type:zi.NO_NON_EMPTY_LOOKAHEAD,ruleName:i.name})}})}),n}function rBe(t,e,r,n){let i=[],a=Xr(t,(l,u,h)=>(e.definition[h].ignoreAmbiguities===!0||Ae(u,f=>{let d=[h];Ae(t,(p,m)=>{h!==m&&Vk(p,f)&&e.definition[m].ignoreAmbiguities!==!0&&d.push(m)}),d.length>1&&!Vk(i,f)&&(i.push(f),l.push({alts:d,path:f}))}),l),[]);return Je(a,l=>{let u=Je(l.alts,f=>f+1);return{message:n.buildAlternationAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:u,prefixPath:l.path}),type:zi.AMBIGUOUS_ALTS,ruleName:r.name,occurrence:e.idx,alternatives:l.alts}})}function nBe(t,e,r,n){let i=Xr(t,(s,l,u)=>{let h=Je(l,f=>({idx:u,path:f}));return s.concat(h)},[]);return Tc(ya(i,s=>{if(e.definition[s.idx].ignoreAmbiguities===!0)return[];let u=s.idx,h=s.path,f=Yr(i,p=>e.definition[p.idx].ignoreAmbiguities!==!0&&p.idx{let m=[p.idx+1,u+1],g=e.idx===0?"":e.idx;return{message:n.buildAlternationPrefixAmbiguityError({topLevelRule:r,alternation:e,ambiguityIndices:m,prefixPath:p.path}),type:zi.AMBIGUOUS_PREFIX_ALTS,ruleName:r.name,occurrence:g,alternatives:m}})}))}function iBe(t,e,r){let n=[],i=Je(e,a=>a.name);return Ae(t,a=>{let s=a.name;if(qn(i,s)){let l=r.buildNamespaceConflictError(a);n.push({message:l,type:zi.CONFLICT_TOKENS_RULES_NAMESPACE,ruleName:s})}}),n}var ON,hx,PN,fx=N(()=>{"use strict";qt();Fs();os();qg();cx();cp();o(Tse,"validateLookahead");o(kse,"validateGrammar");o(ZPe,"validateDuplicateProductions");o(JPe,"identifyProductionForDuplicates");o(Ese,"getExtraProductionArgument");ON=class extends ss{static{o(this,"OccurrenceValidationCollector")}constructor(){super(...arguments),this.allProductions=[]}visitNonTerminal(e){this.allProductions.push(e)}visitOption(e){this.allProductions.push(e)}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}visitAlternation(e){this.allProductions.push(e)}visitTerminal(e){this.allProductions.push(e)}};o(eBe,"validateRuleDoesNotAlreadyExist");o(Sse,"validateRuleIsOverridden");o(BN,"validateNoLeftRecursion");o(Uk,"getFirstNoneTerminal");hx=class extends ss{static{o(this,"OrCollector")}constructor(){super(...arguments),this.alternations=[]}visitAlternation(e){this.alternations.push(e)}};o(Cse,"validateEmptyOrAlternative");o(Ase,"validateAmbiguousAlternationAlternatives");PN=class extends ss{static{o(this,"RepetitionCollector")}constructor(){super(...arguments),this.allProductions=[]}visitRepetitionWithSeparator(e){this.allProductions.push(e)}visitRepetitionMandatory(e){this.allProductions.push(e)}visitRepetitionMandatoryWithSeparator(e){this.allProductions.push(e)}visitRepetition(e){this.allProductions.push(e)}};o(tBe,"validateTooManyAlts");o(_se,"validateSomeNonEmptyLookaheadPath");o(rBe,"checkAlternativesAmbiguities");o(nBe,"checkPrefixAlternativesAmbiguities");o(iBe,"checkTerminalAndNoneTerminalsNameSpace")});function Dse(t){let e=Qh(t,{errMsgProvider:hse}),r={};return Ae(t.rules,n=>{r[n.name]=n}),fse(r,e.errMsgProvider)}function Lse(t){return t=Qh(t,{errMsgProvider:Pl}),kse(t.rules,t.tokenTypes,t.errMsgProvider,t.grammarName)}var Rse=N(()=>{"use strict";qt();dse();fx();Vg();o(Dse,"resolveGrammar");o(Lse,"validateGrammar")});function lf(t){return qn(Pse,t.name)}var Nse,Mse,Ise,Ose,Pse,Yg,hp,dx,px,mx,Xg=N(()=>{"use strict";qt();Nse="MismatchedTokenException",Mse="NoViableAltException",Ise="EarlyExitException",Ose="NotAllInputParsedException",Pse=[Nse,Mse,Ise,Ose];Object.freeze(Pse);o(lf,"isRecognitionException");Yg=class extends Error{static{o(this,"RecognitionException")}constructor(e,r){super(e),this.token=r,this.resyncedTokens=[],Object.setPrototypeOf(this,new.target.prototype),Error.captureStackTrace&&Error.captureStackTrace(this,this.constructor)}},hp=class extends Yg{static{o(this,"MismatchedTokenException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=Nse}},dx=class extends Yg{static{o(this,"NoViableAltException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=Mse}},px=class extends Yg{static{o(this,"NotAllInputParsedException")}constructor(e,r){super(e,r),this.name=Ose}},mx=class extends Yg{static{o(this,"EarlyExitException")}constructor(e,r,n){super(e,r),this.previousToken=n,this.name=Ise}}});function aBe(t,e,r,n,i,a,s){let l=this.getKeyForAutomaticLookahead(n,i),u=this.firstAfterRepMap[l];if(u===void 0){let p=this.getCurrRuleFullName(),m=this.getGAstProductions()[p];u=new a(m,i).startWalking(),this.firstAfterRepMap[l]=u}let h=u.token,f=u.occurrence,d=u.isEndOfRule;this.RULE_STACK.length===1&&d&&h===void 0&&(h=lo,f=1),!(h===void 0||f===void 0)&&this.shouldInRepetitionRecoveryBeTried(h,f,s)&&this.tryInRepetitionRecovery(t,e,r,h)}var FN,zN,$N,Hk,GN=N(()=>{"use strict";up();qt();Xg();bN();Fs();FN={},zN="InRuleRecoveryException",$N=class extends Error{static{o(this,"InRuleRecoveryException")}constructor(e){super(e),this.name=zN}},Hk=class{static{o(this,"Recoverable")}initRecoverable(e){this.firstAfterRepMap={},this.resyncFollows={},this.recoveryEnabled=Bt(e,"recoveryEnabled")?e.recoveryEnabled:ls.recoveryEnabled,this.recoveryEnabled&&(this.attemptInRepetitionRecovery=aBe)}getTokenToInsert(e){let r=$u(e,"",NaN,NaN,NaN,NaN,NaN,NaN);return r.isInsertedInRecovery=!0,r}canTokenTypeBeInsertedInRecovery(e){return!0}canTokenTypeBeDeletedInRecovery(e){return!0}tryInRepetitionRecovery(e,r,n,i){let a=this.findReSyncTokenType(),s=this.exportLexerState(),l=[],u=!1,h=this.LA(1),f=this.LA(1),d=o(()=>{let p=this.LA(0),m=this.errorMessageProvider.buildMismatchTokenMessage({expected:i,actual:h,previous:p,ruleName:this.getCurrRuleFullName()}),g=new hp(m,h,this.LA(0));g.resyncedTokens=Nu(l),this.SAVE_ERROR(g)},"generateErrorMessage");for(;!u;)if(this.tokenMatcher(f,i)){d();return}else if(n.call(this)){d(),e.apply(this,r);return}else this.tokenMatcher(f,a)?u=!0:(f=this.SKIP_TOKEN(),this.addToResyncTokens(f,l));this.importLexerState(s)}shouldInRepetitionRecoveryBeTried(e,r,n){return!(n===!1||this.tokenMatcher(this.LA(1),e)||this.isBackTracking()||this.canPerformInRuleRecovery(e,this.getFollowsForInRuleRecovery(e,r)))}getFollowsForInRuleRecovery(e,r){let n=this.getCurrentGrammarPath(e,r);return this.getNextPossibleTokenTypes(n)}tryInRuleRecovery(e,r){if(this.canRecoverWithSingleTokenInsertion(e,r))return this.getTokenToInsert(e);if(this.canRecoverWithSingleTokenDeletion(e)){let n=this.SKIP_TOKEN();return this.consumeToken(),n}throw new $N("sad sad panda")}canPerformInRuleRecovery(e,r){return this.canRecoverWithSingleTokenInsertion(e,r)||this.canRecoverWithSingleTokenDeletion(e)}canRecoverWithSingleTokenInsertion(e,r){if(!this.canTokenTypeBeInsertedInRecovery(e)||ur(r))return!1;let n=this.LA(1);return ns(r,a=>this.tokenMatcher(n,a))!==void 0}canRecoverWithSingleTokenDeletion(e){return this.canTokenTypeBeDeletedInRecovery(e)?this.tokenMatcher(this.LA(2),e):!1}isInCurrentRuleReSyncSet(e){let r=this.getCurrFollowKey(),n=this.getFollowSetFromFollowKey(r);return qn(n,e)}findReSyncTokenType(){let e=this.flattenFollowSet(),r=this.LA(1),n=2;for(;;){let i=ns(e,a=>sx(r,a));if(i!==void 0)return i;r=this.LA(n),n++}}getCurrFollowKey(){if(this.RULE_STACK.length===1)return FN;let e=this.getLastExplicitRuleShortName(),r=this.getLastExplicitRuleOccurrenceIndex(),n=this.getPreviousExplicitRuleShortName();return{ruleName:this.shortRuleNameToFullName(e),idxInCallingRule:r,inRule:this.shortRuleNameToFullName(n)}}buildFullFollowKeyStack(){let e=this.RULE_STACK,r=this.RULE_OCCURRENCE_STACK;return Je(e,(n,i)=>i===0?FN:{ruleName:this.shortRuleNameToFullName(n),idxInCallingRule:r[i],inRule:this.shortRuleNameToFullName(e[i-1])})}flattenFollowSet(){let e=Je(this.buildFullFollowKeyStack(),r=>this.getFollowSetFromFollowKey(r));return qr(e)}getFollowSetFromFollowKey(e){if(e===FN)return[lo];let r=e.ruleName+e.idxInCallingRule+_k+e.inRule;return this.resyncFollows[r]}addToResyncTokens(e,r){return this.tokenMatcher(e,lo)||r.push(e),r}reSyncTo(e){let r=[],n=this.LA(1);for(;this.tokenMatcher(n,e)===!1;)n=this.SKIP_TOKEN(),this.addToResyncTokens(n,r);return Nu(r)}attemptInRepetitionRecovery(e,r,n,i,a,s,l){}getCurrentGrammarPath(e,r){let n=this.getHumanReadableRuleStack(),i=an(this.RULE_OCCURRENCE_STACK);return{ruleStack:n,occurrenceStack:i,lastTok:e,lastTokOccurrence:r}}getHumanReadableRuleStack(){return Je(this.RULE_STACK,e=>this.shortRuleNameToFullName(e))}};o(aBe,"attemptInRepetitionRecovery")});function Wk(t,e,r){return r|e|t}var qk=N(()=>{"use strict";o(Wk,"getKeyForAutomaticLookahead")});var Gu,VN=N(()=>{"use strict";qt();Vg();Fs();fx();qg();Gu=class{static{o(this,"LLkLookaheadStrategy")}constructor(e){var r;this.maxLookahead=(r=e?.maxLookahead)!==null&&r!==void 0?r:ls.maxLookahead}validate(e){let r=this.validateNoLeftRecursion(e.rules);if(ur(r)){let n=this.validateEmptyOrAlternatives(e.rules),i=this.validateAmbiguousAlternationAlternatives(e.rules,this.maxLookahead),a=this.validateSomeNonEmptyLookaheadPath(e.rules,this.maxLookahead);return[...r,...n,...i,...a]}return r}validateNoLeftRecursion(e){return ya(e,r=>BN(r,r,Pl))}validateEmptyOrAlternatives(e){return ya(e,r=>Cse(r,Pl))}validateAmbiguousAlternationAlternatives(e,r){return ya(e,n=>Ase(n,r,Pl))}validateSomeNonEmptyLookaheadPath(e,r){return _se(e,r,Pl)}buildLookaheadForAlternation(e){return mse(e.prodOccurrence,e.rule,e.maxLookahead,e.hasPredicates,e.dynamicTokensEnabled,yse)}buildLookaheadForOptional(e){return gse(e.prodOccurrence,e.rule,e.maxLookahead,e.dynamicTokensEnabled,ux(e.prodType),vse)}}});function sBe(t){Yk.reset(),t.accept(Yk);let e=Yk.dslMethods;return Yk.reset(),e}var Xk,UN,Yk,Bse=N(()=>{"use strict";qt();Fs();qk();os();VN();Xk=class{static{o(this,"LooksAhead")}initLooksAhead(e){this.dynamicTokensEnabled=Bt(e,"dynamicTokensEnabled")?e.dynamicTokensEnabled:ls.dynamicTokensEnabled,this.maxLookahead=Bt(e,"maxLookahead")?e.maxLookahead:ls.maxLookahead,this.lookaheadStrategy=Bt(e,"lookaheadStrategy")?e.lookaheadStrategy:new Gu({maxLookahead:this.maxLookahead}),this.lookAheadFuncsCache=new Map}preComputeLookaheadFunctions(e){Ae(e,r=>{this.TRACE_INIT(`${r.name} Rule Lookahead`,()=>{let{alternation:n,repetition:i,option:a,repetitionMandatory:s,repetitionMandatoryWithSeparator:l,repetitionWithSeparator:u}=sBe(r);Ae(n,h=>{let f=h.idx===0?"":h.idx;this.TRACE_INIT(`${Bs(h)}${f}`,()=>{let d=this.lookaheadStrategy.buildLookaheadForAlternation({prodOccurrence:h.idx,rule:r,maxLookahead:h.maxLookahead||this.maxLookahead,hasPredicates:h.hasPredicates,dynamicTokensEnabled:this.dynamicTokensEnabled}),p=Wk(this.fullRuleNameToShort[r.name],256,h.idx);this.setLaFuncCache(p,d)})}),Ae(i,h=>{this.computeLookaheadFunc(r,h.idx,768,"Repetition",h.maxLookahead,Bs(h))}),Ae(a,h=>{this.computeLookaheadFunc(r,h.idx,512,"Option",h.maxLookahead,Bs(h))}),Ae(s,h=>{this.computeLookaheadFunc(r,h.idx,1024,"RepetitionMandatory",h.maxLookahead,Bs(h))}),Ae(l,h=>{this.computeLookaheadFunc(r,h.idx,1536,"RepetitionMandatoryWithSeparator",h.maxLookahead,Bs(h))}),Ae(u,h=>{this.computeLookaheadFunc(r,h.idx,1280,"RepetitionWithSeparator",h.maxLookahead,Bs(h))})})})}computeLookaheadFunc(e,r,n,i,a,s){this.TRACE_INIT(`${s}${r===0?"":r}`,()=>{let l=this.lookaheadStrategy.buildLookaheadForOptional({prodOccurrence:r,rule:e,maxLookahead:a||this.maxLookahead,dynamicTokensEnabled:this.dynamicTokensEnabled,prodType:i}),u=Wk(this.fullRuleNameToShort[e.name],n,r);this.setLaFuncCache(u,l)})}getKeyForAutomaticLookahead(e,r){let n=this.getLastExplicitRuleShortName();return Wk(n,e,r)}getLaFuncFromCache(e){return this.lookAheadFuncsCache.get(e)}setLaFuncCache(e,r){this.lookAheadFuncsCache.set(e,r)}},UN=class extends ss{static{o(this,"DslMethodsCollectorVisitor")}constructor(){super(...arguments),this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}reset(){this.dslMethods={option:[],alternation:[],repetition:[],repetitionWithSeparator:[],repetitionMandatory:[],repetitionMandatoryWithSeparator:[]}}visitOption(e){this.dslMethods.option.push(e)}visitRepetitionWithSeparator(e){this.dslMethods.repetitionWithSeparator.push(e)}visitRepetitionMandatory(e){this.dslMethods.repetitionMandatory.push(e)}visitRepetitionMandatoryWithSeparator(e){this.dslMethods.repetitionMandatoryWithSeparator.push(e)}visitRepetition(e){this.dslMethods.repetition.push(e)}visitAlternation(e){this.dslMethods.alternation.push(e)}},Yk=new UN;o(sBe,"collectMethods")});function qN(t,e){isNaN(t.startOffset)===!0?(t.startOffset=e.startOffset,t.endOffset=e.endOffset):t.endOffset{"use strict";o(qN,"setNodeLocationOnlyOffset");o(YN,"setNodeLocationFull");o(Fse,"addTerminalToCst");o($se,"addNoneTerminalToCst")});function XN(t,e){Object.defineProperty(t,oBe,{enumerable:!1,configurable:!0,writable:!1,value:e})}var oBe,Gse=N(()=>{"use strict";oBe="name";o(XN,"defineNameProp")});function lBe(t,e){let r=zr(t),n=r.length;for(let i=0;is.msg);throw Error(`Errors Detected in CST Visitor <${this.constructor.name}>: + ${a.join(` + +`).replace(/\n/g,` + `)}`)}},"validateVisitor")};return r.prototype=n,r.prototype.constructor=r,r._RULE_NAMES=e,r}function Use(t,e,r){let n=o(function(){},"derivedConstructor");XN(n,t+"BaseSemanticsWithDefaults");let i=Object.create(r.prototype);return Ae(e,a=>{i[a]=lBe}),n.prototype=i,n.prototype.constructor=n,n}function cBe(t,e){return uBe(t,e)}function uBe(t,e){let r=Yr(e,i=>Si(t[i])===!1),n=Je(r,i=>({msg:`Missing visitor method: <${i}> on ${t.constructor.name} CST Visitor.`,type:jN.MISSING_METHOD,methodName:i}));return Tc(n)}var jN,Hse=N(()=>{"use strict";qt();Gse();o(lBe,"defaultVisit");o(Vse,"createBaseSemanticVisitorConstructor");o(Use,"createBaseVisitorConstructorWithDefaults");(function(t){t[t.REDUNDANT_METHOD=0]="REDUNDANT_METHOD",t[t.MISSING_METHOD=1]="MISSING_METHOD"})(jN||(jN={}));o(cBe,"validateVisitor");o(uBe,"validateMissingCstMethods")});var Zk,Wse=N(()=>{"use strict";zse();qt();Hse();Fs();Zk=class{static{o(this,"TreeBuilder")}initTreeBuilder(e){if(this.CST_STACK=[],this.outputCst=e.outputCst,this.nodeLocationTracking=Bt(e,"nodeLocationTracking")?e.nodeLocationTracking:ls.nodeLocationTracking,!this.outputCst)this.cstInvocationStateUpdate=ni,this.cstFinallyStateUpdate=ni,this.cstPostTerminal=ni,this.cstPostNonTerminal=ni,this.cstPostRule=ni;else if(/full/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=YN,this.setNodeLocationFromNode=YN,this.cstPostRule=ni,this.setInitialNodeLocation=this.setInitialNodeLocationFullRecovery):(this.setNodeLocationFromToken=ni,this.setNodeLocationFromNode=ni,this.cstPostRule=this.cstPostRuleFull,this.setInitialNodeLocation=this.setInitialNodeLocationFullRegular);else if(/onlyOffset/i.test(this.nodeLocationTracking))this.recoveryEnabled?(this.setNodeLocationFromToken=qN,this.setNodeLocationFromNode=qN,this.cstPostRule=ni,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRecovery):(this.setNodeLocationFromToken=ni,this.setNodeLocationFromNode=ni,this.cstPostRule=this.cstPostRuleOnlyOffset,this.setInitialNodeLocation=this.setInitialNodeLocationOnlyOffsetRegular);else if(/none/i.test(this.nodeLocationTracking))this.setNodeLocationFromToken=ni,this.setNodeLocationFromNode=ni,this.cstPostRule=ni,this.setInitialNodeLocation=ni;else throw Error(`Invalid config option: "${e.nodeLocationTracking}"`)}setInitialNodeLocationOnlyOffsetRecovery(e){e.location={startOffset:NaN,endOffset:NaN}}setInitialNodeLocationOnlyOffsetRegular(e){e.location={startOffset:this.LA(1).startOffset,endOffset:NaN}}setInitialNodeLocationFullRecovery(e){e.location={startOffset:NaN,startLine:NaN,startColumn:NaN,endOffset:NaN,endLine:NaN,endColumn:NaN}}setInitialNodeLocationFullRegular(e){let r=this.LA(1);e.location={startOffset:r.startOffset,startLine:r.startLine,startColumn:r.startColumn,endOffset:NaN,endLine:NaN,endColumn:NaN}}cstInvocationStateUpdate(e){let r={name:e,children:Object.create(null)};this.setInitialNodeLocation(r),this.CST_STACK.push(r)}cstFinallyStateUpdate(){this.CST_STACK.pop()}cstPostRuleFull(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?(n.endOffset=r.endOffset,n.endLine=r.endLine,n.endColumn=r.endColumn):(n.startOffset=NaN,n.startLine=NaN,n.startColumn=NaN)}cstPostRuleOnlyOffset(e){let r=this.LA(0),n=e.location;n.startOffset<=r.startOffset?n.endOffset=r.endOffset:n.startOffset=NaN}cstPostTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];Fse(n,r,e),this.setNodeLocationFromToken(n.location,r)}cstPostNonTerminal(e,r){let n=this.CST_STACK[this.CST_STACK.length-1];$se(n,r,e),this.setNodeLocationFromNode(n.location,e.location)}getBaseCstVisitorConstructor(){if(pr(this.baseCstVisitorConstructor)){let e=Vse(this.className,zr(this.gastProductionsCache));return this.baseCstVisitorConstructor=e,e}return this.baseCstVisitorConstructor}getBaseCstVisitorConstructorWithDefaults(){if(pr(this.baseCstVisitorWithDefaultsConstructor)){let e=Use(this.className,zr(this.gastProductionsCache),this.getBaseCstVisitorConstructor());return this.baseCstVisitorWithDefaultsConstructor=e,e}return this.baseCstVisitorWithDefaultsConstructor}getLastExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-1]}getPreviousExplicitRuleShortName(){let e=this.RULE_STACK;return e[e.length-2]}getLastExplicitRuleOccurrenceIndex(){let e=this.RULE_OCCURRENCE_STACK;return e[e.length-1]}}});var Jk,qse=N(()=>{"use strict";Fs();Jk=class{static{o(this,"LexerAdapter")}initLexerAdapter(){this.tokVector=[],this.tokVectorLength=0,this.currIdx=-1}set input(e){if(this.selfAnalysisDone!==!0)throw Error("Missing invocation at the end of the Parser's constructor.");this.reset(),this.tokVector=e,this.tokVectorLength=e.length}get input(){return this.tokVector}SKIP_TOKEN(){return this.currIdx<=this.tokVector.length-2?(this.consumeToken(),this.LA(1)):jg}LA(e){let r=this.currIdx+e;return r<0||this.tokVectorLength<=r?jg:this.tokVector[r]}consumeToken(){this.currIdx++}exportLexerState(){return this.currIdx}importLexerState(e){this.currIdx=e}resetLexerState(){this.currIdx=-1}moveToTerminatedState(){this.currIdx=this.tokVector.length-1}getLexerPosition(){return this.exportLexerState()}}});var eE,Yse=N(()=>{"use strict";qt();Xg();Fs();Vg();fx();os();eE=class{static{o(this,"RecognizerApi")}ACTION(e){return e.call(this)}consume(e,r,n){return this.consumeInternal(r,e,n)}subrule(e,r,n){return this.subruleInternal(r,e,n)}option(e,r){return this.optionInternal(r,e)}or(e,r){return this.orInternal(r,e)}many(e,r){return this.manyInternal(e,r)}atLeastOne(e,r){return this.atLeastOneInternal(e,r)}CONSUME(e,r){return this.consumeInternal(e,0,r)}CONSUME1(e,r){return this.consumeInternal(e,1,r)}CONSUME2(e,r){return this.consumeInternal(e,2,r)}CONSUME3(e,r){return this.consumeInternal(e,3,r)}CONSUME4(e,r){return this.consumeInternal(e,4,r)}CONSUME5(e,r){return this.consumeInternal(e,5,r)}CONSUME6(e,r){return this.consumeInternal(e,6,r)}CONSUME7(e,r){return this.consumeInternal(e,7,r)}CONSUME8(e,r){return this.consumeInternal(e,8,r)}CONSUME9(e,r){return this.consumeInternal(e,9,r)}SUBRULE(e,r){return this.subruleInternal(e,0,r)}SUBRULE1(e,r){return this.subruleInternal(e,1,r)}SUBRULE2(e,r){return this.subruleInternal(e,2,r)}SUBRULE3(e,r){return this.subruleInternal(e,3,r)}SUBRULE4(e,r){return this.subruleInternal(e,4,r)}SUBRULE5(e,r){return this.subruleInternal(e,5,r)}SUBRULE6(e,r){return this.subruleInternal(e,6,r)}SUBRULE7(e,r){return this.subruleInternal(e,7,r)}SUBRULE8(e,r){return this.subruleInternal(e,8,r)}SUBRULE9(e,r){return this.subruleInternal(e,9,r)}OPTION(e){return this.optionInternal(e,0)}OPTION1(e){return this.optionInternal(e,1)}OPTION2(e){return this.optionInternal(e,2)}OPTION3(e){return this.optionInternal(e,3)}OPTION4(e){return this.optionInternal(e,4)}OPTION5(e){return this.optionInternal(e,5)}OPTION6(e){return this.optionInternal(e,6)}OPTION7(e){return this.optionInternal(e,7)}OPTION8(e){return this.optionInternal(e,8)}OPTION9(e){return this.optionInternal(e,9)}OR(e){return this.orInternal(e,0)}OR1(e){return this.orInternal(e,1)}OR2(e){return this.orInternal(e,2)}OR3(e){return this.orInternal(e,3)}OR4(e){return this.orInternal(e,4)}OR5(e){return this.orInternal(e,5)}OR6(e){return this.orInternal(e,6)}OR7(e){return this.orInternal(e,7)}OR8(e){return this.orInternal(e,8)}OR9(e){return this.orInternal(e,9)}MANY(e){this.manyInternal(0,e)}MANY1(e){this.manyInternal(1,e)}MANY2(e){this.manyInternal(2,e)}MANY3(e){this.manyInternal(3,e)}MANY4(e){this.manyInternal(4,e)}MANY5(e){this.manyInternal(5,e)}MANY6(e){this.manyInternal(6,e)}MANY7(e){this.manyInternal(7,e)}MANY8(e){this.manyInternal(8,e)}MANY9(e){this.manyInternal(9,e)}MANY_SEP(e){this.manySepFirstInternal(0,e)}MANY_SEP1(e){this.manySepFirstInternal(1,e)}MANY_SEP2(e){this.manySepFirstInternal(2,e)}MANY_SEP3(e){this.manySepFirstInternal(3,e)}MANY_SEP4(e){this.manySepFirstInternal(4,e)}MANY_SEP5(e){this.manySepFirstInternal(5,e)}MANY_SEP6(e){this.manySepFirstInternal(6,e)}MANY_SEP7(e){this.manySepFirstInternal(7,e)}MANY_SEP8(e){this.manySepFirstInternal(8,e)}MANY_SEP9(e){this.manySepFirstInternal(9,e)}AT_LEAST_ONE(e){this.atLeastOneInternal(0,e)}AT_LEAST_ONE1(e){return this.atLeastOneInternal(1,e)}AT_LEAST_ONE2(e){this.atLeastOneInternal(2,e)}AT_LEAST_ONE3(e){this.atLeastOneInternal(3,e)}AT_LEAST_ONE4(e){this.atLeastOneInternal(4,e)}AT_LEAST_ONE5(e){this.atLeastOneInternal(5,e)}AT_LEAST_ONE6(e){this.atLeastOneInternal(6,e)}AT_LEAST_ONE7(e){this.atLeastOneInternal(7,e)}AT_LEAST_ONE8(e){this.atLeastOneInternal(8,e)}AT_LEAST_ONE9(e){this.atLeastOneInternal(9,e)}AT_LEAST_ONE_SEP(e){this.atLeastOneSepFirstInternal(0,e)}AT_LEAST_ONE_SEP1(e){this.atLeastOneSepFirstInternal(1,e)}AT_LEAST_ONE_SEP2(e){this.atLeastOneSepFirstInternal(2,e)}AT_LEAST_ONE_SEP3(e){this.atLeastOneSepFirstInternal(3,e)}AT_LEAST_ONE_SEP4(e){this.atLeastOneSepFirstInternal(4,e)}AT_LEAST_ONE_SEP5(e){this.atLeastOneSepFirstInternal(5,e)}AT_LEAST_ONE_SEP6(e){this.atLeastOneSepFirstInternal(6,e)}AT_LEAST_ONE_SEP7(e){this.atLeastOneSepFirstInternal(7,e)}AT_LEAST_ONE_SEP8(e){this.atLeastOneSepFirstInternal(8,e)}AT_LEAST_ONE_SEP9(e){this.atLeastOneSepFirstInternal(9,e)}RULE(e,r,n=Kg){if(qn(this.definedRulesNames,e)){let s={message:Pl.buildDuplicateRuleNameError({topLevelRule:e,grammarName:this.className}),type:zi.DUPLICATE_RULE_NAME,ruleName:e};this.definitionErrors.push(s)}this.definedRulesNames.push(e);let i=this.defineRule(e,r,n);return this[e]=i,i}OVERRIDE_RULE(e,r,n=Kg){let i=Sse(e,this.definedRulesNames,this.className);this.definitionErrors=this.definitionErrors.concat(i);let a=this.defineRule(e,r,n);return this[e]=a,a}BACKTRACK(e,r){return function(){this.isBackTrackingStack.push(1);let n=this.saveRecogState();try{return e.apply(this,r),!0}catch(i){if(lf(i))return!1;throw i}finally{this.reloadRecogState(n),this.isBackTrackingStack.pop()}}}getGAstProductions(){return this.gastProductionsCache}getSerializedGastProductions(){return Sk(br(this.gastProductionsCache))}}});var tE,Xse=N(()=>{"use strict";qt();qk();Xg();qg();cx();Fs();GN();up();cp();tE=class{static{o(this,"RecognizerEngine")}initRecognizerEngine(e,r){if(this.className=this.constructor.name,this.shortRuleNameToFull={},this.fullRuleNameToShort={},this.ruleShortNameIdx=256,this.tokenMatcher=zg,this.subruleIdx=0,this.definedRulesNames=[],this.tokensMap={},this.isBackTrackingStack=[],this.RULE_STACK=[],this.RULE_OCCURRENCE_STACK=[],this.gastProductionsCache={},Bt(r,"serializedGrammar"))throw Error(`The Parser's configuration can no longer contain a property. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_6-0-0 + For Further details.`);if(Pt(e)){if(ur(e))throw Error(`A Token Vocabulary cannot be empty. + Note that the first argument for the parser constructor + is no longer a Token vector (since v4.0).`);if(typeof e[0].startOffset=="number")throw Error(`The Parser constructor no longer accepts a token vector as the first argument. + See: https://chevrotain.io/docs/changes/BREAKING_CHANGES.html#_4-0-0 + For Further details.`)}if(Pt(e))this.tokensMap=Xr(e,(a,s)=>(a[s.name]=s,a),{});else if(Bt(e,"modes")&&Ma(qr(br(e.modes)),rse)){let a=qr(br(e.modes)),s=Bm(a);this.tokensMap=Xr(s,(l,u)=>(l[u.name]=u,l),{})}else if(bn(e))this.tokensMap=an(e);else throw new Error(" argument must be An Array of Token constructors, A dictionary of Token constructors or an IMultiModeLexerDefinition");this.tokensMap.EOF=lo;let n=Bt(e,"modes")?qr(br(e.modes)):br(e),i=Ma(n,a=>ur(a.categoryMatches));this.tokenMatcher=i?zg:Pu,Bu(br(this.tokensMap))}defineRule(e,r,n){if(this.selfAnalysisDone)throw Error(`Grammar rule <${e}> may not be defined after the 'performSelfAnalysis' method has been called' +Make sure that all grammar rule definitions are done before 'performSelfAnalysis' is called.`);let i=Bt(n,"resyncEnabled")?n.resyncEnabled:Kg.resyncEnabled,a=Bt(n,"recoveryValueFunc")?n.recoveryValueFunc:Kg.recoveryValueFunc,s=this.ruleShortNameIdx<<12;this.ruleShortNameIdx++,this.shortRuleNameToFull[s]=e,this.fullRuleNameToShort[e]=s;let l;return this.outputCst===!0?l=o(function(...f){try{this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f);let d=this.CST_STACK[this.CST_STACK.length-1];return this.cstPostRule(d),d}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTry"):l=o(function(...f){try{return this.ruleInvocationStateUpdate(s,e,this.subruleIdx),r.apply(this,f)}catch(d){return this.invokeRuleCatch(d,i,a)}finally{this.ruleFinallyStateUpdate()}},"invokeRuleWithTryCst"),Object.assign(l,{ruleName:e,originalGrammarAction:r})}invokeRuleCatch(e,r,n){let i=this.RULE_STACK.length===1,a=r&&!this.isBackTracking()&&this.recoveryEnabled;if(lf(e)){let s=e;if(a){let l=this.findReSyncTokenType();if(this.isInCurrentRuleReSyncSet(l))if(s.resyncedTokens=this.reSyncTo(l),this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];return u.recoveredNode=!0,u}else return n(e);else{if(this.outputCst){let u=this.CST_STACK[this.CST_STACK.length-1];u.recoveredNode=!0,s.partialCstResult=u}throw s}}else{if(i)return this.moveToTerminatedState(),n(e);throw s}}else throw e}optionInternal(e,r){let n=this.getKeyForAutomaticLookahead(512,r);return this.optionInternalLogic(e,r,n)}optionInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof e!="function"){a=e.DEF;let s=e.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=e;if(i.call(this)===!0)return a.call(this)}atLeastOneInternal(e,r){let n=this.getKeyForAutomaticLookahead(1024,e);return this.atLeastOneInternalLogic(e,r,n)}atLeastOneInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let s=r.GATE;if(s!==void 0){let l=i;i=o(()=>s.call(this)&&l.call(this),"lookAheadFunc")}}else a=r;if(i.call(this)===!0){let s=this.doSingleRepetition(a);for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a)}else throw this.raiseEarlyExitException(e,jn.REPETITION_MANDATORY,r.ERR_MSG);this.attemptInRepetitionRecovery(this.atLeastOneInternal,[e,r],i,1024,e,Bk)}atLeastOneSepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1536,e);this.atLeastOneSepFirstInternalLogic(e,r,n)}atLeastOneSepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,lx],l,1536,e,lx)}else throw this.raiseEarlyExitException(e,jn.REPETITION_MANDATORY_WITH_SEPARATOR,r.ERR_MSG)}manyInternal(e,r){let n=this.getKeyForAutomaticLookahead(768,e);return this.manyInternalLogic(e,r,n)}manyInternalLogic(e,r,n){let i=this.getLaFuncFromCache(n),a;if(typeof r!="function"){a=r.DEF;let l=r.GATE;if(l!==void 0){let u=i;i=o(()=>l.call(this)&&u.call(this),"lookaheadFunction")}}else a=r;let s=!0;for(;i.call(this)===!0&&s===!0;)s=this.doSingleRepetition(a);this.attemptInRepetitionRecovery(this.manyInternal,[e,r],i,768,e,Pk,s)}manySepFirstInternal(e,r){let n=this.getKeyForAutomaticLookahead(1280,e);this.manySepFirstInternalLogic(e,r,n)}manySepFirstInternalLogic(e,r,n){let i=r.DEF,a=r.SEP;if(this.getLaFuncFromCache(n).call(this)===!0){i.call(this);let l=o(()=>this.tokenMatcher(this.LA(1),a),"separatorLookAheadFunc");for(;this.tokenMatcher(this.LA(1),a)===!0;)this.CONSUME(a),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,a,l,i,ox],l,1280,e,ox)}}repetitionSepSecondInternal(e,r,n,i,a){for(;n();)this.CONSUME(r),i.call(this);this.attemptInRepetitionRecovery(this.repetitionSepSecondInternal,[e,r,n,i,a],n,1536,e,a)}doSingleRepetition(e){let r=this.getLexerPosition();return e.call(this),this.getLexerPosition()>r}orInternal(e,r){let n=this.getKeyForAutomaticLookahead(256,r),i=Pt(e)?e:e.DEF,s=this.getLaFuncFromCache(n).call(this,i);if(s!==void 0)return i[s].ALT.call(this);this.raiseNoAltException(r,e.ERR_MSG)}ruleFinallyStateUpdate(){if(this.RULE_STACK.pop(),this.RULE_OCCURRENCE_STACK.pop(),this.cstFinallyStateUpdate(),this.RULE_STACK.length===0&&this.isAtEndOfInput()===!1){let e=this.LA(1),r=this.errorMessageProvider.buildNotAllInputParsedMessage({firstRedundant:e,ruleName:this.getCurrRuleFullName()});this.SAVE_ERROR(new px(r,e))}}subruleInternal(e,r,n){let i;try{let a=n!==void 0?n.ARGS:void 0;return this.subruleIdx=r,i=e.apply(this,a),this.cstPostNonTerminal(i,n!==void 0&&n.LABEL!==void 0?n.LABEL:e.ruleName),i}catch(a){throw this.subruleInternalError(a,n,e.ruleName)}}subruleInternalError(e,r,n){throw lf(e)&&e.partialCstResult!==void 0&&(this.cstPostNonTerminal(e.partialCstResult,r!==void 0&&r.LABEL!==void 0?r.LABEL:n),delete e.partialCstResult),e}consumeInternal(e,r,n){let i;try{let a=this.LA(1);this.tokenMatcher(a,e)===!0?(this.consumeToken(),i=a):this.consumeInternalError(e,a,n)}catch(a){i=this.consumeInternalRecovery(e,r,a)}return this.cstPostTerminal(n!==void 0&&n.LABEL!==void 0?n.LABEL:e.name,i),i}consumeInternalError(e,r,n){let i,a=this.LA(0);throw n!==void 0&&n.ERR_MSG?i=n.ERR_MSG:i=this.errorMessageProvider.buildMismatchTokenMessage({expected:e,actual:r,previous:a,ruleName:this.getCurrRuleFullName()}),this.SAVE_ERROR(new hp(i,r,a))}consumeInternalRecovery(e,r,n){if(this.recoveryEnabled&&n.name==="MismatchedTokenException"&&!this.isBackTracking()){let i=this.getFollowsForInRuleRecovery(e,r);try{return this.tryInRuleRecovery(e,i)}catch(a){throw a.name===zN?n:a}}else throw n}saveRecogState(){let e=this.errors,r=an(this.RULE_STACK);return{errors:e,lexerState:this.exportLexerState(),RULE_STACK:r,CST_STACK:this.CST_STACK}}reloadRecogState(e){this.errors=e.errors,this.importLexerState(e.lexerState),this.RULE_STACK=e.RULE_STACK}ruleInvocationStateUpdate(e,r,n){this.RULE_OCCURRENCE_STACK.push(n),this.RULE_STACK.push(e),this.cstInvocationStateUpdate(r)}isBackTracking(){return this.isBackTrackingStack.length!==0}getCurrRuleFullName(){let e=this.getLastExplicitRuleShortName();return this.shortRuleNameToFull[e]}shortRuleNameToFullName(e){return this.shortRuleNameToFull[e]}isAtEndOfInput(){return this.tokenMatcher(this.LA(1),lo)}reset(){this.resetLexerState(),this.subruleIdx=0,this.isBackTrackingStack=[],this.errors=[],this.RULE_STACK=[],this.CST_STACK=[],this.RULE_OCCURRENCE_STACK=[]}}});var rE,jse=N(()=>{"use strict";Xg();qt();qg();Fs();rE=class{static{o(this,"ErrorHandler")}initErrorHandler(e){this._errors=[],this.errorMessageProvider=Bt(e,"errorMessageProvider")?e.errorMessageProvider:ls.errorMessageProvider}SAVE_ERROR(e){if(lf(e))return e.context={ruleStack:this.getHumanReadableRuleStack(),ruleOccurrenceStack:an(this.RULE_OCCURRENCE_STACK)},this._errors.push(e),e;throw Error("Trying to save an Error which is not a RecognitionException")}get errors(){return an(this._errors)}set errors(e){this._errors=e}raiseEarlyExitException(e,r,n){let i=this.getCurrRuleFullName(),a=this.getGAstProductions()[i],l=Wg(e,a,r,this.maxLookahead)[0],u=[];for(let f=1;f<=this.maxLookahead;f++)u.push(this.LA(f));let h=this.errorMessageProvider.buildEarlyExitMessage({expectedIterationPaths:l,actual:u,previous:this.LA(0),customUserDescription:n,ruleName:i});throw this.SAVE_ERROR(new mx(h,this.LA(1),this.LA(0)))}raiseNoAltException(e,r){let n=this.getCurrRuleFullName(),i=this.getGAstProductions()[n],a=Hg(e,i,this.maxLookahead),s=[];for(let h=1;h<=this.maxLookahead;h++)s.push(this.LA(h));let l=this.LA(0),u=this.errorMessageProvider.buildNoViableAltMessage({expectedPathsPerAlt:a,actual:s,previous:l,customUserDescription:r,ruleName:this.getCurrRuleFullName()});throw this.SAVE_ERROR(new dx(u,this.LA(1),l))}}});var nE,Kse=N(()=>{"use strict";cx();qt();nE=class{static{o(this,"ContentAssist")}initContentAssist(){}computeContentAssist(e,r){let n=this.gastProductionsCache[e];if(pr(n))throw Error(`Rule ->${e}<- does not exist in this grammar.`);return $k([n],r,this.tokenMatcher,this.maxLookahead)}getNextPossibleTokenTypes(e){let r=ia(e.ruleStack),i=this.getGAstProductions()[r];return new Ok(i,e).startWalking()}}});function yx(t,e,r,n=!1){aE(r);let i=ga(this.recordingProdStack),a=Si(e)?e:e.DEF,s=new t({definition:[],idx:r});return n&&(s.separator=e.SEP),Bt(e,"MAX_LOOKAHEAD")&&(s.maxLookahead=e.MAX_LOOKAHEAD),this.recordingProdStack.push(s),a.call(this),i.definition.push(s),this.recordingProdStack.pop(),sE}function dBe(t,e){aE(e);let r=ga(this.recordingProdStack),n=Pt(t)===!1,i=n===!1?t:t.DEF,a=new Tn({definition:[],idx:e,ignoreAmbiguities:n&&t.IGNORE_AMBIGUITIES===!0});Bt(t,"MAX_LOOKAHEAD")&&(a.maxLookahead=t.MAX_LOOKAHEAD);let s=A2(i,l=>Si(l.GATE));return a.hasPredicates=s,r.definition.push(a),Ae(i,l=>{let u=new Dn({definition:[]});a.definition.push(u),Bt(l,"IGNORE_AMBIGUITIES")?u.ignoreAmbiguities=l.IGNORE_AMBIGUITIES:Bt(l,"GATE")&&(u.ignoreAmbiguities=!0),this.recordingProdStack.push(u),l.ALT.call(this),this.recordingProdStack.pop()}),sE}function Jse(t){return t===0?"":`${t}`}function aE(t){if(t<0||t>Zse){let e=new Error(`Invalid DSL Method idx value: <${t}> + Idx value must be a none negative value smaller than ${Zse+1}`);throw e.KNOWN_RECORDER_ERROR=!0,e}}var sE,Qse,Zse,eoe,toe,fBe,iE,roe=N(()=>{"use strict";qt();os();ix();cp();up();Fs();qk();sE={description:"This Object indicates the Parser is during Recording Phase"};Object.freeze(sE);Qse=!0,Zse=Math.pow(2,8)-1,eoe=of({name:"RECORDING_PHASE_TOKEN",pattern:Xn.NA});Bu([eoe]);toe=$u(eoe,`This IToken indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,-1,-1,-1,-1,-1,-1);Object.freeze(toe);fBe={name:`This CSTNode indicates the Parser is in Recording Phase + See: https://chevrotain.io/docs/guide/internals.html#grammar-recording for details`,children:{}},iE=class{static{o(this,"GastRecorder")}initGastRecorder(e){this.recordingProdStack=[],this.RECORDING_PHASE=!1}enableRecording(){this.RECORDING_PHASE=!0,this.TRACE_INIT("Enable Recording",()=>{for(let e=0;e<10;e++){let r=e>0?e:"";this[`CONSUME${r}`]=function(n,i){return this.consumeInternalRecord(n,e,i)},this[`SUBRULE${r}`]=function(n,i){return this.subruleInternalRecord(n,e,i)},this[`OPTION${r}`]=function(n){return this.optionInternalRecord(n,e)},this[`OR${r}`]=function(n){return this.orInternalRecord(n,e)},this[`MANY${r}`]=function(n){this.manyInternalRecord(e,n)},this[`MANY_SEP${r}`]=function(n){this.manySepFirstInternalRecord(e,n)},this[`AT_LEAST_ONE${r}`]=function(n){this.atLeastOneInternalRecord(e,n)},this[`AT_LEAST_ONE_SEP${r}`]=function(n){this.atLeastOneSepFirstInternalRecord(e,n)}}this.consume=function(e,r,n){return this.consumeInternalRecord(r,e,n)},this.subrule=function(e,r,n){return this.subruleInternalRecord(r,e,n)},this.option=function(e,r){return this.optionInternalRecord(r,e)},this.or=function(e,r){return this.orInternalRecord(r,e)},this.many=function(e,r){this.manyInternalRecord(e,r)},this.atLeastOne=function(e,r){this.atLeastOneInternalRecord(e,r)},this.ACTION=this.ACTION_RECORD,this.BACKTRACK=this.BACKTRACK_RECORD,this.LA=this.LA_RECORD})}disableRecording(){this.RECORDING_PHASE=!1,this.TRACE_INIT("Deleting Recording methods",()=>{let e=this;for(let r=0;r<10;r++){let n=r>0?r:"";delete e[`CONSUME${n}`],delete e[`SUBRULE${n}`],delete e[`OPTION${n}`],delete e[`OR${n}`],delete e[`MANY${n}`],delete e[`MANY_SEP${n}`],delete e[`AT_LEAST_ONE${n}`],delete e[`AT_LEAST_ONE_SEP${n}`]}delete e.consume,delete e.subrule,delete e.option,delete e.or,delete e.many,delete e.atLeastOne,delete e.ACTION,delete e.BACKTRACK,delete e.LA})}ACTION_RECORD(e){}BACKTRACK_RECORD(e,r){return()=>!0}LA_RECORD(e){return jg}topLevelRuleRecord(e,r){try{let n=new as({definition:[],name:e});return n.name=e,this.recordingProdStack.push(n),r.call(this),this.recordingProdStack.pop(),n}catch(n){if(n.KNOWN_RECORDER_ERROR!==!0)try{n.message=n.message+` + This error was thrown during the "grammar recording phase" For more info see: + https://chevrotain.io/docs/guide/internals.html#grammar-recording`}catch{throw n}throw n}}optionInternalRecord(e,r){return yx.call(this,ln,e,r)}atLeastOneInternalRecord(e,r){yx.call(this,Ln,r,e)}atLeastOneSepFirstInternalRecord(e,r){yx.call(this,Rn,r,e,Qse)}manyInternalRecord(e,r){yx.call(this,Or,r,e)}manySepFirstInternalRecord(e,r){yx.call(this,wn,r,e,Qse)}orInternalRecord(e,r){return dBe.call(this,e,r)}subruleInternalRecord(e,r,n){if(aE(r),!e||Bt(e,"ruleName")===!1){let l=new Error(` argument is invalid expecting a Parser method reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw l.KNOWN_RECORDER_ERROR=!0,l}let i=ga(this.recordingProdStack),a=e.ruleName,s=new on({idx:r,nonTerminalName:a,label:n?.LABEL,referencedRule:void 0});return i.definition.push(s),this.outputCst?fBe:sE}consumeInternalRecord(e,r,n){if(aE(r),!_N(e)){let s=new Error(` argument is invalid expecting a TokenType reference but got: <${JSON.stringify(e)}> + inside top level rule: <${this.recordingProdStack[0].name}>`);throw s.KNOWN_RECORDER_ERROR=!0,s}let i=ga(this.recordingProdStack),a=new kr({idx:r,terminalType:e,label:n?.LABEL});return i.definition.push(a),toe}};o(yx,"recordProd");o(dBe,"recordOrProd");o(Jse,"getIdxSuffix");o(aE,"assertMethodIdxIsValid")});var oE,noe=N(()=>{"use strict";qt();Og();Fs();oE=class{static{o(this,"PerformanceTracer")}initPerformanceTracer(e){if(Bt(e,"traceInitPerf")){let r=e.traceInitPerf,n=typeof r=="number";this.traceInitMaxIdent=n?r:1/0,this.traceInitPerf=n?r>0:r}else this.traceInitMaxIdent=0,this.traceInitPerf=ls.traceInitPerf;this.traceInitIndent=-1}TRACE_INIT(e,r){if(this.traceInitPerf===!0){this.traceInitIndent++;let n=new Array(this.traceInitIndent+1).join(" ");this.traceInitIndent <${e}>`);let{time:i,value:a}=tx(r),s=i>10?console.warn:console.log;return this.traceInitIndent time: ${i}ms`),this.traceInitIndent--,a}else return r()}}});function ioe(t,e){e.forEach(r=>{let n=r.prototype;Object.getOwnPropertyNames(n).forEach(i=>{if(i==="constructor")return;let a=Object.getOwnPropertyDescriptor(n,i);a&&(a.get||a.set)?Object.defineProperty(t.prototype,i,a):t.prototype[i]=r.prototype[i]})})}var aoe=N(()=>{"use strict";o(ioe,"applyMixins")});function lE(t=void 0){return function(){return t}}var jg,ls,Kg,zi,vx,xx,Fs=N(()=>{"use strict";qt();Og();Oae();up();Vg();Rse();GN();Bse();Wse();qse();Yse();Xse();jse();Kse();roe();noe();aoe();fx();jg=$u(lo,"",NaN,NaN,NaN,NaN,NaN,NaN);Object.freeze(jg);ls=Object.freeze({recoveryEnabled:!1,maxLookahead:3,dynamicTokensEnabled:!1,outputCst:!0,errorMessageProvider:zu,nodeLocationTracking:"none",traceInitPerf:!1,skipValidations:!1}),Kg=Object.freeze({recoveryValueFunc:o(()=>{},"recoveryValueFunc"),resyncEnabled:!0});(function(t){t[t.INVALID_RULE_NAME=0]="INVALID_RULE_NAME",t[t.DUPLICATE_RULE_NAME=1]="DUPLICATE_RULE_NAME",t[t.INVALID_RULE_OVERRIDE=2]="INVALID_RULE_OVERRIDE",t[t.DUPLICATE_PRODUCTIONS=3]="DUPLICATE_PRODUCTIONS",t[t.UNRESOLVED_SUBRULE_REF=4]="UNRESOLVED_SUBRULE_REF",t[t.LEFT_RECURSION=5]="LEFT_RECURSION",t[t.NONE_LAST_EMPTY_ALT=6]="NONE_LAST_EMPTY_ALT",t[t.AMBIGUOUS_ALTS=7]="AMBIGUOUS_ALTS",t[t.CONFLICT_TOKENS_RULES_NAMESPACE=8]="CONFLICT_TOKENS_RULES_NAMESPACE",t[t.INVALID_TOKEN_NAME=9]="INVALID_TOKEN_NAME",t[t.NO_NON_EMPTY_LOOKAHEAD=10]="NO_NON_EMPTY_LOOKAHEAD",t[t.AMBIGUOUS_PREFIX_ALTS=11]="AMBIGUOUS_PREFIX_ALTS",t[t.TOO_MANY_ALTS=12]="TOO_MANY_ALTS",t[t.CUSTOM_LOOKAHEAD_VALIDATION=13]="CUSTOM_LOOKAHEAD_VALIDATION"})(zi||(zi={}));o(lE,"EMPTY_ALT");vx=class t{static{o(this,"Parser")}static performSelfAnalysis(e){throw Error("The **static** `performSelfAnalysis` method has been deprecated. \nUse the **instance** method with the same name instead.")}performSelfAnalysis(){this.TRACE_INIT("performSelfAnalysis",()=>{let e;this.selfAnalysisDone=!0;let r=this.className;this.TRACE_INIT("toFastProps",()=>{rx(this)}),this.TRACE_INIT("Grammar Recording",()=>{try{this.enableRecording(),Ae(this.definedRulesNames,i=>{let s=this[i].originalGrammarAction,l;this.TRACE_INIT(`${i} Rule`,()=>{l=this.topLevelRuleRecord(i,s)}),this.gastProductionsCache[i]=l})}finally{this.disableRecording()}});let n=[];if(this.TRACE_INIT("Grammar Resolving",()=>{n=Dse({rules:br(this.gastProductionsCache)}),this.definitionErrors=this.definitionErrors.concat(n)}),this.TRACE_INIT("Grammar Validations",()=>{if(ur(n)&&this.skipValidations===!1){let i=Lse({rules:br(this.gastProductionsCache),tokenTypes:br(this.tokensMap),errMsgProvider:Pl,grammarName:r}),a=Tse({lookaheadStrategy:this.lookaheadStrategy,rules:br(this.gastProductionsCache),tokenTypes:br(this.tokensMap),grammarName:r});this.definitionErrors=this.definitionErrors.concat(i,a)}}),ur(this.definitionErrors)&&(this.recoveryEnabled&&this.TRACE_INIT("computeAllProdsFollows",()=>{let i=Iae(br(this.gastProductionsCache));this.resyncFollows=i}),this.TRACE_INIT("ComputeLookaheadFunctions",()=>{var i,a;(a=(i=this.lookaheadStrategy).initialize)===null||a===void 0||a.call(i,{rules:br(this.gastProductionsCache)}),this.preComputeLookaheadFunctions(br(this.gastProductionsCache))})),!t.DEFER_DEFINITION_ERRORS_HANDLING&&!ur(this.definitionErrors))throw e=Je(this.definitionErrors,i=>i.message),new Error(`Parser Definition Errors detected: + ${e.join(` +------------------------------- +`)}`)})}constructor(e,r){this.definitionErrors=[],this.selfAnalysisDone=!1;let n=this;if(n.initErrorHandler(r),n.initLexerAdapter(),n.initLooksAhead(r),n.initRecognizerEngine(e,r),n.initRecoverable(r),n.initTreeBuilder(r),n.initContentAssist(),n.initGastRecorder(r),n.initPerformanceTracer(r),Bt(r,"ignoredIssues"))throw new Error(`The IParserConfig property has been deprecated. + Please use the flag on the relevant DSL method instead. + See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#IGNORING_AMBIGUITIES + For further details.`);this.skipValidations=Bt(r,"skipValidations")?r.skipValidations:ls.skipValidations}};vx.DEFER_DEFINITION_ERRORS_HANDLING=!1;ioe(vx,[Hk,Xk,Zk,Jk,tE,eE,rE,nE,iE,oE]);xx=class extends vx{static{o(this,"EmbeddedActionsParser")}constructor(e,r=ls){let n=an(r);n.outputCst=!1,super(e,n)}}});var soe=N(()=>{"use strict";os()});var ooe=N(()=>{"use strict"});var loe=N(()=>{"use strict";soe();ooe()});var coe=N(()=>{"use strict";gN()});var cf=N(()=>{"use strict";gN();Fs();ix();up();qg();VN();Vg();Xg();DN();os();os();loe();coe()});function fp(t,e,r){return`${t.name}_${e}_${r}`}function doe(t){let e={decisionMap:{},decisionStates:[],ruleToStartState:new Map,ruleToStopState:new Map,states:[]};bBe(e,t);let r=t.length;for(let n=0;npoe(t,e,s));return e1(t,e,n,r,...i)}function CBe(t,e,r){let n=aa(t,e,r,{type:uf});hf(t,n);let i=e1(t,e,n,r,dp(t,e,r));return ABe(t,e,r,i)}function dp(t,e,r){let n=Yr(Je(r.definition,i=>poe(t,e,i)),i=>i!==void 0);return n.length===1?n[0]:n.length===0?void 0:DBe(t,n)}function moe(t,e,r,n,i){let a=n.left,s=n.right,l=aa(t,e,r,{type:xBe});hf(t,l);let u=aa(t,e,r,{type:foe});return a.loopback=l,u.loopback=l,t.decisionMap[fp(e,i?"RepetitionMandatoryWithSeparator":"RepetitionMandatory",r.idx)]=l,Ai(s,l),i===void 0?(Ai(l,a),Ai(l,u)):(Ai(l,u),Ai(l,i.left),Ai(i.right,a)),{left:a,right:u}}function goe(t,e,r,n,i){let a=n.left,s=n.right,l=aa(t,e,r,{type:vBe});hf(t,l);let u=aa(t,e,r,{type:foe}),h=aa(t,e,r,{type:yBe});return l.loopback=h,u.loopback=h,Ai(l,a),Ai(l,u),Ai(s,h),i!==void 0?(Ai(h,u),Ai(h,i.left),Ai(i.right,a)):Ai(h,l),t.decisionMap[fp(e,i?"RepetitionWithSeparator":"Repetition",r.idx)]=l,{left:l,right:u}}function ABe(t,e,r,n){let i=n.left,a=n.right;return Ai(i,a),t.decisionMap[fp(e,"Option",r.idx)]=i,n}function hf(t,e){return t.decisionStates.push(e),e.decision=t.decisionStates.length-1,e.decision}function e1(t,e,r,n,...i){let a=aa(t,e,n,{type:gBe,start:r});r.end=a;for(let l of i)l!==void 0?(Ai(r,l.left),Ai(l.right,a)):Ai(r,a);let s={left:r,right:a};return t.decisionMap[fp(e,_Be(n),n.idx)]=r,s}function _Be(t){if(t instanceof Tn)return"Alternation";if(t instanceof ln)return"Option";if(t instanceof Or)return"Repetition";if(t instanceof wn)return"RepetitionWithSeparator";if(t instanceof Ln)return"RepetitionMandatory";if(t instanceof Rn)return"RepetitionMandatoryWithSeparator";throw new Error("Invalid production type encountered")}function DBe(t,e){let r=e.length;for(let a=0;a{"use strict";Im();DL();cf();o(fp,"buildATNKey");uf=1,mBe=2,uoe=4,hoe=5,Jg=7,gBe=8,yBe=9,vBe=10,xBe=11,foe=12,bx=class{static{o(this,"AbstractTransition")}constructor(e){this.target=e}isEpsilon(){return!1}},Qg=class extends bx{static{o(this,"AtomTransition")}constructor(e,r){super(e),this.tokenType=r}},wx=class extends bx{static{o(this,"EpsilonTransition")}constructor(e){super(e)}isEpsilon(){return!0}},Zg=class extends bx{static{o(this,"RuleTransition")}constructor(e,r,n){super(e),this.rule=r,this.followState=n}isEpsilon(){return!0}};o(doe,"createATN");o(bBe,"createRuleStartAndStopATNStates");o(poe,"atom");o(wBe,"repetition");o(TBe,"repetitionSep");o(kBe,"repetitionMandatory");o(EBe,"repetitionMandatorySep");o(SBe,"alternation");o(CBe,"option");o(dp,"block");o(moe,"plus");o(goe,"star");o(ABe,"optional");o(hf,"defineDecisionState");o(e1,"makeAlts");o(_Be,"getProdType");o(DBe,"makeBlock");o(QN,"tokenRef");o(LBe,"ruleRef");o(RBe,"buildRuleHandle");o(Ai,"epsilon");o(aa,"newState");o(ZN,"addTransition");o(NBe,"removeState")});function JN(t,e=!0){return`${e?`a${t.alt}`:""}s${t.state.stateNumber}:${t.stack.map(r=>r.stateNumber.toString()).join("_")}`}var Tx,t1,voe=N(()=>{"use strict";Im();Tx={},t1=class{static{o(this,"ATNConfigSet")}constructor(){this.map={},this.configs=[]}get size(){return this.configs.length}finalize(){this.map={}}add(e){let r=JN(e);r in this.map||(this.map[r]=this.configs.length,this.configs.push(e))}get elements(){return this.configs}get alts(){return Je(this.configs,e=>e.alt)}get key(){let e="";for(let r in this.map)e+=r+":";return e}};o(JN,"getATNConfigKey")});function MBe(t,e){let r={};return n=>{let i=n.toString(),a=r[i];return a!==void 0||(a={atnStartState:t,decision:e,states:{}},r[i]=a),a}}function boe(t,e=!0){let r=new Set;for(let n of t){let i=new Set;for(let a of n){if(a===void 0){if(e)break;return!1}let s=[a.tokenTypeIdx].concat(a.categoryMatches);for(let l of s)if(r.has(l)){if(!i.has(l))return!1}else r.add(l),i.add(l)}}return!0}function IBe(t){let e=t.decisionStates.length,r=Array(e);for(let n=0;nFu(i)).join(", "),r=t.production.idx===0?"":t.production.idx,n=`Ambiguous Alternatives Detected: <${t.ambiguityIndices.join(", ")}> in <${$Be(t.production)}${r}> inside <${t.topLevelRule.name}> Rule, +<${e}> may appears as a prefix path in all these alternatives. +`;return n=n+`See: https://chevrotain.io/docs/guide/resolving_grammar_errors.html#AMBIGUOUS_ALTERNATIVES +For Further details.`,n}function $Be(t){if(t instanceof on)return"SUBRULE";if(t instanceof ln)return"OPTION";if(t instanceof Tn)return"OR";if(t instanceof Ln)return"AT_LEAST_ONE";if(t instanceof Rn)return"AT_LEAST_ONE_SEP";if(t instanceof wn)return"MANY_SEP";if(t instanceof Or)return"MANY";if(t instanceof kr)return"CONSUME";throw Error("non exhaustive match")}function zBe(t,e,r){let n=ya(e.configs.elements,a=>a.state.transitions),i=Qre(n.filter(a=>a instanceof Qg).map(a=>a.tokenType),a=>a.tokenTypeIdx);return{actualToken:r,possibleTokenTypes:i,tokenPath:t}}function GBe(t,e){return t.edges[e.tokenTypeIdx]}function VBe(t,e,r){let n=new t1,i=[];for(let s of t.elements){if(r.is(s.alt)===!1)continue;if(s.state.type===Jg){i.push(s);continue}let l=s.state.transitions.length;for(let u=0;u0&&!YBe(a))for(let s of i)a.add(s);return a}function UBe(t,e){if(t instanceof Qg&&sx(e,t.tokenType))return t.target}function HBe(t,e){let r;for(let n of t.elements)if(e.is(n.alt)===!0){if(r===void 0)r=n.alt;else if(r!==n.alt)return}return r}function Toe(t){return{configs:t,edges:{},isAcceptState:!1,prediction:-1}}function woe(t,e,r,n){return n=koe(t,n),e.edges[r.tokenTypeIdx]=n,n}function koe(t,e){if(e===Tx)return e;let r=e.configs.key,n=t.states[r];return n!==void 0?n:(e.configs.finalize(),t.states[r]=e,e)}function WBe(t){let e=new t1,r=t.transitions.length;for(let n=0;n0){let i=[...t.stack],s={state:i.pop(),alt:t.alt,stack:i};uE(s,e)}else e.add(t);return}r.epsilonOnlyTransitions||e.add(t);let n=r.transitions.length;for(let i=0;i1)return!0;return!1}function ZBe(t){for(let e of Array.from(t.values()))if(Object.keys(e).length===1)return!0;return!1}var cE,xoe,kx,Eoe=N(()=>{"use strict";cf();yoe();voe();BL();RL();Zre();Im();uT();$T();HT();GL();o(MBe,"createDFACache");cE=class{static{o(this,"PredicateSet")}constructor(){this.predicates=[]}is(e){return e>=this.predicates.length||this.predicates[e]}set(e,r){this.predicates[e]=r}toString(){let e="",r=this.predicates.length;for(let n=0;nconsole.log(n)}initialize(e){this.atn=doe(e.rules),this.dfas=IBe(this.atn)}validateAmbiguousAlternationAlternatives(){return[]}validateEmptyOrAlternatives(){return[]}buildLookaheadForAlternation(e){let{prodOccurrence:r,rule:n,hasPredicates:i,dynamicTokensEnabled:a}=e,s=this.dfas,l=this.logging,u=fp(n,"Alternation",r),f=this.atn.decisionMap[u].decision,d=Je(Gk({maxLookahead:1,occurrence:r,prodType:"Alternation",rule:n}),p=>Je(p,m=>m[0]));if(boe(d,!1)&&!a){let p=Xr(d,(m,g,y)=>(Ae(g,v=>{v&&(m[v.tokenTypeIdx]=y,Ae(v.categoryMatches,x=>{m[x]=y}))}),m),{});return i?function(m){var g;let y=this.LA(1),v=p[y.tokenTypeIdx];if(m!==void 0&&v!==void 0){let x=(g=m[v])===null||g===void 0?void 0:g.GATE;if(x!==void 0&&x.call(this)===!1)return}return v}:function(){let m=this.LA(1);return p[m.tokenTypeIdx]}}else return i?function(p){let m=new cE,g=p===void 0?0:p.length;for(let v=0;vJe(p,m=>m[0]));if(boe(d)&&d[0][0]&&!a){let p=d[0],m=qr(p);if(m.length===1&&ur(m[0].categoryMatches)){let y=m[0].tokenTypeIdx;return function(){return this.LA(1).tokenTypeIdx===y}}else{let g=Xr(m,(y,v)=>(v!==void 0&&(y[v.tokenTypeIdx]=!0,Ae(v.categoryMatches,x=>{y[x]=!0})),y),{});return function(){let y=this.LA(1);return g[y.tokenTypeIdx]===!0}}}return function(){let p=eM.call(this,s,f,xoe,l);return typeof p=="object"?!1:p===0}}};o(boe,"isLL1Sequence");o(IBe,"initATNSimulator");o(eM,"adaptivePredict");o(OBe,"performLookahead");o(PBe,"computeLookaheadTarget");o(BBe,"reportLookaheadAmbiguity");o(FBe,"buildAmbiguityError");o($Be,"getProductionDslName");o(zBe,"buildAdaptivePredictError");o(GBe,"getExistingTargetState");o(VBe,"computeReachSet");o(UBe,"getReachableTarget");o(HBe,"getUniqueAlt");o(Toe,"newDFAState");o(woe,"addDFAEdge");o(koe,"addDFAState");o(WBe,"computeStartState");o(uE,"closure");o(qBe,"getEpsilonTarget");o(YBe,"hasConfigInRuleStopState");o(XBe,"allConfigsInRuleStopStates");o(jBe,"hasConflictTerminatingPrediction");o(KBe,"getConflictingAltSets");o(QBe,"hasConflictingAltSet");o(ZBe,"hasStateAssociatedWithOneAlt")});var Soe=N(()=>{"use strict";Eoe()});var Coe,tM,Aoe,hE,jr,Pr,fE,_oe,rM,Doe,Loe,Roe,Noe,nM,Moe,Ioe,Ooe,dE,r1,n1,iM,i1,Poe,aM,sM,oM,lM,cM,Boe,Foe,uM,$oe,hM,Ex,zoe,Goe,Voe,Uoe,Hoe,Woe,qoe,Yoe,pE,Xoe,joe,Koe,Qoe,Zoe,Joe,ele,tle,rle,nle,ile,mE,ale,sle,ole,lle,cle,ule,hle,fle,dle,ple,mle,gle,yle,fM,dM,vle,xle,ble,wle,Tle,kle,Ele,Sle,Cle,pM,Fe,mM=N(()=>{"use strict";(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(Coe||(Coe={}));(function(t){function e(r){return typeof r=="string"}o(e,"is"),t.is=e})(tM||(tM={}));(function(t){t.MIN_VALUE=-2147483648,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(Aoe||(Aoe={}));(function(t){t.MIN_VALUE=0,t.MAX_VALUE=2147483647;function e(r){return typeof r=="number"&&t.MIN_VALUE<=r&&r<=t.MAX_VALUE}o(e,"is"),t.is=e})(hE||(hE={}));(function(t){function e(n,i){return n===Number.MAX_VALUE&&(n=hE.MAX_VALUE),i===Number.MAX_VALUE&&(i=hE.MAX_VALUE),{line:n,character:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Fe.uinteger(i.line)&&Fe.uinteger(i.character)}o(r,"is"),t.is=r})(jr||(jr={}));(function(t){function e(n,i,a,s){if(Fe.uinteger(n)&&Fe.uinteger(i)&&Fe.uinteger(a)&&Fe.uinteger(s))return{start:jr.create(n,i),end:jr.create(a,s)};if(jr.is(n)&&jr.is(i))return{start:n,end:i};throw new Error(`Range#create called with invalid arguments[${n}, ${i}, ${a}, ${s}]`)}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&jr.is(i.start)&&jr.is(i.end)}o(r,"is"),t.is=r})(Pr||(Pr={}));(function(t){function e(n,i){return{uri:n,range:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Pr.is(i.range)&&(Fe.string(i.uri)||Fe.undefined(i.uri))}o(r,"is"),t.is=r})(fE||(fE={}));(function(t){function e(n,i,a,s){return{targetUri:n,targetRange:i,targetSelectionRange:a,originSelectionRange:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Pr.is(i.targetRange)&&Fe.string(i.targetUri)&&Pr.is(i.targetSelectionRange)&&(Pr.is(i.originSelectionRange)||Fe.undefined(i.originSelectionRange))}o(r,"is"),t.is=r})(_oe||(_oe={}));(function(t){function e(n,i,a,s){return{red:n,green:i,blue:a,alpha:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Fe.numberRange(i.red,0,1)&&Fe.numberRange(i.green,0,1)&&Fe.numberRange(i.blue,0,1)&&Fe.numberRange(i.alpha,0,1)}o(r,"is"),t.is=r})(rM||(rM={}));(function(t){function e(n,i){return{range:n,color:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Pr.is(i.range)&&rM.is(i.color)}o(r,"is"),t.is=r})(Doe||(Doe={}));(function(t){function e(n,i,a){return{label:n,textEdit:i,additionalTextEdits:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Fe.string(i.label)&&(Fe.undefined(i.textEdit)||n1.is(i))&&(Fe.undefined(i.additionalTextEdits)||Fe.typedArray(i.additionalTextEdits,n1.is))}o(r,"is"),t.is=r})(Loe||(Loe={}));(function(t){t.Comment="comment",t.Imports="imports",t.Region="region"})(Roe||(Roe={}));(function(t){function e(n,i,a,s,l,u){let h={startLine:n,endLine:i};return Fe.defined(a)&&(h.startCharacter=a),Fe.defined(s)&&(h.endCharacter=s),Fe.defined(l)&&(h.kind=l),Fe.defined(u)&&(h.collapsedText=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Fe.uinteger(i.startLine)&&Fe.uinteger(i.startLine)&&(Fe.undefined(i.startCharacter)||Fe.uinteger(i.startCharacter))&&(Fe.undefined(i.endCharacter)||Fe.uinteger(i.endCharacter))&&(Fe.undefined(i.kind)||Fe.string(i.kind))}o(r,"is"),t.is=r})(Noe||(Noe={}));(function(t){function e(n,i){return{location:n,message:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&fE.is(i.location)&&Fe.string(i.message)}o(r,"is"),t.is=r})(nM||(nM={}));(function(t){t.Error=1,t.Warning=2,t.Information=3,t.Hint=4})(Moe||(Moe={}));(function(t){t.Unnecessary=1,t.Deprecated=2})(Ioe||(Ioe={}));(function(t){function e(r){let n=r;return Fe.objectLiteral(n)&&Fe.string(n.href)}o(e,"is"),t.is=e})(Ooe||(Ooe={}));(function(t){function e(n,i,a,s,l,u){let h={range:n,message:i};return Fe.defined(a)&&(h.severity=a),Fe.defined(s)&&(h.code=s),Fe.defined(l)&&(h.source=l),Fe.defined(u)&&(h.relatedInformation=u),h}o(e,"create"),t.create=e;function r(n){var i;let a=n;return Fe.defined(a)&&Pr.is(a.range)&&Fe.string(a.message)&&(Fe.number(a.severity)||Fe.undefined(a.severity))&&(Fe.integer(a.code)||Fe.string(a.code)||Fe.undefined(a.code))&&(Fe.undefined(a.codeDescription)||Fe.string((i=a.codeDescription)===null||i===void 0?void 0:i.href))&&(Fe.string(a.source)||Fe.undefined(a.source))&&(Fe.undefined(a.relatedInformation)||Fe.typedArray(a.relatedInformation,nM.is))}o(r,"is"),t.is=r})(dE||(dE={}));(function(t){function e(n,i,...a){let s={title:n,command:i};return Fe.defined(a)&&a.length>0&&(s.arguments=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Fe.string(i.title)&&Fe.string(i.command)}o(r,"is"),t.is=r})(r1||(r1={}));(function(t){function e(a,s){return{range:a,newText:s}}o(e,"replace"),t.replace=e;function r(a,s){return{range:{start:a,end:a},newText:s}}o(r,"insert"),t.insert=r;function n(a){return{range:a,newText:""}}o(n,"del"),t.del=n;function i(a){let s=a;return Fe.objectLiteral(s)&&Fe.string(s.newText)&&Pr.is(s.range)}o(i,"is"),t.is=i})(n1||(n1={}));(function(t){function e(n,i,a){let s={label:n};return i!==void 0&&(s.needsConfirmation=i),a!==void 0&&(s.description=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Fe.string(i.label)&&(Fe.boolean(i.needsConfirmation)||i.needsConfirmation===void 0)&&(Fe.string(i.description)||i.description===void 0)}o(r,"is"),t.is=r})(iM||(iM={}));(function(t){function e(r){let n=r;return Fe.string(n)}o(e,"is"),t.is=e})(i1||(i1={}));(function(t){function e(a,s,l){return{range:a,newText:s,annotationId:l}}o(e,"replace"),t.replace=e;function r(a,s,l){return{range:{start:a,end:a},newText:s,annotationId:l}}o(r,"insert"),t.insert=r;function n(a,s){return{range:a,newText:"",annotationId:s}}o(n,"del"),t.del=n;function i(a){let s=a;return n1.is(s)&&(iM.is(s.annotationId)||i1.is(s.annotationId))}o(i,"is"),t.is=i})(Poe||(Poe={}));(function(t){function e(n,i){return{textDocument:n,edits:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&uM.is(i.textDocument)&&Array.isArray(i.edits)}o(r,"is"),t.is=r})(aM||(aM={}));(function(t){function e(n,i,a){let s={kind:"create",uri:n};return i!==void 0&&(i.overwrite!==void 0||i.ignoreIfExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="create"&&Fe.string(i.uri)&&(i.options===void 0||(i.options.overwrite===void 0||Fe.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Fe.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||i1.is(i.annotationId))}o(r,"is"),t.is=r})(sM||(sM={}));(function(t){function e(n,i,a,s){let l={kind:"rename",oldUri:n,newUri:i};return a!==void 0&&(a.overwrite!==void 0||a.ignoreIfExists!==void 0)&&(l.options=a),s!==void 0&&(l.annotationId=s),l}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="rename"&&Fe.string(i.oldUri)&&Fe.string(i.newUri)&&(i.options===void 0||(i.options.overwrite===void 0||Fe.boolean(i.options.overwrite))&&(i.options.ignoreIfExists===void 0||Fe.boolean(i.options.ignoreIfExists)))&&(i.annotationId===void 0||i1.is(i.annotationId))}o(r,"is"),t.is=r})(oM||(oM={}));(function(t){function e(n,i,a){let s={kind:"delete",uri:n};return i!==void 0&&(i.recursive!==void 0||i.ignoreIfNotExists!==void 0)&&(s.options=i),a!==void 0&&(s.annotationId=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&i.kind==="delete"&&Fe.string(i.uri)&&(i.options===void 0||(i.options.recursive===void 0||Fe.boolean(i.options.recursive))&&(i.options.ignoreIfNotExists===void 0||Fe.boolean(i.options.ignoreIfNotExists)))&&(i.annotationId===void 0||i1.is(i.annotationId))}o(r,"is"),t.is=r})(lM||(lM={}));(function(t){function e(r){let n=r;return n&&(n.changes!==void 0||n.documentChanges!==void 0)&&(n.documentChanges===void 0||n.documentChanges.every(i=>Fe.string(i.kind)?sM.is(i)||oM.is(i)||lM.is(i):aM.is(i)))}o(e,"is"),t.is=e})(cM||(cM={}));(function(t){function e(n){return{uri:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Fe.string(i.uri)}o(r,"is"),t.is=r})(Boe||(Boe={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Fe.string(i.uri)&&Fe.integer(i.version)}o(r,"is"),t.is=r})(Foe||(Foe={}));(function(t){function e(n,i){return{uri:n,version:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Fe.string(i.uri)&&(i.version===null||Fe.integer(i.version))}o(r,"is"),t.is=r})(uM||(uM={}));(function(t){function e(n,i,a,s){return{uri:n,languageId:i,version:a,text:s}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Fe.string(i.uri)&&Fe.string(i.languageId)&&Fe.integer(i.version)&&Fe.string(i.text)}o(r,"is"),t.is=r})($oe||($oe={}));(function(t){t.PlainText="plaintext",t.Markdown="markdown";function e(r){let n=r;return n===t.PlainText||n===t.Markdown}o(e,"is"),t.is=e})(hM||(hM={}));(function(t){function e(r){let n=r;return Fe.objectLiteral(r)&&hM.is(n.kind)&&Fe.string(n.value)}o(e,"is"),t.is=e})(Ex||(Ex={}));(function(t){t.Text=1,t.Method=2,t.Function=3,t.Constructor=4,t.Field=5,t.Variable=6,t.Class=7,t.Interface=8,t.Module=9,t.Property=10,t.Unit=11,t.Value=12,t.Enum=13,t.Keyword=14,t.Snippet=15,t.Color=16,t.File=17,t.Reference=18,t.Folder=19,t.EnumMember=20,t.Constant=21,t.Struct=22,t.Event=23,t.Operator=24,t.TypeParameter=25})(zoe||(zoe={}));(function(t){t.PlainText=1,t.Snippet=2})(Goe||(Goe={}));(function(t){t.Deprecated=1})(Voe||(Voe={}));(function(t){function e(n,i,a){return{newText:n,insert:i,replace:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Fe.string(i.newText)&&Pr.is(i.insert)&&Pr.is(i.replace)}o(r,"is"),t.is=r})(Uoe||(Uoe={}));(function(t){t.asIs=1,t.adjustIndentation=2})(Hoe||(Hoe={}));(function(t){function e(r){let n=r;return n&&(Fe.string(n.detail)||n.detail===void 0)&&(Fe.string(n.description)||n.description===void 0)}o(e,"is"),t.is=e})(Woe||(Woe={}));(function(t){function e(r){return{label:r}}o(e,"create"),t.create=e})(qoe||(qoe={}));(function(t){function e(r,n){return{items:r||[],isIncomplete:!!n}}o(e,"create"),t.create=e})(Yoe||(Yoe={}));(function(t){function e(n){return n.replace(/[\\`*_{}[\]()#+\-.!]/g,"\\$&")}o(e,"fromPlainText"),t.fromPlainText=e;function r(n){let i=n;return Fe.string(i)||Fe.objectLiteral(i)&&Fe.string(i.language)&&Fe.string(i.value)}o(r,"is"),t.is=r})(pE||(pE={}));(function(t){function e(r){let n=r;return!!n&&Fe.objectLiteral(n)&&(Ex.is(n.contents)||pE.is(n.contents)||Fe.typedArray(n.contents,pE.is))&&(r.range===void 0||Pr.is(r.range))}o(e,"is"),t.is=e})(Xoe||(Xoe={}));(function(t){function e(r,n){return n?{label:r,documentation:n}:{label:r}}o(e,"create"),t.create=e})(joe||(joe={}));(function(t){function e(r,n,...i){let a={label:r};return Fe.defined(n)&&(a.documentation=n),Fe.defined(i)?a.parameters=i:a.parameters=[],a}o(e,"create"),t.create=e})(Koe||(Koe={}));(function(t){t.Text=1,t.Read=2,t.Write=3})(Qoe||(Qoe={}));(function(t){function e(r,n){let i={range:r};return Fe.number(n)&&(i.kind=n),i}o(e,"create"),t.create=e})(Zoe||(Zoe={}));(function(t){t.File=1,t.Module=2,t.Namespace=3,t.Package=4,t.Class=5,t.Method=6,t.Property=7,t.Field=8,t.Constructor=9,t.Enum=10,t.Interface=11,t.Function=12,t.Variable=13,t.Constant=14,t.String=15,t.Number=16,t.Boolean=17,t.Array=18,t.Object=19,t.Key=20,t.Null=21,t.EnumMember=22,t.Struct=23,t.Event=24,t.Operator=25,t.TypeParameter=26})(Joe||(Joe={}));(function(t){t.Deprecated=1})(ele||(ele={}));(function(t){function e(r,n,i,a,s){let l={name:r,kind:n,location:{uri:a,range:i}};return s&&(l.containerName=s),l}o(e,"create"),t.create=e})(tle||(tle={}));(function(t){function e(r,n,i,a){return a!==void 0?{name:r,kind:n,location:{uri:i,range:a}}:{name:r,kind:n,location:{uri:i}}}o(e,"create"),t.create=e})(rle||(rle={}));(function(t){function e(n,i,a,s,l,u){let h={name:n,detail:i,kind:a,range:s,selectionRange:l};return u!==void 0&&(h.children=u),h}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Fe.string(i.name)&&Fe.number(i.kind)&&Pr.is(i.range)&&Pr.is(i.selectionRange)&&(i.detail===void 0||Fe.string(i.detail))&&(i.deprecated===void 0||Fe.boolean(i.deprecated))&&(i.children===void 0||Array.isArray(i.children))&&(i.tags===void 0||Array.isArray(i.tags))}o(r,"is"),t.is=r})(nle||(nle={}));(function(t){t.Empty="",t.QuickFix="quickfix",t.Refactor="refactor",t.RefactorExtract="refactor.extract",t.RefactorInline="refactor.inline",t.RefactorRewrite="refactor.rewrite",t.Source="source",t.SourceOrganizeImports="source.organizeImports",t.SourceFixAll="source.fixAll"})(ile||(ile={}));(function(t){t.Invoked=1,t.Automatic=2})(mE||(mE={}));(function(t){function e(n,i,a){let s={diagnostics:n};return i!=null&&(s.only=i),a!=null&&(s.triggerKind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Fe.typedArray(i.diagnostics,dE.is)&&(i.only===void 0||Fe.typedArray(i.only,Fe.string))&&(i.triggerKind===void 0||i.triggerKind===mE.Invoked||i.triggerKind===mE.Automatic)}o(r,"is"),t.is=r})(ale||(ale={}));(function(t){function e(n,i,a){let s={title:n},l=!0;return typeof i=="string"?(l=!1,s.kind=i):r1.is(i)?s.command=i:s.edit=i,l&&a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return i&&Fe.string(i.title)&&(i.diagnostics===void 0||Fe.typedArray(i.diagnostics,dE.is))&&(i.kind===void 0||Fe.string(i.kind))&&(i.edit!==void 0||i.command!==void 0)&&(i.command===void 0||r1.is(i.command))&&(i.isPreferred===void 0||Fe.boolean(i.isPreferred))&&(i.edit===void 0||cM.is(i.edit))}o(r,"is"),t.is=r})(sle||(sle={}));(function(t){function e(n,i){let a={range:n};return Fe.defined(i)&&(a.data=i),a}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Pr.is(i.range)&&(Fe.undefined(i.command)||r1.is(i.command))}o(r,"is"),t.is=r})(ole||(ole={}));(function(t){function e(n,i){return{tabSize:n,insertSpaces:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Fe.uinteger(i.tabSize)&&Fe.boolean(i.insertSpaces)}o(r,"is"),t.is=r})(lle||(lle={}));(function(t){function e(n,i,a){return{range:n,target:i,data:a}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Pr.is(i.range)&&(Fe.undefined(i.target)||Fe.string(i.target))}o(r,"is"),t.is=r})(cle||(cle={}));(function(t){function e(n,i){return{range:n,parent:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&Pr.is(i.range)&&(i.parent===void 0||t.is(i.parent))}o(r,"is"),t.is=r})(ule||(ule={}));(function(t){t.namespace="namespace",t.type="type",t.class="class",t.enum="enum",t.interface="interface",t.struct="struct",t.typeParameter="typeParameter",t.parameter="parameter",t.variable="variable",t.property="property",t.enumMember="enumMember",t.event="event",t.function="function",t.method="method",t.macro="macro",t.keyword="keyword",t.modifier="modifier",t.comment="comment",t.string="string",t.number="number",t.regexp="regexp",t.operator="operator",t.decorator="decorator"})(hle||(hle={}));(function(t){t.declaration="declaration",t.definition="definition",t.readonly="readonly",t.static="static",t.deprecated="deprecated",t.abstract="abstract",t.async="async",t.modification="modification",t.documentation="documentation",t.defaultLibrary="defaultLibrary"})(fle||(fle={}));(function(t){function e(r){let n=r;return Fe.objectLiteral(n)&&(n.resultId===void 0||typeof n.resultId=="string")&&Array.isArray(n.data)&&(n.data.length===0||typeof n.data[0]=="number")}o(e,"is"),t.is=e})(dle||(dle={}));(function(t){function e(n,i){return{range:n,text:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Pr.is(i.range)&&Fe.string(i.text)}o(r,"is"),t.is=r})(ple||(ple={}));(function(t){function e(n,i,a){return{range:n,variableName:i,caseSensitiveLookup:a}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Pr.is(i.range)&&Fe.boolean(i.caseSensitiveLookup)&&(Fe.string(i.variableName)||i.variableName===void 0)}o(r,"is"),t.is=r})(mle||(mle={}));(function(t){function e(n,i){return{range:n,expression:i}}o(e,"create"),t.create=e;function r(n){let i=n;return i!=null&&Pr.is(i.range)&&(Fe.string(i.expression)||i.expression===void 0)}o(r,"is"),t.is=r})(gle||(gle={}));(function(t){function e(n,i){return{frameId:n,stoppedLocation:i}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.defined(i)&&Pr.is(n.stoppedLocation)}o(r,"is"),t.is=r})(yle||(yle={}));(function(t){t.Type=1,t.Parameter=2;function e(r){return r===1||r===2}o(e,"is"),t.is=e})(fM||(fM={}));(function(t){function e(n){return{value:n}}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&(i.tooltip===void 0||Fe.string(i.tooltip)||Ex.is(i.tooltip))&&(i.location===void 0||fE.is(i.location))&&(i.command===void 0||r1.is(i.command))}o(r,"is"),t.is=r})(dM||(dM={}));(function(t){function e(n,i,a){let s={position:n,label:i};return a!==void 0&&(s.kind=a),s}o(e,"create"),t.create=e;function r(n){let i=n;return Fe.objectLiteral(i)&&jr.is(i.position)&&(Fe.string(i.label)||Fe.typedArray(i.label,dM.is))&&(i.kind===void 0||fM.is(i.kind))&&i.textEdits===void 0||Fe.typedArray(i.textEdits,n1.is)&&(i.tooltip===void 0||Fe.string(i.tooltip)||Ex.is(i.tooltip))&&(i.paddingLeft===void 0||Fe.boolean(i.paddingLeft))&&(i.paddingRight===void 0||Fe.boolean(i.paddingRight))}o(r,"is"),t.is=r})(vle||(vle={}));(function(t){function e(r){return{kind:"snippet",value:r}}o(e,"createSnippet"),t.createSnippet=e})(xle||(xle={}));(function(t){function e(r,n,i,a){return{insertText:r,filterText:n,range:i,command:a}}o(e,"create"),t.create=e})(ble||(ble={}));(function(t){function e(r){return{items:r}}o(e,"create"),t.create=e})(wle||(wle={}));(function(t){t.Invoked=0,t.Automatic=1})(Tle||(Tle={}));(function(t){function e(r,n){return{range:r,text:n}}o(e,"create"),t.create=e})(kle||(kle={}));(function(t){function e(r,n){return{triggerKind:r,selectedCompletionInfo:n}}o(e,"create"),t.create=e})(Ele||(Ele={}));(function(t){function e(r){let n=r;return Fe.objectLiteral(n)&&tM.is(n.uri)&&Fe.string(n.name)}o(e,"is"),t.is=e})(Sle||(Sle={}));(function(t){function e(a,s,l,u){return new pM(a,s,l,u)}o(e,"create"),t.create=e;function r(a){let s=a;return!!(Fe.defined(s)&&Fe.string(s.uri)&&(Fe.undefined(s.languageId)||Fe.string(s.languageId))&&Fe.uinteger(s.lineCount)&&Fe.func(s.getText)&&Fe.func(s.positionAt)&&Fe.func(s.offsetAt))}o(r,"is"),t.is=r;function n(a,s){let l=a.getText(),u=i(s,(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),h=l.length;for(let f=u.length-1;f>=0;f--){let d=u[f],p=a.offsetAt(d.range.start),m=a.offsetAt(d.range.end);if(m<=h)l=l.substring(0,p)+d.newText+l.substring(m,l.length);else throw new Error("Overlapping edit");h=p}return l}o(n,"applyEdits"),t.applyEdits=n;function i(a,s){if(a.length<=1)return a;let l=a.length/2|0,u=a.slice(0,l),h=a.slice(l);i(u,s),i(h,s);let f=0,d=0,p=0;for(;f0&&e.push(r.length),this._lineOffsets=e}return this._lineOffsets}positionAt(e){e=Math.max(Math.min(e,this._content.length),0);let r=this.getLineOffsets(),n=0,i=r.length;if(i===0)return jr.create(0,e);for(;ne?i=s:n=s+1}let a=n-1;return jr.create(a,e-r[a])}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line],i=e.line+1"u"}o(n,"undefined"),t.undefined=n;function i(m){return m===!0||m===!1}o(i,"boolean"),t.boolean=i;function a(m){return e.call(m)==="[object String]"}o(a,"string"),t.string=a;function s(m){return e.call(m)==="[object Number]"}o(s,"number"),t.number=s;function l(m,g,y){return e.call(m)==="[object Number]"&&g<=m&&m<=y}o(l,"numberRange"),t.numberRange=l;function u(m){return e.call(m)==="[object Number]"&&-2147483648<=m&&m<=2147483647}o(u,"integer"),t.integer=u;function h(m){return e.call(m)==="[object Number]"&&0<=m&&m<=2147483647}o(h,"uinteger"),t.uinteger=h;function f(m){return e.call(m)==="[object Function]"}o(f,"func"),t.func=f;function d(m){return m!==null&&typeof m=="object"}o(d,"objectLiteral"),t.objectLiteral=d;function p(m,g){return Array.isArray(m)&&m.every(g)}o(p,"typedArray"),t.typedArray=p})(Fe||(Fe={}))});var Sx,Cx,pp,mp,gM,a1,gE=N(()=>{"use strict";mM();Nl();Sx=class{static{o(this,"CstNodeBuilder")}constructor(){this.nodeStack=[]}get current(){var e;return(e=this.nodeStack[this.nodeStack.length-1])!==null&&e!==void 0?e:this.rootNode}buildRootNode(e){return this.rootNode=new a1(e),this.rootNode.root=this.rootNode,this.nodeStack=[this.rootNode],this.rootNode}buildCompositeNode(e){let r=new mp;return r.grammarSource=e,r.root=this.rootNode,this.current.content.push(r),this.nodeStack.push(r),r}buildLeafNode(e,r){let n=new pp(e.startOffset,e.image.length,Gm(e),e.tokenType,!r);return n.grammarSource=r,n.root=this.rootNode,this.current.content.push(n),n}removeNode(e){let r=e.container;if(r){let n=r.content.indexOf(e);n>=0&&r.content.splice(n,1)}}addHiddenNodes(e){let r=[];for(let a of e){let s=new pp(a.startOffset,a.image.length,Gm(a),a.tokenType,!0);s.root=this.rootNode,r.push(s)}let n=this.current,i=!1;if(n.content.length>0){n.content.push(...r);return}for(;n.container;){let a=n.container.content.indexOf(n);if(a>0){n.container.content.splice(a,0,...r),i=!0;break}n=n.container}i||this.rootNode.content.unshift(...r)}construct(e){let r=this.current;typeof e.$type=="string"&&(this.current.astNode=e),e.$cstNode=r;let n=this.nodeStack.pop();n?.content.length===0&&this.removeNode(n)}},Cx=class{static{o(this,"AbstractCstNode")}get parent(){return this.container}get feature(){return this.grammarSource}get hidden(){return!1}get astNode(){var e,r;let n=typeof((e=this._astNode)===null||e===void 0?void 0:e.$type)=="string"?this._astNode:(r=this.container)===null||r===void 0?void 0:r.astNode;if(!n)throw new Error("This node has no associated AST element");return n}set astNode(e){this._astNode=e}get element(){return this.astNode}get text(){return this.root.fullText.substring(this.offset,this.end)}},pp=class extends Cx{static{o(this,"LeafCstNodeImpl")}get offset(){return this._offset}get length(){return this._length}get end(){return this._offset+this._length}get hidden(){return this._hidden}get tokenType(){return this._tokenType}get range(){return this._range}constructor(e,r,n,i,a=!1){super(),this._hidden=a,this._offset=e,this._tokenType=i,this._length=r,this._range=n}},mp=class extends Cx{static{o(this,"CompositeCstNodeImpl")}constructor(){super(...arguments),this.content=new gM(this)}get children(){return this.content}get offset(){var e,r;return(r=(e=this.firstNonHiddenNode)===null||e===void 0?void 0:e.offset)!==null&&r!==void 0?r:0}get length(){return this.end-this.offset}get end(){var e,r;return(r=(e=this.lastNonHiddenNode)===null||e===void 0?void 0:e.end)!==null&&r!==void 0?r:0}get range(){let e=this.firstNonHiddenNode,r=this.lastNonHiddenNode;if(e&&r){if(this._rangeCache===void 0){let{range:n}=e,{range:i}=r;this._rangeCache={start:n.start,end:i.end.line=0;e--){let r=this.content[e];if(!r.hidden)return r}return this.content[this.content.length-1]}},gM=class t extends Array{static{o(this,"CstNodeContainer")}constructor(e){super(),this.parent=e,Object.setPrototypeOf(this,t.prototype)}push(...e){return this.addParents(e),super.push(...e)}unshift(...e){return this.addParents(e),super.unshift(...e)}splice(e,r,...n){return this.addParents(n),super.splice(e,r,...n)}addParents(e){for(let r of e)r.container=this.parent}},a1=class extends mp{static{o(this,"RootCstNodeImpl")}get text(){return this._text.substring(this.offset,this.end)}get fullText(){return this._text}constructor(e){super(),this._text="",this._text=e??""}}});function yM(t){return t.$type===yE}var yE,Ale,_le,Ax,_x,vE,s1,Dx,JBe,vM,Lx=N(()=>{"use strict";cf();Soe();Rc();Ol();is();gE();yE=Symbol("Datatype");o(yM,"isDataTypeNode");Ale="\u200B",_le=o(t=>t.endsWith(Ale)?t:t+Ale,"withRuleSuffix"),Ax=class{static{o(this,"AbstractLangiumParser")}constructor(e){this._unorderedGroups=new Map,this.allRules=new Map,this.lexer=e.parser.Lexer;let r=this.lexer.definition,n=e.LanguageMetaData.mode==="production";this.wrapper=new vM(r,Object.assign(Object.assign({},e.parser.ParserConfig),{skipValidations:n,errorMessageProvider:e.parser.ParserErrorMessageProvider}))}alternatives(e,r){this.wrapper.wrapOr(e,r)}optional(e,r){this.wrapper.wrapOption(e,r)}many(e,r){this.wrapper.wrapMany(e,r)}atLeastOne(e,r){this.wrapper.wrapAtLeastOne(e,r)}getRule(e){return this.allRules.get(e)}isRecording(){return this.wrapper.IS_RECORDING}get unorderedGroups(){return this._unorderedGroups}getRuleStack(){return this.wrapper.RULE_STACK}finalize(){this.wrapper.wrapSelfAnalysis()}},_x=class extends Ax{static{o(this,"LangiumParser")}get current(){return this.stack[this.stack.length-1]}constructor(e){super(e),this.nodeBuilder=new Sx,this.stack=[],this.assignmentMap=new Map,this.linker=e.references.Linker,this.converter=e.parser.ValueConverter,this.astReflection=e.shared.AstReflection}rule(e,r){let n=this.computeRuleType(e),i=this.wrapper.DEFINE_RULE(_le(e.name),this.startImplementation(n,r).bind(this));return this.allRules.set(e.name,i),e.entry&&(this.mainRule=i),i}computeRuleType(e){if(!e.fragment){if(Z2(e))return yE;{let r=Rg(e);return r??e.name}}}parse(e,r={}){this.nodeBuilder.buildRootNode(e);let n=this.lexerResult=this.lexer.tokenize(e);this.wrapper.input=n.tokens;let i=r.rule?this.allRules.get(r.rule):this.mainRule;if(!i)throw new Error(r.rule?`No rule found with name '${r.rule}'`:"No main rule available.");let a=i.call(this.wrapper,{});return this.nodeBuilder.addHiddenNodes(n.hidden),this.unorderedGroups.clear(),this.lexerResult=void 0,{value:a,lexerErrors:n.errors,lexerReport:n.report,parserErrors:this.wrapper.errors}}startImplementation(e,r){return n=>{let i=!this.isRecording()&&e!==void 0;if(i){let s={$type:e};this.stack.push(s),e===yE&&(s.value="")}let a;try{a=r(n)}catch{a=void 0}return a===void 0&&i&&(a=this.construct()),a}}extractHiddenTokens(e){let r=this.lexerResult.hidden;if(!r.length)return[];let n=e.startOffset;for(let i=0;in)return r.splice(0,i);return r.splice(0,r.length)}consume(e,r,n){let i=this.wrapper.wrapConsume(e,r);if(!this.isRecording()&&this.isValidToken(i)){let a=this.extractHiddenTokens(i);this.nodeBuilder.addHiddenNodes(a);let s=this.nodeBuilder.buildLeafNode(i,n),{assignment:l,isCrossRef:u}=this.getAssignment(n),h=this.current;if(l){let f=Ho(n)?i.image:this.converter.convert(i.image,s);this.assign(l.operator,l.feature,f,s,u)}else if(yM(h)){let f=i.image;Ho(n)||(f=this.converter.convert(f,s).toString()),h.value+=f}}}isValidToken(e){return!e.isInsertedInRecovery&&!isNaN(e.startOffset)&&typeof e.endOffset=="number"&&!isNaN(e.endOffset)}subrule(e,r,n,i,a){let s;!this.isRecording()&&!n&&(s=this.nodeBuilder.buildCompositeNode(i));let l=this.wrapper.wrapSubrule(e,r,a);!this.isRecording()&&s&&s.length>0&&this.performSubruleAssignment(l,i,s)}performSubruleAssignment(e,r,n){let{assignment:i,isCrossRef:a}=this.getAssignment(r);if(i)this.assign(i.operator,i.feature,e,n,a);else if(!i){let s=this.current;if(yM(s))s.value+=e.toString();else if(typeof e=="object"&&e){let u=this.assignWithoutOverride(e,s);this.stack.pop(),this.stack.push(u)}}}action(e,r){if(!this.isRecording()){let n=this.current;if(r.feature&&r.operator){n=this.construct(),this.nodeBuilder.removeNode(n.$cstNode),this.nodeBuilder.buildCompositeNode(r).content.push(n.$cstNode);let a={$type:e};this.stack.push(a),this.assign(r.operator,r.feature,n,n.$cstNode,!1)}else n.$type=e}}construct(){if(this.isRecording())return;let e=this.current;return vk(e),this.nodeBuilder.construct(e),this.stack.pop(),yM(e)?this.converter.convert(e.value,e.$cstNode):(XR(this.astReflection,e),e)}getAssignment(e){if(!this.assignmentMap.has(e)){let r=tp(e,Ml);this.assignmentMap.set(e,{assignment:r,isCrossRef:r?ep(r.terminal):!1})}return this.assignmentMap.get(e)}assign(e,r,n,i,a){let s=this.current,l;switch(a&&typeof n=="string"?l=this.linker.buildReference(s,r,i,n):l=n,e){case"=":{s[r]=l;break}case"?=":{s[r]=!0;break}case"+=":Array.isArray(s[r])||(s[r]=[]),s[r].push(l)}}assignWithoutOverride(e,r){for(let[i,a]of Object.entries(r)){let s=e[i];s===void 0?e[i]=a:Array.isArray(s)&&Array.isArray(a)&&(a.push(...s),e[i]=a)}let n=e.$cstNode;return n&&(n.astNode=void 0,e.$cstNode=void 0),e}get definitionErrors(){return this.wrapper.definitionErrors}},vE=class{static{o(this,"AbstractParserErrorMessageProvider")}buildMismatchTokenMessage(e){return zu.buildMismatchTokenMessage(e)}buildNotAllInputParsedMessage(e){return zu.buildNotAllInputParsedMessage(e)}buildNoViableAltMessage(e){return zu.buildNoViableAltMessage(e)}buildEarlyExitMessage(e){return zu.buildEarlyExitMessage(e)}},s1=class extends vE{static{o(this,"LangiumParserErrorMessageProvider")}buildMismatchTokenMessage({expected:e,actual:r}){return`Expecting ${e.LABEL?"`"+e.LABEL+"`":e.name.endsWith(":KW")?`keyword '${e.name.substring(0,e.name.length-3)}'`:`token of type '${e.name}'`} but found \`${r.image}\`.`}buildNotAllInputParsedMessage({firstRedundant:e}){return`Expecting end of file but found \`${e.image}\`.`}},Dx=class extends Ax{static{o(this,"LangiumCompletionParser")}constructor(){super(...arguments),this.tokens=[],this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}action(){}construct(){}parse(e){this.resetState();let r=this.lexer.tokenize(e,{mode:"partial"});return this.tokens=r.tokens,this.wrapper.input=[...this.tokens],this.mainRule.call(this.wrapper,{}),this.unorderedGroups.clear(),{tokens:this.tokens,elementStack:[...this.lastElementStack],tokenIndex:this.nextTokenIndex}}rule(e,r){let n=this.wrapper.DEFINE_RULE(_le(e.name),this.startImplementation(r).bind(this));return this.allRules.set(e.name,n),e.entry&&(this.mainRule=n),n}resetState(){this.elementStack=[],this.lastElementStack=[],this.nextTokenIndex=0,this.stackSize=0}startImplementation(e){return r=>{let n=this.keepStackSize();try{e(r)}finally{this.resetStackSize(n)}}}removeUnexpectedElements(){this.elementStack.splice(this.stackSize)}keepStackSize(){let e=this.elementStack.length;return this.stackSize=e,e}resetStackSize(e){this.removeUnexpectedElements(),this.stackSize=e}consume(e,r,n){this.wrapper.wrapConsume(e,r),this.isRecording()||(this.lastElementStack=[...this.elementStack,n],this.nextTokenIndex=this.currIdx+1)}subrule(e,r,n,i,a){this.before(i),this.wrapper.wrapSubrule(e,r,a),this.after(i)}before(e){this.isRecording()||this.elementStack.push(e)}after(e){if(!this.isRecording()){let r=this.elementStack.lastIndexOf(e);r>=0&&this.elementStack.splice(r)}}get currIdx(){return this.wrapper.currIdx}},JBe={recoveryEnabled:!0,nodeLocationTracking:"full",skipValidations:!0,errorMessageProvider:new s1},vM=class extends xx{static{o(this,"ChevrotainWrapper")}constructor(e,r){let n=r&&"maxLookahead"in r;super(e,Object.assign(Object.assign(Object.assign({},JBe),{lookaheadStrategy:n?new Gu({maxLookahead:r.maxLookahead}):new kx({logging:r.skipValidations?()=>{}:void 0})}),r))}get IS_RECORDING(){return this.RECORDING_PHASE}DEFINE_RULE(e,r){return this.RULE(e,r)}wrapSelfAnalysis(){this.performSelfAnalysis()}wrapConsume(e,r){return this.consume(e,r)}wrapSubrule(e,r,n){return this.subrule(e,r,{ARGS:[n]})}wrapOr(e,r){this.or(e,r)}wrapOption(e,r){this.option(e,r)}wrapMany(e,r){this.many(e,r)}wrapAtLeastOne(e,r){this.atLeastOne(e,r)}}});function Rx(t,e,r){return eFe({parser:e,tokens:r,ruleNames:new Map},t),e}function eFe(t,e){let r=K2(e,!1),n=en(e.rules).filter(Oa).filter(i=>r.has(i));for(let i of n){let a=Object.assign(Object.assign({},t),{consume:1,optional:1,subrule:1,many:1,or:1});t.parser.rule(i,gp(a,i.definition))}}function gp(t,e,r=!1){let n;if(Ho(e))n=oFe(t,e);else if(Mu(e))n=tFe(t,e);else if(Ml(e))n=gp(t,e.terminal);else if(ep(e))n=Dle(t,e);else if(Il(e))n=rFe(t,e);else if(mk(e))n=iFe(t,e);else if(yk(e))n=aFe(t,e);else if(sf(e))n=sFe(t,e);else if($R(e)){let i=t.consume++;n=o(()=>t.parser.consume(i,lo,e),"method")}else throw new Zd(e.$cstNode,`Unexpected element type: ${e.$type}`);return Lle(t,r?void 0:xE(e),n,e.cardinality)}function tFe(t,e){let r=J2(e);return()=>t.parser.action(r,e)}function rFe(t,e){let r=e.rule.ref;if(Oa(r)){let n=t.subrule++,i=r.fragment,a=e.arguments.length>0?nFe(r,e.arguments):()=>({});return s=>t.parser.subrule(n,Rle(t,r),i,e,a(s))}else if(so(r)){let n=t.consume++,i=xM(t,r.name);return()=>t.parser.consume(n,i,e)}else if(r)Lc(r);else throw new Zd(e.$cstNode,`Undefined rule: ${e.rule.$refText}`)}function nFe(t,e){let r=e.map(n=>Vu(n.value));return n=>{let i={};for(let a=0;ae(n)||r(n)}else if(RR(t)){let e=Vu(t.left),r=Vu(t.right);return n=>e(n)&&r(n)}else if(MR(t)){let e=Vu(t.value);return r=>!e(r)}else if(IR(t)){let e=t.parameter.ref.name;return r=>r!==void 0&&r[e]===!0}else if(LR(t)){let e=!!t.true;return()=>e}Lc(t)}function iFe(t,e){if(e.elements.length===1)return gp(t,e.elements[0]);{let r=[];for(let i of e.elements){let a={ALT:gp(t,i,!0)},s=xE(i);s&&(a.GATE=Vu(s)),r.push(a)}let n=t.or++;return i=>t.parser.alternatives(n,r.map(a=>{let s={ALT:o(()=>a.ALT(i),"ALT")},l=a.GATE;return l&&(s.GATE=()=>l(i)),s}))}}function aFe(t,e){if(e.elements.length===1)return gp(t,e.elements[0]);let r=[];for(let l of e.elements){let u={ALT:gp(t,l,!0)},h=xE(l);h&&(u.GATE=Vu(h)),r.push(u)}let n=t.or++,i=o((l,u)=>{let h=u.getRuleStack().join("-");return`uGroup_${l}_${h}`},"idFunc"),a=o(l=>t.parser.alternatives(n,r.map((u,h)=>{let f={ALT:o(()=>!0,"ALT")},d=t.parser;f.ALT=()=>{if(u.ALT(l),!d.isRecording()){let m=i(n,d);d.unorderedGroups.get(m)||d.unorderedGroups.set(m,[]);let g=d.unorderedGroups.get(m);typeof g?.[h]>"u"&&(g[h]=!0)}};let p=u.GATE;return p?f.GATE=()=>p(l):f.GATE=()=>{let m=d.unorderedGroups.get(i(n,d));return!m?.[h]},f})),"alternatives"),s=Lle(t,xE(e),a,"*");return l=>{s(l),t.parser.isRecording()||t.parser.unorderedGroups.delete(i(n,t.parser))}}function sFe(t,e){let r=e.elements.map(n=>gp(t,n));return n=>r.forEach(i=>i(n))}function xE(t){if(sf(t))return t.guardCondition}function Dle(t,e,r=e.terminal){if(r)if(Il(r)&&Oa(r.rule.ref)){let n=r.rule.ref,i=t.subrule++;return a=>t.parser.subrule(i,Rle(t,n),!1,e,a)}else if(Il(r)&&so(r.rule.ref)){let n=t.consume++,i=xM(t,r.rule.ref.name);return()=>t.parser.consume(n,i,e)}else if(Ho(r)){let n=t.consume++,i=xM(t,r.value);return()=>t.parser.consume(n,i,e)}else throw new Error("Could not build cross reference parser");else{if(!e.type.ref)throw new Error("Could not resolve reference to type: "+e.type.$refText);let n=kk(e.type.ref),i=n?.terminal;if(!i)throw new Error("Could not find name assignment for type: "+J2(e.type.ref));return Dle(t,e,i)}}function oFe(t,e){let r=t.consume++,n=t.tokens[e.value];if(!n)throw new Error("Could not find token for keyword: "+e.value);return()=>t.parser.consume(r,n,e)}function Lle(t,e,r,n){let i=e&&Vu(e);if(!n)if(i){let a=t.or++;return s=>t.parser.alternatives(a,[{ALT:o(()=>r(s),"ALT"),GATE:o(()=>i(s),"GATE")},{ALT:lE(),GATE:o(()=>!i(s),"GATE")}])}else return r;if(n==="*"){let a=t.many++;return s=>t.parser.many(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else if(n==="+"){let a=t.many++;if(i){let s=t.or++;return l=>t.parser.alternatives(s,[{ALT:o(()=>t.parser.atLeastOne(a,{DEF:o(()=>r(l),"DEF")}),"ALT"),GATE:o(()=>i(l),"GATE")},{ALT:lE(),GATE:o(()=>!i(l),"GATE")}])}else return s=>t.parser.atLeastOne(a,{DEF:o(()=>r(s),"DEF")})}else if(n==="?"){let a=t.optional++;return s=>t.parser.optional(a,{DEF:o(()=>r(s),"DEF"),GATE:i?()=>i(s):void 0})}else Lc(n)}function Rle(t,e){let r=lFe(t,e),n=t.parser.getRule(r);if(!n)throw new Error(`Rule "${r}" not found."`);return n}function lFe(t,e){if(Oa(e))return e.name;if(t.ruleNames.has(e))return t.ruleNames.get(e);{let r=e,n=r.$container,i=e.$type;for(;!Oa(n);)(sf(n)||mk(n)||yk(n))&&(i=n.elements.indexOf(r).toString()+":"+i),r=n,n=n.$container;return i=n.name+":"+i,t.ruleNames.set(e,i),i}}function xM(t,e){let r=t.tokens[e];if(!r)throw new Error(`Token "${e}" not found."`);return r}var bE=N(()=>{"use strict";cf();Rc();uk();Ps();Ol();o(Rx,"createParser");o(eFe,"buildRules");o(gp,"buildElement");o(tFe,"buildAction");o(rFe,"buildRuleCall");o(nFe,"buildRuleCallPredicate");o(Vu,"buildPredicate");o(iFe,"buildAlternatives");o(aFe,"buildUnorderedGroup");o(sFe,"buildGroup");o(xE,"getGuardCondition");o(Dle,"buildCrossReference");o(oFe,"buildKeyword");o(Lle,"wrap");o(Rle,"getRule");o(lFe,"getRuleName");o(xM,"getToken")});function bM(t){let e=t.Grammar,r=t.parser.Lexer,n=new Dx(t);return Rx(e,n,r.definition),n.finalize(),n}var wM=N(()=>{"use strict";Lx();bE();o(bM,"createCompletionParser")});function TM(t){let e=Nle(t);return e.finalize(),e}function Nle(t){let e=t.Grammar,r=t.parser.Lexer,n=new _x(t);return Rx(e,n,r.definition)}var kM=N(()=>{"use strict";Lx();bE();o(TM,"createLangiumParser");o(Nle,"prepareLangiumParser")});var Uu,wE=N(()=>{"use strict";cf();Rc();is();Ol();Lg();Ps();Uu=class{static{o(this,"DefaultTokenBuilder")}constructor(){this.diagnostics=[]}buildTokens(e,r){let n=en(K2(e,!1)),i=this.buildTerminalTokens(n),a=this.buildKeywordTokens(n,i,r);return i.forEach(s=>{let l=s.PATTERN;typeof l=="object"&&l&&"test"in l&&Dg(l)?a.unshift(s):a.push(s)}),a}flushLexingReport(e){return{diagnostics:this.popDiagnostics()}}popDiagnostics(){let e=[...this.diagnostics];return this.diagnostics=[],e}buildTerminalTokens(e){return e.filter(so).filter(r=>!r.fragment).map(r=>this.buildTerminalToken(r)).toArray()}buildTerminalToken(e){let r=Ng(e),n=this.requiresCustomPattern(r)?this.regexPatternFunction(r):r,i={name:e.name,PATTERN:n};return typeof n=="function"&&(i.LINE_BREAKS=!0),e.hidden&&(i.GROUP=Dg(r)?Xn.SKIPPED:"hidden"),i}requiresCustomPattern(e){return e.flags.includes("u")||e.flags.includes("s")?!0:!!(e.source.includes("?<=")||e.source.includes("?(r.lastIndex=i,r.exec(n))}buildKeywordTokens(e,r,n){return e.filter(Oa).flatMap(i=>Nc(i).filter(Ho)).distinct(i=>i.value).toArray().sort((i,a)=>a.value.length-i.value.length).map(i=>this.buildKeywordToken(i,r,!!n?.caseInsensitive))}buildKeywordToken(e,r,n){let i=this.buildKeywordPattern(e,n),a={name:e.value,PATTERN:i,LONGER_ALT:this.findLongerAlt(e,r)};return typeof i=="function"&&(a.LINE_BREAKS=!0),a}buildKeywordPattern(e,r){return r?new RegExp(tN(e.value)):e.value}findLongerAlt(e,r){return r.reduce((n,i)=>{let a=i?.PATTERN;return a?.source&&rN("^"+a.source+"$",e.value)&&n.push(i),n},[])}}});var yp,Oc,EM=N(()=>{"use strict";Rc();Ol();yp=class{static{o(this,"DefaultValueConverter")}convert(e,r){let n=r.grammarSource;if(ep(n)&&(n=aN(n)),Il(n)){let i=n.rule.ref;if(!i)throw new Error("This cst node was not parsed by a rule.");return this.runConverter(i,e,r)}return e}runConverter(e,r,n){var i;switch(e.name.toUpperCase()){case"INT":return Oc.convertInt(r);case"STRING":return Oc.convertString(r);case"ID":return Oc.convertID(r)}switch((i=fN(e))===null||i===void 0?void 0:i.toLowerCase()){case"number":return Oc.convertNumber(r);case"boolean":return Oc.convertBoolean(r);case"bigint":return Oc.convertBigint(r);case"date":return Oc.convertDate(r);default:return r}}};(function(t){function e(h){let f="";for(let d=1;d{"use strict";Object.defineProperty(AM,"__esModule",{value:!0});var SM;function CM(){if(SM===void 0)throw new Error("No runtime abstraction layer installed");return SM}o(CM,"RAL");(function(t){function e(r){if(r===void 0)throw new Error("No runtime abstraction layer provided");SM=r}o(e,"install"),t.install=e})(CM||(CM={}));AM.default=CM});var Ole=Mi(Ba=>{"use strict";Object.defineProperty(Ba,"__esModule",{value:!0});Ba.stringArray=Ba.array=Ba.func=Ba.error=Ba.number=Ba.string=Ba.boolean=void 0;function cFe(t){return t===!0||t===!1}o(cFe,"boolean");Ba.boolean=cFe;function Mle(t){return typeof t=="string"||t instanceof String}o(Mle,"string");Ba.string=Mle;function uFe(t){return typeof t=="number"||t instanceof Number}o(uFe,"number");Ba.number=uFe;function hFe(t){return t instanceof Error}o(hFe,"error");Ba.error=hFe;function fFe(t){return typeof t=="function"}o(fFe,"func");Ba.func=fFe;function Ile(t){return Array.isArray(t)}o(Ile,"array");Ba.array=Ile;function dFe(t){return Ile(t)&&t.every(e=>Mle(e))}o(dFe,"stringArray");Ba.stringArray=dFe});var LM=Mi(o1=>{"use strict";Object.defineProperty(o1,"__esModule",{value:!0});o1.Emitter=o1.Event=void 0;var pFe=_M(),Ple;(function(t){let e={dispose(){}};t.None=function(){return e}})(Ple||(o1.Event=Ple={}));var DM=class{static{o(this,"CallbackList")}add(e,r=null,n){this._callbacks||(this._callbacks=[],this._contexts=[]),this._callbacks.push(e),this._contexts.push(r),Array.isArray(n)&&n.push({dispose:o(()=>this.remove(e,r),"dispose")})}remove(e,r=null){if(!this._callbacks)return;let n=!1;for(let i=0,a=this._callbacks.length;i{this._callbacks||(this._callbacks=new DM),this._options&&this._options.onFirstListenerAdd&&this._callbacks.isEmpty()&&this._options.onFirstListenerAdd(this),this._callbacks.add(e,r);let i={dispose:o(()=>{this._callbacks&&(this._callbacks.remove(e,r),i.dispose=t._noop,this._options&&this._options.onLastListenerRemove&&this._callbacks.isEmpty()&&this._options.onLastListenerRemove(this))},"dispose")};return Array.isArray(n)&&n.push(i),i}),this._event}fire(e){this._callbacks&&this._callbacks.invoke.call(this._callbacks,e)}dispose(){this._callbacks&&(this._callbacks.dispose(),this._callbacks=void 0)}};o1.Emitter=TE;TE._noop=function(){}});var Ble=Mi(l1=>{"use strict";Object.defineProperty(l1,"__esModule",{value:!0});l1.CancellationTokenSource=l1.CancellationToken=void 0;var mFe=_M(),gFe=Ole(),RM=LM(),kE;(function(t){t.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:RM.Event.None}),t.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:RM.Event.None});function e(r){let n=r;return n&&(n===t.None||n===t.Cancelled||gFe.boolean(n.isCancellationRequested)&&!!n.onCancellationRequested)}o(e,"is"),t.is=e})(kE||(l1.CancellationToken=kE={}));var yFe=Object.freeze(function(t,e){let r=(0,mFe.default)().timer.setTimeout(t.bind(e),0);return{dispose(){r.dispose()}}}),EE=class{static{o(this,"MutableToken")}constructor(){this._isCancelled=!1}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?yFe:(this._emitter||(this._emitter=new RM.Emitter),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=void 0)}},NM=class{static{o(this,"CancellationTokenSource")}get token(){return this._token||(this._token=new EE),this._token}cancel(){this._token?this._token.cancel():this._token=kE.Cancelled}dispose(){this._token?this._token instanceof EE&&this._token.dispose():this._token=kE.None}};l1.CancellationTokenSource=NM});var yr={};var qo=N(()=>{"use strict";Sr(yr,Sa(Ble(),1))});function MM(){return new Promise(t=>{typeof setImmediate>"u"?setTimeout(t,0):setImmediate(t)})}function CE(){return SE=performance.now(),new yr.CancellationTokenSource}function $le(t){Fle=t}function Bc(t){return t===Pc}async function xi(t){if(t===yr.CancellationToken.None)return;let e=performance.now();if(e-SE>=Fle&&(SE=e,await MM(),SE=performance.now()),t.isCancellationRequested)throw Pc}var SE,Fle,Pc,cs,Yo=N(()=>{"use strict";qo();o(MM,"delayNextTick");SE=0,Fle=10;o(CE,"startCancelableOperation");o($le,"setInterruptionPeriod");Pc=Symbol("OperationCancelled");o(Bc,"isOperationCancelled");o(xi,"interruptAndCheck");cs=class{static{o(this,"Deferred")}constructor(){this.promise=new Promise((e,r)=>{this.resolve=n=>(e(n),this),this.reject=n=>(r(n),this)})}}});function IM(t,e){if(t.length<=1)return t;let r=t.length/2|0,n=t.slice(0,r),i=t.slice(r);IM(n,e),IM(i,e);let a=0,s=0,l=0;for(;ar.line||e.line===r.line&&e.character>r.character?{start:r,end:e}:t}function vFe(t){let e=Vle(t.range);return e!==t.range?{newText:t.newText,range:e}:t}var AE,c1,Ule=N(()=>{"use strict";AE=class t{static{o(this,"FullTextDocument")}constructor(e,r,n,i){this._uri=e,this._languageId=r,this._version=n,this._content=i,this._lineOffsets=void 0}get uri(){return this._uri}get languageId(){return this._languageId}get version(){return this._version}getText(e){if(e){let r=this.offsetAt(e.start),n=this.offsetAt(e.end);return this._content.substring(r,n)}return this._content}update(e,r){for(let n of e)if(t.isIncremental(n)){let i=Vle(n.range),a=this.offsetAt(i.start),s=this.offsetAt(i.end);this._content=this._content.substring(0,a)+n.text+this._content.substring(s,this._content.length);let l=Math.max(i.start.line,0),u=Math.max(i.end.line,0),h=this._lineOffsets,f=zle(n.text,!1,a);if(u-l===f.length)for(let p=0,m=f.length;pe?i=s:n=s+1}let a=n-1;return e=this.ensureBeforeEOL(e,r[a]),{line:a,character:e-r[a]}}offsetAt(e){let r=this.getLineOffsets();if(e.line>=r.length)return this._content.length;if(e.line<0)return 0;let n=r[e.line];if(e.character<=0)return n;let i=e.line+1r&&Gle(this._content.charCodeAt(e-1));)e--;return e}get lineCount(){return this.getLineOffsets().length}static isIncremental(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range!==void 0&&(r.rangeLength===void 0||typeof r.rangeLength=="number")}static isFull(e){let r=e;return r!=null&&typeof r.text=="string"&&r.range===void 0&&r.rangeLength===void 0}};(function(t){function e(i,a,s,l){return new AE(i,a,s,l)}o(e,"create"),t.create=e;function r(i,a,s){if(i instanceof AE)return i.update(a,s),i;throw new Error("TextDocument.update: document must be created by TextDocument.create")}o(r,"update"),t.update=r;function n(i,a){let s=i.getText(),l=IM(a.map(vFe),(f,d)=>{let p=f.range.start.line-d.range.start.line;return p===0?f.range.start.character-d.range.start.character:p}),u=0,h=[];for(let f of l){let d=i.offsetAt(f.range.start);if(du&&h.push(s.substring(u,d)),f.newText.length&&h.push(f.newText),u=i.offsetAt(f.range.end)}return h.push(s.substr(u)),h.join("")}o(n,"applyEdits"),t.applyEdits=n})(c1||(c1={}));o(IM,"mergeSort");o(zle,"computeLineOffsets");o(Gle,"isEOL");o(Vle,"getWellformedRange");o(vFe,"getWellformedEdit")});var Hle,us,u1,OM=N(()=>{"use strict";(()=>{"use strict";var t={470:i=>{function a(u){if(typeof u!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(u))}o(a,"e");function s(u,h){for(var f,d="",p=0,m=-1,g=0,y=0;y<=u.length;++y){if(y2){var v=d.lastIndexOf("/");if(v!==d.length-1){v===-1?(d="",p=0):p=(d=d.slice(0,v)).length-1-d.lastIndexOf("/"),m=y,g=0;continue}}else if(d.length===2||d.length===1){d="",p=0,m=y,g=0;continue}}h&&(d.length>0?d+="/..":d="..",p=2)}else d.length>0?d+="/"+u.slice(m+1,y):d=u.slice(m+1,y),p=y-m-1;m=y,g=0}else f===46&&g!==-1?++g:g=-1}return d}o(s,"r");var l={resolve:o(function(){for(var u,h="",f=!1,d=arguments.length-1;d>=-1&&!f;d--){var p;d>=0?p=arguments[d]:(u===void 0&&(u=process.cwd()),p=u),a(p),p.length!==0&&(h=p+"/"+h,f=p.charCodeAt(0)===47)}return h=s(h,!f),f?h.length>0?"/"+h:"/":h.length>0?h:"."},"resolve"),normalize:o(function(u){if(a(u),u.length===0)return".";var h=u.charCodeAt(0)===47,f=u.charCodeAt(u.length-1)===47;return(u=s(u,!h)).length!==0||h||(u="."),u.length>0&&f&&(u+="/"),h?"/"+u:u},"normalize"),isAbsolute:o(function(u){return a(u),u.length>0&&u.charCodeAt(0)===47},"isAbsolute"),join:o(function(){if(arguments.length===0)return".";for(var u,h=0;h0&&(u===void 0?u=f:u+="/"+f)}return u===void 0?".":l.normalize(u)},"join"),relative:o(function(u,h){if(a(u),a(h),u===h||(u=l.resolve(u))===(h=l.resolve(h)))return"";for(var f=1;fy){if(h.charCodeAt(m+x)===47)return h.slice(m+x+1);if(x===0)return h.slice(m+x)}else p>y&&(u.charCodeAt(f+x)===47?v=x:x===0&&(v=0));break}var b=u.charCodeAt(f+x);if(b!==h.charCodeAt(m+x))break;b===47&&(v=x)}var w="";for(x=f+v+1;x<=d;++x)x!==d&&u.charCodeAt(x)!==47||(w.length===0?w+="..":w+="/..");return w.length>0?w+h.slice(m+v):(m+=v,h.charCodeAt(m)===47&&++m,h.slice(m))},"relative"),_makeLong:o(function(u){return u},"_makeLong"),dirname:o(function(u){if(a(u),u.length===0)return".";for(var h=u.charCodeAt(0),f=h===47,d=-1,p=!0,m=u.length-1;m>=1;--m)if((h=u.charCodeAt(m))===47){if(!p){d=m;break}}else p=!1;return d===-1?f?"/":".":f&&d===1?"//":u.slice(0,d)},"dirname"),basename:o(function(u,h){if(h!==void 0&&typeof h!="string")throw new TypeError('"ext" argument must be a string');a(u);var f,d=0,p=-1,m=!0;if(h!==void 0&&h.length>0&&h.length<=u.length){if(h.length===u.length&&h===u)return"";var g=h.length-1,y=-1;for(f=u.length-1;f>=0;--f){var v=u.charCodeAt(f);if(v===47){if(!m){d=f+1;break}}else y===-1&&(m=!1,y=f+1),g>=0&&(v===h.charCodeAt(g)?--g==-1&&(p=f):(g=-1,p=y))}return d===p?p=y:p===-1&&(p=u.length),u.slice(d,p)}for(f=u.length-1;f>=0;--f)if(u.charCodeAt(f)===47){if(!m){d=f+1;break}}else p===-1&&(m=!1,p=f+1);return p===-1?"":u.slice(d,p)},"basename"),extname:o(function(u){a(u);for(var h=-1,f=0,d=-1,p=!0,m=0,g=u.length-1;g>=0;--g){var y=u.charCodeAt(g);if(y!==47)d===-1&&(p=!1,d=g+1),y===46?h===-1?h=g:m!==1&&(m=1):h!==-1&&(m=-1);else if(!p){f=g+1;break}}return h===-1||d===-1||m===0||m===1&&h===d-1&&h===f+1?"":u.slice(h,d)},"extname"),format:o(function(u){if(u===null||typeof u!="object")throw new TypeError('The "pathObject" argument must be of type Object. Received type '+typeof u);return function(h,f){var d=f.dir||f.root,p=f.base||(f.name||"")+(f.ext||"");return d?d===f.root?d+p:d+"/"+p:p}(0,u)},"format"),parse:o(function(u){a(u);var h={root:"",dir:"",base:"",ext:"",name:""};if(u.length===0)return h;var f,d=u.charCodeAt(0),p=d===47;p?(h.root="/",f=1):f=0;for(var m=-1,g=0,y=-1,v=!0,x=u.length-1,b=0;x>=f;--x)if((d=u.charCodeAt(x))!==47)y===-1&&(v=!1,y=x+1),d===46?m===-1?m=x:b!==1&&(b=1):m!==-1&&(b=-1);else if(!v){g=x+1;break}return m===-1||y===-1||b===0||b===1&&m===y-1&&m===g+1?y!==-1&&(h.base=h.name=g===0&&p?u.slice(1,y):u.slice(g,y)):(g===0&&p?(h.name=u.slice(1,m),h.base=u.slice(1,y)):(h.name=u.slice(g,m),h.base=u.slice(g,y)),h.ext=u.slice(m,y)),g>0?h.dir=u.slice(0,g-1):p&&(h.dir="/"),h},"parse"),sep:"/",delimiter:":",win32:null,posix:null};l.posix=l,i.exports=l}},e={};function r(i){var a=e[i];if(a!==void 0)return a.exports;var s=e[i]={exports:{}};return t[i](s,s.exports,r),s.exports}o(r,"r"),r.d=(i,a)=>{for(var s in a)r.o(a,s)&&!r.o(i,s)&&Object.defineProperty(i,s,{enumerable:!0,get:a[s]})},r.o=(i,a)=>Object.prototype.hasOwnProperty.call(i,a),r.r=i=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(i,"__esModule",{value:!0})};var n={};(()=>{let i;r.r(n),r.d(n,{URI:o(()=>p,"URI"),Utils:o(()=>I,"Utils")}),typeof process=="object"?i=process.platform==="win32":typeof navigator=="object"&&(i=navigator.userAgent.indexOf("Windows")>=0);let a=/^\w[\w\d+.-]*$/,s=/^\//,l=/^\/\//;function u(D,k){if(!D.scheme&&k)throw new Error(`[UriError]: Scheme is missing: {scheme: "", authority: "${D.authority}", path: "${D.path}", query: "${D.query}", fragment: "${D.fragment}"}`);if(D.scheme&&!a.test(D.scheme))throw new Error("[UriError]: Scheme contains illegal characters.");if(D.path){if(D.authority){if(!s.test(D.path))throw new Error('[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character')}else if(l.test(D.path))throw new Error('[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")')}}o(u,"s");let h="",f="/",d=/^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/;class p{static{o(this,"f")}static isUri(k){return k instanceof p||!!k&&typeof k.authority=="string"&&typeof k.fragment=="string"&&typeof k.path=="string"&&typeof k.query=="string"&&typeof k.scheme=="string"&&typeof k.fsPath=="string"&&typeof k.with=="function"&&typeof k.toString=="function"}scheme;authority;path;query;fragment;constructor(k,L,R,O,M,B=!1){typeof k=="object"?(this.scheme=k.scheme||h,this.authority=k.authority||h,this.path=k.path||h,this.query=k.query||h,this.fragment=k.fragment||h):(this.scheme=function(F,P){return F||P?F:"file"}(k,B),this.authority=L||h,this.path=function(F,P){switch(F){case"https":case"http":case"file":P?P[0]!==f&&(P=f+P):P=f}return P}(this.scheme,R||h),this.query=O||h,this.fragment=M||h,u(this,B))}get fsPath(){return b(this,!1)}with(k){if(!k)return this;let{scheme:L,authority:R,path:O,query:M,fragment:B}=k;return L===void 0?L=this.scheme:L===null&&(L=h),R===void 0?R=this.authority:R===null&&(R=h),O===void 0?O=this.path:O===null&&(O=h),M===void 0?M=this.query:M===null&&(M=h),B===void 0?B=this.fragment:B===null&&(B=h),L===this.scheme&&R===this.authority&&O===this.path&&M===this.query&&B===this.fragment?this:new g(L,R,O,M,B)}static parse(k,L=!1){let R=d.exec(k);return R?new g(R[2]||h,E(R[4]||h),E(R[5]||h),E(R[7]||h),E(R[9]||h),L):new g(h,h,h,h,h)}static file(k){let L=h;if(i&&(k=k.replace(/\\/g,f)),k[0]===f&&k[1]===f){let R=k.indexOf(f,2);R===-1?(L=k.substring(2),k=f):(L=k.substring(2,R),k=k.substring(R)||f)}return new g("file",L,k,h,h)}static from(k){let L=new g(k.scheme,k.authority,k.path,k.query,k.fragment);return u(L,!0),L}toString(k=!1){return w(this,k)}toJSON(){return this}static revive(k){if(k){if(k instanceof p)return k;{let L=new g(k);return L._formatted=k.external,L._fsPath=k._sep===m?k.fsPath:null,L}}return k}}let m=i?1:void 0;class g extends p{static{o(this,"l")}_formatted=null;_fsPath=null;get fsPath(){return this._fsPath||(this._fsPath=b(this,!1)),this._fsPath}toString(k=!1){return k?w(this,!0):(this._formatted||(this._formatted=w(this,!1)),this._formatted)}toJSON(){let k={$mid:1};return this._fsPath&&(k.fsPath=this._fsPath,k._sep=m),this._formatted&&(k.external=this._formatted),this.path&&(k.path=this.path),this.scheme&&(k.scheme=this.scheme),this.authority&&(k.authority=this.authority),this.query&&(k.query=this.query),this.fragment&&(k.fragment=this.fragment),k}}let y={58:"%3A",47:"%2F",63:"%3F",35:"%23",91:"%5B",93:"%5D",64:"%40",33:"%21",36:"%24",38:"%26",39:"%27",40:"%28",41:"%29",42:"%2A",43:"%2B",44:"%2C",59:"%3B",61:"%3D",32:"%20"};function v(D,k,L){let R,O=-1;for(let M=0;M=97&&B<=122||B>=65&&B<=90||B>=48&&B<=57||B===45||B===46||B===95||B===126||k&&B===47||L&&B===91||L&&B===93||L&&B===58)O!==-1&&(R+=encodeURIComponent(D.substring(O,M)),O=-1),R!==void 0&&(R+=D.charAt(M));else{R===void 0&&(R=D.substr(0,M));let F=y[B];F!==void 0?(O!==-1&&(R+=encodeURIComponent(D.substring(O,M)),O=-1),R+=F):O===-1&&(O=M)}}return O!==-1&&(R+=encodeURIComponent(D.substring(O))),R!==void 0?R:D}o(v,"d");function x(D){let k;for(let L=0;L1&&D.scheme==="file"?`//${D.authority}${D.path}`:D.path.charCodeAt(0)===47&&(D.path.charCodeAt(1)>=65&&D.path.charCodeAt(1)<=90||D.path.charCodeAt(1)>=97&&D.path.charCodeAt(1)<=122)&&D.path.charCodeAt(2)===58?k?D.path.substr(1):D.path[1].toLowerCase()+D.path.substr(2):D.path,i&&(L=L.replace(/\//g,"\\")),L}o(b,"m");function w(D,k){let L=k?x:v,R="",{scheme:O,authority:M,path:B,query:F,fragment:P}=D;if(O&&(R+=O,R+=":"),(M||O==="file")&&(R+=f,R+=f),M){let z=M.indexOf("@");if(z!==-1){let $=M.substr(0,z);M=M.substr(z+1),z=$.lastIndexOf(":"),z===-1?R+=L($,!1,!1):(R+=L($.substr(0,z),!1,!1),R+=":",R+=L($.substr(z+1),!1,!0)),R+="@"}M=M.toLowerCase(),z=M.lastIndexOf(":"),z===-1?R+=L(M,!1,!0):(R+=L(M.substr(0,z),!1,!0),R+=M.substr(z))}if(B){if(B.length>=3&&B.charCodeAt(0)===47&&B.charCodeAt(2)===58){let z=B.charCodeAt(1);z>=65&&z<=90&&(B=`/${String.fromCharCode(z+32)}:${B.substr(3)}`)}else if(B.length>=2&&B.charCodeAt(1)===58){let z=B.charCodeAt(0);z>=65&&z<=90&&(B=`${String.fromCharCode(z+32)}:${B.substr(2)}`)}R+=L(B,!0,!1)}return F&&(R+="?",R+=L(F,!1,!1)),P&&(R+="#",R+=k?P:v(P,!1,!1)),R}o(w,"y");function C(D){try{return decodeURIComponent(D)}catch{return D.length>3?D.substr(0,3)+C(D.substr(3)):D}}o(C,"v");let T=/(%[0-9A-Za-z][0-9A-Za-z])+/g;function E(D){return D.match(T)?D.replace(T,k=>C(k)):D}o(E,"C");var A=r(470);let S=A.posix||A,_="/";var I;(function(D){D.joinPath=function(k,...L){return k.with({path:S.join(k.path,...L)})},D.resolvePath=function(k,...L){let R=k.path,O=!1;R[0]!==_&&(R=_+R,O=!0);let M=S.resolve(R,...L);return O&&M[0]===_&&!k.authority&&(M=M.substring(1)),k.with({path:M})},D.dirname=function(k){if(k.path.length===0||k.path===_)return k;let L=S.dirname(k.path);return L.length===1&&L.charCodeAt(0)===46&&(L=""),k.with({path:L})},D.basename=function(k){return S.basename(k.path)},D.extname=function(k){return S.extname(k.path)}})(I||(I={}))})(),Hle=n})();({URI:us,Utils:u1}=Hle)});var hs,Fc=N(()=>{"use strict";OM();(function(t){t.basename=u1.basename,t.dirname=u1.dirname,t.extname=u1.extname,t.joinPath=u1.joinPath,t.resolvePath=u1.resolvePath;function e(i,a){return i?.toString()===a?.toString()}o(e,"equals"),t.equals=e;function r(i,a){let s=typeof i=="string"?i:i.path,l=typeof a=="string"?a:a.path,u=s.split("/").filter(m=>m.length>0),h=l.split("/").filter(m=>m.length>0),f=0;for(;f{"use strict";Ule();h1();qo();Ps();Fc();(function(t){t[t.Changed=0]="Changed",t[t.Parsed=1]="Parsed",t[t.IndexedContent=2]="IndexedContent",t[t.ComputedScopes=3]="ComputedScopes",t[t.Linked=4]="Linked",t[t.IndexedReferences=5]="IndexedReferences",t[t.Validated=6]="Validated"})(kn||(kn={}));Nx=class{static{o(this,"DefaultLangiumDocumentFactory")}constructor(e){this.serviceRegistry=e.ServiceRegistry,this.textDocuments=e.workspace.TextDocuments,this.fileSystemProvider=e.workspace.FileSystemProvider}async fromUri(e,r=yr.CancellationToken.None){let n=await this.fileSystemProvider.readFile(e);return this.createAsync(e,n,r)}fromTextDocument(e,r,n){return r=r??us.parse(e.uri),yr.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromString(e,r,n){return yr.CancellationToken.is(n)?this.createAsync(r,e,n):this.create(r,e,n)}fromModel(e,r){return this.create(r,{$model:e})}create(e,r,n){if(typeof r=="string"){let i=this.parse(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else if("$model"in r){let i={value:r.$model,parserErrors:[],lexerErrors:[]};return this.createLangiumDocument(i,e)}else{let i=this.parse(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}async createAsync(e,r,n){if(typeof r=="string"){let i=await this.parseAsync(e,r,n);return this.createLangiumDocument(i,e,void 0,r)}else{let i=await this.parseAsync(e,r.getText(),n);return this.createLangiumDocument(i,e,r)}}createLangiumDocument(e,r,n,i){let a;if(n)a={parseResult:e,uri:r,state:kn.Parsed,references:[],textDocument:n};else{let s=this.createTextDocumentGetter(r,i);a={parseResult:e,uri:r,state:kn.Parsed,references:[],get textDocument(){return s()}}}return e.value.$document=a,a}async update(e,r){var n,i;let a=(n=e.parseResult.value.$cstNode)===null||n===void 0?void 0:n.root.fullText,s=(i=this.textDocuments)===null||i===void 0?void 0:i.get(e.uri.toString()),l=s?s.getText():await this.fileSystemProvider.readFile(e.uri);if(s)Object.defineProperty(e,"textDocument",{value:s});else{let u=this.createTextDocumentGetter(e.uri,l);Object.defineProperty(e,"textDocument",{get:u})}return a!==l&&(e.parseResult=await this.parseAsync(e.uri,l,r),e.parseResult.value.$document=e),e.state=kn.Parsed,e}parse(e,r,n){return this.serviceRegistry.getServices(e).parser.LangiumParser.parse(r,n)}parseAsync(e,r,n){return this.serviceRegistry.getServices(e).parser.AsyncParser.parse(r,n)}createTextDocumentGetter(e,r){let n=this.serviceRegistry,i;return()=>i??(i=c1.create(e.toString(),n.getServices(e).LanguageMetaData.languageId,0,r??""))}},Mx=class{static{o(this,"DefaultLangiumDocuments")}constructor(e){this.documentMap=new Map,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.serviceRegistry=e.ServiceRegistry}get all(){return en(this.documentMap.values())}addDocument(e){let r=e.uri.toString();if(this.documentMap.has(r))throw new Error(`A document with the URI '${r}' is already present.`);this.documentMap.set(r,e)}getDocument(e){let r=e.toString();return this.documentMap.get(r)}async getOrCreateDocument(e,r){let n=this.getDocument(e);return n||(n=await this.langiumDocumentFactory.fromUri(e,r),this.addDocument(n),n)}createDocument(e,r,n){if(n)return this.langiumDocumentFactory.fromString(r,e,n).then(i=>(this.addDocument(i),i));{let i=this.langiumDocumentFactory.fromString(r,e);return this.addDocument(i),i}}hasDocument(e){return this.documentMap.has(e.toString())}invalidateDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(this.serviceRegistry.getServices(e).references.Linker.unlink(n),n.state=kn.Changed,n.precomputedScopes=void 0,n.diagnostics=void 0),n}deleteDocument(e){let r=e.toString(),n=this.documentMap.get(r);return n&&(n.state=kn.Changed,this.documentMap.delete(r)),n}}});var PM,Ix,BM=N(()=>{"use strict";qo();Rl();is();Yo();h1();PM=Symbol("ref_resolving"),Ix=class{static{o(this,"DefaultLinker")}constructor(e){this.reflection=e.shared.AstReflection,this.langiumDocuments=()=>e.shared.workspace.LangiumDocuments,this.scopeProvider=e.references.ScopeProvider,this.astNodeLocator=e.workspace.AstNodeLocator}async link(e,r=yr.CancellationToken.None){for(let n of Wo(e.parseResult.value))await xi(r),Ag(n).forEach(i=>this.doLink(i,e))}doLink(e,r){var n;let i=e.reference;if(i._ref===void 0){i._ref=PM;try{let a=this.getCandidate(e);if(jd(a))i._ref=a;else if(i._nodeDescription=a,this.langiumDocuments().hasDocument(a.documentUri)){let s=this.loadAstNode(a);i._ref=s??this.createLinkingError(e,a)}else i._ref=void 0}catch(a){console.error(`An error occurred while resolving reference to '${i.$refText}':`,a);let s=(n=a.message)!==null&&n!==void 0?n:String(a);i._ref=Object.assign(Object.assign({},e),{message:`An error occurred while resolving reference to '${i.$refText}': ${s}`})}r.references.push(i)}}unlink(e){for(let r of e.references)delete r._ref,delete r._nodeDescription;e.references=[]}getCandidate(e){let n=this.scopeProvider.getScope(e).getElement(e.reference.$refText);return n??this.createLinkingError(e)}buildReference(e,r,n,i){let a=this,s={$refNode:n,$refText:i,get ref(){var l;if(ii(this._ref))return this._ref;if(kR(this._nodeDescription)){let u=a.loadAstNode(this._nodeDescription);this._ref=u??a.createLinkingError({reference:s,container:e,property:r},this._nodeDescription)}else if(this._ref===void 0){this._ref=PM;let u=H2(e).$document,h=a.getLinkedNode({reference:s,container:e,property:r});if(h.error&&u&&u.state{"use strict";Ol();o(Wle,"isNamed");Ox=class{static{o(this,"DefaultNameProvider")}getName(e){if(Wle(e))return e.name}getNameNode(e){return Q2(e.$cstNode,"name")}}});var Px,$M=N(()=>{"use strict";Ol();Rl();is();Nl();Ps();Fc();Px=class{static{o(this,"DefaultReferences")}constructor(e){this.nameProvider=e.references.NameProvider,this.index=e.shared.workspace.IndexManager,this.nodeLocator=e.workspace.AstNodeLocator}findDeclaration(e){if(e){let r=hN(e),n=e.astNode;if(r&&n){let i=n[r.feature];if(va(i))return i.ref;if(Array.isArray(i)){for(let a of i)if(va(a)&&a.$refNode&&a.$refNode.offset<=e.offset&&a.$refNode.end>=e.end)return a.ref}}if(n){let i=this.nameProvider.getNameNode(n);if(i&&(i===e||SR(e,i)))return n}}}findDeclarationNode(e){let r=this.findDeclaration(e);if(r?.$cstNode){let n=this.nameProvider.getNameNode(r);return n??r.$cstNode}}findReferences(e,r){let n=[];if(r.includeDeclaration){let a=this.getReferenceToSelf(e);a&&n.push(a)}let i=this.index.findAllReferences(e,this.nodeLocator.getAstNodePath(e));return r.documentUri&&(i=i.filter(a=>hs.equals(a.sourceUri,r.documentUri))),n.push(...i),en(n)}getReferenceToSelf(e){let r=this.nameProvider.getNameNode(e);if(r){let n=Pa(e),i=this.nodeLocator.getAstNodePath(e);return{sourceUri:n.uri,sourcePath:i,targetUri:n.uri,targetPath:i,segment:Qd(r),local:!0}}}}});var Bl,vp,f1=N(()=>{"use strict";Ps();Bl=class{static{o(this,"MultiMap")}constructor(e){if(this.map=new Map,e)for(let[r,n]of e)this.add(r,n)}get size(){return zm.sum(en(this.map.values()).map(e=>e.length))}clear(){this.map.clear()}delete(e,r){if(r===void 0)return this.map.delete(e);{let n=this.map.get(e);if(n){let i=n.indexOf(r);if(i>=0)return n.length===1?this.map.delete(e):n.splice(i,1),!0}return!1}}get(e){var r;return(r=this.map.get(e))!==null&&r!==void 0?r:[]}has(e,r){if(r===void 0)return this.map.has(e);{let n=this.map.get(e);return n?n.indexOf(r)>=0:!1}}add(e,r){return this.map.has(e)?this.map.get(e).push(r):this.map.set(e,[r]),this}addAll(e,r){return this.map.has(e)?this.map.get(e).push(...r):this.map.set(e,Array.from(r)),this}forEach(e){this.map.forEach((r,n)=>r.forEach(i=>e(i,n,this)))}[Symbol.iterator](){return this.entries().iterator()}entries(){return en(this.map.entries()).flatMap(([e,r])=>r.map(n=>[e,n]))}keys(){return en(this.map.keys())}values(){return en(this.map.values()).flat()}entriesGroupedByKey(){return en(this.map.entries())}},vp=class{static{o(this,"BiMap")}get size(){return this.map.size}constructor(e){if(this.map=new Map,this.inverse=new Map,e)for(let[r,n]of e)this.set(r,n)}clear(){this.map.clear(),this.inverse.clear()}set(e,r){return this.map.set(e,r),this.inverse.set(r,e),this}get(e){return this.map.get(e)}getKey(e){return this.inverse.get(e)}delete(e){let r=this.map.get(e);return r!==void 0?(this.map.delete(e),this.inverse.delete(r),!0):!1}}});var Bx,zM=N(()=>{"use strict";qo();is();f1();Yo();Bx=class{static{o(this,"DefaultScopeComputation")}constructor(e){this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider}async computeExports(e,r=yr.CancellationToken.None){return this.computeExportsForNode(e.parseResult.value,e,void 0,r)}async computeExportsForNode(e,r,n=W2,i=yr.CancellationToken.None){let a=[];this.exportNode(e,a,r);for(let s of n(e))await xi(i),this.exportNode(s,a,r);return a}exportNode(e,r,n){let i=this.nameProvider.getName(e);i&&r.push(this.descriptions.createDescription(e,i,n))}async computeLocalScopes(e,r=yr.CancellationToken.None){let n=e.parseResult.value,i=new Bl;for(let a of Nc(n))await xi(r),this.processNode(a,e,i);return i}processNode(e,r,n){let i=e.$container;if(i){let a=this.nameProvider.getName(e);a&&n.add(i,this.descriptions.createDescription(e,a,r))}}}});var d1,Fx,xFe,GM=N(()=>{"use strict";Ps();d1=class{static{o(this,"StreamScope")}constructor(e,r,n){var i;this.elements=e,this.outerScope=r,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1}getAllElements(){return this.outerScope?this.elements.concat(this.outerScope.getAllElements()):this.elements}getElement(e){let r=this.caseInsensitive?this.elements.find(n=>n.name.toLowerCase()===e.toLowerCase()):this.elements.find(n=>n.name===e);if(r)return r;if(this.outerScope)return this.outerScope.getElement(e)}},Fx=class{static{o(this,"MapScope")}constructor(e,r,n){var i;this.elements=new Map,this.caseInsensitive=(i=n?.caseInsensitive)!==null&&i!==void 0?i:!1;for(let a of e){let s=this.caseInsensitive?a.name.toLowerCase():a.name;this.elements.set(s,a)}this.outerScope=r}getElement(e){let r=this.caseInsensitive?e.toLowerCase():e,n=this.elements.get(r);if(n)return n;if(this.outerScope)return this.outerScope.getElement(e)}getAllElements(){let e=en(this.elements.values());return this.outerScope&&(e=e.concat(this.outerScope.getAllElements())),e}},xFe={getElement(){},getAllElements(){return I2}}});var p1,$x,xp,_E,m1,DE=N(()=>{"use strict";p1=class{static{o(this,"DisposableCache")}constructor(){this.toDispose=[],this.isDisposed=!1}onDispose(e){this.toDispose.push(e)}dispose(){this.throwIfDisposed(),this.clear(),this.isDisposed=!0,this.toDispose.forEach(e=>e.dispose())}throwIfDisposed(){if(this.isDisposed)throw new Error("This cache has already been disposed")}},$x=class extends p1{static{o(this,"SimpleCache")}constructor(){super(...arguments),this.cache=new Map}has(e){return this.throwIfDisposed(),this.cache.has(e)}set(e,r){this.throwIfDisposed(),this.cache.set(e,r)}get(e,r){if(this.throwIfDisposed(),this.cache.has(e))return this.cache.get(e);if(r){let n=r();return this.cache.set(e,n),n}else return}delete(e){return this.throwIfDisposed(),this.cache.delete(e)}clear(){this.throwIfDisposed(),this.cache.clear()}},xp=class extends p1{static{o(this,"ContextCache")}constructor(e){super(),this.cache=new Map,this.converter=e??(r=>r)}has(e,r){return this.throwIfDisposed(),this.cacheForContext(e).has(r)}set(e,r,n){this.throwIfDisposed(),this.cacheForContext(e).set(r,n)}get(e,r,n){this.throwIfDisposed();let i=this.cacheForContext(e);if(i.has(r))return i.get(r);if(n){let a=n();return i.set(r,a),a}else return}delete(e,r){return this.throwIfDisposed(),this.cacheForContext(e).delete(r)}clear(e){if(this.throwIfDisposed(),e){let r=this.converter(e);this.cache.delete(r)}else this.cache.clear()}cacheForContext(e){let r=this.converter(e),n=this.cache.get(r);return n||(n=new Map,this.cache.set(r,n)),n}},_E=class extends xp{static{o(this,"DocumentCache")}constructor(e,r){super(n=>n.toString()),r?(this.toDispose.push(e.workspace.DocumentBuilder.onDocumentPhase(r,n=>{this.clear(n.uri.toString())})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{for(let a of i)this.clear(a)}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{let a=n.concat(i);for(let s of a)this.clear(s)}))}},m1=class extends $x{static{o(this,"WorkspaceCache")}constructor(e,r){super(),r?(this.toDispose.push(e.workspace.DocumentBuilder.onBuildPhase(r,()=>{this.clear()})),this.toDispose.push(e.workspace.DocumentBuilder.onUpdate((n,i)=>{i.length>0&&this.clear()}))):this.toDispose.push(e.workspace.DocumentBuilder.onUpdate(()=>{this.clear()}))}}});var zx,VM=N(()=>{"use strict";GM();is();Ps();DE();zx=class{static{o(this,"DefaultScopeProvider")}constructor(e){this.reflection=e.shared.AstReflection,this.nameProvider=e.references.NameProvider,this.descriptions=e.workspace.AstNodeDescriptionProvider,this.indexManager=e.shared.workspace.IndexManager,this.globalScopeCache=new m1(e.shared)}getScope(e){let r=[],n=this.reflection.getReferenceType(e),i=Pa(e.container).precomputedScopes;if(i){let s=e.container;do{let l=i.get(s);l.length>0&&r.push(en(l).filter(u=>this.reflection.isSubtype(u.type,n))),s=s.$container}while(s)}let a=this.getGlobalScope(n,e);for(let s=r.length-1;s>=0;s--)a=this.createScope(r[s],a);return a}createScope(e,r,n){return new d1(en(e),r,n)}createScopeForNodes(e,r,n){let i=en(e).map(a=>{let s=this.nameProvider.getName(a);if(s)return this.descriptions.createDescription(a,s)}).nonNullable();return new d1(i,r,n)}getGlobalScope(e,r){return this.globalScopeCache.get(e,()=>new Fx(this.indexManager.allElements(e)))}}});function UM(t){return typeof t.$comment=="string"}function qle(t){return typeof t=="object"&&!!t&&("$ref"in t||"$error"in t)}var Gx,LE=N(()=>{"use strict";OM();Rl();is();Ol();o(UM,"isAstNodeWithComment");o(qle,"isIntermediateReference");Gx=class{static{o(this,"DefaultJsonSerializer")}constructor(e){this.ignoreProperties=new Set(["$container","$containerProperty","$containerIndex","$document","$cstNode"]),this.langiumDocuments=e.shared.workspace.LangiumDocuments,this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider,this.commentProvider=e.documentation.CommentProvider}serialize(e,r){let n=r??{},i=r?.replacer,a=o((l,u)=>this.replacer(l,u,n),"defaultReplacer"),s=i?(l,u)=>i(l,u,a):a;try{return this.currentDocument=Pa(e),JSON.stringify(e,s,r?.space)}finally{this.currentDocument=void 0}}deserialize(e,r){let n=r??{},i=JSON.parse(e);return this.linkNode(i,i,n),i}replacer(e,r,{refText:n,sourceText:i,textRegions:a,comments:s,uriConverter:l}){var u,h,f,d;if(!this.ignoreProperties.has(e))if(va(r)){let p=r.ref,m=n?r.$refText:void 0;if(p){let g=Pa(p),y="";this.currentDocument&&this.currentDocument!==g&&(l?y=l(g.uri,r):y=g.uri.toString());let v=this.astNodeLocator.getAstNodePath(p);return{$ref:`${y}#${v}`,$refText:m}}else return{$error:(h=(u=r.error)===null||u===void 0?void 0:u.message)!==null&&h!==void 0?h:"Could not resolve reference",$refText:m}}else if(ii(r)){let p;if(a&&(p=this.addAstNodeRegionWithAssignmentsTo(Object.assign({},r)),(!e||r.$document)&&p?.$textRegion&&(p.$textRegion.documentURI=(f=this.currentDocument)===null||f===void 0?void 0:f.uri.toString())),i&&!e&&(p??(p=Object.assign({},r)),p.$sourceText=(d=r.$cstNode)===null||d===void 0?void 0:d.text),s){p??(p=Object.assign({},r));let m=this.commentProvider.getComment(r);m&&(p.$comment=m.replace(/\r/g,""))}return p??r}else return r}addAstNodeRegionWithAssignmentsTo(e){let r=o(n=>({offset:n.offset,end:n.end,length:n.length,range:n.range}),"createDocumentSegment");if(e.$cstNode){let n=e.$textRegion=r(e.$cstNode),i=n.assignments={};return Object.keys(e).filter(a=>!a.startsWith("$")).forEach(a=>{let s=oN(e.$cstNode,a).map(r);s.length!==0&&(i[a]=s)}),e}}linkNode(e,r,n,i,a,s){for(let[u,h]of Object.entries(e))if(Array.isArray(h))for(let f=0;f{"use strict";Fc();Vx=class{static{o(this,"DefaultServiceRegistry")}get map(){return this.fileExtensionMap}constructor(e){this.languageIdMap=new Map,this.fileExtensionMap=new Map,this.textDocuments=e?.workspace.TextDocuments}register(e){let r=e.LanguageMetaData;for(let n of r.fileExtensions)this.fileExtensionMap.has(n)&&console.warn(`The file extension ${n} is used by multiple languages. It is now assigned to '${r.languageId}'.`),this.fileExtensionMap.set(n,e);this.languageIdMap.set(r.languageId,e),this.languageIdMap.size===1?this.singleton=e:this.singleton=void 0}getServices(e){var r,n;if(this.singleton!==void 0)return this.singleton;if(this.languageIdMap.size===0)throw new Error("The service registry is empty. Use `register` to register the services of a language.");let i=(n=(r=this.textDocuments)===null||r===void 0?void 0:r.get(e))===null||n===void 0?void 0:n.languageId;if(i!==void 0){let l=this.languageIdMap.get(i);if(l)return l}let a=hs.extname(e),s=this.fileExtensionMap.get(a);if(!s)throw i?new Error(`The service registry contains no services for the extension '${a}' for language '${i}'.`):new Error(`The service registry contains no services for the extension '${a}'.`);return s}hasServices(e){try{return this.getServices(e),!0}catch{return!1}}get all(){return Array.from(this.languageIdMap.values())}}});function bp(t){return{code:t}}var g1,Ux,Hx=N(()=>{"use strict";Xo();f1();Yo();Ps();o(bp,"diagnosticData");(function(t){t.all=["fast","slow","built-in"]})(g1||(g1={}));Ux=class{static{o(this,"ValidationRegistry")}constructor(e){this.entries=new Bl,this.entriesBefore=[],this.entriesAfter=[],this.reflection=e.shared.AstReflection}register(e,r=this,n="fast"){if(n==="built-in")throw new Error("The 'built-in' category is reserved for lexer, parser, and linker errors.");for(let[i,a]of Object.entries(e)){let s=a;if(Array.isArray(s))for(let l of s){let u={check:this.wrapValidationException(l,r),category:n};this.addEntry(i,u)}else if(typeof s=="function"){let l={check:this.wrapValidationException(s,r),category:n};this.addEntry(i,l)}else Lc(s)}}wrapValidationException(e,r){return async(n,i,a)=>{await this.handleException(()=>e.call(r,n,i,a),"An error occurred during validation",i,n)}}async handleException(e,r,n,i){try{await e()}catch(a){if(Bc(a))throw a;console.error(`${r}:`,a),a instanceof Error&&a.stack&&console.error(a.stack);let s=a instanceof Error?a.message:String(a);n("error",`${r}: ${s}`,{node:i})}}addEntry(e,r){if(e==="AstNode"){this.entries.add("AstNode",r);return}for(let n of this.reflection.getAllSubTypes(e))this.entries.add(n,r)}getChecks(e,r){let n=en(this.entries.get(e)).concat(this.entries.get("AstNode"));return r&&(n=n.filter(i=>r.includes(i.category))),n.map(i=>i.check)}registerBeforeDocument(e,r=this){this.entriesBefore.push(this.wrapPreparationException(e,"An error occurred during set-up of the validation",r))}registerAfterDocument(e,r=this){this.entriesAfter.push(this.wrapPreparationException(e,"An error occurred during tear-down of the validation",r))}wrapPreparationException(e,r,n){return async(i,a,s,l)=>{await this.handleException(()=>e.call(n,i,a,s,l),r,a,i)}}get checksBefore(){return this.entriesBefore}get checksAfter(){return this.entriesAfter}}});function Yle(t){if(t.range)return t.range;let e;return typeof t.property=="string"?e=Q2(t.node.$cstNode,t.property,t.index):typeof t.keyword=="string"&&(e=cN(t.node.$cstNode,t.keyword,t.index)),e??(e=t.node.$cstNode),e?e.range:{start:{line:0,character:0},end:{line:0,character:0}}}function RE(t){switch(t){case"error":return 1;case"warning":return 2;case"info":return 3;case"hint":return 4;default:throw new Error("Invalid diagnostic severity: "+t)}}function Xle(t){switch(t){case"error":return bp(jo.LexingError);case"warning":return bp(jo.LexingWarning);case"info":return bp(jo.LexingInfo);case"hint":return bp(jo.LexingHint);default:throw new Error("Invalid diagnostic severity: "+t)}}var Wx,jo,WM=N(()=>{"use strict";qo();Ol();is();Nl();Yo();Hx();Wx=class{static{o(this,"DefaultDocumentValidator")}constructor(e){this.validationRegistry=e.validation.ValidationRegistry,this.metadata=e.LanguageMetaData}async validateDocument(e,r={},n=yr.CancellationToken.None){let i=e.parseResult,a=[];if(await xi(n),(!r.categories||r.categories.includes("built-in"))&&(this.processLexingErrors(i,a,r),r.stopAfterLexingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===jo.LexingError})||(this.processParsingErrors(i,a,r),r.stopAfterParsingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===jo.ParsingError}))||(this.processLinkingErrors(e,a,r),r.stopAfterLinkingErrors&&a.some(s=>{var l;return((l=s.data)===null||l===void 0?void 0:l.code)===jo.LinkingError}))))return a;try{a.push(...await this.validateAst(i.value,r,n))}catch(s){if(Bc(s))throw s;console.error("An error occurred during validation:",s)}return await xi(n),a}processLexingErrors(e,r,n){var i,a,s;let l=[...e.lexerErrors,...(a=(i=e.lexerReport)===null||i===void 0?void 0:i.diagnostics)!==null&&a!==void 0?a:[]];for(let u of l){let h=(s=u.severity)!==null&&s!==void 0?s:"error",f={severity:RE(h),range:{start:{line:u.line-1,character:u.column-1},end:{line:u.line-1,character:u.column+u.length-1}},message:u.message,data:Xle(h),source:this.getSource()};r.push(f)}}processParsingErrors(e,r,n){for(let i of e.parserErrors){let a;if(isNaN(i.token.startOffset)){if("previousToken"in i){let s=i.previousToken;if(isNaN(s.startOffset)){let l={line:0,character:0};a={start:l,end:l}}else{let l={line:s.endLine-1,character:s.endColumn};a={start:l,end:l}}}}else a=Gm(i.token);if(a){let s={severity:RE("error"),range:a,message:i.message,data:bp(jo.ParsingError),source:this.getSource()};r.push(s)}}}processLinkingErrors(e,r,n){for(let i of e.references){let a=i.error;if(a){let s={node:a.container,property:a.property,index:a.index,data:{code:jo.LinkingError,containerType:a.container.$type,property:a.property,refText:a.reference.$refText}};r.push(this.toDiagnostic("error",a.message,s))}}}async validateAst(e,r,n=yr.CancellationToken.None){let i=[],a=o((s,l,u)=>{i.push(this.toDiagnostic(s,l,u))},"acceptor");return await this.validateAstBefore(e,r,a,n),await this.validateAstNodes(e,r,a,n),await this.validateAstAfter(e,r,a,n),i}async validateAstBefore(e,r,n,i=yr.CancellationToken.None){var a;let s=this.validationRegistry.checksBefore;for(let l of s)await xi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}async validateAstNodes(e,r,n,i=yr.CancellationToken.None){await Promise.all(Wo(e).map(async a=>{await xi(i);let s=this.validationRegistry.getChecks(a.$type,r.categories);for(let l of s)await l(a,n,i)}))}async validateAstAfter(e,r,n,i=yr.CancellationToken.None){var a;let s=this.validationRegistry.checksAfter;for(let l of s)await xi(i),await l(e,n,(a=r.categories)!==null&&a!==void 0?a:[],i)}toDiagnostic(e,r,n){return{message:r,range:Yle(n),severity:RE(e),code:n.code,codeDescription:n.codeDescription,tags:n.tags,relatedInformation:n.relatedInformation,data:n.data,source:this.getSource()}}getSource(){return this.metadata.languageId}};o(Yle,"getDiagnosticRange");o(RE,"toDiagnosticSeverity");o(Xle,"toDiagnosticData");(function(t){t.LexingError="lexing-error",t.LexingWarning="lexing-warning",t.LexingInfo="lexing-info",t.LexingHint="lexing-hint",t.ParsingError="parsing-error",t.LinkingError="linking-error"})(jo||(jo={}))});var qx,Yx,qM=N(()=>{"use strict";qo();Rl();is();Nl();Yo();Fc();qx=class{static{o(this,"DefaultAstNodeDescriptionProvider")}constructor(e){this.astNodeLocator=e.workspace.AstNodeLocator,this.nameProvider=e.references.NameProvider}createDescription(e,r,n){let i=n??Pa(e);r??(r=this.nameProvider.getName(e));let a=this.astNodeLocator.getAstNodePath(e);if(!r)throw new Error(`Node at path ${a} has no name.`);let s,l=o(()=>{var u;return s??(s=Qd((u=this.nameProvider.getNameNode(e))!==null&&u!==void 0?u:e.$cstNode))},"nameSegmentGetter");return{node:e,name:r,get nameSegment(){return l()},selectionSegment:Qd(e.$cstNode),type:e.$type,documentUri:i.uri,path:a}}},Yx=class{static{o(this,"DefaultReferenceDescriptionProvider")}constructor(e){this.nodeLocator=e.workspace.AstNodeLocator}async createDescriptions(e,r=yr.CancellationToken.None){let n=[],i=e.parseResult.value;for(let a of Wo(i))await xi(r),Ag(a).filter(s=>!jd(s)).forEach(s=>{let l=this.createDescription(s);l&&n.push(l)});return n}createDescription(e){let r=e.reference.$nodeDescription,n=e.reference.$refNode;if(!r||!n)return;let i=Pa(e.container).uri;return{sourceUri:i,sourcePath:this.nodeLocator.getAstNodePath(e.container),targetUri:r.documentUri,targetPath:r.path,segment:Qd(n),local:hs.equals(r.documentUri,i)}}}});var Xx,YM=N(()=>{"use strict";Xx=class{static{o(this,"DefaultAstNodeLocator")}constructor(){this.segmentSeparator="/",this.indexSeparator="@"}getAstNodePath(e){if(e.$container){let r=this.getAstNodePath(e.$container),n=this.getPathSegment(e);return r+this.segmentSeparator+n}return""}getPathSegment({$containerProperty:e,$containerIndex:r}){if(!e)throw new Error("Missing '$containerProperty' in AST node.");return r!==void 0?e+this.indexSeparator+r:e}getAstNode(e,r){return r.split(this.segmentSeparator).reduce((i,a)=>{if(!i||a.length===0)return i;let s=a.indexOf(this.indexSeparator);if(s>0){let l=a.substring(0,s),u=parseInt(a.substring(s+1)),h=i[l];return h?.[u]}return i[a]},e)}}});var Kn={};var NE=N(()=>{"use strict";Sr(Kn,Sa(LM(),1))});var jx,XM=N(()=>{"use strict";NE();Yo();jx=class{static{o(this,"DefaultConfigurationProvider")}constructor(e){this._ready=new cs,this.settings={},this.workspaceConfig=!1,this.onConfigurationSectionUpdateEmitter=new Kn.Emitter,this.serviceRegistry=e.ServiceRegistry}get ready(){return this._ready.promise}initialize(e){var r,n;this.workspaceConfig=(n=(r=e.capabilities.workspace)===null||r===void 0?void 0:r.configuration)!==null&&n!==void 0?n:!1}async initialized(e){if(this.workspaceConfig){if(e.register){let r=this.serviceRegistry.all;e.register({section:r.map(n=>this.toSectionName(n.LanguageMetaData.languageId))})}if(e.fetchConfiguration){let r=this.serviceRegistry.all.map(i=>({section:this.toSectionName(i.LanguageMetaData.languageId)})),n=await e.fetchConfiguration(r);r.forEach((i,a)=>{this.updateSectionConfiguration(i.section,n[a])})}}this._ready.resolve()}updateConfiguration(e){e.settings&&Object.keys(e.settings).forEach(r=>{let n=e.settings[r];this.updateSectionConfiguration(r,n),this.onConfigurationSectionUpdateEmitter.fire({section:r,configuration:n})})}updateSectionConfiguration(e,r){this.settings[e]=r}async getConfiguration(e,r){await this.ready;let n=this.toSectionName(e);if(this.settings[n])return this.settings[n][r]}toSectionName(e){return`${e}`}get onConfigurationSectionUpdate(){return this.onConfigurationSectionUpdateEmitter.event}}});var ff,jM=N(()=>{"use strict";(function(t){function e(r){return{dispose:o(async()=>await r(),"dispose")}}o(e,"create"),t.create=e})(ff||(ff={}))});var Kx,KM=N(()=>{"use strict";qo();jM();f1();Yo();Ps();Hx();h1();Kx=class{static{o(this,"DefaultDocumentBuilder")}constructor(e){this.updateBuildOptions={validation:{categories:["built-in","fast"]}},this.updateListeners=[],this.buildPhaseListeners=new Bl,this.documentPhaseListeners=new Bl,this.buildState=new Map,this.documentBuildWaiters=new Map,this.currentState=kn.Changed,this.langiumDocuments=e.workspace.LangiumDocuments,this.langiumDocumentFactory=e.workspace.LangiumDocumentFactory,this.textDocuments=e.workspace.TextDocuments,this.indexManager=e.workspace.IndexManager,this.serviceRegistry=e.ServiceRegistry}async build(e,r={},n=yr.CancellationToken.None){var i,a;for(let s of e){let l=s.uri.toString();if(s.state===kn.Validated){if(typeof r.validation=="boolean"&&r.validation)s.state=kn.IndexedReferences,s.diagnostics=void 0,this.buildState.delete(l);else if(typeof r.validation=="object"){let u=this.buildState.get(l),h=(i=u?.result)===null||i===void 0?void 0:i.validationChecks;if(h){let d=((a=r.validation.categories)!==null&&a!==void 0?a:g1.all).filter(p=>!h.includes(p));d.length>0&&(this.buildState.set(l,{completed:!1,options:{validation:Object.assign(Object.assign({},r.validation),{categories:d})},result:u.result}),s.state=kn.IndexedReferences)}}}else this.buildState.delete(l)}this.currentState=kn.Changed,await this.emitUpdate(e.map(s=>s.uri),[]),await this.buildDocuments(e,r,n)}async update(e,r,n=yr.CancellationToken.None){this.currentState=kn.Changed;for(let s of r)this.langiumDocuments.deleteDocument(s),this.buildState.delete(s.toString()),this.indexManager.remove(s);for(let s of e){if(!this.langiumDocuments.invalidateDocument(s)){let u=this.langiumDocumentFactory.fromModel({$type:"INVALID"},s);u.state=kn.Changed,this.langiumDocuments.addDocument(u)}this.buildState.delete(s.toString())}let i=en(e).concat(r).map(s=>s.toString()).toSet();this.langiumDocuments.all.filter(s=>!i.has(s.uri.toString())&&this.shouldRelink(s,i)).forEach(s=>{this.serviceRegistry.getServices(s.uri).references.Linker.unlink(s),s.state=Math.min(s.state,kn.ComputedScopes),s.diagnostics=void 0}),await this.emitUpdate(e,r),await xi(n);let a=this.sortDocuments(this.langiumDocuments.all.filter(s=>{var l;return s.staten(e,r)))}sortDocuments(e){let r=0,n=e.length-1;for(;r=0&&!this.hasTextDocument(e[n]);)n--;rn.error!==void 0)?!0:this.indexManager.isAffected(e,r)}onUpdate(e){return this.updateListeners.push(e),ff.create(()=>{let r=this.updateListeners.indexOf(e);r>=0&&this.updateListeners.splice(r,1)})}async buildDocuments(e,r,n){this.prepareBuild(e,r),await this.runCancelable(e,kn.Parsed,n,a=>this.langiumDocumentFactory.update(a,n)),await this.runCancelable(e,kn.IndexedContent,n,a=>this.indexManager.updateContent(a,n)),await this.runCancelable(e,kn.ComputedScopes,n,async a=>{let s=this.serviceRegistry.getServices(a.uri).references.ScopeComputation;a.precomputedScopes=await s.computeLocalScopes(a,n)}),await this.runCancelable(e,kn.Linked,n,a=>this.serviceRegistry.getServices(a.uri).references.Linker.link(a,n)),await this.runCancelable(e,kn.IndexedReferences,n,a=>this.indexManager.updateReferences(a,n));let i=e.filter(a=>this.shouldValidate(a));await this.runCancelable(i,kn.Validated,n,a=>this.validate(a,n));for(let a of e){let s=this.buildState.get(a.uri.toString());s&&(s.completed=!0)}}prepareBuild(e,r){for(let n of e){let i=n.uri.toString(),a=this.buildState.get(i);(!a||a.completed)&&this.buildState.set(i,{completed:!1,options:r,result:a?.result})}}async runCancelable(e,r,n,i){let a=e.filter(l=>l.statel.state===r);await this.notifyBuildPhase(s,r,n),this.currentState=r}onBuildPhase(e,r){return this.buildPhaseListeners.add(e,r),ff.create(()=>{this.buildPhaseListeners.delete(e,r)})}onDocumentPhase(e,r){return this.documentPhaseListeners.add(e,r),ff.create(()=>{this.documentPhaseListeners.delete(e,r)})}waitUntil(e,r,n){let i;if(r&&"path"in r?i=r:n=r,n??(n=yr.CancellationToken.None),i){let a=this.langiumDocuments.getDocument(i);if(a&&a.state>e)return Promise.resolve(i)}return this.currentState>=e?Promise.resolve(void 0):n.isCancellationRequested?Promise.reject(Pc):new Promise((a,s)=>{let l=this.onBuildPhase(e,()=>{if(l.dispose(),u.dispose(),i){let h=this.langiumDocuments.getDocument(i);a(h?.uri)}else a(void 0)}),u=n.onCancellationRequested(()=>{l.dispose(),u.dispose(),s(Pc)})})}async notifyDocumentPhase(e,r,n){let a=this.documentPhaseListeners.get(r).slice();for(let s of a)try{await s(e,n)}catch(l){if(!Bc(l))throw l}}async notifyBuildPhase(e,r,n){if(e.length===0)return;let a=this.buildPhaseListeners.get(r).slice();for(let s of a)await xi(n),await s(e,n)}shouldValidate(e){return!!this.getBuildOptions(e).validation}async validate(e,r){var n,i;let a=this.serviceRegistry.getServices(e.uri).validation.DocumentValidator,s=this.getBuildOptions(e).validation,l=typeof s=="object"?s:void 0,u=await a.validateDocument(e,l,r);e.diagnostics?e.diagnostics.push(...u):e.diagnostics=u;let h=this.buildState.get(e.uri.toString());if(h){(n=h.result)!==null&&n!==void 0||(h.result={});let f=(i=l?.categories)!==null&&i!==void 0?i:g1.all;h.result.validationChecks?h.result.validationChecks.push(...f):h.result.validationChecks=[...f]}}getBuildOptions(e){var r,n;return(n=(r=this.buildState.get(e.uri.toString()))===null||r===void 0?void 0:r.options)!==null&&n!==void 0?n:{}}}});var Qx,QM=N(()=>{"use strict";is();DE();qo();Ps();Fc();Qx=class{static{o(this,"DefaultIndexManager")}constructor(e){this.symbolIndex=new Map,this.symbolByTypeIndex=new xp,this.referenceIndex=new Map,this.documents=e.workspace.LangiumDocuments,this.serviceRegistry=e.ServiceRegistry,this.astReflection=e.AstReflection}findAllReferences(e,r){let n=Pa(e).uri,i=[];return this.referenceIndex.forEach(a=>{a.forEach(s=>{hs.equals(s.targetUri,n)&&s.targetPath===r&&i.push(s)})}),en(i)}allElements(e,r){let n=en(this.symbolIndex.keys());return r&&(n=n.filter(i=>!r||r.has(i))),n.map(i=>this.getFileDescriptions(i,e)).flat()}getFileDescriptions(e,r){var n;return r?this.symbolByTypeIndex.get(e,r,()=>{var a;return((a=this.symbolIndex.get(e))!==null&&a!==void 0?a:[]).filter(l=>this.astReflection.isSubtype(l.type,r))}):(n=this.symbolIndex.get(e))!==null&&n!==void 0?n:[]}remove(e){let r=e.toString();this.symbolIndex.delete(r),this.symbolByTypeIndex.clear(r),this.referenceIndex.delete(r)}async updateContent(e,r=yr.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).references.ScopeComputation.computeExports(e,r),a=e.uri.toString();this.symbolIndex.set(a,i),this.symbolByTypeIndex.clear(a)}async updateReferences(e,r=yr.CancellationToken.None){let i=await this.serviceRegistry.getServices(e.uri).workspace.ReferenceDescriptionProvider.createDescriptions(e,r);this.referenceIndex.set(e.uri.toString(),i)}isAffected(e,r){let n=this.referenceIndex.get(e.uri.toString());return n?n.some(i=>!i.local&&r.has(i.targetUri.toString())):!1}}});var Zx,ZM=N(()=>{"use strict";qo();Yo();Fc();Zx=class{static{o(this,"DefaultWorkspaceManager")}constructor(e){this.initialBuildOptions={},this._ready=new cs,this.serviceRegistry=e.ServiceRegistry,this.langiumDocuments=e.workspace.LangiumDocuments,this.documentBuilder=e.workspace.DocumentBuilder,this.fileSystemProvider=e.workspace.FileSystemProvider,this.mutex=e.workspace.WorkspaceLock}get ready(){return this._ready.promise}get workspaceFolders(){return this.folders}initialize(e){var r;this.folders=(r=e.workspaceFolders)!==null&&r!==void 0?r:void 0}initialized(e){return this.mutex.write(r=>{var n;return this.initializeWorkspace((n=this.folders)!==null&&n!==void 0?n:[],r)})}async initializeWorkspace(e,r=yr.CancellationToken.None){let n=await this.performStartup(e);await xi(r),await this.documentBuilder.build(n,this.initialBuildOptions,r)}async performStartup(e){let r=this.serviceRegistry.all.flatMap(a=>a.LanguageMetaData.fileExtensions),n=[],i=o(a=>{n.push(a),this.langiumDocuments.hasDocument(a.uri)||this.langiumDocuments.addDocument(a)},"collector");return await this.loadAdditionalDocuments(e,i),await Promise.all(e.map(a=>[a,this.getRootFolder(a)]).map(async a=>this.traverseFolder(...a,r,i))),this._ready.resolve(),n}loadAdditionalDocuments(e,r){return Promise.resolve()}getRootFolder(e){return us.parse(e.uri)}async traverseFolder(e,r,n,i){let a=await this.fileSystemProvider.readDirectory(r);await Promise.all(a.map(async s=>{if(this.includeEntry(e,s,n)){if(s.isDirectory)await this.traverseFolder(e,s.uri,n,i);else if(s.isFile){let l=await this.langiumDocuments.getOrCreateDocument(s.uri);i(l)}}}))}includeEntry(e,r,n){let i=hs.basename(r.uri);if(i.startsWith("."))return!1;if(r.isDirectory)return i!=="node_modules"&&i!=="out";if(r.isFile){let a=hs.extname(r.uri);return n.includes(a)}return!1}}});function IE(t){return Array.isArray(t)&&(t.length===0||"name"in t[0])}function eI(t){return t&&"modes"in t&&"defaultMode"in t}function JM(t){return!IE(t)&&!eI(t)}var Jx,ME,wp,OE=N(()=>{"use strict";cf();Jx=class{static{o(this,"DefaultLexerErrorMessageProvider")}buildUnexpectedCharactersMessage(e,r,n,i,a){return Gg.buildUnexpectedCharactersMessage(e,r,n,i,a)}buildUnableToPopLexerModeMessage(e){return Gg.buildUnableToPopLexerModeMessage(e)}},ME={mode:"full"},wp=class{static{o(this,"DefaultLexer")}constructor(e){this.errorMessageProvider=e.parser.LexerErrorMessageProvider,this.tokenBuilder=e.parser.TokenBuilder;let r=this.tokenBuilder.buildTokens(e.Grammar,{caseInsensitive:e.LanguageMetaData.caseInsensitive});this.tokenTypes=this.toTokenTypeDictionary(r);let n=JM(r)?Object.values(r):r,i=e.LanguageMetaData.mode==="production";this.chevrotainLexer=new Xn(n,{positionTracking:"full",skipValidations:i,errorMessageProvider:this.errorMessageProvider})}get definition(){return this.tokenTypes}tokenize(e,r=ME){var n,i,a;let s=this.chevrotainLexer.tokenize(e);return{tokens:s.tokens,errors:s.errors,hidden:(n=s.groups.hidden)!==null&&n!==void 0?n:[],report:(a=(i=this.tokenBuilder).flushLexingReport)===null||a===void 0?void 0:a.call(i,e)}}toTokenTypeDictionary(e){if(JM(e))return e;let r=eI(e)?Object.values(e.modes).flat():e,n={};return r.forEach(i=>n[i.name]=i),n}};o(IE,"isTokenTypeArray");o(eI,"isIMultiModeLexerDefinition");o(JM,"isTokenTypeDictionary")});function nI(t,e,r){let n,i;typeof t=="string"?(i=e,n=r):(i=t.range.start,n=e),i||(i=jr.create(0,0));let a=Qle(t),s=aI(n),l=wFe({lines:a,position:i,options:s});return CFe({index:0,tokens:l,position:i})}function iI(t,e){let r=aI(e),n=Qle(t);if(n.length===0)return!1;let i=n[0],a=n[n.length-1],s=r.start,l=r.end;return!!s?.exec(i)&&!!l?.exec(a)}function Qle(t){let e="";return typeof t=="string"?e=t:e=t.text,e.split(JR)}function wFe(t){var e,r,n;let i=[],a=t.position.line,s=t.position.character;for(let l=0;l=f.length){if(i.length>0){let m=jr.create(a,s);i.push({type:"break",content:"",range:Pr.create(m,m)})}}else{jle.lastIndex=d;let m=jle.exec(f);if(m){let g=m[0],y=m[1],v=jr.create(a,s+d),x=jr.create(a,s+d+g.length);i.push({type:"tag",content:y,range:Pr.create(v,x)}),d+=g.length,d=rI(f,d)}if(d0&&i[i.length-1].type==="break"?i.slice(0,-1):i}function TFe(t,e,r,n){let i=[];if(t.length===0){let a=jr.create(r,n),s=jr.create(r,n+e.length);i.push({type:"text",content:e,range:Pr.create(a,s)})}else{let a=0;for(let l of t){let u=l.index,h=e.substring(a,u);h.length>0&&i.push({type:"text",content:e.substring(a,u),range:Pr.create(jr.create(r,a+n),jr.create(r,u+n))});let f=h.length+1,d=l[1];if(i.push({type:"inline-tag",content:d,range:Pr.create(jr.create(r,a+f+n),jr.create(r,a+f+d.length+n))}),f+=d.length,l.length===4){f+=l[2].length;let p=l[3];i.push({type:"text",content:p,range:Pr.create(jr.create(r,a+f+n),jr.create(r,a+f+p.length+n))})}else i.push({type:"text",content:"",range:Pr.create(jr.create(r,a+f+n),jr.create(r,a+f+n))});a=u+l[0].length}let s=e.substring(a);s.length>0&&i.push({type:"text",content:s,range:Pr.create(jr.create(r,a+n),jr.create(r,a+n+s.length))})}return i}function rI(t,e){let r=t.substring(e).match(kFe);return r?e+r.index:t.length}function SFe(t){let e=t.match(EFe);if(e&&typeof e.index=="number")return e.index}function CFe(t){var e,r,n,i;let a=jr.create(t.position.line,t.position.character);if(t.tokens.length===0)return new PE([],Pr.create(a,a));let s=[];for(;t.index0){let u=rI(e,a);s=e.substring(u),e=e.substring(0,a)}return(t==="linkcode"||t==="link"&&r.link==="code")&&(s=`\`${s}\``),(i=(n=r.renderLink)===null||n===void 0?void 0:n.call(r,e,s))!==null&&i!==void 0?i:RFe(e,s)}}function RFe(t,e){try{return us.parse(t,!0),`[${e}](${t})`}catch{return t}}function Kle(t){return t.endsWith(` +`)?` +`:` + +`}var jle,bFe,kFe,EFe,PE,eb,tb,BE,sI=N(()=>{"use strict";mM();Lg();Fc();o(nI,"parseJSDoc");o(iI,"isJSDoc");o(Qle,"getLines");jle=/\s*(@([\p{L}][\p{L}\p{N}]*)?)/uy,bFe=/\{(@[\p{L}][\p{L}\p{N}]*)(\s*)([^\r\n}]+)?\}/gu;o(wFe,"tokenize");o(TFe,"buildInlineTokens");kFe=/\S/,EFe=/\s*$/;o(rI,"skipWhitespace");o(SFe,"lastCharacter");o(CFe,"parseJSDocComment");o(AFe,"parseJSDocElement");o(_Fe,"appendEmptyLine");o(Zle,"parseJSDocText");o(DFe,"parseJSDocInline");o(Jle,"parseJSDocTag");o(ece,"parseJSDocLine");o(aI,"normalizeOptions");o(tI,"normalizeOption");PE=class{static{o(this,"JSDocCommentImpl")}constructor(e,r){this.elements=e,this.range=r}getTag(e){return this.getAllTags().find(r=>r.name===e)}getTags(e){return this.getAllTags().filter(r=>r.name===e)}getAllTags(){return this.elements.filter(e=>"name"in e)}toString(){let e="";for(let r of this.elements)if(e.length===0)e=r.toString();else{let n=r.toString();e+=Kle(e)+n}return e.trim()}toMarkdown(e){let r="";for(let n of this.elements)if(r.length===0)r=n.toMarkdown(e);else{let i=n.toMarkdown(e);r+=Kle(r)+i}return r.trim()}},eb=class{static{o(this,"JSDocTagImpl")}constructor(e,r,n,i){this.name=e,this.content=r,this.inline=n,this.range=i}toString(){let e=`@${this.name}`,r=this.content.toString();return this.content.inlines.length===1?e=`${e} ${r}`:this.content.inlines.length>1&&(e=`${e} +${r}`),this.inline?`{${e}}`:e}toMarkdown(e){var r,n;return(n=(r=e?.renderTag)===null||r===void 0?void 0:r.call(e,this))!==null&&n!==void 0?n:this.toMarkdownDefault(e)}toMarkdownDefault(e){let r=this.content.toMarkdown(e);if(this.inline){let a=LFe(this.name,r,e??{});if(typeof a=="string")return a}let n="";e?.tag==="italic"||e?.tag===void 0?n="*":e?.tag==="bold"?n="**":e?.tag==="bold-italic"&&(n="***");let i=`${n}@${this.name}${n}`;return this.content.inlines.length===1?i=`${i} \u2014 ${r}`:this.content.inlines.length>1&&(i=`${i} +${r}`),this.inline?`{${i}}`:i}};o(LFe,"renderInlineTag");o(RFe,"renderLinkDefault");tb=class{static{o(this,"JSDocTextImpl")}constructor(e,r){this.inlines=e,this.range=r}toString(){let e="";for(let r=0;rn.range.start.line&&(e+=` +`)}return e}toMarkdown(e){let r="";for(let n=0;ni.range.start.line&&(r+=` +`)}return r}},BE=class{static{o(this,"JSDocLineImpl")}constructor(e,r){this.text=e,this.range=r}toString(){return this.text}toMarkdown(){return this.text}};o(Kle,"fillNewlines")});var rb,oI=N(()=>{"use strict";is();sI();rb=class{static{o(this,"JSDocDocumentationProvider")}constructor(e){this.indexManager=e.shared.workspace.IndexManager,this.commentProvider=e.documentation.CommentProvider}getDocumentation(e){let r=this.commentProvider.getComment(e);if(r&&iI(r))return nI(r).toMarkdown({renderLink:o((i,a)=>this.documentationLinkRenderer(e,i,a),"renderLink"),renderTag:o(i=>this.documentationTagRenderer(e,i),"renderTag")})}documentationLinkRenderer(e,r,n){var i;let a=(i=this.findNameInPrecomputedScopes(e,r))!==null&&i!==void 0?i:this.findNameInGlobalScope(e,r);if(a&&a.nameSegment){let s=a.nameSegment.range.start.line+1,l=a.nameSegment.range.start.character+1,u=a.documentUri.with({fragment:`L${s},${l}`});return`[${n}](${u.toString()})`}else return}documentationTagRenderer(e,r){}findNameInPrecomputedScopes(e,r){let i=Pa(e).precomputedScopes;if(!i)return;let a=e;do{let l=i.get(a).find(u=>u.name===r);if(l)return l;a=a.$container}while(a)}findNameInGlobalScope(e,r){return this.indexManager.allElements().find(i=>i.name===r)}}});var nb,lI=N(()=>{"use strict";LE();Nl();nb=class{static{o(this,"DefaultCommentProvider")}constructor(e){this.grammarConfig=()=>e.parser.GrammarConfig}getComment(e){var r;return UM(e)?e.$comment:(r=AR(e.$cstNode,this.grammarConfig().multilineCommentRules))===null||r===void 0?void 0:r.text}}});var ib,cI,uI,hI=N(()=>{"use strict";Yo();NE();ib=class{static{o(this,"DefaultAsyncParser")}constructor(e){this.syncParser=e.parser.LangiumParser}parse(e,r){return Promise.resolve(this.syncParser.parse(e))}},cI=class{static{o(this,"AbstractThreadedAsyncParser")}constructor(e){this.threadCount=8,this.terminationDelay=200,this.workerPool=[],this.queue=[],this.hydrator=e.serializer.Hydrator}initializeWorkers(){for(;this.workerPool.length{if(this.queue.length>0){let r=this.queue.shift();r&&(e.lock(),r.resolve(e))}}),this.workerPool.push(e)}}async parse(e,r){let n=await this.acquireParserWorker(r),i=new cs,a,s=r.onCancellationRequested(()=>{a=setTimeout(()=>{this.terminateWorker(n)},this.terminationDelay)});return n.parse(e).then(l=>{let u=this.hydrator.hydrate(l);i.resolve(u)}).catch(l=>{i.reject(l)}).finally(()=>{s.dispose(),clearTimeout(a)}),i.promise}terminateWorker(e){e.terminate();let r=this.workerPool.indexOf(e);r>=0&&this.workerPool.splice(r,1)}async acquireParserWorker(e){this.initializeWorkers();for(let n of this.workerPool)if(n.ready)return n.lock(),n;let r=new cs;return e.onCancellationRequested(()=>{let n=this.queue.indexOf(r);n>=0&&this.queue.splice(n,1),r.reject(Pc)}),this.queue.push(r),r.promise}},uI=class{static{o(this,"ParserWorker")}get ready(){return this._ready}get onReady(){return this.onReadyEmitter.event}constructor(e,r,n,i){this.onReadyEmitter=new Kn.Emitter,this.deferred=new cs,this._ready=!0,this._parsing=!1,this.sendMessage=e,this._terminate=i,r(a=>{let s=a;this.deferred.resolve(s),this.unlock()}),n(a=>{this.deferred.reject(a),this.unlock()})}terminate(){this.deferred.reject(Pc),this._terminate()}lock(){this._ready=!1}unlock(){this._parsing=!1,this._ready=!0,this.onReadyEmitter.fire()}parse(e){if(this._parsing)throw new Error("Parser worker is busy");return this._parsing=!0,this.deferred=new cs,this.sendMessage(e),this.deferred.promise}}});var ab,fI=N(()=>{"use strict";qo();Yo();ab=class{static{o(this,"DefaultWorkspaceLock")}constructor(){this.previousTokenSource=new yr.CancellationTokenSource,this.writeQueue=[],this.readQueue=[],this.done=!0}write(e){this.cancelWrite();let r=CE();return this.previousTokenSource=r,this.enqueue(this.writeQueue,e,r.token)}read(e){return this.enqueue(this.readQueue,e)}enqueue(e,r,n=yr.CancellationToken.None){let i=new cs,a={action:r,deferred:i,cancellationToken:n};return e.push(a),this.performNextOperation(),i.promise}async performNextOperation(){if(!this.done)return;let e=[];if(this.writeQueue.length>0)e.push(this.writeQueue.shift());else if(this.readQueue.length>0)e.push(...this.readQueue.splice(0,this.readQueue.length));else return;this.done=!1,await Promise.all(e.map(async({action:r,deferred:n,cancellationToken:i})=>{try{let a=await Promise.resolve().then(()=>r(i));n.resolve(a)}catch(a){Bc(a)?n.resolve(void 0):n.reject(a)}})),this.done=!0,this.performNextOperation()}cancelWrite(){this.previousTokenSource.cancel()}}});var sb,dI=N(()=>{"use strict";gE();Rc();Rl();is();f1();Nl();sb=class{static{o(this,"DefaultHydrator")}constructor(e){this.grammarElementIdMap=new vp,this.tokenTypeIdMap=new vp,this.grammar=e.Grammar,this.lexer=e.parser.Lexer,this.linker=e.references.Linker}dehydrate(e){return{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport?this.dehydrateLexerReport(e.lexerReport):void 0,parserErrors:e.parserErrors.map(r=>Object.assign(Object.assign({},r),{message:r.message})),value:this.dehydrateAstNode(e.value,this.createDehyrationContext(e.value))}}dehydrateLexerReport(e){return e}createDehyrationContext(e){let r=new Map,n=new Map;for(let i of Wo(e))r.set(i,{});if(e.$cstNode)for(let i of Kd(e.$cstNode))n.set(i,{});return{astNodes:r,cstNodes:n}}dehydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode!==void 0&&(n.$cstNode=this.dehydrateCstNode(e.$cstNode,r));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)ii(l)?s.push(this.dehydrateAstNode(l,r)):va(l)?s.push(this.dehydrateReference(l,r)):s.push(l)}else ii(a)?n[i]=this.dehydrateAstNode(a,r):va(a)?n[i]=this.dehydrateReference(a,r):a!==void 0&&(n[i]=a);return n}dehydrateReference(e,r){let n={};return n.$refText=e.$refText,e.$refNode&&(n.$refNode=r.cstNodes.get(e.$refNode)),n}dehydrateCstNode(e,r){let n=r.cstNodes.get(e);return M2(e)?n.fullText=e.fullText:n.grammarSource=this.getGrammarElementId(e.grammarSource),n.hidden=e.hidden,n.astNode=r.astNodes.get(e.astNode),Ll(e)?n.content=e.content.map(i=>this.dehydrateCstNode(i,r)):af(e)&&(n.tokenType=e.tokenType.name,n.offset=e.offset,n.length=e.length,n.startLine=e.range.start.line,n.startColumn=e.range.start.character,n.endLine=e.range.end.line,n.endColumn=e.range.end.character),n}hydrate(e){let r=e.value,n=this.createHydrationContext(r);return"$cstNode"in r&&this.hydrateCstNode(r.$cstNode,n),{lexerErrors:e.lexerErrors,lexerReport:e.lexerReport,parserErrors:e.parserErrors,value:this.hydrateAstNode(r,n)}}createHydrationContext(e){let r=new Map,n=new Map;for(let a of Wo(e))r.set(a,{});let i;if(e.$cstNode)for(let a of Kd(e.$cstNode)){let s;"fullText"in a?(s=new a1(a.fullText),i=s):"content"in a?s=new mp:"tokenType"in a&&(s=this.hydrateCstLeafNode(a)),s&&(n.set(a,s),s.root=i)}return{astNodes:r,cstNodes:n}}hydrateAstNode(e,r){let n=r.astNodes.get(e);n.$type=e.$type,n.$containerIndex=e.$containerIndex,n.$containerProperty=e.$containerProperty,e.$cstNode&&(n.$cstNode=r.cstNodes.get(e.$cstNode));for(let[i,a]of Object.entries(e))if(!i.startsWith("$"))if(Array.isArray(a)){let s=[];n[i]=s;for(let l of a)ii(l)?s.push(this.setParent(this.hydrateAstNode(l,r),n)):va(l)?s.push(this.hydrateReference(l,n,i,r)):s.push(l)}else ii(a)?n[i]=this.setParent(this.hydrateAstNode(a,r),n):va(a)?n[i]=this.hydrateReference(a,n,i,r):a!==void 0&&(n[i]=a);return n}setParent(e,r){return e.$container=r,e}hydrateReference(e,r,n,i){return this.linker.buildReference(r,n,i.cstNodes.get(e.$refNode),e.$refText)}hydrateCstNode(e,r,n=0){let i=r.cstNodes.get(e);if(typeof e.grammarSource=="number"&&(i.grammarSource=this.getGrammarElement(e.grammarSource)),i.astNode=r.astNodes.get(e.astNode),Ll(i))for(let a of e.content){let s=this.hydrateCstNode(a,r,n++);i.content.push(s)}return i}hydrateCstLeafNode(e){let r=this.getTokenType(e.tokenType),n=e.offset,i=e.length,a=e.startLine,s=e.startColumn,l=e.endLine,u=e.endColumn,h=e.hidden;return new pp(n,i,{start:{line:a,character:s},end:{line:l,character:u}},r,h)}getTokenType(e){return this.lexer.definition[e]}getGrammarElementId(e){if(e)return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.get(e)}getGrammarElement(e){return this.grammarElementIdMap.size===0&&this.createGrammarElementIdMap(),this.grammarElementIdMap.getKey(e)}createGrammarElementIdMap(){let e=0;for(let r of Wo(this.grammar))G2(r)&&this.grammarElementIdMap.set(r,e++)}}});function fs(t){return{documentation:{CommentProvider:o(e=>new nb(e),"CommentProvider"),DocumentationProvider:o(e=>new rb(e),"DocumentationProvider")},parser:{AsyncParser:o(e=>new ib(e),"AsyncParser"),GrammarConfig:o(e=>pN(e),"GrammarConfig"),LangiumParser:o(e=>TM(e),"LangiumParser"),CompletionParser:o(e=>bM(e),"CompletionParser"),ValueConverter:o(()=>new yp,"ValueConverter"),TokenBuilder:o(()=>new Uu,"TokenBuilder"),Lexer:o(e=>new wp(e),"Lexer"),ParserErrorMessageProvider:o(()=>new s1,"ParserErrorMessageProvider"),LexerErrorMessageProvider:o(()=>new Jx,"LexerErrorMessageProvider")},workspace:{AstNodeLocator:o(()=>new Xx,"AstNodeLocator"),AstNodeDescriptionProvider:o(e=>new qx(e),"AstNodeDescriptionProvider"),ReferenceDescriptionProvider:o(e=>new Yx(e),"ReferenceDescriptionProvider")},references:{Linker:o(e=>new Ix(e),"Linker"),NameProvider:o(()=>new Ox,"NameProvider"),ScopeProvider:o(e=>new zx(e),"ScopeProvider"),ScopeComputation:o(e=>new Bx(e),"ScopeComputation"),References:o(e=>new Px(e),"References")},serializer:{Hydrator:o(e=>new sb(e),"Hydrator"),JsonSerializer:o(e=>new Gx(e),"JsonSerializer")},validation:{DocumentValidator:o(e=>new Wx(e),"DocumentValidator"),ValidationRegistry:o(e=>new Ux(e),"ValidationRegistry")},shared:o(()=>t.shared,"shared")}}function ds(t){return{ServiceRegistry:o(e=>new Vx(e),"ServiceRegistry"),workspace:{LangiumDocuments:o(e=>new Mx(e),"LangiumDocuments"),LangiumDocumentFactory:o(e=>new Nx(e),"LangiumDocumentFactory"),DocumentBuilder:o(e=>new Kx(e),"DocumentBuilder"),IndexManager:o(e=>new Qx(e),"IndexManager"),WorkspaceManager:o(e=>new Zx(e),"WorkspaceManager"),FileSystemProvider:o(e=>t.fileSystemProvider(e),"FileSystemProvider"),WorkspaceLock:o(()=>new ab,"WorkspaceLock"),ConfigurationProvider:o(e=>new jx(e),"ConfigurationProvider")}}}var pI=N(()=>{"use strict";mN();wM();kM();wE();EM();BM();FM();$M();zM();VM();LE();HM();WM();Hx();qM();YM();XM();KM();h1();QM();ZM();OE();oI();lI();Lx();hI();fI();dI();o(fs,"createDefaultCoreModule");o(ds,"createDefaultSharedCoreModule")});function ui(t,e,r,n,i,a,s,l,u){let h=[t,e,r,n,i,a,s,l,u].reduce(FE,{});return ace(h)}function ice(t){if(t&&t[nce])for(let e of Object.values(t))ice(e);return t}function ace(t,e){let r=new Proxy({},{deleteProperty:o(()=>!1,"deleteProperty"),set:o(()=>{throw new Error("Cannot set property on injected service container")},"set"),get:o((n,i)=>i===nce?!0:rce(n,i,t,e||r),"get"),getOwnPropertyDescriptor:o((n,i)=>(rce(n,i,t,e||r),Object.getOwnPropertyDescriptor(n,i)),"getOwnPropertyDescriptor"),has:o((n,i)=>i in t,"has"),ownKeys:o(()=>[...Object.getOwnPropertyNames(t)],"ownKeys")});return r}function rce(t,e,r,n){if(e in t){if(t[e]instanceof Error)throw new Error("Construction failure. Please make sure that your dependencies are constructable.",{cause:t[e]});if(t[e]===tce)throw new Error('Cycle detected. Please make "'+String(e)+'" lazy. Visit https://langium.org/docs/reference/configuration-services/#resolving-cyclic-dependencies');return t[e]}else if(e in r){let i=r[e];t[e]=tce;try{t[e]=typeof i=="function"?i(n):ace(i,n)}catch(a){throw t[e]=a instanceof Error?a:void 0,a}return t[e]}else return}function FE(t,e){if(e){for(let[r,n]of Object.entries(e))if(n!==void 0){let i=t[r];i!==null&&n!==null&&typeof i=="object"&&typeof n=="object"?t[r]=FE(i,n):t[r]=n}}return t}var mI,nce,tce,gI=N(()=>{"use strict";(function(t){t.merge=(e,r)=>FE(FE({},e),r)})(mI||(mI={}));o(ui,"inject");nce=Symbol("isProxy");o(ice,"eagerLoad");o(ace,"_inject");tce=Symbol();o(rce,"_resolve");o(FE,"_merge")});var sce=N(()=>{"use strict"});var oce=N(()=>{"use strict";lI();oI();sI()});var lce=N(()=>{"use strict"});var cce=N(()=>{"use strict";mN();lce()});var yI,Tp,$E,vI,uce=N(()=>{"use strict";cf();wE();OE();yI={indentTokenName:"INDENT",dedentTokenName:"DEDENT",whitespaceTokenName:"WS",ignoreIndentationDelimiters:[]};(function(t){t.REGULAR="indentation-sensitive",t.IGNORE_INDENTATION="ignore-indentation"})(Tp||(Tp={}));$E=class extends Uu{static{o(this,"IndentationAwareTokenBuilder")}constructor(e=yI){super(),this.indentationStack=[0],this.whitespaceRegExp=/[ \t]+/y,this.options=Object.assign(Object.assign({},yI),e),this.indentTokenType=of({name:this.options.indentTokenName,pattern:this.indentMatcher.bind(this),line_breaks:!1}),this.dedentTokenType=of({name:this.options.dedentTokenName,pattern:this.dedentMatcher.bind(this),line_breaks:!1})}buildTokens(e,r){let n=super.buildTokens(e,r);if(!IE(n))throw new Error("Invalid tokens built by default builder");let{indentTokenName:i,dedentTokenName:a,whitespaceTokenName:s,ignoreIndentationDelimiters:l}=this.options,u,h,f,d=[];for(let p of n){for(let[m,g]of l)p.name===m?p.PUSH_MODE=Tp.IGNORE_INDENTATION:p.name===g&&(p.POP_MODE=!0);p.name===a?u=p:p.name===i?h=p:p.name===s?f=p:d.push(p)}if(!u||!h||!f)throw new Error("Some indentation/whitespace tokens not found!");return l.length>0?{modes:{[Tp.REGULAR]:[u,h,...d,f],[Tp.IGNORE_INDENTATION]:[...d,f]},defaultMode:Tp.REGULAR}:[u,h,f,...d]}flushLexingReport(e){let r=super.flushLexingReport(e);return Object.assign(Object.assign({},r),{remainingDedents:this.flushRemainingDedents(e)})}isStartOfLine(e,r){return r===0||`\r +`.includes(e[r-1])}matchWhitespace(e,r,n,i){var a;this.whitespaceRegExp.lastIndex=r;let s=this.whitespaceRegExp.exec(e);return{currIndentLevel:(a=s?.[0].length)!==null&&a!==void 0?a:0,prevIndentLevel:this.indentationStack.at(-1),match:s}}createIndentationTokenInstance(e,r,n,i){let a=this.getLineNumber(r,i);return $u(e,n,i,i+n.length,a,a,1,n.length)}getLineNumber(e,r){return e.substring(0,r).split(/\r\n|\r|\n/).length}indentMatcher(e,r,n,i){if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:a,prevIndentLevel:s,match:l}=this.matchWhitespace(e,r,n,i);return a<=s?null:(this.indentationStack.push(a),l)}dedentMatcher(e,r,n,i){var a,s,l,u;if(!this.isStartOfLine(e,r))return null;let{currIndentLevel:h,prevIndentLevel:f,match:d}=this.matchWhitespace(e,r,n,i);if(h>=f)return null;let p=this.indentationStack.lastIndexOf(h);if(p===-1)return this.diagnostics.push({severity:"error",message:`Invalid dedent level ${h} at offset: ${r}. Current indentation stack: ${this.indentationStack}`,offset:r,length:(s=(a=d?.[0])===null||a===void 0?void 0:a.length)!==null&&s!==void 0?s:0,line:this.getLineNumber(e,r),column:1}),null;let m=this.indentationStack.length-p-1,g=(u=(l=e.substring(0,r).match(/[\r\n]+$/))===null||l===void 0?void 0:l[0].length)!==null&&u!==void 0?u:1;for(let y=0;y1;)r.push(this.createIndentationTokenInstance(this.dedentTokenType,e,"",e.length)),this.indentationStack.pop();return this.indentationStack=[0],r}},vI=class extends wp{static{o(this,"IndentationAwareLexer")}constructor(e){if(super(e),e.parser.TokenBuilder instanceof $E)this.indentationTokenBuilder=e.parser.TokenBuilder;else throw new Error("IndentationAwareLexer requires an accompanying IndentationAwareTokenBuilder")}tokenize(e,r=ME){let n=super.tokenize(e),i=n.report;r?.mode==="full"&&n.tokens.push(...i.remainingDedents),i.remainingDedents=[];let{indentTokenType:a,dedentTokenType:s}=this.indentationTokenBuilder,l=a.tokenTypeIdx,u=s.tokenTypeIdx,h=[],f=n.tokens.length-1;for(let d=0;d=0&&h.push(n.tokens[f]),n.tokens=h,n}}});var hce=N(()=>{"use strict"});var fce=N(()=>{"use strict";hI();wM();gE();uce();kM();Lx();OE();bE();hce();wE();EM()});var dce=N(()=>{"use strict";BM();FM();$M();GM();zM();VM()});var pce=N(()=>{"use strict";dI();LE()});var zE,ps,xI=N(()=>{"use strict";zE=class{static{o(this,"EmptyFileSystemProvider")}readFile(){throw new Error("No file system is available.")}async readDirectory(){return[]}},ps={fileSystemProvider:o(()=>new zE,"fileSystemProvider")}});function IFe(){let t=ui(ds(ps),MFe),e=ui(fs({shared:t}),NFe);return t.ServiceRegistry.register(e),e}function Hu(t){var e;let r=IFe(),n=r.serializer.JsonSerializer.deserialize(t);return r.shared.workspace.LangiumDocumentFactory.fromModel(n,us.parse(`memory://${(e=n.name)!==null&&e!==void 0?e:"grammar"}.langium`)),n}var NFe,MFe,mce=N(()=>{"use strict";pI();gI();Rc();xI();Fc();NFe={Grammar:o(()=>{},"Grammar"),LanguageMetaData:o(()=>({caseInsensitive:!1,fileExtensions:[".langium"],languageId:"langium"}),"LanguageMetaData")},MFe={AstReflection:o(()=>new Cg,"AstReflection")};o(IFe,"createMinimalGrammarServices");o(Hu,"loadGrammarFromJson")});var Gr={};hr(Gr,{AstUtils:()=>xk,BiMap:()=>vp,Cancellation:()=>yr,ContextCache:()=>xp,CstUtils:()=>ck,DONE_RESULT:()=>Ia,Deferred:()=>cs,Disposable:()=>ff,DisposableCache:()=>p1,DocumentCache:()=>_E,EMPTY_STREAM:()=>I2,ErrorWithLocation:()=>Zd,GrammarUtils:()=>Ek,MultiMap:()=>Bl,OperationCancelled:()=>Pc,Reduction:()=>zm,RegExpUtils:()=>Tk,SimpleCache:()=>$x,StreamImpl:()=>ao,TreeStreamImpl:()=>_c,URI:()=>us,UriUtils:()=>hs,WorkspaceCache:()=>m1,assertUnreachable:()=>Lc,delayNextTick:()=>MM,interruptAndCheck:()=>xi,isOperationCancelled:()=>Bc,loadGrammarFromJson:()=>Hu,setInterruptionPeriod:()=>$le,startCancelableOperation:()=>CE,stream:()=>en});var gce=N(()=>{"use strict";DE();NE();Sr(Gr,Kn);f1();jM();uk();mce();Yo();Ps();Fc();is();qo();Nl();Ol();Lg()});var yce=N(()=>{"use strict";WM();Hx()});var vce=N(()=>{"use strict";qM();YM();XM();KM();h1();xI();QM();fI();ZM()});var xa={};hr(xa,{AbstractAstReflection:()=>Xd,AbstractCstNode:()=>Cx,AbstractLangiumParser:()=>Ax,AbstractParserErrorMessageProvider:()=>vE,AbstractThreadedAsyncParser:()=>cI,AstUtils:()=>xk,BiMap:()=>vp,Cancellation:()=>yr,CompositeCstNodeImpl:()=>mp,ContextCache:()=>xp,CstNodeBuilder:()=>Sx,CstUtils:()=>ck,DEFAULT_TOKENIZE_OPTIONS:()=>ME,DONE_RESULT:()=>Ia,DatatypeSymbol:()=>yE,DefaultAstNodeDescriptionProvider:()=>qx,DefaultAstNodeLocator:()=>Xx,DefaultAsyncParser:()=>ib,DefaultCommentProvider:()=>nb,DefaultConfigurationProvider:()=>jx,DefaultDocumentBuilder:()=>Kx,DefaultDocumentValidator:()=>Wx,DefaultHydrator:()=>sb,DefaultIndexManager:()=>Qx,DefaultJsonSerializer:()=>Gx,DefaultLangiumDocumentFactory:()=>Nx,DefaultLangiumDocuments:()=>Mx,DefaultLexer:()=>wp,DefaultLexerErrorMessageProvider:()=>Jx,DefaultLinker:()=>Ix,DefaultNameProvider:()=>Ox,DefaultReferenceDescriptionProvider:()=>Yx,DefaultReferences:()=>Px,DefaultScopeComputation:()=>Bx,DefaultScopeProvider:()=>zx,DefaultServiceRegistry:()=>Vx,DefaultTokenBuilder:()=>Uu,DefaultValueConverter:()=>yp,DefaultWorkspaceLock:()=>ab,DefaultWorkspaceManager:()=>Zx,Deferred:()=>cs,Disposable:()=>ff,DisposableCache:()=>p1,DocumentCache:()=>_E,DocumentState:()=>kn,DocumentValidator:()=>jo,EMPTY_SCOPE:()=>xFe,EMPTY_STREAM:()=>I2,EmptyFileSystem:()=>ps,EmptyFileSystemProvider:()=>zE,ErrorWithLocation:()=>Zd,GrammarAST:()=>U2,GrammarUtils:()=>Ek,IndentationAwareLexer:()=>vI,IndentationAwareTokenBuilder:()=>$E,JSDocDocumentationProvider:()=>rb,LangiumCompletionParser:()=>Dx,LangiumParser:()=>_x,LangiumParserErrorMessageProvider:()=>s1,LeafCstNodeImpl:()=>pp,LexingMode:()=>Tp,MapScope:()=>Fx,Module:()=>mI,MultiMap:()=>Bl,OperationCancelled:()=>Pc,ParserWorker:()=>uI,Reduction:()=>zm,RegExpUtils:()=>Tk,RootCstNodeImpl:()=>a1,SimpleCache:()=>$x,StreamImpl:()=>ao,StreamScope:()=>d1,TextDocument:()=>c1,TreeStreamImpl:()=>_c,URI:()=>us,UriUtils:()=>hs,ValidationCategory:()=>g1,ValidationRegistry:()=>Ux,ValueConverter:()=>Oc,WorkspaceCache:()=>m1,assertUnreachable:()=>Lc,createCompletionParser:()=>bM,createDefaultCoreModule:()=>fs,createDefaultSharedCoreModule:()=>ds,createGrammarConfig:()=>pN,createLangiumParser:()=>TM,createParser:()=>Rx,delayNextTick:()=>MM,diagnosticData:()=>bp,eagerLoad:()=>ice,getDiagnosticRange:()=>Yle,indentationBuilderDefaultOptions:()=>yI,inject:()=>ui,interruptAndCheck:()=>xi,isAstNode:()=>ii,isAstNodeDescription:()=>kR,isAstNodeWithComment:()=>UM,isCompositeCstNode:()=>Ll,isIMultiModeLexerDefinition:()=>eI,isJSDoc:()=>iI,isLeafCstNode:()=>af,isLinkingError:()=>jd,isNamed:()=>Wle,isOperationCancelled:()=>Bc,isReference:()=>va,isRootCstNode:()=>M2,isTokenTypeArray:()=>IE,isTokenTypeDictionary:()=>JM,loadGrammarFromJson:()=>Hu,parseJSDoc:()=>nI,prepareLangiumParser:()=>Nle,setInterruptionPeriod:()=>$le,startCancelableOperation:()=>CE,stream:()=>en,toDiagnosticData:()=>Xle,toDiagnosticSeverity:()=>RE});var Xo=N(()=>{"use strict";pI();gI();HM();sce();Rl();oce();cce();fce();dce();pce();gce();Sr(xa,Gr);yce();vce();Rc()});function Sce(t){return Fl.isInstance(t,ob)}function Cce(t){return Fl.isInstance(t,y1)}function Ace(t){return Fl.isInstance(t,v1)}function _ce(t){return Fl.isInstance(t,WE)}function Dce(t){return Fl.isInstance(t,x1)}function Lce(t){return Fl.isInstance(t,lb)}function Rce(t){return Fl.isInstance(t,b1)}function Nce(t){return Fl.isInstance(t,cb)}function Mce(t){return Fl.isInstance(t,ub)}function Ice(t){return Fl.isInstance(t,hb)}function Oce(t){return Fl.isInstance(t,fb)}var OFe,Lt,AI,ob,GE,y1,VE,UE,v1,WE,bI,wI,TI,x1,kI,lb,EI,b1,SI,cb,ub,hb,fb,qE,CI,HE,Pce,Fl,xce,PFe,bce,BFe,wce,FFe,Tce,$Fe,kce,zFe,Ece,GFe,VFe,UFe,HFe,WFe,qFe,YFe,co,_I,DI,LI,RI,NI,MI,XFe,jFe,KFe,QFe,w1,Wu,$s,ZFe,zs=N(()=>{"use strict";Xo();Xo();Xo();Xo();OFe=Object.defineProperty,Lt=o((t,e)=>OFe(t,"name",{value:e,configurable:!0}),"__name"),AI="Statement",ob="Architecture";o(Sce,"isArchitecture");Lt(Sce,"isArchitecture");GE="Axis",y1="Branch";o(Cce,"isBranch");Lt(Cce,"isBranch");VE="Checkout",UE="CherryPicking",v1="Commit";o(Ace,"isCommit");Lt(Ace,"isCommit");WE="Common";o(_ce,"isCommon");Lt(_ce,"isCommon");bI="Curve",wI="Edge",TI="Entry",x1="GitGraph";o(Dce,"isGitGraph");Lt(Dce,"isGitGraph");kI="Group",lb="Info";o(Lce,"isInfo");Lt(Lce,"isInfo");EI="Junction",b1="Merge";o(Rce,"isMerge");Lt(Rce,"isMerge");SI="Option",cb="Packet";o(Nce,"isPacket");Lt(Nce,"isPacket");ub="PacketBlock";o(Mce,"isPacketBlock");Lt(Mce,"isPacketBlock");hb="Pie";o(Ice,"isPie");Lt(Ice,"isPie");fb="PieSection";o(Oce,"isPieSection");Lt(Oce,"isPieSection");qE="Radar",CI="Service",HE="Direction",Pce=class extends Xd{static{o(this,"MermaidAstReflection")}static{Lt(this,"MermaidAstReflection")}getAllTypes(){return[ob,GE,y1,VE,UE,v1,WE,bI,HE,wI,TI,x1,kI,lb,EI,b1,SI,cb,ub,hb,fb,qE,CI,AI]}computeIsSubtype(t,e){switch(t){case y1:case VE:case UE:case v1:case b1:return this.isSubtype(AI,e);case HE:return this.isSubtype(x1,e);default:return!1}}getReferenceType(t){let e=`${t.container.$type}:${t.property}`;switch(e){case"Entry:axis":return GE;default:throw new Error(`${e} is not a valid reference id.`)}}getTypeMetaData(t){switch(t){case ob:return{name:ob,properties:[{name:"accDescr"},{name:"accTitle"},{name:"edges",defaultValue:[]},{name:"groups",defaultValue:[]},{name:"junctions",defaultValue:[]},{name:"services",defaultValue:[]},{name:"title"}]};case GE:return{name:GE,properties:[{name:"label"},{name:"name"}]};case y1:return{name:y1,properties:[{name:"name"},{name:"order"}]};case VE:return{name:VE,properties:[{name:"branch"}]};case UE:return{name:UE,properties:[{name:"id"},{name:"parent"},{name:"tags",defaultValue:[]}]};case v1:return{name:v1,properties:[{name:"id"},{name:"message"},{name:"tags",defaultValue:[]},{name:"type"}]};case WE:return{name:WE,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"}]};case bI:return{name:bI,properties:[{name:"entries",defaultValue:[]},{name:"label"},{name:"name"}]};case wI:return{name:wI,properties:[{name:"lhsDir"},{name:"lhsGroup",defaultValue:!1},{name:"lhsId"},{name:"lhsInto",defaultValue:!1},{name:"rhsDir"},{name:"rhsGroup",defaultValue:!1},{name:"rhsId"},{name:"rhsInto",defaultValue:!1},{name:"title"}]};case TI:return{name:TI,properties:[{name:"axis"},{name:"value"}]};case x1:return{name:x1,properties:[{name:"accDescr"},{name:"accTitle"},{name:"statements",defaultValue:[]},{name:"title"}]};case kI:return{name:kI,properties:[{name:"icon"},{name:"id"},{name:"in"},{name:"title"}]};case lb:return{name:lb,properties:[{name:"accDescr"},{name:"accTitle"},{name:"title"}]};case EI:return{name:EI,properties:[{name:"id"},{name:"in"}]};case b1:return{name:b1,properties:[{name:"branch"},{name:"id"},{name:"tags",defaultValue:[]},{name:"type"}]};case SI:return{name:SI,properties:[{name:"name"},{name:"value",defaultValue:!1}]};case cb:return{name:cb,properties:[{name:"accDescr"},{name:"accTitle"},{name:"blocks",defaultValue:[]},{name:"title"}]};case ub:return{name:ub,properties:[{name:"end"},{name:"label"},{name:"start"}]};case hb:return{name:hb,properties:[{name:"accDescr"},{name:"accTitle"},{name:"sections",defaultValue:[]},{name:"showData",defaultValue:!1},{name:"title"}]};case fb:return{name:fb,properties:[{name:"label"},{name:"value"}]};case qE:return{name:qE,properties:[{name:"accDescr"},{name:"accTitle"},{name:"axes",defaultValue:[]},{name:"curves",defaultValue:[]},{name:"options",defaultValue:[]},{name:"title"}]};case CI:return{name:CI,properties:[{name:"icon"},{name:"iconText"},{name:"id"},{name:"in"},{name:"title"}]};case HE:return{name:HE,properties:[{name:"accDescr"},{name:"accTitle"},{name:"dir"},{name:"statements",defaultValue:[]},{name:"title"}]};default:return{name:t,properties:[]}}}},Fl=new Pce,PFe=Lt(()=>xce??(xce=Hu('{"$type":"Grammar","isDeclared":true,"name":"Info","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Info","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"info"},{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[],"cardinality":"*"},{"$type":"Group","elements":[{"$type":"Keyword","value":"showInfo"},{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[],"cardinality":"*"}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[{"$type":"Interface","name":"Common","attributes":[{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"types":[],"usedGrammars":[]}')),"InfoGrammar"),BFe=Lt(()=>bce??(bce=Hu(`{"$type":"Grammar","isDeclared":true,"name":"Packet","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Packet","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"packet-beta"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Assignment","feature":"blocks","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]},"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"+"},{"$type":"Assignment","feature":"blocks","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]},"cardinality":"+"}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"*"}]}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PacketBlock","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"start","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"end","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}}],"cardinality":"?"},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/0|[1-9][0-9]*/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","definition":{"$type":"RegexToken","regex":"/\\"[^\\"]*\\"|'[^']*'/"},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[{"$type":"Interface","name":"Common","attributes":[{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"types":[],"usedGrammars":[]}`)),"PacketGrammar"),FFe=Lt(()=>wce??(wce=Hu('{"$type":"Grammar","isDeclared":true,"name":"Pie","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Pie","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"pie"},{"$type":"Assignment","feature":"showData","operator":"?=","terminal":{"$type":"Keyword","value":"showData"},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Assignment","feature":"sections","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]},"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"+"},{"$type":"Assignment","feature":"sections","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]},"cardinality":"+"}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"*"}]}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"PieSection","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}},{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"PIE_SECTION_LABEL","definition":{"$type":"RegexToken","regex":"/\\"[^\\"]+\\"/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"PIE_SECTION_VALUE","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/(0|[1-9][0-9]*)(\\\\.[0-9]+)?/"},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[{"$type":"Interface","name":"Common","attributes":[{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"types":[],"usedGrammars":[]}')),"PieGrammar"),$Fe=Lt(()=>Tce??(Tce=Hu('{"$type":"Grammar","isDeclared":true,"name":"Architecture","imports":[],"rules":[{"$type":"ParserRule","entry":true,"name":"Architecture","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[],"cardinality":"*"},{"$type":"Keyword","value":"architecture-beta"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}]},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[],"cardinality":"*"}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[],"cardinality":"*"}]}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"groups","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}},{"$type":"Assignment","feature":"services","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@6"},"arguments":[]}},{"$type":"Assignment","feature":"junctions","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@7"},"arguments":[]}},{"$type":"Assignment","feature":"edges","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@8"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"LeftPort","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":":"},{"$type":"Assignment","feature":"lhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"RightPort","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"rhsDir","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@9"},"arguments":[]}},{"$type":"Keyword","value":":"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Arrow","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]},{"$type":"Assignment","feature":"lhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]},"cardinality":"?"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"--"},{"$type":"Group","elements":[{"$type":"Keyword","value":"-"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}},{"$type":"Keyword","value":"-"}]}]},{"$type":"Assignment","feature":"rhsInto","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Group","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"group"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]},"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Service","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"service"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}},{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"iconText","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"Assignment","feature":"icon","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}}],"cardinality":"?"},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]},"cardinality":"?"},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Junction","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"junction"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"in"},{"$type":"Assignment","feature":"in","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Edge","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"lhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}},{"$type":"Assignment","feature":"lhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]},{"$type":"Assignment","feature":"rhsId","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@10"},"arguments":[]}},{"$type":"Assignment","feature":"rhsGroup","operator":"?=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]},"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"ARROW_DIRECTION","definition":{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"L"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"R"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"T"}}]},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"B"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARCH_ID","definition":{"$type":"RegexToken","regex":"/[\\\\w]+/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARCH_TEXT_ICON","definition":{"$type":"RegexToken","regex":"/\\\\(\\"[^\\"]+\\"\\\\)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARCH_ICON","definition":{"$type":"RegexToken","regex":"/\\\\([\\\\w-:]+\\\\)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARCH_TITLE","definition":{"$type":"RegexToken","regex":"/\\\\[[\\\\w ]+\\\\]/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_GROUP","definition":{"$type":"RegexToken","regex":"/\\\\{group\\\\}/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ARROW_INTO","definition":{"$type":"RegexToken","regex":"/<|>/"},"fragment":false,"hidden":false},{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false}],"definesHiddenTokens":false,"hiddenTokens":[],"interfaces":[{"$type":"Interface","name":"Common","attributes":[{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"types":[],"usedGrammars":[]}')),"ArchitectureGrammar"),zFe=Lt(()=>kce??(kce=Hu(`{"$type":"Grammar","isDeclared":true,"name":"GitGraph","interfaces":[{"$type":"Interface","name":"Common","attributes":[{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]}],"rules":[{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"ParserRule","entry":true,"name":"GitGraph","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"Keyword","value":":"}]},{"$type":"Keyword","value":"gitGraph:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"gitGraph"},{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]},{"$type":"Keyword","value":":"}]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@0"},"arguments":[]},{"$type":"Assignment","feature":"statements","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}],"cardinality":"*"}]}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Statement","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Direction","definition":{"$type":"Assignment","feature":"dir","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"LR"},{"$type":"Keyword","value":"TB"},{"$type":"Keyword","value":"BT"}]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Commit","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"commit"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"msg:","cardinality":"?"},{"$type":"Assignment","feature":"message","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Branch","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"branch"},{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}]}},{"$type":"Group","elements":[{"$type":"Keyword","value":"order:"},{"$type":"Assignment","feature":"order","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}}],"cardinality":"?"},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Merge","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"merge"},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}]}},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"type:"},{"$type":"Assignment","feature":"type","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"NORMAL"},{"$type":"Keyword","value":"REVERSE"},{"$type":"Keyword","value":"HIGHLIGHT"}]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Checkout","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"checkout"},{"$type":"Keyword","value":"switch"}]},{"$type":"Assignment","feature":"branch","operator":"=","terminal":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]},{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"CherryPicking","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"cherry-pick"},{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Keyword","value":"id:"},{"$type":"Assignment","feature":"id","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"tag:"},{"$type":"Assignment","feature":"tags","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"parent:"},{"$type":"Assignment","feature":"parent","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"INT","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/[0-9]+(?=\\\\s)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/\\\\w([-\\\\./\\\\w]*[-\\\\w])?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","definition":{"$type":"RegexToken","regex":"/\\"[^\\"]*\\"|'[^']*'/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"imports":[],"types":[],"usedGrammars":[]}`)),"GitGraphGrammar"),GFe=Lt(()=>Ece??(Ece=Hu(`{"$type":"Grammar","isDeclared":true,"name":"Radar","interfaces":[{"$type":"Interface","name":"Common","attributes":[{"$type":"TypeAttribute","name":"accDescr","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"accTitle","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}},{"$type":"TypeAttribute","name":"title","isOptional":true,"type":{"$type":"SimpleType","primitiveType":"string"}}],"superTypes":[]},{"$type":"Interface","name":"Entry","attributes":[{"$type":"TypeAttribute","name":"axis","isOptional":true,"type":{"$type":"ReferenceType","referenceType":{"$type":"SimpleType","typeRef":{"$ref":"#/rules@12"}}}},{"$type":"TypeAttribute","name":"value","type":{"$type":"SimpleType","primitiveType":"number"},"isOptional":false}],"superTypes":[]}],"rules":[{"$type":"ParserRule","fragment":true,"name":"TitleAndAccessibilities","definition":{"$type":"Group","elements":[{"$type":"Alternatives","elements":[{"$type":"Assignment","feature":"accDescr","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@3"},"arguments":[]}},{"$type":"Assignment","feature":"accTitle","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@4"},"arguments":[]}},{"$type":"Assignment","feature":"title","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@5"},"arguments":[]}}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@1"},"arguments":[]}],"cardinality":"+"},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"EOL","dataType":"string","definition":{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"+"},{"$type":"EndOfFile"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"NEWLINE","definition":{"$type":"RegexToken","regex":"/\\\\r?\\\\n/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_DESCR","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accDescr(?:[\\\\t ]*:([^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)|\\\\s*{([^}]*)})/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ACC_TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*accTitle[\\\\t ]*:(?:[^\\\\n\\\\r]*?(?=%%)|[^\\\\n\\\\r]*)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"TITLE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*title(?:[\\\\t ][^\\\\n\\\\r]*?(?=%%)|[\\\\t ][^\\\\n\\\\r]*|)/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","hidden":true,"name":"WHITESPACE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]+/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"YAML","definition":{"$type":"RegexToken","regex":"/---[\\\\t ]*\\\\r?\\\\n(?:[\\\\S\\\\s]*?\\\\r?\\\\n)?---(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"DIRECTIVE","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%{[\\\\S\\\\s]*?}%%(?:\\\\r?\\\\n|(?!\\\\S))/"},"fragment":false},{"$type":"TerminalRule","hidden":true,"name":"SINGLE_LINE_COMMENT","definition":{"$type":"RegexToken","regex":"/[\\\\t ]*%%[^\\\\n\\\\r]*/"},"fragment":false},{"$type":"ParserRule","entry":true,"name":"Radar","definition":{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":"radar-beta:"},{"$type":"Group","elements":[{"$type":"Keyword","value":"radar-beta"},{"$type":"Keyword","value":":"}]}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Alternatives","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@0"},"arguments":[]},{"$type":"Group","elements":[{"$type":"Keyword","value":"axis"},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"axes","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@12"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Keyword","value":"curve"},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"curves","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@13"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"Assignment","feature":"options","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@17"},"arguments":[]}}],"cardinality":"*"}]},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[]}],"cardinality":"*"}]},"definesHiddenTokens":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Label","definition":{"$type":"Group","elements":[{"$type":"Keyword","value":"["},{"$type":"Assignment","feature":"label","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@22"},"arguments":[]}},{"$type":"Keyword","value":"]"}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Axis","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[],"cardinality":"?"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Curve","definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]}},{"$type":"RuleCall","rule":{"$ref":"#/rules@11"},"arguments":[],"cardinality":"?"},{"$type":"Keyword","value":"{"},{"$type":"RuleCall","rule":{"$ref":"#/rules@14"},"arguments":[]},{"$type":"Keyword","value":"}"}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","fragment":true,"name":"Entries","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@16"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"}]},{"$type":"Group","elements":[{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}},{"$type":"Group","elements":[{"$type":"Keyword","value":","},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"},{"$type":"Assignment","feature":"entries","operator":"+=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@15"},"arguments":[]}}],"cardinality":"*"},{"$type":"RuleCall","rule":{"$ref":"#/rules@2"},"arguments":[],"cardinality":"*"}]}]},"definesHiddenTokens":false,"entry":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"DetailedEntry","returnType":{"$ref":"#/interfaces@1"},"definition":{"$type":"Group","elements":[{"$type":"Assignment","feature":"axis","operator":"=","terminal":{"$type":"CrossReference","type":{"$ref":"#/rules@12"},"terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@21"},"arguments":[]},"deprecatedSyntax":false}},{"$type":"Keyword","value":":","cardinality":"?"},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"NumberEntry","returnType":{"$ref":"#/interfaces@1"},"definition":{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"ParserRule","name":"Option","definition":{"$type":"Alternatives","elements":[{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"showLegend"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@19"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"ticks"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"max"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"min"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@18"},"arguments":[]}}]},{"$type":"Group","elements":[{"$type":"Assignment","feature":"name","operator":"=","terminal":{"$type":"Keyword","value":"graticule"}},{"$type":"Assignment","feature":"value","operator":"=","terminal":{"$type":"RuleCall","rule":{"$ref":"#/rules@20"},"arguments":[]}}]}]},"definesHiddenTokens":false,"entry":false,"fragment":false,"hiddenTokens":[],"parameters":[],"wildcard":false},{"$type":"TerminalRule","name":"NUMBER","type":{"$type":"ReturnType","name":"number"},"definition":{"$type":"RegexToken","regex":"/(0|[1-9][0-9]*)(\\\\.[0-9]+)?/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"BOOLEAN","type":{"$type":"ReturnType","name":"boolean"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"true"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"false"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"GRATICULE","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"TerminalAlternatives","elements":[{"$type":"CharacterRange","left":{"$type":"Keyword","value":"circle"}},{"$type":"CharacterRange","left":{"$type":"Keyword","value":"polygon"}}]},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"ID","type":{"$type":"ReturnType","name":"string"},"definition":{"$type":"RegexToken","regex":"/[a-zA-Z_][a-zA-Z0-9\\\\-_]*/"},"fragment":false,"hidden":false},{"$type":"TerminalRule","name":"STRING","definition":{"$type":"RegexToken","regex":"/\\"[^\\"]*\\"|'[^']*'/"},"fragment":false,"hidden":false}],"definesHiddenTokens":false,"hiddenTokens":[],"imports":[],"types":[],"usedGrammars":[]}`)),"RadarGrammar"),VFe={languageId:"info",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},UFe={languageId:"packet",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},HFe={languageId:"pie",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},WFe={languageId:"architecture",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},qFe={languageId:"gitGraph",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},YFe={languageId:"radar",fileExtensions:[".mmd",".mermaid"],caseInsensitive:!1,mode:"production"},co={AstReflection:Lt(()=>new Pce,"AstReflection")},_I={Grammar:Lt(()=>PFe(),"Grammar"),LanguageMetaData:Lt(()=>VFe,"LanguageMetaData"),parser:{}},DI={Grammar:Lt(()=>BFe(),"Grammar"),LanguageMetaData:Lt(()=>UFe,"LanguageMetaData"),parser:{}},LI={Grammar:Lt(()=>FFe(),"Grammar"),LanguageMetaData:Lt(()=>HFe,"LanguageMetaData"),parser:{}},RI={Grammar:Lt(()=>$Fe(),"Grammar"),LanguageMetaData:Lt(()=>WFe,"LanguageMetaData"),parser:{}},NI={Grammar:Lt(()=>zFe(),"Grammar"),LanguageMetaData:Lt(()=>qFe,"LanguageMetaData"),parser:{}},MI={Grammar:Lt(()=>GFe(),"Grammar"),LanguageMetaData:Lt(()=>YFe,"LanguageMetaData"),parser:{}},XFe=/accDescr(?:[\t ]*:([^\n\r]*)|\s*{([^}]*)})/,jFe=/accTitle[\t ]*:([^\n\r]*)/,KFe=/title([\t ][^\n\r]*|)/,QFe={ACC_DESCR:XFe,ACC_TITLE:jFe,TITLE:KFe},w1=class extends yp{static{o(this,"AbstractMermaidValueConverter")}static{Lt(this,"AbstractMermaidValueConverter")}runConverter(t,e,r){let n=this.runCommonConverter(t,e,r);return n===void 0&&(n=this.runCustomConverter(t,e,r)),n===void 0?super.runConverter(t,e,r):n}runCommonConverter(t,e,r){let n=QFe[t.name];if(n===void 0)return;let i=n.exec(e);if(i!==null){if(i[1]!==void 0)return i[1].trim().replace(/[\t ]{2,}/gm," ");if(i[2]!==void 0)return i[2].replace(/^\s*/gm,"").replace(/\s+$/gm,"").replace(/[\t ]{2,}/gm," ").replace(/[\n\r]{2,}/gm,` +`)}}},Wu=class extends w1{static{o(this,"CommonValueConverter")}static{Lt(this,"CommonValueConverter")}runCustomConverter(t,e,r){}},$s=class extends Uu{static{o(this,"AbstractMermaidTokenBuilder")}static{Lt(this,"AbstractMermaidTokenBuilder")}constructor(t){super(),this.keywords=new Set(t)}buildKeywordTokens(t,e,r){let n=super.buildKeywordTokens(t,e,r);return n.forEach(i=>{this.keywords.has(i.name)&&i.PATTERN!==void 0&&(i.PATTERN=new RegExp(i.PATTERN.toString()+"(?:(?=%%)|(?!\\S))"))}),n}},ZFe=class extends $s{static{o(this,"CommonTokenBuilder")}static{Lt(this,"CommonTokenBuilder")}}});function XE(t=ps){let e=ui(ds(t),co),r=ui(fs({shared:e}),NI,YE);return e.ServiceRegistry.register(r),{shared:e,GitGraph:r}}var JFe,YE,II=N(()=>{"use strict";zs();Xo();JFe=class extends $s{static{o(this,"GitGraphTokenBuilder")}static{Lt(this,"GitGraphTokenBuilder")}constructor(){super(["gitGraph"])}},YE={parser:{TokenBuilder:Lt(()=>new JFe,"TokenBuilder"),ValueConverter:Lt(()=>new Wu,"ValueConverter")}};o(XE,"createGitGraphServices");Lt(XE,"createGitGraphServices")});function KE(t=ps){let e=ui(ds(t),co),r=ui(fs({shared:e}),_I,jE);return e.ServiceRegistry.register(r),{shared:e,Info:r}}var e$e,jE,OI=N(()=>{"use strict";zs();Xo();e$e=class extends $s{static{o(this,"InfoTokenBuilder")}static{Lt(this,"InfoTokenBuilder")}constructor(){super(["info","showInfo"])}},jE={parser:{TokenBuilder:Lt(()=>new e$e,"TokenBuilder"),ValueConverter:Lt(()=>new Wu,"ValueConverter")}};o(KE,"createInfoServices");Lt(KE,"createInfoServices")});function ZE(t=ps){let e=ui(ds(t),co),r=ui(fs({shared:e}),DI,QE);return e.ServiceRegistry.register(r),{shared:e,Packet:r}}var t$e,QE,PI=N(()=>{"use strict";zs();Xo();t$e=class extends $s{static{o(this,"PacketTokenBuilder")}static{Lt(this,"PacketTokenBuilder")}constructor(){super(["packet-beta"])}},QE={parser:{TokenBuilder:Lt(()=>new t$e,"TokenBuilder"),ValueConverter:Lt(()=>new Wu,"ValueConverter")}};o(ZE,"createPacketServices");Lt(ZE,"createPacketServices")});function e6(t=ps){let e=ui(ds(t),co),r=ui(fs({shared:e}),LI,JE);return e.ServiceRegistry.register(r),{shared:e,Pie:r}}var r$e,n$e,JE,BI=N(()=>{"use strict";zs();Xo();r$e=class extends $s{static{o(this,"PieTokenBuilder")}static{Lt(this,"PieTokenBuilder")}constructor(){super(["pie","showData"])}},n$e=class extends w1{static{o(this,"PieValueConverter")}static{Lt(this,"PieValueConverter")}runCustomConverter(t,e,r){if(t.name==="PIE_SECTION_LABEL")return e.replace(/"/g,"").trim()}},JE={parser:{TokenBuilder:Lt(()=>new r$e,"TokenBuilder"),ValueConverter:Lt(()=>new n$e,"ValueConverter")}};o(e6,"createPieServices");Lt(e6,"createPieServices")});function r6(t=ps){let e=ui(ds(t),co),r=ui(fs({shared:e}),RI,t6);return e.ServiceRegistry.register(r),{shared:e,Architecture:r}}var i$e,a$e,t6,FI=N(()=>{"use strict";zs();Xo();i$e=class extends $s{static{o(this,"ArchitectureTokenBuilder")}static{Lt(this,"ArchitectureTokenBuilder")}constructor(){super(["architecture"])}},a$e=class extends w1{static{o(this,"ArchitectureValueConverter")}static{Lt(this,"ArchitectureValueConverter")}runCustomConverter(t,e,r){if(t.name==="ARCH_ICON")return e.replace(/[()]/g,"").trim();if(t.name==="ARCH_TEXT_ICON")return e.replace(/["()]/g,"");if(t.name==="ARCH_TITLE")return e.replace(/[[\]]/g,"").trim()}},t6={parser:{TokenBuilder:Lt(()=>new i$e,"TokenBuilder"),ValueConverter:Lt(()=>new a$e,"ValueConverter")}};o(r6,"createArchitectureServices");Lt(r6,"createArchitectureServices")});function i6(t=ps){let e=ui(ds(t),co),r=ui(fs({shared:e}),MI,n6);return e.ServiceRegistry.register(r),{shared:e,Radar:r}}var s$e,n6,$I=N(()=>{"use strict";zs();Xo();s$e=class extends $s{static{o(this,"RadarTokenBuilder")}static{Lt(this,"RadarTokenBuilder")}constructor(){super(["radar-beta"])}},n6={parser:{TokenBuilder:Lt(()=>new s$e,"TokenBuilder"),ValueConverter:Lt(()=>new Wu,"ValueConverter")}};o(i6,"createRadarServices");Lt(i6,"createRadarServices")});var Bce={};hr(Bce,{InfoModule:()=>jE,createInfoServices:()=>KE});var Fce=N(()=>{"use strict";OI();zs()});var $ce={};hr($ce,{PacketModule:()=>QE,createPacketServices:()=>ZE});var zce=N(()=>{"use strict";PI();zs()});var Gce={};hr(Gce,{PieModule:()=>JE,createPieServices:()=>e6});var Vce=N(()=>{"use strict";BI();zs()});var Uce={};hr(Uce,{ArchitectureModule:()=>t6,createArchitectureServices:()=>r6});var Hce=N(()=>{"use strict";FI();zs()});var Wce={};hr(Wce,{GitGraphModule:()=>YE,createGitGraphServices:()=>XE});var qce=N(()=>{"use strict";II();zs()});var Yce={};hr(Yce,{RadarModule:()=>n6,createRadarServices:()=>i6});var Xce=N(()=>{"use strict";$I();zs()});async function uo(t,e){let r=o$e[t];if(!r)throw new Error(`Unknown diagram type: ${t}`);df[t]||await r();let i=df[t].parse(e);if(i.lexerErrors.length>0||i.parserErrors.length>0)throw new l$e(i);return i.value}var df,o$e,l$e,kp=N(()=>{"use strict";II();OI();PI();BI();FI();$I();zs();df={},o$e={info:Lt(async()=>{let{createInfoServices:t}=await Promise.resolve().then(()=>(Fce(),Bce)),e=t().Info.parser.LangiumParser;df.info=e},"info"),packet:Lt(async()=>{let{createPacketServices:t}=await Promise.resolve().then(()=>(zce(),$ce)),e=t().Packet.parser.LangiumParser;df.packet=e},"packet"),pie:Lt(async()=>{let{createPieServices:t}=await Promise.resolve().then(()=>(Vce(),Gce)),e=t().Pie.parser.LangiumParser;df.pie=e},"pie"),architecture:Lt(async()=>{let{createArchitectureServices:t}=await Promise.resolve().then(()=>(Hce(),Uce)),e=t().Architecture.parser.LangiumParser;df.architecture=e},"architecture"),gitGraph:Lt(async()=>{let{createGitGraphServices:t}=await Promise.resolve().then(()=>(qce(),Wce)),e=t().GitGraph.parser.LangiumParser;df.gitGraph=e},"gitGraph"),radar:Lt(async()=>{let{createRadarServices:t}=await Promise.resolve().then(()=>(Xce(),Yce)),e=t().Radar.parser.LangiumParser;df.radar=e},"radar")};o(uo,"parse");Lt(uo,"parse");l$e=class extends Error{static{o(this,"MermaidParseError")}constructor(t){let e=t.lexerErrors.map(n=>n.message).join(` +`),r=t.parserErrors.map(n=>n.message).join(` +`);super(`Parsing failed: ${e} ${r}`),this.result=t}static{Lt(this,"MermaidParseError")}}});function $c(t,e){t.accDescr&&e.setAccDescription?.(t.accDescr),t.accTitle&&e.setAccTitle?.(t.accTitle),t.title&&e.setDiagramTitle?.(t.title)}var T1=N(()=>{"use strict";o($c,"populateCommonDb")});var Kr,a6=N(()=>{"use strict";Kr={NORMAL:0,REVERSE:1,HIGHLIGHT:2,MERGE:3,CHERRY_PICK:4}});var pf,s6=N(()=>{"use strict";pf=class{constructor(e){this.init=e;this.records=this.init()}static{o(this,"ImperativeState")}reset(){this.records=this.init()}}});function zI(){return j9({length:7})}function u$e(t,e){let r=Object.create(null);return t.reduce((n,i)=>{let a=e(i);return r[a]||(r[a]=!0,n.push(i)),n},[])}function jce(t,e,r){let n=t.indexOf(e);n===-1?t.push(r):t.splice(n,1,r)}function Qce(t){let e=t.reduce((i,a)=>i.seq>a.seq?i:a,t[0]),r="";t.forEach(function(i){i===e?r+=" *":r+=" |"});let n=[r,e.id,e.seq];for(let i in _t.records.branches)_t.records.branches.get(i)===e.id&&n.push(i);if(Y.debug(n.join(" ")),e.parents&&e.parents.length==2&&e.parents[0]&&e.parents[1]){let i=_t.records.commits.get(e.parents[0]);jce(t,e,i),e.parents[1]&&t.push(_t.records.commits.get(e.parents[1]))}else{if(e.parents.length==0)return;if(e.parents[0]){let i=_t.records.commits.get(e.parents[0]);jce(t,e,i)}}t=u$e(t,i=>i.id),Qce(t)}var c$e,Ep,_t,h$e,f$e,d$e,p$e,m$e,g$e,y$e,Kce,v$e,x$e,b$e,w$e,T$e,Zce,k$e,E$e,S$e,o6,GI=N(()=>{"use strict";vt();ir();ji();gr();mi();a6();s6();Ya();c$e=or.gitGraph,Ep=o(()=>Fi({...c$e,...cr().gitGraph}),"getConfig"),_t=new pf(()=>{let t=Ep(),e=t.mainBranchName,r=t.mainBranchOrder;return{mainBranchName:e,commits:new Map,head:null,branchConfig:new Map([[e,{name:e,order:r}]]),branches:new Map([[e,null]]),currBranch:e,direction:"LR",seq:0,options:{}}});o(zI,"getID");o(u$e,"uniqBy");h$e=o(function(t){_t.records.direction=t},"setDirection"),f$e=o(function(t){Y.debug("options str",t),t=t?.trim(),t=t||"{}";try{_t.records.options=JSON.parse(t)}catch(e){Y.error("error while parsing gitGraph options",e.message)}},"setOptions"),d$e=o(function(){return _t.records.options},"getOptions"),p$e=o(function(t){let e=t.msg,r=t.id,n=t.type,i=t.tags;Y.info("commit",e,r,n,i),Y.debug("Entering commit:",e,r,n,i);let a=Ep();r=Ze.sanitizeText(r,a),e=Ze.sanitizeText(e,a),i=i?.map(l=>Ze.sanitizeText(l,a));let s={id:r||_t.records.seq+"-"+zI(),message:e,seq:_t.records.seq++,type:n??Kr.NORMAL,tags:i??[],parents:_t.records.head==null?[]:[_t.records.head.id],branch:_t.records.currBranch};_t.records.head=s,Y.info("main branch",a.mainBranchName),_t.records.commits.set(s.id,s),_t.records.branches.set(_t.records.currBranch,s.id),Y.debug("in pushCommit "+s.id)},"commit"),m$e=o(function(t){let e=t.name,r=t.order;if(e=Ze.sanitizeText(e,Ep()),_t.records.branches.has(e))throw new Error(`Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using "checkout ${e}")`);_t.records.branches.set(e,_t.records.head!=null?_t.records.head.id:null),_t.records.branchConfig.set(e,{name:e,order:r}),Kce(e),Y.debug("in createBranch")},"branch"),g$e=o(t=>{let e=t.branch,r=t.id,n=t.type,i=t.tags,a=Ep();e=Ze.sanitizeText(e,a),r&&(r=Ze.sanitizeText(r,a));let s=_t.records.branches.get(_t.records.currBranch),l=_t.records.branches.get(e),u=s?_t.records.commits.get(s):void 0,h=l?_t.records.commits.get(l):void 0;if(u&&h&&u.branch===e)throw new Error(`Cannot merge branch '${e}' into itself.`);if(_t.records.currBranch===e){let p=new Error('Incorrect usage of "merge". Cannot merge a branch to itself');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(u===void 0||!u){let p=new Error(`Incorrect usage of "merge". Current branch (${_t.records.currBranch})has no commits`);throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["commit"]},p}if(!_t.records.branches.has(e)){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") does not exist");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:[`branch ${e}`]},p}if(h===void 0||!h){let p=new Error('Incorrect usage of "merge". Branch to be merged ('+e+") has no commits");throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:['"commit"']},p}if(u===h){let p=new Error('Incorrect usage of "merge". Both branches have same head');throw p.hash={text:`merge ${e}`,token:`merge ${e}`,expected:["branch abc"]},p}if(r&&_t.records.commits.has(r)){let p=new Error('Incorrect usage of "merge". Commit with id:'+r+" already exists, use different custom Id");throw p.hash={text:`merge ${e} ${r} ${n} ${i?.join(" ")}`,token:`merge ${e} ${r} ${n} ${i?.join(" ")}`,expected:[`merge ${e} ${r}_UNIQUE ${n} ${i?.join(" ")}`]},p}let f=l||"",d={id:r||`${_t.records.seq}-${zI()}`,message:`merged branch ${e} into ${_t.records.currBranch}`,seq:_t.records.seq++,parents:_t.records.head==null?[]:[_t.records.head.id,f],branch:_t.records.currBranch,type:Kr.MERGE,customType:n,customId:!!r,tags:i??[]};_t.records.head=d,_t.records.commits.set(d.id,d),_t.records.branches.set(_t.records.currBranch,d.id),Y.debug(_t.records.branches),Y.debug("in mergeBranch")},"merge"),y$e=o(function(t){let e=t.id,r=t.targetId,n=t.tags,i=t.parent;Y.debug("Entering cherryPick:",e,r,n);let a=Ep();if(e=Ze.sanitizeText(e,a),r=Ze.sanitizeText(r,a),n=n?.map(u=>Ze.sanitizeText(u,a)),i=Ze.sanitizeText(i,a),!e||!_t.records.commits.has(e)){let u=new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');throw u.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},u}let s=_t.records.commits.get(e);if(s===void 0||!s)throw new Error('Incorrect usage of "cherryPick". Source commit id should exist and provided');if(i&&!(Array.isArray(s.parents)&&s.parents.includes(i)))throw new Error("Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.");let l=s.branch;if(s.type===Kr.MERGE&&!i)throw new Error("Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.");if(!r||!_t.records.commits.has(r)){if(l===_t.records.currBranch){let d=new Error('Incorrect usage of "cherryPick". Source commit is already on current branch');throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let u=_t.records.branches.get(_t.records.currBranch);if(u===void 0||!u){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${_t.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let h=_t.records.commits.get(u);if(h===void 0||!h){let d=new Error(`Incorrect usage of "cherry-pick". Current branch (${_t.records.currBranch})has no commits`);throw d.hash={text:`cherryPick ${e} ${r}`,token:`cherryPick ${e} ${r}`,expected:["cherry-pick abc"]},d}let f={id:_t.records.seq+"-"+zI(),message:`cherry-picked ${s?.message} into ${_t.records.currBranch}`,seq:_t.records.seq++,parents:_t.records.head==null?[]:[_t.records.head.id,s.id],branch:_t.records.currBranch,type:Kr.CHERRY_PICK,tags:n?n.filter(Boolean):[`cherry-pick:${s.id}${s.type===Kr.MERGE?`|parent:${i}`:""}`]};_t.records.head=f,_t.records.commits.set(f.id,f),_t.records.branches.set(_t.records.currBranch,f.id),Y.debug(_t.records.branches),Y.debug("in cherryPick")}},"cherryPick"),Kce=o(function(t){if(t=Ze.sanitizeText(t,Ep()),_t.records.branches.has(t)){_t.records.currBranch=t;let e=_t.records.branches.get(_t.records.currBranch);e===void 0||!e?_t.records.head=null:_t.records.head=_t.records.commits.get(e)??null}else{let e=new Error(`Trying to checkout branch which is not yet created. (Help try using "branch ${t}")`);throw e.hash={text:`checkout ${t}`,token:`checkout ${t}`,expected:[`branch ${t}`]},e}},"checkout");o(jce,"upsert");o(Qce,"prettyPrintCommitHistory");v$e=o(function(){Y.debug(_t.records.commits);let t=Zce()[0];Qce([t])},"prettyPrint"),x$e=o(function(){_t.reset(),Ar()},"clear"),b$e=o(function(){return[..._t.records.branchConfig.values()].map((e,r)=>e.order!==null&&e.order!==void 0?e:{...e,order:parseFloat(`0.${r}`)}).sort((e,r)=>(e.order??0)-(r.order??0)).map(({name:e})=>({name:e}))},"getBranchesAsObjArray"),w$e=o(function(){return _t.records.branches},"getBranches"),T$e=o(function(){return _t.records.commits},"getCommits"),Zce=o(function(){let t=[..._t.records.commits.values()];return t.forEach(function(e){Y.debug(e.id)}),t.sort((e,r)=>e.seq-r.seq),t},"getCommitsArray"),k$e=o(function(){return _t.records.currBranch},"getCurrentBranch"),E$e=o(function(){return _t.records.direction},"getDirection"),S$e=o(function(){return _t.records.head},"getHead"),o6={commitType:Kr,getConfig:Ep,setDirection:h$e,setOptions:f$e,getOptions:d$e,commit:p$e,branch:m$e,merge:g$e,cherryPick:y$e,checkout:Kce,prettyPrint:v$e,clear:x$e,getBranchesAsObjArray:b$e,getBranches:w$e,getCommits:T$e,getCommitsArray:Zce,getCurrentBranch:k$e,getDirection:E$e,getHead:S$e,setAccTitle:Lr,getAccTitle:Rr,getAccDescription:Mr,setAccDescription:Nr,setDiagramTitle:$r,getDiagramTitle:Ir}});var C$e,A$e,_$e,D$e,L$e,R$e,N$e,Jce,eue=N(()=>{"use strict";kp();vt();T1();GI();a6();C$e=o((t,e)=>{$c(t,e),t.dir&&e.setDirection(t.dir);for(let r of t.statements)A$e(r,e)},"populate"),A$e=o((t,e)=>{let n={Commit:o(i=>e.commit(_$e(i)),"Commit"),Branch:o(i=>e.branch(D$e(i)),"Branch"),Merge:o(i=>e.merge(L$e(i)),"Merge"),Checkout:o(i=>e.checkout(R$e(i)),"Checkout"),CherryPicking:o(i=>e.cherryPick(N$e(i)),"CherryPicking")}[t.$type];n?n(t):Y.error(`Unknown statement type: ${t.$type}`)},"parseStatement"),_$e=o(t=>({id:t.id,msg:t.message??"",type:t.type!==void 0?Kr[t.type]:Kr.NORMAL,tags:t.tags??void 0}),"parseCommit"),D$e=o(t=>({name:t.name,order:t.order??0}),"parseBranch"),L$e=o(t=>({branch:t.branch,id:t.id??"",type:t.type!==void 0?Kr[t.type]:void 0,tags:t.tags??void 0}),"parseMerge"),R$e=o(t=>t.branch,"parseCheckout"),N$e=o(t=>({id:t.id,targetId:"",tags:t.tags?.length===0?void 0:t.tags,parent:t.parent}),"parseCherryPicking"),Jce={parse:o(async t=>{let e=await uo("gitGraph",t);Y.debug(e),C$e(e,o6)},"parse")}});var M$e,Ko,gf,yf,zc,qu,Sp,Gs,Vs,l6,db,c6,mf,Br,I$e,rue,nue,O$e,P$e,B$e,F$e,$$e,z$e,G$e,V$e,U$e,H$e,W$e,q$e,tue,Y$e,pb,X$e,j$e,K$e,Q$e,Z$e,iue,aue=N(()=>{"use strict";dr();zt();vt();ir();a6();M$e=me(),Ko=M$e?.gitGraph,gf=10,yf=40,zc=4,qu=2,Sp=8,Gs=new Map,Vs=new Map,l6=30,db=new Map,c6=[],mf=0,Br="LR",I$e=o(()=>{Gs.clear(),Vs.clear(),db.clear(),mf=0,c6=[],Br="LR"},"clear"),rue=o(t=>{let e=document.createElementNS("http://www.w3.org/2000/svg","text");return(typeof t=="string"?t.split(/\\n|\n|/gi):t).forEach(n=>{let i=document.createElementNS("http://www.w3.org/2000/svg","tspan");i.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),i.setAttribute("dy","1em"),i.setAttribute("x","0"),i.setAttribute("class","row"),i.textContent=n.trim(),e.appendChild(i)}),e},"drawText"),nue=o(t=>{let e,r,n;return Br==="BT"?(r=o((i,a)=>i<=a,"comparisonFunc"),n=1/0):(r=o((i,a)=>i>=a,"comparisonFunc"),n=0),t.forEach(i=>{let a=Br==="TB"||Br=="BT"?Vs.get(i)?.y:Vs.get(i)?.x;a!==void 0&&r(a,n)&&(e=i,n=a)}),e},"findClosestParent"),O$e=o(t=>{let e="",r=1/0;return t.forEach(n=>{let i=Vs.get(n).y;i<=r&&(e=n,r=i)}),e||void 0},"findClosestParentBT"),P$e=o((t,e,r)=>{let n=r,i=r,a=[];t.forEach(s=>{let l=e.get(s);if(!l)throw new Error(`Commit not found for key ${s}`);l.parents.length?(n=F$e(l),i=Math.max(n,i)):a.push(l),$$e(l,n)}),n=i,a.forEach(s=>{z$e(s,n,r)}),t.forEach(s=>{let l=e.get(s);if(l?.parents.length){let u=O$e(l.parents);n=Vs.get(u).y-yf,n<=i&&(i=n);let h=Gs.get(l.branch).pos,f=n-gf;Vs.set(l.id,{x:h,y:f})}})},"setParallelBTPos"),B$e=o(t=>{let e=nue(t.parents.filter(n=>n!==null));if(!e)throw new Error(`Closest parent not found for commit ${t.id}`);let r=Vs.get(e)?.y;if(r===void 0)throw new Error(`Closest parent position not found for commit ${t.id}`);return r},"findClosestParentPos"),F$e=o(t=>B$e(t)+yf,"calculateCommitPosition"),$$e=o((t,e)=>{let r=Gs.get(t.branch);if(!r)throw new Error(`Branch not found for commit ${t.id}`);let n=r.pos,i=e+gf;return Vs.set(t.id,{x:n,y:i}),{x:n,y:i}},"setCommitPosition"),z$e=o((t,e,r)=>{let n=Gs.get(t.branch);if(!n)throw new Error(`Branch not found for commit ${t.id}`);let i=e+r,a=n.pos;Vs.set(t.id,{x:a,y:i})},"setRootPosition"),G$e=o((t,e,r,n,i,a)=>{if(a===Kr.HIGHLIGHT)t.append("rect").attr("x",r.x-10).attr("y",r.y-10).attr("width",20).attr("height",20).attr("class",`commit ${e.id} commit-highlight${i%Sp} ${n}-outer`),t.append("rect").attr("x",r.x-6).attr("y",r.y-6).attr("width",12).attr("height",12).attr("class",`commit ${e.id} commit${i%Sp} ${n}-inner`);else if(a===Kr.CHERRY_PICK)t.append("circle").attr("cx",r.x).attr("cy",r.y).attr("r",10).attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x-3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("circle").attr("cx",r.x+3).attr("cy",r.y+2).attr("r",2.75).attr("fill","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x+3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`),t.append("line").attr("x1",r.x-3).attr("y1",r.y+1).attr("x2",r.x).attr("y2",r.y-5).attr("stroke","#fff").attr("class",`commit ${e.id} ${n}`);else{let s=t.append("circle");if(s.attr("cx",r.x),s.attr("cy",r.y),s.attr("r",e.type===Kr.MERGE?9:10),s.attr("class",`commit ${e.id} commit${i%Sp}`),a===Kr.MERGE){let l=t.append("circle");l.attr("cx",r.x),l.attr("cy",r.y),l.attr("r",6),l.attr("class",`commit ${n} ${e.id} commit${i%Sp}`)}a===Kr.REVERSE&&t.append("path").attr("d",`M ${r.x-5},${r.y-5}L${r.x+5},${r.y+5}M${r.x-5},${r.y+5}L${r.x+5},${r.y-5}`).attr("class",`commit ${n} ${e.id} commit${i%Sp}`)}},"drawCommitBullet"),V$e=o((t,e,r,n)=>{if(e.type!==Kr.CHERRY_PICK&&(e.customId&&e.type===Kr.MERGE||e.type!==Kr.MERGE)&&Ko?.showCommitLabel){let i=t.append("g"),a=i.insert("rect").attr("class","commit-label-bkg"),s=i.append("text").attr("x",n).attr("y",r.y+25).attr("class","commit-label").text(e.id),l=s.node()?.getBBox();if(l&&(a.attr("x",r.posWithOffset-l.width/2-qu).attr("y",r.y+13.5).attr("width",l.width+2*qu).attr("height",l.height+2*qu),Br==="TB"||Br==="BT"?(a.attr("x",r.x-(l.width+4*zc+5)).attr("y",r.y-12),s.attr("x",r.x-(l.width+4*zc)).attr("y",r.y+l.height-12)):s.attr("x",r.posWithOffset-l.width/2),Ko.rotateCommitLabel))if(Br==="TB"||Br==="BT")s.attr("transform","rotate(-45, "+r.x+", "+r.y+")"),a.attr("transform","rotate(-45, "+r.x+", "+r.y+")");else{let u=-7.5-(l.width+10)/25*9.5,h=10+l.width/25*8.5;i.attr("transform","translate("+u+", "+h+") rotate(-45, "+n+", "+r.y+")")}}},"drawCommitLabel"),U$e=o((t,e,r,n)=>{if(e.tags.length>0){let i=0,a=0,s=0,l=[];for(let u of e.tags.reverse()){let h=t.insert("polygon"),f=t.append("circle"),d=t.append("text").attr("y",r.y-16-i).attr("class","tag-label").text(u),p=d.node()?.getBBox();if(!p)throw new Error("Tag bbox not found");a=Math.max(a,p.width),s=Math.max(s,p.height),d.attr("x",r.posWithOffset-p.width/2),l.push({tag:d,hole:f,rect:h,yOffset:i}),i+=20}for(let{tag:u,hole:h,rect:f,yOffset:d}of l){let p=s/2,m=r.y-19.2-d;if(f.attr("class","tag-label-bkg").attr("points",` + ${n-a/2-zc/2},${m+qu} + ${n-a/2-zc/2},${m-qu} + ${r.posWithOffset-a/2-zc},${m-p-qu} + ${r.posWithOffset+a/2+zc},${m-p-qu} + ${r.posWithOffset+a/2+zc},${m+p+qu} + ${r.posWithOffset-a/2-zc},${m+p+qu}`),h.attr("cy",m).attr("cx",n-a/2+zc/2).attr("r",1.5).attr("class","tag-hole"),Br==="TB"||Br==="BT"){let g=n+d;f.attr("class","tag-label-bkg").attr("points",` + ${r.x},${g+2} + ${r.x},${g-2} + ${r.x+gf},${g-p-2} + ${r.x+gf+a+4},${g-p-2} + ${r.x+gf+a+4},${g+p+2} + ${r.x+gf},${g+p+2}`).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),h.attr("cx",r.x+zc/2).attr("cy",g).attr("transform","translate(12,12) rotate(45, "+r.x+","+n+")"),u.attr("x",r.x+5).attr("y",g+3).attr("transform","translate(14,14) rotate(45, "+r.x+","+n+")")}}}},"drawCommitTags"),H$e=o(t=>{switch(t.customType??t.type){case Kr.NORMAL:return"commit-normal";case Kr.REVERSE:return"commit-reverse";case Kr.HIGHLIGHT:return"commit-highlight";case Kr.MERGE:return"commit-merge";case Kr.CHERRY_PICK:return"commit-cherry-pick";default:return"commit-normal"}},"getCommitClassType"),W$e=o((t,e,r,n)=>{let i={x:0,y:0};if(t.parents.length>0){let a=nue(t.parents);if(a){let s=n.get(a)??i;return e==="TB"?s.y+yf:e==="BT"?(n.get(t.id)??i).y-yf:s.x+yf}}else return e==="TB"?l6:e==="BT"?(n.get(t.id)??i).y-yf:0;return 0},"calculatePosition"),q$e=o((t,e,r)=>{let n=Br==="BT"&&r?e:e+gf,i=Br==="TB"||Br==="BT"?n:Gs.get(t.branch)?.pos,a=Br==="TB"||Br==="BT"?Gs.get(t.branch)?.pos:n;if(a===void 0||i===void 0)throw new Error(`Position were undefined for commit ${t.id}`);return{x:a,y:i,posWithOffset:n}},"getCommitPosition"),tue=o((t,e,r)=>{if(!Ko)throw new Error("GitGraph config not found");let n=t.append("g").attr("class","commit-bullets"),i=t.append("g").attr("class","commit-labels"),a=Br==="TB"||Br==="BT"?l6:0,s=[...e.keys()],l=Ko?.parallelCommits??!1,u=o((f,d)=>{let p=e.get(f)?.seq,m=e.get(d)?.seq;return p!==void 0&&m!==void 0?p-m:0},"sortKeys"),h=s.sort(u);Br==="BT"&&(l&&P$e(h,e,a),h=h.reverse()),h.forEach(f=>{let d=e.get(f);if(!d)throw new Error(`Commit not found for key ${f}`);l&&(a=W$e(d,Br,a,Vs));let p=q$e(d,a,l);if(r){let m=H$e(d),g=d.customType??d.type,y=Gs.get(d.branch)?.index??0;G$e(n,d,p,m,y,g),V$e(i,d,p,a),U$e(i,d,p,a)}Br==="TB"||Br==="BT"?Vs.set(d.id,{x:p.x,y:p.posWithOffset}):Vs.set(d.id,{x:p.posWithOffset,y:p.y}),a=Br==="BT"&&l?a+yf:a+yf+gf,a>mf&&(mf=a)})},"drawCommits"),Y$e=o((t,e,r,n,i)=>{let s=(Br==="TB"||Br==="BT"?r.xh.branch===s,"isOnBranchToGetCurve"),u=o(h=>h.seq>t.seq&&h.sequ(h)&&l(h))},"shouldRerouteArrow"),pb=o((t,e,r=0)=>{let n=t+Math.abs(t-e)/2;if(r>5)return n;if(c6.every(s=>Math.abs(s-n)>=10))return c6.push(n),n;let a=Math.abs(t-e);return pb(t,e-a/5,r+1)},"findLane"),X$e=o((t,e,r,n)=>{let i=Vs.get(e.id),a=Vs.get(r.id);if(i===void 0||a===void 0)throw new Error(`Commit positions not found for commits ${e.id} and ${r.id}`);let s=Y$e(e,r,i,a,n),l="",u="",h=0,f=0,d=Gs.get(r.branch)?.index;r.type===Kr.MERGE&&e.id!==r.parents[0]&&(d=Gs.get(e.branch)?.index);let p;if(s){l="A 10 10, 0, 0, 0,",u="A 10 10, 0, 0, 1,",h=10,f=10;let m=i.ya.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===Kr.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y-h} ${u} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x+h} ${i.y} ${l} ${a.x} ${i.y+f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):Br==="BT"?(i.xa.x&&(l="A 20 20, 0, 0, 0,",u="A 20 20, 0, 0, 1,",h=20,f=20,r.type===Kr.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${l} ${i.x-f} ${a.y} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`),i.x===a.x&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`)):(i.ya.y&&(r.type===Kr.MERGE&&e.id!==r.parents[0]?p=`M ${i.x} ${i.y} L ${a.x-h} ${i.y} ${l} ${a.x} ${i.y-f} L ${a.x} ${a.y}`:p=`M ${i.x} ${i.y} L ${i.x} ${a.y+h} ${u} ${i.x+f} ${a.y} L ${a.x} ${a.y}`),i.y===a.y&&(p=`M ${i.x} ${i.y} L ${a.x} ${a.y}`));if(p===void 0)throw new Error("Line definition not found");t.append("path").attr("d",p).attr("class","arrow arrow"+d%Sp)},"drawArrow"),j$e=o((t,e)=>{let r=t.append("g").attr("class","commit-arrows");[...e.keys()].forEach(n=>{let i=e.get(n);i.parents&&i.parents.length>0&&i.parents.forEach(a=>{X$e(r,e.get(a),i,e)})})},"drawArrows"),K$e=o((t,e)=>{let r=t.append("g");e.forEach((n,i)=>{let a=i%Sp,s=Gs.get(n.name)?.pos;if(s===void 0)throw new Error(`Position not found for branch ${n.name}`);let l=r.append("line");l.attr("x1",0),l.attr("y1",s),l.attr("x2",mf),l.attr("y2",s),l.attr("class","branch branch"+a),Br==="TB"?(l.attr("y1",l6),l.attr("x1",s),l.attr("y2",mf),l.attr("x2",s)):Br==="BT"&&(l.attr("y1",mf),l.attr("x1",s),l.attr("y2",l6),l.attr("x2",s)),c6.push(s);let u=n.name,h=rue(u),f=r.insert("rect"),p=r.insert("g").attr("class","branchLabel").insert("g").attr("class","label branch-label"+a);p.node().appendChild(h);let m=h.getBBox();f.attr("class","branchLabelBkg label"+a).attr("rx",4).attr("ry",4).attr("x",-m.width-4-(Ko?.rotateCommitLabel===!0?30:0)).attr("y",-m.height/2+8).attr("width",m.width+18).attr("height",m.height+4),p.attr("transform","translate("+(-m.width-14-(Ko?.rotateCommitLabel===!0?30:0))+", "+(s-m.height/2-1)+")"),Br==="TB"?(f.attr("x",s-m.width/2-10).attr("y",0),p.attr("transform","translate("+(s-m.width/2-5)+", 0)")):Br==="BT"?(f.attr("x",s-m.width/2-10).attr("y",mf),p.attr("transform","translate("+(s-m.width/2-5)+", "+mf+")")):f.attr("transform","translate(-19, "+(s-m.height/2)+")")})},"drawBranches"),Q$e=o(function(t,e,r,n,i){return Gs.set(t,{pos:e,index:r}),e+=50+(i?40:0)+(Br==="TB"||Br==="BT"?n.width/2:0),e},"setBranchPosition"),Z$e=o(function(t,e,r,n){if(I$e(),Y.debug("in gitgraph renderer",t+` +`,"id:",e,r),!Ko)throw new Error("GitGraph config not found");let i=Ko.rotateCommitLabel??!1,a=n.db;db=a.getCommits();let s=a.getBranchesAsObjArray();Br=a.getDirection();let l=Ge(`[id="${e}"]`),u=0;s.forEach((h,f)=>{let d=rue(h.name),p=l.append("g"),m=p.insert("g").attr("class","branchLabel"),g=m.insert("g").attr("class","label branch-label");g.node()?.appendChild(d);let y=d.getBBox();u=Q$e(h.name,u,f,y,i),g.remove(),m.remove(),p.remove()}),tue(l,db,!1),Ko.showBranches&&K$e(l,s),j$e(l,db),tue(l,db,!0),Gt.insertTitle(l,"gitTitleText",Ko.titleTopMargin??0,a.getDiagramTitle()),oA(void 0,l,Ko.diagramPadding,Ko.useMaxWidth)},"draw"),iue={draw:Z$e}});var J$e,sue,oue=N(()=>{"use strict";J$e=o(t=>` + .commit-id, + .commit-msg, + .branch-label { + fill: lightgrey; + color: lightgrey; + font-family: 'trebuchet ms', verdana, arial, sans-serif; + font-family: var(--mermaid-font-family); + } + ${[0,1,2,3,4,5,6,7].map(e=>` + .branch-label${e} { fill: ${t["gitBranchLabel"+e]}; } + .commit${e} { stroke: ${t["git"+e]}; fill: ${t["git"+e]}; } + .commit-highlight${e} { stroke: ${t["gitInv"+e]}; fill: ${t["gitInv"+e]}; } + .label${e} { fill: ${t["git"+e]}; } + .arrow${e} { stroke: ${t["git"+e]}; } + `).join(` +`)} + + .branch { + stroke-width: 1; + stroke: ${t.lineColor}; + stroke-dasharray: 2; + } + .commit-label { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelColor};} + .commit-label-bkg { font-size: ${t.commitLabelFontSize}; fill: ${t.commitLabelBackground}; opacity: 0.5; } + .tag-label { font-size: ${t.tagLabelFontSize}; fill: ${t.tagLabelColor};} + .tag-label-bkg { fill: ${t.tagLabelBackground}; stroke: ${t.tagLabelBorder}; } + .tag-hole { fill: ${t.textColor}; } + + .commit-merge { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + .commit-reverse { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + stroke-width: 3; + } + .commit-highlight-outer { + } + .commit-highlight-inner { + stroke: ${t.primaryColor}; + fill: ${t.primaryColor}; + } + + .arrow { stroke-width: 8; stroke-linecap: round; fill: none} + .gitTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } +`,"getStyles"),sue=J$e});var lue={};hr(lue,{diagram:()=>eze});var eze,cue=N(()=>{"use strict";eue();GI();aue();oue();eze={parser:Jce,db:o6,renderer:iue,styles:sue}});var VI,fue,due=N(()=>{"use strict";VI=function(){var t=o(function(L,R,O,M){for(O=O||{},M=L.length;M--;O[L[M]]=R);return O},"o"),e=[6,8,10,12,13,14,15,16,17,18,20,21,22,23,24,25,26,27,28,29,30,31,33,35,36,38,40],r=[1,26],n=[1,27],i=[1,28],a=[1,29],s=[1,30],l=[1,31],u=[1,32],h=[1,33],f=[1,34],d=[1,9],p=[1,10],m=[1,11],g=[1,12],y=[1,13],v=[1,14],x=[1,15],b=[1,16],w=[1,19],C=[1,20],T=[1,21],E=[1,22],A=[1,23],S=[1,25],_=[1,35],I={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,gantt:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NL:10,weekday:11,weekday_monday:12,weekday_tuesday:13,weekday_wednesday:14,weekday_thursday:15,weekday_friday:16,weekday_saturday:17,weekday_sunday:18,weekend:19,weekend_friday:20,weekend_saturday:21,dateFormat:22,inclusiveEndDates:23,topAxis:24,axisFormat:25,tickInterval:26,excludes:27,includes:28,todayMarker:29,title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,section:36,clickStatement:37,taskTxt:38,taskData:39,click:40,callbackname:41,callbackargs:42,href:43,clickStatementDebug:44,$accept:0,$end:1},terminals_:{2:"error",4:"gantt",6:"EOF",8:"SPACE",10:"NL",12:"weekday_monday",13:"weekday_tuesday",14:"weekday_wednesday",15:"weekday_thursday",16:"weekday_friday",17:"weekday_saturday",18:"weekday_sunday",20:"weekend_friday",21:"weekend_saturday",22:"dateFormat",23:"inclusiveEndDates",24:"topAxis",25:"axisFormat",26:"tickInterval",27:"excludes",28:"includes",29:"todayMarker",30:"title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"section",38:"taskTxt",39:"taskData",40:"click",41:"callbackname",42:"callbackargs",43:"href"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[11,1],[19,1],[19,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,2],[37,2],[37,3],[37,3],[37,4],[37,3],[37,4],[37,2],[44,2],[44,3],[44,3],[44,4],[44,3],[44,4],[44,2]],performAction:o(function(R,O,M,B,F,P,z){var $=P.length-1;switch(F){case 1:return P[$-1];case 2:this.$=[];break;case 3:P[$-1].push(P[$]),this.$=P[$-1];break;case 4:case 5:this.$=P[$];break;case 6:case 7:this.$=[];break;case 8:B.setWeekday("monday");break;case 9:B.setWeekday("tuesday");break;case 10:B.setWeekday("wednesday");break;case 11:B.setWeekday("thursday");break;case 12:B.setWeekday("friday");break;case 13:B.setWeekday("saturday");break;case 14:B.setWeekday("sunday");break;case 15:B.setWeekend("friday");break;case 16:B.setWeekend("saturday");break;case 17:B.setDateFormat(P[$].substr(11)),this.$=P[$].substr(11);break;case 18:B.enableInclusiveEndDates(),this.$=P[$].substr(18);break;case 19:B.TopAxis(),this.$=P[$].substr(8);break;case 20:B.setAxisFormat(P[$].substr(11)),this.$=P[$].substr(11);break;case 21:B.setTickInterval(P[$].substr(13)),this.$=P[$].substr(13);break;case 22:B.setExcludes(P[$].substr(9)),this.$=P[$].substr(9);break;case 23:B.setIncludes(P[$].substr(9)),this.$=P[$].substr(9);break;case 24:B.setTodayMarker(P[$].substr(12)),this.$=P[$].substr(12);break;case 27:B.setDiagramTitle(P[$].substr(6)),this.$=P[$].substr(6);break;case 28:this.$=P[$].trim(),B.setAccTitle(this.$);break;case 29:case 30:this.$=P[$].trim(),B.setAccDescription(this.$);break;case 31:B.addSection(P[$].substr(8)),this.$=P[$].substr(8);break;case 33:B.addTask(P[$-1],P[$]),this.$="task";break;case 34:this.$=P[$-1],B.setClickEvent(P[$-1],P[$],null);break;case 35:this.$=P[$-2],B.setClickEvent(P[$-2],P[$-1],P[$]);break;case 36:this.$=P[$-2],B.setClickEvent(P[$-2],P[$-1],null),B.setLink(P[$-2],P[$]);break;case 37:this.$=P[$-3],B.setClickEvent(P[$-3],P[$-2],P[$-1]),B.setLink(P[$-3],P[$]);break;case 38:this.$=P[$-2],B.setClickEvent(P[$-2],P[$],null),B.setLink(P[$-2],P[$-1]);break;case 39:this.$=P[$-3],B.setClickEvent(P[$-3],P[$-1],P[$]),B.setLink(P[$-3],P[$-2]);break;case 40:this.$=P[$-1],B.setLink(P[$-1],P[$]);break;case 41:case 47:this.$=P[$-1]+" "+P[$];break;case 42:case 43:case 45:this.$=P[$-2]+" "+P[$-1]+" "+P[$];break;case 44:case 46:this.$=P[$-3]+" "+P[$-2]+" "+P[$-1]+" "+P[$];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:w,31:C,33:T,35:E,36:A,37:24,38:S,40:_},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:36,11:17,12:r,13:n,14:i,15:a,16:s,17:l,18:u,19:18,20:h,21:f,22:d,23:p,24:m,25:g,26:y,27:v,28:x,29:b,30:w,31:C,33:T,35:E,36:A,37:24,38:S,40:_},t(e,[2,5]),t(e,[2,6]),t(e,[2,17]),t(e,[2,18]),t(e,[2,19]),t(e,[2,20]),t(e,[2,21]),t(e,[2,22]),t(e,[2,23]),t(e,[2,24]),t(e,[2,25]),t(e,[2,26]),t(e,[2,27]),{32:[1,37]},{34:[1,38]},t(e,[2,30]),t(e,[2,31]),t(e,[2,32]),{39:[1,39]},t(e,[2,8]),t(e,[2,9]),t(e,[2,10]),t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),{41:[1,40],43:[1,41]},t(e,[2,4]),t(e,[2,28]),t(e,[2,29]),t(e,[2,33]),t(e,[2,34],{42:[1,42],43:[1,43]}),t(e,[2,40],{41:[1,44]}),t(e,[2,35],{43:[1,45]}),t(e,[2,36]),t(e,[2,38],{42:[1,46]}),t(e,[2,37]),t(e,[2,39])],defaultActions:{},parseError:o(function(R,O){if(O.recoverable)this.trace(R);else{var M=new Error(R);throw M.hash=O,M}},"parseError"),parse:o(function(R){var O=this,M=[0],B=[],F=[null],P=[],z=this.table,$="",H=0,Q=0,j=0,ie=2,ne=1,le=P.slice.call(arguments,1),he=Object.create(this.lexer),K={yy:{}};for(var X in this.yy)Object.prototype.hasOwnProperty.call(this.yy,X)&&(K.yy[X]=this.yy[X]);he.setInput(R,K.yy),K.yy.lexer=he,K.yy.parser=this,typeof he.yylloc>"u"&&(he.yylloc={});var te=he.yylloc;P.push(te);var J=he.options&&he.options.ranges;typeof K.yy.parseError=="function"?this.parseError=K.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function se(W){M.length=M.length-2*W,F.length=F.length-W,P.length=P.length-W}o(se,"popStack");function ue(){var W;return W=B.pop()||he.lex()||ne,typeof W!="number"&&(W instanceof Array&&(B=W,W=B.pop()),W=O.symbols_[W]||W),W}o(ue,"lex");for(var Z,Se,ce,ae,Oe,ge,ze={},He,$e,Re,Ie;;){if(ce=M[M.length-1],this.defaultActions[ce]?ae=this.defaultActions[ce]:((Z===null||typeof Z>"u")&&(Z=ue()),ae=z[ce]&&z[ce][Z]),typeof ae>"u"||!ae.length||!ae[0]){var be="";Ie=[];for(He in z[ce])this.terminals_[He]&&He>ie&&Ie.push("'"+this.terminals_[He]+"'");he.showPosition?be="Parse error on line "+(H+1)+`: +`+he.showPosition()+` +Expecting `+Ie.join(", ")+", got '"+(this.terminals_[Z]||Z)+"'":be="Parse error on line "+(H+1)+": Unexpected "+(Z==ne?"end of input":"'"+(this.terminals_[Z]||Z)+"'"),this.parseError(be,{text:he.match,token:this.terminals_[Z]||Z,line:he.yylineno,loc:te,expected:Ie})}if(ae[0]instanceof Array&&ae.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ce+", token: "+Z);switch(ae[0]){case 1:M.push(Z),F.push(he.yytext),P.push(he.yylloc),M.push(ae[1]),Z=null,Se?(Z=Se,Se=null):(Q=he.yyleng,$=he.yytext,H=he.yylineno,te=he.yylloc,j>0&&j--);break;case 2:if($e=this.productions_[ae[1]][1],ze.$=F[F.length-$e],ze._$={first_line:P[P.length-($e||1)].first_line,last_line:P[P.length-1].last_line,first_column:P[P.length-($e||1)].first_column,last_column:P[P.length-1].last_column},J&&(ze._$.range=[P[P.length-($e||1)].range[0],P[P.length-1].range[1]]),ge=this.performAction.apply(ze,[$,Q,H,K.yy,ae[1],F,P].concat(le)),typeof ge<"u")return ge;$e&&(M=M.slice(0,-1*$e*2),F=F.slice(0,-1*$e),P=P.slice(0,-1*$e)),M.push(this.productions_[ae[1]][0]),F.push(ze.$),P.push(ze._$),Re=z[M[M.length-2]][M[M.length-1]],M.push(Re);break;case 3:return!0}}return!0},"parse")},D=function(){var L={EOF:1,parseError:o(function(O,M){if(this.yy.parser)this.yy.parser.parseError(O,M);else throw new Error(O)},"parseError"),setInput:o(function(R,O){return this.yy=O||this.yy||{},this._input=R,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var R=this._input[0];this.yytext+=R,this.yyleng++,this.offset++,this.match+=R,this.matched+=R;var O=R.match(/(?:\r\n?|\n).*/g);return O?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),R},"input"),unput:o(function(R){var O=R.length,M=R.split(/(?:\r\n?|\n)/g);this._input=R+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-O),this.offset-=O;var B=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),M.length-1&&(this.yylineno-=M.length-1);var F=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:M?(M.length===B.length?this.yylloc.first_column:0)+B[B.length-M.length].length-M[0].length:this.yylloc.first_column-O},this.options.ranges&&(this.yylloc.range=[F[0],F[0]+this.yyleng-O]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(R){this.unput(this.match.slice(R))},"less"),pastInput:o(function(){var R=this.matched.substr(0,this.matched.length-this.match.length);return(R.length>20?"...":"")+R.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var R=this.match;return R.length<20&&(R+=this._input.substr(0,20-R.length)),(R.substr(0,20)+(R.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var R=this.pastInput(),O=new Array(R.length+1).join("-");return R+this.upcomingInput()+` +`+O+"^"},"showPosition"),test_match:o(function(R,O){var M,B,F;if(this.options.backtrack_lexer&&(F={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(F.yylloc.range=this.yylloc.range.slice(0))),B=R[0].match(/(?:\r\n?|\n).*/g),B&&(this.yylineno+=B.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:B?B[B.length-1].length-B[B.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+R[0].length},this.yytext+=R[0],this.match+=R[0],this.matches=R,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(R[0].length),this.matched+=R[0],M=this.performAction.call(this,this.yy,this,O,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),M)return M;if(this._backtrack){for(var P in F)this[P]=F[P];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var R,O,M,B;this._more||(this.yytext="",this.match="");for(var F=this._currentRules(),P=0;PO[0].length)){if(O=M,B=P,this.options.backtrack_lexer){if(R=this.test_match(M,F[P]),R!==!1)return R;if(this._backtrack){O=!1;continue}else return!1}else if(!this.options.flex)break}return O?(R=this.test_match(O,F[B]),R!==!1?R:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var O=this.next();return O||this.lex()},"lex"),begin:o(function(O){this.conditionStack.push(O)},"begin"),popState:o(function(){var O=this.conditionStack.length-1;return O>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(O){return O=this.conditionStack.length-1-Math.abs(O||0),O>=0?this.conditionStack[O]:"INITIAL"},"topState"),pushState:o(function(O){this.begin(O)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(O,M,B,F){var P=F;switch(B){case 0:return this.begin("open_directive"),"open_directive";break;case 1:return this.begin("acc_title"),31;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),33;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:break;case 9:break;case 10:break;case 11:return 10;case 12:break;case 13:break;case 14:this.begin("href");break;case 15:this.popState();break;case 16:return 43;case 17:this.begin("callbackname");break;case 18:this.popState();break;case 19:this.popState(),this.begin("callbackargs");break;case 20:return 41;case 21:this.popState();break;case 22:return 42;case 23:this.begin("click");break;case 24:this.popState();break;case 25:return 40;case 26:return 4;case 27:return 22;case 28:return 23;case 29:return 24;case 30:return 25;case 31:return 26;case 32:return 28;case 33:return 27;case 34:return 29;case 35:return 12;case 36:return 13;case 37:return 14;case 38:return 15;case 39:return 16;case 40:return 17;case 41:return 18;case 42:return 20;case 43:return 21;case 44:return"date";case 45:return 30;case 46:return"accDescription";case 47:return 36;case 48:return 38;case 49:return 39;case 50:return":";case 51:return 6;case 52:return"INVALID"}},"anonymous"),rules:[/^(?:%%\{)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:%%(?!\{)*[^\n]*)/i,/^(?:[^\}]%%*[^\n]*)/i,/^(?:%%*[^\n]*[\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:%[^\n]*)/i,/^(?:href[\s]+["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:call[\s]+)/i,/^(?:\([\s]*\))/i,/^(?:\()/i,/^(?:[^(]*)/i,/^(?:\))/i,/^(?:[^)]*)/i,/^(?:click[\s]+)/i,/^(?:[\s\n])/i,/^(?:[^\s\n]*)/i,/^(?:gantt\b)/i,/^(?:dateFormat\s[^#\n;]+)/i,/^(?:inclusiveEndDates\b)/i,/^(?:topAxis\b)/i,/^(?:axisFormat\s[^#\n;]+)/i,/^(?:tickInterval\s[^#\n;]+)/i,/^(?:includes\s[^#\n;]+)/i,/^(?:excludes\s[^#\n;]+)/i,/^(?:todayMarker\s[^\n;]+)/i,/^(?:weekday\s+monday\b)/i,/^(?:weekday\s+tuesday\b)/i,/^(?:weekday\s+wednesday\b)/i,/^(?:weekday\s+thursday\b)/i,/^(?:weekday\s+friday\b)/i,/^(?:weekday\s+saturday\b)/i,/^(?:weekday\s+sunday\b)/i,/^(?:weekend\s+friday\b)/i,/^(?:weekend\s+saturday\b)/i,/^(?:\d\d\d\d-\d\d-\d\d\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accDescription\s[^#\n;]+)/i,/^(?:section\s[^\n]+)/i,/^(?:[^:\n]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[6,7],inclusive:!1},acc_descr:{rules:[4],inclusive:!1},acc_title:{rules:[2],inclusive:!1},callbackargs:{rules:[21,22],inclusive:!1},callbackname:{rules:[18,19,20],inclusive:!1},href:{rules:[15,16],inclusive:!1},click:{rules:[24,25],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,17,23,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],inclusive:!0}}};return L}();I.lexer=D;function k(){this.yy={}}return o(k,"Parser"),k.prototype=I,I.Parser=k,new k}();VI.parser=VI;fue=VI});var pue=Mi((UI,HI)=>{"use strict";(function(t,e){typeof UI=="object"&&typeof HI<"u"?HI.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_isoWeek=e()})(UI,function(){"use strict";var t="day";return function(e,r,n){var i=o(function(l){return l.add(4-l.isoWeekday(),t)},"a"),a=r.prototype;a.isoWeekYear=function(){return i(this).year()},a.isoWeek=function(l){if(!this.$utils().u(l))return this.add(7*(l-this.isoWeek()),t);var u,h,f,d,p=i(this),m=(u=this.isoWeekYear(),h=this.$u,f=(h?n.utc:n)().year(u).startOf("year"),d=4-f.isoWeekday(),f.isoWeekday()>4&&(d+=7),f.add(d,t));return p.diff(m,"week")+1},a.isoWeekday=function(l){return this.$utils().u(l)?this.day()||7:this.day(this.day()%7?l:l-7)};var s=a.startOf;a.startOf=function(l,u){var h=this.$utils(),f=!!h.u(u)||u;return h.p(l)==="isoweek"?f?this.date(this.date()-(this.isoWeekday()-1)).startOf("day"):this.date(this.date()-1-(this.isoWeekday()-1)+7).endOf("day"):s.bind(this)(l,u)}}})});var mue=Mi((WI,qI)=>{"use strict";(function(t,e){typeof WI=="object"&&typeof qI<"u"?qI.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_customParseFormat=e()})(WI,function(){"use strict";var t={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},e=/(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|Q|YYYY|YY?|ww?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,r=/\d/,n=/\d\d/,i=/\d\d?/,a=/\d*[^-_:/,()\s\d]+/,s={},l=o(function(g){return(g=+g)+(g>68?1900:2e3)},"a"),u=o(function(g){return function(y){this[g]=+y}},"f"),h=[/[+-]\d\d:?(\d\d)?|Z/,function(g){(this.zone||(this.zone={})).offset=function(y){if(!y||y==="Z")return 0;var v=y.match(/([+-]|\d\d)/g),x=60*v[1]+(+v[2]||0);return x===0?0:v[0]==="+"?-x:x}(g)}],f=o(function(g){var y=s[g];return y&&(y.indexOf?y:y.s.concat(y.f))},"u"),d=o(function(g,y){var v,x=s.meridiem;if(x){for(var b=1;b<=24;b+=1)if(g.indexOf(x(b,0,y))>-1){v=b>12;break}}else v=g===(y?"pm":"PM");return v},"d"),p={A:[a,function(g){this.afternoon=d(g,!1)}],a:[a,function(g){this.afternoon=d(g,!0)}],Q:[r,function(g){this.month=3*(g-1)+1}],S:[r,function(g){this.milliseconds=100*+g}],SS:[n,function(g){this.milliseconds=10*+g}],SSS:[/\d{3}/,function(g){this.milliseconds=+g}],s:[i,u("seconds")],ss:[i,u("seconds")],m:[i,u("minutes")],mm:[i,u("minutes")],H:[i,u("hours")],h:[i,u("hours")],HH:[i,u("hours")],hh:[i,u("hours")],D:[i,u("day")],DD:[n,u("day")],Do:[a,function(g){var y=s.ordinal,v=g.match(/\d+/);if(this.day=v[0],y)for(var x=1;x<=31;x+=1)y(x).replace(/\[|\]/g,"")===g&&(this.day=x)}],w:[i,u("week")],ww:[n,u("week")],M:[i,u("month")],MM:[n,u("month")],MMM:[a,function(g){var y=f("months"),v=(f("monthsShort")||y.map(function(x){return x.slice(0,3)})).indexOf(g)+1;if(v<1)throw new Error;this.month=v%12||v}],MMMM:[a,function(g){var y=f("months").indexOf(g)+1;if(y<1)throw new Error;this.month=y%12||y}],Y:[/[+-]?\d+/,u("year")],YY:[n,function(g){this.year=l(g)}],YYYY:[/\d{4}/,u("year")],Z:h,ZZ:h};function m(g){var y,v;y=g,v=s&&s.formats;for(var x=(g=y.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,function(S,_,I){var D=I&&I.toUpperCase();return _||v[I]||t[I]||v[D].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,function(k,L,R){return L||R.slice(1)})})).match(e),b=x.length,w=0;w-1)return new Date((M==="X"?1e3:1)*O);var P=m(M)(O),z=P.year,$=P.month,H=P.day,Q=P.hours,j=P.minutes,ie=P.seconds,ne=P.milliseconds,le=P.zone,he=P.week,K=new Date,X=H||(z||$?1:K.getDate()),te=z||K.getFullYear(),J=0;z&&!$||(J=$>0?$-1:K.getMonth());var se,ue=Q||0,Z=j||0,Se=ie||0,ce=ne||0;return le?new Date(Date.UTC(te,J,X,ue,Z,Se,ce+60*le.offset*1e3)):B?new Date(Date.UTC(te,J,X,ue,Z,Se,ce)):(se=new Date(te,J,X,ue,Z,Se,ce),he&&(se=F(se).week(he).toDate()),se)}catch{return new Date("")}}(C,A,T,v),this.init(),D&&D!==!0&&(this.$L=this.locale(D).$L),I&&C!=this.format(A)&&(this.$d=new Date("")),s={}}else if(A instanceof Array)for(var k=A.length,L=1;L<=k;L+=1){E[1]=A[L-1];var R=v.apply(this,E);if(R.isValid()){this.$d=R.$d,this.$L=R.$L,this.init();break}L===k&&(this.$d=new Date(""))}else b.call(this,w)}}})});var gue=Mi((YI,XI)=>{"use strict";(function(t,e){typeof YI=="object"&&typeof XI<"u"?XI.exports=e():typeof define=="function"&&define.amd?define(e):(t=typeof globalThis<"u"?globalThis:t||self).dayjs_plugin_advancedFormat=e()})(YI,function(){"use strict";return function(t,e){var r=e.prototype,n=r.format;r.format=function(i){var a=this,s=this.$locale();if(!this.isValid())return n.bind(this)(i);var l=this.$utils(),u=(i||"YYYY-MM-DDTHH:mm:ssZ").replace(/\[([^\]]+)]|Q|wo|ww|w|WW|W|zzz|z|gggg|GGGG|Do|X|x|k{1,2}|S/g,function(h){switch(h){case"Q":return Math.ceil((a.$M+1)/3);case"Do":return s.ordinal(a.$D);case"gggg":return a.weekYear();case"GGGG":return a.isoWeekYear();case"wo":return s.ordinal(a.week(),"W");case"w":case"ww":return l.s(a.week(),h==="w"?1:2,"0");case"W":case"WW":return l.s(a.isoWeek(),h==="W"?1:2,"0");case"k":case"kk":return l.s(String(a.$H===0?24:a.$H),h==="k"?1:2,"0");case"X":return Math.floor(a.$d.getTime()/1e3);case"x":return a.$d.getTime();case"z":return"["+a.offsetName()+"]";case"zzz":return"["+a.offsetName("long")+"]";default:return h}});return n.bind(this)(u)}}})});function Nue(t,e,r){let n=!0;for(;n;)n=!1,r.forEach(function(i){let a="^\\s*"+i+"\\s*$",s=new RegExp(a);t[0].match(s)&&(e[i]=!0,t.shift(1),n=!0)})}var xue,ho,bue,wue,Tue,yue,Gc,ZI,JI,eO,mb,gb,tO,rO,f6,E1,nO,kue,iO,yb,aO,sO,d6,jI,ize,aze,sze,oze,lze,cze,uze,hze,fze,dze,pze,mze,gze,yze,vze,xze,bze,wze,Tze,kze,Eze,Sze,Cze,Eue,Aze,_ze,Dze,Sue,Lze,KI,Cue,Aue,u6,k1,Rze,Nze,QI,h6,Gi,_ue,Mze,Cp,Ize,vue,Oze,Due,Pze,Lue,Bze,Fze,Rue,Mue=N(()=>{"use strict";xue=Sa(z0(),1),ho=Sa(R4(),1),bue=Sa(pue(),1),wue=Sa(mue(),1),Tue=Sa(gue(),1);vt();zt();ir();mi();ho.default.extend(bue.default);ho.default.extend(wue.default);ho.default.extend(Tue.default);yue={friday:5,saturday:6},Gc="",ZI="",eO="",mb=[],gb=[],tO=new Map,rO=[],f6=[],E1="",nO="",kue=["active","done","crit","milestone"],iO=[],yb=!1,aO=!1,sO="sunday",d6="saturday",jI=0,ize=o(function(){rO=[],f6=[],E1="",iO=[],u6=0,QI=void 0,h6=void 0,Gi=[],Gc="",ZI="",nO="",JI=void 0,eO="",mb=[],gb=[],yb=!1,aO=!1,jI=0,tO=new Map,Ar(),sO="sunday",d6="saturday"},"clear"),aze=o(function(t){ZI=t},"setAxisFormat"),sze=o(function(){return ZI},"getAxisFormat"),oze=o(function(t){JI=t},"setTickInterval"),lze=o(function(){return JI},"getTickInterval"),cze=o(function(t){eO=t},"setTodayMarker"),uze=o(function(){return eO},"getTodayMarker"),hze=o(function(t){Gc=t},"setDateFormat"),fze=o(function(){yb=!0},"enableInclusiveEndDates"),dze=o(function(){return yb},"endDatesAreInclusive"),pze=o(function(){aO=!0},"enableTopAxis"),mze=o(function(){return aO},"topAxisEnabled"),gze=o(function(t){nO=t},"setDisplayMode"),yze=o(function(){return nO},"getDisplayMode"),vze=o(function(){return Gc},"getDateFormat"),xze=o(function(t){mb=t.toLowerCase().split(/[\s,]+/)},"setIncludes"),bze=o(function(){return mb},"getIncludes"),wze=o(function(t){gb=t.toLowerCase().split(/[\s,]+/)},"setExcludes"),Tze=o(function(){return gb},"getExcludes"),kze=o(function(){return tO},"getLinks"),Eze=o(function(t){E1=t,rO.push(t)},"addSection"),Sze=o(function(){return rO},"getSections"),Cze=o(function(){let t=vue(),e=10,r=0;for(;!t&&r[\d\w- ]+)/.exec(r);if(i!==null){let s=null;for(let u of i.groups.ids.split(" ")){let h=Cp(u);h!==void 0&&(!s||h.endTime>s.endTime)&&(s=h)}if(s)return s.endTime;let l=new Date;return l.setHours(0,0,0,0),l}let a=(0,ho.default)(r,e.trim(),!0);if(a.isValid())return a.toDate();{Y.debug("Invalid date:"+r),Y.debug("With date format:"+e.trim());let s=new Date(r);if(s===void 0||isNaN(s.getTime())||s.getFullYear()<-1e4||s.getFullYear()>1e4)throw new Error("Invalid date:"+r);return s}},"getStartDate"),Cue=o(function(t){let e=/^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$/.exec(t.trim());return e!==null?[Number.parseFloat(e[1]),e[2]]:[NaN,"ms"]},"parseDuration"),Aue=o(function(t,e,r,n=!1){r=r.trim();let a=/^until\s+(?[\d\w- ]+)/.exec(r);if(a!==null){let f=null;for(let p of a.groups.ids.split(" ")){let m=Cp(p);m!==void 0&&(!f||m.startTime{window.open(r,"_self")}),tO.set(n,r))}),Due(t,"clickable")},"setLink"),Due=o(function(t,e){t.split(",").forEach(function(r){let n=Cp(r);n!==void 0&&n.classes.push(e)})},"setClass"),Pze=o(function(t,e,r){if(me().securityLevel!=="loose"||e===void 0)return;let n=[];if(typeof r=="string"){n=r.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let a=0;a{Gt.runFunc(e,...n)})},"setClickFun"),Lue=o(function(t,e){iO.push(function(){let r=document.querySelector(`[id="${t}"]`);r!==null&&r.addEventListener("click",function(){e()})},function(){let r=document.querySelector(`[id="${t}-text"]`);r!==null&&r.addEventListener("click",function(){e()})})},"pushFun"),Bze=o(function(t,e,r){t.split(",").forEach(function(n){Pze(n,e,r)}),Due(t,"clickable")},"setClickEvent"),Fze=o(function(t){iO.forEach(function(e){e(t)})},"bindFunctions"),Rue={getConfig:o(()=>me().gantt,"getConfig"),clear:ize,setDateFormat:hze,getDateFormat:vze,enableInclusiveEndDates:fze,endDatesAreInclusive:dze,enableTopAxis:pze,topAxisEnabled:mze,setAxisFormat:aze,getAxisFormat:sze,setTickInterval:oze,getTickInterval:lze,setTodayMarker:cze,getTodayMarker:uze,setAccTitle:Lr,getAccTitle:Rr,setDiagramTitle:$r,getDiagramTitle:Ir,setDisplayMode:gze,getDisplayMode:yze,setAccDescription:Nr,getAccDescription:Mr,addSection:Eze,getSections:Sze,getTasks:Cze,addTask:Mze,findTaskById:Cp,addTaskOrg:Ize,setIncludes:xze,getIncludes:bze,setExcludes:wze,getExcludes:Tze,setClickEvent:Bze,setLink:Oze,getLinks:kze,bindFunctions:Fze,parseDuration:Cue,isInvalidDate:Eue,setWeekday:Aze,getWeekday:_ze,setWeekend:Dze};o(Nue,"getTaskTags")});var p6,$ze,Iue,zze,Yu,Gze,Oue,Pue=N(()=>{"use strict";p6=Sa(R4(),1);vt();dr();gr();zt();Ei();$ze=o(function(){Y.debug("Something is calling, setConf, remove the call")},"setConf"),Iue={monday:Ch,tuesday:T5,wednesday:k5,thursday:oc,friday:E5,saturday:S5,sunday:yl},zze=o((t,e)=>{let r=[...t].map(()=>-1/0),n=[...t].sort((a,s)=>a.startTime-s.startTime||a.order-s.order),i=0;for(let a of n)for(let s=0;s=r[s]){r[s]=a.endTime,a.order=s+e,s>i&&(i=s);break}return i},"getMaxIntersections"),Gze=o(function(t,e,r,n){let i=me().gantt,a=me().securityLevel,s;a==="sandbox"&&(s=Ge("#i"+e));let l=a==="sandbox"?Ge(s.nodes()[0].contentDocument.body):Ge("body"),u=a==="sandbox"?s.nodes()[0].contentDocument:document,h=u.getElementById(e);Yu=h.parentElement.offsetWidth,Yu===void 0&&(Yu=1200),i.useWidth!==void 0&&(Yu=i.useWidth);let f=n.db.getTasks(),d=[];for(let S of f)d.push(S.type);d=A(d);let p={},m=2*i.topPadding;if(n.db.getDisplayMode()==="compact"||i.displayMode==="compact"){let S={};for(let I of f)S[I.section]===void 0?S[I.section]=[I]:S[I.section].push(I);let _=0;for(let I of Object.keys(S)){let D=zze(S[I],_)+1;_+=D,m+=D*(i.barHeight+i.barGap),p[I]=D}}else{m+=f.length*(i.barHeight+i.barGap);for(let S of d)p[S]=f.filter(_=>_.type===S).length}h.setAttribute("viewBox","0 0 "+Yu+" "+m);let g=l.select(`[id="${e}"]`),y=_5().domain([M3(f,function(S){return S.startTime}),N3(f,function(S){return S.endTime})]).rangeRound([0,Yu-i.leftPadding-i.rightPadding]);function v(S,_){let I=S.startTime,D=_.startTime,k=0;return I>D?k=1:Iz.order))].map(z=>S.find($=>$.order===z));g.append("g").selectAll("rect").data(M).enter().append("rect").attr("x",0).attr("y",function(z,$){return $=z.order,$*_+I-2}).attr("width",function(){return R-i.rightPadding/2}).attr("height",_).attr("class",function(z){for(let[$,H]of d.entries())if(z.type===H)return"section section"+$%i.numberSectionStyles;return"section section0"});let B=g.append("g").selectAll("rect").data(S).enter(),F=n.db.getLinks();if(B.append("rect").attr("id",function(z){return z.id}).attr("rx",3).attr("ry",3).attr("x",function(z){return z.milestone?y(z.startTime)+D+.5*(y(z.endTime)-y(z.startTime))-.5*k:y(z.startTime)+D}).attr("y",function(z,$){return $=z.order,$*_+I}).attr("width",function(z){return z.milestone?k:y(z.renderEndTime||z.endTime)-y(z.startTime)}).attr("height",k).attr("transform-origin",function(z,$){return $=z.order,(y(z.startTime)+D+.5*(y(z.endTime)-y(z.startTime))).toString()+"px "+($*_+I+.5*k).toString()+"px"}).attr("class",function(z){let $="task",H="";z.classes.length>0&&(H=z.classes.join(" "));let Q=0;for(let[ie,ne]of d.entries())z.type===ne&&(Q=ie%i.numberSectionStyles);let j="";return z.active?z.crit?j+=" activeCrit":j=" active":z.done?z.crit?j=" doneCrit":j=" done":z.crit&&(j+=" crit"),j.length===0&&(j=" task"),z.milestone&&(j=" milestone "+j),j+=Q,j+=" "+H,$+j}),B.append("text").attr("id",function(z){return z.id+"-text"}).text(function(z){return z.task}).attr("font-size",i.fontSize).attr("x",function(z){let $=y(z.startTime),H=y(z.renderEndTime||z.endTime);z.milestone&&($+=.5*(y(z.endTime)-y(z.startTime))-.5*k),z.milestone&&(H=$+k);let Q=this.getBBox().width;return Q>H-$?H+Q+1.5*i.leftPadding>R?$+D-5:H+D+5:(H-$)/2+$+D}).attr("y",function(z,$){return $=z.order,$*_+i.barHeight/2+(i.fontSize/2-2)+I}).attr("text-height",k).attr("class",function(z){let $=y(z.startTime),H=y(z.endTime);z.milestone&&(H=$+k);let Q=this.getBBox().width,j="";z.classes.length>0&&(j=z.classes.join(" "));let ie=0;for(let[le,he]of d.entries())z.type===he&&(ie=le%i.numberSectionStyles);let ne="";return z.active&&(z.crit?ne="activeCritText"+ie:ne="activeText"+ie),z.done?z.crit?ne=ne+" doneCritText"+ie:ne=ne+" doneText"+ie:z.crit&&(ne=ne+" critText"+ie),z.milestone&&(ne+=" milestoneText"),Q>H-$?H+Q+1.5*i.leftPadding>R?j+" taskTextOutsideLeft taskTextOutside"+ie+" "+ne:j+" taskTextOutsideRight taskTextOutside"+ie+" "+ne+" width-"+Q:j+" taskText taskText"+ie+" "+ne+" width-"+Q}),me().securityLevel==="sandbox"){let z;z=Ge("#i"+e);let $=z.nodes()[0].contentDocument;B.filter(function(H){return F.has(H.id)}).each(function(H){var Q=$.querySelector("#"+H.id),j=$.querySelector("#"+H.id+"-text");let ie=Q.parentNode;var ne=$.createElement("a");ne.setAttribute("xlink:href",F.get(H.id)),ne.setAttribute("target","_top"),ie.appendChild(ne),ne.appendChild(Q),ne.appendChild(j)})}}o(b,"drawRects");function w(S,_,I,D,k,L,R,O){if(R.length===0&&O.length===0)return;let M,B;for(let{startTime:Q,endTime:j}of L)(M===void 0||QB)&&(B=j);if(!M||!B)return;if((0,p6.default)(B).diff((0,p6.default)(M),"year")>5){Y.warn("The difference between the min and max time is more than 5 years. This will cause performance issues. Skipping drawing exclude days.");return}let F=n.db.getDateFormat(),P=[],z=null,$=(0,p6.default)(M);for(;$.valueOf()<=B;)n.db.isInvalidDate($,F,R,O)?z?z.end=$:z={start:$,end:$}:z&&(P.push(z),z=null),$=$.add(1,"d");g.append("g").selectAll("rect").data(P).enter().append("rect").attr("id",function(Q){return"exclude-"+Q.start.format("YYYY-MM-DD")}).attr("x",function(Q){return y(Q.start)+I}).attr("y",i.gridLineStartPadding).attr("width",function(Q){let j=Q.end.add(1,"day");return y(j)-y(Q.start)}).attr("height",k-_-i.gridLineStartPadding).attr("transform-origin",function(Q,j){return(y(Q.start)+I+.5*(y(Q.end)-y(Q.start))).toString()+"px "+(j*S+.5*k).toString()+"px"}).attr("class","exclude-range")}o(w,"drawExcludeDays");function C(S,_,I,D){let k=bA(y).tickSize(-D+_+i.gridLineStartPadding).tickFormat(wd(n.db.getAxisFormat()||i.axisFormat||"%Y-%m-%d")),R=/^([1-9]\d*)(millisecond|second|minute|hour|day|week|month)$/.exec(n.db.getTickInterval()||i.tickInterval);if(R!==null){let O=R[1],M=R[2],B=n.db.getWeekday()||i.weekday;switch(M){case"millisecond":k.ticks(ac.every(O));break;case"second":k.ticks(Ks.every(O));break;case"minute":k.ticks(vu.every(O));break;case"hour":k.ticks(xu.every(O));break;case"day":k.ticks(_o.every(O));break;case"week":k.ticks(Iue[B].every(O));break;case"month":k.ticks(bu.every(O));break}}if(g.append("g").attr("class","grid").attr("transform","translate("+S+", "+(D-50)+")").call(k).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10).attr("dy","1em"),n.db.topAxisEnabled()||i.topAxis){let O=xA(y).tickSize(-D+_+i.gridLineStartPadding).tickFormat(wd(n.db.getAxisFormat()||i.axisFormat||"%Y-%m-%d"));if(R!==null){let M=R[1],B=R[2],F=n.db.getWeekday()||i.weekday;switch(B){case"millisecond":O.ticks(ac.every(M));break;case"second":O.ticks(Ks.every(M));break;case"minute":O.ticks(vu.every(M));break;case"hour":O.ticks(xu.every(M));break;case"day":O.ticks(_o.every(M));break;case"week":O.ticks(Iue[F].every(M));break;case"month":O.ticks(bu.every(M));break}}g.append("g").attr("class","grid").attr("transform","translate("+S+", "+_+")").call(O).selectAll("text").style("text-anchor","middle").attr("fill","#000").attr("stroke","none").attr("font-size",10)}}o(C,"makeGrid");function T(S,_){let I=0,D=Object.keys(p).map(k=>[k,p[k]]);g.append("g").selectAll("text").data(D).enter().append(function(k){let L=k[0].split(Ze.lineBreakRegex),R=-(L.length-1)/2,O=u.createElementNS("http://www.w3.org/2000/svg","text");O.setAttribute("dy",R+"em");for(let[M,B]of L.entries()){let F=u.createElementNS("http://www.w3.org/2000/svg","tspan");F.setAttribute("alignment-baseline","central"),F.setAttribute("x","10"),M>0&&F.setAttribute("dy","1em"),F.textContent=B,O.appendChild(F)}return O}).attr("x",10).attr("y",function(k,L){if(L>0)for(let R=0;R{"use strict";Vze=o(t=>` + .mermaid-main-font { + font-family: ${t.fontFamily}; + } + + .exclude-range { + fill: ${t.excludeBkgColor}; + } + + .section { + stroke: none; + opacity: 0.2; + } + + .section0 { + fill: ${t.sectionBkgColor}; + } + + .section2 { + fill: ${t.sectionBkgColor2}; + } + + .section1, + .section3 { + fill: ${t.altSectionBkgColor}; + opacity: 0.2; + } + + .sectionTitle0 { + fill: ${t.titleColor}; + } + + .sectionTitle1 { + fill: ${t.titleColor}; + } + + .sectionTitle2 { + fill: ${t.titleColor}; + } + + .sectionTitle3 { + fill: ${t.titleColor}; + } + + .sectionTitle { + text-anchor: start; + font-family: ${t.fontFamily}; + } + + + /* Grid and axis */ + + .grid .tick { + stroke: ${t.gridColor}; + opacity: 0.8; + shape-rendering: crispEdges; + } + + .grid .tick text { + font-family: ${t.fontFamily}; + fill: ${t.textColor}; + } + + .grid path { + stroke-width: 0; + } + + + /* Today line */ + + .today { + fill: none; + stroke: ${t.todayLineColor}; + stroke-width: 2px; + } + + + /* Task styling */ + + /* Default task */ + + .task { + stroke-width: 2; + } + + .taskText { + text-anchor: middle; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideRight { + fill: ${t.taskTextDarkColor}; + text-anchor: start; + font-family: ${t.fontFamily}; + } + + .taskTextOutsideLeft { + fill: ${t.taskTextDarkColor}; + text-anchor: end; + } + + + /* Special case clickable */ + + .task.clickable { + cursor: pointer; + } + + .taskText.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideLeft.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + .taskTextOutsideRight.clickable { + cursor: pointer; + fill: ${t.taskTextClickableColor} !important; + font-weight: bold; + } + + + /* Specific task settings for the sections*/ + + .taskText0, + .taskText1, + .taskText2, + .taskText3 { + fill: ${t.taskTextColor}; + } + + .task0, + .task1, + .task2, + .task3 { + fill: ${t.taskBkgColor}; + stroke: ${t.taskBorderColor}; + } + + .taskTextOutside0, + .taskTextOutside2 + { + fill: ${t.taskTextOutsideColor}; + } + + .taskTextOutside1, + .taskTextOutside3 { + fill: ${t.taskTextOutsideColor}; + } + + + /* Active task */ + + .active0, + .active1, + .active2, + .active3 { + fill: ${t.activeTaskBkgColor}; + stroke: ${t.activeTaskBorderColor}; + } + + .activeText0, + .activeText1, + .activeText2, + .activeText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Completed task */ + + .done0, + .done1, + .done2, + .done3 { + stroke: ${t.doneTaskBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + } + + .doneText0, + .doneText1, + .doneText2, + .doneText3 { + fill: ${t.taskTextDarkColor} !important; + } + + + /* Tasks on the critical line */ + + .crit0, + .crit1, + .crit2, + .crit3 { + stroke: ${t.critBorderColor}; + fill: ${t.critBkgColor}; + stroke-width: 2; + } + + .activeCrit0, + .activeCrit1, + .activeCrit2, + .activeCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.activeTaskBkgColor}; + stroke-width: 2; + } + + .doneCrit0, + .doneCrit1, + .doneCrit2, + .doneCrit3 { + stroke: ${t.critBorderColor}; + fill: ${t.doneTaskBkgColor}; + stroke-width: 2; + cursor: pointer; + shape-rendering: crispEdges; + } + + .milestone { + transform: rotate(45deg) scale(0.8,0.8); + } + + .milestoneText { + font-style: italic; + } + .doneCritText0, + .doneCritText1, + .doneCritText2, + .doneCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .activeCritText0, + .activeCritText1, + .activeCritText2, + .activeCritText3 { + fill: ${t.taskTextDarkColor} !important; + } + + .titleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.titleColor||t.textColor}; + font-family: ${t.fontFamily}; + } +`,"getStyles"),Bue=Vze});var $ue={};hr($ue,{diagram:()=>Uze});var Uze,zue=N(()=>{"use strict";due();Mue();Pue();Fue();Uze={parser:fue,db:Rue,renderer:Oue,styles:Bue}});var Uue,Hue=N(()=>{"use strict";kp();vt();Uue={parse:o(async t=>{let e=await uo("info",t);Y.debug(e)},"parse")}});var vb,oO=N(()=>{vb={name:"mermaid",version:"11.6.0",description:"Markdown-ish syntax for generating flowcharts, mindmaps, sequence diagrams, class diagrams, gantt charts, git graphs and more.",type:"module",module:"./dist/mermaid.core.mjs",types:"./dist/mermaid.d.ts",exports:{".":{types:"./dist/mermaid.d.ts",import:"./dist/mermaid.core.mjs",default:"./dist/mermaid.core.mjs"},"./*":"./*"},keywords:["diagram","markdown","flowchart","sequence diagram","gantt","class diagram","git graph","mindmap","packet diagram","c4 diagram","er diagram","pie chart","pie diagram","quadrant chart","requirement diagram","graph"],scripts:{clean:"rimraf dist",dev:"pnpm -w dev","docs:code":"typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup","docs:build":"rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts","docs:verify":"pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify","docs:pre:vitepress":"pnpm --filter ./src/docs prefetch && rimraf src/vitepress && pnpm docs:code && tsx scripts/docs.cli.mts --vitepress && pnpm --filter ./src/vitepress install --no-frozen-lockfile --ignore-scripts","docs:build:vitepress":"pnpm docs:pre:vitepress && (cd src/vitepress && pnpm run build) && cpy --flat src/docs/landing/ ./src/vitepress/.vitepress/dist/landing","docs:dev":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:dev:docker":'pnpm docs:pre:vitepress && concurrently "pnpm --filter ./src/vitepress dev:docker" "tsx scripts/docs.cli.mts --watch --vitepress"',"docs:serve":"pnpm docs:build:vitepress && vitepress serve src/vitepress","docs:spellcheck":'cspell "src/docs/**/*.md"',"docs:release-version":"tsx scripts/update-release-version.mts","docs:verify-version":"tsx scripts/update-release-version.mts --verify","types:build-config":"tsx scripts/create-types-from-json-schema.mts","types:verify-config":"tsx scripts/create-types-from-json-schema.mts --verify",checkCircle:"npx madge --circular ./src",prepublishOnly:"pnpm docs:verify-version"},repository:{type:"git",url:"https://github.com/mermaid-js/mermaid"},author:"Knut Sveidqvist",license:"MIT",standard:{ignore:["**/parser/*.js","dist/**/*.js","cypress/**/*.js"],globals:["page"]},dependencies:{"@braintree/sanitize-url":"^7.0.4","@iconify/utils":"^2.1.33","@mermaid-js/parser":"workspace:^","@types/d3":"^7.4.3",cytoscape:"^3.29.3","cytoscape-cose-bilkent":"^4.1.0","cytoscape-fcose":"^2.2.0",d3:"^7.9.0","d3-sankey":"^0.12.3","dagre-d3-es":"7.0.11",dayjs:"^1.11.13",dompurify:"^3.2.4",katex:"^0.16.9",khroma:"^2.1.0","lodash-es":"^4.17.21",marked:"^15.0.7",roughjs:"^4.6.6",stylis:"^4.3.6","ts-dedent":"^2.2.0",uuid:"^11.1.0"},devDependencies:{"@adobe/jsonschema2md":"^8.0.2","@iconify/types":"^2.0.0","@types/cytoscape":"^3.21.9","@types/cytoscape-fcose":"^2.2.4","@types/d3-sankey":"^0.12.4","@types/d3-scale":"^4.0.9","@types/d3-scale-chromatic":"^3.1.0","@types/d3-selection":"^3.0.11","@types/d3-shape":"^3.1.7","@types/jsdom":"^21.1.7","@types/katex":"^0.16.7","@types/lodash-es":"^4.17.12","@types/micromatch":"^4.0.9","@types/stylis":"^4.2.7","@types/uuid":"^10.0.0",ajv:"^8.17.1",chokidar:"^4.0.3",concurrently:"^9.1.2","csstree-validator":"^4.0.1",globby:"^14.0.2",jison:"^0.4.18","js-base64":"^3.7.7",jsdom:"^26.0.0","json-schema-to-typescript":"^15.0.4",micromatch:"^4.0.8","path-browserify":"^1.0.1",prettier:"^3.5.2",remark:"^15.0.1","remark-frontmatter":"^5.0.0","remark-gfm":"^4.0.1",rimraf:"^6.0.1","start-server-and-test":"^2.0.10","type-fest":"^4.35.0",typedoc:"^0.27.8","typedoc-plugin-markdown":"^4.4.2",typescript:"~5.7.3","unist-util-flatmap":"^1.0.0","unist-util-visit":"^5.0.0",vitepress:"^1.0.2","vitepress-plugin-search":"1.0.4-alpha.22"},files:["dist/","README.md"],publishConfig:{access:"public"}}});var Xze,jze,Wue,que=N(()=>{"use strict";oO();Xze={version:vb.version},jze=o(()=>Xze.version,"getVersion"),Wue={getVersion:jze}});var sa,Vc=N(()=>{"use strict";dr();zt();sa=o(t=>{let{securityLevel:e}=me(),r=Ge("body");if(e==="sandbox"){let a=Ge(`#i${t}`).node()?.contentDocument??document;r=Ge(a.body)}return r.select(`#${t}`)},"selectSvgElement")});var Kze,Yue,Xue=N(()=>{"use strict";vt();Vc();Ei();Kze=o((t,e,r)=>{Y.debug(`rendering info diagram +`+t);let n=sa(e);vn(n,100,400,!0),n.append("g").append("text").attr("x",100).attr("y",40).attr("class","version").attr("font-size",32).style("text-anchor","middle").text(`v${r}`)},"draw"),Yue={draw:Kze}});var jue={};hr(jue,{diagram:()=>Qze});var Qze,Kue=N(()=>{"use strict";Hue();que();Xue();Qze={parser:Uue,db:Wue,renderer:Yue}});var Jue,lO,m6,cO,eGe,tGe,rGe,nGe,iGe,aGe,sGe,g6,uO=N(()=>{"use strict";vt();mi();Ya();Jue=or.pie,lO={sections:new Map,showData:!1,config:Jue},m6=lO.sections,cO=lO.showData,eGe=structuredClone(Jue),tGe=o(()=>structuredClone(eGe),"getConfig"),rGe=o(()=>{m6=new Map,cO=lO.showData,Ar()},"clear"),nGe=o(({label:t,value:e})=>{m6.has(t)||(m6.set(t,e),Y.debug(`added new section: ${t}, with value: ${e}`))},"addSection"),iGe=o(()=>m6,"getSections"),aGe=o(t=>{cO=t},"setShowData"),sGe=o(()=>cO,"getShowData"),g6={getConfig:tGe,clear:rGe,setDiagramTitle:$r,getDiagramTitle:Ir,setAccTitle:Lr,getAccTitle:Rr,setAccDescription:Nr,getAccDescription:Mr,addSection:nGe,getSections:iGe,setShowData:aGe,getShowData:sGe}});var oGe,ehe,the=N(()=>{"use strict";kp();vt();T1();uO();oGe=o((t,e)=>{$c(t,e),e.setShowData(t.showData),t.sections.map(e.addSection)},"populateDb"),ehe={parse:o(async t=>{let e=await uo("pie",t);Y.debug(e),oGe(e,g6)},"parse")}});var lGe,rhe,nhe=N(()=>{"use strict";lGe=o(t=>` + .pieCircle{ + stroke: ${t.pieStrokeColor}; + stroke-width : ${t.pieStrokeWidth}; + opacity : ${t.pieOpacity}; + } + .pieOuterCircle{ + stroke: ${t.pieOuterStrokeColor}; + stroke-width: ${t.pieOuterStrokeWidth}; + fill: none; + } + .pieTitleText { + text-anchor: middle; + font-size: ${t.pieTitleTextSize}; + fill: ${t.pieTitleTextColor}; + font-family: ${t.fontFamily}; + } + .slice { + font-family: ${t.fontFamily}; + fill: ${t.pieSectionTextColor}; + font-size:${t.pieSectionTextSize}; + // fill: white; + } + .legend text { + fill: ${t.pieLegendTextColor}; + font-family: ${t.fontFamily}; + font-size: ${t.pieLegendTextSize}; + } +`,"getStyles"),rhe=lGe});var cGe,uGe,ihe,ahe=N(()=>{"use strict";dr();zt();vt();Vc();Ei();ir();cGe=o(t=>{let e=[...t.entries()].map(n=>({label:n[0],value:n[1]})).sort((n,i)=>i.value-n.value);return I5().value(n=>n.value)(e)},"createPieArcs"),uGe=o((t,e,r,n)=>{Y.debug(`rendering pie chart +`+t);let i=n.db,a=me(),s=Fi(i.getConfig(),a.pie),l=40,u=18,h=4,f=450,d=f,p=sa(e),m=p.append("g");m.attr("transform","translate("+d/2+","+f/2+")");let{themeVariables:g}=a,[y]=Bo(g.pieOuterStrokeWidth);y??=2;let v=s.textPosition,x=Math.min(d,f)/2-l,b=bl().innerRadius(0).outerRadius(x),w=bl().innerRadius(x*v).outerRadius(x*v);m.append("circle").attr("cx",0).attr("cy",0).attr("r",x+y/2).attr("class","pieOuterCircle");let C=i.getSections(),T=cGe(C),E=[g.pie1,g.pie2,g.pie3,g.pie4,g.pie5,g.pie6,g.pie7,g.pie8,g.pie9,g.pie10,g.pie11,g.pie12],A=gu(E);m.selectAll("mySlices").data(T).enter().append("path").attr("d",b).attr("fill",k=>A(k.data.label)).attr("class","pieCircle");let S=0;C.forEach(k=>{S+=k}),m.selectAll("mySlices").data(T).enter().append("text").text(k=>(k.data.value/S*100).toFixed(0)+"%").attr("transform",k=>"translate("+w.centroid(k)+")").style("text-anchor","middle").attr("class","slice"),m.append("text").text(i.getDiagramTitle()).attr("x",0).attr("y",-(f-50)/2).attr("class","pieTitleText");let _=m.selectAll(".legend").data(A.domain()).enter().append("g").attr("class","legend").attr("transform",(k,L)=>{let R=u+h,O=R*A.domain().length/2,M=12*u,B=L*R-O;return"translate("+M+","+B+")"});_.append("rect").attr("width",u).attr("height",u).style("fill",A).style("stroke",A),_.data(T).append("text").attr("x",u+h).attr("y",u-h).text(k=>{let{label:L,value:R}=k.data;return i.getShowData()?`${L} [${R}]`:L});let I=Math.max(..._.selectAll("text").nodes().map(k=>k?.getBoundingClientRect().width??0)),D=d+l+u+h+I;p.attr("viewBox",`0 0 ${D} ${f}`),vn(p,f,D,s.useMaxWidth)},"draw"),ihe={draw:uGe}});var she={};hr(she,{diagram:()=>hGe});var hGe,ohe=N(()=>{"use strict";the();uO();nhe();ahe();hGe={parser:ehe,db:g6,renderer:ihe,styles:rhe}});var hO,uhe,hhe=N(()=>{"use strict";hO=function(){var t=o(function(xe,q,pe,ve){for(pe=pe||{},ve=xe.length;ve--;pe[xe[ve]]=q);return pe},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[1,7],s=[1,4,5,10,12,13,14,18,25,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],l=[1,4,5,10,12,13,14,18,25,28,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],u=[55,56,57],h=[2,36],f=[1,37],d=[1,36],p=[1,38],m=[1,35],g=[1,43],y=[1,41],v=[1,14],x=[1,23],b=[1,18],w=[1,19],C=[1,20],T=[1,21],E=[1,22],A=[1,24],S=[1,25],_=[1,26],I=[1,27],D=[1,28],k=[1,29],L=[1,32],R=[1,33],O=[1,34],M=[1,39],B=[1,40],F=[1,42],P=[1,44],z=[1,62],$=[1,61],H=[4,5,8,10,12,13,14,18,44,47,49,55,56,57,63,64,65,66,67],Q=[1,65],j=[1,66],ie=[1,67],ne=[1,68],le=[1,69],he=[1,70],K=[1,71],X=[1,72],te=[1,73],J=[1,74],se=[1,75],ue=[1,76],Z=[4,5,6,7,8,9,10,11,12,13,14,15,18],Se=[1,90],ce=[1,91],ae=[1,92],Oe=[1,99],ge=[1,93],ze=[1,96],He=[1,94],$e=[1,95],Re=[1,97],Ie=[1,98],be=[1,102],W=[10,55,56,57],de=[4,5,6,8,10,11,13,17,18,19,20,55,56,57],re={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,idStringToken:3,ALPHA:4,NUM:5,NODE_STRING:6,DOWN:7,MINUS:8,DEFAULT:9,COMMA:10,COLON:11,AMP:12,BRKT:13,MULT:14,UNICODE_TEXT:15,styleComponent:16,UNIT:17,SPACE:18,STYLE:19,PCT:20,idString:21,style:22,stylesOpt:23,classDefStatement:24,CLASSDEF:25,start:26,eol:27,QUADRANT:28,document:29,line:30,statement:31,axisDetails:32,quadrantDetails:33,points:34,title:35,title_value:36,acc_title:37,acc_title_value:38,acc_descr:39,acc_descr_value:40,acc_descr_multiline_value:41,section:42,text:43,point_start:44,point_x:45,point_y:46,class_name:47,"X-AXIS":48,"AXIS-TEXT-DELIMITER":49,"Y-AXIS":50,QUADRANT_1:51,QUADRANT_2:52,QUADRANT_3:53,QUADRANT_4:54,NEWLINE:55,SEMI:56,EOF:57,alphaNumToken:58,textNoTagsToken:59,STR:60,MD_STR:61,alphaNum:62,PUNCTUATION:63,PLUS:64,EQUALS:65,DOT:66,UNDERSCORE:67,$accept:0,$end:1},terminals_:{2:"error",4:"ALPHA",5:"NUM",6:"NODE_STRING",7:"DOWN",8:"MINUS",9:"DEFAULT",10:"COMMA",11:"COLON",12:"AMP",13:"BRKT",14:"MULT",15:"UNICODE_TEXT",17:"UNIT",18:"SPACE",19:"STYLE",20:"PCT",25:"CLASSDEF",28:"QUADRANT",35:"title",36:"title_value",37:"acc_title",38:"acc_title_value",39:"acc_descr",40:"acc_descr_value",41:"acc_descr_multiline_value",42:"section",44:"point_start",45:"point_x",46:"point_y",47:"class_name",48:"X-AXIS",49:"AXIS-TEXT-DELIMITER",50:"Y-AXIS",51:"QUADRANT_1",52:"QUADRANT_2",53:"QUADRANT_3",54:"QUADRANT_4",55:"NEWLINE",56:"SEMI",57:"EOF",60:"STR",61:"MD_STR",63:"PUNCTUATION",64:"PLUS",65:"EQUALS",66:"DOT",67:"UNDERSCORE"},productions_:[0,[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[21,1],[21,2],[22,1],[22,2],[23,1],[23,3],[24,5],[26,2],[26,2],[26,2],[29,0],[29,2],[30,2],[31,0],[31,1],[31,2],[31,1],[31,1],[31,1],[31,2],[31,2],[31,2],[31,1],[31,1],[34,4],[34,5],[34,5],[34,6],[32,4],[32,3],[32,2],[32,4],[32,3],[32,2],[33,2],[33,2],[33,2],[33,2],[27,1],[27,1],[27,1],[43,1],[43,2],[43,1],[43,1],[62,1],[62,2],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[59,1],[59,1],[59,1]],performAction:o(function(q,pe,ve,Pe,_e,we,Ve){var De=we.length-1;switch(_e){case 23:this.$=we[De];break;case 24:this.$=we[De-1]+""+we[De];break;case 26:this.$=we[De-1]+we[De];break;case 27:this.$=[we[De].trim()];break;case 28:we[De-2].push(we[De].trim()),this.$=we[De-2];break;case 29:this.$=we[De-4],Pe.addClass(we[De-2],we[De]);break;case 37:this.$=[];break;case 42:this.$=we[De].trim(),Pe.setDiagramTitle(this.$);break;case 43:this.$=we[De].trim(),Pe.setAccTitle(this.$);break;case 44:case 45:this.$=we[De].trim(),Pe.setAccDescription(this.$);break;case 46:Pe.addSection(we[De].substr(8)),this.$=we[De].substr(8);break;case 47:Pe.addPoint(we[De-3],"",we[De-1],we[De],[]);break;case 48:Pe.addPoint(we[De-4],we[De-3],we[De-1],we[De],[]);break;case 49:Pe.addPoint(we[De-4],"",we[De-2],we[De-1],we[De]);break;case 50:Pe.addPoint(we[De-5],we[De-4],we[De-2],we[De-1],we[De]);break;case 51:Pe.setXAxisLeftText(we[De-2]),Pe.setXAxisRightText(we[De]);break;case 52:we[De-1].text+=" \u27F6 ",Pe.setXAxisLeftText(we[De-1]);break;case 53:Pe.setXAxisLeftText(we[De]);break;case 54:Pe.setYAxisBottomText(we[De-2]),Pe.setYAxisTopText(we[De]);break;case 55:we[De-1].text+=" \u27F6 ",Pe.setYAxisBottomText(we[De-1]);break;case 56:Pe.setYAxisBottomText(we[De]);break;case 57:Pe.setQuadrant1Text(we[De]);break;case 58:Pe.setQuadrant2Text(we[De]);break;case 59:Pe.setQuadrant3Text(we[De]);break;case 60:Pe.setQuadrant4Text(we[De]);break;case 64:this.$={text:we[De],type:"text"};break;case 65:this.$={text:we[De-1].text+""+we[De],type:we[De-1].type};break;case 66:this.$={text:we[De],type:"text"};break;case 67:this.$={text:we[De],type:"markdown"};break;case 68:this.$=we[De];break;case 69:this.$=we[De-1]+""+we[De];break}},"anonymous"),table:[{18:e,26:1,27:2,28:r,55:n,56:i,57:a},{1:[3]},{18:e,26:8,27:2,28:r,55:n,56:i,57:a},{18:e,26:9,27:2,28:r,55:n,56:i,57:a},t(s,[2,33],{29:10}),t(l,[2,61]),t(l,[2,62]),t(l,[2,63]),{1:[2,30]},{1:[2,31]},t(u,h,{30:11,31:12,24:13,32:15,33:16,34:17,43:30,58:31,1:[2,32],4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:w,39:C,41:T,42:E,48:A,50:S,51:_,52:I,53:D,54:k,60:L,61:R,63:O,64:M,65:B,66:F,67:P}),t(s,[2,34]),{27:45,55:n,56:i,57:a},t(u,[2,37]),t(u,h,{24:13,32:15,33:16,34:17,43:30,58:31,31:46,4:f,5:d,10:p,12:m,13:g,14:y,18:v,25:x,35:b,37:w,39:C,41:T,42:E,48:A,50:S,51:_,52:I,53:D,54:k,60:L,61:R,63:O,64:M,65:B,66:F,67:P}),t(u,[2,39]),t(u,[2,40]),t(u,[2,41]),{36:[1,47]},{38:[1,48]},{40:[1,49]},t(u,[2,45]),t(u,[2,46]),{18:[1,50]},{4:f,5:d,10:p,12:m,13:g,14:y,43:51,58:31,60:L,61:R,63:O,64:M,65:B,66:F,67:P},{4:f,5:d,10:p,12:m,13:g,14:y,43:52,58:31,60:L,61:R,63:O,64:M,65:B,66:F,67:P},{4:f,5:d,10:p,12:m,13:g,14:y,43:53,58:31,60:L,61:R,63:O,64:M,65:B,66:F,67:P},{4:f,5:d,10:p,12:m,13:g,14:y,43:54,58:31,60:L,61:R,63:O,64:M,65:B,66:F,67:P},{4:f,5:d,10:p,12:m,13:g,14:y,43:55,58:31,60:L,61:R,63:O,64:M,65:B,66:F,67:P},{4:f,5:d,10:p,12:m,13:g,14:y,43:56,58:31,60:L,61:R,63:O,64:M,65:B,66:F,67:P},{4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,44:[1,57],47:[1,58],58:60,59:59,63:O,64:M,65:B,66:F,67:P},t(H,[2,64]),t(H,[2,66]),t(H,[2,67]),t(H,[2,70]),t(H,[2,71]),t(H,[2,72]),t(H,[2,73]),t(H,[2,74]),t(H,[2,75]),t(H,[2,76]),t(H,[2,77]),t(H,[2,78]),t(H,[2,79]),t(H,[2,80]),t(s,[2,35]),t(u,[2,38]),t(u,[2,42]),t(u,[2,43]),t(u,[2,44]),{3:64,4:Q,5:j,6:ie,7:ne,8:le,9:he,10:K,11:X,12:te,13:J,14:se,15:ue,21:63},t(u,[2,53],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,49:[1,77],63:O,64:M,65:B,66:F,67:P}),t(u,[2,56],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,49:[1,78],63:O,64:M,65:B,66:F,67:P}),t(u,[2,57],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:B,66:F,67:P}),t(u,[2,58],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:B,66:F,67:P}),t(u,[2,59],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:B,66:F,67:P}),t(u,[2,60],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:B,66:F,67:P}),{45:[1,79]},{44:[1,80]},t(H,[2,65]),t(H,[2,81]),t(H,[2,82]),t(H,[2,83]),{3:82,4:Q,5:j,6:ie,7:ne,8:le,9:he,10:K,11:X,12:te,13:J,14:se,15:ue,18:[1,81]},t(Z,[2,23]),t(Z,[2,1]),t(Z,[2,2]),t(Z,[2,3]),t(Z,[2,4]),t(Z,[2,5]),t(Z,[2,6]),t(Z,[2,7]),t(Z,[2,8]),t(Z,[2,9]),t(Z,[2,10]),t(Z,[2,11]),t(Z,[2,12]),t(u,[2,52],{58:31,43:83,4:f,5:d,10:p,12:m,13:g,14:y,60:L,61:R,63:O,64:M,65:B,66:F,67:P}),t(u,[2,55],{58:31,43:84,4:f,5:d,10:p,12:m,13:g,14:y,60:L,61:R,63:O,64:M,65:B,66:F,67:P}),{46:[1,85]},{45:[1,86]},{4:Se,5:ce,6:ae,8:Oe,11:ge,13:ze,16:89,17:He,18:$e,19:Re,20:Ie,22:88,23:87},t(Z,[2,24]),t(u,[2,51],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:B,66:F,67:P}),t(u,[2,54],{59:59,58:60,4:f,5:d,8:z,10:p,12:m,13:g,14:y,18:$,63:O,64:M,65:B,66:F,67:P}),t(u,[2,47],{22:88,16:89,23:100,4:Se,5:ce,6:ae,8:Oe,11:ge,13:ze,17:He,18:$e,19:Re,20:Ie}),{46:[1,101]},t(u,[2,29],{10:be}),t(W,[2,27],{16:103,4:Se,5:ce,6:ae,8:Oe,11:ge,13:ze,17:He,18:$e,19:Re,20:Ie}),t(de,[2,25]),t(de,[2,13]),t(de,[2,14]),t(de,[2,15]),t(de,[2,16]),t(de,[2,17]),t(de,[2,18]),t(de,[2,19]),t(de,[2,20]),t(de,[2,21]),t(de,[2,22]),t(u,[2,49],{10:be}),t(u,[2,48],{22:88,16:89,23:104,4:Se,5:ce,6:ae,8:Oe,11:ge,13:ze,17:He,18:$e,19:Re,20:Ie}),{4:Se,5:ce,6:ae,8:Oe,11:ge,13:ze,16:89,17:He,18:$e,19:Re,20:Ie,22:105},t(de,[2,26]),t(u,[2,50],{10:be}),t(W,[2,28],{16:103,4:Se,5:ce,6:ae,8:Oe,11:ge,13:ze,17:He,18:$e,19:Re,20:Ie})],defaultActions:{8:[2,30],9:[2,31]},parseError:o(function(q,pe){if(pe.recoverable)this.trace(q);else{var ve=new Error(q);throw ve.hash=pe,ve}},"parseError"),parse:o(function(q){var pe=this,ve=[0],Pe=[],_e=[null],we=[],Ve=this.table,De="",qe=0,at=0,Rt=0,st=2,Ue=1,ct=we.slice.call(arguments,1),We=Object.create(this.lexer),ot={yy:{}};for(var Yt in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Yt)&&(ot.yy[Yt]=this.yy[Yt]);We.setInput(q,ot.yy),ot.yy.lexer=We,ot.yy.parser=this,typeof We.yylloc>"u"&&(We.yylloc={});var bt=We.yylloc;we.push(bt);var Mt=We.options&&We.options.ranges;typeof ot.yy.parseError=="function"?this.parseError=ot.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function xt(Ce){ve.length=ve.length-2*Ce,_e.length=_e.length-Ce,we.length=we.length-Ce}o(xt,"popStack");function ut(){var Ce;return Ce=Pe.pop()||We.lex()||Ue,typeof Ce!="number"&&(Ce instanceof Array&&(Pe=Ce,Ce=Pe.pop()),Ce=pe.symbols_[Ce]||Ce),Ce}o(ut,"lex");for(var Et,ft,yt,nt,dn,Tt,On={},tn,_r,Dr,Pn;;){if(yt=ve[ve.length-1],this.defaultActions[yt]?nt=this.defaultActions[yt]:((Et===null||typeof Et>"u")&&(Et=ut()),nt=Ve[yt]&&Ve[yt][Et]),typeof nt>"u"||!nt.length||!nt[0]){var At="";Pn=[];for(tn in Ve[yt])this.terminals_[tn]&&tn>st&&Pn.push("'"+this.terminals_[tn]+"'");We.showPosition?At="Parse error on line "+(qe+1)+`: +`+We.showPosition()+` +Expecting `+Pn.join(", ")+", got '"+(this.terminals_[Et]||Et)+"'":At="Parse error on line "+(qe+1)+": Unexpected "+(Et==Ue?"end of input":"'"+(this.terminals_[Et]||Et)+"'"),this.parseError(At,{text:We.match,token:this.terminals_[Et]||Et,line:We.yylineno,loc:bt,expected:Pn})}if(nt[0]instanceof Array&&nt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+yt+", token: "+Et);switch(nt[0]){case 1:ve.push(Et),_e.push(We.yytext),we.push(We.yylloc),ve.push(nt[1]),Et=null,ft?(Et=ft,ft=null):(at=We.yyleng,De=We.yytext,qe=We.yylineno,bt=We.yylloc,Rt>0&&Rt--);break;case 2:if(_r=this.productions_[nt[1]][1],On.$=_e[_e.length-_r],On._$={first_line:we[we.length-(_r||1)].first_line,last_line:we[we.length-1].last_line,first_column:we[we.length-(_r||1)].first_column,last_column:we[we.length-1].last_column},Mt&&(On._$.range=[we[we.length-(_r||1)].range[0],we[we.length-1].range[1]]),Tt=this.performAction.apply(On,[De,at,qe,ot.yy,nt[1],_e,we].concat(ct)),typeof Tt<"u")return Tt;_r&&(ve=ve.slice(0,-1*_r*2),_e=_e.slice(0,-1*_r),we=we.slice(0,-1*_r)),ve.push(this.productions_[nt[1]][0]),_e.push(On.$),we.push(On._$),Dr=Ve[ve[ve.length-2]][ve[ve.length-1]],ve.push(Dr);break;case 3:return!0}}return!0},"parse")},oe=function(){var xe={EOF:1,parseError:o(function(pe,ve){if(this.yy.parser)this.yy.parser.parseError(pe,ve);else throw new Error(pe)},"parseError"),setInput:o(function(q,pe){return this.yy=pe||this.yy||{},this._input=q,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var q=this._input[0];this.yytext+=q,this.yyleng++,this.offset++,this.match+=q,this.matched+=q;var pe=q.match(/(?:\r\n?|\n).*/g);return pe?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),q},"input"),unput:o(function(q){var pe=q.length,ve=q.split(/(?:\r\n?|\n)/g);this._input=q+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-pe),this.offset-=pe;var Pe=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),ve.length-1&&(this.yylineno-=ve.length-1);var _e=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:ve?(ve.length===Pe.length?this.yylloc.first_column:0)+Pe[Pe.length-ve.length].length-ve[0].length:this.yylloc.first_column-pe},this.options.ranges&&(this.yylloc.range=[_e[0],_e[0]+this.yyleng-pe]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(q){this.unput(this.match.slice(q))},"less"),pastInput:o(function(){var q=this.matched.substr(0,this.matched.length-this.match.length);return(q.length>20?"...":"")+q.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var q=this.match;return q.length<20&&(q+=this._input.substr(0,20-q.length)),(q.substr(0,20)+(q.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var q=this.pastInput(),pe=new Array(q.length+1).join("-");return q+this.upcomingInput()+` +`+pe+"^"},"showPosition"),test_match:o(function(q,pe){var ve,Pe,_e;if(this.options.backtrack_lexer&&(_e={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(_e.yylloc.range=this.yylloc.range.slice(0))),Pe=q[0].match(/(?:\r\n?|\n).*/g),Pe&&(this.yylineno+=Pe.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:Pe?Pe[Pe.length-1].length-Pe[Pe.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+q[0].length},this.yytext+=q[0],this.match+=q[0],this.matches=q,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(q[0].length),this.matched+=q[0],ve=this.performAction.call(this,this.yy,this,pe,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),ve)return ve;if(this._backtrack){for(var we in _e)this[we]=_e[we];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var q,pe,ve,Pe;this._more||(this.yytext="",this.match="");for(var _e=this._currentRules(),we=0;we<_e.length;we++)if(ve=this._input.match(this.rules[_e[we]]),ve&&(!pe||ve[0].length>pe[0].length)){if(pe=ve,Pe=we,this.options.backtrack_lexer){if(q=this.test_match(ve,_e[we]),q!==!1)return q;if(this._backtrack){pe=!1;continue}else return!1}else if(!this.options.flex)break}return pe?(q=this.test_match(pe,_e[Pe]),q!==!1?q:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var pe=this.next();return pe||this.lex()},"lex"),begin:o(function(pe){this.conditionStack.push(pe)},"begin"),popState:o(function(){var pe=this.conditionStack.length-1;return pe>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(pe){return pe=this.conditionStack.length-1-Math.abs(pe||0),pe>=0?this.conditionStack[pe]:"INITIAL"},"topState"),pushState:o(function(pe){this.begin(pe)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(pe,ve,Pe,_e){var we=_e;switch(Pe){case 0:break;case 1:break;case 2:return 55;case 3:break;case 4:return this.begin("title"),35;break;case 5:return this.popState(),"title_value";break;case 6:return this.begin("acc_title"),37;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),39;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 48;case 14:return 50;case 15:return 49;case 16:return 51;case 17:return 52;case 18:return 53;case 19:return 54;case 20:return 25;case 21:this.begin("md_string");break;case 22:return"MD_STR";case 23:this.popState();break;case 24:this.begin("string");break;case 25:this.popState();break;case 26:return"STR";case 27:this.begin("class_name");break;case 28:return this.popState(),47;break;case 29:return this.begin("point_start"),44;break;case 30:return this.begin("point_x"),45;break;case 31:this.popState();break;case 32:this.popState(),this.begin("point_y");break;case 33:return this.popState(),46;break;case 34:return 28;case 35:return 4;case 36:return 11;case 37:return 64;case 38:return 10;case 39:return 65;case 40:return 65;case 41:return 14;case 42:return 13;case 43:return 67;case 44:return 66;case 45:return 12;case 46:return 8;case 47:return 5;case 48:return 18;case 49:return 56;case 50:return 63;case 51:return 57}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?: *x-axis *)/i,/^(?: *y-axis *)/i,/^(?: *--+> *)/i,/^(?: *quadrant-1 *)/i,/^(?: *quadrant-2 *)/i,/^(?: *quadrant-3 *)/i,/^(?: *quadrant-4 *)/i,/^(?:classDef\b)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?::::)/i,/^(?:^\w+)/i,/^(?:\s*:\s*\[\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?:\s*\] *)/i,/^(?:\s*,\s*)/i,/^(?:(1)|(0(.\d+)?))/i,/^(?: *quadrantChart *)/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s)/i,/^(?:;)/i,/^(?:[!"#$%&'*+,-.`?\\_/])/i,/^(?:$)/i],conditions:{class_name:{rules:[28],inclusive:!1},point_y:{rules:[33],inclusive:!1},point_x:{rules:[32],inclusive:!1},point_start:{rules:[30,31],inclusive:!1},acc_descr_multiline:{rules:[11,12],inclusive:!1},acc_descr:{rules:[9],inclusive:!1},acc_title:{rules:[7],inclusive:!1},title:{rules:[5],inclusive:!1},md_string:{rules:[22,23],inclusive:!1},string:{rules:[25,26],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,6,8,10,13,14,15,16,17,18,19,20,21,24,27,29,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],inclusive:!0}}};return xe}();re.lexer=oe;function V(){this.yy={}}return o(V,"Parser"),V.prototype=re,re.Parser=V,new V}();hO.parser=hO;uhe=hO});var ms,y6,fhe=N(()=>{"use strict";dr();Ya();vt();_y();ms=oh(),y6=class{constructor(){this.classes=new Map;this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData()}static{o(this,"QuadrantBuilder")}getDefaultData(){return{titleText:"",quadrant1Text:"",quadrant2Text:"",quadrant3Text:"",quadrant4Text:"",xAxisLeftText:"",xAxisRightText:"",yAxisBottomText:"",yAxisTopText:"",points:[]}}getDefaultConfig(){return{showXAxis:!0,showYAxis:!0,showTitle:!0,chartHeight:or.quadrantChart?.chartWidth||500,chartWidth:or.quadrantChart?.chartHeight||500,titlePadding:or.quadrantChart?.titlePadding||10,titleFontSize:or.quadrantChart?.titleFontSize||20,quadrantPadding:or.quadrantChart?.quadrantPadding||5,xAxisLabelPadding:or.quadrantChart?.xAxisLabelPadding||5,yAxisLabelPadding:or.quadrantChart?.yAxisLabelPadding||5,xAxisLabelFontSize:or.quadrantChart?.xAxisLabelFontSize||16,yAxisLabelFontSize:or.quadrantChart?.yAxisLabelFontSize||16,quadrantLabelFontSize:or.quadrantChart?.quadrantLabelFontSize||16,quadrantTextTopPadding:or.quadrantChart?.quadrantTextTopPadding||5,pointTextPadding:or.quadrantChart?.pointTextPadding||5,pointLabelFontSize:or.quadrantChart?.pointLabelFontSize||12,pointRadius:or.quadrantChart?.pointRadius||5,xAxisPosition:or.quadrantChart?.xAxisPosition||"top",yAxisPosition:or.quadrantChart?.yAxisPosition||"left",quadrantInternalBorderStrokeWidth:or.quadrantChart?.quadrantInternalBorderStrokeWidth||1,quadrantExternalBorderStrokeWidth:or.quadrantChart?.quadrantExternalBorderStrokeWidth||2}}getDefaultThemeConfig(){return{quadrant1Fill:ms.quadrant1Fill,quadrant2Fill:ms.quadrant2Fill,quadrant3Fill:ms.quadrant3Fill,quadrant4Fill:ms.quadrant4Fill,quadrant1TextFill:ms.quadrant1TextFill,quadrant2TextFill:ms.quadrant2TextFill,quadrant3TextFill:ms.quadrant3TextFill,quadrant4TextFill:ms.quadrant4TextFill,quadrantPointFill:ms.quadrantPointFill,quadrantPointTextFill:ms.quadrantPointTextFill,quadrantXAxisTextFill:ms.quadrantXAxisTextFill,quadrantYAxisTextFill:ms.quadrantYAxisTextFill,quadrantTitleFill:ms.quadrantTitleFill,quadrantInternalBorderStrokeFill:ms.quadrantInternalBorderStrokeFill,quadrantExternalBorderStrokeFill:ms.quadrantExternalBorderStrokeFill}}clear(){this.config=this.getDefaultConfig(),this.themeConfig=this.getDefaultThemeConfig(),this.data=this.getDefaultData(),this.classes=new Map,Y.info("clear called")}setData(e){this.data={...this.data,...e}}addPoints(e){this.data.points=[...e,...this.data.points]}addClass(e,r){this.classes.set(e,r)}setConfig(e){Y.trace("setConfig called with: ",e),this.config={...this.config,...e}}setThemeConfig(e){Y.trace("setThemeConfig called with: ",e),this.themeConfig={...this.themeConfig,...e}}calculateSpace(e,r,n,i){let a=this.config.xAxisLabelPadding*2+this.config.xAxisLabelFontSize,s={top:e==="top"&&r?a:0,bottom:e==="bottom"&&r?a:0},l=this.config.yAxisLabelPadding*2+this.config.yAxisLabelFontSize,u={left:this.config.yAxisPosition==="left"&&n?l:0,right:this.config.yAxisPosition==="right"&&n?l:0},h=this.config.titleFontSize+this.config.titlePadding*2,f={top:i?h:0},d=this.config.quadrantPadding+u.left,p=this.config.quadrantPadding+s.top+f.top,m=this.config.chartWidth-this.config.quadrantPadding*2-u.left-u.right,g=this.config.chartHeight-this.config.quadrantPadding*2-s.top-s.bottom-f.top,y=m/2,v=g/2;return{xAxisSpace:s,yAxisSpace:u,titleSpace:f,quadrantSpace:{quadrantLeft:d,quadrantTop:p,quadrantWidth:m,quadrantHalfWidth:y,quadrantHeight:g,quadrantHalfHeight:v}}}getAxisLabels(e,r,n,i){let{quadrantSpace:a,titleSpace:s}=i,{quadrantHalfHeight:l,quadrantHeight:u,quadrantLeft:h,quadrantHalfWidth:f,quadrantTop:d,quadrantWidth:p}=a,m=!!this.data.xAxisRightText,g=!!this.data.yAxisTopText,y=[];return this.data.xAxisLeftText&&r&&y.push({text:this.data.xAxisLeftText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.xAxisRightText&&r&&y.push({text:this.data.xAxisRightText,fill:this.themeConfig.quadrantXAxisTextFill,x:h+f+(m?f/2:0),y:e==="top"?this.config.xAxisLabelPadding+s.top:this.config.xAxisLabelPadding+d+u+this.config.quadrantPadding,fontSize:this.config.xAxisLabelFontSize,verticalPos:m?"center":"left",horizontalPos:"top",rotation:0}),this.data.yAxisBottomText&&n&&y.push({text:this.data.yAxisBottomText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+u-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),this.data.yAxisTopText&&n&&y.push({text:this.data.yAxisTopText,fill:this.themeConfig.quadrantYAxisTextFill,x:this.config.yAxisPosition==="left"?this.config.yAxisLabelPadding:this.config.yAxisLabelPadding+h+p+this.config.quadrantPadding,y:d+l-(g?l/2:0),fontSize:this.config.yAxisLabelFontSize,verticalPos:g?"center":"left",horizontalPos:"top",rotation:-90}),y}getQuadrants(e){let{quadrantSpace:r}=e,{quadrantHalfHeight:n,quadrantLeft:i,quadrantHalfWidth:a,quadrantTop:s}=r,l=[{text:{text:this.data.quadrant1Text,fill:this.themeConfig.quadrant1TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s,width:a,height:n,fill:this.themeConfig.quadrant1Fill},{text:{text:this.data.quadrant2Text,fill:this.themeConfig.quadrant2TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s,width:a,height:n,fill:this.themeConfig.quadrant2Fill},{text:{text:this.data.quadrant3Text,fill:this.themeConfig.quadrant3TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant3Fill},{text:{text:this.data.quadrant4Text,fill:this.themeConfig.quadrant4TextFill,x:0,y:0,fontSize:this.config.quadrantLabelFontSize,verticalPos:"center",horizontalPos:"middle",rotation:0},x:i+a,y:s+n,width:a,height:n,fill:this.themeConfig.quadrant4Fill}];for(let u of l)u.text.x=u.x+u.width/2,this.data.points.length===0?(u.text.y=u.y+u.height/2,u.text.horizontalPos="middle"):(u.text.y=u.y+this.config.quadrantTextTopPadding,u.text.horizontalPos="top");return l}getQuadrantPoints(e){let{quadrantSpace:r}=e,{quadrantHeight:n,quadrantLeft:i,quadrantTop:a,quadrantWidth:s}=r,l=gl().domain([0,1]).range([i,s+i]),u=gl().domain([0,1]).range([n+a,a]);return this.data.points.map(f=>{let d=this.classes.get(f.className);return d&&(f={...d,...f}),{x:l(f.x),y:u(f.y),fill:f.color??this.themeConfig.quadrantPointFill,radius:f.radius??this.config.pointRadius,text:{text:f.text,fill:this.themeConfig.quadrantPointTextFill,x:l(f.x),y:u(f.y)+this.config.pointTextPadding,verticalPos:"center",horizontalPos:"top",fontSize:this.config.pointLabelFontSize,rotation:0},strokeColor:f.strokeColor??this.themeConfig.quadrantPointFill,strokeWidth:f.strokeWidth??"0px"}})}getBorders(e){let r=this.config.quadrantExternalBorderStrokeWidth/2,{quadrantSpace:n}=e,{quadrantHalfHeight:i,quadrantHeight:a,quadrantLeft:s,quadrantHalfWidth:l,quadrantTop:u,quadrantWidth:h}=n;return[{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u,x2:s+h+r,y2:u},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s+h,y1:u+r,x2:s+h,y2:u+a-r},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s-r,y1:u+a,x2:s+h+r,y2:u+a},{strokeFill:this.themeConfig.quadrantExternalBorderStrokeFill,strokeWidth:this.config.quadrantExternalBorderStrokeWidth,x1:s,y1:u+r,x2:s,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+l,y1:u+r,x2:s+l,y2:u+a-r},{strokeFill:this.themeConfig.quadrantInternalBorderStrokeFill,strokeWidth:this.config.quadrantInternalBorderStrokeWidth,x1:s+r,y1:u+i,x2:s+h-r,y2:u+i}]}getTitle(e){if(e)return{text:this.data.titleText,fill:this.themeConfig.quadrantTitleFill,fontSize:this.config.titleFontSize,horizontalPos:"top",verticalPos:"center",rotation:0,y:this.config.titlePadding,x:this.config.chartWidth/2}}build(){let e=this.config.showXAxis&&!!(this.data.xAxisLeftText||this.data.xAxisRightText),r=this.config.showYAxis&&!!(this.data.yAxisTopText||this.data.yAxisBottomText),n=this.config.showTitle&&!!this.data.titleText,i=this.data.points.length>0?"bottom":this.config.xAxisPosition,a=this.calculateSpace(i,e,r,n);return{points:this.getQuadrantPoints(a),quadrants:this.getQuadrants(a),axisLabels:this.getAxisLabels(i,e,r,a),borderLines:this.getBorders(a),title:this.getTitle(n)}}}});function fO(t){return!/^#?([\dA-Fa-f]{6}|[\dA-Fa-f]{3})$/.test(t)}function dhe(t){return!/^\d+$/.test(t)}function phe(t){return!/^\d+px$/.test(t)}var Ap,mhe=N(()=>{"use strict";Ap=class extends Error{static{o(this,"InvalidStyleError")}constructor(e,r,n){super(`value for ${e} ${r} is invalid, please use a valid ${n}`),this.name="InvalidStyleError"}};o(fO,"validateHexCode");o(dhe,"validateNumber");o(phe,"validateSizeInPixels")});function Xu(t){return Tr(t.trim(),pGe)}function mGe(t){ba.setData({quadrant1Text:Xu(t.text)})}function gGe(t){ba.setData({quadrant2Text:Xu(t.text)})}function yGe(t){ba.setData({quadrant3Text:Xu(t.text)})}function vGe(t){ba.setData({quadrant4Text:Xu(t.text)})}function xGe(t){ba.setData({xAxisLeftText:Xu(t.text)})}function bGe(t){ba.setData({xAxisRightText:Xu(t.text)})}function wGe(t){ba.setData({yAxisTopText:Xu(t.text)})}function TGe(t){ba.setData({yAxisBottomText:Xu(t.text)})}function dO(t){let e={};for(let r of t){let[n,i]=r.trim().split(/\s*:\s*/);if(n==="radius"){if(dhe(i))throw new Ap(n,i,"number");e.radius=parseInt(i)}else if(n==="color"){if(fO(i))throw new Ap(n,i,"hex code");e.color=i}else if(n==="stroke-color"){if(fO(i))throw new Ap(n,i,"hex code");e.strokeColor=i}else if(n==="stroke-width"){if(phe(i))throw new Ap(n,i,"number of pixels (eg. 10px)");e.strokeWidth=i}else throw new Error(`style named ${n} is not supported.`)}return e}function kGe(t,e,r,n,i){let a=dO(i);ba.addPoints([{x:r,y:n,text:Xu(t.text),className:e,...a}])}function EGe(t,e){ba.addClass(t,dO(e))}function SGe(t){ba.setConfig({chartWidth:t})}function CGe(t){ba.setConfig({chartHeight:t})}function AGe(){let t=me(),{themeVariables:e,quadrantChart:r}=t;return r&&ba.setConfig(r),ba.setThemeConfig({quadrant1Fill:e.quadrant1Fill,quadrant2Fill:e.quadrant2Fill,quadrant3Fill:e.quadrant3Fill,quadrant4Fill:e.quadrant4Fill,quadrant1TextFill:e.quadrant1TextFill,quadrant2TextFill:e.quadrant2TextFill,quadrant3TextFill:e.quadrant3TextFill,quadrant4TextFill:e.quadrant4TextFill,quadrantPointFill:e.quadrantPointFill,quadrantPointTextFill:e.quadrantPointTextFill,quadrantXAxisTextFill:e.quadrantXAxisTextFill,quadrantYAxisTextFill:e.quadrantYAxisTextFill,quadrantExternalBorderStrokeFill:e.quadrantExternalBorderStrokeFill,quadrantInternalBorderStrokeFill:e.quadrantInternalBorderStrokeFill,quadrantTitleFill:e.quadrantTitleFill}),ba.setData({titleText:Ir()}),ba.build()}var pGe,ba,_Ge,ghe,yhe=N(()=>{"use strict";zt();gr();mi();fhe();mhe();pGe=me();o(Xu,"textSanitizer");ba=new y6;o(mGe,"setQuadrant1Text");o(gGe,"setQuadrant2Text");o(yGe,"setQuadrant3Text");o(vGe,"setQuadrant4Text");o(xGe,"setXAxisLeftText");o(bGe,"setXAxisRightText");o(wGe,"setYAxisTopText");o(TGe,"setYAxisBottomText");o(dO,"parseStyles");o(kGe,"addPoint");o(EGe,"addClass");o(SGe,"setWidth");o(CGe,"setHeight");o(AGe,"getQuadrantData");_Ge=o(function(){ba.clear(),Ar()},"clear"),ghe={setWidth:SGe,setHeight:CGe,setQuadrant1Text:mGe,setQuadrant2Text:gGe,setQuadrant3Text:yGe,setQuadrant4Text:vGe,setXAxisLeftText:xGe,setXAxisRightText:bGe,setYAxisTopText:wGe,setYAxisBottomText:TGe,parseStyles:dO,addPoint:kGe,addClass:EGe,getQuadrantData:AGe,clear:_Ge,setAccTitle:Lr,getAccTitle:Rr,setDiagramTitle:$r,getDiagramTitle:Ir,getAccDescription:Mr,setAccDescription:Nr}});var DGe,vhe,xhe=N(()=>{"use strict";dr();zt();vt();Ei();DGe=o((t,e,r,n)=>{function i(S){return S==="top"?"hanging":"middle"}o(i,"getDominantBaseLine");function a(S){return S==="left"?"start":"middle"}o(a,"getTextAnchor");function s(S){return`translate(${S.x}, ${S.y}) rotate(${S.rotation||0})`}o(s,"getTransformation");let l=me();Y.debug(`Rendering quadrant chart +`+t);let u=l.securityLevel,h;u==="sandbox"&&(h=Ge("#i"+e));let d=(u==="sandbox"?Ge(h.nodes()[0].contentDocument.body):Ge("body")).select(`[id="${e}"]`),p=d.append("g").attr("class","main"),m=l.quadrantChart?.chartWidth??500,g=l.quadrantChart?.chartHeight??500;vn(d,g,m,l.quadrantChart?.useMaxWidth??!0),d.attr("viewBox","0 0 "+m+" "+g),n.db.setHeight(g),n.db.setWidth(m);let y=n.db.getQuadrantData(),v=p.append("g").attr("class","quadrants"),x=p.append("g").attr("class","border"),b=p.append("g").attr("class","data-points"),w=p.append("g").attr("class","labels"),C=p.append("g").attr("class","title");y.title&&C.append("text").attr("x",0).attr("y",0).attr("fill",y.title.fill).attr("font-size",y.title.fontSize).attr("dominant-baseline",i(y.title.horizontalPos)).attr("text-anchor",a(y.title.verticalPos)).attr("transform",s(y.title)).text(y.title.text),y.borderLines&&x.selectAll("line").data(y.borderLines).enter().append("line").attr("x1",S=>S.x1).attr("y1",S=>S.y1).attr("x2",S=>S.x2).attr("y2",S=>S.y2).style("stroke",S=>S.strokeFill).style("stroke-width",S=>S.strokeWidth);let T=v.selectAll("g.quadrant").data(y.quadrants).enter().append("g").attr("class","quadrant");T.append("rect").attr("x",S=>S.x).attr("y",S=>S.y).attr("width",S=>S.width).attr("height",S=>S.height).attr("fill",S=>S.fill),T.append("text").attr("x",0).attr("y",0).attr("fill",S=>S.text.fill).attr("font-size",S=>S.text.fontSize).attr("dominant-baseline",S=>i(S.text.horizontalPos)).attr("text-anchor",S=>a(S.text.verticalPos)).attr("transform",S=>s(S.text)).text(S=>S.text.text),w.selectAll("g.label").data(y.axisLabels).enter().append("g").attr("class","label").append("text").attr("x",0).attr("y",0).text(S=>S.text).attr("fill",S=>S.fill).attr("font-size",S=>S.fontSize).attr("dominant-baseline",S=>i(S.horizontalPos)).attr("text-anchor",S=>a(S.verticalPos)).attr("transform",S=>s(S));let A=b.selectAll("g.data-point").data(y.points).enter().append("g").attr("class","data-point");A.append("circle").attr("cx",S=>S.x).attr("cy",S=>S.y).attr("r",S=>S.radius).attr("fill",S=>S.fill).attr("stroke",S=>S.strokeColor).attr("stroke-width",S=>S.strokeWidth),A.append("text").attr("x",0).attr("y",0).text(S=>S.text.text).attr("fill",S=>S.text.fill).attr("font-size",S=>S.text.fontSize).attr("dominant-baseline",S=>i(S.text.horizontalPos)).attr("text-anchor",S=>a(S.text.verticalPos)).attr("transform",S=>s(S.text))},"draw"),vhe={draw:DGe}});var bhe={};hr(bhe,{diagram:()=>LGe});var LGe,whe=N(()=>{"use strict";hhe();yhe();xhe();LGe={parser:uhe,db:ghe,renderer:vhe,styles:o(()=>"","styles")}});var pO,Ehe,She=N(()=>{"use strict";pO=function(){var t=o(function(O,M,B,F){for(B=B||{},F=O.length;F--;B[O[F]]=M);return B},"o"),e=[1,10,12,14,16,18,19,21,23],r=[2,6],n=[1,3],i=[1,5],a=[1,6],s=[1,7],l=[1,5,10,12,14,16,18,19,21,23,34,35,36],u=[1,25],h=[1,26],f=[1,28],d=[1,29],p=[1,30],m=[1,31],g=[1,32],y=[1,33],v=[1,34],x=[1,35],b=[1,36],w=[1,37],C=[1,43],T=[1,42],E=[1,47],A=[1,50],S=[1,10,12,14,16,18,19,21,23,34,35,36],_=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36],I=[1,10,12,14,16,18,19,21,23,24,26,27,28,34,35,36,41,42,43,44,45,46,47,48,49,50],D=[1,64],k={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,eol:4,XYCHART:5,chartConfig:6,document:7,CHART_ORIENTATION:8,statement:9,title:10,text:11,X_AXIS:12,parseXAxis:13,Y_AXIS:14,parseYAxis:15,LINE:16,plotData:17,BAR:18,acc_title:19,acc_title_value:20,acc_descr:21,acc_descr_value:22,acc_descr_multiline_value:23,SQUARE_BRACES_START:24,commaSeparatedNumbers:25,SQUARE_BRACES_END:26,NUMBER_WITH_DECIMAL:27,COMMA:28,xAxisData:29,bandData:30,ARROW_DELIMITER:31,commaSeparatedTexts:32,yAxisData:33,NEWLINE:34,SEMI:35,EOF:36,alphaNum:37,STR:38,MD_STR:39,alphaNumToken:40,AMP:41,NUM:42,ALPHA:43,PLUS:44,EQUALS:45,MULT:46,DOT:47,BRKT:48,MINUS:49,UNDERSCORE:50,$accept:0,$end:1},terminals_:{2:"error",5:"XYCHART",8:"CHART_ORIENTATION",10:"title",12:"X_AXIS",14:"Y_AXIS",16:"LINE",18:"BAR",19:"acc_title",20:"acc_title_value",21:"acc_descr",22:"acc_descr_value",23:"acc_descr_multiline_value",24:"SQUARE_BRACES_START",26:"SQUARE_BRACES_END",27:"NUMBER_WITH_DECIMAL",28:"COMMA",31:"ARROW_DELIMITER",34:"NEWLINE",35:"SEMI",36:"EOF",38:"STR",39:"MD_STR",41:"AMP",42:"NUM",43:"ALPHA",44:"PLUS",45:"EQUALS",46:"MULT",47:"DOT",48:"BRKT",49:"MINUS",50:"UNDERSCORE"},productions_:[0,[3,2],[3,3],[3,2],[3,1],[6,1],[7,0],[7,2],[9,2],[9,2],[9,2],[9,2],[9,2],[9,3],[9,2],[9,3],[9,2],[9,2],[9,1],[17,3],[25,3],[25,1],[13,1],[13,2],[13,1],[29,1],[29,3],[30,3],[32,3],[32,1],[15,1],[15,2],[15,1],[33,3],[4,1],[4,1],[4,1],[11,1],[11,1],[11,1],[37,1],[37,2],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1],[40,1]],performAction:o(function(M,B,F,P,z,$,H){var Q=$.length-1;switch(z){case 5:P.setOrientation($[Q]);break;case 9:P.setDiagramTitle($[Q].text.trim());break;case 12:P.setLineData({text:"",type:"text"},$[Q]);break;case 13:P.setLineData($[Q-1],$[Q]);break;case 14:P.setBarData({text:"",type:"text"},$[Q]);break;case 15:P.setBarData($[Q-1],$[Q]);break;case 16:this.$=$[Q].trim(),P.setAccTitle(this.$);break;case 17:case 18:this.$=$[Q].trim(),P.setAccDescription(this.$);break;case 19:this.$=$[Q-1];break;case 20:this.$=[Number($[Q-2]),...$[Q]];break;case 21:this.$=[Number($[Q])];break;case 22:P.setXAxisTitle($[Q]);break;case 23:P.setXAxisTitle($[Q-1]);break;case 24:P.setXAxisTitle({type:"text",text:""});break;case 25:P.setXAxisBand($[Q]);break;case 26:P.setXAxisRangeData(Number($[Q-2]),Number($[Q]));break;case 27:this.$=$[Q-1];break;case 28:this.$=[$[Q-2],...$[Q]];break;case 29:this.$=[$[Q]];break;case 30:P.setYAxisTitle($[Q]);break;case 31:P.setYAxisTitle($[Q-1]);break;case 32:P.setYAxisTitle({type:"text",text:""});break;case 33:P.setYAxisRangeData(Number($[Q-2]),Number($[Q]));break;case 37:this.$={text:$[Q],type:"text"};break;case 38:this.$={text:$[Q],type:"text"};break;case 39:this.$={text:$[Q],type:"markdown"};break;case 40:this.$=$[Q];break;case 41:this.$=$[Q-1]+""+$[Q];break}},"anonymous"),table:[t(e,r,{3:1,4:2,7:4,5:n,34:i,35:a,36:s}),{1:[3]},t(e,r,{4:2,7:4,3:8,5:n,34:i,35:a,36:s}),t(e,r,{4:2,7:4,6:9,3:10,5:n,8:[1,11],34:i,35:a,36:s}),{1:[2,4],9:12,10:[1,13],12:[1,14],14:[1,15],16:[1,16],18:[1,17],19:[1,18],21:[1,19],23:[1,20]},t(l,[2,34]),t(l,[2,35]),t(l,[2,36]),{1:[2,1]},t(e,r,{4:2,7:4,3:21,5:n,34:i,35:a,36:s}),{1:[2,3]},t(l,[2,5]),t(e,[2,7],{4:22,34:i,35:a,36:s}),{11:23,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w},{11:39,13:38,24:C,27:T,29:40,30:41,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w},{11:45,15:44,27:E,33:46,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w},{11:49,17:48,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w},{11:52,17:51,24:A,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w},{20:[1,53]},{22:[1,54]},t(S,[2,18]),{1:[2,2]},t(S,[2,8]),t(S,[2,9]),t(_,[2,37],{40:55,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w}),t(_,[2,38]),t(_,[2,39]),t(I,[2,40]),t(I,[2,42]),t(I,[2,43]),t(I,[2,44]),t(I,[2,45]),t(I,[2,46]),t(I,[2,47]),t(I,[2,48]),t(I,[2,49]),t(I,[2,50]),t(I,[2,51]),t(S,[2,10]),t(S,[2,22],{30:41,29:56,24:C,27:T}),t(S,[2,24]),t(S,[2,25]),{31:[1,57]},{11:59,32:58,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w},t(S,[2,11]),t(S,[2,30],{33:60,27:E}),t(S,[2,32]),{31:[1,61]},t(S,[2,12]),{17:62,24:A},{25:63,27:D},t(S,[2,14]),{17:65,24:A},t(S,[2,16]),t(S,[2,17]),t(I,[2,41]),t(S,[2,23]),{27:[1,66]},{26:[1,67]},{26:[2,29],28:[1,68]},t(S,[2,31]),{27:[1,69]},t(S,[2,13]),{26:[1,70]},{26:[2,21],28:[1,71]},t(S,[2,15]),t(S,[2,26]),t(S,[2,27]),{11:59,32:72,37:24,38:u,39:h,40:27,41:f,42:d,43:p,44:m,45:g,46:y,47:v,48:x,49:b,50:w},t(S,[2,33]),t(S,[2,19]),{25:73,27:D},{26:[2,28]},{26:[2,20]}],defaultActions:{8:[2,1],10:[2,3],21:[2,2],72:[2,28],73:[2,20]},parseError:o(function(M,B){if(B.recoverable)this.trace(M);else{var F=new Error(M);throw F.hash=B,F}},"parseError"),parse:o(function(M){var B=this,F=[0],P=[],z=[null],$=[],H=this.table,Q="",j=0,ie=0,ne=0,le=2,he=1,K=$.slice.call(arguments,1),X=Object.create(this.lexer),te={yy:{}};for(var J in this.yy)Object.prototype.hasOwnProperty.call(this.yy,J)&&(te.yy[J]=this.yy[J]);X.setInput(M,te.yy),te.yy.lexer=X,te.yy.parser=this,typeof X.yylloc>"u"&&(X.yylloc={});var se=X.yylloc;$.push(se);var ue=X.options&&X.options.ranges;typeof te.yy.parseError=="function"?this.parseError=te.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Z(re){F.length=F.length-2*re,z.length=z.length-re,$.length=$.length-re}o(Z,"popStack");function Se(){var re;return re=P.pop()||X.lex()||he,typeof re!="number"&&(re instanceof Array&&(P=re,re=P.pop()),re=B.symbols_[re]||re),re}o(Se,"lex");for(var ce,ae,Oe,ge,ze,He,$e={},Re,Ie,be,W;;){if(Oe=F[F.length-1],this.defaultActions[Oe]?ge=this.defaultActions[Oe]:((ce===null||typeof ce>"u")&&(ce=Se()),ge=H[Oe]&&H[Oe][ce]),typeof ge>"u"||!ge.length||!ge[0]){var de="";W=[];for(Re in H[Oe])this.terminals_[Re]&&Re>le&&W.push("'"+this.terminals_[Re]+"'");X.showPosition?de="Parse error on line "+(j+1)+`: +`+X.showPosition()+` +Expecting `+W.join(", ")+", got '"+(this.terminals_[ce]||ce)+"'":de="Parse error on line "+(j+1)+": Unexpected "+(ce==he?"end of input":"'"+(this.terminals_[ce]||ce)+"'"),this.parseError(de,{text:X.match,token:this.terminals_[ce]||ce,line:X.yylineno,loc:se,expected:W})}if(ge[0]instanceof Array&&ge.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Oe+", token: "+ce);switch(ge[0]){case 1:F.push(ce),z.push(X.yytext),$.push(X.yylloc),F.push(ge[1]),ce=null,ae?(ce=ae,ae=null):(ie=X.yyleng,Q=X.yytext,j=X.yylineno,se=X.yylloc,ne>0&&ne--);break;case 2:if(Ie=this.productions_[ge[1]][1],$e.$=z[z.length-Ie],$e._$={first_line:$[$.length-(Ie||1)].first_line,last_line:$[$.length-1].last_line,first_column:$[$.length-(Ie||1)].first_column,last_column:$[$.length-1].last_column},ue&&($e._$.range=[$[$.length-(Ie||1)].range[0],$[$.length-1].range[1]]),He=this.performAction.apply($e,[Q,ie,j,te.yy,ge[1],z,$].concat(K)),typeof He<"u")return He;Ie&&(F=F.slice(0,-1*Ie*2),z=z.slice(0,-1*Ie),$=$.slice(0,-1*Ie)),F.push(this.productions_[ge[1]][0]),z.push($e.$),$.push($e._$),be=H[F[F.length-2]][F[F.length-1]],F.push(be);break;case 3:return!0}}return!0},"parse")},L=function(){var O={EOF:1,parseError:o(function(B,F){if(this.yy.parser)this.yy.parser.parseError(B,F);else throw new Error(B)},"parseError"),setInput:o(function(M,B){return this.yy=B||this.yy||{},this._input=M,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var M=this._input[0];this.yytext+=M,this.yyleng++,this.offset++,this.match+=M,this.matched+=M;var B=M.match(/(?:\r\n?|\n).*/g);return B?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),M},"input"),unput:o(function(M){var B=M.length,F=M.split(/(?:\r\n?|\n)/g);this._input=M+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-B),this.offset-=B;var P=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),F.length-1&&(this.yylineno-=F.length-1);var z=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:F?(F.length===P.length?this.yylloc.first_column:0)+P[P.length-F.length].length-F[0].length:this.yylloc.first_column-B},this.options.ranges&&(this.yylloc.range=[z[0],z[0]+this.yyleng-B]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(M){this.unput(this.match.slice(M))},"less"),pastInput:o(function(){var M=this.matched.substr(0,this.matched.length-this.match.length);return(M.length>20?"...":"")+M.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var M=this.match;return M.length<20&&(M+=this._input.substr(0,20-M.length)),(M.substr(0,20)+(M.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var M=this.pastInput(),B=new Array(M.length+1).join("-");return M+this.upcomingInput()+` +`+B+"^"},"showPosition"),test_match:o(function(M,B){var F,P,z;if(this.options.backtrack_lexer&&(z={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(z.yylloc.range=this.yylloc.range.slice(0))),P=M[0].match(/(?:\r\n?|\n).*/g),P&&(this.yylineno+=P.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:P?P[P.length-1].length-P[P.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+M[0].length},this.yytext+=M[0],this.match+=M[0],this.matches=M,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(M[0].length),this.matched+=M[0],F=this.performAction.call(this,this.yy,this,B,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),F)return F;if(this._backtrack){for(var $ in z)this[$]=z[$];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var M,B,F,P;this._more||(this.yytext="",this.match="");for(var z=this._currentRules(),$=0;$B[0].length)){if(B=F,P=$,this.options.backtrack_lexer){if(M=this.test_match(F,z[$]),M!==!1)return M;if(this._backtrack){B=!1;continue}else return!1}else if(!this.options.flex)break}return B?(M=this.test_match(B,z[P]),M!==!1?M:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var B=this.next();return B||this.lex()},"lex"),begin:o(function(B){this.conditionStack.push(B)},"begin"),popState:o(function(){var B=this.conditionStack.length-1;return B>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(B){return B=this.conditionStack.length-1-Math.abs(B||0),B>=0?this.conditionStack[B]:"INITIAL"},"topState"),pushState:o(function(B){this.begin(B)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(B,F,P,z){var $=z;switch(P){case 0:break;case 1:break;case 2:return this.popState(),34;break;case 3:return this.popState(),34;break;case 4:return 34;case 5:break;case 6:return 10;case 7:return this.pushState("acc_title"),19;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.pushState("acc_descr"),21;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.pushState("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 5;case 15:return 8;case 16:return this.pushState("axis_data"),"X_AXIS";break;case 17:return this.pushState("axis_data"),"Y_AXIS";break;case 18:return this.pushState("axis_band_data"),24;break;case 19:return 31;case 20:return this.pushState("data"),16;break;case 21:return this.pushState("data"),18;break;case 22:return this.pushState("data_inner"),24;break;case 23:return 27;case 24:return this.popState(),26;break;case 25:this.popState();break;case 26:this.pushState("string");break;case 27:this.popState();break;case 28:return"STR";case 29:return 24;case 30:return 26;case 31:return 43;case 32:return"COLON";case 33:return 44;case 34:return 28;case 35:return 45;case 36:return 46;case 37:return 48;case 38:return 50;case 39:return 47;case 40:return 41;case 41:return 49;case 42:return 42;case 43:break;case 44:return 35;case 45:return 36}},"anonymous"),rules:[/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:(\r?\n))/i,/^(?:(\r?\n))/i,/^(?:[\n\r]+)/i,/^(?:%%[^\n]*)/i,/^(?:title\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:\{)/i,/^(?:[^\}]*)/i,/^(?:xychart-beta\b)/i,/^(?:(?:vertical|horizontal))/i,/^(?:x-axis\b)/i,/^(?:y-axis\b)/i,/^(?:\[)/i,/^(?:-->)/i,/^(?:line\b)/i,/^(?:bar\b)/i,/^(?:\[)/i,/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+))/i,/^(?:\])/i,/^(?:(?:`\) \{ this\.pushState\(md_string\); \}\n\(\?:\(\?!`"\)\.\)\+ \{ return MD_STR; \}\n\(\?:`))/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:\[)/i,/^(?:\])/i,/^(?:[A-Za-z]+)/i,/^(?::)/i,/^(?:\+)/i,/^(?:,)/i,/^(?:=)/i,/^(?:\*)/i,/^(?:#)/i,/^(?:[\_])/i,/^(?:\.)/i,/^(?:&)/i,/^(?:-)/i,/^(?:[0-9]+)/i,/^(?:\s+)/i,/^(?:;)/i,/^(?:$)/i],conditions:{data_inner:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,20,21,23,24,25,26,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45],inclusive:!0},data:{rules:[0,1,3,4,5,6,7,9,11,14,15,16,17,20,21,22,25,26,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45],inclusive:!0},axis_band_data:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,20,21,24,25,26,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45],inclusive:!0},axis_data:{rules:[0,1,2,4,5,6,7,9,11,14,15,16,17,18,19,20,21,23,25,26,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45],inclusive:!0},acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},title:{rules:[],inclusive:!1},md_string:{rules:[],inclusive:!1},string:{rules:[27,28],inclusive:!1},INITIAL:{rules:[0,1,4,5,6,7,9,11,14,15,16,17,20,21,25,26,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45],inclusive:!0}}};return O}();k.lexer=L;function R(){this.yy={}}return o(R,"Parser"),R.prototype=k,k.Parser=R,new R}();pO.parser=pO;Ehe=pO});function mO(t){return t.type==="bar"}function v6(t){return t.type==="band"}function S1(t){return t.type==="linear"}var x6=N(()=>{"use strict";o(mO,"isBarPlot");o(v6,"isBandAxisData");o(S1,"isLinearAxisData")});var C1,gO=N(()=>{"use strict";to();C1=class{constructor(e){this.parentGroup=e}static{o(this,"TextDimensionCalculatorWithFont")}getMaxDimension(e,r){if(!this.parentGroup)return{width:e.reduce((a,s)=>Math.max(s.length,a),0)*r,height:r};let n={width:0,height:0},i=this.parentGroup.append("g").attr("visibility","hidden").attr("font-size",r);for(let a of e){let s=sK(i,1,a),l=s?s.width:a.length*r,u=s?s.height:r;n.width=Math.max(n.width,l),n.height=Math.max(n.height,u)}return i.remove(),n}}});var A1,yO=N(()=>{"use strict";A1=class{constructor(e,r,n,i){this.axisConfig=e;this.title=r;this.textDimensionCalculator=n;this.axisThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0};this.axisPosition="left";this.showTitle=!1;this.showLabel=!1;this.showTick=!1;this.showAxisLine=!1;this.outerPadding=0;this.titleTextHeight=0;this.labelTextHeight=0;this.range=[0,10],this.boundingRect={x:0,y:0,width:0,height:0},this.axisPosition="left"}static{o(this,"BaseAxis")}setRange(e){this.range=e,this.axisPosition==="left"||this.axisPosition==="right"?this.boundingRect.height=e[1]-e[0]:this.boundingRect.width=e[1]-e[0],this.recalculateScale()}getRange(){return[this.range[0]+this.outerPadding,this.range[1]-this.outerPadding]}setAxisPosition(e){this.axisPosition=e,this.setRange(this.range)}getTickDistance(){let e=this.getRange();return Math.abs(e[0]-e[1])/this.getTickValues().length}getAxisOuterPadding(){return this.outerPadding}getLabelDimension(){return this.textDimensionCalculator.getMaxDimension(this.getTickValues().map(e=>e.toString()),this.axisConfig.labelFontSize)}recalculateOuterPaddingToDrawBar(){.7*this.getTickDistance()>this.outerPadding*2&&(this.outerPadding=Math.floor(.7*this.getTickDistance()/2)),this.recalculateScale()}calculateSpaceIfDrawnHorizontally(e){let r=e.height;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.width;this.outerPadding=Math.min(n.width/2,i);let a=n.height+this.axisConfig.labelPadding*2;this.labelTextHeight=n.height,a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width,this.boundingRect.height=e.height-r}calculateSpaceIfDrawnVertical(e){let r=e.width;if(this.axisConfig.showAxisLine&&r>this.axisConfig.axisLineWidth&&(r-=this.axisConfig.axisLineWidth,this.showAxisLine=!0),this.axisConfig.showLabel){let n=this.getLabelDimension(),i=.2*e.height;this.outerPadding=Math.min(n.height/2,i);let a=n.width+this.axisConfig.labelPadding*2;a<=r&&(r-=a,this.showLabel=!0)}if(this.axisConfig.showTick&&r>=this.axisConfig.tickLength&&(this.showTick=!0,r-=this.axisConfig.tickLength),this.axisConfig.showTitle&&this.title){let n=this.textDimensionCalculator.getMaxDimension([this.title],this.axisConfig.titleFontSize),i=n.height+this.axisConfig.titlePadding*2;this.titleTextHeight=n.height,i<=r&&(r-=i,this.showTitle=!0)}this.boundingRect.width=e.width-r,this.boundingRect.height=e.height}calculateSpace(e){return this.axisPosition==="left"||this.axisPosition==="right"?this.calculateSpaceIfDrawnVertical(e):this.calculateSpaceIfDrawnHorizontally(e),this.recalculateScale(),{width:this.boundingRect.width,height:this.boundingRect.height}}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}getDrawableElementsForLeftAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.x+this.boundingRect.width-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["left-axis","axisl-line"],data:[{path:`M ${r},${this.boundingRect.y} L ${r},${this.boundingRect.y+this.boundingRect.height} `,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["left-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.boundingRect.x+this.boundingRect.width-(this.showLabel?this.axisConfig.labelPadding:0)-(this.showTick?this.axisConfig.tickLength:0)-(this.showAxisLine?this.axisConfig.axisLineWidth:0),y:this.getScaleValue(r),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"middle",horizontalPos:"right"}))}),this.showTick){let r=this.boundingRect.x+this.boundingRect.width-(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["left-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${r},${this.getScaleValue(n)} L ${r-this.axisConfig.tickLength},${this.getScaleValue(n)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["left-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.axisConfig.titlePadding,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:270,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForBottomAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["bottom-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["bottom-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+this.axisConfig.labelPadding+(this.showTick?this.axisConfig.tickLength:0)+(this.showAxisLine?this.axisConfig.axisLineWidth:0),fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y+(this.showAxisLine?this.axisConfig.axisLineWidth:0);e.push({type:"path",groupTexts:["bottom-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r} L ${this.getScaleValue(n)},${r+this.axisConfig.tickLength}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["bottom-axis","title"],data:[{text:this.title,x:this.range[0]+(this.range[1]-this.range[0])/2,y:this.boundingRect.y+this.boundingRect.height-this.axisConfig.titlePadding-this.titleTextHeight,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElementsForTopAxis(){let e=[];if(this.showAxisLine){let r=this.boundingRect.y+this.boundingRect.height-this.axisConfig.axisLineWidth/2;e.push({type:"path",groupTexts:["top-axis","axis-line"],data:[{path:`M ${this.boundingRect.x},${r} L ${this.boundingRect.x+this.boundingRect.width},${r}`,strokeFill:this.axisThemeConfig.axisLineColor,strokeWidth:this.axisConfig.axisLineWidth}]})}if(this.showLabel&&e.push({type:"text",groupTexts:["top-axis","label"],data:this.getTickValues().map(r=>({text:r.toString(),x:this.getScaleValue(r),y:this.boundingRect.y+(this.showTitle?this.titleTextHeight+this.axisConfig.titlePadding*2:0)+this.axisConfig.labelPadding,fill:this.axisThemeConfig.labelColor,fontSize:this.axisConfig.labelFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}))}),this.showTick){let r=this.boundingRect.y;e.push({type:"path",groupTexts:["top-axis","ticks"],data:this.getTickValues().map(n=>({path:`M ${this.getScaleValue(n)},${r+this.boundingRect.height-(this.showAxisLine?this.axisConfig.axisLineWidth:0)} L ${this.getScaleValue(n)},${r+this.boundingRect.height-this.axisConfig.tickLength-(this.showAxisLine?this.axisConfig.axisLineWidth:0)}`,strokeFill:this.axisThemeConfig.tickColor,strokeWidth:this.axisConfig.tickWidth}))})}return this.showTitle&&e.push({type:"text",groupTexts:["top-axis","title"],data:[{text:this.title,x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.axisConfig.titlePadding,fill:this.axisThemeConfig.titleColor,fontSize:this.axisConfig.titleFontSize,rotation:0,verticalPos:"top",horizontalPos:"center"}]}),e}getDrawableElements(){if(this.axisPosition==="left")return this.getDrawableElementsForLeftAxis();if(this.axisPosition==="right")throw Error("Drawing of right axis is not implemented");return this.axisPosition==="bottom"?this.getDrawableElementsForBottomAxis():this.axisPosition==="top"?this.getDrawableElementsForTopAxis():[]}}});var b6,Che=N(()=>{"use strict";dr();vt();yO();b6=class extends A1{static{o(this,"BandAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.categories=n,this.scale=L0().domain(this.categories).range(this.getRange())}setRange(e){super.setRange(e)}recalculateScale(){this.scale=L0().domain(this.categories).range(this.getRange()).paddingInner(1).paddingOuter(0).align(.5),Y.trace("BandAxis axis final categories, range: ",this.categories,this.getRange())}getTickValues(){return this.categories}getScaleValue(e){return this.scale(e)??this.getRange()[0]}}});var w6,Ahe=N(()=>{"use strict";dr();yO();w6=class extends A1{static{o(this,"LinearAxis")}constructor(e,r,n,i,a){super(e,i,a,r),this.domain=n,this.scale=gl().domain(this.domain).range(this.getRange())}getTickValues(){return this.scale.ticks()}recalculateScale(){let e=[...this.domain];this.axisPosition==="left"&&e.reverse(),this.scale=gl().domain(e).range(this.getRange())}getScaleValue(e){return this.scale(e)}}});function vO(t,e,r,n){let i=new C1(n);return v6(t)?new b6(e,r,t.categories,t.title,i):new w6(e,r,[t.min,t.max],t.title,i)}var _he=N(()=>{"use strict";x6();gO();Che();Ahe();o(vO,"getAxis")});function Dhe(t,e,r,n){let i=new C1(n);return new xO(i,t,e,r)}var xO,Lhe=N(()=>{"use strict";gO();xO=class{constructor(e,r,n,i){this.textDimensionCalculator=e;this.chartConfig=r;this.chartData=n;this.chartThemeConfig=i;this.boundingRect={x:0,y:0,width:0,height:0},this.showChartTitle=!1}static{o(this,"ChartTitle")}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){let r=this.textDimensionCalculator.getMaxDimension([this.chartData.title],this.chartConfig.titleFontSize),n=Math.max(r.width,e.width),i=r.height+2*this.chartConfig.titlePadding;return r.width<=n&&r.height<=i&&this.chartConfig.showTitle&&this.chartData.title&&(this.boundingRect.width=n,this.boundingRect.height=i,this.showChartTitle=!0),{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){let e=[];return this.showChartTitle&&e.push({groupTexts:["chart-title"],type:"text",data:[{fontSize:this.chartConfig.titleFontSize,text:this.chartData.title,verticalPos:"middle",horizontalPos:"center",x:this.boundingRect.x+this.boundingRect.width/2,y:this.boundingRect.y+this.boundingRect.height/2,fill:this.chartThemeConfig.titleColor,rotation:0}]}),e}};o(Dhe,"getChartTitleComponent")});var T6,Rhe=N(()=>{"use strict";dr();T6=class{constructor(e,r,n,i,a){this.plotData=e;this.xAxis=r;this.yAxis=n;this.orientation=i;this.plotIndex=a}static{o(this,"LinePlot")}getDrawableElement(){let e=this.plotData.data.map(n=>[this.xAxis.getScaleValue(n[0]),this.yAxis.getScaleValue(n[1])]),r;return this.orientation==="horizontal"?r=wl().y(n=>n[0]).x(n=>n[1])(e):r=wl().x(n=>n[0]).y(n=>n[1])(e),r?[{groupTexts:["plot",`line-plot-${this.plotIndex}`],type:"path",data:[{path:r,strokeFill:this.plotData.strokeFill,strokeWidth:this.plotData.strokeWidth}]}]:[]}}});var k6,Nhe=N(()=>{"use strict";k6=class{constructor(e,r,n,i,a,s){this.barData=e;this.boundingRect=r;this.xAxis=n;this.yAxis=i;this.orientation=a;this.plotIndex=s}static{o(this,"BarPlot")}getDrawableElement(){let e=this.barData.data.map(a=>[this.xAxis.getScaleValue(a[0]),this.yAxis.getScaleValue(a[1])]),n=Math.min(this.xAxis.getAxisOuterPadding()*2,this.xAxis.getTickDistance())*(1-.05),i=n/2;return this.orientation==="horizontal"?[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:this.boundingRect.x,y:a[0]-i,height:n,width:a[1]-this.boundingRect.x,fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]:[{groupTexts:["plot",`bar-plot-${this.plotIndex}`],type:"rect",data:e.map(a=>({x:a[0]-i,y:a[1],width:n,height:this.boundingRect.y+this.boundingRect.height-a[1],fill:this.barData.fill,strokeWidth:0,strokeFill:this.barData.fill}))}]}}});function Mhe(t,e,r){return new bO(t,e,r)}var bO,Ihe=N(()=>{"use strict";Rhe();Nhe();bO=class{constructor(e,r,n){this.chartConfig=e;this.chartData=r;this.chartThemeConfig=n;this.boundingRect={x:0,y:0,width:0,height:0}}static{o(this,"BasePlot")}setAxes(e,r){this.xAxis=e,this.yAxis=r}setBoundingBoxXY(e){this.boundingRect.x=e.x,this.boundingRect.y=e.y}calculateSpace(e){return this.boundingRect.width=e.width,this.boundingRect.height=e.height,{width:this.boundingRect.width,height:this.boundingRect.height}}getDrawableElements(){if(!(this.xAxis&&this.yAxis))throw Error("Axes must be passed to render Plots");let e=[];for(let[r,n]of this.chartData.plots.entries())switch(n.type){case"line":{let i=new T6(n,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break;case"bar":{let i=new k6(n,this.boundingRect,this.xAxis,this.yAxis,this.chartConfig.chartOrientation,r);e.push(...i.getDrawableElement())}break}return e}};o(Mhe,"getPlotComponent")});var E6,Ohe=N(()=>{"use strict";_he();Lhe();Ihe();x6();E6=class{constructor(e,r,n,i){this.chartConfig=e;this.chartData=r;this.componentStore={title:Dhe(e,r,n,i),plot:Mhe(e,r,n),xAxis:vO(r.xAxis,e.xAxis,{titleColor:n.xAxisTitleColor,labelColor:n.xAxisLabelColor,tickColor:n.xAxisTickColor,axisLineColor:n.xAxisLineColor},i),yAxis:vO(r.yAxis,e.yAxis,{titleColor:n.yAxisTitleColor,labelColor:n.yAxisLabelColor,tickColor:n.yAxisTickColor,axisLineColor:n.yAxisLineColor},i)}}static{o(this,"Orchestrator")}calculateVerticalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),s=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),l=this.componentStore.plot.calculateSpace({width:a,height:s});e-=l.width,r-=l.height,l=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),i=l.height,r-=l.height,this.componentStore.xAxis.setAxisPosition("bottom"),l=this.componentStore.xAxis.calculateSpace({width:e,height:r}),r-=l.height,this.componentStore.yAxis.setAxisPosition("left"),l=this.componentStore.yAxis.calculateSpace({width:e,height:r}),n=l.width,e-=l.width,e>0&&(a+=e,e=0),r>0&&(s+=r,r=0),this.componentStore.plot.calculateSpace({width:a,height:s}),this.componentStore.plot.setBoundingBoxXY({x:n,y:i}),this.componentStore.xAxis.setRange([n,n+a]),this.componentStore.xAxis.setBoundingBoxXY({x:n,y:i+s}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:0,y:i}),this.chartData.plots.some(u=>mO(u))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateHorizontalSpace(){let e=this.chartConfig.width,r=this.chartConfig.height,n=0,i=0,a=0,s=Math.floor(e*this.chartConfig.plotReservedSpacePercent/100),l=Math.floor(r*this.chartConfig.plotReservedSpacePercent/100),u=this.componentStore.plot.calculateSpace({width:s,height:l});e-=u.width,r-=u.height,u=this.componentStore.title.calculateSpace({width:this.chartConfig.width,height:r}),n=u.height,r-=u.height,this.componentStore.xAxis.setAxisPosition("left"),u=this.componentStore.xAxis.calculateSpace({width:e,height:r}),e-=u.width,i=u.width,this.componentStore.yAxis.setAxisPosition("top"),u=this.componentStore.yAxis.calculateSpace({width:e,height:r}),r-=u.height,a=n+u.height,e>0&&(s+=e,e=0),r>0&&(l+=r,r=0),this.componentStore.plot.calculateSpace({width:s,height:l}),this.componentStore.plot.setBoundingBoxXY({x:i,y:a}),this.componentStore.yAxis.setRange([i,i+s]),this.componentStore.yAxis.setBoundingBoxXY({x:i,y:n}),this.componentStore.xAxis.setRange([a,a+l]),this.componentStore.xAxis.setBoundingBoxXY({x:0,y:a}),this.chartData.plots.some(h=>mO(h))&&this.componentStore.xAxis.recalculateOuterPaddingToDrawBar()}calculateSpace(){this.chartConfig.chartOrientation==="horizontal"?this.calculateHorizontalSpace():this.calculateVerticalSpace()}getDrawableElement(){this.calculateSpace();let e=[];this.componentStore.plot.setAxes(this.componentStore.xAxis,this.componentStore.yAxis);for(let r of Object.values(this.componentStore))e.push(...r.getDrawableElements());return e}}});var S6,Phe=N(()=>{"use strict";Ohe();S6=class{static{o(this,"XYChartBuilder")}static build(e,r,n,i){return new E6(e,r,n,i).getDrawableElement()}}});function Fhe(){let t=oh(),e=cr();return Fi(t.xyChart,e.themeVariables.xyChart)}function $he(){let t=cr();return Fi(or.xyChart,t.xyChart)}function zhe(){return{yAxis:{type:"linear",title:"",min:1/0,max:-1/0},xAxis:{type:"band",title:"",categories:[]},title:"",plots:[]}}function kO(t){let e=cr();return Tr(t.trim(),e)}function IGe(t){Bhe=t}function OGe(t){t==="horizontal"?bb.chartOrientation="horizontal":bb.chartOrientation="vertical"}function PGe(t){fn.xAxis.title=kO(t.text)}function Ghe(t,e){fn.xAxis={type:"linear",title:fn.xAxis.title,min:t,max:e},C6=!0}function BGe(t){fn.xAxis={type:"band",title:fn.xAxis.title,categories:t.map(e=>kO(e.text))},C6=!0}function FGe(t){fn.yAxis.title=kO(t.text)}function $Ge(t,e){fn.yAxis={type:"linear",title:fn.yAxis.title,min:t,max:e},TO=!0}function zGe(t){let e=Math.min(...t),r=Math.max(...t),n=S1(fn.yAxis)?fn.yAxis.min:1/0,i=S1(fn.yAxis)?fn.yAxis.max:-1/0;fn.yAxis={type:"linear",title:fn.yAxis.title,min:Math.min(n,e),max:Math.max(i,r)}}function Vhe(t){let e=[];if(t.length===0)return e;if(!C6){let r=S1(fn.xAxis)?fn.xAxis.min:1/0,n=S1(fn.xAxis)?fn.xAxis.max:-1/0;Ghe(Math.min(r,1),Math.max(n,t.length))}if(TO||zGe(t),v6(fn.xAxis)&&(e=fn.xAxis.categories.map((r,n)=>[r,t[n]])),S1(fn.xAxis)){let r=fn.xAxis.min,n=fn.xAxis.max,i=(n-r)/(t.length-1),a=[];for(let s=r;s<=n;s+=i)a.push(`${s}`);e=a.map((s,l)=>[s,t[l]])}return e}function Uhe(t){return wO[t===0?0:t%wO.length]}function GGe(t,e){let r=Vhe(e);fn.plots.push({type:"line",strokeFill:Uhe(xb),strokeWidth:2,data:r}),xb++}function VGe(t,e){let r=Vhe(e);fn.plots.push({type:"bar",fill:Uhe(xb),data:r}),xb++}function UGe(){if(fn.plots.length===0)throw Error("No Plot to render, please provide a plot with some data");return fn.title=Ir(),S6.build(bb,fn,wb,Bhe)}function HGe(){return wb}function WGe(){return bb}var xb,Bhe,bb,wb,fn,wO,C6,TO,qGe,Hhe,Whe=N(()=>{"use strict";ji();Ya();_y();ir();gr();mi();Phe();x6();xb=0,bb=$he(),wb=Fhe(),fn=zhe(),wO=wb.plotColorPalette.split(",").map(t=>t.trim()),C6=!1,TO=!1;o(Fhe,"getChartDefaultThemeConfig");o($he,"getChartDefaultConfig");o(zhe,"getChartDefaultData");o(kO,"textSanitizer");o(IGe,"setTmpSVGG");o(OGe,"setOrientation");o(PGe,"setXAxisTitle");o(Ghe,"setXAxisRangeData");o(BGe,"setXAxisBand");o(FGe,"setYAxisTitle");o($Ge,"setYAxisRangeData");o(zGe,"setYAxisRangeFromPlotData");o(Vhe,"transformDataWithoutCategory");o(Uhe,"getPlotColorFromPalette");o(GGe,"setLineData");o(VGe,"setBarData");o(UGe,"getDrawableElem");o(HGe,"getChartThemeConfig");o(WGe,"getChartConfig");qGe=o(function(){Ar(),xb=0,bb=$he(),fn=zhe(),wb=Fhe(),wO=wb.plotColorPalette.split(",").map(t=>t.trim()),C6=!1,TO=!1},"clear"),Hhe={getDrawableElem:UGe,clear:qGe,setAccTitle:Lr,getAccTitle:Rr,setDiagramTitle:$r,getDiagramTitle:Ir,getAccDescription:Mr,setAccDescription:Nr,setOrientation:OGe,setXAxisTitle:PGe,setXAxisRangeData:Ghe,setXAxisBand:BGe,setYAxisTitle:FGe,setYAxisRangeData:$Ge,setLineData:GGe,setBarData:VGe,setTmpSVGG:IGe,getChartThemeConfig:HGe,getChartConfig:WGe}});var YGe,qhe,Yhe=N(()=>{"use strict";vt();Vc();Ei();YGe=o((t,e,r,n)=>{let i=n.db,a=i.getChartThemeConfig(),s=i.getChartConfig();function l(v){return v==="top"?"text-before-edge":"middle"}o(l,"getDominantBaseLine");function u(v){return v==="left"?"start":v==="right"?"end":"middle"}o(u,"getTextAnchor");function h(v){return`translate(${v.x}, ${v.y}) rotate(${v.rotation||0})`}o(h,"getTextTransformation"),Y.debug(`Rendering xychart chart +`+t);let f=sa(e),d=f.append("g").attr("class","main"),p=d.append("rect").attr("width",s.width).attr("height",s.height).attr("class","background");vn(f,s.height,s.width,!0),f.attr("viewBox",`0 0 ${s.width} ${s.height}`),p.attr("fill",a.backgroundColor),i.setTmpSVGG(f.append("g").attr("class","mermaid-tmp-group"));let m=i.getDrawableElem(),g={};function y(v){let x=d,b="";for(let[w]of v.entries()){let C=d;w>0&&g[b]&&(C=g[b]),b+=v[w],x=g[b],x||(x=g[b]=C.append("g").attr("class",v[w]))}return x}o(y,"getGroup");for(let v of m){if(v.data.length===0)continue;let x=y(v.groupTexts);switch(v.type){case"rect":x.selectAll("rect").data(v.data).enter().append("rect").attr("x",b=>b.x).attr("y",b=>b.y).attr("width",b=>b.width).attr("height",b=>b.height).attr("fill",b=>b.fill).attr("stroke",b=>b.strokeFill).attr("stroke-width",b=>b.strokeWidth);break;case"text":x.selectAll("text").data(v.data).enter().append("text").attr("x",0).attr("y",0).attr("fill",b=>b.fill).attr("font-size",b=>b.fontSize).attr("dominant-baseline",b=>l(b.verticalPos)).attr("text-anchor",b=>u(b.horizontalPos)).attr("transform",b=>h(b)).text(b=>b.text);break;case"path":x.selectAll("path").data(v.data).enter().append("path").attr("d",b=>b.path).attr("fill",b=>b.fill?b.fill:"none").attr("stroke",b=>b.strokeFill).attr("stroke-width",b=>b.strokeWidth);break}}},"draw"),qhe={draw:YGe}});var Xhe={};hr(Xhe,{diagram:()=>XGe});var XGe,jhe=N(()=>{"use strict";She();Whe();Yhe();XGe={parser:Ehe,db:Hhe,renderer:qhe}});var EO,Zhe,Jhe=N(()=>{"use strict";EO=function(){var t=o(function(re,oe,V,xe){for(V=V||{},xe=re.length;xe--;V[re[xe]]=oe);return V},"o"),e=[1,3],r=[1,4],n=[1,5],i=[1,6],a=[5,6,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],s=[1,22],l=[2,7],u=[1,26],h=[1,27],f=[1,28],d=[1,29],p=[1,33],m=[1,34],g=[1,35],y=[1,36],v=[1,37],x=[1,38],b=[1,24],w=[1,31],C=[1,32],T=[1,30],E=[1,39],A=[1,40],S=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],_=[1,61],I=[89,90],D=[5,8,9,11,13,21,22,23,24,27,29,41,42,43,44,45,46,54,61,63,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],k=[27,29],L=[1,70],R=[1,71],O=[1,72],M=[1,73],B=[1,74],F=[1,75],P=[1,76],z=[1,83],$=[1,80],H=[1,84],Q=[1,85],j=[1,86],ie=[1,87],ne=[1,88],le=[1,89],he=[1,90],K=[1,91],X=[1,92],te=[5,8,9,11,13,21,22,23,24,27,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],J=[63,64],se=[1,101],ue=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,76,77,89,90],Z=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],Se=[1,110],ce=[1,106],ae=[1,107],Oe=[1,108],ge=[1,109],ze=[1,111],He=[1,116],$e=[1,117],Re=[1,114],Ie=[1,115],be={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,directive:4,NEWLINE:5,RD:6,diagram:7,EOF:8,acc_title:9,acc_title_value:10,acc_descr:11,acc_descr_value:12,acc_descr_multiline_value:13,requirementDef:14,elementDef:15,relationshipDef:16,direction:17,styleStatement:18,classDefStatement:19,classStatement:20,direction_tb:21,direction_bt:22,direction_rl:23,direction_lr:24,requirementType:25,requirementName:26,STRUCT_START:27,requirementBody:28,STYLE_SEPARATOR:29,idList:30,ID:31,COLONSEP:32,id:33,TEXT:34,text:35,RISK:36,riskLevel:37,VERIFYMTHD:38,verifyType:39,STRUCT_STOP:40,REQUIREMENT:41,FUNCTIONAL_REQUIREMENT:42,INTERFACE_REQUIREMENT:43,PERFORMANCE_REQUIREMENT:44,PHYSICAL_REQUIREMENT:45,DESIGN_CONSTRAINT:46,LOW_RISK:47,MED_RISK:48,HIGH_RISK:49,VERIFY_ANALYSIS:50,VERIFY_DEMONSTRATION:51,VERIFY_INSPECTION:52,VERIFY_TEST:53,ELEMENT:54,elementName:55,elementBody:56,TYPE:57,type:58,DOCREF:59,ref:60,END_ARROW_L:61,relationship:62,LINE:63,END_ARROW_R:64,CONTAINS:65,COPIES:66,DERIVES:67,SATISFIES:68,VERIFIES:69,REFINES:70,TRACES:71,CLASSDEF:72,stylesOpt:73,CLASS:74,ALPHA:75,COMMA:76,STYLE:77,style:78,styleComponent:79,NUM:80,COLON:81,UNIT:82,SPACE:83,BRKT:84,PCT:85,MINUS:86,LABEL:87,SEMICOLON:88,unqString:89,qString:90,$accept:0,$end:1},terminals_:{2:"error",5:"NEWLINE",6:"RD",8:"EOF",9:"acc_title",10:"acc_title_value",11:"acc_descr",12:"acc_descr_value",13:"acc_descr_multiline_value",21:"direction_tb",22:"direction_bt",23:"direction_rl",24:"direction_lr",27:"STRUCT_START",29:"STYLE_SEPARATOR",31:"ID",32:"COLONSEP",34:"TEXT",36:"RISK",38:"VERIFYMTHD",40:"STRUCT_STOP",41:"REQUIREMENT",42:"FUNCTIONAL_REQUIREMENT",43:"INTERFACE_REQUIREMENT",44:"PERFORMANCE_REQUIREMENT",45:"PHYSICAL_REQUIREMENT",46:"DESIGN_CONSTRAINT",47:"LOW_RISK",48:"MED_RISK",49:"HIGH_RISK",50:"VERIFY_ANALYSIS",51:"VERIFY_DEMONSTRATION",52:"VERIFY_INSPECTION",53:"VERIFY_TEST",54:"ELEMENT",57:"TYPE",59:"DOCREF",61:"END_ARROW_L",63:"LINE",64:"END_ARROW_R",65:"CONTAINS",66:"COPIES",67:"DERIVES",68:"SATISFIES",69:"VERIFIES",70:"REFINES",71:"TRACES",72:"CLASSDEF",74:"CLASS",75:"ALPHA",76:"COMMA",77:"STYLE",80:"NUM",81:"COLON",82:"UNIT",83:"SPACE",84:"BRKT",85:"PCT",86:"MINUS",87:"LABEL",88:"SEMICOLON",89:"unqString",90:"qString"},productions_:[0,[3,3],[3,2],[3,4],[4,2],[4,2],[4,1],[7,0],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[17,1],[17,1],[17,1],[17,1],[14,5],[14,7],[28,5],[28,5],[28,5],[28,5],[28,2],[28,1],[25,1],[25,1],[25,1],[25,1],[25,1],[25,1],[37,1],[37,1],[37,1],[39,1],[39,1],[39,1],[39,1],[15,5],[15,7],[56,5],[56,5],[56,2],[56,1],[16,5],[16,5],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[19,3],[20,3],[20,3],[30,1],[30,3],[30,1],[30,3],[18,3],[73,1],[73,3],[78,1],[78,2],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[26,1],[26,1],[33,1],[33,1],[35,1],[35,1],[55,1],[55,1],[58,1],[58,1],[60,1],[60,1]],performAction:o(function(oe,V,xe,q,pe,ve,Pe){var _e=ve.length-1;switch(pe){case 4:this.$=ve[_e].trim(),q.setAccTitle(this.$);break;case 5:case 6:this.$=ve[_e].trim(),q.setAccDescription(this.$);break;case 7:this.$=[];break;case 17:q.setDirection("TB");break;case 18:q.setDirection("BT");break;case 19:q.setDirection("RL");break;case 20:q.setDirection("LR");break;case 21:q.addRequirement(ve[_e-3],ve[_e-4]);break;case 22:q.addRequirement(ve[_e-5],ve[_e-6]),q.setClass([ve[_e-5]],ve[_e-3]);break;case 23:q.setNewReqId(ve[_e-2]);break;case 24:q.setNewReqText(ve[_e-2]);break;case 25:q.setNewReqRisk(ve[_e-2]);break;case 26:q.setNewReqVerifyMethod(ve[_e-2]);break;case 29:this.$=q.RequirementType.REQUIREMENT;break;case 30:this.$=q.RequirementType.FUNCTIONAL_REQUIREMENT;break;case 31:this.$=q.RequirementType.INTERFACE_REQUIREMENT;break;case 32:this.$=q.RequirementType.PERFORMANCE_REQUIREMENT;break;case 33:this.$=q.RequirementType.PHYSICAL_REQUIREMENT;break;case 34:this.$=q.RequirementType.DESIGN_CONSTRAINT;break;case 35:this.$=q.RiskLevel.LOW_RISK;break;case 36:this.$=q.RiskLevel.MED_RISK;break;case 37:this.$=q.RiskLevel.HIGH_RISK;break;case 38:this.$=q.VerifyType.VERIFY_ANALYSIS;break;case 39:this.$=q.VerifyType.VERIFY_DEMONSTRATION;break;case 40:this.$=q.VerifyType.VERIFY_INSPECTION;break;case 41:this.$=q.VerifyType.VERIFY_TEST;break;case 42:q.addElement(ve[_e-3]);break;case 43:q.addElement(ve[_e-5]),q.setClass([ve[_e-5]],ve[_e-3]);break;case 44:q.setNewElementType(ve[_e-2]);break;case 45:q.setNewElementDocRef(ve[_e-2]);break;case 48:q.addRelationship(ve[_e-2],ve[_e],ve[_e-4]);break;case 49:q.addRelationship(ve[_e-2],ve[_e-4],ve[_e]);break;case 50:this.$=q.Relationships.CONTAINS;break;case 51:this.$=q.Relationships.COPIES;break;case 52:this.$=q.Relationships.DERIVES;break;case 53:this.$=q.Relationships.SATISFIES;break;case 54:this.$=q.Relationships.VERIFIES;break;case 55:this.$=q.Relationships.REFINES;break;case 56:this.$=q.Relationships.TRACES;break;case 57:this.$=ve[_e-2],q.defineClass(ve[_e-1],ve[_e]);break;case 58:q.setClass(ve[_e-1],ve[_e]);break;case 59:q.setClass([ve[_e-2]],ve[_e]);break;case 60:case 62:this.$=[ve[_e]];break;case 61:case 63:this.$=ve[_e-2].concat([ve[_e]]);break;case 64:this.$=ve[_e-2],q.setCssStyle(ve[_e-1],ve[_e]);break;case 65:this.$=[ve[_e]];break;case 66:ve[_e-2].push(ve[_e]),this.$=ve[_e-2];break;case 68:this.$=ve[_e-1]+ve[_e];break}},"anonymous"),table:[{3:1,4:2,6:e,9:r,11:n,13:i},{1:[3]},{3:8,4:2,5:[1,7],6:e,9:r,11:n,13:i},{5:[1,9]},{10:[1,10]},{12:[1,11]},t(a,[2,6]),{3:12,4:2,6:e,9:r,11:n,13:i},{1:[2,2]},{4:17,5:s,7:13,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},t(a,[2,4]),t(a,[2,5]),{1:[2,1]},{8:[1,41]},{4:17,5:s,7:42,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:43,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:44,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:45,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:46,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:47,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:48,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:49,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{4:17,5:s,7:50,8:l,9:r,11:n,13:i,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:u,22:h,23:f,24:d,25:23,33:25,41:p,42:m,43:g,44:y,45:v,46:x,54:b,72:w,74:C,77:T,89:E,90:A},{26:51,89:[1,52],90:[1,53]},{55:54,89:[1,55],90:[1,56]},{29:[1,59],61:[1,57],63:[1,58]},t(S,[2,17]),t(S,[2,18]),t(S,[2,19]),t(S,[2,20]),{30:60,33:62,75:_,89:E,90:A},{30:63,33:62,75:_,89:E,90:A},{30:64,33:62,75:_,89:E,90:A},t(I,[2,29]),t(I,[2,30]),t(I,[2,31]),t(I,[2,32]),t(I,[2,33]),t(I,[2,34]),t(D,[2,81]),t(D,[2,82]),{1:[2,3]},{8:[2,8]},{8:[2,9]},{8:[2,10]},{8:[2,11]},{8:[2,12]},{8:[2,13]},{8:[2,14]},{8:[2,15]},{8:[2,16]},{27:[1,65],29:[1,66]},t(k,[2,79]),t(k,[2,80]),{27:[1,67],29:[1,68]},t(k,[2,85]),t(k,[2,86]),{62:69,65:L,66:R,67:O,68:M,69:B,70:F,71:P},{62:77,65:L,66:R,67:O,68:M,69:B,70:F,71:P},{30:78,33:62,75:_,89:E,90:A},{73:79,75:z,76:$,78:81,79:82,80:H,81:Q,82:j,83:ie,84:ne,85:le,86:he,87:K,88:X},t(te,[2,60]),t(te,[2,62]),{73:93,75:z,76:$,78:81,79:82,80:H,81:Q,82:j,83:ie,84:ne,85:le,86:he,87:K,88:X},{30:94,33:62,75:_,76:$,89:E,90:A},{5:[1,95]},{30:96,33:62,75:_,89:E,90:A},{5:[1,97]},{30:98,33:62,75:_,89:E,90:A},{63:[1,99]},t(J,[2,50]),t(J,[2,51]),t(J,[2,52]),t(J,[2,53]),t(J,[2,54]),t(J,[2,55]),t(J,[2,56]),{64:[1,100]},t(S,[2,59],{76:$}),t(S,[2,64],{76:se}),{33:103,75:[1,102],89:E,90:A},t(ue,[2,65],{79:104,75:z,80:H,81:Q,82:j,83:ie,84:ne,85:le,86:he,87:K,88:X}),t(Z,[2,67]),t(Z,[2,69]),t(Z,[2,70]),t(Z,[2,71]),t(Z,[2,72]),t(Z,[2,73]),t(Z,[2,74]),t(Z,[2,75]),t(Z,[2,76]),t(Z,[2,77]),t(Z,[2,78]),t(S,[2,57],{76:se}),t(S,[2,58],{76:$}),{5:Se,28:105,31:ce,34:ae,36:Oe,38:ge,40:ze},{27:[1,112],76:$},{5:He,40:$e,56:113,57:Re,59:Ie},{27:[1,118],76:$},{33:119,89:E,90:A},{33:120,89:E,90:A},{75:z,78:121,79:82,80:H,81:Q,82:j,83:ie,84:ne,85:le,86:he,87:K,88:X},t(te,[2,61]),t(te,[2,63]),t(Z,[2,68]),t(S,[2,21]),{32:[1,122]},{32:[1,123]},{32:[1,124]},{32:[1,125]},{5:Se,28:126,31:ce,34:ae,36:Oe,38:ge,40:ze},t(S,[2,28]),{5:[1,127]},t(S,[2,42]),{32:[1,128]},{32:[1,129]},{5:He,40:$e,56:130,57:Re,59:Ie},t(S,[2,47]),{5:[1,131]},t(S,[2,48]),t(S,[2,49]),t(ue,[2,66],{79:104,75:z,80:H,81:Q,82:j,83:ie,84:ne,85:le,86:he,87:K,88:X}),{33:132,89:E,90:A},{35:133,89:[1,134],90:[1,135]},{37:136,47:[1,137],48:[1,138],49:[1,139]},{39:140,50:[1,141],51:[1,142],52:[1,143],53:[1,144]},t(S,[2,27]),{5:Se,28:145,31:ce,34:ae,36:Oe,38:ge,40:ze},{58:146,89:[1,147],90:[1,148]},{60:149,89:[1,150],90:[1,151]},t(S,[2,46]),{5:He,40:$e,56:152,57:Re,59:Ie},{5:[1,153]},{5:[1,154]},{5:[2,83]},{5:[2,84]},{5:[1,155]},{5:[2,35]},{5:[2,36]},{5:[2,37]},{5:[1,156]},{5:[2,38]},{5:[2,39]},{5:[2,40]},{5:[2,41]},t(S,[2,22]),{5:[1,157]},{5:[2,87]},{5:[2,88]},{5:[1,158]},{5:[2,89]},{5:[2,90]},t(S,[2,43]),{5:Se,28:159,31:ce,34:ae,36:Oe,38:ge,40:ze},{5:Se,28:160,31:ce,34:ae,36:Oe,38:ge,40:ze},{5:Se,28:161,31:ce,34:ae,36:Oe,38:ge,40:ze},{5:Se,28:162,31:ce,34:ae,36:Oe,38:ge,40:ze},{5:He,40:$e,56:163,57:Re,59:Ie},{5:He,40:$e,56:164,57:Re,59:Ie},t(S,[2,23]),t(S,[2,24]),t(S,[2,25]),t(S,[2,26]),t(S,[2,44]),t(S,[2,45])],defaultActions:{8:[2,2],12:[2,1],41:[2,3],42:[2,8],43:[2,9],44:[2,10],45:[2,11],46:[2,12],47:[2,13],48:[2,14],49:[2,15],50:[2,16],134:[2,83],135:[2,84],137:[2,35],138:[2,36],139:[2,37],141:[2,38],142:[2,39],143:[2,40],144:[2,41],147:[2,87],148:[2,88],150:[2,89],151:[2,90]},parseError:o(function(oe,V){if(V.recoverable)this.trace(oe);else{var xe=new Error(oe);throw xe.hash=V,xe}},"parseError"),parse:o(function(oe){var V=this,xe=[0],q=[],pe=[null],ve=[],Pe=this.table,_e="",we=0,Ve=0,De=0,qe=2,at=1,Rt=ve.slice.call(arguments,1),st=Object.create(this.lexer),Ue={yy:{}};for(var ct in this.yy)Object.prototype.hasOwnProperty.call(this.yy,ct)&&(Ue.yy[ct]=this.yy[ct]);st.setInput(oe,Ue.yy),Ue.yy.lexer=st,Ue.yy.parser=this,typeof st.yylloc>"u"&&(st.yylloc={});var We=st.yylloc;ve.push(We);var ot=st.options&&st.options.ranges;typeof Ue.yy.parseError=="function"?this.parseError=Ue.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Yt(Dr){xe.length=xe.length-2*Dr,pe.length=pe.length-Dr,ve.length=ve.length-Dr}o(Yt,"popStack");function bt(){var Dr;return Dr=q.pop()||st.lex()||at,typeof Dr!="number"&&(Dr instanceof Array&&(q=Dr,Dr=q.pop()),Dr=V.symbols_[Dr]||Dr),Dr}o(bt,"lex");for(var Mt,xt,ut,Et,ft,yt,nt={},dn,Tt,On,tn;;){if(ut=xe[xe.length-1],this.defaultActions[ut]?Et=this.defaultActions[ut]:((Mt===null||typeof Mt>"u")&&(Mt=bt()),Et=Pe[ut]&&Pe[ut][Mt]),typeof Et>"u"||!Et.length||!Et[0]){var _r="";tn=[];for(dn in Pe[ut])this.terminals_[dn]&&dn>qe&&tn.push("'"+this.terminals_[dn]+"'");st.showPosition?_r="Parse error on line "+(we+1)+`: +`+st.showPosition()+` +Expecting `+tn.join(", ")+", got '"+(this.terminals_[Mt]||Mt)+"'":_r="Parse error on line "+(we+1)+": Unexpected "+(Mt==at?"end of input":"'"+(this.terminals_[Mt]||Mt)+"'"),this.parseError(_r,{text:st.match,token:this.terminals_[Mt]||Mt,line:st.yylineno,loc:We,expected:tn})}if(Et[0]instanceof Array&&Et.length>1)throw new Error("Parse Error: multiple actions possible at state: "+ut+", token: "+Mt);switch(Et[0]){case 1:xe.push(Mt),pe.push(st.yytext),ve.push(st.yylloc),xe.push(Et[1]),Mt=null,xt?(Mt=xt,xt=null):(Ve=st.yyleng,_e=st.yytext,we=st.yylineno,We=st.yylloc,De>0&&De--);break;case 2:if(Tt=this.productions_[Et[1]][1],nt.$=pe[pe.length-Tt],nt._$={first_line:ve[ve.length-(Tt||1)].first_line,last_line:ve[ve.length-1].last_line,first_column:ve[ve.length-(Tt||1)].first_column,last_column:ve[ve.length-1].last_column},ot&&(nt._$.range=[ve[ve.length-(Tt||1)].range[0],ve[ve.length-1].range[1]]),yt=this.performAction.apply(nt,[_e,Ve,we,Ue.yy,Et[1],pe,ve].concat(Rt)),typeof yt<"u")return yt;Tt&&(xe=xe.slice(0,-1*Tt*2),pe=pe.slice(0,-1*Tt),ve=ve.slice(0,-1*Tt)),xe.push(this.productions_[Et[1]][0]),pe.push(nt.$),ve.push(nt._$),On=Pe[xe[xe.length-2]][xe[xe.length-1]],xe.push(On);break;case 3:return!0}}return!0},"parse")},W=function(){var re={EOF:1,parseError:o(function(V,xe){if(this.yy.parser)this.yy.parser.parseError(V,xe);else throw new Error(V)},"parseError"),setInput:o(function(oe,V){return this.yy=V||this.yy||{},this._input=oe,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var oe=this._input[0];this.yytext+=oe,this.yyleng++,this.offset++,this.match+=oe,this.matched+=oe;var V=oe.match(/(?:\r\n?|\n).*/g);return V?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),oe},"input"),unput:o(function(oe){var V=oe.length,xe=oe.split(/(?:\r\n?|\n)/g);this._input=oe+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-V),this.offset-=V;var q=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),xe.length-1&&(this.yylineno-=xe.length-1);var pe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:xe?(xe.length===q.length?this.yylloc.first_column:0)+q[q.length-xe.length].length-xe[0].length:this.yylloc.first_column-V},this.options.ranges&&(this.yylloc.range=[pe[0],pe[0]+this.yyleng-V]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(oe){this.unput(this.match.slice(oe))},"less"),pastInput:o(function(){var oe=this.matched.substr(0,this.matched.length-this.match.length);return(oe.length>20?"...":"")+oe.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var oe=this.match;return oe.length<20&&(oe+=this._input.substr(0,20-oe.length)),(oe.substr(0,20)+(oe.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var oe=this.pastInput(),V=new Array(oe.length+1).join("-");return oe+this.upcomingInput()+` +`+V+"^"},"showPosition"),test_match:o(function(oe,V){var xe,q,pe;if(this.options.backtrack_lexer&&(pe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(pe.yylloc.range=this.yylloc.range.slice(0))),q=oe[0].match(/(?:\r\n?|\n).*/g),q&&(this.yylineno+=q.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:q?q[q.length-1].length-q[q.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+oe[0].length},this.yytext+=oe[0],this.match+=oe[0],this.matches=oe,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(oe[0].length),this.matched+=oe[0],xe=this.performAction.call(this,this.yy,this,V,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),xe)return xe;if(this._backtrack){for(var ve in pe)this[ve]=pe[ve];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var oe,V,xe,q;this._more||(this.yytext="",this.match="");for(var pe=this._currentRules(),ve=0;veV[0].length)){if(V=xe,q=ve,this.options.backtrack_lexer){if(oe=this.test_match(xe,pe[ve]),oe!==!1)return oe;if(this._backtrack){V=!1;continue}else return!1}else if(!this.options.flex)break}return V?(oe=this.test_match(V,pe[q]),oe!==!1?oe:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var V=this.next();return V||this.lex()},"lex"),begin:o(function(V){this.conditionStack.push(V)},"begin"),popState:o(function(){var V=this.conditionStack.length-1;return V>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(V){return V=this.conditionStack.length-1-Math.abs(V||0),V>=0?this.conditionStack[V]:"INITIAL"},"topState"),pushState:o(function(V){this.begin(V)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(V,xe,q,pe){var ve=pe;switch(q){case 0:return"title";case 1:return this.begin("acc_title"),9;break;case 2:return this.popState(),"acc_title_value";break;case 3:return this.begin("acc_descr"),11;break;case 4:return this.popState(),"acc_descr_value";break;case 5:this.begin("acc_descr_multiline");break;case 6:this.popState();break;case 7:return"acc_descr_multiline_value";case 8:return 21;case 9:return 22;case 10:return 23;case 11:return 24;case 12:return 5;case 13:break;case 14:break;case 15:break;case 16:return 8;case 17:return 6;case 18:return 27;case 19:return 40;case 20:return 29;case 21:return 32;case 22:return 31;case 23:return 34;case 24:return 36;case 25:return 38;case 26:return 41;case 27:return 42;case 28:return 43;case 29:return 44;case 30:return 45;case 31:return 46;case 32:return 47;case 33:return 48;case 34:return 49;case 35:return 50;case 36:return 51;case 37:return 52;case 38:return 53;case 39:return 54;case 40:return 65;case 41:return 66;case 42:return 67;case 43:return 68;case 44:return 69;case 45:return 70;case 46:return 71;case 47:return 57;case 48:return 59;case 49:return this.begin("style"),77;break;case 50:return 75;case 51:return 81;case 52:return 88;case 53:return"PERCENT";case 54:return 86;case 55:return 84;case 56:break;case 57:this.begin("string");break;case 58:this.popState();break;case 59:return this.begin("style"),72;break;case 60:return this.begin("style"),74;break;case 61:return 61;case 62:return 64;case 63:return 63;case 64:this.begin("string");break;case 65:this.popState();break;case 66:return"qString";case 67:return xe.yytext=xe.yytext.trim(),89;break;case 68:return 75;case 69:return 80;case 70:return 76}},"anonymous"),rules:[/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:(\r?\n)+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:$)/i,/^(?:requirementDiagram\b)/i,/^(?:\{)/i,/^(?:\})/i,/^(?::{3})/i,/^(?::)/i,/^(?:id\b)/i,/^(?:text\b)/i,/^(?:risk\b)/i,/^(?:verifyMethod\b)/i,/^(?:requirement\b)/i,/^(?:functionalRequirement\b)/i,/^(?:interfaceRequirement\b)/i,/^(?:performanceRequirement\b)/i,/^(?:physicalRequirement\b)/i,/^(?:designConstraint\b)/i,/^(?:low\b)/i,/^(?:medium\b)/i,/^(?:high\b)/i,/^(?:analysis\b)/i,/^(?:demonstration\b)/i,/^(?:inspection\b)/i,/^(?:test\b)/i,/^(?:element\b)/i,/^(?:contains\b)/i,/^(?:copies\b)/i,/^(?:derives\b)/i,/^(?:satisfies\b)/i,/^(?:verifies\b)/i,/^(?:refines\b)/i,/^(?:traces\b)/i,/^(?:type\b)/i,/^(?:docref\b)/i,/^(?:style\b)/i,/^(?:\w+)/i,/^(?::)/i,/^(?:;)/i,/^(?:%)/i,/^(?:-)/i,/^(?:#)/i,/^(?: )/i,/^(?:["])/i,/^(?:\n)/i,/^(?:classDef\b)/i,/^(?:class\b)/i,/^(?:<-)/i,/^(?:->)/i,/^(?:-)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[\w][^:,\r\n\{\<\>\-\=]*)/i,/^(?:\w+)/i,/^(?:[0-9]+)/i,/^(?:,)/i],conditions:{acc_descr_multiline:{rules:[6,7,68,69,70],inclusive:!1},acc_descr:{rules:[4,68,69,70],inclusive:!1},acc_title:{rules:[2,68,69,70],inclusive:!1},style:{rules:[50,51,52,53,54,55,56,57,58,68,69,70],inclusive:!1},unqString:{rules:[68,69,70],inclusive:!1},token:{rules:[68,69,70],inclusive:!1},string:{rules:[65,66,68,69,70],inclusive:!1},INITIAL:{rules:[0,1,3,5,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,59,60,61,62,63,64,67,68,69,70],inclusive:!0}}};return re}();be.lexer=W;function de(){this.yy={}}return o(de,"Parser"),de.prototype=be,be.Parser=de,new de}();EO.parser=EO;Zhe=EO});var A6,efe=N(()=>{"use strict";zt();vt();mi();A6=class{constructor(){this.relations=[];this.latestRequirement=this.getInitialRequirement();this.requirements=new Map;this.latestElement=this.getInitialElement();this.elements=new Map;this.classes=new Map;this.direction="TB";this.RequirementType={REQUIREMENT:"Requirement",FUNCTIONAL_REQUIREMENT:"Functional Requirement",INTERFACE_REQUIREMENT:"Interface Requirement",PERFORMANCE_REQUIREMENT:"Performance Requirement",PHYSICAL_REQUIREMENT:"Physical Requirement",DESIGN_CONSTRAINT:"Design Constraint"};this.RiskLevel={LOW_RISK:"Low",MED_RISK:"Medium",HIGH_RISK:"High"};this.VerifyType={VERIFY_ANALYSIS:"Analysis",VERIFY_DEMONSTRATION:"Demonstration",VERIFY_INSPECTION:"Inspection",VERIFY_TEST:"Test"};this.Relationships={CONTAINS:"contains",COPIES:"copies",DERIVES:"derives",SATISFIES:"satisfies",VERIFIES:"verifies",REFINES:"refines",TRACES:"traces"};this.setAccTitle=Lr;this.getAccTitle=Rr;this.setAccDescription=Nr;this.getAccDescription=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Ir;this.getConfig=o(()=>me().requirement,"getConfig");this.clear(),this.setDirection=this.setDirection.bind(this),this.addRequirement=this.addRequirement.bind(this),this.setNewReqId=this.setNewReqId.bind(this),this.setNewReqRisk=this.setNewReqRisk.bind(this),this.setNewReqText=this.setNewReqText.bind(this),this.setNewReqVerifyMethod=this.setNewReqVerifyMethod.bind(this),this.addElement=this.addElement.bind(this),this.setNewElementType=this.setNewElementType.bind(this),this.setNewElementDocRef=this.setNewElementDocRef.bind(this),this.addRelationship=this.addRelationship.bind(this),this.setCssStyle=this.setCssStyle.bind(this),this.setClass=this.setClass.bind(this),this.defineClass=this.defineClass.bind(this),this.setAccTitle=this.setAccTitle.bind(this),this.setAccDescription=this.setAccDescription.bind(this)}static{o(this,"RequirementDB")}getDirection(){return this.direction}setDirection(e){this.direction=e}resetLatestRequirement(){this.latestRequirement=this.getInitialRequirement()}resetLatestElement(){this.latestElement=this.getInitialElement()}getInitialRequirement(){return{requirementId:"",text:"",risk:"",verifyMethod:"",name:"",type:"",cssStyles:[],classes:["default"]}}getInitialElement(){return{name:"",type:"",docRef:"",cssStyles:[],classes:["default"]}}addRequirement(e,r){return this.requirements.has(e)||this.requirements.set(e,{name:e,type:r,requirementId:this.latestRequirement.requirementId,text:this.latestRequirement.text,risk:this.latestRequirement.risk,verifyMethod:this.latestRequirement.verifyMethod,cssStyles:[],classes:["default"]}),this.resetLatestRequirement(),this.requirements.get(e)}getRequirements(){return this.requirements}setNewReqId(e){this.latestRequirement!==void 0&&(this.latestRequirement.requirementId=e)}setNewReqText(e){this.latestRequirement!==void 0&&(this.latestRequirement.text=e)}setNewReqRisk(e){this.latestRequirement!==void 0&&(this.latestRequirement.risk=e)}setNewReqVerifyMethod(e){this.latestRequirement!==void 0&&(this.latestRequirement.verifyMethod=e)}addElement(e){return this.elements.has(e)||(this.elements.set(e,{name:e,type:this.latestElement.type,docRef:this.latestElement.docRef,cssStyles:[],classes:["default"]}),Y.info("Added new element: ",e)),this.resetLatestElement(),this.elements.get(e)}getElements(){return this.elements}setNewElementType(e){this.latestElement!==void 0&&(this.latestElement.type=e)}setNewElementDocRef(e){this.latestElement!==void 0&&(this.latestElement.docRef=e)}addRelationship(e,r,n){this.relations.push({type:e,src:r,dst:n})}getRelationships(){return this.relations}clear(){this.relations=[],this.resetLatestRequirement(),this.requirements=new Map,this.resetLatestElement(),this.elements=new Map,this.classes=new Map,Ar()}setCssStyle(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(!r||!i)return;for(let a of r)a.includes(",")?i.cssStyles.push(...a.split(",")):i.cssStyles.push(a)}}setClass(e,r){for(let n of e){let i=this.requirements.get(n)??this.elements.get(n);if(i)for(let a of r){i.classes.push(a);let s=this.classes.get(a)?.styles;s&&i.cssStyles.push(...s)}}}defineClass(e,r){for(let n of e){let i=this.classes.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.classes.set(n,i)),r&&r.forEach(function(a){if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.requirements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))}),this.elements.forEach(a=>{a.classes.includes(n)&&a.cssStyles.push(...r.flatMap(s=>s.split(",")))})}}getClasses(){return this.classes}getData(){let e=me(),r=[],n=[];for(let i of this.requirements.values()){let a=i;a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),a.shape="requirementBox",a.look=e.look,r.push(a)}for(let i of this.elements.values()){let a=i;a.shape="requirementBox",a.look=e.look,a.id=i.name,a.cssStyles=i.cssStyles,a.cssClasses=i.classes.join(" "),r.push(a)}for(let i of this.relations){let a=0,s=i.type===this.Relationships.CONTAINS,l={id:`${i.src}-${i.dst}-${a}`,start:this.requirements.get(i.src)?.name??this.elements.get(i.src)?.name,end:this.requirements.get(i.dst)?.name??this.elements.get(i.dst)?.name,label:`<<${i.type}>>`,classes:"relationshipLine",style:["fill:none",s?"":"stroke-dasharray: 10,7"],labelpos:"c",thickness:"normal",type:"normal",pattern:s?"normal":"dashed",arrowTypeStart:s?"requirement_contains":"",arrowTypeEnd:s?"":"requirement_arrow",look:e.look};n.push(l),a++}return{nodes:r,edges:n,other:{},config:e,direction:this.getDirection()}}}});var ZGe,tfe,rfe=N(()=>{"use strict";ZGe=o(t=>` + + marker { + fill: ${t.relationColor}; + stroke: ${t.relationColor}; + } + + marker.cross { + stroke: ${t.lineColor}; + } + + svg { + font-family: ${t.fontFamily}; + font-size: ${t.fontSize}; + } + + .reqBox { + fill: ${t.requirementBackground}; + fill-opacity: 1.0; + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + + .reqTitle, .reqLabel{ + fill: ${t.requirementTextColor}; + } + .reqLabelBox { + fill: ${t.relationLabelBackground}; + fill-opacity: 1.0; + } + + .req-title-line { + stroke: ${t.requirementBorderColor}; + stroke-width: ${t.requirementBorderSize}; + } + .relationshipLine { + stroke: ${t.relationColor}; + stroke-width: 1; + } + .relationshipLabel { + fill: ${t.relationLabelColor}; + } + .divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; + } + .label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .label text,span { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + .labelBkg { + background-color: ${t.edgeLabelBackground}; + } + +`,"getStyles"),tfe=ZGe});var SO={};hr(SO,{draw:()=>JGe});var JGe,nfe=N(()=>{"use strict";zt();vt();gm();Yd();$m();ir();JGe=o(async function(t,e,r,n){Y.info("REF0:"),Y.info("Drawing requirement diagram (unified)",e);let{securityLevel:i,state:a,layout:s}=me(),l=n.db.getData(),u=yc(e,i);l.type=n.type,l.layoutAlgorithm=nf(s),l.nodeSpacing=a?.nodeSpacing??50,l.rankSpacing=a?.rankSpacing??50,l.markers=["requirement_contains","requirement_arrow"],l.diagramId=e,await Cc(l,u);let h=8;Gt.insertTitle(u,"requirementDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ac(u,h,"requirementDiagram",a?.useMaxWidth??!0)},"draw")});var ife={};hr(ife,{diagram:()=>eVe});var eVe,afe=N(()=>{"use strict";Jhe();efe();rfe();nfe();eVe={parser:Zhe,get db(){return new A6},renderer:SO,styles:tfe}});var CO,lfe,cfe=N(()=>{"use strict";CO=function(){var t=o(function(K,X,te,J){for(te=te||{},J=K.length;J--;te[K[J]]=X);return te},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,13],u=[1,14],h=[1,16],f=[1,17],d=[1,18],p=[1,24],m=[1,25],g=[1,26],y=[1,27],v=[1,28],x=[1,29],b=[1,30],w=[1,31],C=[1,32],T=[1,33],E=[1,34],A=[1,35],S=[1,36],_=[1,37],I=[1,38],D=[1,39],k=[1,41],L=[1,42],R=[1,43],O=[1,44],M=[1,45],B=[1,46],F=[1,4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,48,49,50,52,53,54,59,60,61,62,70],P=[4,5,16,50,52,53],z=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,54,59,60,61,62,70],$=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,49,50,52,53,54,59,60,61,62,70],H=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,48,50,52,53,54,59,60,61,62,70],Q=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,50,52,53,54,59,60,61,62,70],j=[68,69,70],ie=[1,122],ne={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NEWLINE:5,SD:6,document:7,line:8,statement:9,box_section:10,box_line:11,participant_statement:12,create:13,box:14,restOfLine:15,end:16,signal:17,autonumber:18,NUM:19,off:20,activate:21,actor:22,deactivate:23,note_statement:24,links_statement:25,link_statement:26,properties_statement:27,details_statement:28,title:29,legacy_title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,loop:36,rect:37,opt:38,alt:39,else_sections:40,par:41,par_sections:42,par_over:43,critical:44,option_sections:45,break:46,option:47,and:48,else:49,participant:50,AS:51,participant_actor:52,destroy:53,note:54,placement:55,text2:56,over:57,actor_pair:58,links:59,link:60,properties:61,details:62,spaceList:63,",":64,left_of:65,right_of:66,signaltype:67,"+":68,"-":69,ACTOR:70,SOLID_OPEN_ARROW:71,DOTTED_OPEN_ARROW:72,SOLID_ARROW:73,BIDIRECTIONAL_SOLID_ARROW:74,DOTTED_ARROW:75,BIDIRECTIONAL_DOTTED_ARROW:76,SOLID_CROSS:77,DOTTED_CROSS:78,SOLID_POINT:79,DOTTED_POINT:80,TXT:81,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NEWLINE",6:"SD",13:"create",14:"box",15:"restOfLine",16:"end",18:"autonumber",19:"NUM",20:"off",21:"activate",23:"deactivate",29:"title",30:"legacy_title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"loop",37:"rect",38:"opt",39:"alt",41:"par",43:"par_over",44:"critical",46:"break",47:"option",48:"and",49:"else",50:"participant",51:"AS",52:"participant_actor",53:"destroy",54:"note",57:"over",59:"links",60:"link",61:"properties",62:"details",64:",",65:"left_of",66:"right_of",68:"+",69:"-",70:"ACTOR",71:"SOLID_OPEN_ARROW",72:"DOTTED_OPEN_ARROW",73:"SOLID_ARROW",74:"BIDIRECTIONAL_SOLID_ARROW",75:"DOTTED_ARROW",76:"BIDIRECTIONAL_DOTTED_ARROW",77:"SOLID_CROSS",78:"DOTTED_CROSS",79:"SOLID_POINT",80:"DOTTED_POINT",81:"TXT"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[10,0],[10,2],[11,2],[11,1],[11,1],[9,1],[9,2],[9,4],[9,2],[9,4],[9,3],[9,3],[9,2],[9,3],[9,3],[9,2],[9,2],[9,2],[9,2],[9,2],[9,1],[9,1],[9,2],[9,2],[9,1],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[45,1],[45,4],[42,1],[42,4],[40,1],[40,4],[12,5],[12,3],[12,5],[12,3],[12,3],[24,4],[24,4],[25,3],[26,3],[27,3],[28,3],[63,2],[63,1],[58,3],[58,1],[55,1],[55,1],[17,5],[17,5],[17,4],[22,1],[67,1],[67,1],[67,1],[67,1],[67,1],[67,1],[67,1],[67,1],[67,1],[67,1],[56,1]],performAction:o(function(X,te,J,se,ue,Z,Se){var ce=Z.length-1;switch(ue){case 3:return se.apply(Z[ce]),Z[ce];break;case 4:case 9:this.$=[];break;case 5:case 10:Z[ce-1].push(Z[ce]),this.$=Z[ce-1];break;case 6:case 7:case 11:case 12:this.$=Z[ce];break;case 8:case 13:this.$=[];break;case 15:Z[ce].type="createParticipant",this.$=Z[ce];break;case 16:Z[ce-1].unshift({type:"boxStart",boxData:se.parseBoxData(Z[ce-2])}),Z[ce-1].push({type:"boxEnd",boxText:Z[ce-2]}),this.$=Z[ce-1];break;case 18:this.$={type:"sequenceIndex",sequenceIndex:Number(Z[ce-2]),sequenceIndexStep:Number(Z[ce-1]),sequenceVisible:!0,signalType:se.LINETYPE.AUTONUMBER};break;case 19:this.$={type:"sequenceIndex",sequenceIndex:Number(Z[ce-1]),sequenceIndexStep:1,sequenceVisible:!0,signalType:se.LINETYPE.AUTONUMBER};break;case 20:this.$={type:"sequenceIndex",sequenceVisible:!1,signalType:se.LINETYPE.AUTONUMBER};break;case 21:this.$={type:"sequenceIndex",sequenceVisible:!0,signalType:se.LINETYPE.AUTONUMBER};break;case 22:this.$={type:"activeStart",signalType:se.LINETYPE.ACTIVE_START,actor:Z[ce-1].actor};break;case 23:this.$={type:"activeEnd",signalType:se.LINETYPE.ACTIVE_END,actor:Z[ce-1].actor};break;case 29:se.setDiagramTitle(Z[ce].substring(6)),this.$=Z[ce].substring(6);break;case 30:se.setDiagramTitle(Z[ce].substring(7)),this.$=Z[ce].substring(7);break;case 31:this.$=Z[ce].trim(),se.setAccTitle(this.$);break;case 32:case 33:this.$=Z[ce].trim(),se.setAccDescription(this.$);break;case 34:Z[ce-1].unshift({type:"loopStart",loopText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.LOOP_START}),Z[ce-1].push({type:"loopEnd",loopText:Z[ce-2],signalType:se.LINETYPE.LOOP_END}),this.$=Z[ce-1];break;case 35:Z[ce-1].unshift({type:"rectStart",color:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.RECT_START}),Z[ce-1].push({type:"rectEnd",color:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.RECT_END}),this.$=Z[ce-1];break;case 36:Z[ce-1].unshift({type:"optStart",optText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.OPT_START}),Z[ce-1].push({type:"optEnd",optText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.OPT_END}),this.$=Z[ce-1];break;case 37:Z[ce-1].unshift({type:"altStart",altText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.ALT_START}),Z[ce-1].push({type:"altEnd",signalType:se.LINETYPE.ALT_END}),this.$=Z[ce-1];break;case 38:Z[ce-1].unshift({type:"parStart",parText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.PAR_START}),Z[ce-1].push({type:"parEnd",signalType:se.LINETYPE.PAR_END}),this.$=Z[ce-1];break;case 39:Z[ce-1].unshift({type:"parStart",parText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.PAR_OVER_START}),Z[ce-1].push({type:"parEnd",signalType:se.LINETYPE.PAR_END}),this.$=Z[ce-1];break;case 40:Z[ce-1].unshift({type:"criticalStart",criticalText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.CRITICAL_START}),Z[ce-1].push({type:"criticalEnd",signalType:se.LINETYPE.CRITICAL_END}),this.$=Z[ce-1];break;case 41:Z[ce-1].unshift({type:"breakStart",breakText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.BREAK_START}),Z[ce-1].push({type:"breakEnd",optText:se.parseMessage(Z[ce-2]),signalType:se.LINETYPE.BREAK_END}),this.$=Z[ce-1];break;case 43:this.$=Z[ce-3].concat([{type:"option",optionText:se.parseMessage(Z[ce-1]),signalType:se.LINETYPE.CRITICAL_OPTION},Z[ce]]);break;case 45:this.$=Z[ce-3].concat([{type:"and",parText:se.parseMessage(Z[ce-1]),signalType:se.LINETYPE.PAR_AND},Z[ce]]);break;case 47:this.$=Z[ce-3].concat([{type:"else",altText:se.parseMessage(Z[ce-1]),signalType:se.LINETYPE.ALT_ELSE},Z[ce]]);break;case 48:Z[ce-3].draw="participant",Z[ce-3].type="addParticipant",Z[ce-3].description=se.parseMessage(Z[ce-1]),this.$=Z[ce-3];break;case 49:Z[ce-1].draw="participant",Z[ce-1].type="addParticipant",this.$=Z[ce-1];break;case 50:Z[ce-3].draw="actor",Z[ce-3].type="addParticipant",Z[ce-3].description=se.parseMessage(Z[ce-1]),this.$=Z[ce-3];break;case 51:Z[ce-1].draw="actor",Z[ce-1].type="addParticipant",this.$=Z[ce-1];break;case 52:Z[ce-1].type="destroyParticipant",this.$=Z[ce-1];break;case 53:this.$=[Z[ce-1],{type:"addNote",placement:Z[ce-2],actor:Z[ce-1].actor,text:Z[ce]}];break;case 54:Z[ce-2]=[].concat(Z[ce-1],Z[ce-1]).slice(0,2),Z[ce-2][0]=Z[ce-2][0].actor,Z[ce-2][1]=Z[ce-2][1].actor,this.$=[Z[ce-1],{type:"addNote",placement:se.PLACEMENT.OVER,actor:Z[ce-2].slice(0,2),text:Z[ce]}];break;case 55:this.$=[Z[ce-1],{type:"addLinks",actor:Z[ce-1].actor,text:Z[ce]}];break;case 56:this.$=[Z[ce-1],{type:"addALink",actor:Z[ce-1].actor,text:Z[ce]}];break;case 57:this.$=[Z[ce-1],{type:"addProperties",actor:Z[ce-1].actor,text:Z[ce]}];break;case 58:this.$=[Z[ce-1],{type:"addDetails",actor:Z[ce-1].actor,text:Z[ce]}];break;case 61:this.$=[Z[ce-2],Z[ce]];break;case 62:this.$=Z[ce];break;case 63:this.$=se.PLACEMENT.LEFTOF;break;case 64:this.$=se.PLACEMENT.RIGHTOF;break;case 65:this.$=[Z[ce-4],Z[ce-1],{type:"addMessage",from:Z[ce-4].actor,to:Z[ce-1].actor,signalType:Z[ce-3],msg:Z[ce],activate:!0},{type:"activeStart",signalType:se.LINETYPE.ACTIVE_START,actor:Z[ce-1].actor}];break;case 66:this.$=[Z[ce-4],Z[ce-1],{type:"addMessage",from:Z[ce-4].actor,to:Z[ce-1].actor,signalType:Z[ce-3],msg:Z[ce]},{type:"activeEnd",signalType:se.LINETYPE.ACTIVE_END,actor:Z[ce-4].actor}];break;case 67:this.$=[Z[ce-3],Z[ce-1],{type:"addMessage",from:Z[ce-3].actor,to:Z[ce-1].actor,signalType:Z[ce-2],msg:Z[ce]}];break;case 68:this.$={type:"addParticipant",actor:Z[ce]};break;case 69:this.$=se.LINETYPE.SOLID_OPEN;break;case 70:this.$=se.LINETYPE.DOTTED_OPEN;break;case 71:this.$=se.LINETYPE.SOLID;break;case 72:this.$=se.LINETYPE.BIDIRECTIONAL_SOLID;break;case 73:this.$=se.LINETYPE.DOTTED;break;case 74:this.$=se.LINETYPE.BIDIRECTIONAL_DOTTED;break;case 75:this.$=se.LINETYPE.SOLID_CROSS;break;case 76:this.$=se.LINETYPE.DOTTED_CROSS;break;case 77:this.$=se.LINETYPE.SOLID_POINT;break;case 78:this.$=se.LINETYPE.DOTTED_POINT;break;case 79:this.$=se.parseMessage(Z[ce].trim().substring(1));break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,13,14,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,54,59,60,61,62,70],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},t(F,[2,5]),{9:47,12:12,13:l,14:u,17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},t(F,[2,7]),t(F,[2,8]),t(F,[2,14]),{12:48,50:_,52:I,53:D},{15:[1,49]},{5:[1,50]},{5:[1,53],19:[1,51],20:[1,52]},{22:54,70:B},{22:55,70:B},{5:[1,56]},{5:[1,57]},{5:[1,58]},{5:[1,59]},{5:[1,60]},t(F,[2,29]),t(F,[2,30]),{32:[1,61]},{34:[1,62]},t(F,[2,33]),{15:[1,63]},{15:[1,64]},{15:[1,65]},{15:[1,66]},{15:[1,67]},{15:[1,68]},{15:[1,69]},{15:[1,70]},{22:71,70:B},{22:72,70:B},{22:73,70:B},{67:74,71:[1,75],72:[1,76],73:[1,77],74:[1,78],75:[1,79],76:[1,80],77:[1,81],78:[1,82],79:[1,83],80:[1,84]},{55:85,57:[1,86],65:[1,87],66:[1,88]},{22:89,70:B},{22:90,70:B},{22:91,70:B},{22:92,70:B},t([5,51,64,71,72,73,74,75,76,77,78,79,80,81],[2,68]),t(F,[2,6]),t(F,[2,15]),t(P,[2,9],{10:93}),t(F,[2,17]),{5:[1,95],19:[1,94]},{5:[1,96]},t(F,[2,21]),{5:[1,97]},{5:[1,98]},t(F,[2,24]),t(F,[2,25]),t(F,[2,26]),t(F,[2,27]),t(F,[2,28]),t(F,[2,31]),t(F,[2,32]),t(z,i,{7:99}),t(z,i,{7:100}),t(z,i,{7:101}),t($,i,{40:102,7:103}),t(H,i,{42:104,7:105}),t(H,i,{7:105,42:106}),t(Q,i,{45:107,7:108}),t(z,i,{7:109}),{5:[1,111],51:[1,110]},{5:[1,113],51:[1,112]},{5:[1,114]},{22:117,68:[1,115],69:[1,116],70:B},t(j,[2,69]),t(j,[2,70]),t(j,[2,71]),t(j,[2,72]),t(j,[2,73]),t(j,[2,74]),t(j,[2,75]),t(j,[2,76]),t(j,[2,77]),t(j,[2,78]),{22:118,70:B},{22:120,58:119,70:B},{70:[2,63]},{70:[2,64]},{56:121,81:ie},{56:123,81:ie},{56:124,81:ie},{56:125,81:ie},{4:[1,128],5:[1,130],11:127,12:129,16:[1,126],50:_,52:I,53:D},{5:[1,131]},t(F,[2,19]),t(F,[2,20]),t(F,[2,22]),t(F,[2,23]),{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,132],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,133],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,134],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},{16:[1,135]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,46],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,49:[1,136],50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},{16:[1,137]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,44],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,48:[1,138],50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},{16:[1,139]},{16:[1,140]},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[2,42],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,47:[1,141],50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},{4:a,5:s,8:8,9:10,12:12,13:l,14:u,16:[1,142],17:15,18:h,21:f,22:40,23:d,24:19,25:20,26:21,27:22,28:23,29:p,30:m,31:g,33:y,35:v,36:x,37:b,38:w,39:C,41:T,43:E,44:A,46:S,50:_,52:I,53:D,54:k,59:L,60:R,61:O,62:M,70:B},{15:[1,143]},t(F,[2,49]),{15:[1,144]},t(F,[2,51]),t(F,[2,52]),{22:145,70:B},{22:146,70:B},{56:147,81:ie},{56:148,81:ie},{56:149,81:ie},{64:[1,150],81:[2,62]},{5:[2,55]},{5:[2,79]},{5:[2,56]},{5:[2,57]},{5:[2,58]},t(F,[2,16]),t(P,[2,10]),{12:151,50:_,52:I,53:D},t(P,[2,12]),t(P,[2,13]),t(F,[2,18]),t(F,[2,34]),t(F,[2,35]),t(F,[2,36]),t(F,[2,37]),{15:[1,152]},t(F,[2,38]),{15:[1,153]},t(F,[2,39]),t(F,[2,40]),{15:[1,154]},t(F,[2,41]),{5:[1,155]},{5:[1,156]},{56:157,81:ie},{56:158,81:ie},{5:[2,67]},{5:[2,53]},{5:[2,54]},{22:159,70:B},t(P,[2,11]),t($,i,{7:103,40:160}),t(H,i,{7:105,42:161}),t(Q,i,{7:108,45:162}),t(F,[2,48]),t(F,[2,50]),{5:[2,65]},{5:[2,66]},{81:[2,61]},{16:[2,47]},{16:[2,45]},{16:[2,43]}],defaultActions:{5:[2,1],6:[2,2],87:[2,63],88:[2,64],121:[2,55],122:[2,79],123:[2,56],124:[2,57],125:[2,58],147:[2,67],148:[2,53],149:[2,54],157:[2,65],158:[2,66],159:[2,61],160:[2,47],161:[2,45],162:[2,43]},parseError:o(function(X,te){if(te.recoverable)this.trace(X);else{var J=new Error(X);throw J.hash=te,J}},"parseError"),parse:o(function(X){var te=this,J=[0],se=[],ue=[null],Z=[],Se=this.table,ce="",ae=0,Oe=0,ge=0,ze=2,He=1,$e=Z.slice.call(arguments,1),Re=Object.create(this.lexer),Ie={yy:{}};for(var be in this.yy)Object.prototype.hasOwnProperty.call(this.yy,be)&&(Ie.yy[be]=this.yy[be]);Re.setInput(X,Ie.yy),Ie.yy.lexer=Re,Ie.yy.parser=this,typeof Re.yylloc>"u"&&(Re.yylloc={});var W=Re.yylloc;Z.push(W);var de=Re.options&&Re.options.ranges;typeof Ie.yy.parseError=="function"?this.parseError=Ie.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function re(Rt){J.length=J.length-2*Rt,ue.length=ue.length-Rt,Z.length=Z.length-Rt}o(re,"popStack");function oe(){var Rt;return Rt=se.pop()||Re.lex()||He,typeof Rt!="number"&&(Rt instanceof Array&&(se=Rt,Rt=se.pop()),Rt=te.symbols_[Rt]||Rt),Rt}o(oe,"lex");for(var V,xe,q,pe,ve,Pe,_e={},we,Ve,De,qe;;){if(q=J[J.length-1],this.defaultActions[q]?pe=this.defaultActions[q]:((V===null||typeof V>"u")&&(V=oe()),pe=Se[q]&&Se[q][V]),typeof pe>"u"||!pe.length||!pe[0]){var at="";qe=[];for(we in Se[q])this.terminals_[we]&&we>ze&&qe.push("'"+this.terminals_[we]+"'");Re.showPosition?at="Parse error on line "+(ae+1)+`: +`+Re.showPosition()+` +Expecting `+qe.join(", ")+", got '"+(this.terminals_[V]||V)+"'":at="Parse error on line "+(ae+1)+": Unexpected "+(V==He?"end of input":"'"+(this.terminals_[V]||V)+"'"),this.parseError(at,{text:Re.match,token:this.terminals_[V]||V,line:Re.yylineno,loc:W,expected:qe})}if(pe[0]instanceof Array&&pe.length>1)throw new Error("Parse Error: multiple actions possible at state: "+q+", token: "+V);switch(pe[0]){case 1:J.push(V),ue.push(Re.yytext),Z.push(Re.yylloc),J.push(pe[1]),V=null,xe?(V=xe,xe=null):(Oe=Re.yyleng,ce=Re.yytext,ae=Re.yylineno,W=Re.yylloc,ge>0&&ge--);break;case 2:if(Ve=this.productions_[pe[1]][1],_e.$=ue[ue.length-Ve],_e._$={first_line:Z[Z.length-(Ve||1)].first_line,last_line:Z[Z.length-1].last_line,first_column:Z[Z.length-(Ve||1)].first_column,last_column:Z[Z.length-1].last_column},de&&(_e._$.range=[Z[Z.length-(Ve||1)].range[0],Z[Z.length-1].range[1]]),Pe=this.performAction.apply(_e,[ce,Oe,ae,Ie.yy,pe[1],ue,Z].concat($e)),typeof Pe<"u")return Pe;Ve&&(J=J.slice(0,-1*Ve*2),ue=ue.slice(0,-1*Ve),Z=Z.slice(0,-1*Ve)),J.push(this.productions_[pe[1]][0]),ue.push(_e.$),Z.push(_e._$),De=Se[J[J.length-2]][J[J.length-1]],J.push(De);break;case 3:return!0}}return!0},"parse")},le=function(){var K={EOF:1,parseError:o(function(te,J){if(this.yy.parser)this.yy.parser.parseError(te,J);else throw new Error(te)},"parseError"),setInput:o(function(X,te){return this.yy=te||this.yy||{},this._input=X,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var X=this._input[0];this.yytext+=X,this.yyleng++,this.offset++,this.match+=X,this.matched+=X;var te=X.match(/(?:\r\n?|\n).*/g);return te?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),X},"input"),unput:o(function(X){var te=X.length,J=X.split(/(?:\r\n?|\n)/g);this._input=X+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-te),this.offset-=te;var se=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),J.length-1&&(this.yylineno-=J.length-1);var ue=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:J?(J.length===se.length?this.yylloc.first_column:0)+se[se.length-J.length].length-J[0].length:this.yylloc.first_column-te},this.options.ranges&&(this.yylloc.range=[ue[0],ue[0]+this.yyleng-te]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(X){this.unput(this.match.slice(X))},"less"),pastInput:o(function(){var X=this.matched.substr(0,this.matched.length-this.match.length);return(X.length>20?"...":"")+X.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var X=this.match;return X.length<20&&(X+=this._input.substr(0,20-X.length)),(X.substr(0,20)+(X.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var X=this.pastInput(),te=new Array(X.length+1).join("-");return X+this.upcomingInput()+` +`+te+"^"},"showPosition"),test_match:o(function(X,te){var J,se,ue;if(this.options.backtrack_lexer&&(ue={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(ue.yylloc.range=this.yylloc.range.slice(0))),se=X[0].match(/(?:\r\n?|\n).*/g),se&&(this.yylineno+=se.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:se?se[se.length-1].length-se[se.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+X[0].length},this.yytext+=X[0],this.match+=X[0],this.matches=X,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(X[0].length),this.matched+=X[0],J=this.performAction.call(this,this.yy,this,te,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),J)return J;if(this._backtrack){for(var Z in ue)this[Z]=ue[Z];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var X,te,J,se;this._more||(this.yytext="",this.match="");for(var ue=this._currentRules(),Z=0;Zte[0].length)){if(te=J,se=Z,this.options.backtrack_lexer){if(X=this.test_match(J,ue[Z]),X!==!1)return X;if(this._backtrack){te=!1;continue}else return!1}else if(!this.options.flex)break}return te?(X=this.test_match(te,ue[se]),X!==!1?X:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var te=this.next();return te||this.lex()},"lex"),begin:o(function(te){this.conditionStack.push(te)},"begin"),popState:o(function(){var te=this.conditionStack.length-1;return te>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(te){return te=this.conditionStack.length-1-Math.abs(te||0),te>=0?this.conditionStack[te]:"INITIAL"},"topState"),pushState:o(function(te){this.begin(te)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(te,J,se,ue){var Z=ue;switch(se){case 0:return 5;case 1:break;case 2:break;case 3:break;case 4:break;case 5:break;case 6:return 19;case 7:return this.begin("LINE"),14;break;case 8:return this.begin("ID"),50;break;case 9:return this.begin("ID"),52;break;case 10:return 13;case 11:return this.begin("ID"),53;break;case 12:return J.yytext=J.yytext.trim(),this.begin("ALIAS"),70;break;case 13:return this.popState(),this.popState(),this.begin("LINE"),51;break;case 14:return this.popState(),this.popState(),5;break;case 15:return this.begin("LINE"),36;break;case 16:return this.begin("LINE"),37;break;case 17:return this.begin("LINE"),38;break;case 18:return this.begin("LINE"),39;break;case 19:return this.begin("LINE"),49;break;case 20:return this.begin("LINE"),41;break;case 21:return this.begin("LINE"),43;break;case 22:return this.begin("LINE"),48;break;case 23:return this.begin("LINE"),44;break;case 24:return this.begin("LINE"),47;break;case 25:return this.begin("LINE"),46;break;case 26:return this.popState(),15;break;case 27:return 16;case 28:return 65;case 29:return 66;case 30:return 59;case 31:return 60;case 32:return 61;case 33:return 62;case 34:return 57;case 35:return 54;case 36:return this.begin("ID"),21;break;case 37:return this.begin("ID"),23;break;case 38:return 29;case 39:return 30;case 40:return this.begin("acc_title"),31;break;case 41:return this.popState(),"acc_title_value";break;case 42:return this.begin("acc_descr"),33;break;case 43:return this.popState(),"acc_descr_value";break;case 44:this.begin("acc_descr_multiline");break;case 45:this.popState();break;case 46:return"acc_descr_multiline_value";case 47:return 6;case 48:return 18;case 49:return 20;case 50:return 64;case 51:return 5;case 52:return J.yytext=J.yytext.trim(),70;break;case 53:return 73;case 54:return 74;case 55:return 75;case 56:return 76;case 57:return 71;case 58:return 72;case 59:return 77;case 60:return 78;case 61:return 79;case 62:return 80;case 63:return 81;case 64:return 68;case 65:return 69;case 66:return 5;case 67:return"INVALID"}},"anonymous"),rules:[/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[0-9]+(?=[ \n]+))/i,/^(?:box\b)/i,/^(?:participant\b)/i,/^(?:actor\b)/i,/^(?:create\b)/i,/^(?:destroy\b)/i,/^(?:[^\<->\->:\n,;]+?([\-]*[^\<->\->:\n,;]+?)*?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:as\b)/i,/^(?:(?:))/i,/^(?:loop\b)/i,/^(?:rect\b)/i,/^(?:opt\b)/i,/^(?:alt\b)/i,/^(?:else\b)/i,/^(?:par\b)/i,/^(?:par_over\b)/i,/^(?:and\b)/i,/^(?:critical\b)/i,/^(?:option\b)/i,/^(?:break\b)/i,/^(?:(?:[:]?(?:no)?wrap)?[^#\n;]*)/i,/^(?:end\b)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:links\b)/i,/^(?:link\b)/i,/^(?:properties\b)/i,/^(?:details\b)/i,/^(?:over\b)/i,/^(?:note\b)/i,/^(?:activate\b)/i,/^(?:deactivate\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:title:\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:sequenceDiagram\b)/i,/^(?:autonumber\b)/i,/^(?:off\b)/i,/^(?:,)/i,/^(?:;)/i,/^(?:[^\+\<->\->:\n,;]+((?!(-x|--x|-\)|--\)))[\-]*[^\+\<->\->:\n,;]+)*)/i,/^(?:->>)/i,/^(?:<<->>)/i,/^(?:-->>)/i,/^(?:<<-->>)/i,/^(?:->)/i,/^(?:-->)/i,/^(?:-[x])/i,/^(?:--[x])/i,/^(?:-[\)])/i,/^(?:--[\)])/i,/^(?::(?:(?:no)?wrap)?[^#\n;]+)/i,/^(?:\+)/i,/^(?:-)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[45,46],inclusive:!1},acc_descr:{rules:[43],inclusive:!1},acc_title:{rules:[41],inclusive:!1},ID:{rules:[2,3,12],inclusive:!1},ALIAS:{rules:[2,3,13,14],inclusive:!1},LINE:{rules:[2,3,26],inclusive:!1},INITIAL:{rules:[0,1,3,4,5,6,7,8,9,10,11,15,16,17,18,19,20,21,22,23,24,25,27,28,29,30,31,32,33,34,35,36,37,38,39,40,42,44,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67],inclusive:!0}}};return K}();ne.lexer=le;function he(){this.yy={}}return o(he,"Parser"),he.prototype=ne,ne.Parser=he,new he}();CO.parser=CO;lfe=CO});var iVe,aVe,sVe,_6,ufe=N(()=>{"use strict";zt();vt();s6();gr();mi();iVe={SOLID:0,DOTTED:1,NOTE:2,SOLID_CROSS:3,DOTTED_CROSS:4,SOLID_OPEN:5,DOTTED_OPEN:6,LOOP_START:10,LOOP_END:11,ALT_START:12,ALT_ELSE:13,ALT_END:14,OPT_START:15,OPT_END:16,ACTIVE_START:17,ACTIVE_END:18,PAR_START:19,PAR_AND:20,PAR_END:21,RECT_START:22,RECT_END:23,SOLID_POINT:24,DOTTED_POINT:25,AUTONUMBER:26,CRITICAL_START:27,CRITICAL_OPTION:28,CRITICAL_END:29,BREAK_START:30,BREAK_END:31,PAR_OVER_START:32,BIDIRECTIONAL_SOLID:33,BIDIRECTIONAL_DOTTED:34},aVe={FILLED:0,OPEN:1},sVe={LEFTOF:0,RIGHTOF:1,OVER:2},_6=class{constructor(){this.state=new pf(()=>({prevActor:void 0,actors:new Map,createdActors:new Map,destroyedActors:new Map,boxes:[],messages:[],notes:[],sequenceNumbersEnabled:!1,wrapEnabled:void 0,currentBox:void 0,lastCreated:void 0,lastDestroyed:void 0}));this.setAccTitle=Lr;this.setAccDescription=Nr;this.setDiagramTitle=$r;this.getAccTitle=Rr;this.getAccDescription=Mr;this.getDiagramTitle=Ir;this.apply=this.apply.bind(this),this.parseBoxData=this.parseBoxData.bind(this),this.parseMessage=this.parseMessage.bind(this),this.clear(),this.setWrap(me().wrap),this.LINETYPE=iVe,this.ARROWTYPE=aVe,this.PLACEMENT=sVe}static{o(this,"SequenceDB")}addBox(e){this.state.records.boxes.push({name:e.text,wrap:e.wrap??this.autoWrap(),fill:e.color,actorKeys:[]}),this.state.records.currentBox=this.state.records.boxes.slice(-1)[0]}addActor(e,r,n,i){let a=this.state.records.currentBox,s=this.state.records.actors.get(e);if(s){if(this.state.records.currentBox&&s.box&&this.state.records.currentBox!==s.box)throw new Error(`A same participant should only be defined in one Box: ${s.name} can't be in '${s.box.name}' and in '${this.state.records.currentBox.name}' at the same time.`);if(a=s.box?s.box:this.state.records.currentBox,s.box=a,s&&r===s.name&&n==null)return}if(n?.text==null&&(n={text:r,type:i}),(i==null||n.text==null)&&(n={text:r,type:i}),this.state.records.actors.set(e,{box:a,name:r,description:n.text,wrap:n.wrap??this.autoWrap(),prevActor:this.state.records.prevActor,links:{},properties:{},actorCnt:null,rectData:null,type:i??"participant"}),this.state.records.prevActor){let l=this.state.records.actors.get(this.state.records.prevActor);l&&(l.nextActor=e)}this.state.records.currentBox&&this.state.records.currentBox.actorKeys.push(e),this.state.records.prevActor=e}activationCount(e){let r,n=0;if(!e)return 0;for(r=0;r>-",token:"->>-",line:"1",loc:{first_line:1,last_line:1,first_column:1,last_column:1},expected:["'ACTIVE_PARTICIPANT'"]},l}return this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:e,to:r,message:n?.text??"",wrap:n?.wrap??this.autoWrap(),type:i,activate:a}),!0}hasAtLeastOneBox(){return this.state.records.boxes.length>0}hasAtLeastOneBoxWithTitle(){return this.state.records.boxes.some(e=>e.name)}getMessages(){return this.state.records.messages}getBoxes(){return this.state.records.boxes}getActors(){return this.state.records.actors}getCreatedActors(){return this.state.records.createdActors}getDestroyedActors(){return this.state.records.destroyedActors}getActor(e){return this.state.records.actors.get(e)}getActorKeys(){return[...this.state.records.actors.keys()]}enableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!0}disableSequenceNumbers(){this.state.records.sequenceNumbersEnabled=!1}showSequenceNumbers(){return this.state.records.sequenceNumbersEnabled}setWrap(e){this.state.records.wrapEnabled=e}extractWrap(e){if(e===void 0)return{};e=e.trim();let r=/^:?wrap:/.exec(e)!==null?!0:/^:?nowrap:/.exec(e)!==null?!1:void 0;return{cleanedText:(r===void 0?e:e.replace(/^:?(?:no)?wrap:/,"")).trim(),wrap:r}}autoWrap(){return this.state.records.wrapEnabled!==void 0?this.state.records.wrapEnabled:me().sequence?.wrap??!1}clear(){this.state.reset(),Ar()}parseMessage(e){let r=e.trim(),{wrap:n,cleanedText:i}=this.extractWrap(r),a={text:i,wrap:n};return Y.debug(`parseMessage: ${JSON.stringify(a)}`),a}parseBoxData(e){let r=/^((?:rgba?|hsla?)\s*\(.*\)|\w*)(.*)$/.exec(e),n=r?.[1]?r[1].trim():"transparent",i=r?.[2]?r[2].trim():void 0;if(window?.CSS)window.CSS.supports("color",n)||(n="transparent",i=e.trim());else{let l=new Option().style;l.color=n,l.color!==n&&(n="transparent",i=e.trim())}let{wrap:a,cleanedText:s}=this.extractWrap(i);return{text:s?Tr(s,me()):void 0,color:n,wrap:a}}addNote(e,r,n){let i={actor:e,placement:r,message:n.text,wrap:n.wrap??this.autoWrap()},a=[].concat(e,e);this.state.records.notes.push(i),this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:a[0],to:a[1],message:n.text,wrap:n.wrap??this.autoWrap(),type:this.LINETYPE.NOTE,placement:r})}addLinks(e,r){let n=this.getActor(e);try{let i=Tr(r.text,me());i=i.replace(/=/g,"="),i=i.replace(/&/g,"&");let a=JSON.parse(i);this.insertLinks(n,a)}catch(i){Y.error("error while parsing actor link text",i)}}addALink(e,r){let n=this.getActor(e);try{let i={},a=Tr(r.text,me()),s=a.indexOf("@");a=a.replace(/=/g,"="),a=a.replace(/&/g,"&");let l=a.slice(0,s-1).trim(),u=a.slice(s+1).trim();i[l]=u,this.insertLinks(n,i)}catch(i){Y.error("error while parsing actor link text",i)}}insertLinks(e,r){if(e.links==null)e.links=r;else for(let n in r)e.links[n]=r[n]}addProperties(e,r){let n=this.getActor(e);try{let i=Tr(r.text,me()),a=JSON.parse(i);this.insertProperties(n,a)}catch(i){Y.error("error while parsing actor properties text",i)}}insertProperties(e,r){if(e.properties==null)e.properties=r;else for(let n in r)e.properties[n]=r[n]}boxEnd(){this.state.records.currentBox=void 0}addDetails(e,r){let n=this.getActor(e),i=document.getElementById(r.text);try{let a=i.innerHTML,s=JSON.parse(a);s.properties&&this.insertProperties(n,s.properties),s.links&&this.insertLinks(n,s.links)}catch(a){Y.error("error while parsing actor details text",a)}}getActorProperty(e,r){if(e?.properties!==void 0)return e.properties[r]}apply(e){if(Array.isArray(e))e.forEach(r=>{this.apply(r)});else switch(e.type){case"sequenceIndex":this.state.records.messages.push({id:this.state.records.messages.length.toString(),from:void 0,to:void 0,message:{start:e.sequenceIndex,step:e.sequenceIndexStep,visible:e.sequenceVisible},wrap:!1,type:e.signalType});break;case"addParticipant":this.addActor(e.actor,e.actor,e.description,e.draw);break;case"createParticipant":if(this.state.records.actors.has(e.actor))throw new Error("It is not possible to have actors with the same id, even if one is destroyed before the next is created. Use 'AS' aliases to simulate the behavior");this.state.records.lastCreated=e.actor,this.addActor(e.actor,e.actor,e.description,e.draw),this.state.records.createdActors.set(e.actor,this.state.records.messages.length);break;case"destroyParticipant":this.state.records.lastDestroyed=e.actor,this.state.records.destroyedActors.set(e.actor,this.state.records.messages.length);break;case"activeStart":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"activeEnd":this.addSignal(e.actor,void 0,void 0,e.signalType);break;case"addNote":this.addNote(e.actor,e.placement,e.text);break;case"addLinks":this.addLinks(e.actor,e.text);break;case"addALink":this.addALink(e.actor,e.text);break;case"addProperties":this.addProperties(e.actor,e.text);break;case"addDetails":this.addDetails(e.actor,e.text);break;case"addMessage":if(this.state.records.lastCreated){if(e.to!==this.state.records.lastCreated)throw new Error("The created participant "+this.state.records.lastCreated.name+" does not have an associated creating message after its declaration. Please check the sequence diagram.");this.state.records.lastCreated=void 0}else if(this.state.records.lastDestroyed){if(e.to!==this.state.records.lastDestroyed&&e.from!==this.state.records.lastDestroyed)throw new Error("The destroyed participant "+this.state.records.lastDestroyed.name+" does not have an associated destroying message after its declaration. Please check the sequence diagram.");this.state.records.lastDestroyed=void 0}this.addSignal(e.from,e.to,e.msg,e.signalType,e.activate);break;case"boxStart":this.addBox(e.boxData);break;case"boxEnd":this.boxEnd();break;case"loopStart":this.addSignal(void 0,void 0,e.loopText,e.signalType);break;case"loopEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"rectStart":this.addSignal(void 0,void 0,e.color,e.signalType);break;case"rectEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"optStart":this.addSignal(void 0,void 0,e.optText,e.signalType);break;case"optEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"altStart":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"else":this.addSignal(void 0,void 0,e.altText,e.signalType);break;case"altEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"setAccTitle":Lr(e.text);break;case"parStart":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"and":this.addSignal(void 0,void 0,e.parText,e.signalType);break;case"parEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"criticalStart":this.addSignal(void 0,void 0,e.criticalText,e.signalType);break;case"option":this.addSignal(void 0,void 0,e.optionText,e.signalType);break;case"criticalEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break;case"breakStart":this.addSignal(void 0,void 0,e.breakText,e.signalType);break;case"breakEnd":this.addSignal(void 0,void 0,void 0,e.signalType);break}}getConfig(){return me().sequence}}});var oVe,hfe,ffe=N(()=>{"use strict";oVe=o(t=>`.actor { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + + text.actor > tspan { + fill: ${t.actorTextColor}; + stroke: none; + } + + .actor-line { + stroke: ${t.actorLineColor}; + } + + .messageLine0 { + stroke-width: 1.5; + stroke-dasharray: none; + stroke: ${t.signalColor}; + } + + .messageLine1 { + stroke-width: 1.5; + stroke-dasharray: 2, 2; + stroke: ${t.signalColor}; + } + + #arrowhead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .sequenceNumber { + fill: ${t.sequenceNumberColor}; + } + + #sequencenumber { + fill: ${t.signalColor}; + } + + #crosshead path { + fill: ${t.signalColor}; + stroke: ${t.signalColor}; + } + + .messageText { + fill: ${t.signalTextColor}; + stroke: none; + } + + .labelBox { + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBkgColor}; + } + + .labelText, .labelText > tspan { + fill: ${t.labelTextColor}; + stroke: none; + } + + .loopText, .loopText > tspan { + fill: ${t.loopTextColor}; + stroke: none; + } + + .loopLine { + stroke-width: 2px; + stroke-dasharray: 2, 2; + stroke: ${t.labelBoxBorderColor}; + fill: ${t.labelBoxBorderColor}; + } + + .note { + //stroke: #decc93; + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + } + + .noteText, .noteText > tspan { + fill: ${t.noteTextColor}; + stroke: none; + } + + .activation0 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation1 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .activation2 { + fill: ${t.activationBkgColor}; + stroke: ${t.activationBorderColor}; + } + + .actorPopupMenu { + position: absolute; + } + + .actorPopupMenuPanel { + position: absolute; + fill: ${t.actorBkg}; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + filter: drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4)); +} + .actor-man line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + } + .actor-man circle, line { + stroke: ${t.actorBorder}; + fill: ${t.actorBkg}; + stroke-width: 2px; + } +`,"getStyles"),hfe=oVe});var AO,vf,pfe,mfe,lVe,dfe,_O,cVe,uVe,Tb,_p,gfe,Uc,DO,hVe,fVe,dVe,pVe,mVe,gVe,yVe,yfe,vVe,xVe,bVe,wVe,TVe,kVe,EVe,vfe,SVe,LO,CVe,hi,xfe=N(()=>{"use strict";gr();Wv();ir();AO=Sa(z0(),1);ji();vf=18*2,pfe="actor-top",mfe="actor-bottom",lVe="actor-box",dfe="actor-man",_O=o(function(t,e){return kd(t,e)},"drawRect"),cVe=o(function(t,e,r,n,i){if(e.links===void 0||e.links===null||Object.keys(e.links).length===0)return{height:0,width:0};let a=e.links,s=e.actorCnt,l=e.rectData;var u="none";i&&(u="block !important");let h=t.append("g");h.attr("id","actor"+s+"_popup"),h.attr("class","actorPopupMenu"),h.attr("display",u);var f="";l.class!==void 0&&(f=" "+l.class);let d=l.width>r?l.width:r,p=h.append("rect");if(p.attr("class","actorPopupMenuPanel"+f),p.attr("x",l.x),p.attr("y",l.height),p.attr("fill",l.fill),p.attr("stroke",l.stroke),p.attr("width",d),p.attr("height",l.height),p.attr("rx",l.rx),p.attr("ry",l.ry),a!=null){var m=20;for(let v in a){var g=h.append("a"),y=(0,AO.sanitizeUrl)(a[v]);g.attr("xlink:href",y),g.attr("target","_blank"),CVe(n)(v,g,l.x+10,l.height+m,d,20,{class:"actor"},n),m+=30}}return p.attr("height",m),{height:l.height+m,width:d}},"drawPopup"),uVe=o(function(t){return"var pu = document.getElementById('"+t+"'); if (pu != null) { pu.style.display = pu.style.display == 'block' ? 'none' : 'block'; }"},"popupMenuToggle"),Tb=o(async function(t,e,r=null){let n=t.append("foreignObject"),i=await mh(e.text,cr()),s=n.append("xhtml:div").attr("style","width: fit-content;").attr("xmlns","http://www.w3.org/1999/xhtml").html(i).node().getBoundingClientRect();if(n.attr("height",Math.round(s.height)).attr("width",Math.round(s.width)),e.class==="noteText"){let l=t.node().firstChild;l.setAttribute("height",s.height+2*e.textMargin);let u=l.getBBox();n.attr("x",Math.round(u.x+u.width/2-s.width/2)).attr("y",Math.round(u.y+u.height/2-s.height/2))}else if(r){let{startx:l,stopx:u,starty:h}=r;if(l>u){let f=l;l=u,u=f}n.attr("x",Math.round(l+Math.abs(l-u)/2-s.width/2)),e.class==="loopText"?n.attr("y",Math.round(h)):n.attr("y",Math.round(h-s.height))}return[n]},"drawKatex"),_p=o(function(t,e){let r=0,n=0,i=e.text.split(Ze.lineBreakRegex),[a,s]=Bo(e.fontSize),l=[],u=0,h=o(()=>e.y,"yfunc");if(e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0)switch(e.valign){case"top":case"start":h=o(()=>Math.round(e.y+e.textMargin),"yfunc");break;case"middle":case"center":h=o(()=>Math.round(e.y+(r+n+e.textMargin)/2),"yfunc");break;case"bottom":case"end":h=o(()=>Math.round(e.y+(r+n+2*e.textMargin)-e.textMargin),"yfunc");break}if(e.anchor!==void 0&&e.textMargin!==void 0&&e.width!==void 0)switch(e.anchor){case"left":case"start":e.x=Math.round(e.x+e.textMargin),e.anchor="start",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"middle":case"center":e.x=Math.round(e.x+e.width/2),e.anchor="middle",e.dominantBaseline="middle",e.alignmentBaseline="middle";break;case"right":case"end":e.x=Math.round(e.x+e.width-e.textMargin),e.anchor="end",e.dominantBaseline="middle",e.alignmentBaseline="middle";break}for(let[f,d]of i.entries()){e.textMargin!==void 0&&e.textMargin===0&&a!==void 0&&(u=f*a);let p=t.append("text");p.attr("x",e.x),p.attr("y",h()),e.anchor!==void 0&&p.attr("text-anchor",e.anchor).attr("dominant-baseline",e.dominantBaseline).attr("alignment-baseline",e.alignmentBaseline),e.fontFamily!==void 0&&p.style("font-family",e.fontFamily),s!==void 0&&p.style("font-size",s),e.fontWeight!==void 0&&p.style("font-weight",e.fontWeight),e.fill!==void 0&&p.attr("fill",e.fill),e.class!==void 0&&p.attr("class",e.class),e.dy!==void 0?p.attr("dy",e.dy):u!==0&&p.attr("dy",u);let m=d||H9;if(e.tspan){let g=p.append("tspan");g.attr("x",e.x),e.fill!==void 0&&g.attr("fill",e.fill),g.text(m)}else p.text(m);e.valign!==void 0&&e.textMargin!==void 0&&e.textMargin>0&&(n+=(p._groups||p)[0][0].getBBox().height,r=n),l.push(p)}return l},"drawText"),gfe=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");return n.attr("points",r(e.x,e.y,e.width,e.height,7)),n.attr("class","labelBox"),e.y=e.y+e.height/2,_p(t,e),n},"drawLabel"),Uc=-1,DO=o((t,e,r,n)=>{t.select&&r.forEach(i=>{let a=e.get(i),s=t.select("#actor"+a.actorCnt);!n.mirrorActors&&a.stopy?s.attr("y2",a.stopy+a.height/2):n.mirrorActors&&s.attr("y2",a.stopy)})},"fixLifeLineHeights"),hVe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+e.height,l=t.append("g").lower();var u=l;n||(Uc++,Object.keys(e.links||{}).length&&!r.forceMenus&&u.attr("onclick",uVe(`actor${Uc}_popup`)).attr("cursor","pointer"),u.append("line").attr("id","actor"+Uc).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),u=l.append("g"),e.actorCnt=Uc,e.links!=null&&u.attr("id","root-"+Uc));let h=Tl();var f="actor";e.properties?.class?f=e.properties.class:h.fill="#eaeaea",n?f+=` ${mfe}`:f+=` ${pfe}`,h.x=e.x,h.y=i,h.width=e.width,h.height=e.height,h.class=f,h.rx=3,h.ry=3,h.name=e.name;let d=_O(u,h);if(e.rectData=h,e.properties?.icon){let m=e.properties.icon.trim();m.charAt(0)==="@"?Iq(u,h.x+h.width-20,h.y+10,m.substr(1)):Mq(u,h.x+h.width-20,h.y+10,m)}LO(r,pi(e.description))(e.description,u,h.x,h.y,h.width,h.height,{class:`actor ${lVe}`},r);let p=e.height;if(d.node){let m=d.node().getBBox();e.height=m.height,p=m.height}return p},"drawActorTypeParticipant"),fVe=o(function(t,e,r,n){let i=n?e.stopy:e.starty,a=e.x+e.width/2,s=i+80,l=t.append("g").lower();n||(Uc++,l.append("line").attr("id","actor"+Uc).attr("x1",a).attr("y1",s).attr("x2",a).attr("y2",2e3).attr("class","actor-line 200").attr("stroke-width","0.5px").attr("stroke","#999").attr("name",e.name),e.actorCnt=Uc);let u=t.append("g"),h=dfe;n?h+=` ${mfe}`:h+=` ${pfe}`,u.attr("class",h),u.attr("name",e.name);let f=Tl();f.x=e.x,f.y=i,f.fill="#eaeaea",f.width=e.width,f.height=e.height,f.class="actor",f.rx=3,f.ry=3,u.append("line").attr("id","actor-man-torso"+Uc).attr("x1",a).attr("y1",i+25).attr("x2",a).attr("y2",i+45),u.append("line").attr("id","actor-man-arms"+Uc).attr("x1",a-vf/2).attr("y1",i+33).attr("x2",a+vf/2).attr("y2",i+33),u.append("line").attr("x1",a-vf/2).attr("y1",i+60).attr("x2",a).attr("y2",i+45),u.append("line").attr("x1",a).attr("y1",i+45).attr("x2",a+vf/2-2).attr("y2",i+60);let d=u.append("circle");d.attr("cx",e.x+e.width/2),d.attr("cy",i+10),d.attr("r",15),d.attr("width",e.width),d.attr("height",e.height);let p=u.node().getBBox();return e.height=p.height,LO(r,pi(e.description))(e.description,u,f.x,f.y+35,f.width,f.height,{class:`actor ${dfe}`},r),e.height},"drawActorTypeActor"),dVe=o(async function(t,e,r,n){switch(e.type){case"actor":return await fVe(t,e,r,n);case"participant":return await hVe(t,e,r,n)}},"drawActor"),pVe=o(function(t,e,r){let i=t.append("g");yfe(i,e),e.name&&LO(r)(e.name,i,e.x,e.y+(e.textMaxHeight||0)/2,e.width,0,{class:"text"},r),i.lower()},"drawBox"),mVe=o(function(t){return t.append("g")},"anchorElement"),gVe=o(function(t,e,r,n,i){let a=Tl(),s=e.anchored;a.x=e.startx,a.y=e.starty,a.class="activation"+i%3,a.width=e.stopx-e.startx,a.height=r-e.starty,_O(s,a)},"drawActivation"),yVe=o(async function(t,e,r,n){let{boxMargin:i,boxTextMargin:a,labelBoxHeight:s,labelBoxWidth:l,messageFontFamily:u,messageFontSize:h,messageFontWeight:f}=n,d=t.append("g"),p=o(function(y,v,x,b){return d.append("line").attr("x1",y).attr("y1",v).attr("x2",x).attr("y2",b).attr("class","loopLine")},"drawLoopLine");p(e.startx,e.starty,e.stopx,e.starty),p(e.stopx,e.starty,e.stopx,e.stopy),p(e.startx,e.stopy,e.stopx,e.stopy),p(e.startx,e.starty,e.startx,e.stopy),e.sections!==void 0&&e.sections.forEach(function(y){p(e.startx,y.y,e.stopx,y.y).style("stroke-dasharray","3, 3")});let m=Hv();m.text=r,m.x=e.startx,m.y=e.starty,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.anchor="middle",m.valign="middle",m.tspan=!1,m.width=l||50,m.height=s||20,m.textMargin=a,m.class="labelText",gfe(d,m),m=vfe(),m.text=e.title,m.x=e.startx+l/2+(e.stopx-e.startx)/2,m.y=e.starty+i+a,m.anchor="middle",m.valign="middle",m.textMargin=a,m.class="loopText",m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=!0;let g=pi(m.text)?await Tb(d,m,e):_p(d,m);if(e.sectionTitles!==void 0){for(let[y,v]of Object.entries(e.sectionTitles))if(v.message){m.text=v.message,m.x=e.startx+(e.stopx-e.startx)/2,m.y=e.sections[y].y+i+a,m.class="loopText",m.anchor="middle",m.valign="middle",m.tspan=!1,m.fontFamily=u,m.fontSize=h,m.fontWeight=f,m.wrap=e.wrap,pi(m.text)?(e.starty=e.sections[y].y,await Tb(d,m,e)):_p(d,m);let x=Math.round(g.map(b=>(b._groups||b)[0][0].getBBox().height).reduce((b,w)=>b+w));e.sections[y].height+=x-(i+a)}}return e.height=Math.round(e.stopy-e.starty),d},"drawLoop"),yfe=o(function(t,e){q5(t,e)},"drawBackgroundRect"),vVe=o(function(t){t.append("defs").append("symbol").attr("id","database").attr("fill-rule","evenodd").attr("clip-rule","evenodd").append("path").attr("transform","scale(.5)").attr("d","M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z")},"insertDatabaseIcon"),xVe=o(function(t){t.append("defs").append("symbol").attr("id","computer").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z")},"insertComputerIcon"),bVe=o(function(t){t.append("defs").append("symbol").attr("id","clock").attr("width","24").attr("height","24").append("path").attr("transform","scale(.5)").attr("d","M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z")},"insertClockIcon"),wVe=o(function(t){t.append("defs").append("marker").attr("id","arrowhead").attr("refX",7.9).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto-start-reverse").append("path").attr("d","M -1 0 L 10 5 L 0 10 z")},"insertArrowHead"),TVe=o(function(t){t.append("defs").append("marker").attr("id","filled-head").attr("refX",15.5).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"insertArrowFilledHead"),kVe=o(function(t){t.append("defs").append("marker").attr("id","sequencenumber").attr("refX",15).attr("refY",15).attr("markerWidth",60).attr("markerHeight",40).attr("orient","auto").append("circle").attr("cx",15).attr("cy",15).attr("r",6)},"insertSequenceNumber"),EVe=o(function(t){t.append("defs").append("marker").attr("id","crosshead").attr("markerWidth",15).attr("markerHeight",8).attr("orient","auto").attr("refX",4).attr("refY",4.5).append("path").attr("fill","none").attr("stroke","#000000").style("stroke-dasharray","0, 0").attr("stroke-width","1pt").attr("d","M 1,2 L 6,7 M 6,2 L 1,7")},"insertArrowCrossHead"),vfe=o(function(){return{x:0,y:0,fill:void 0,anchor:void 0,style:"#666",width:void 0,height:void 0,textMargin:0,rx:0,ry:0,tspan:!0,valign:void 0}},"getTextObj"),SVe=o(function(){return{x:0,y:0,fill:"#EDF2AE",stroke:"#666",width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),LO=function(){function t(a,s,l,u,h,f,d){let p=s.append("text").attr("x",l+h/2).attr("y",u+f/2+5).style("text-anchor","middle").text(a);i(p,d)}o(t,"byText");function e(a,s,l,u,h,f,d,p){let{actorFontSize:m,actorFontFamily:g,actorFontWeight:y}=p,[v,x]=Bo(m),b=a.split(Ze.lineBreakRegex);for(let w=0;w{let s=Dp(Ne),l=a.actorKeys.reduce((f,d)=>f+=t.get(d).width+(t.get(d).margin||0),0);l-=2*Ne.boxTextMargin,a.wrap&&(a.name=Gt.wrapLabel(a.name,l-2*Ne.wrapPadding,s));let u=Gt.calculateTextDimensions(a.name,s);i=Ze.getMax(u.height,i);let h=Ze.getMax(l,u.width+2*Ne.wrapPadding);if(a.margin=Ne.boxTextMargin,la.textMaxHeight=i),Ze.getMax(n,Ne.height)}var Ne,rt,AVe,Dp,_1,RO,DVe,LVe,NO,wfe,Tfe,D6,bfe,NVe,IVe,PVe,BVe,FVe,kfe,Efe=N(()=>{"use strict";dr();xfe();vt();gr();Wv();zt();s0();ir();Ei();Ne={},rt={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],activations:[],models:{getHeight:o(function(){return Math.max.apply(null,this.actors.length===0?[0]:this.actors.map(t=>t.height||0))+(this.loops.length===0?0:this.loops.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.messages.length===0?0:this.messages.map(t=>t.height||0).reduce((t,e)=>t+e))+(this.notes.length===0?0:this.notes.map(t=>t.height||0).reduce((t,e)=>t+e))},"getHeight"),clear:o(function(){this.actors=[],this.boxes=[],this.loops=[],this.messages=[],this.notes=[]},"clear"),addBox:o(function(t){this.boxes.push(t)},"addBox"),addActor:o(function(t){this.actors.push(t)},"addActor"),addLoop:o(function(t){this.loops.push(t)},"addLoop"),addMessage:o(function(t){this.messages.push(t)},"addMessage"),addNote:o(function(t){this.notes.push(t)},"addNote"),lastActor:o(function(){return this.actors[this.actors.length-1]},"lastActor"),lastLoop:o(function(){return this.loops[this.loops.length-1]},"lastLoop"),lastMessage:o(function(){return this.messages[this.messages.length-1]},"lastMessage"),lastNote:o(function(){return this.notes[this.notes.length-1]},"lastNote"),actors:[],boxes:[],loops:[],messages:[],notes:[]},init:o(function(){this.sequenceItems=[],this.activations=[],this.models.clear(),this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0,Tfe(me())},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=this,a=0;function s(l){return o(function(h){a++;let f=i.sequenceItems.length-a+1;i.updateVal(h,"starty",e-f*Ne.boxMargin,Math.min),i.updateVal(h,"stopy",n+f*Ne.boxMargin,Math.max),i.updateVal(rt.data,"startx",t-f*Ne.boxMargin,Math.min),i.updateVal(rt.data,"stopx",r+f*Ne.boxMargin,Math.max),l!=="activation"&&(i.updateVal(h,"startx",t-f*Ne.boxMargin,Math.min),i.updateVal(h,"stopx",r+f*Ne.boxMargin,Math.max),i.updateVal(rt.data,"starty",e-f*Ne.boxMargin,Math.min),i.updateVal(rt.data,"stopy",n+f*Ne.boxMargin,Math.max))},"updateItemBounds")}o(s,"updateFn"),this.sequenceItems.forEach(s()),this.activations.forEach(s("activation"))},"updateBounds"),insert:o(function(t,e,r,n){let i=Ze.getMin(t,r),a=Ze.getMax(t,r),s=Ze.getMin(e,n),l=Ze.getMax(e,n);this.updateVal(rt.data,"startx",i,Math.min),this.updateVal(rt.data,"starty",s,Math.min),this.updateVal(rt.data,"stopx",a,Math.max),this.updateVal(rt.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),newActivation:o(function(t,e,r){let n=r.get(t.from),i=D6(t.from).length||0,a=n.x+n.width/2+(i-1)*Ne.activationWidth/2;this.activations.push({startx:a,starty:this.verticalPos+2,stopx:a+Ne.activationWidth,stopy:void 0,actor:t.from,anchored:hi.anchorElement(e)})},"newActivation"),endActivation:o(function(t){let e=this.activations.map(function(r){return r.actor}).lastIndexOf(t.from);return this.activations.splice(e,1)[0]},"endActivation"),createLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){return{startx:void 0,starty:this.verticalPos,stopx:void 0,stopy:void 0,title:t.message,wrap:t.wrap,width:t.width,height:0,fill:e}},"createLoop"),newLoop:o(function(t={message:void 0,wrap:!1,width:void 0},e){this.sequenceItems.push(this.createLoop(t,e))},"newLoop"),endLoop:o(function(){return this.sequenceItems.pop()},"endLoop"),isLoopOverlap:o(function(){return this.sequenceItems.length?this.sequenceItems[this.sequenceItems.length-1].overlap:!1},"isLoopOverlap"),addSectionToLoop:o(function(t){let e=this.sequenceItems.pop();e.sections=e.sections||[],e.sectionTitles=e.sectionTitles||[],e.sections.push({y:rt.getVerticalPos(),height:0}),e.sectionTitles.push(t),this.sequenceItems.push(e)},"addSectionToLoop"),saveVerticalPos:o(function(){this.isLoopOverlap()&&(this.savedVerticalPos=this.verticalPos)},"saveVerticalPos"),resetVerticalPos:o(function(){this.isLoopOverlap()&&(this.verticalPos=this.savedVerticalPos)},"resetVerticalPos"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=Ze.getMax(this.data.stopy,this.verticalPos)},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return{bounds:this.data,models:this.models}},"getBounds")},AVe=o(async function(t,e){rt.bumpVerticalPos(Ne.boxMargin),e.height=Ne.boxMargin,e.starty=rt.getVerticalPos();let r=Tl();r.x=e.startx,r.y=e.starty,r.width=e.width||Ne.width,r.class="note";let n=t.append("g"),i=hi.drawRect(n,r),a=Hv();a.x=e.startx,a.y=e.starty,a.width=r.width,a.dy="1em",a.text=e.message,a.class="noteText",a.fontFamily=Ne.noteFontFamily,a.fontSize=Ne.noteFontSize,a.fontWeight=Ne.noteFontWeight,a.anchor=Ne.noteAlign,a.textMargin=Ne.noteMargin,a.valign="center";let s=pi(a.text)?await Tb(n,a):_p(n,a),l=Math.round(s.map(u=>(u._groups||u)[0][0].getBBox().height).reduce((u,h)=>u+h));i.attr("height",l+2*Ne.noteMargin),e.height+=l+2*Ne.noteMargin,rt.bumpVerticalPos(l+2*Ne.noteMargin),e.stopy=e.starty+l+2*Ne.noteMargin,e.stopx=e.startx+r.width,rt.insert(e.startx,e.starty,e.stopx,e.stopy),rt.models.addNote(e)},"drawNote"),Dp=o(t=>({fontFamily:t.messageFontFamily,fontSize:t.messageFontSize,fontWeight:t.messageFontWeight}),"messageFont"),_1=o(t=>({fontFamily:t.noteFontFamily,fontSize:t.noteFontSize,fontWeight:t.noteFontWeight}),"noteFont"),RO=o(t=>({fontFamily:t.actorFontFamily,fontSize:t.actorFontSize,fontWeight:t.actorFontWeight}),"actorFont");o(_Ve,"boundMessage");DVe=o(async function(t,e,r,n){let{startx:i,stopx:a,starty:s,message:l,type:u,sequenceIndex:h,sequenceVisible:f}=e,d=Gt.calculateTextDimensions(l,Dp(Ne)),p=Hv();p.x=i,p.y=s+10,p.width=a-i,p.class="messageText",p.dy="1em",p.text=l,p.fontFamily=Ne.messageFontFamily,p.fontSize=Ne.messageFontSize,p.fontWeight=Ne.messageFontWeight,p.anchor=Ne.messageAlign,p.valign="center",p.textMargin=Ne.wrapPadding,p.tspan=!1,pi(p.text)?await Tb(t,p,{startx:i,stopx:a,starty:r}):_p(t,p);let m=d.width,g;i===a?Ne.rightAngles?g=t.append("path").attr("d",`M ${i},${r} H ${i+Ze.getMax(Ne.width/2,m/2)} V ${r+25} H ${i}`):g=t.append("path").attr("d","M "+i+","+r+" C "+(i+60)+","+(r-10)+" "+(i+60)+","+(r+30)+" "+i+","+(r+20)):(g=t.append("line"),g.attr("x1",i),g.attr("y1",r),g.attr("x2",a),g.attr("y2",r)),u===n.db.LINETYPE.DOTTED||u===n.db.LINETYPE.DOTTED_CROSS||u===n.db.LINETYPE.DOTTED_POINT||u===n.db.LINETYPE.DOTTED_OPEN||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED?(g.style("stroke-dasharray","3, 3"),g.attr("class","messageLine1")):g.attr("class","messageLine0");let y="";Ne.arrowMarkerAbsolute&&(y=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,y=y.replace(/\(/g,"\\("),y=y.replace(/\)/g,"\\)")),g.attr("stroke-width",2),g.attr("stroke","none"),g.style("fill","none"),(u===n.db.LINETYPE.SOLID||u===n.db.LINETYPE.DOTTED)&&g.attr("marker-end","url("+y+"#arrowhead)"),(u===n.db.LINETYPE.BIDIRECTIONAL_SOLID||u===n.db.LINETYPE.BIDIRECTIONAL_DOTTED)&&(g.attr("marker-start","url("+y+"#arrowhead)"),g.attr("marker-end","url("+y+"#arrowhead)")),(u===n.db.LINETYPE.SOLID_POINT||u===n.db.LINETYPE.DOTTED_POINT)&&g.attr("marker-end","url("+y+"#filled-head)"),(u===n.db.LINETYPE.SOLID_CROSS||u===n.db.LINETYPE.DOTTED_CROSS)&&g.attr("marker-end","url("+y+"#crosshead)"),(f||Ne.showSequenceNumbers)&&(g.attr("marker-start","url("+y+"#sequencenumber)"),t.append("text").attr("x",i).attr("y",r+4).attr("font-family","sans-serif").attr("font-size","12px").attr("text-anchor","middle").attr("class","sequenceNumber").text(h))},"drawMessage"),LVe=o(function(t,e,r,n,i,a,s){let l=0,u=0,h,f=0;for(let d of n){let p=e.get(d),m=p.box;h&&h!=m&&(s||rt.models.addBox(h),u+=Ne.boxMargin+h.margin),m&&m!=h&&(s||(m.x=l+u,m.y=i),u+=m.margin),p.width=p.width||Ne.width,p.height=Ze.getMax(p.height||Ne.height,Ne.height),p.margin=p.margin||Ne.actorMargin,f=Ze.getMax(f,p.height),r.get(p.name)&&(u+=p.width/2),p.x=l+u,p.starty=rt.getVerticalPos(),rt.insert(p.x,i,p.x+p.width,p.height),l+=p.width+u,p.box&&(p.box.width=l+m.margin-p.box.x),u=p.margin,h=p.box,rt.models.addActor(p)}h&&!s&&rt.models.addBox(h),rt.bumpVerticalPos(f)},"addActorRenderingData"),NO=o(async function(t,e,r,n){if(n){let i=0;rt.bumpVerticalPos(Ne.boxMargin*2);for(let a of r){let s=e.get(a);s.stopy||(s.stopy=rt.getVerticalPos());let l=await hi.drawActor(t,s,Ne,!0);i=Ze.getMax(i,l)}rt.bumpVerticalPos(i+Ne.boxMargin)}else for(let i of r){let a=e.get(i);await hi.drawActor(t,a,Ne,!1)}},"drawActors"),wfe=o(function(t,e,r,n){let i=0,a=0;for(let s of r){let l=e.get(s),u=IVe(l),h=hi.drawPopup(t,l,u,Ne,Ne.forceMenus,n);h.height>i&&(i=h.height),h.width+l.x>a&&(a=h.width+l.x)}return{maxHeight:i,maxWidth:a}},"drawActorsPopup"),Tfe=o(function(t){Gn(Ne,t),t.fontFamily&&(Ne.actorFontFamily=Ne.noteFontFamily=Ne.messageFontFamily=t.fontFamily),t.fontSize&&(Ne.actorFontSize=Ne.noteFontSize=Ne.messageFontSize=t.fontSize),t.fontWeight&&(Ne.actorFontWeight=Ne.noteFontWeight=Ne.messageFontWeight=t.fontWeight)},"setConf"),D6=o(function(t){return rt.activations.filter(function(e){return e.actor===t})},"actorActivations"),bfe=o(function(t,e){let r=e.get(t),n=D6(t),i=n.reduce(function(s,l){return Ze.getMin(s,l.startx)},r.x+r.width/2-1),a=n.reduce(function(s,l){return Ze.getMax(s,l.stopx)},r.x+r.width/2+1);return[i,a]},"activationBounds");o(Hc,"adjustLoopHeightForWrap");o(RVe,"adjustCreatedDestroyedData");NVe=o(async function(t,e,r,n){let{securityLevel:i,sequence:a}=me();Ne=a;let s;i==="sandbox"&&(s=Ge("#i"+e));let l=i==="sandbox"?Ge(s.nodes()[0].contentDocument.body):Ge("body"),u=i==="sandbox"?s.nodes()[0].contentDocument:document;rt.init(),Y.debug(n.db);let h=i==="sandbox"?l.select(`[id="${e}"]`):Ge(`[id="${e}"]`),f=n.db.getActors(),d=n.db.getCreatedActors(),p=n.db.getDestroyedActors(),m=n.db.getBoxes(),g=n.db.getActorKeys(),y=n.db.getMessages(),v=n.db.getDiagramTitle(),x=n.db.hasAtLeastOneBox(),b=n.db.hasAtLeastOneBoxWithTitle(),w=await MVe(f,y,n);if(Ne.height=await OVe(f,w,m),hi.insertComputerIcon(h),hi.insertDatabaseIcon(h),hi.insertClockIcon(h),x&&(rt.bumpVerticalPos(Ne.boxMargin),b&&rt.bumpVerticalPos(m[0].textMaxHeight)),Ne.hideUnusedParticipants===!0){let F=new Set;y.forEach(P=>{F.add(P.from),F.add(P.to)}),g=g.filter(P=>F.has(P))}LVe(h,f,d,g,0,y,!1);let C=await FVe(y,f,w,n);hi.insertArrowHead(h),hi.insertArrowCrossHead(h),hi.insertArrowFilledHead(h),hi.insertSequenceNumber(h);function T(F,P){let z=rt.endActivation(F);z.starty+18>P&&(z.starty=P-6,P+=12),hi.drawActivation(h,z,P,Ne,D6(F.from).length),rt.insert(z.startx,P-10,z.stopx,P)}o(T,"activeEnd");let E=1,A=1,S=[],_=[],I=0;for(let F of y){let P,z,$;switch(F.type){case n.db.LINETYPE.NOTE:rt.resetVerticalPos(),z=F.noteModel,await AVe(h,z);break;case n.db.LINETYPE.ACTIVE_START:rt.newActivation(F,h,f);break;case n.db.LINETYPE.ACTIVE_END:T(F,rt.getVerticalPos());break;case n.db.LINETYPE.LOOP_START:Hc(C,F,Ne.boxMargin,Ne.boxMargin+Ne.boxTextMargin,H=>rt.newLoop(H));break;case n.db.LINETYPE.LOOP_END:P=rt.endLoop(),await hi.drawLoop(h,P,"loop",Ne),rt.bumpVerticalPos(P.stopy-rt.getVerticalPos()),rt.models.addLoop(P);break;case n.db.LINETYPE.RECT_START:Hc(C,F,Ne.boxMargin,Ne.boxMargin,H=>rt.newLoop(void 0,H.message));break;case n.db.LINETYPE.RECT_END:P=rt.endLoop(),_.push(P),rt.models.addLoop(P),rt.bumpVerticalPos(P.stopy-rt.getVerticalPos());break;case n.db.LINETYPE.OPT_START:Hc(C,F,Ne.boxMargin,Ne.boxMargin+Ne.boxTextMargin,H=>rt.newLoop(H));break;case n.db.LINETYPE.OPT_END:P=rt.endLoop(),await hi.drawLoop(h,P,"opt",Ne),rt.bumpVerticalPos(P.stopy-rt.getVerticalPos()),rt.models.addLoop(P);break;case n.db.LINETYPE.ALT_START:Hc(C,F,Ne.boxMargin,Ne.boxMargin+Ne.boxTextMargin,H=>rt.newLoop(H));break;case n.db.LINETYPE.ALT_ELSE:Hc(C,F,Ne.boxMargin+Ne.boxTextMargin,Ne.boxMargin,H=>rt.addSectionToLoop(H));break;case n.db.LINETYPE.ALT_END:P=rt.endLoop(),await hi.drawLoop(h,P,"alt",Ne),rt.bumpVerticalPos(P.stopy-rt.getVerticalPos()),rt.models.addLoop(P);break;case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:Hc(C,F,Ne.boxMargin,Ne.boxMargin+Ne.boxTextMargin,H=>rt.newLoop(H)),rt.saveVerticalPos();break;case n.db.LINETYPE.PAR_AND:Hc(C,F,Ne.boxMargin+Ne.boxTextMargin,Ne.boxMargin,H=>rt.addSectionToLoop(H));break;case n.db.LINETYPE.PAR_END:P=rt.endLoop(),await hi.drawLoop(h,P,"par",Ne),rt.bumpVerticalPos(P.stopy-rt.getVerticalPos()),rt.models.addLoop(P);break;case n.db.LINETYPE.AUTONUMBER:E=F.message.start||E,A=F.message.step||A,F.message.visible?n.db.enableSequenceNumbers():n.db.disableSequenceNumbers();break;case n.db.LINETYPE.CRITICAL_START:Hc(C,F,Ne.boxMargin,Ne.boxMargin+Ne.boxTextMargin,H=>rt.newLoop(H));break;case n.db.LINETYPE.CRITICAL_OPTION:Hc(C,F,Ne.boxMargin+Ne.boxTextMargin,Ne.boxMargin,H=>rt.addSectionToLoop(H));break;case n.db.LINETYPE.CRITICAL_END:P=rt.endLoop(),await hi.drawLoop(h,P,"critical",Ne),rt.bumpVerticalPos(P.stopy-rt.getVerticalPos()),rt.models.addLoop(P);break;case n.db.LINETYPE.BREAK_START:Hc(C,F,Ne.boxMargin,Ne.boxMargin+Ne.boxTextMargin,H=>rt.newLoop(H));break;case n.db.LINETYPE.BREAK_END:P=rt.endLoop(),await hi.drawLoop(h,P,"break",Ne),rt.bumpVerticalPos(P.stopy-rt.getVerticalPos()),rt.models.addLoop(P);break;default:try{$=F.msgModel,$.starty=rt.getVerticalPos(),$.sequenceIndex=E,$.sequenceVisible=n.db.showSequenceNumbers();let H=await _Ve(h,$);RVe(F,$,H,I,f,d,p),S.push({messageModel:$,lineStartY:H}),rt.models.addMessage($)}catch(H){Y.error("error while drawing message",H)}}[n.db.LINETYPE.SOLID_OPEN,n.db.LINETYPE.DOTTED_OPEN,n.db.LINETYPE.SOLID,n.db.LINETYPE.DOTTED,n.db.LINETYPE.SOLID_CROSS,n.db.LINETYPE.DOTTED_CROSS,n.db.LINETYPE.SOLID_POINT,n.db.LINETYPE.DOTTED_POINT,n.db.LINETYPE.BIDIRECTIONAL_SOLID,n.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(F.type)&&(E=E+A),I++}Y.debug("createdActors",d),Y.debug("destroyedActors",p),await NO(h,f,g,!1);for(let F of S)await DVe(h,F.messageModel,F.lineStartY,n);Ne.mirrorActors&&await NO(h,f,g,!0),_.forEach(F=>hi.drawBackgroundRect(h,F)),DO(h,f,g,Ne);for(let F of rt.models.boxes)F.height=rt.getVerticalPos()-F.y,rt.insert(F.x,F.y,F.x+F.width,F.height),F.startx=F.x,F.starty=F.y,F.stopx=F.startx+F.width,F.stopy=F.starty+F.height,F.stroke="rgb(0,0,0, 0.5)",hi.drawBox(h,F,Ne);x&&rt.bumpVerticalPos(Ne.boxMargin);let D=wfe(h,f,g,u),{bounds:k}=rt.getBounds();k.startx===void 0&&(k.startx=0),k.starty===void 0&&(k.starty=0),k.stopx===void 0&&(k.stopx=0),k.stopy===void 0&&(k.stopy=0);let L=k.stopy-k.starty;L2,d=o(y=>l?-y:y,"adjustValue");t.from===t.to?h=u:(t.activate&&!f&&(h+=d(Ne.activationWidth/2-1)),[r.db.LINETYPE.SOLID_OPEN,r.db.LINETYPE.DOTTED_OPEN].includes(t.type)||(h+=d(3)),[r.db.LINETYPE.BIDIRECTIONAL_SOLID,r.db.LINETYPE.BIDIRECTIONAL_DOTTED].includes(t.type)&&(u-=d(3)));let p=[n,i,a,s],m=Math.abs(u-h);t.wrap&&t.message&&(t.message=Gt.wrapLabel(t.message,Ze.getMax(m+2*Ne.wrapPadding,Ne.width),Dp(Ne)));let g=Gt.calculateTextDimensions(t.message,Dp(Ne));return{width:Ze.getMax(t.wrap?0:g.width+2*Ne.wrapPadding,m+2*Ne.wrapPadding,Ne.width),height:0,startx:u,stopx:h,starty:0,stopy:0,message:t.message,type:t.type,wrap:t.wrap,fromBounds:Math.min.apply(null,p),toBounds:Math.max.apply(null,p)}},"buildMessageModel"),FVe=o(async function(t,e,r,n){let i={},a=[],s,l,u;for(let h of t){switch(h.type){case n.db.LINETYPE.LOOP_START:case n.db.LINETYPE.ALT_START:case n.db.LINETYPE.OPT_START:case n.db.LINETYPE.PAR_START:case n.db.LINETYPE.PAR_OVER_START:case n.db.LINETYPE.CRITICAL_START:case n.db.LINETYPE.BREAK_START:a.push({id:h.id,msg:h.message,from:Number.MAX_SAFE_INTEGER,to:Number.MIN_SAFE_INTEGER,width:0});break;case n.db.LINETYPE.ALT_ELSE:case n.db.LINETYPE.PAR_AND:case n.db.LINETYPE.CRITICAL_OPTION:h.message&&(s=a.pop(),i[s.id]=s,i[h.id]=s,a.push(s));break;case n.db.LINETYPE.LOOP_END:case n.db.LINETYPE.ALT_END:case n.db.LINETYPE.OPT_END:case n.db.LINETYPE.PAR_END:case n.db.LINETYPE.CRITICAL_END:case n.db.LINETYPE.BREAK_END:s=a.pop(),i[s.id]=s;break;case n.db.LINETYPE.ACTIVE_START:{let d=e.get(h.from?h.from:h.to.actor),p=D6(h.from?h.from:h.to.actor).length,m=d.x+d.width/2+(p-1)*Ne.activationWidth/2,g={startx:m,stopx:m+Ne.activationWidth,actor:h.from,enabled:!0};rt.activations.push(g)}break;case n.db.LINETYPE.ACTIVE_END:{let d=rt.activations.map(p=>p.actor).lastIndexOf(h.from);rt.activations.splice(d,1).splice(0,1)}break}h.placement!==void 0?(l=await PVe(h,e,n),h.noteModel=l,a.forEach(d=>{s=d,s.from=Ze.getMin(s.from,l.startx),s.to=Ze.getMax(s.to,l.startx+l.width),s.width=Ze.getMax(s.width,Math.abs(s.from-s.to))-Ne.labelBoxWidth})):(u=BVe(h,e,n),h.msgModel=u,u.startx&&u.stopx&&a.length>0&&a.forEach(d=>{if(s=d,u.startx===u.stopx){let p=e.get(h.from),m=e.get(h.to);s.from=Ze.getMin(p.x-u.width/2,p.x-p.width/2,s.from),s.to=Ze.getMax(m.x+u.width/2,m.x+p.width/2,s.to),s.width=Ze.getMax(s.width,Math.abs(s.to-s.from))-Ne.labelBoxWidth}else s.from=Ze.getMin(u.startx,s.from),s.to=Ze.getMax(u.stopx,s.to),s.width=Ze.getMax(s.width,u.width)-Ne.labelBoxWidth}))}return rt.activations=[],Y.debug("Loop type widths:",i),i},"calculateLoopBounds"),kfe={bounds:rt,drawActors:NO,drawActorsPopup:wfe,setConf:Tfe,draw:NVe}});var Sfe={};hr(Sfe,{diagram:()=>$Ve});var $Ve,Cfe=N(()=>{"use strict";cfe();ufe();ffe();zt();Efe();$Ve={parser:lfe,get db(){return new _6},renderer:kfe,styles:hfe,init:o(t=>{t.sequence||(t.sequence={}),t.wrap&&(t.sequence.wrap=t.wrap,Yy({sequence:{wrap:t.wrap}}))},"init")}});var MO,L6,IO=N(()=>{"use strict";MO=function(){var t=o(function(Ie,be,W,de){for(W=W||{},de=Ie.length;de--;W[Ie[de]]=be);return W},"o"),e=[1,18],r=[1,19],n=[1,20],i=[1,41],a=[1,42],s=[1,26],l=[1,24],u=[1,25],h=[1,32],f=[1,33],d=[1,34],p=[1,45],m=[1,35],g=[1,36],y=[1,37],v=[1,38],x=[1,27],b=[1,28],w=[1,29],C=[1,30],T=[1,31],E=[1,44],A=[1,46],S=[1,43],_=[1,47],I=[1,9],D=[1,8,9],k=[1,58],L=[1,59],R=[1,60],O=[1,61],M=[1,62],B=[1,63],F=[1,64],P=[1,8,9,41],z=[1,76],$=[1,8,9,12,13,22,39,41,44,66,67,68,69,70,71,72,77,79],H=[1,8,9,12,13,17,20,22,39,41,44,48,58,66,67,68,69,70,71,72,77,79,84,99,101,102],Q=[13,58,84,99,101,102],j=[13,58,71,72,84,99,101,102],ie=[13,58,66,67,68,69,70,84,99,101,102],ne=[1,98],le=[1,115],he=[1,107],K=[1,113],X=[1,108],te=[1,109],J=[1,110],se=[1,111],ue=[1,112],Z=[1,114],Se=[22,58,59,80,84,85,86,87,88,89],ce=[1,8,9,39,41,44],ae=[1,8,9,22],Oe=[1,143],ge=[1,8,9,59],ze=[1,8,9,22,58,59,80,84,85,86,87,88,89],He={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mermaidDoc:4,statements:5,graphConfig:6,CLASS_DIAGRAM:7,NEWLINE:8,EOF:9,statement:10,classLabel:11,SQS:12,STR:13,SQE:14,namespaceName:15,alphaNumToken:16,DOT:17,className:18,classLiteralName:19,GENERICTYPE:20,relationStatement:21,LABEL:22,namespaceStatement:23,classStatement:24,memberStatement:25,annotationStatement:26,clickStatement:27,styleStatement:28,cssClassStatement:29,noteStatement:30,classDefStatement:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,namespaceIdentifier:38,STRUCT_START:39,classStatements:40,STRUCT_STOP:41,NAMESPACE:42,classIdentifier:43,STYLE_SEPARATOR:44,members:45,CLASS:46,ANNOTATION_START:47,ANNOTATION_END:48,MEMBER:49,SEPARATOR:50,relation:51,NOTE_FOR:52,noteText:53,NOTE:54,CLASSDEF:55,classList:56,stylesOpt:57,ALPHA:58,COMMA:59,direction_tb:60,direction_bt:61,direction_rl:62,direction_lr:63,relationType:64,lineType:65,AGGREGATION:66,EXTENSION:67,COMPOSITION:68,DEPENDENCY:69,LOLLIPOP:70,LINE:71,DOTTED_LINE:72,CALLBACK:73,LINK:74,LINK_TARGET:75,CLICK:76,CALLBACK_NAME:77,CALLBACK_ARGS:78,HREF:79,STYLE:80,CSSCLASS:81,style:82,styleComponent:83,NUM:84,COLON:85,UNIT:86,SPACE:87,BRKT:88,PCT:89,commentToken:90,textToken:91,graphCodeTokens:92,textNoTagsToken:93,TAGSTART:94,TAGEND:95,"==":96,"--":97,DEFAULT:98,MINUS:99,keywords:100,UNICODE_TEXT:101,BQUOTE_STR:102,$accept:0,$end:1},terminals_:{2:"error",7:"CLASS_DIAGRAM",8:"NEWLINE",9:"EOF",12:"SQS",13:"STR",14:"SQE",17:"DOT",20:"GENERICTYPE",22:"LABEL",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",39:"STRUCT_START",41:"STRUCT_STOP",42:"NAMESPACE",44:"STYLE_SEPARATOR",46:"CLASS",47:"ANNOTATION_START",48:"ANNOTATION_END",49:"MEMBER",50:"SEPARATOR",52:"NOTE_FOR",54:"NOTE",55:"CLASSDEF",58:"ALPHA",59:"COMMA",60:"direction_tb",61:"direction_bt",62:"direction_rl",63:"direction_lr",66:"AGGREGATION",67:"EXTENSION",68:"COMPOSITION",69:"DEPENDENCY",70:"LOLLIPOP",71:"LINE",72:"DOTTED_LINE",73:"CALLBACK",74:"LINK",75:"LINK_TARGET",76:"CLICK",77:"CALLBACK_NAME",78:"CALLBACK_ARGS",79:"HREF",80:"STYLE",81:"CSSCLASS",84:"NUM",85:"COLON",86:"UNIT",87:"SPACE",88:"BRKT",89:"PCT",92:"graphCodeTokens",94:"TAGSTART",95:"TAGEND",96:"==",97:"--",98:"DEFAULT",99:"MINUS",100:"keywords",101:"UNICODE_TEXT",102:"BQUOTE_STR"},productions_:[0,[3,1],[3,1],[4,1],[6,4],[5,1],[5,2],[5,3],[11,3],[15,1],[15,3],[15,2],[18,1],[18,3],[18,1],[18,2],[18,2],[18,2],[10,1],[10,2],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,1],[10,2],[10,2],[10,1],[23,4],[23,5],[38,2],[40,1],[40,2],[40,3],[24,1],[24,3],[24,4],[24,6],[43,2],[43,3],[26,4],[45,1],[45,2],[25,1],[25,2],[25,1],[25,1],[21,3],[21,4],[21,4],[21,5],[30,3],[30,2],[31,3],[56,1],[56,3],[32,1],[32,1],[32,1],[32,1],[51,3],[51,2],[51,2],[51,1],[64,1],[64,1],[64,1],[64,1],[64,1],[65,1],[65,1],[27,3],[27,4],[27,3],[27,4],[27,4],[27,5],[27,3],[27,4],[27,4],[27,5],[27,4],[27,5],[27,5],[27,6],[28,3],[29,3],[57,1],[57,3],[82,1],[82,2],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[83,1],[90,1],[90,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[91,1],[93,1],[93,1],[93,1],[93,1],[16,1],[16,1],[16,1],[16,1],[19,1],[53,1]],performAction:o(function(be,W,de,re,oe,V,xe){var q=V.length-1;switch(oe){case 8:this.$=V[q-1];break;case 9:case 12:case 14:this.$=V[q];break;case 10:case 13:this.$=V[q-2]+"."+V[q];break;case 11:case 15:this.$=V[q-1]+V[q];break;case 16:case 17:this.$=V[q-1]+"~"+V[q]+"~";break;case 18:re.addRelation(V[q]);break;case 19:V[q-1].title=re.cleanupLabel(V[q]),re.addRelation(V[q-1]);break;case 30:this.$=V[q].trim(),re.setAccTitle(this.$);break;case 31:case 32:this.$=V[q].trim(),re.setAccDescription(this.$);break;case 33:re.addClassesToNamespace(V[q-3],V[q-1]);break;case 34:re.addClassesToNamespace(V[q-4],V[q-1]);break;case 35:this.$=V[q],re.addNamespace(V[q]);break;case 36:this.$=[V[q]];break;case 37:this.$=[V[q-1]];break;case 38:V[q].unshift(V[q-2]),this.$=V[q];break;case 40:re.setCssClass(V[q-2],V[q]);break;case 41:re.addMembers(V[q-3],V[q-1]);break;case 42:re.setCssClass(V[q-5],V[q-3]),re.addMembers(V[q-5],V[q-1]);break;case 43:this.$=V[q],re.addClass(V[q]);break;case 44:this.$=V[q-1],re.addClass(V[q-1]),re.setClassLabel(V[q-1],V[q]);break;case 45:re.addAnnotation(V[q],V[q-2]);break;case 46:case 59:this.$=[V[q]];break;case 47:V[q].push(V[q-1]),this.$=V[q];break;case 48:break;case 49:re.addMember(V[q-1],re.cleanupLabel(V[q]));break;case 50:break;case 51:break;case 52:this.$={id1:V[q-2],id2:V[q],relation:V[q-1],relationTitle1:"none",relationTitle2:"none"};break;case 53:this.$={id1:V[q-3],id2:V[q],relation:V[q-1],relationTitle1:V[q-2],relationTitle2:"none"};break;case 54:this.$={id1:V[q-3],id2:V[q],relation:V[q-2],relationTitle1:"none",relationTitle2:V[q-1]};break;case 55:this.$={id1:V[q-4],id2:V[q],relation:V[q-2],relationTitle1:V[q-3],relationTitle2:V[q-1]};break;case 56:re.addNote(V[q],V[q-1]);break;case 57:re.addNote(V[q]);break;case 58:this.$=V[q-2],re.defineClass(V[q-1],V[q]);break;case 60:this.$=V[q-2].concat([V[q]]);break;case 61:re.setDirection("TB");break;case 62:re.setDirection("BT");break;case 63:re.setDirection("RL");break;case 64:re.setDirection("LR");break;case 65:this.$={type1:V[q-2],type2:V[q],lineType:V[q-1]};break;case 66:this.$={type1:"none",type2:V[q],lineType:V[q-1]};break;case 67:this.$={type1:V[q-1],type2:"none",lineType:V[q]};break;case 68:this.$={type1:"none",type2:"none",lineType:V[q]};break;case 69:this.$=re.relationType.AGGREGATION;break;case 70:this.$=re.relationType.EXTENSION;break;case 71:this.$=re.relationType.COMPOSITION;break;case 72:this.$=re.relationType.DEPENDENCY;break;case 73:this.$=re.relationType.LOLLIPOP;break;case 74:this.$=re.lineType.LINE;break;case 75:this.$=re.lineType.DOTTED_LINE;break;case 76:case 82:this.$=V[q-2],re.setClickEvent(V[q-1],V[q]);break;case 77:case 83:this.$=V[q-3],re.setClickEvent(V[q-2],V[q-1]),re.setTooltip(V[q-2],V[q]);break;case 78:this.$=V[q-2],re.setLink(V[q-1],V[q]);break;case 79:this.$=V[q-3],re.setLink(V[q-2],V[q-1],V[q]);break;case 80:this.$=V[q-3],re.setLink(V[q-2],V[q-1]),re.setTooltip(V[q-2],V[q]);break;case 81:this.$=V[q-4],re.setLink(V[q-3],V[q-2],V[q]),re.setTooltip(V[q-3],V[q-1]);break;case 84:this.$=V[q-3],re.setClickEvent(V[q-2],V[q-1],V[q]);break;case 85:this.$=V[q-4],re.setClickEvent(V[q-3],V[q-2],V[q-1]),re.setTooltip(V[q-3],V[q]);break;case 86:this.$=V[q-3],re.setLink(V[q-2],V[q]);break;case 87:this.$=V[q-4],re.setLink(V[q-3],V[q-1],V[q]);break;case 88:this.$=V[q-4],re.setLink(V[q-3],V[q-1]),re.setTooltip(V[q-3],V[q]);break;case 89:this.$=V[q-5],re.setLink(V[q-4],V[q-2],V[q]),re.setTooltip(V[q-4],V[q-1]);break;case 90:this.$=V[q-2],re.setCssStyle(V[q-1],V[q]);break;case 91:re.setCssClass(V[q-1],V[q]);break;case 92:this.$=[V[q]];break;case 93:V[q-2].push(V[q]),this.$=V[q-2];break;case 95:this.$=V[q-1]+V[q];break}},"anonymous"),table:[{3:1,4:2,5:3,6:4,7:[1,6],10:5,16:39,18:21,19:40,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,47:s,49:l,50:u,52:h,54:f,55:d,58:p,60:m,61:g,62:y,63:v,73:x,74:b,76:w,80:C,81:T,84:E,99:A,101:S,102:_},{1:[3]},{1:[2,1]},{1:[2,2]},{1:[2,3]},t(I,[2,5],{8:[1,48]}),{8:[1,49]},t(D,[2,18],{22:[1,50]}),t(D,[2,20]),t(D,[2,21]),t(D,[2,22]),t(D,[2,23]),t(D,[2,24]),t(D,[2,25]),t(D,[2,26]),t(D,[2,27]),t(D,[2,28]),t(D,[2,29]),{34:[1,51]},{36:[1,52]},t(D,[2,32]),t(D,[2,48],{51:53,64:56,65:57,13:[1,54],22:[1,55],66:k,67:L,68:R,69:O,70:M,71:B,72:F}),{39:[1,65]},t(P,[2,39],{39:[1,67],44:[1,66]}),t(D,[2,50]),t(D,[2,51]),{16:68,58:p,84:E,99:A,101:S},{16:39,18:69,19:40,58:p,84:E,99:A,101:S,102:_},{16:39,18:70,19:40,58:p,84:E,99:A,101:S,102:_},{16:39,18:71,19:40,58:p,84:E,99:A,101:S,102:_},{58:[1,72]},{13:[1,73]},{16:39,18:74,19:40,58:p,84:E,99:A,101:S,102:_},{13:z,53:75},{56:77,58:[1,78]},t(D,[2,61]),t(D,[2,62]),t(D,[2,63]),t(D,[2,64]),t($,[2,12],{16:39,19:40,18:80,17:[1,79],20:[1,81],58:p,84:E,99:A,101:S,102:_}),t($,[2,14],{20:[1,82]}),{15:83,16:84,58:p,84:E,99:A,101:S},{16:39,18:85,19:40,58:p,84:E,99:A,101:S,102:_},t(H,[2,118]),t(H,[2,119]),t(H,[2,120]),t(H,[2,121]),t([1,8,9,12,13,20,22,39,41,44,66,67,68,69,70,71,72,77,79],[2,122]),t(I,[2,6],{10:5,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,18:21,38:22,43:23,16:39,19:40,5:86,33:e,35:r,37:n,42:i,46:a,47:s,49:l,50:u,52:h,54:f,55:d,58:p,60:m,61:g,62:y,63:v,73:x,74:b,76:w,80:C,81:T,84:E,99:A,101:S,102:_}),{5:87,10:5,16:39,18:21,19:40,21:7,23:8,24:9,25:10,26:11,27:12,28:13,29:14,30:15,31:16,32:17,33:e,35:r,37:n,38:22,42:i,43:23,46:a,47:s,49:l,50:u,52:h,54:f,55:d,58:p,60:m,61:g,62:y,63:v,73:x,74:b,76:w,80:C,81:T,84:E,99:A,101:S,102:_},t(D,[2,19]),t(D,[2,30]),t(D,[2,31]),{13:[1,89],16:39,18:88,19:40,58:p,84:E,99:A,101:S,102:_},{51:90,64:56,65:57,66:k,67:L,68:R,69:O,70:M,71:B,72:F},t(D,[2,49]),{65:91,71:B,72:F},t(Q,[2,68],{64:92,66:k,67:L,68:R,69:O,70:M}),t(j,[2,69]),t(j,[2,70]),t(j,[2,71]),t(j,[2,72]),t(j,[2,73]),t(ie,[2,74]),t(ie,[2,75]),{8:[1,94],24:95,40:93,43:23,46:a},{16:96,58:p,84:E,99:A,101:S},{45:97,49:ne},{48:[1,99]},{13:[1,100]},{13:[1,101]},{77:[1,102],79:[1,103]},{22:le,57:104,58:he,80:K,82:105,83:106,84:X,85:te,86:J,87:se,88:ue,89:Z},{58:[1,116]},{13:z,53:117},t(D,[2,57]),t(D,[2,123]),{22:le,57:118,58:he,59:[1,119],80:K,82:105,83:106,84:X,85:te,86:J,87:se,88:ue,89:Z},t(Se,[2,59]),{16:39,18:120,19:40,58:p,84:E,99:A,101:S,102:_},t($,[2,15]),t($,[2,16]),t($,[2,17]),{39:[2,35]},{15:122,16:84,17:[1,121],39:[2,9],58:p,84:E,99:A,101:S},t(ce,[2,43],{11:123,12:[1,124]}),t(I,[2,7]),{9:[1,125]},t(ae,[2,52]),{16:39,18:126,19:40,58:p,84:E,99:A,101:S,102:_},{13:[1,128],16:39,18:127,19:40,58:p,84:E,99:A,101:S,102:_},t(Q,[2,67],{64:129,66:k,67:L,68:R,69:O,70:M}),t(Q,[2,66]),{41:[1,130]},{24:95,40:131,43:23,46:a},{8:[1,132],41:[2,36]},t(P,[2,40],{39:[1,133]}),{41:[1,134]},{41:[2,46],45:135,49:ne},{16:39,18:136,19:40,58:p,84:E,99:A,101:S,102:_},t(D,[2,76],{13:[1,137]}),t(D,[2,78],{13:[1,139],75:[1,138]}),t(D,[2,82],{13:[1,140],78:[1,141]}),{13:[1,142]},t(D,[2,90],{59:Oe}),t(ge,[2,92],{83:144,22:le,58:he,80:K,84:X,85:te,86:J,87:se,88:ue,89:Z}),t(ze,[2,94]),t(ze,[2,96]),t(ze,[2,97]),t(ze,[2,98]),t(ze,[2,99]),t(ze,[2,100]),t(ze,[2,101]),t(ze,[2,102]),t(ze,[2,103]),t(ze,[2,104]),t(D,[2,91]),t(D,[2,56]),t(D,[2,58],{59:Oe}),{58:[1,145]},t($,[2,13]),{15:146,16:84,58:p,84:E,99:A,101:S},{39:[2,11]},t(ce,[2,44]),{13:[1,147]},{1:[2,4]},t(ae,[2,54]),t(ae,[2,53]),{16:39,18:148,19:40,58:p,84:E,99:A,101:S,102:_},t(Q,[2,65]),t(D,[2,33]),{41:[1,149]},{24:95,40:150,41:[2,37],43:23,46:a},{45:151,49:ne},t(P,[2,41]),{41:[2,47]},t(D,[2,45]),t(D,[2,77]),t(D,[2,79]),t(D,[2,80],{75:[1,152]}),t(D,[2,83]),t(D,[2,84],{13:[1,153]}),t(D,[2,86],{13:[1,155],75:[1,154]}),{22:le,58:he,80:K,82:156,83:106,84:X,85:te,86:J,87:se,88:ue,89:Z},t(ze,[2,95]),t(Se,[2,60]),{39:[2,10]},{14:[1,157]},t(ae,[2,55]),t(D,[2,34]),{41:[2,38]},{41:[1,158]},t(D,[2,81]),t(D,[2,85]),t(D,[2,87]),t(D,[2,88],{75:[1,159]}),t(ge,[2,93],{83:144,22:le,58:he,80:K,84:X,85:te,86:J,87:se,88:ue,89:Z}),t(ce,[2,8]),t(P,[2,42]),t(D,[2,89])],defaultActions:{2:[2,1],3:[2,2],4:[2,3],83:[2,35],122:[2,11],125:[2,4],135:[2,47],146:[2,10],150:[2,38]},parseError:o(function(be,W){if(W.recoverable)this.trace(be);else{var de=new Error(be);throw de.hash=W,de}},"parseError"),parse:o(function(be){var W=this,de=[0],re=[],oe=[null],V=[],xe=this.table,q="",pe=0,ve=0,Pe=0,_e=2,we=1,Ve=V.slice.call(arguments,1),De=Object.create(this.lexer),qe={yy:{}};for(var at in this.yy)Object.prototype.hasOwnProperty.call(this.yy,at)&&(qe.yy[at]=this.yy[at]);De.setInput(be,qe.yy),qe.yy.lexer=De,qe.yy.parser=this,typeof De.yylloc>"u"&&(De.yylloc={});var Rt=De.yylloc;V.push(Rt);var st=De.options&&De.options.ranges;typeof qe.yy.parseError=="function"?this.parseError=qe.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Ue(Tt){de.length=de.length-2*Tt,oe.length=oe.length-Tt,V.length=V.length-Tt}o(Ue,"popStack");function ct(){var Tt;return Tt=re.pop()||De.lex()||we,typeof Tt!="number"&&(Tt instanceof Array&&(re=Tt,Tt=re.pop()),Tt=W.symbols_[Tt]||Tt),Tt}o(ct,"lex");for(var We,ot,Yt,bt,Mt,xt,ut={},Et,ft,yt,nt;;){if(Yt=de[de.length-1],this.defaultActions[Yt]?bt=this.defaultActions[Yt]:((We===null||typeof We>"u")&&(We=ct()),bt=xe[Yt]&&xe[Yt][We]),typeof bt>"u"||!bt.length||!bt[0]){var dn="";nt=[];for(Et in xe[Yt])this.terminals_[Et]&&Et>_e&&nt.push("'"+this.terminals_[Et]+"'");De.showPosition?dn="Parse error on line "+(pe+1)+`: +`+De.showPosition()+` +Expecting `+nt.join(", ")+", got '"+(this.terminals_[We]||We)+"'":dn="Parse error on line "+(pe+1)+": Unexpected "+(We==we?"end of input":"'"+(this.terminals_[We]||We)+"'"),this.parseError(dn,{text:De.match,token:this.terminals_[We]||We,line:De.yylineno,loc:Rt,expected:nt})}if(bt[0]instanceof Array&&bt.length>1)throw new Error("Parse Error: multiple actions possible at state: "+Yt+", token: "+We);switch(bt[0]){case 1:de.push(We),oe.push(De.yytext),V.push(De.yylloc),de.push(bt[1]),We=null,ot?(We=ot,ot=null):(ve=De.yyleng,q=De.yytext,pe=De.yylineno,Rt=De.yylloc,Pe>0&&Pe--);break;case 2:if(ft=this.productions_[bt[1]][1],ut.$=oe[oe.length-ft],ut._$={first_line:V[V.length-(ft||1)].first_line,last_line:V[V.length-1].last_line,first_column:V[V.length-(ft||1)].first_column,last_column:V[V.length-1].last_column},st&&(ut._$.range=[V[V.length-(ft||1)].range[0],V[V.length-1].range[1]]),xt=this.performAction.apply(ut,[q,ve,pe,qe.yy,bt[1],oe,V].concat(Ve)),typeof xt<"u")return xt;ft&&(de=de.slice(0,-1*ft*2),oe=oe.slice(0,-1*ft),V=V.slice(0,-1*ft)),de.push(this.productions_[bt[1]][0]),oe.push(ut.$),V.push(ut._$),yt=xe[de[de.length-2]][de[de.length-1]],de.push(yt);break;case 3:return!0}}return!0},"parse")},$e=function(){var Ie={EOF:1,parseError:o(function(W,de){if(this.yy.parser)this.yy.parser.parseError(W,de);else throw new Error(W)},"parseError"),setInput:o(function(be,W){return this.yy=W||this.yy||{},this._input=be,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var be=this._input[0];this.yytext+=be,this.yyleng++,this.offset++,this.match+=be,this.matched+=be;var W=be.match(/(?:\r\n?|\n).*/g);return W?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),be},"input"),unput:o(function(be){var W=be.length,de=be.split(/(?:\r\n?|\n)/g);this._input=be+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-W),this.offset-=W;var re=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),de.length-1&&(this.yylineno-=de.length-1);var oe=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:de?(de.length===re.length?this.yylloc.first_column:0)+re[re.length-de.length].length-de[0].length:this.yylloc.first_column-W},this.options.ranges&&(this.yylloc.range=[oe[0],oe[0]+this.yyleng-W]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(be){this.unput(this.match.slice(be))},"less"),pastInput:o(function(){var be=this.matched.substr(0,this.matched.length-this.match.length);return(be.length>20?"...":"")+be.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var be=this.match;return be.length<20&&(be+=this._input.substr(0,20-be.length)),(be.substr(0,20)+(be.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var be=this.pastInput(),W=new Array(be.length+1).join("-");return be+this.upcomingInput()+` +`+W+"^"},"showPosition"),test_match:o(function(be,W){var de,re,oe;if(this.options.backtrack_lexer&&(oe={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(oe.yylloc.range=this.yylloc.range.slice(0))),re=be[0].match(/(?:\r\n?|\n).*/g),re&&(this.yylineno+=re.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:re?re[re.length-1].length-re[re.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+be[0].length},this.yytext+=be[0],this.match+=be[0],this.matches=be,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(be[0].length),this.matched+=be[0],de=this.performAction.call(this,this.yy,this,W,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),de)return de;if(this._backtrack){for(var V in oe)this[V]=oe[V];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var be,W,de,re;this._more||(this.yytext="",this.match="");for(var oe=this._currentRules(),V=0;VW[0].length)){if(W=de,re=V,this.options.backtrack_lexer){if(be=this.test_match(de,oe[V]),be!==!1)return be;if(this._backtrack){W=!1;continue}else return!1}else if(!this.options.flex)break}return W?(be=this.test_match(W,oe[re]),be!==!1?be:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var W=this.next();return W||this.lex()},"lex"),begin:o(function(W){this.conditionStack.push(W)},"begin"),popState:o(function(){var W=this.conditionStack.length-1;return W>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(W){return W=this.conditionStack.length-1-Math.abs(W||0),W>=0?this.conditionStack[W]:"INITIAL"},"topState"),pushState:o(function(W){this.begin(W)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(W,de,re,oe){var V=oe;switch(re){case 0:return 60;case 1:return 61;case 2:return 62;case 3:return 63;case 4:break;case 5:break;case 6:return this.begin("acc_title"),33;break;case 7:return this.popState(),"acc_title_value";break;case 8:return this.begin("acc_descr"),35;break;case 9:return this.popState(),"acc_descr_value";break;case 10:this.begin("acc_descr_multiline");break;case 11:this.popState();break;case 12:return"acc_descr_multiline_value";case 13:return 8;case 14:break;case 15:return 7;case 16:return 7;case 17:return"EDGE_STATE";case 18:this.begin("callback_name");break;case 19:this.popState();break;case 20:this.popState(),this.begin("callback_args");break;case 21:return 77;case 22:this.popState();break;case 23:return 78;case 24:this.popState();break;case 25:return"STR";case 26:this.begin("string");break;case 27:return 80;case 28:return 55;case 29:return this.begin("namespace"),42;break;case 30:return this.popState(),8;break;case 31:break;case 32:return this.begin("namespace-body"),39;break;case 33:return this.popState(),41;break;case 34:return"EOF_IN_STRUCT";case 35:return 8;case 36:break;case 37:return"EDGE_STATE";case 38:return this.begin("class"),46;break;case 39:return this.popState(),8;break;case 40:break;case 41:return this.popState(),this.popState(),41;break;case 42:return this.begin("class-body"),39;break;case 43:return this.popState(),41;break;case 44:return"EOF_IN_STRUCT";case 45:return"EDGE_STATE";case 46:return"OPEN_IN_STRUCT";case 47:break;case 48:return"MEMBER";case 49:return 81;case 50:return 73;case 51:return 74;case 52:return 76;case 53:return 52;case 54:return 54;case 55:return 47;case 56:return 48;case 57:return 79;case 58:this.popState();break;case 59:return"GENERICTYPE";case 60:this.begin("generic");break;case 61:this.popState();break;case 62:return"BQUOTE_STR";case 63:this.begin("bqstring");break;case 64:return 75;case 65:return 75;case 66:return 75;case 67:return 75;case 68:return 67;case 69:return 67;case 70:return 69;case 71:return 69;case 72:return 68;case 73:return 66;case 74:return 70;case 75:return 71;case 76:return 72;case 77:return 22;case 78:return 44;case 79:return 99;case 80:return 17;case 81:return"PLUS";case 82:return 85;case 83:return 59;case 84:return 88;case 85:return 88;case 86:return 89;case 87:return"EQUALS";case 88:return"EQUALS";case 89:return 58;case 90:return 12;case 91:return 14;case 92:return"PUNCTUATION";case 93:return 84;case 94:return 101;case 95:return 87;case 96:return 87;case 97:return 9}},"anonymous"),rules:[/^(?:.*direction\s+TB[^\n]*)/,/^(?:.*direction\s+BT[^\n]*)/,/^(?:.*direction\s+RL[^\n]*)/,/^(?:.*direction\s+LR[^\n]*)/,/^(?:%%(?!\{)*[^\n]*(\r?\n?)+)/,/^(?:%%[^\n]*(\r?\n)*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:classDiagram-v2\b)/,/^(?:classDiagram\b)/,/^(?:\[\*\])/,/^(?:call[\s]+)/,/^(?:\([\s]*\))/,/^(?:\()/,/^(?:[^(]*)/,/^(?:\))/,/^(?:[^)]*)/,/^(?:["])/,/^(?:[^"]*)/,/^(?:["])/,/^(?:style\b)/,/^(?:classDef\b)/,/^(?:namespace\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:\[\*\])/,/^(?:class\b)/,/^(?:\s*(\r?\n)+)/,/^(?:\s+)/,/^(?:[}])/,/^(?:[{])/,/^(?:[}])/,/^(?:$)/,/^(?:\[\*\])/,/^(?:[{])/,/^(?:[\n])/,/^(?:[^{}\n]*)/,/^(?:cssClass\b)/,/^(?:callback\b)/,/^(?:link\b)/,/^(?:click\b)/,/^(?:note for\b)/,/^(?:note\b)/,/^(?:<<)/,/^(?:>>)/,/^(?:href\b)/,/^(?:[~])/,/^(?:[^~]*)/,/^(?:~)/,/^(?:[`])/,/^(?:[^`]+)/,/^(?:[`])/,/^(?:_self\b)/,/^(?:_blank\b)/,/^(?:_parent\b)/,/^(?:_top\b)/,/^(?:\s*<\|)/,/^(?:\s*\|>)/,/^(?:\s*>)/,/^(?:\s*<)/,/^(?:\s*\*)/,/^(?:\s*o\b)/,/^(?:\s*\(\))/,/^(?:--)/,/^(?:\.\.)/,/^(?::{1}[^:\n;]+)/,/^(?::{3})/,/^(?:-)/,/^(?:\.)/,/^(?:\+)/,/^(?::)/,/^(?:,)/,/^(?:#)/,/^(?:#)/,/^(?:%)/,/^(?:=)/,/^(?:=)/,/^(?:\w+)/,/^(?:\[)/,/^(?:\])/,/^(?:[!"#$%&'*+,-.`?\\/])/,/^(?:[0-9]+)/,/^(?:[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|[\uFFD2-\uFFD7\uFFDA-\uFFDC])/,/^(?:\s)/,/^(?:\s)/,/^(?:$)/],conditions:{"namespace-body":{rules:[26,33,34,35,36,37,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},namespace:{rules:[26,29,30,31,32,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},"class-body":{rules:[26,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},class:{rules:[26,39,40,41,42,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr_multiline:{rules:[11,12,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_descr:{rules:[9,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},acc_title:{rules:[7,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_args:{rules:[22,23,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},callback_name:{rules:[19,20,21,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},href:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},struct:{rules:[26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},generic:{rules:[26,49,50,51,52,53,54,55,56,57,58,59,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},bqstring:{rules:[26,49,50,51,52,53,54,55,56,57,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},string:{rules:[24,25,26,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,86,87,88,89,90,91,92,93,94,95,97],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,8,10,13,14,15,16,17,18,26,27,28,29,38,49,50,51,52,53,54,55,56,57,60,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],inclusive:!0}}};return Ie}();He.lexer=$e;function Re(){this.yy={}}return o(Re,"Parser"),Re.prototype=He,He.Parser=Re,new Re}();MO.parser=MO;L6=MO});var Dfe,kb,Lfe=N(()=>{"use strict";zt();gr();Dfe=["#","+","~","-",""],kb=class{static{o(this,"ClassMember")}constructor(e,r){this.memberType=r,this.visibility="",this.classifier="",this.text="";let n=Tr(e,me());this.parseMember(n)}getDisplayDetails(){let e=this.visibility+ec(this.id);this.memberType==="method"&&(e+=`(${ec(this.parameters.trim())})`,this.returnType&&(e+=" : "+ec(this.returnType))),e=e.trim();let r=this.parseClassifier();return{displayText:e,cssStyle:r}}parseMember(e){let r="";if(this.memberType==="method"){let a=/([#+~-])?(.+)\((.*)\)([\s$*])?(.*)([$*])?/.exec(e);if(a){let s=a[1]?a[1].trim():"";if(Dfe.includes(s)&&(this.visibility=s),this.id=a[2],this.parameters=a[3]?a[3].trim():"",r=a[4]?a[4].trim():"",this.returnType=a[5]?a[5].trim():"",r===""){let l=this.returnType.substring(this.returnType.length-1);/[$*]/.exec(l)&&(r=l,this.returnType=this.returnType.substring(0,this.returnType.length-1))}}}else{let i=e.length,a=e.substring(0,1),s=e.substring(i-1);Dfe.includes(a)&&(this.visibility=a),/[$*]/.exec(s)&&(r=s),this.id=e.substring(this.visibility===""?0:1,r===""?i:i-1)}this.classifier=r,this.id=this.id.startsWith(" ")?" "+this.id.trim():this.id.trim();let n=`${this.visibility?"\\"+this.visibility:""}${ec(this.id)}${this.memberType==="method"?`(${ec(this.parameters)})${this.returnType?" : "+ec(this.returnType):""}`:""}`;this.text=n.replaceAll("<","<").replaceAll(">",">"),this.text.startsWith("\\<")&&(this.text=this.text.replace("\\<","~"))}parseClassifier(){switch(this.classifier){case"*":return"font-style:italic;";case"$":return"text-decoration:underline;";default:return""}}}});var R6,Rfe,Lp,D1,OO=N(()=>{"use strict";dr();vt();zt();gr();ir();mi();Lfe();R6="classId-",Rfe=0,Lp=o(t=>Ze.sanitizeText(t,me()),"sanitizeText"),D1=class{constructor(){this.relations=[];this.classes=new Map;this.styleClasses=new Map;this.notes=[];this.interfaces=[];this.namespaces=new Map;this.namespaceCounter=0;this.functions=[];this.lineType={LINE:0,DOTTED_LINE:1};this.relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3,LOLLIPOP:4};this.setupToolTips=o(e=>{let r=Ge(".mermaidTooltip");(r._groups||r)[0][0]===null&&(r=Ge("body").append("div").attr("class","mermaidTooltip").style("opacity",0)),Ge(e).select("svg").selectAll("g.node").on("mouseover",a=>{let s=Ge(a.currentTarget);if(s.attr("title")===null)return;let u=this.getBoundingClientRect();r.transition().duration(200).style("opacity",".9"),r.text(s.attr("title")).style("left",window.scrollX+u.left+(u.right-u.left)/2+"px").style("top",window.scrollY+u.top-14+document.body.scrollTop+"px"),r.html(r.html().replace(/<br\/>/g,"
    ")),s.classed("hover",!0)}).on("mouseout",a=>{r.transition().duration(500).style("opacity",0),Ge(a.currentTarget).classed("hover",!1)})},"setupToolTips");this.direction="TB";this.setAccTitle=Lr;this.getAccTitle=Rr;this.setAccDescription=Nr;this.getAccDescription=Mr;this.setDiagramTitle=$r;this.getDiagramTitle=Ir;this.getConfig=o(()=>me().class,"getConfig");this.functions.push(this.setupToolTips.bind(this)),this.clear(),this.addRelation=this.addRelation.bind(this),this.addClassesToNamespace=this.addClassesToNamespace.bind(this),this.addNamespace=this.addNamespace.bind(this),this.setCssClass=this.setCssClass.bind(this),this.addMembers=this.addMembers.bind(this),this.addClass=this.addClass.bind(this),this.setClassLabel=this.setClassLabel.bind(this),this.addAnnotation=this.addAnnotation.bind(this),this.addMember=this.addMember.bind(this),this.cleanupLabel=this.cleanupLabel.bind(this),this.addNote=this.addNote.bind(this),this.defineClass=this.defineClass.bind(this),this.setDirection=this.setDirection.bind(this),this.setLink=this.setLink.bind(this),this.bindFunctions=this.bindFunctions.bind(this),this.clear=this.clear.bind(this),this.setTooltip=this.setTooltip.bind(this),this.setClickEvent=this.setClickEvent.bind(this),this.setCssStyle=this.setCssStyle.bind(this)}static{o(this,"ClassDB")}splitClassNameAndType(e){let r=Ze.sanitizeText(e,me()),n="",i=r;if(r.indexOf("~")>0){let a=r.split("~");i=Lp(a[0]),n=Lp(a[1])}return{className:i,type:n}}setClassLabel(e,r){let n=Ze.sanitizeText(e,me());r&&(r=Lp(r));let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).label=r,this.classes.get(i).text=`${r}${this.classes.get(i).type?`<${this.classes.get(i).type}>`:""}`}addClass(e){let r=Ze.sanitizeText(e,me()),{className:n,type:i}=this.splitClassNameAndType(r);if(this.classes.has(n))return;let a=Ze.sanitizeText(n,me());this.classes.set(a,{id:a,type:i,label:a,text:`${a}${i?`<${i}>`:""}`,shape:"classBox",cssClasses:"default",methods:[],members:[],annotations:[],styles:[],domId:R6+a+"-"+Rfe}),Rfe++}addInterface(e,r){let n={id:`interface${this.interfaces.length}`,label:e,classId:r};this.interfaces.push(n)}lookUpDomId(e){let r=Ze.sanitizeText(e,me());if(this.classes.has(r))return this.classes.get(r).domId;throw new Error("Class not found: "+r)}clear(){this.relations=[],this.classes=new Map,this.notes=[],this.interfaces=[],this.functions=[],this.functions.push(this.setupToolTips.bind(this)),this.namespaces=new Map,this.namespaceCounter=0,this.direction="TB",Ar()}getClass(e){return this.classes.get(e)}getClasses(){return this.classes}getRelations(){return this.relations}getNotes(){return this.notes}addRelation(e){Y.debug("Adding relation: "+JSON.stringify(e));let r=[this.relationType.LOLLIPOP,this.relationType.AGGREGATION,this.relationType.COMPOSITION,this.relationType.DEPENDENCY,this.relationType.EXTENSION];e.relation.type1===this.relationType.LOLLIPOP&&!r.includes(e.relation.type2)?(this.addClass(e.id2),this.addInterface(e.id1,e.id2),e.id1=`interface${this.interfaces.length-1}`):e.relation.type2===this.relationType.LOLLIPOP&&!r.includes(e.relation.type1)?(this.addClass(e.id1),this.addInterface(e.id2,e.id1),e.id2=`interface${this.interfaces.length-1}`):(this.addClass(e.id1),this.addClass(e.id2)),e.id1=this.splitClassNameAndType(e.id1).className,e.id2=this.splitClassNameAndType(e.id2).className,e.relationTitle1=Ze.sanitizeText(e.relationTitle1.trim(),me()),e.relationTitle2=Ze.sanitizeText(e.relationTitle2.trim(),me()),this.relations.push(e)}addAnnotation(e,r){let n=this.splitClassNameAndType(e).className;this.classes.get(n).annotations.push(r)}addMember(e,r){this.addClass(e);let n=this.splitClassNameAndType(e).className,i=this.classes.get(n);if(typeof r=="string"){let a=r.trim();a.startsWith("<<")&&a.endsWith(">>")?i.annotations.push(Lp(a.substring(2,a.length-2))):a.indexOf(")")>0?i.methods.push(new kb(a,"method")):a&&i.members.push(new kb(a,"attribute"))}}addMembers(e,r){Array.isArray(r)&&(r.reverse(),r.forEach(n=>this.addMember(e,n)))}addNote(e,r){let n={id:`note${this.notes.length}`,class:r,text:e};this.notes.push(n)}cleanupLabel(e){return e.startsWith(":")&&(e=e.substring(1)),Lp(e.trim())}setCssClass(e,r){e.split(",").forEach(n=>{let i=n;/\d/.exec(n[0])&&(i=R6+i);let a=this.classes.get(i);a&&(a.cssClasses+=" "+r)})}defineClass(e,r){for(let n of e){let i=this.styleClasses.get(n);i===void 0&&(i={id:n,styles:[],textStyles:[]},this.styleClasses.set(n,i)),r&&r.forEach(a=>{if(/color/.exec(a)){let s=a.replace("fill","bgFill");i.textStyles.push(s)}i.styles.push(a)}),this.classes.forEach(a=>{a.cssClasses.includes(n)&&a.styles.push(...r.flatMap(s=>s.split(",")))})}}setTooltip(e,r){e.split(",").forEach(n=>{r!==void 0&&(this.classes.get(n).tooltip=Lp(r))})}getTooltip(e,r){return r&&this.namespaces.has(r)?this.namespaces.get(r).classes.get(e).tooltip:this.classes.get(e).tooltip}setLink(e,r,n){let i=me();e.split(",").forEach(a=>{let s=a;/\d/.exec(a[0])&&(s=R6+s);let l=this.classes.get(s);l&&(l.link=Gt.formatUrl(r,i),i.securityLevel==="sandbox"?l.linkTarget="_top":typeof n=="string"?l.linkTarget=Lp(n):l.linkTarget="_blank")}),this.setCssClass(e,"clickable")}setClickEvent(e,r,n){e.split(",").forEach(i=>{this.setClickFunc(i,r,n),this.classes.get(i).haveCallback=!0}),this.setCssClass(e,"clickable")}setClickFunc(e,r,n){let i=Ze.sanitizeText(e,me());if(me().securityLevel!=="loose"||r===void 0)return;let s=i;if(this.classes.has(s)){let l=this.lookUpDomId(s),u=[];if(typeof n=="string"){u=n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);for(let h=0;h{let h=document.querySelector(`[id="${l}"]`);h!==null&&h.addEventListener("click",()=>{Gt.runFunc(r,...u)},!1)})}}bindFunctions(e){this.functions.forEach(r=>{r(e)})}getDirection(){return this.direction}setDirection(e){this.direction=e}addNamespace(e){this.namespaces.has(e)||(this.namespaces.set(e,{id:e,classes:new Map,children:{},domId:R6+e+"-"+this.namespaceCounter}),this.namespaceCounter++)}getNamespace(e){return this.namespaces.get(e)}getNamespaces(){return this.namespaces}addClassesToNamespace(e,r){if(this.namespaces.has(e))for(let n of r){let{className:i}=this.splitClassNameAndType(n);this.classes.get(i).parent=e,this.namespaces.get(e).classes.set(i,this.classes.get(i))}}setCssStyle(e,r){let n=this.classes.get(e);if(!(!r||!n))for(let i of r)i.includes(",")?n.styles.push(...i.split(",")):n.styles.push(i)}getArrowMarker(e){let r;switch(e){case 0:r="aggregation";break;case 1:r="extension";break;case 2:r="composition";break;case 3:r="dependency";break;case 4:r="lollipop";break;default:r="none"}return r}getData(){let e=[],r=[],n=me();for(let a of this.namespaces.keys()){let s=this.namespaces.get(a);if(s){let l={id:s.id,label:s.id,isGroup:!0,padding:n.class.padding??16,shape:"rect",cssStyles:["fill: none","stroke: black"],look:n.look};e.push(l)}}for(let a of this.classes.keys()){let s=this.classes.get(a);if(s){let l=s;l.parentId=s.parent,l.look=n.look,e.push(l)}}let i=0;for(let a of this.notes){i++;let s={id:a.id,label:a.text,isGroup:!1,shape:"note",padding:n.class.padding??6,cssStyles:["text-align: left","white-space: nowrap",`fill: ${n.themeVariables.noteBkgColor}`,`stroke: ${n.themeVariables.noteBorderColor}`],look:n.look};e.push(s);let l=this.classes.get(a.class)?.id??"";if(l){let u={id:`edgeNote${i}`,start:a.id,end:l,type:"normal",thickness:"normal",classes:"relation",arrowTypeStart:"none",arrowTypeEnd:"none",arrowheadStyle:"",labelStyle:[""],style:["fill: none"],pattern:"dotted",look:n.look};r.push(u)}}for(let a of this.interfaces){let s={id:a.id,label:a.label,isGroup:!1,shape:"rect",cssStyles:["opacity: 0;"],look:n.look};e.push(s)}i=0;for(let a of this.relations){i++;let s={id:$h(a.id1,a.id2,{prefix:"id",counter:i}),start:a.id1,end:a.id2,type:"normal",label:a.title,labelpos:"c",thickness:"normal",classes:"relation",arrowTypeStart:this.getArrowMarker(a.relation.type1),arrowTypeEnd:this.getArrowMarker(a.relation.type2),startLabelRight:a.relationTitle1==="none"?"":a.relationTitle1,endLabelLeft:a.relationTitle2==="none"?"":a.relationTitle2,arrowheadStyle:"",labelStyle:["display: inline-block"],style:a.style||"",pattern:a.relation.lineType==1?"dashed":"solid",look:n.look};r.push(s)}return{nodes:e,edges:r,other:{},config:n,direction:this.getDirection()}}}});var UVe,N6,PO=N(()=>{"use strict";UVe=o(t=>`g.classGroup text { + fill: ${t.nodeBorder||t.classText}; + stroke: none; + font-family: ${t.fontFamily}; + font-size: 10px; + + .title { + font-weight: bolder; + } + +} + +.nodeLabel, .edgeLabel { + color: ${t.classText}; +} +.edgeLabel .label rect { + fill: ${t.mainBkg}; +} +.label text { + fill: ${t.classText}; +} + +.labelBkg { + background: ${t.mainBkg}; +} +.edgeLabel .label span { + background: ${t.mainBkg}; +} + +.classTitle { + font-weight: bolder; +} +.node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + +.divider { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +g.clickable { + cursor: pointer; +} + +g.classGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.classGroup line { + stroke: ${t.nodeBorder}; + stroke-width: 1; +} + +.classLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.classLabel .label { + fill: ${t.nodeBorder}; + font-size: 10px; +} + +.relation { + stroke: ${t.lineColor}; + stroke-width: 1; + fill: none; +} + +.dashed-line{ + stroke-dasharray: 3; +} + +.dotted-line{ + stroke-dasharray: 1 2; +} + +#compositionStart, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#compositionEnd, .composition { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#dependencyStart, .dependency { + fill: ${t.lineColor} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionStart, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#extensionEnd, .extension { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationStart, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#aggregationEnd, .aggregation { + fill: transparent !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopStart, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +#lollipopEnd, .lollipop { + fill: ${t.mainBkg} !important; + stroke: ${t.lineColor} !important; + stroke-width: 1; +} + +.edgeTerminals { + font-size: 11px; + line-height: initial; +} + +.classTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} +`,"getStyles"),N6=UVe});var HVe,WVe,qVe,M6,BO=N(()=>{"use strict";zt();vt();gm();Yd();$m();ir();HVe=o((t,e="TB")=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),WVe=o(function(t,e){return e.db.getClasses()},"getClasses"),qVe=o(async function(t,e,r,n){Y.info("REF0:"),Y.info("Drawing class diagram (v3)",e);let{securityLevel:i,state:a,layout:s}=me(),l=n.db.getData(),u=yc(e,i);l.type=n.type,l.layoutAlgorithm=nf(s),l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["aggregation","extension","composition","dependency","lollipop"],l.diagramId=e,await Cc(l,u);let h=8;Gt.insertTitle(u,"classDiagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ac(u,h,"classDiagram",a?.useMaxWidth??!0)},"draw"),M6={getClasses:WVe,draw:qVe,getDir:HVe}});var Nfe={};hr(Nfe,{diagram:()=>YVe});var YVe,Mfe=N(()=>{"use strict";IO();OO();PO();BO();YVe={parser:L6,get db(){return new D1},renderer:M6,styles:N6,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var Pfe={};hr(Pfe,{diagram:()=>QVe});var QVe,Bfe=N(()=>{"use strict";IO();OO();PO();BO();QVe={parser:L6,get db(){return new D1},renderer:M6,styles:N6,init:o(t=>{t.class||(t.class={}),t.class.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var FO,I6,$O=N(()=>{"use strict";FO=function(){var t=o(function(F,P,z,$){for(z=z||{},$=F.length;$--;z[F[$]]=P);return z},"o"),e=[1,2],r=[1,3],n=[1,4],i=[2,4],a=[1,9],s=[1,11],l=[1,16],u=[1,17],h=[1,18],f=[1,19],d=[1,32],p=[1,20],m=[1,21],g=[1,22],y=[1,23],v=[1,24],x=[1,26],b=[1,27],w=[1,28],C=[1,29],T=[1,30],E=[1,31],A=[1,34],S=[1,35],_=[1,36],I=[1,37],D=[1,33],k=[1,4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,42,45,48,49,50,51,54],L=[1,4,5,14,15,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,42,45,48,49,50,51,54],R=[4,5,16,17,19,21,22,24,25,26,27,28,29,33,35,37,38,42,45,48,49,50,51,54],O={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NL:5,SD:6,document:7,line:8,statement:9,classDefStatement:10,styleStatement:11,cssClassStatement:12,idStatement:13,DESCR:14,"-->":15,HIDE_EMPTY:16,scale:17,WIDTH:18,COMPOSIT_STATE:19,STRUCT_START:20,STRUCT_STOP:21,STATE_DESCR:22,AS:23,ID:24,FORK:25,JOIN:26,CHOICE:27,CONCURRENT:28,note:29,notePosition:30,NOTE_TEXT:31,direction:32,acc_title:33,acc_title_value:34,acc_descr:35,acc_descr_value:36,acc_descr_multiline_value:37,classDef:38,CLASSDEF_ID:39,CLASSDEF_STYLEOPTS:40,DEFAULT:41,style:42,STYLE_IDS:43,STYLEDEF_STYLEOPTS:44,class:45,CLASSENTITY_IDS:46,STYLECLASS:47,direction_tb:48,direction_bt:49,direction_rl:50,direction_lr:51,eol:52,";":53,EDGE_STATE:54,STYLE_SEPARATOR:55,left_of:56,right_of:57,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NL",6:"SD",14:"DESCR",15:"-->",16:"HIDE_EMPTY",17:"scale",18:"WIDTH",19:"COMPOSIT_STATE",20:"STRUCT_START",21:"STRUCT_STOP",22:"STATE_DESCR",23:"AS",24:"ID",25:"FORK",26:"JOIN",27:"CHOICE",28:"CONCURRENT",29:"note",31:"NOTE_TEXT",33:"acc_title",34:"acc_title_value",35:"acc_descr",36:"acc_descr_value",37:"acc_descr_multiline_value",38:"classDef",39:"CLASSDEF_ID",40:"CLASSDEF_STYLEOPTS",41:"DEFAULT",42:"style",43:"STYLE_IDS",44:"STYLEDEF_STYLEOPTS",45:"class",46:"CLASSENTITY_IDS",47:"STYLECLASS",48:"direction_tb",49:"direction_bt",50:"direction_rl",51:"direction_lr",53:";",54:"EDGE_STATE",55:"STYLE_SEPARATOR",56:"left_of",57:"right_of"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[9,1],[9,1],[9,1],[9,1],[9,2],[9,3],[9,4],[9,1],[9,2],[9,1],[9,4],[9,3],[9,6],[9,1],[9,1],[9,1],[9,1],[9,4],[9,4],[9,1],[9,2],[9,2],[9,1],[10,3],[10,3],[11,3],[12,3],[32,1],[32,1],[32,1],[32,1],[52,1],[52,1],[13,1],[13,1],[13,3],[13,3],[30,1],[30,1]],performAction:o(function(P,z,$,H,Q,j,ie){var ne=j.length-1;switch(Q){case 3:return H.setRootDoc(j[ne]),j[ne];break;case 4:this.$=[];break;case 5:j[ne]!="nl"&&(j[ne-1].push(j[ne]),this.$=j[ne-1]);break;case 6:case 7:this.$=j[ne];break;case 8:this.$="nl";break;case 12:this.$=j[ne];break;case 13:let X=j[ne-1];X.description=H.trimColon(j[ne]),this.$=X;break;case 14:this.$={stmt:"relation",state1:j[ne-2],state2:j[ne]};break;case 15:let te=H.trimColon(j[ne]);this.$={stmt:"relation",state1:j[ne-3],state2:j[ne-1],description:te};break;case 19:this.$={stmt:"state",id:j[ne-3],type:"default",description:"",doc:j[ne-1]};break;case 20:var le=j[ne],he=j[ne-2].trim();if(j[ne].match(":")){var K=j[ne].split(":");le=K[0],he=[he,K[1]]}this.$={stmt:"state",id:le,type:"default",description:he};break;case 21:this.$={stmt:"state",id:j[ne-3],type:"default",description:j[ne-5],doc:j[ne-1]};break;case 22:this.$={stmt:"state",id:j[ne],type:"fork"};break;case 23:this.$={stmt:"state",id:j[ne],type:"join"};break;case 24:this.$={stmt:"state",id:j[ne],type:"choice"};break;case 25:this.$={stmt:"state",id:H.getDividerId(),type:"divider"};break;case 26:this.$={stmt:"state",id:j[ne-1].trim(),note:{position:j[ne-2].trim(),text:j[ne].trim()}};break;case 29:this.$=j[ne].trim(),H.setAccTitle(this.$);break;case 30:case 31:this.$=j[ne].trim(),H.setAccDescription(this.$);break;case 32:case 33:this.$={stmt:"classDef",id:j[ne-1].trim(),classes:j[ne].trim()};break;case 34:this.$={stmt:"style",id:j[ne-1].trim(),styleClass:j[ne].trim()};break;case 35:this.$={stmt:"applyClass",id:j[ne-1].trim(),styleClass:j[ne].trim()};break;case 36:H.setDirection("TB"),this.$={stmt:"dir",value:"TB"};break;case 37:H.setDirection("BT"),this.$={stmt:"dir",value:"BT"};break;case 38:H.setDirection("RL"),this.$={stmt:"dir",value:"RL"};break;case 39:H.setDirection("LR"),this.$={stmt:"dir",value:"LR"};break;case 42:case 43:this.$={stmt:"state",id:j[ne].trim(),type:"default",description:""};break;case 44:this.$={stmt:"state",id:j[ne-2].trim(),classes:[j[ne].trim()],type:"default",description:""};break;case 45:this.$={stmt:"state",id:j[ne-2].trim(),classes:[j[ne].trim()],type:"default",description:""};break}},"anonymous"),table:[{3:1,4:e,5:r,6:n},{1:[3]},{3:5,4:e,5:r,6:n},{3:6,4:e,5:r,6:n},t([1,4,5,16,17,19,22,24,25,26,27,28,29,33,35,37,38,42,45,48,49,50,51,54],i,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:w,38:C,42:T,45:E,48:A,49:S,50:_,51:I,54:D},t(k,[2,5]),{9:38,10:12,11:13,12:14,13:15,16:l,17:u,19:h,22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:w,38:C,42:T,45:E,48:A,49:S,50:_,51:I,54:D},t(k,[2,7]),t(k,[2,8]),t(k,[2,9]),t(k,[2,10]),t(k,[2,11]),t(k,[2,12],{14:[1,39],15:[1,40]}),t(k,[2,16]),{18:[1,41]},t(k,[2,18],{20:[1,42]}),{23:[1,43]},t(k,[2,22]),t(k,[2,23]),t(k,[2,24]),t(k,[2,25]),{30:44,31:[1,45],56:[1,46],57:[1,47]},t(k,[2,28]),{34:[1,48]},{36:[1,49]},t(k,[2,31]),{39:[1,50],41:[1,51]},{43:[1,52]},{46:[1,53]},t(L,[2,42],{55:[1,54]}),t(L,[2,43],{55:[1,55]}),t(k,[2,36]),t(k,[2,37]),t(k,[2,38]),t(k,[2,39]),t(k,[2,6]),t(k,[2,13]),{13:56,24:d,54:D},t(k,[2,17]),t(R,i,{7:57}),{24:[1,58]},{24:[1,59]},{23:[1,60]},{24:[2,46]},{24:[2,47]},t(k,[2,29]),t(k,[2,30]),{40:[1,61]},{40:[1,62]},{44:[1,63]},{47:[1,64]},{24:[1,65]},{24:[1,66]},t(k,[2,14],{14:[1,67]}),{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,68],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:w,38:C,42:T,45:E,48:A,49:S,50:_,51:I,54:D},t(k,[2,20],{20:[1,69]}),{31:[1,70]},{24:[1,71]},t(k,[2,32]),t(k,[2,33]),t(k,[2,34]),t(k,[2,35]),t(L,[2,44]),t(L,[2,45]),t(k,[2,15]),t(k,[2,19]),t(R,i,{7:72}),t(k,[2,26]),t(k,[2,27]),{4:a,5:s,8:8,9:10,10:12,11:13,12:14,13:15,16:l,17:u,19:h,21:[1,73],22:f,24:d,25:p,26:m,27:g,28:y,29:v,32:25,33:x,35:b,37:w,38:C,42:T,45:E,48:A,49:S,50:_,51:I,54:D},t(k,[2,21])],defaultActions:{5:[2,1],6:[2,2],46:[2,46],47:[2,47]},parseError:o(function(P,z){if(z.recoverable)this.trace(P);else{var $=new Error(P);throw $.hash=z,$}},"parseError"),parse:o(function(P){var z=this,$=[0],H=[],Q=[null],j=[],ie=this.table,ne="",le=0,he=0,K=0,X=2,te=1,J=j.slice.call(arguments,1),se=Object.create(this.lexer),ue={yy:{}};for(var Z in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Z)&&(ue.yy[Z]=this.yy[Z]);se.setInput(P,ue.yy),ue.yy.lexer=se,ue.yy.parser=this,typeof se.yylloc>"u"&&(se.yylloc={});var Se=se.yylloc;j.push(Se);var ce=se.options&&se.options.ranges;typeof ue.yy.parseError=="function"?this.parseError=ue.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ae(xe){$.length=$.length-2*xe,Q.length=Q.length-xe,j.length=j.length-xe}o(ae,"popStack");function Oe(){var xe;return xe=H.pop()||se.lex()||te,typeof xe!="number"&&(xe instanceof Array&&(H=xe,xe=H.pop()),xe=z.symbols_[xe]||xe),xe}o(Oe,"lex");for(var ge,ze,He,$e,Re,Ie,be={},W,de,re,oe;;){if(He=$[$.length-1],this.defaultActions[He]?$e=this.defaultActions[He]:((ge===null||typeof ge>"u")&&(ge=Oe()),$e=ie[He]&&ie[He][ge]),typeof $e>"u"||!$e.length||!$e[0]){var V="";oe=[];for(W in ie[He])this.terminals_[W]&&W>X&&oe.push("'"+this.terminals_[W]+"'");se.showPosition?V="Parse error on line "+(le+1)+`: +`+se.showPosition()+` +Expecting `+oe.join(", ")+", got '"+(this.terminals_[ge]||ge)+"'":V="Parse error on line "+(le+1)+": Unexpected "+(ge==te?"end of input":"'"+(this.terminals_[ge]||ge)+"'"),this.parseError(V,{text:se.match,token:this.terminals_[ge]||ge,line:se.yylineno,loc:Se,expected:oe})}if($e[0]instanceof Array&&$e.length>1)throw new Error("Parse Error: multiple actions possible at state: "+He+", token: "+ge);switch($e[0]){case 1:$.push(ge),Q.push(se.yytext),j.push(se.yylloc),$.push($e[1]),ge=null,ze?(ge=ze,ze=null):(he=se.yyleng,ne=se.yytext,le=se.yylineno,Se=se.yylloc,K>0&&K--);break;case 2:if(de=this.productions_[$e[1]][1],be.$=Q[Q.length-de],be._$={first_line:j[j.length-(de||1)].first_line,last_line:j[j.length-1].last_line,first_column:j[j.length-(de||1)].first_column,last_column:j[j.length-1].last_column},ce&&(be._$.range=[j[j.length-(de||1)].range[0],j[j.length-1].range[1]]),Ie=this.performAction.apply(be,[ne,he,le,ue.yy,$e[1],Q,j].concat(J)),typeof Ie<"u")return Ie;de&&($=$.slice(0,-1*de*2),Q=Q.slice(0,-1*de),j=j.slice(0,-1*de)),$.push(this.productions_[$e[1]][0]),Q.push(be.$),j.push(be._$),re=ie[$[$.length-2]][$[$.length-1]],$.push(re);break;case 3:return!0}}return!0},"parse")},M=function(){var F={EOF:1,parseError:o(function(z,$){if(this.yy.parser)this.yy.parser.parseError(z,$);else throw new Error(z)},"parseError"),setInput:o(function(P,z){return this.yy=z||this.yy||{},this._input=P,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var P=this._input[0];this.yytext+=P,this.yyleng++,this.offset++,this.match+=P,this.matched+=P;var z=P.match(/(?:\r\n?|\n).*/g);return z?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),P},"input"),unput:o(function(P){var z=P.length,$=P.split(/(?:\r\n?|\n)/g);this._input=P+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-z),this.offset-=z;var H=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),$.length-1&&(this.yylineno-=$.length-1);var Q=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:$?($.length===H.length?this.yylloc.first_column:0)+H[H.length-$.length].length-$[0].length:this.yylloc.first_column-z},this.options.ranges&&(this.yylloc.range=[Q[0],Q[0]+this.yyleng-z]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(P){this.unput(this.match.slice(P))},"less"),pastInput:o(function(){var P=this.matched.substr(0,this.matched.length-this.match.length);return(P.length>20?"...":"")+P.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var P=this.match;return P.length<20&&(P+=this._input.substr(0,20-P.length)),(P.substr(0,20)+(P.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var P=this.pastInput(),z=new Array(P.length+1).join("-");return P+this.upcomingInput()+` +`+z+"^"},"showPosition"),test_match:o(function(P,z){var $,H,Q;if(this.options.backtrack_lexer&&(Q={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(Q.yylloc.range=this.yylloc.range.slice(0))),H=P[0].match(/(?:\r\n?|\n).*/g),H&&(this.yylineno+=H.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:H?H[H.length-1].length-H[H.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+P[0].length},this.yytext+=P[0],this.match+=P[0],this.matches=P,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(P[0].length),this.matched+=P[0],$=this.performAction.call(this,this.yy,this,z,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),$)return $;if(this._backtrack){for(var j in Q)this[j]=Q[j];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var P,z,$,H;this._more||(this.yytext="",this.match="");for(var Q=this._currentRules(),j=0;jz[0].length)){if(z=$,H=j,this.options.backtrack_lexer){if(P=this.test_match($,Q[j]),P!==!1)return P;if(this._backtrack){z=!1;continue}else return!1}else if(!this.options.flex)break}return z?(P=this.test_match(z,Q[H]),P!==!1?P:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var z=this.next();return z||this.lex()},"lex"),begin:o(function(z){this.conditionStack.push(z)},"begin"),popState:o(function(){var z=this.conditionStack.length-1;return z>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(z){return z=this.conditionStack.length-1-Math.abs(z||0),z>=0?this.conditionStack[z]:"INITIAL"},"topState"),pushState:o(function(z){this.begin(z)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(z,$,H,Q){var j=Q;switch(H){case 0:return 41;case 1:return 48;case 2:return 49;case 3:return 50;case 4:return 51;case 5:break;case 6:break;case 7:return 5;case 8:break;case 9:break;case 10:break;case 11:break;case 12:return this.pushState("SCALE"),17;break;case 13:return 18;case 14:this.popState();break;case 15:return this.begin("acc_title"),33;break;case 16:return this.popState(),"acc_title_value";break;case 17:return this.begin("acc_descr"),35;break;case 18:return this.popState(),"acc_descr_value";break;case 19:this.begin("acc_descr_multiline");break;case 20:this.popState();break;case 21:return"acc_descr_multiline_value";case 22:return this.pushState("CLASSDEF"),38;break;case 23:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 24:return this.popState(),this.pushState("CLASSDEFID"),39;break;case 25:return this.popState(),40;break;case 26:return this.pushState("CLASS"),45;break;case 27:return this.popState(),this.pushState("CLASS_STYLE"),46;break;case 28:return this.popState(),47;break;case 29:return this.pushState("STYLE"),42;break;case 30:return this.popState(),this.pushState("STYLEDEF_STYLES"),43;break;case 31:return this.popState(),44;break;case 32:return this.pushState("SCALE"),17;break;case 33:return 18;case 34:this.popState();break;case 35:this.pushState("STATE");break;case 36:return this.popState(),$.yytext=$.yytext.slice(0,-8).trim(),25;break;case 37:return this.popState(),$.yytext=$.yytext.slice(0,-8).trim(),26;break;case 38:return this.popState(),$.yytext=$.yytext.slice(0,-10).trim(),27;break;case 39:return this.popState(),$.yytext=$.yytext.slice(0,-8).trim(),25;break;case 40:return this.popState(),$.yytext=$.yytext.slice(0,-8).trim(),26;break;case 41:return this.popState(),$.yytext=$.yytext.slice(0,-10).trim(),27;break;case 42:return 48;case 43:return 49;case 44:return 50;case 45:return 51;case 46:this.pushState("STATE_STRING");break;case 47:return this.pushState("STATE_ID"),"AS";break;case 48:return this.popState(),"ID";break;case 49:this.popState();break;case 50:return"STATE_DESCR";case 51:return 19;case 52:this.popState();break;case 53:return this.popState(),this.pushState("struct"),20;break;case 54:break;case 55:return this.popState(),21;break;case 56:break;case 57:return this.begin("NOTE"),29;break;case 58:return this.popState(),this.pushState("NOTE_ID"),56;break;case 59:return this.popState(),this.pushState("NOTE_ID"),57;break;case 60:this.popState(),this.pushState("FLOATING_NOTE");break;case 61:return this.popState(),this.pushState("FLOATING_NOTE_ID"),"AS";break;case 62:break;case 63:return"NOTE_TEXT";case 64:return this.popState(),"ID";break;case 65:return this.popState(),this.pushState("NOTE_TEXT"),24;break;case 66:return this.popState(),$.yytext=$.yytext.substr(2).trim(),31;break;case 67:return this.popState(),$.yytext=$.yytext.slice(0,-8).trim(),31;break;case 68:return 6;case 69:return 6;case 70:return 16;case 71:return 54;case 72:return 24;case 73:return $.yytext=$.yytext.trim(),14;break;case 74:return 15;case 75:return 28;case 76:return 55;case 77:return 5;case 78:return"INVALID"}},"anonymous"),rules:[/^(?:default\b)/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:[\s]+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:classDef\s+)/i,/^(?:DEFAULT\s+)/i,/^(?:\w+\s+)/i,/^(?:[^\n]*)/i,/^(?:class\s+)/i,/^(?:(\w+)+((,\s*\w+)*))/i,/^(?:[^\n]*)/i,/^(?:style\s+)/i,/^(?:[\w,]+\s+)/i,/^(?:[^\n]*)/i,/^(?:scale\s+)/i,/^(?:\d+)/i,/^(?:\s+width\b)/i,/^(?:state\s+)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*<>)/i,/^(?:.*\[\[fork\]\])/i,/^(?:.*\[\[join\]\])/i,/^(?:.*\[\[choice\]\])/i,/^(?:.*direction\s+TB[^\n]*)/i,/^(?:.*direction\s+BT[^\n]*)/i,/^(?:.*direction\s+RL[^\n]*)/i,/^(?:.*direction\s+LR[^\n]*)/i,/^(?:["])/i,/^(?:\s*as\s+)/i,/^(?:[^\n\{]*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n\s\{]+)/i,/^(?:\n)/i,/^(?:\{)/i,/^(?:%%(?!\{)[^\n]*)/i,/^(?:\})/i,/^(?:[\n])/i,/^(?:note\s+)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:")/i,/^(?:\s*as\s*)/i,/^(?:["])/i,/^(?:[^"]*)/i,/^(?:[^\n]*)/i,/^(?:\s*[^:\n\s\-]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:[\s\S]*?end note\b)/i,/^(?:stateDiagram\s+)/i,/^(?:stateDiagram-v2\s+)/i,/^(?:hide empty description\b)/i,/^(?:\[\*\])/i,/^(?:[^:\n\s\-\{]+)/i,/^(?:\s*:[^:\n;]+)/i,/^(?:-->)/i,/^(?:--)/i,/^(?::::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{LINE:{rules:[9,10],inclusive:!1},struct:{rules:[9,10,22,26,29,35,42,43,44,45,54,55,56,57,71,72,73,74,75],inclusive:!1},FLOATING_NOTE_ID:{rules:[64],inclusive:!1},FLOATING_NOTE:{rules:[61,62,63],inclusive:!1},NOTE_TEXT:{rules:[66,67],inclusive:!1},NOTE_ID:{rules:[65],inclusive:!1},NOTE:{rules:[58,59,60],inclusive:!1},STYLEDEF_STYLEOPTS:{rules:[],inclusive:!1},STYLEDEF_STYLES:{rules:[31],inclusive:!1},STYLE_IDS:{rules:[],inclusive:!1},STYLE:{rules:[30],inclusive:!1},CLASS_STYLE:{rules:[28],inclusive:!1},CLASS:{rules:[27],inclusive:!1},CLASSDEFID:{rules:[25],inclusive:!1},CLASSDEF:{rules:[23,24],inclusive:!1},acc_descr_multiline:{rules:[20,21],inclusive:!1},acc_descr:{rules:[18],inclusive:!1},acc_title:{rules:[16],inclusive:!1},SCALE:{rules:[13,14,33,34],inclusive:!1},ALIAS:{rules:[],inclusive:!1},STATE_ID:{rules:[48],inclusive:!1},STATE_STRING:{rules:[49,50],inclusive:!1},FORK_STATE:{rules:[],inclusive:!1},STATE:{rules:[9,10,36,37,38,39,40,41,46,47,51,52,53],inclusive:!1},ID:{rules:[9,10],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,10,11,12,15,17,19,22,26,29,32,35,53,57,68,69,70,71,72,73,74,76,77,78],inclusive:!0}}};return F}();O.lexer=M;function B(){this.yy={}}return o(B,"Parser"),B.prototype=O,O.Parser=B,new B}();FO.parser=FO;I6=FO});var zfe,O6,zO,L1,Eb,Gfe,Vfe,Ufe,Rp,P6,GO,VO,UO,HO,WO,B6,F6,Hfe,Wfe,qO,YO,qfe,Yfe,R1,tUe,Xfe,XO,rUe,nUe,jfe,Kfe,iUe,Qfe,aUe,Zfe,jO,KO,Jfe,$6,ede,QO,z6=N(()=>{"use strict";zfe="TB",O6="TB",zO="dir",L1="state",Eb="relation",Gfe="classDef",Vfe="style",Ufe="applyClass",Rp="default",P6="divider",GO="fill:none",VO="fill: #333",UO="c",HO="text",WO="normal",B6="rect",F6="rectWithTitle",Hfe="stateStart",Wfe="stateEnd",qO="divider",YO="roundedWithTitle",qfe="note",Yfe="noteGroup",R1="statediagram",tUe="state",Xfe=`${R1}-${tUe}`,XO="transition",rUe="note",nUe="note-edge",jfe=`${XO} ${nUe}`,Kfe=`${R1}-${rUe}`,iUe="cluster",Qfe=`${R1}-${iUe}`,aUe="cluster-alt",Zfe=`${R1}-${aUe}`,jO="parent",KO="note",Jfe="state",$6="----",ede=`${$6}${KO}`,QO=`${$6}${jO}`});function ZO(t="",e=0,r="",n=$6){let i=r!==null&&r.length>0?`${n}${r}`:"";return`${Jfe}-${t}${i}-${e}`}function G6(t,e,r){if(!e.id||e.id===""||e.id==="")return;e.cssClasses&&(Array.isArray(e.cssCompiledStyles)||(e.cssCompiledStyles=[]),e.cssClasses.split(" ").forEach(i=>{if(r.get(i)){let a=r.get(i);e.cssCompiledStyles=[...e.cssCompiledStyles,...a.styles]}}));let n=t.find(i=>i.id===e.id);n?Object.assign(n,e):t.push(e)}function oUe(t){return t?.classes?.join(" ")??""}function lUe(t){return t?.styles??[]}var V6,xf,sUe,tde,N1,rde,nde=N(()=>{"use strict";zt();vt();gr();z6();V6=new Map,xf=0;o(ZO,"stateDomId");sUe=o((t,e,r,n,i,a,s,l)=>{Y.trace("items",e),e.forEach(u=>{switch(u.stmt){case L1:N1(t,u,r,n,i,a,s,l);break;case Rp:N1(t,u,r,n,i,a,s,l);break;case Eb:{N1(t,u.state1,r,n,i,a,s,l),N1(t,u.state2,r,n,i,a,s,l);let h={id:"edge"+xf,start:u.state1.id,end:u.state2.id,arrowhead:"normal",arrowTypeEnd:"arrow_barb",style:GO,labelStyle:"",label:Ze.sanitizeText(u.description,me()),arrowheadStyle:VO,labelpos:UO,labelType:HO,thickness:WO,classes:XO,look:s};i.push(h),xf++}break}})},"setupDoc"),tde=o((t,e=O6)=>{let r=e;if(t.doc)for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir");o(G6,"insertOrUpdateNode");o(oUe,"getClassesFromDbInfo");o(lUe,"getStylesFromDbInfo");N1=o((t,e,r,n,i,a,s,l)=>{let u=e.id,h=r.get(u),f=oUe(h),d=lUe(h);if(Y.info("dataFetcher parsedItem",e,h,d),u!=="root"){let p=B6;e.start===!0?p=Hfe:e.start===!1&&(p=Wfe),e.type!==Rp&&(p=e.type),V6.get(u)||V6.set(u,{id:u,shape:p,description:Ze.sanitizeText(u,me()),cssClasses:`${f} ${Xfe}`,cssStyles:d});let m=V6.get(u);e.description&&(Array.isArray(m.description)?(m.shape=F6,m.description.push(e.description)):m.description?.length>0?(m.shape=F6,m.description===u?m.description=[e.description]:m.description=[m.description,e.description]):(m.shape=B6,m.description=e.description),m.description=Ze.sanitizeTextOrArray(m.description,me())),m.description?.length===1&&m.shape===F6&&(m.type==="group"?m.shape=YO:m.shape=B6),!m.type&&e.doc&&(Y.info("Setting cluster for XCX",u,tde(e)),m.type="group",m.isGroup=!0,m.dir=tde(e),m.shape=e.type===P6?qO:YO,m.cssClasses=`${m.cssClasses} ${Qfe} ${a?Zfe:""}`);let g={labelStyle:"",shape:m.shape,label:m.description,cssClasses:m.cssClasses,cssCompiledStyles:[],cssStyles:m.cssStyles,id:u,dir:m.dir,domId:ZO(u,xf),type:m.type,isGroup:m.type==="group",padding:8,rx:10,ry:10,look:s};if(g.shape===qO&&(g.label=""),t&&t.id!=="root"&&(Y.trace("Setting node ",u," to be child of its parent ",t.id),g.parentId=t.id),g.centerLabel=!0,e.note){let y={labelStyle:"",shape:qfe,label:e.note.text,cssClasses:Kfe,cssStyles:[],cssCompilesStyles:[],id:u+ede+"-"+xf,domId:ZO(u,xf,KO),type:m.type,isGroup:m.type==="group",padding:me().flowchart.padding,look:s,position:e.note.position},v=u+QO,x={labelStyle:"",shape:Yfe,label:e.note.text,cssClasses:m.cssClasses,cssStyles:[],id:u+QO,domId:ZO(u,xf,jO),type:"group",isGroup:!0,padding:16,look:s,position:e.note.position};xf++,x.id=v,y.parentId=v,G6(n,x,l),G6(n,y,l),G6(n,g,l);let b=u,w=y.id;e.note.position==="left of"&&(b=y.id,w=u),i.push({id:b+"-"+w,start:b,end:w,arrowhead:"none",arrowTypeEnd:"",style:GO,labelStyle:"",classes:jfe,arrowheadStyle:VO,labelpos:UO,labelType:HO,thickness:WO,look:s})}else G6(n,g,l)}e.doc&&(Y.trace("Adding nodes children "),sUe(e,e.doc,r,n,i,!a,s,l))},"dataFetcher"),rde=o(()=>{V6.clear(),xf=0},"reset")});var JO,cUe,uUe,ide,eP=N(()=>{"use strict";zt();vt();gm();Yd();$m();ir();z6();JO=o((t,e=O6)=>{if(!t.doc)return e;let r=e;for(let n of t.doc)n.stmt==="dir"&&(r=n.value);return r},"getDir"),cUe=o(function(t,e){return e.db.getClasses()},"getClasses"),uUe=o(async function(t,e,r,n){Y.info("REF0:"),Y.info("Drawing state diagram (v2)",e);let{securityLevel:i,state:a,layout:s}=me();n.db.extract(n.db.getRootDocV2());let l=n.db.getData(),u=yc(e,i);l.type=n.type,l.layoutAlgorithm=s,l.nodeSpacing=a?.nodeSpacing||50,l.rankSpacing=a?.rankSpacing||50,l.markers=["barb"],l.diagramId=e,await Cc(l,u);let h=8;Gt.insertTitle(u,"statediagramTitleText",a?.titleTopMargin??25,n.db.getDiagramTitle()),Ac(u,h,R1,a?.useMaxWidth??!0)},"draw"),ide={getClasses:cUe,draw:uUe,getDir:JO}});function ude(){return new Map}var tP,ade,sde,ode,lde,cde,hUe,fUe,hde,U6,Qo,H6=N(()=>{"use strict";zt();vt();ir();gr();mi();nde();eP();z6();tP="[*]",ade="start",sde=tP,ode="end",lde="color",cde="fill",hUe="bgFill",fUe=",";o(ude,"newClassesList");hde=o(()=>({relations:[],states:new Map,documents:{}}),"newDoc"),U6=o(t=>JSON.parse(JSON.stringify(t)),"clone"),Qo=class{static{o(this,"StateDB")}constructor(e){this.clear(),this.version=e,this.setRootDoc=this.setRootDoc.bind(this),this.getDividerId=this.getDividerId.bind(this),this.setDirection=this.setDirection.bind(this),this.trimColon=this.trimColon.bind(this)}version;nodes=[];edges=[];rootDoc=[];classes=ude();documents={root:hde()};currentDocument=this.documents.root;startEndCount=0;dividerCnt=0;static relationType={AGGREGATION:0,EXTENSION:1,COMPOSITION:2,DEPENDENCY:3};setRootDoc(e){Y.info("Setting root doc",e),this.rootDoc=e,this.version===1?this.extract(e):this.extract(this.getRootDocV2())}getRootDoc(){return this.rootDoc}docTranslator(e,r,n){if(r.stmt===Eb)this.docTranslator(e,r.state1,!0),this.docTranslator(e,r.state2,!1);else if(r.stmt===L1&&(r.id==="[*]"?(r.id=n?e.id+"_start":e.id+"_end",r.start=n):r.id=r.id.trim()),r.doc){let i=[],a=[],s;for(s=0;s0&&a.length>0){let l={stmt:L1,id:X9(),type:"divider",doc:U6(a)};i.push(U6(l)),r.doc=i}r.doc.forEach(l=>this.docTranslator(r,l,!0))}}getRootDocV2(){return this.docTranslator({id:"root"},{id:"root",doc:this.rootDoc},!0),{id:"root",doc:this.rootDoc}}extract(e){let r;e.doc?r=e.doc:r=e,Y.info(r),this.clear(!0),Y.info("Extract initial document:",r),r.forEach(s=>{switch(Y.warn("Statement",s.stmt),s.stmt){case L1:this.addState(s.id.trim(),s.type,s.doc,s.description,s.note,s.classes,s.styles,s.textStyles);break;case Eb:this.addRelation(s.state1,s.state2,s.description);break;case Gfe:this.addStyleClass(s.id.trim(),s.classes);break;case Vfe:{let l=s.id.trim().split(","),u=s.styleClass.split(",");l.forEach(h=>{let f=this.getState(h);if(f===void 0){let d=h.trim();this.addState(d),f=this.getState(d)}f.styles=u.map(d=>d.replace(/;/g,"")?.trim())})}break;case Ufe:this.setCssClass(s.id.trim(),s.styleClass);break}});let n=this.getStates(),a=me().look;rde(),N1(void 0,this.getRootDocV2(),n,this.nodes,this.edges,!0,a,this.classes),this.nodes.forEach(s=>{if(Array.isArray(s.label)){if(s.description=s.label.slice(1),s.isGroup&&s.description.length>0)throw new Error("Group nodes can only have label. Remove the additional description for node ["+s.id+"]");s.label=s.label[0]}})}addState(e,r=Rp,n=null,i=null,a=null,s=null,l=null,u=null){let h=e?.trim();if(this.currentDocument.states.has(h)?(this.currentDocument.states.get(h).doc||(this.currentDocument.states.get(h).doc=n),this.currentDocument.states.get(h).type||(this.currentDocument.states.get(h).type=r)):(Y.info("Adding state ",h,i),this.currentDocument.states.set(h,{id:h,descriptions:[],type:r,doc:n,note:a,classes:[],styles:[],textStyles:[]})),i&&(Y.info("Setting state description",h,i),typeof i=="string"&&this.addDescription(h,i.trim()),typeof i=="object"&&i.forEach(f=>this.addDescription(h,f.trim()))),a){let f=this.currentDocument.states.get(h);f.note=a,f.note.text=Ze.sanitizeText(f.note.text,me())}s&&(Y.info("Setting state classes",h,s),(typeof s=="string"?[s]:s).forEach(d=>this.setCssClass(h,d.trim()))),l&&(Y.info("Setting state styles",h,l),(typeof l=="string"?[l]:l).forEach(d=>this.setStyle(h,d.trim()))),u&&(Y.info("Setting state styles",h,l),(typeof u=="string"?[u]:u).forEach(d=>this.setTextStyle(h,d.trim())))}clear(e){this.nodes=[],this.edges=[],this.documents={root:hde()},this.currentDocument=this.documents.root,this.startEndCount=0,this.classes=ude(),e||Ar()}getState(e){return this.currentDocument.states.get(e)}getStates(){return this.currentDocument.states}logDocuments(){Y.info("Documents = ",this.documents)}getRelations(){return this.currentDocument.relations}startIdIfNeeded(e=""){let r=e;return e===tP&&(this.startEndCount++,r=`${ade}${this.startEndCount}`),r}startTypeIfNeeded(e="",r=Rp){return e===tP?ade:r}endIdIfNeeded(e=""){let r=e;return e===sde&&(this.startEndCount++,r=`${ode}${this.startEndCount}`),r}endTypeIfNeeded(e="",r=Rp){return e===sde?ode:r}addRelationObjs(e,r,n){let i=this.startIdIfNeeded(e.id.trim()),a=this.startTypeIfNeeded(e.id.trim(),e.type),s=this.startIdIfNeeded(r.id.trim()),l=this.startTypeIfNeeded(r.id.trim(),r.type);this.addState(i,a,e.doc,e.description,e.note,e.classes,e.styles,e.textStyles),this.addState(s,l,r.doc,r.description,r.note,r.classes,r.styles,r.textStyles),this.currentDocument.relations.push({id1:i,id2:s,relationTitle:Ze.sanitizeText(n,me())})}addRelation(e,r,n){if(typeof e=="object")this.addRelationObjs(e,r,n);else{let i=this.startIdIfNeeded(e.trim()),a=this.startTypeIfNeeded(e),s=this.endIdIfNeeded(r.trim()),l=this.endTypeIfNeeded(r);this.addState(i,a),this.addState(s,l),this.currentDocument.relations.push({id1:i,id2:s,title:Ze.sanitizeText(n,me())})}}addDescription(e,r){let n=this.currentDocument.states.get(e),i=r.startsWith(":")?r.replace(":","").trim():r;n.descriptions.push(Ze.sanitizeText(i,me()))}cleanupLabel(e){return e.substring(0,1)===":"?e.substr(2).trim():e.trim()}getDividerId(){return this.dividerCnt++,"divider-id-"+this.dividerCnt}addStyleClass(e,r=""){this.classes.has(e)||this.classes.set(e,{id:e,styles:[],textStyles:[]});let n=this.classes.get(e);r?.split(fUe).forEach(i=>{let a=i.replace(/([^;]*);/,"$1").trim();if(RegExp(lde).exec(i)){let l=a.replace(cde,hUe).replace(lde,cde);n.textStyles.push(l)}n.styles.push(a)})}getClasses(){return this.classes}setCssClass(e,r){e.split(",").forEach(n=>{let i=this.getState(n);if(i===void 0){let a=n.trim();this.addState(a),i=this.getState(a)}i.classes.push(r)})}setStyle(e,r){let n=this.getState(e);n!==void 0&&n.styles.push(r)}setTextStyle(e,r){let n=this.getState(e);n!==void 0&&n.textStyles.push(r)}getDirectionStatement(){return this.rootDoc.find(e=>e.stmt===zO)}getDirection(){return this.getDirectionStatement()?.value??zfe}setDirection(e){let r=this.getDirectionStatement();r?r.value=e:this.rootDoc.unshift({stmt:zO,value:e})}trimColon(e){return e&&e[0]===":"?e.substr(1).trim():e.trim()}getData(){let e=me();return{nodes:this.nodes,edges:this.edges,other:{},config:e,direction:JO(this.getRootDocV2())}}getConfig(){return me().state}getAccTitle=Rr;setAccTitle=Lr;getAccDescription=Mr;setAccDescription=Nr;setDiagramTitle=$r;getDiagramTitle=Ir}});var dUe,W6,rP=N(()=>{"use strict";dUe=o(t=>` +defs #statediagram-barbEnd { + fill: ${t.transitionColor}; + stroke: ${t.transitionColor}; + } +g.stateGroup text { + fill: ${t.nodeBorder}; + stroke: none; + font-size: 10px; +} +g.stateGroup text { + fill: ${t.textColor}; + stroke: none; + font-size: 10px; + +} +g.stateGroup .state-title { + font-weight: bolder; + fill: ${t.stateLabelColor}; +} + +g.stateGroup rect { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; +} + +g.stateGroup line { + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.transition { + stroke: ${t.transitionColor}; + stroke-width: 1; + fill: none; +} + +.stateGroup .composit { + fill: ${t.background}; + border-bottom: 1px +} + +.stateGroup .alt-composit { + fill: #e0e0e0; + border-bottom: 1px +} + +.state-note { + stroke: ${t.noteBorderColor}; + fill: ${t.noteBkgColor}; + + text { + fill: ${t.noteTextColor}; + stroke: none; + font-size: 10px; + } +} + +.stateLabel .box { + stroke: none; + stroke-width: 0; + fill: ${t.mainBkg}; + opacity: 0.5; +} + +.edgeLabel .label rect { + fill: ${t.labelBackgroundColor}; + opacity: 0.5; +} +.edgeLabel { + background-color: ${t.edgeLabelBackground}; + p { + background-color: ${t.edgeLabelBackground}; + } + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; +} +.edgeLabel .label text { + fill: ${t.transitionLabelColor||t.tertiaryTextColor}; +} +.label div .edgeLabel { + color: ${t.transitionLabelColor||t.tertiaryTextColor}; +} + +.stateLabel text { + fill: ${t.stateLabelColor}; + font-size: 10px; + font-weight: bold; +} + +.node circle.state-start { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node .fork-join { + fill: ${t.specialStateColor}; + stroke: ${t.specialStateColor}; +} + +.node circle.state-end { + fill: ${t.innerEndBackground}; + stroke: ${t.background}; + stroke-width: 1.5 +} +.end-state-inner { + fill: ${t.compositeBackground||t.background}; + // stroke: ${t.background}; + stroke-width: 1.5 +} + +.node rect { + fill: ${t.stateBkg||t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} +.node polygon { + fill: ${t.mainBkg}; + stroke: ${t.stateBorder||t.nodeBorder};; + stroke-width: 1px; +} +#statediagram-barbEnd { + fill: ${t.lineColor}; +} + +.statediagram-cluster rect { + fill: ${t.compositeTitleBackground}; + stroke: ${t.stateBorder||t.nodeBorder}; + stroke-width: 1px; +} + +.cluster-label, .nodeLabel { + color: ${t.stateLabelColor}; + // line-height: 1; +} + +.statediagram-cluster rect.outer { + rx: 5px; + ry: 5px; +} +.statediagram-state .divider { + stroke: ${t.stateBorder||t.nodeBorder}; +} + +.statediagram-state .title-state { + rx: 5px; + ry: 5px; +} +.statediagram-cluster.statediagram-cluster .inner { + fill: ${t.compositeBackground||t.background}; +} +.statediagram-cluster.statediagram-cluster-alt .inner { + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.statediagram-cluster .inner { + rx:0; + ry:0; +} + +.statediagram-state rect.basic { + rx: 5px; + ry: 5px; +} +.statediagram-state rect.divider { + stroke-dasharray: 10,10; + fill: ${t.altBackground?t.altBackground:"#efefef"}; +} + +.note-edge { + stroke-dasharray: 5; +} + +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} +.statediagram-note rect { + fill: ${t.noteBkgColor}; + stroke: ${t.noteBorderColor}; + stroke-width: 1px; + rx: 0; + ry: 0; +} + +.statediagram-note text { + fill: ${t.noteTextColor}; +} + +.statediagram-note .nodeLabel { + color: ${t.noteTextColor}; +} +.statediagram .edgeLabel { + color: red; // ${t.noteTextColor}; +} + +#dependencyStart, #dependencyEnd { + fill: ${t.lineColor}; + stroke: ${t.lineColor}; + stroke-width: 1; +} + +.statediagramTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; +} +`,"getStyles"),W6=dUe});var nP,pUe,mUe,fde,gUe,dde,pde=N(()=>{"use strict";nP={},pUe=o((t,e)=>{nP[t]=e},"set"),mUe=o(t=>nP[t],"get"),fde=o(()=>Object.keys(nP),"keys"),gUe=o(()=>fde().length,"size"),dde={get:mUe,set:pUe,keys:fde,size:gUe}});var yUe,vUe,xUe,bUe,gde,wUe,TUe,kUe,EUe,iP,mde,yde,vde=N(()=>{"use strict";dr();pde();H6();ir();gr();zt();vt();yUe=o(t=>t.append("circle").attr("class","start-state").attr("r",me().state.sizeUnit).attr("cx",me().state.padding+me().state.sizeUnit).attr("cy",me().state.padding+me().state.sizeUnit),"drawStartState"),vUe=o(t=>t.append("line").style("stroke","grey").style("stroke-dasharray","3").attr("x1",me().state.textHeight).attr("class","divider").attr("x2",me().state.textHeight*2).attr("y1",0).attr("y2",0),"drawDivider"),xUe=o((t,e)=>{let r=t.append("text").attr("x",2*me().state.padding).attr("y",me().state.textHeight+2*me().state.padding).attr("font-size",me().state.fontSize).attr("class","state-title").text(e.id),n=r.node().getBBox();return t.insert("rect",":first-child").attr("x",me().state.padding).attr("y",me().state.padding).attr("width",n.width+2*me().state.padding).attr("height",n.height+2*me().state.padding).attr("rx",me().state.radius),r},"drawSimpleState"),bUe=o((t,e)=>{let r=o(function(p,m,g){let y=p.append("tspan").attr("x",2*me().state.padding).text(m);g||y.attr("dy",me().state.textHeight)},"addTspan"),i=t.append("text").attr("x",2*me().state.padding).attr("y",me().state.textHeight+1.3*me().state.padding).attr("font-size",me().state.fontSize).attr("class","state-title").text(e.descriptions[0]).node().getBBox(),a=i.height,s=t.append("text").attr("x",me().state.padding).attr("y",a+me().state.padding*.4+me().state.dividerMargin+me().state.textHeight).attr("class","state-description"),l=!0,u=!0;e.descriptions.forEach(function(p){l||(r(s,p,u),u=!1),l=!1});let h=t.append("line").attr("x1",me().state.padding).attr("y1",me().state.padding+a+me().state.dividerMargin/2).attr("y2",me().state.padding+a+me().state.dividerMargin/2).attr("class","descr-divider"),f=s.node().getBBox(),d=Math.max(f.width,i.width);return h.attr("x2",d+3*me().state.padding),t.insert("rect",":first-child").attr("x",me().state.padding).attr("y",me().state.padding).attr("width",d+2*me().state.padding).attr("height",f.height+a+2*me().state.padding).attr("rx",me().state.radius),t},"drawDescrState"),gde=o((t,e,r)=>{let n=me().state.padding,i=2*me().state.padding,a=t.node().getBBox(),s=a.width,l=a.x,u=t.append("text").attr("x",0).attr("y",me().state.titleShift).attr("font-size",me().state.fontSize).attr("class","state-title").text(e.id),f=u.node().getBBox().width+i,d=Math.max(f,s);d===s&&(d=d+i);let p,m=t.node().getBBox();e.doc,p=l-n,f>s&&(p=(s-d)/2+n),Math.abs(l-m.x)s&&(p=l-(f-s)/2);let g=1-me().state.textHeight;return t.insert("rect",":first-child").attr("x",p).attr("y",g).attr("class",r?"alt-composit":"composit").attr("width",d).attr("height",m.height+me().state.textHeight+me().state.titleShift+1).attr("rx","0"),u.attr("x",p+n),f<=s&&u.attr("x",l+(d-i)/2-f/2+n),t.insert("rect",":first-child").attr("x",p).attr("y",me().state.titleShift-me().state.textHeight-me().state.padding).attr("width",d).attr("height",me().state.textHeight*3).attr("rx",me().state.radius),t.insert("rect",":first-child").attr("x",p).attr("y",me().state.titleShift-me().state.textHeight-me().state.padding).attr("width",d).attr("height",m.height+3+2*me().state.textHeight).attr("rx",me().state.radius),t},"addTitleAndBox"),wUe=o(t=>(t.append("circle").attr("class","end-state-outer").attr("r",me().state.sizeUnit+me().state.miniPadding).attr("cx",me().state.padding+me().state.sizeUnit+me().state.miniPadding).attr("cy",me().state.padding+me().state.sizeUnit+me().state.miniPadding),t.append("circle").attr("class","end-state-inner").attr("r",me().state.sizeUnit).attr("cx",me().state.padding+me().state.sizeUnit+2).attr("cy",me().state.padding+me().state.sizeUnit+2)),"drawEndState"),TUe=o((t,e)=>{let r=me().state.forkWidth,n=me().state.forkHeight;if(e.parentId){let i=r;r=n,n=i}return t.append("rect").style("stroke","black").style("fill","black").attr("width",r).attr("height",n).attr("x",me().state.padding).attr("y",me().state.padding)},"drawForkJoinState"),kUe=o((t,e,r,n)=>{let i=0,a=n.append("text");a.style("text-anchor","start"),a.attr("class","noteText");let s=t.replace(/\r\n/g,"
    ");s=s.replace(/\n/g,"
    ");let l=s.split(Ze.lineBreakRegex),u=1.25*me().state.noteMargin;for(let h of l){let f=h.trim();if(f.length>0){let d=a.append("tspan");if(d.text(f),u===0){let p=d.node().getBBox();u+=p.height}i+=u,d.attr("x",e+me().state.noteMargin),d.attr("y",r+i+1.25*me().state.noteMargin)}}return{textWidth:a.node().getBBox().width,textHeight:i}},"_drawLongText"),EUe=o((t,e)=>{e.attr("class","state-note");let r=e.append("rect").attr("x",0).attr("y",me().state.padding),n=e.append("g"),{textWidth:i,textHeight:a}=kUe(t,0,0,n);return r.attr("height",a+2*me().state.noteMargin),r.attr("width",i+me().state.noteMargin*2),r},"drawNote"),iP=o(function(t,e){let r=e.id,n={id:r,label:e.id,width:0,height:0},i=t.append("g").attr("id",r).attr("class","stateGroup");e.type==="start"&&yUe(i),e.type==="end"&&wUe(i),(e.type==="fork"||e.type==="join")&&TUe(i,e),e.type==="note"&&EUe(e.note.text,i),e.type==="divider"&&vUe(i),e.type==="default"&&e.descriptions.length===0&&xUe(i,e),e.type==="default"&&e.descriptions.length>0&&bUe(i,e);let a=i.node().getBBox();return n.width=a.width+2*me().state.padding,n.height=a.height+2*me().state.padding,dde.set(r,n),n},"drawState"),mde=0,yde=o(function(t,e,r){let n=o(function(u){switch(u){case Qo.relationType.AGGREGATION:return"aggregation";case Qo.relationType.EXTENSION:return"extension";case Qo.relationType.COMPOSITION:return"composition";case Qo.relationType.DEPENDENCY:return"dependency"}},"getRelationType");e.points=e.points.filter(u=>!Number.isNaN(u.y));let i=e.points,a=wl().x(function(u){return u.x}).y(function(u){return u.y}).curve(Do),s=t.append("path").attr("d",a(i)).attr("id","edge"+mde).attr("class","transition"),l="";if(me().state.arrowMarkerAbsolute&&(l=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,l=l.replace(/\(/g,"\\("),l=l.replace(/\)/g,"\\)")),s.attr("marker-end","url("+l+"#"+n(Qo.relationType.DEPENDENCY)+"End)"),r.title!==void 0){let u=t.append("g").attr("class","stateLabel"),{x:h,y:f}=Gt.calcLabelPosition(e.points),d=Ze.getRows(r.title),p=0,m=[],g=0,y=0;for(let b=0;b<=d.length;b++){let w=u.append("text").attr("text-anchor","middle").text(d[b]).attr("x",h).attr("y",f+p),C=w.node().getBBox();g=Math.max(g,C.width),y=Math.min(y,C.x),Y.info(C.x,h,f+p),p===0&&(p=w.node().getBBox().height,Y.info("Title height",p,f)),m.push(w)}let v=p*d.length;if(d.length>1){let b=(d.length-1)*p*.5;m.forEach((w,C)=>w.attr("y",f+C*p-b)),v=p*d.length}let x=u.node().getBBox();u.insert("rect",":first-child").attr("class","box").attr("x",h-g/2-me().state.padding/2).attr("y",f-v/2-me().state.padding/2-3.5).attr("width",g+me().state.padding).attr("height",v+me().state.padding),Y.info(x)}mde++},"drawEdge")});var fo,aP,SUe,CUe,AUe,_Ue,xde,bde,wde=N(()=>{"use strict";dr();gR();Vo();vt();gr();vde();zt();Ei();aP={},SUe=o(function(){},"setConf"),CUe=o(function(t){t.append("defs").append("marker").attr("id","dependencyEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"insertMarkers"),AUe=o(function(t,e,r,n){fo=me().state;let i=me().securityLevel,a;i==="sandbox"&&(a=Ge("#i"+e));let s=i==="sandbox"?Ge(a.nodes()[0].contentDocument.body):Ge("body"),l=i==="sandbox"?a.nodes()[0].contentDocument:document;Y.debug("Rendering diagram "+t);let u=s.select(`[id='${e}']`);CUe(u);let h=n.db.getRootDoc();xde(h,u,void 0,!1,s,l,n);let f=fo.padding,d=u.node().getBBox(),p=d.width+f*2,m=d.height+f*2,g=p*1.75;vn(u,m,g,fo.useMaxWidth),u.attr("viewBox",`${d.x-fo.padding} ${d.y-fo.padding} `+p+" "+m)},"draw"),_Ue=o(t=>t?t.length*fo.fontSizeFactor:1,"getLabelWidth"),xde=o((t,e,r,n,i,a,s)=>{let l=new sn({compound:!0,multigraph:!0}),u,h=!0;for(u=0;u{let T=C.parentElement,E=0,A=0;T&&(T.parentElement&&(E=T.parentElement.getBBox().width),A=parseInt(T.getAttribute("data-x-shift"),10),Number.isNaN(A)&&(A=0)),C.setAttribute("x1",0-A+8),C.setAttribute("x2",E-A-8)})):Y.debug("No Node "+b+": "+JSON.stringify(l.node(b)))});let v=y.getBBox();l.edges().forEach(function(b){b!==void 0&&l.edge(b)!==void 0&&(Y.debug("Edge "+b.v+" -> "+b.w+": "+JSON.stringify(l.edge(b))),yde(e,l.edge(b),l.edge(b).relation))}),v=y.getBBox();let x={id:r||"root",label:r||"root",width:0,height:0};return x.width=v.width+2*fo.padding,x.height=v.height+2*fo.padding,Y.debug("Doc rendered",x,l),x},"renderDoc"),bde={setConf:SUe,draw:AUe}});var Tde={};hr(Tde,{diagram:()=>DUe});var DUe,kde=N(()=>{"use strict";$O();H6();rP();wde();DUe={parser:I6,get db(){return new Qo(1)},renderer:bde,styles:W6,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var Cde={};hr(Cde,{diagram:()=>MUe});var MUe,Ade=N(()=>{"use strict";$O();H6();rP();eP();MUe={parser:I6,get db(){return new Qo(2)},renderer:ide,styles:W6,init:o(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")}});var sP,Lde,Rde=N(()=>{"use strict";sP=function(){var t=o(function(d,p,m,g){for(m=m||{},g=d.length;g--;m[d[g]]=p);return m},"o"),e=[6,8,10,11,12,14,16,17,18],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,14],u={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,journey:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,taskName:18,taskData:19,$accept:0,$end:1},terminals_:{2:"error",4:"journey",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",18:"taskName",19:"taskData"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,2]],performAction:o(function(p,m,g,y,v,x,b){var w=x.length-1;switch(v){case 1:return x[w-1];case 2:this.$=[];break;case 3:x[w-1].push(x[w]),this.$=x[w-1];break;case 4:case 5:this.$=x[w];break;case 6:case 7:this.$=[];break;case 8:y.setDiagramTitle(x[w].substr(6)),this.$=x[w].substr(6);break;case 9:this.$=x[w].trim(),y.setAccTitle(this.$);break;case 10:case 11:this.$=x[w].trim(),y.setAccDescription(this.$);break;case 12:y.addSection(x[w].substr(8)),this.$=x[w].substr(8);break;case 13:y.addTask(x[w-1],x[w]),this.$="task";break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:15,11:r,12:n,14:i,16:a,17:s,18:l},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,16]},{15:[1,17]},t(e,[2,11]),t(e,[2,12]),{19:[1,18]},t(e,[2,4]),t(e,[2,9]),t(e,[2,10]),t(e,[2,13])],defaultActions:{},parseError:o(function(p,m){if(m.recoverable)this.trace(p);else{var g=new Error(p);throw g.hash=m,g}},"parseError"),parse:o(function(p){var m=this,g=[0],y=[],v=[null],x=[],b=this.table,w="",C=0,T=0,E=0,A=2,S=1,_=x.slice.call(arguments,1),I=Object.create(this.lexer),D={yy:{}};for(var k in this.yy)Object.prototype.hasOwnProperty.call(this.yy,k)&&(D.yy[k]=this.yy[k]);I.setInput(p,D.yy),D.yy.lexer=I,D.yy.parser=this,typeof I.yylloc>"u"&&(I.yylloc={});var L=I.yylloc;x.push(L);var R=I.options&&I.options.ranges;typeof D.yy.parseError=="function"?this.parseError=D.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function O(K){g.length=g.length-2*K,v.length=v.length-K,x.length=x.length-K}o(O,"popStack");function M(){var K;return K=y.pop()||I.lex()||S,typeof K!="number"&&(K instanceof Array&&(y=K,K=y.pop()),K=m.symbols_[K]||K),K}o(M,"lex");for(var B,F,P,z,$,H,Q={},j,ie,ne,le;;){if(P=g[g.length-1],this.defaultActions[P]?z=this.defaultActions[P]:((B===null||typeof B>"u")&&(B=M()),z=b[P]&&b[P][B]),typeof z>"u"||!z.length||!z[0]){var he="";le=[];for(j in b[P])this.terminals_[j]&&j>A&&le.push("'"+this.terminals_[j]+"'");I.showPosition?he="Parse error on line "+(C+1)+`: +`+I.showPosition()+` +Expecting `+le.join(", ")+", got '"+(this.terminals_[B]||B)+"'":he="Parse error on line "+(C+1)+": Unexpected "+(B==S?"end of input":"'"+(this.terminals_[B]||B)+"'"),this.parseError(he,{text:I.match,token:this.terminals_[B]||B,line:I.yylineno,loc:L,expected:le})}if(z[0]instanceof Array&&z.length>1)throw new Error("Parse Error: multiple actions possible at state: "+P+", token: "+B);switch(z[0]){case 1:g.push(B),v.push(I.yytext),x.push(I.yylloc),g.push(z[1]),B=null,F?(B=F,F=null):(T=I.yyleng,w=I.yytext,C=I.yylineno,L=I.yylloc,E>0&&E--);break;case 2:if(ie=this.productions_[z[1]][1],Q.$=v[v.length-ie],Q._$={first_line:x[x.length-(ie||1)].first_line,last_line:x[x.length-1].last_line,first_column:x[x.length-(ie||1)].first_column,last_column:x[x.length-1].last_column},R&&(Q._$.range=[x[x.length-(ie||1)].range[0],x[x.length-1].range[1]]),H=this.performAction.apply(Q,[w,T,C,D.yy,z[1],v,x].concat(_)),typeof H<"u")return H;ie&&(g=g.slice(0,-1*ie*2),v=v.slice(0,-1*ie),x=x.slice(0,-1*ie)),g.push(this.productions_[z[1]][0]),v.push(Q.$),x.push(Q._$),ne=b[g[g.length-2]][g[g.length-1]],g.push(ne);break;case 3:return!0}}return!0},"parse")},h=function(){var d={EOF:1,parseError:o(function(m,g){if(this.yy.parser)this.yy.parser.parseError(m,g);else throw new Error(m)},"parseError"),setInput:o(function(p,m){return this.yy=m||this.yy||{},this._input=p,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var p=this._input[0];this.yytext+=p,this.yyleng++,this.offset++,this.match+=p,this.matched+=p;var m=p.match(/(?:\r\n?|\n).*/g);return m?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),p},"input"),unput:o(function(p){var m=p.length,g=p.split(/(?:\r\n?|\n)/g);this._input=p+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-m),this.offset-=m;var y=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),g.length-1&&(this.yylineno-=g.length-1);var v=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:g?(g.length===y.length?this.yylloc.first_column:0)+y[y.length-g.length].length-g[0].length:this.yylloc.first_column-m},this.options.ranges&&(this.yylloc.range=[v[0],v[0]+this.yyleng-m]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(p){this.unput(this.match.slice(p))},"less"),pastInput:o(function(){var p=this.matched.substr(0,this.matched.length-this.match.length);return(p.length>20?"...":"")+p.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var p=this.match;return p.length<20&&(p+=this._input.substr(0,20-p.length)),(p.substr(0,20)+(p.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var p=this.pastInput(),m=new Array(p.length+1).join("-");return p+this.upcomingInput()+` +`+m+"^"},"showPosition"),test_match:o(function(p,m){var g,y,v;if(this.options.backtrack_lexer&&(v={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(v.yylloc.range=this.yylloc.range.slice(0))),y=p[0].match(/(?:\r\n?|\n).*/g),y&&(this.yylineno+=y.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:y?y[y.length-1].length-y[y.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+p[0].length},this.yytext+=p[0],this.match+=p[0],this.matches=p,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(p[0].length),this.matched+=p[0],g=this.performAction.call(this,this.yy,this,m,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),g)return g;if(this._backtrack){for(var x in v)this[x]=v[x];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var p,m,g,y;this._more||(this.yytext="",this.match="");for(var v=this._currentRules(),x=0;xm[0].length)){if(m=g,y=x,this.options.backtrack_lexer){if(p=this.test_match(g,v[x]),p!==!1)return p;if(this._backtrack){m=!1;continue}else return!1}else if(!this.options.flex)break}return m?(p=this.test_match(m,v[y]),p!==!1?p:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var m=this.next();return m||this.lex()},"lex"),begin:o(function(m){this.conditionStack.push(m)},"begin"),popState:o(function(){var m=this.conditionStack.length-1;return m>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(m){return m=this.conditionStack.length-1-Math.abs(m||0),m>=0?this.conditionStack[m]:"INITIAL"},"topState"),pushState:o(function(m){this.begin(m)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(m,g,y,v){var x=v;switch(y){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 18;case 16:return 19;case 17:return":";case 18:return 6;case 19:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:journey\b)/i,/^(?:title\s[^#\n;]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^#:\n;]+)/i,/^(?:[^#:\n;]+)/i,/^(?::[^#\n;]+)/i,/^(?::)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18,19],inclusive:!0}}};return d}();u.lexer=h;function f(){this.yy={}}return o(f,"Parser"),f.prototype=u,u.Parser=f,new f}();sP.parser=sP;Lde=sP});var M1,oP,Sb,Cb,BUe,FUe,$Ue,zUe,GUe,VUe,UUe,Nde,HUe,lP,Mde=N(()=>{"use strict";zt();mi();M1="",oP=[],Sb=[],Cb=[],BUe=o(function(){oP.length=0,Sb.length=0,M1="",Cb.length=0,Ar()},"clear"),FUe=o(function(t){M1=t,oP.push(t)},"addSection"),$Ue=o(function(){return oP},"getSections"),zUe=o(function(){let t=Nde(),e=100,r=0;for(;!t&&r{r.people&&t.push(...r.people)}),[...new Set(t)].sort()},"updateActors"),VUe=o(function(t,e){let r=e.substr(1).split(":"),n=0,i=[];r.length===1?(n=Number(r[0]),i=[]):(n=Number(r[0]),i=r[1].split(","));let a=i.map(l=>l.trim()),s={section:M1,type:M1,people:a,task:t,score:n};Cb.push(s)},"addTask"),UUe=o(function(t){let e={section:M1,type:M1,description:t,task:t,classes:[]};Sb.push(e)},"addTaskOrg"),Nde=o(function(){let t=o(function(r){return Cb[r].processed},"compileTask"),e=!0;for(let[r,n]of Cb.entries())t(r),e=e&&n.processed;return e},"compileTasks"),HUe=o(function(){return GUe()},"getActors"),lP={getConfig:o(()=>me().journey,"getConfig"),clear:BUe,setDiagramTitle:$r,getDiagramTitle:Ir,setAccTitle:Lr,getAccTitle:Rr,setAccDescription:Nr,getAccDescription:Mr,addSection:FUe,getSections:$Ue,getTasks:zUe,addTask:VUe,addTaskOrg:UUe,getActors:HUe}});var WUe,Ide,Ode=N(()=>{"use strict";WUe=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.textColor}; + } + .mouth { + stroke: #666; + } + + line { + stroke: ${t.textColor} + } + + .legend { + fill: ${t.textColor}; + font-family: ${t.fontFamily}; + } + + .label text { + fill: #333; + } + .label { + color: ${t.textColor} + } + + .face { + ${t.faceColor?`fill: ${t.faceColor}`:"fill: #FFF8DC"}; + stroke: #999; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 1.5px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + } + text-align: center; + } + + .cluster rect { + } + + .cluster text { + fill: ${t.titleColor}; + } + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .task-type-0, .section-type-0 { + ${t.fillType0?`fill: ${t.fillType0}`:""}; + } + .task-type-1, .section-type-1 { + ${t.fillType0?`fill: ${t.fillType1}`:""}; + } + .task-type-2, .section-type-2 { + ${t.fillType0?`fill: ${t.fillType2}`:""}; + } + .task-type-3, .section-type-3 { + ${t.fillType0?`fill: ${t.fillType3}`:""}; + } + .task-type-4, .section-type-4 { + ${t.fillType0?`fill: ${t.fillType4}`:""}; + } + .task-type-5, .section-type-5 { + ${t.fillType0?`fill: ${t.fillType5}`:""}; + } + .task-type-6, .section-type-6 { + ${t.fillType0?`fill: ${t.fillType6}`:""}; + } + .task-type-7, .section-type-7 { + ${t.fillType0?`fill: ${t.fillType7}`:""}; + } + + .actor-0 { + ${t.actor0?`fill: ${t.actor0}`:""}; + } + .actor-1 { + ${t.actor1?`fill: ${t.actor1}`:""}; + } + .actor-2 { + ${t.actor2?`fill: ${t.actor2}`:""}; + } + .actor-3 { + ${t.actor3?`fill: ${t.actor3}`:""}; + } + .actor-4 { + ${t.actor4?`fill: ${t.actor4}`:""}; + } + .actor-5 { + ${t.actor5?`fill: ${t.actor5}`:""}; + } +`,"getStyles"),Ide=WUe});var cP,qUe,Bde,Fde,YUe,XUe,Pde,jUe,KUe,$de,QUe,I1,zde=N(()=>{"use strict";dr();Wv();cP=o(function(t,e){return kd(t,e)},"drawRect"),qUe=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=bl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=bl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),Bde=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),Fde=o(function(t,e){return Nq(t,e)},"drawText"),YUe=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,Fde(t,e)},"drawLabel"),XUe=o(function(t,e,r){let n=t.append("g"),i=Tl();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width*e.taskCount+r.diagramMarginX*(e.taskCount-1),i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,cP(n,i),$de(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),Pde=-1,jUe=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");Pde++;let a=300+5*30;i.append("line").attr("id","task"+Pde).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",a).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),qUe(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=Tl();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,cP(i,s);let l=e.x+14;e.people.forEach(u=>{let h=e.actors[u].color,f={cx:l,cy:e.y,r:7,fill:h,stroke:"#000",title:u,pos:e.actors[u].position};Bde(i,f),l+=10}),$de(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),KUe=o(function(t,e){q5(t,e)},"drawBackgroundRect"),$de=function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{let i=ju[n].color,a={cx:20,cy:r,r:7,fill:i,stroke:"#000",pos:ju[n].position};I1.drawCircle(t,a);let s={x:40,y:r+7,fill:"#666",text:n,textMargin:e.boxTextMargin|5};I1.drawText(t,s),r+=20})}var ZUe,ju,q6,Np,eHe,Zo,uP,Gde,tHe,hP,Vde=N(()=>{"use strict";dr();zde();zt();Ei();ZUe=o(function(t){Object.keys(t).forEach(function(r){q6[r]=t[r]})},"setConf"),ju={};o(JUe,"drawActorLegend");q6=me().journey,Np=q6.leftMargin,eHe=o(function(t,e,r,n){let i=me().journey,a=me().securityLevel,s;a==="sandbox"&&(s=Ge("#i"+e));let l=a==="sandbox"?Ge(s.nodes()[0].contentDocument.body):Ge("body");Zo.init();let u=l.select("#"+e);I1.initGraphics(u);let h=n.db.getTasks(),f=n.db.getDiagramTitle(),d=n.db.getActors();for(let x in ju)delete ju[x];let p=0;d.forEach(x=>{ju[x]={color:i.actorColours[p%i.actorColours.length],position:p},p++}),JUe(u),Zo.insert(0,0,Np,Object.keys(ju).length*50),tHe(u,h,0);let m=Zo.getBounds();f&&u.append("text").text(f).attr("x",Np).attr("font-size","4ex").attr("font-weight","bold").attr("y",25);let g=m.stopy-m.starty+2*i.diagramMarginY,y=Np+m.stopx+2*i.diagramMarginX;vn(u,g,y,i.useMaxWidth),u.append("line").attr("x1",Np).attr("y1",i.height*4).attr("x2",y-Np-4).attr("y2",i.height*4).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)");let v=f?70:0;u.attr("viewBox",`${m.startx} -25 ${y} ${g+v}`),u.attr("preserveAspectRatio","xMinYMin meet"),u.attr("height",g+v+25)},"draw"),Zo={data:{startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},verticalPos:0,sequenceItems:[],init:o(function(){this.sequenceItems=[],this.data={startx:void 0,stopx:void 0,starty:void 0,stopy:void 0},this.verticalPos=0},"init"),updateVal:o(function(t,e,r,n){t[e]===void 0?t[e]=r:t[e]=n(r,t[e])},"updateVal"),updateBounds:o(function(t,e,r,n){let i=me().journey,a=this,s=0;function l(u){return o(function(f){s++;let d=a.sequenceItems.length-s+1;a.updateVal(f,"starty",e-d*i.boxMargin,Math.min),a.updateVal(f,"stopy",n+d*i.boxMargin,Math.max),a.updateVal(Zo.data,"startx",t-d*i.boxMargin,Math.min),a.updateVal(Zo.data,"stopx",r+d*i.boxMargin,Math.max),u!=="activation"&&(a.updateVal(f,"startx",t-d*i.boxMargin,Math.min),a.updateVal(f,"stopx",r+d*i.boxMargin,Math.max),a.updateVal(Zo.data,"starty",e-d*i.boxMargin,Math.min),a.updateVal(Zo.data,"stopy",n+d*i.boxMargin,Math.max))},"updateItemBounds")}o(l,"updateFn"),this.sequenceItems.forEach(l())},"updateBounds"),insert:o(function(t,e,r,n){let i=Math.min(t,r),a=Math.max(t,r),s=Math.min(e,n),l=Math.max(e,n);this.updateVal(Zo.data,"startx",i,Math.min),this.updateVal(Zo.data,"starty",s,Math.min),this.updateVal(Zo.data,"stopx",a,Math.max),this.updateVal(Zo.data,"stopy",l,Math.max),this.updateBounds(i,s,a,l)},"insert"),bumpVerticalPos:o(function(t){this.verticalPos=this.verticalPos+t,this.data.stopy=this.verticalPos},"bumpVerticalPos"),getVerticalPos:o(function(){return this.verticalPos},"getVerticalPos"),getBounds:o(function(){return this.data},"getBounds")},uP=q6.sectionFills,Gde=q6.sectionColours,tHe=o(function(t,e,r){let n=me().journey,i="",a=n.height*2+n.diagramMarginY,s=r+a,l=0,u="#CCC",h="black",f=0;for(let[d,p]of e.entries()){if(i!==p.section){u=uP[l%uP.length],f=l%uP.length,h=Gde[l%Gde.length];let g=0,y=p.section;for(let x=d;x(ju[y]&&(g[y]=ju[y]),g),{});p.x=d*n.taskMargin+d*n.width+Np,p.y=s,p.width=n.diagramMarginX,p.height=n.diagramMarginY,p.colour=h,p.fill=u,p.num=f,p.actors=m,I1.drawTask(t,p,n),Zo.insert(p.x,p.y,p.x+p.width+n.taskMargin,300+5*30)}},"drawTasks"),hP={setConf:ZUe,draw:eHe}});var Ude={};hr(Ude,{diagram:()=>rHe});var rHe,Hde=N(()=>{"use strict";Rde();Mde();Ode();Vde();rHe={parser:Lde,db:lP,renderer:hP,styles:Ide,init:o(t=>{hP.setConf(t.journey),lP.clear()},"init")}});var dP,Qde,Zde=N(()=>{"use strict";dP=function(){var t=o(function(p,m,g,y){for(g=g||{},y=p.length;y--;g[p[y]]=m);return g},"o"),e=[6,8,10,11,12,14,16,17,20,21],r=[1,9],n=[1,10],i=[1,11],a=[1,12],s=[1,13],l=[1,16],u=[1,17],h={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,timeline:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,period_statement:18,event_statement:19,period:20,event:21,$accept:0,$end:1},terminals_:{2:"error",4:"timeline",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",20:"period",21:"event"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[18,1],[19,1]],performAction:o(function(m,g,y,v,x,b,w){var C=b.length-1;switch(x){case 1:return b[C-1];case 2:this.$=[];break;case 3:b[C-1].push(b[C]),this.$=b[C-1];break;case 4:case 5:this.$=b[C];break;case 6:case 7:this.$=[];break;case 8:v.getCommonDb().setDiagramTitle(b[C].substr(6)),this.$=b[C].substr(6);break;case 9:this.$=b[C].trim(),v.getCommonDb().setAccTitle(this.$);break;case 10:case 11:this.$=b[C].trim(),v.getCommonDb().setAccDescription(this.$);break;case 12:v.addSection(b[C].substr(8)),this.$=b[C].substr(8);break;case 15:v.addTask(b[C],0,""),this.$=b[C];break;case 16:v.addEvent(b[C].substr(2)),this.$=b[C];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},t(e,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,7],{1:[2,1]}),t(e,[2,3]),{9:18,11:r,12:n,14:i,16:a,17:s,18:14,19:15,20:l,21:u},t(e,[2,5]),t(e,[2,6]),t(e,[2,8]),{13:[1,19]},{15:[1,20]},t(e,[2,11]),t(e,[2,12]),t(e,[2,13]),t(e,[2,14]),t(e,[2,15]),t(e,[2,16]),t(e,[2,4]),t(e,[2,9]),t(e,[2,10])],defaultActions:{},parseError:o(function(m,g){if(g.recoverable)this.trace(m);else{var y=new Error(m);throw y.hash=g,y}},"parseError"),parse:o(function(m){var g=this,y=[0],v=[],x=[null],b=[],w=this.table,C="",T=0,E=0,A=0,S=2,_=1,I=b.slice.call(arguments,1),D=Object.create(this.lexer),k={yy:{}};for(var L in this.yy)Object.prototype.hasOwnProperty.call(this.yy,L)&&(k.yy[L]=this.yy[L]);D.setInput(m,k.yy),k.yy.lexer=D,k.yy.parser=this,typeof D.yylloc>"u"&&(D.yylloc={});var R=D.yylloc;b.push(R);var O=D.options&&D.options.ranges;typeof k.yy.parseError=="function"?this.parseError=k.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function M(X){y.length=y.length-2*X,x.length=x.length-X,b.length=b.length-X}o(M,"popStack");function B(){var X;return X=v.pop()||D.lex()||_,typeof X!="number"&&(X instanceof Array&&(v=X,X=v.pop()),X=g.symbols_[X]||X),X}o(B,"lex");for(var F,P,z,$,H,Q,j={},ie,ne,le,he;;){if(z=y[y.length-1],this.defaultActions[z]?$=this.defaultActions[z]:((F===null||typeof F>"u")&&(F=B()),$=w[z]&&w[z][F]),typeof $>"u"||!$.length||!$[0]){var K="";he=[];for(ie in w[z])this.terminals_[ie]&&ie>S&&he.push("'"+this.terminals_[ie]+"'");D.showPosition?K="Parse error on line "+(T+1)+`: +`+D.showPosition()+` +Expecting `+he.join(", ")+", got '"+(this.terminals_[F]||F)+"'":K="Parse error on line "+(T+1)+": Unexpected "+(F==_?"end of input":"'"+(this.terminals_[F]||F)+"'"),this.parseError(K,{text:D.match,token:this.terminals_[F]||F,line:D.yylineno,loc:R,expected:he})}if($[0]instanceof Array&&$.length>1)throw new Error("Parse Error: multiple actions possible at state: "+z+", token: "+F);switch($[0]){case 1:y.push(F),x.push(D.yytext),b.push(D.yylloc),y.push($[1]),F=null,P?(F=P,P=null):(E=D.yyleng,C=D.yytext,T=D.yylineno,R=D.yylloc,A>0&&A--);break;case 2:if(ne=this.productions_[$[1]][1],j.$=x[x.length-ne],j._$={first_line:b[b.length-(ne||1)].first_line,last_line:b[b.length-1].last_line,first_column:b[b.length-(ne||1)].first_column,last_column:b[b.length-1].last_column},O&&(j._$.range=[b[b.length-(ne||1)].range[0],b[b.length-1].range[1]]),Q=this.performAction.apply(j,[C,E,T,k.yy,$[1],x,b].concat(I)),typeof Q<"u")return Q;ne&&(y=y.slice(0,-1*ne*2),x=x.slice(0,-1*ne),b=b.slice(0,-1*ne)),y.push(this.productions_[$[1]][0]),x.push(j.$),b.push(j._$),le=w[y[y.length-2]][y[y.length-1]],y.push(le);break;case 3:return!0}}return!0},"parse")},f=function(){var p={EOF:1,parseError:o(function(g,y){if(this.yy.parser)this.yy.parser.parseError(g,y);else throw new Error(g)},"parseError"),setInput:o(function(m,g){return this.yy=g||this.yy||{},this._input=m,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var m=this._input[0];this.yytext+=m,this.yyleng++,this.offset++,this.match+=m,this.matched+=m;var g=m.match(/(?:\r\n?|\n).*/g);return g?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),m},"input"),unput:o(function(m){var g=m.length,y=m.split(/(?:\r\n?|\n)/g);this._input=m+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-g),this.offset-=g;var v=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),y.length-1&&(this.yylineno-=y.length-1);var x=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:y?(y.length===v.length?this.yylloc.first_column:0)+v[v.length-y.length].length-y[0].length:this.yylloc.first_column-g},this.options.ranges&&(this.yylloc.range=[x[0],x[0]+this.yyleng-g]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(m){this.unput(this.match.slice(m))},"less"),pastInput:o(function(){var m=this.matched.substr(0,this.matched.length-this.match.length);return(m.length>20?"...":"")+m.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var m=this.match;return m.length<20&&(m+=this._input.substr(0,20-m.length)),(m.substr(0,20)+(m.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var m=this.pastInput(),g=new Array(m.length+1).join("-");return m+this.upcomingInput()+` +`+g+"^"},"showPosition"),test_match:o(function(m,g){var y,v,x;if(this.options.backtrack_lexer&&(x={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(x.yylloc.range=this.yylloc.range.slice(0))),v=m[0].match(/(?:\r\n?|\n).*/g),v&&(this.yylineno+=v.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:v?v[v.length-1].length-v[v.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+m[0].length},this.yytext+=m[0],this.match+=m[0],this.matches=m,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(m[0].length),this.matched+=m[0],y=this.performAction.call(this,this.yy,this,g,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),y)return y;if(this._backtrack){for(var b in x)this[b]=x[b];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var m,g,y,v;this._more||(this.yytext="",this.match="");for(var x=this._currentRules(),b=0;bg[0].length)){if(g=y,v=b,this.options.backtrack_lexer){if(m=this.test_match(y,x[b]),m!==!1)return m;if(this._backtrack){g=!1;continue}else return!1}else if(!this.options.flex)break}return g?(m=this.test_match(g,x[v]),m!==!1?m:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var g=this.next();return g||this.lex()},"lex"),begin:o(function(g){this.conditionStack.push(g)},"begin"),popState:o(function(){var g=this.conditionStack.length-1;return g>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(g){return g=this.conditionStack.length-1-Math.abs(g||0),g>=0?this.conditionStack[g]:"INITIAL"},"topState"),pushState:o(function(g){this.begin(g)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(g,y,v,x){var b=x;switch(v){case 0:break;case 1:break;case 2:return 10;case 3:break;case 4:break;case 5:return 4;case 6:return 11;case 7:return this.begin("acc_title"),12;break;case 8:return this.popState(),"acc_title_value";break;case 9:return this.begin("acc_descr"),14;break;case 10:return this.popState(),"acc_descr_value";break;case 11:this.begin("acc_descr_multiline");break;case 12:this.popState();break;case 13:return"acc_descr_multiline_value";case 14:return 17;case 15:return 21;case 16:return 20;case 17:return 6;case 18:return"INVALID"}},"anonymous"),rules:[/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:#[^\n]*)/i,/^(?:timeline\b)/i,/^(?:title\s[^\n]+)/i,/^(?:accTitle\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*:\s*)/i,/^(?:(?!\n||)*[^\n]*)/i,/^(?:accDescr\s*\{\s*)/i,/^(?:[\}])/i,/^(?:[^\}]*)/i,/^(?:section\s[^:\n]+)/i,/^(?::\s[^:\n]+)/i,/^(?:[^#:\n]+)/i,/^(?:$)/i,/^(?:.)/i],conditions:{acc_descr_multiline:{rules:[12,13],inclusive:!1},acc_descr:{rules:[10],inclusive:!1},acc_title:{rules:[8],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,9,11,14,15,16,17,18],inclusive:!0}}};return p}();h.lexer=f;function d(){this.yy={}}return o(d,"Parser"),d.prototype=h,h.Parser=d,new d}();dP.parser=dP;Qde=dP});var mP={};hr(mP,{addEvent:()=>ope,addSection:()=>npe,addTask:()=>spe,addTaskOrg:()=>lpe,clear:()=>rpe,default:()=>hHe,getCommonDb:()=>tpe,getSections:()=>ipe,getTasks:()=>ape});var O1,epe,pP,Y6,P1,tpe,rpe,npe,ipe,ape,spe,ope,lpe,Jde,hHe,cpe=N(()=>{"use strict";mi();O1="",epe=0,pP=[],Y6=[],P1=[],tpe=o(()=>qy,"getCommonDb"),rpe=o(function(){pP.length=0,Y6.length=0,O1="",P1.length=0,Ar()},"clear"),npe=o(function(t){O1=t,pP.push(t)},"addSection"),ipe=o(function(){return pP},"getSections"),ape=o(function(){let t=Jde(),e=100,r=0;for(;!t&&rr.id===epe-1).events.push(t)},"addEvent"),lpe=o(function(t){let e={section:O1,type:O1,description:t,task:t,classes:[]};Y6.push(e)},"addTaskOrg"),Jde=o(function(){let t=o(function(r){return P1[r].processed},"compileTask"),e=!0;for(let[r,n]of P1.entries())t(r),e=e&&n.processed;return e},"compileTasks"),hHe={clear:rpe,getCommonDb:tpe,addSection:npe,getSections:ipe,getTasks:ape,addTask:spe,addTaskOrg:lpe,addEvent:ope}});function dpe(t,e){t.each(function(){var r=Ge(this),n=r.text().split(/(\s+|
    )/).reverse(),i,a=[],s=1.1,l=r.attr("y"),u=parseFloat(r.attr("dy")),h=r.text(null).append("tspan").attr("x",0).attr("y",l).attr("dy",u+"em");for(let f=0;fe||i==="
    ")&&(a.pop(),h.text(a.join(" ").trim()),i==="
    "?a=[""]:a=[i],h=r.append("tspan").attr("x",0).attr("y",l).attr("dy",s+"em").text(i))})}var fHe,X6,dHe,pHe,hpe,mHe,gHe,upe,yHe,vHe,xHe,gP,fpe,bHe,wHe,THe,kHe,bf,ppe=N(()=>{"use strict";dr();fHe=12,X6=o(function(t,e){let r=t.append("rect");return r.attr("x",e.x),r.attr("y",e.y),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("width",e.width),r.attr("height",e.height),r.attr("rx",e.rx),r.attr("ry",e.ry),e.class!==void 0&&r.attr("class",e.class),r},"drawRect"),dHe=o(function(t,e){let n=t.append("circle").attr("cx",e.cx).attr("cy",e.cy).attr("class","face").attr("r",15).attr("stroke-width",2).attr("overflow","visible"),i=t.append("g");i.append("circle").attr("cx",e.cx-15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666"),i.append("circle").attr("cx",e.cx+15/3).attr("cy",e.cy-15/3).attr("r",1.5).attr("stroke-width",2).attr("fill","#666").attr("stroke","#666");function a(u){let h=bl().startAngle(Math.PI/2).endAngle(3*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+2)+")")}o(a,"smile");function s(u){let h=bl().startAngle(3*Math.PI/2).endAngle(5*(Math.PI/2)).innerRadius(7.5).outerRadius(6.8181818181818175);u.append("path").attr("class","mouth").attr("d",h).attr("transform","translate("+e.cx+","+(e.cy+7)+")")}o(s,"sad");function l(u){u.append("line").attr("class","mouth").attr("stroke",2).attr("x1",e.cx-5).attr("y1",e.cy+7).attr("x2",e.cx+5).attr("y2",e.cy+7).attr("class","mouth").attr("stroke-width","1px").attr("stroke","#666")}return o(l,"ambivalent"),e.score>3?a(i):e.score<3?s(i):l(i),n},"drawFace"),pHe=o(function(t,e){let r=t.append("circle");return r.attr("cx",e.cx),r.attr("cy",e.cy),r.attr("class","actor-"+e.pos),r.attr("fill",e.fill),r.attr("stroke",e.stroke),r.attr("r",e.r),r.class!==void 0&&r.attr("class",r.class),e.title!==void 0&&r.append("title").text(e.title),r},"drawCircle"),hpe=o(function(t,e){let r=e.text.replace(//gi," "),n=t.append("text");n.attr("x",e.x),n.attr("y",e.y),n.attr("class","legend"),n.style("text-anchor",e.anchor),e.class!==void 0&&n.attr("class",e.class);let i=n.append("tspan");return i.attr("x",e.x+e.textMargin*2),i.text(r),n},"drawText"),mHe=o(function(t,e){function r(i,a,s,l,u){return i+","+a+" "+(i+s)+","+a+" "+(i+s)+","+(a+l-u)+" "+(i+s-u*1.2)+","+(a+l)+" "+i+","+(a+l)}o(r,"genPoints");let n=t.append("polygon");n.attr("points",r(e.x,e.y,50,20,7)),n.attr("class","labelBox"),e.y=e.y+e.labelMargin,e.x=e.x+.5*e.labelMargin,hpe(t,e)},"drawLabel"),gHe=o(function(t,e,r){let n=t.append("g"),i=gP();i.x=e.x,i.y=e.y,i.fill=e.fill,i.width=r.width,i.height=r.height,i.class="journey-section section-type-"+e.num,i.rx=3,i.ry=3,X6(n,i),fpe(r)(e.text,n,i.x,i.y,i.width,i.height,{class:"journey-section section-type-"+e.num},r,e.colour)},"drawSection"),upe=-1,yHe=o(function(t,e,r){let n=e.x+r.width/2,i=t.append("g");upe++;let a=300+5*30;i.append("line").attr("id","task"+upe).attr("x1",n).attr("y1",e.y).attr("x2",n).attr("y2",a).attr("class","task-line").attr("stroke-width","1px").attr("stroke-dasharray","4 2").attr("stroke","#666"),dHe(i,{cx:n,cy:300+(5-e.score)*30,score:e.score});let s=gP();s.x=e.x,s.y=e.y,s.fill=e.fill,s.width=r.width,s.height=r.height,s.class="task task-type-"+e.num,s.rx=3,s.ry=3,X6(i,s),fpe(r)(e.task,i,s.x,s.y,s.width,s.height,{class:"task"},r,e.colour)},"drawTask"),vHe=o(function(t,e){X6(t,{x:e.startx,y:e.starty,width:e.stopx-e.startx,height:e.stopy-e.starty,fill:e.fill,class:"rect"}).lower()},"drawBackgroundRect"),xHe=o(function(){return{x:0,y:0,fill:void 0,"text-anchor":"start",width:100,height:100,textMargin:0,rx:0,ry:0}},"getTextObj"),gP=o(function(){return{x:0,y:0,width:100,anchor:"start",height:100,rx:0,ry:0}},"getNoteRect"),fpe=function(){function t(i,a,s,l,u,h,f,d){let p=a.append("text").attr("x",s+u/2).attr("y",l+h/2+5).style("font-color",d).style("text-anchor","middle").text(i);n(p,f)}o(t,"byText");function e(i,a,s,l,u,h,f,d,p){let{taskFontSize:m,taskFontFamily:g}=d,y=i.split(//gi);for(let v=0;v{"use strict";dr();ppe();vt();zt();Ei();EHe=o(function(t,e,r,n){let i=me(),a=i.leftMargin??50;Y.debug("timeline",n.db);let s=i.securityLevel,l;s==="sandbox"&&(l=Ge("#i"+e));let h=(s==="sandbox"?Ge(l.nodes()[0].contentDocument.body):Ge("body")).select("#"+e);h.append("g");let f=n.db.getTasks(),d=n.db.getCommonDb().getDiagramTitle();Y.debug("task",f),bf.initGraphics(h);let p=n.db.getSections();Y.debug("sections",p);let m=0,g=0,y=0,v=0,x=50+a,b=50;v=50;let w=0,C=!0;p.forEach(function(_){let I={number:w,descr:_,section:w,width:150,padding:20,maxHeight:m},D=bf.getVirtualNodeHeight(h,I,i);Y.debug("sectionHeight before draw",D),m=Math.max(m,D+20)});let T=0,E=0;Y.debug("tasks.length",f.length);for(let[_,I]of f.entries()){let D={number:_,descr:I,section:I.section,width:150,padding:20,maxHeight:g},k=bf.getVirtualNodeHeight(h,D,i);Y.debug("taskHeight before draw",k),g=Math.max(g,k+20),T=Math.max(T,I.events.length);let L=0;for(let R of I.events){let O={descr:R,section:I.section,number:I.section,width:150,padding:20,maxHeight:50};L+=bf.getVirtualNodeHeight(h,O,i)}E=Math.max(E,L)}Y.debug("maxSectionHeight before draw",m),Y.debug("maxTaskHeight before draw",g),p&&p.length>0?p.forEach(_=>{let I=f.filter(R=>R.section===_),D={number:w,descr:_,section:w,width:200*Math.max(I.length,1)-50,padding:20,maxHeight:m};Y.debug("sectionNode",D);let k=h.append("g"),L=bf.drawNode(k,D,w,i);Y.debug("sectionNode output",L),k.attr("transform",`translate(${x}, ${v})`),b+=m+50,I.length>0&&mpe(h,I,w,x,b,g,i,T,E,m,!1),x+=200*Math.max(I.length,1),b=v,w++}):(C=!1,mpe(h,f,w,x,b,g,i,T,E,m,!0));let A=h.node().getBBox();Y.debug("bounds",A),d&&h.append("text").text(d).attr("x",A.width/2-a).attr("font-size","4ex").attr("font-weight","bold").attr("y",20),y=C?m+g+150:g+100,h.append("g").attr("class","lineWrapper").append("line").attr("x1",a).attr("y1",y).attr("x2",A.width+3*a).attr("y2",y).attr("stroke-width",4).attr("stroke","black").attr("marker-end","url(#arrowhead)"),Ao(void 0,h,i.timeline?.padding??50,i.timeline?.useMaxWidth??!1)},"draw"),mpe=o(function(t,e,r,n,i,a,s,l,u,h,f){for(let d of e){let p={descr:d.task,section:r,number:r,width:150,padding:20,maxHeight:a};Y.debug("taskNode",p);let m=t.append("g").attr("class","taskWrapper"),y=bf.drawNode(m,p,r,s).height;if(Y.debug("taskHeight after draw",y),m.attr("transform",`translate(${n}, ${i})`),a=Math.max(a,y),d.events){let v=t.append("g").attr("class","lineWrapper"),x=a;i+=100,x=x+SHe(t,d.events,r,n,i,s),i-=100,v.append("line").attr("x1",n+190/2).attr("y1",i+a).attr("x2",n+190/2).attr("y2",i+a+(f?a:h)+u+120).attr("stroke-width",2).attr("stroke","black").attr("marker-end","url(#arrowhead)").attr("stroke-dasharray","5,5")}n=n+200,f&&!s.timeline?.disableMulticolor&&r++}i=i-10},"drawTasks"),SHe=o(function(t,e,r,n,i,a){let s=0,l=i;i=i+100;for(let u of e){let h={descr:u,section:r,number:r,width:150,padding:20,maxHeight:50};Y.debug("eventNode",h);let f=t.append("g").attr("class","eventWrapper"),p=bf.drawNode(f,h,r,a).height;s=s+p,f.attr("transform",`translate(${n}, ${i})`),i=i+10+p}return i=l,s},"drawEvents"),gpe={setConf:o(()=>{},"setConf"),draw:EHe}});var CHe,AHe,vpe,xpe=N(()=>{"use strict";Ys();CHe=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${CHe(t)} + .section-root rect, .section-root path, .section-root circle { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .eventWrapper { + filter: brightness(120%); + } +`,"getStyles"),vpe=AHe});var bpe={};hr(bpe,{diagram:()=>_He});var _He,wpe=N(()=>{"use strict";Zde();cpe();ype();xpe();_He={db:mP,renderer:gpe,parser:Qde,styles:vpe}});var yP,Epe,Spe=N(()=>{"use strict";yP=function(){var t=o(function(C,T,E,A){for(E=E||{},A=C.length;A--;E[C[A]]=T);return E},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,6,13,15,16,19,22],g=[1,33],y=[1,34],v=[1,6,7,11,13,15,16,19,22],x={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,MINDMAP:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,ICON:15,CLASS:16,nodeWithId:17,nodeWithoutId:18,NODE_DSTART:19,NODE_DESCR:20,NODE_DEND:21,NODE_ID:22,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"MINDMAP",11:"EOF",13:"SPACELIST",15:"ICON",16:"CLASS",19:"NODE_DSTART",20:"NODE_DESCR",21:"NODE_DEND",22:"NODE_ID"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,2],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[18,3],[17,1],[17,4]],performAction:o(function(T,E,A,S,_,I,D){var k=I.length-1;switch(_){case 6:case 7:return S;case 8:S.getLogger().trace("Stop NL ");break;case 9:S.getLogger().trace("Stop EOF ");break;case 11:S.getLogger().trace("Stop NL2 ");break;case 12:S.getLogger().trace("Stop EOF2 ");break;case 15:S.getLogger().info("Node: ",I[k].id),S.addNode(I[k-1].length,I[k].id,I[k].descr,I[k].type);break;case 16:S.getLogger().trace("Icon: ",I[k]),S.decorateNode({icon:I[k]});break;case 17:case 21:S.decorateNode({class:I[k]});break;case 18:S.getLogger().trace("SPACELIST");break;case 19:S.getLogger().trace("Node: ",I[k].id),S.addNode(0,I[k].id,I[k].descr,I[k].type);break;case 20:S.decorateNode({icon:I[k]});break;case 25:S.getLogger().trace("node found ..",I[k-2]),this.$={id:I[k-1],descr:I[k-1],type:S.getType(I[k-2],I[k])};break;case 26:this.$={id:I[k],descr:I[k],type:S.nodeType.DEFAULT};break;case 27:S.getLogger().trace("node found ..",I[k-3]),this.$={id:I[k-3],descr:I[k-1],type:S.getType(I[k-2],I[k])};break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:r,9:22,12:11,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},{6:h,7:f,10:23,11:d},t(p,[2,22],{17:17,18:18,14:27,15:[1,28],16:[1,29],19:s,22:l}),t(p,[2,18]),t(p,[2,19]),t(p,[2,20]),t(p,[2,21]),t(p,[2,23]),t(p,[2,24]),t(p,[2,26],{19:[1,30]}),{20:[1,31]},{6:h,7:f,10:32,11:d},{1:[2,7],6:r,12:21,13:n,14:14,15:i,16:a,17:17,18:18,19:s,22:l},t(m,[2,14],{7:g,11:y}),t(v,[2,8]),t(v,[2,9]),t(v,[2,10]),t(p,[2,15]),t(p,[2,16]),t(p,[2,17]),{20:[1,35]},{21:[1,36]},t(m,[2,13],{7:g,11:y}),t(v,[2,11]),t(v,[2,12]),{21:[1,37]},t(p,[2,25]),t(p,[2,27])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(T,E){if(E.recoverable)this.trace(T);else{var A=new Error(T);throw A.hash=E,A}},"parseError"),parse:o(function(T){var E=this,A=[0],S=[],_=[null],I=[],D=this.table,k="",L=0,R=0,O=0,M=2,B=1,F=I.slice.call(arguments,1),P=Object.create(this.lexer),z={yy:{}};for(var $ in this.yy)Object.prototype.hasOwnProperty.call(this.yy,$)&&(z.yy[$]=this.yy[$]);P.setInput(T,z.yy),z.yy.lexer=P,z.yy.parser=this,typeof P.yylloc>"u"&&(P.yylloc={});var H=P.yylloc;I.push(H);var Q=P.options&&P.options.ranges;typeof z.yy.parseError=="function"?this.parseError=z.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function j(ae){A.length=A.length-2*ae,_.length=_.length-ae,I.length=I.length-ae}o(j,"popStack");function ie(){var ae;return ae=S.pop()||P.lex()||B,typeof ae!="number"&&(ae instanceof Array&&(S=ae,ae=S.pop()),ae=E.symbols_[ae]||ae),ae}o(ie,"lex");for(var ne,le,he,K,X,te,J={},se,ue,Z,Se;;){if(he=A[A.length-1],this.defaultActions[he]?K=this.defaultActions[he]:((ne===null||typeof ne>"u")&&(ne=ie()),K=D[he]&&D[he][ne]),typeof K>"u"||!K.length||!K[0]){var ce="";Se=[];for(se in D[he])this.terminals_[se]&&se>M&&Se.push("'"+this.terminals_[se]+"'");P.showPosition?ce="Parse error on line "+(L+1)+`: +`+P.showPosition()+` +Expecting `+Se.join(", ")+", got '"+(this.terminals_[ne]||ne)+"'":ce="Parse error on line "+(L+1)+": Unexpected "+(ne==B?"end of input":"'"+(this.terminals_[ne]||ne)+"'"),this.parseError(ce,{text:P.match,token:this.terminals_[ne]||ne,line:P.yylineno,loc:H,expected:Se})}if(K[0]instanceof Array&&K.length>1)throw new Error("Parse Error: multiple actions possible at state: "+he+", token: "+ne);switch(K[0]){case 1:A.push(ne),_.push(P.yytext),I.push(P.yylloc),A.push(K[1]),ne=null,le?(ne=le,le=null):(R=P.yyleng,k=P.yytext,L=P.yylineno,H=P.yylloc,O>0&&O--);break;case 2:if(ue=this.productions_[K[1]][1],J.$=_[_.length-ue],J._$={first_line:I[I.length-(ue||1)].first_line,last_line:I[I.length-1].last_line,first_column:I[I.length-(ue||1)].first_column,last_column:I[I.length-1].last_column},Q&&(J._$.range=[I[I.length-(ue||1)].range[0],I[I.length-1].range[1]]),te=this.performAction.apply(J,[k,R,L,z.yy,K[1],_,I].concat(F)),typeof te<"u")return te;ue&&(A=A.slice(0,-1*ue*2),_=_.slice(0,-1*ue),I=I.slice(0,-1*ue)),A.push(this.productions_[K[1]][0]),_.push(J.$),I.push(J._$),Z=D[A[A.length-2]][A[A.length-1]],A.push(Z);break;case 3:return!0}}return!0},"parse")},b=function(){var C={EOF:1,parseError:o(function(E,A){if(this.yy.parser)this.yy.parser.parseError(E,A);else throw new Error(E)},"parseError"),setInput:o(function(T,E){return this.yy=E||this.yy||{},this._input=T,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var T=this._input[0];this.yytext+=T,this.yyleng++,this.offset++,this.match+=T,this.matched+=T;var E=T.match(/(?:\r\n?|\n).*/g);return E?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),T},"input"),unput:o(function(T){var E=T.length,A=T.split(/(?:\r\n?|\n)/g);this._input=T+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-E),this.offset-=E;var S=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),A.length-1&&(this.yylineno-=A.length-1);var _=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:A?(A.length===S.length?this.yylloc.first_column:0)+S[S.length-A.length].length-A[0].length:this.yylloc.first_column-E},this.options.ranges&&(this.yylloc.range=[_[0],_[0]+this.yyleng-E]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(T){this.unput(this.match.slice(T))},"less"),pastInput:o(function(){var T=this.matched.substr(0,this.matched.length-this.match.length);return(T.length>20?"...":"")+T.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var T=this.match;return T.length<20&&(T+=this._input.substr(0,20-T.length)),(T.substr(0,20)+(T.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var T=this.pastInput(),E=new Array(T.length+1).join("-");return T+this.upcomingInput()+` +`+E+"^"},"showPosition"),test_match:o(function(T,E){var A,S,_;if(this.options.backtrack_lexer&&(_={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(_.yylloc.range=this.yylloc.range.slice(0))),S=T[0].match(/(?:\r\n?|\n).*/g),S&&(this.yylineno+=S.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:S?S[S.length-1].length-S[S.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+T[0].length},this.yytext+=T[0],this.match+=T[0],this.matches=T,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(T[0].length),this.matched+=T[0],A=this.performAction.call(this,this.yy,this,E,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),A)return A;if(this._backtrack){for(var I in _)this[I]=_[I];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var T,E,A,S;this._more||(this.yytext="",this.match="");for(var _=this._currentRules(),I=0;I<_.length;I++)if(A=this._input.match(this.rules[_[I]]),A&&(!E||A[0].length>E[0].length)){if(E=A,S=I,this.options.backtrack_lexer){if(T=this.test_match(A,_[I]),T!==!1)return T;if(this._backtrack){E=!1;continue}else return!1}else if(!this.options.flex)break}return E?(T=this.test_match(E,_[S]),T!==!1?T:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var E=this.next();return E||this.lex()},"lex"),begin:o(function(E){this.conditionStack.push(E)},"begin"),popState:o(function(){var E=this.conditionStack.length-1;return E>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(E){return E=this.conditionStack.length-1-Math.abs(E||0),E>=0?this.conditionStack[E]:"INITIAL"},"topState"),pushState:o(function(E){this.begin(E)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(E,A,S,_){var I=_;switch(S){case 0:return E.getLogger().trace("Found comment",A.yytext),6;break;case 1:return 8;case 2:this.begin("CLASS");break;case 3:return this.popState(),16;break;case 4:this.popState();break;case 5:E.getLogger().trace("Begin icon"),this.begin("ICON");break;case 6:return E.getLogger().trace("SPACELINE"),6;break;case 7:return 7;case 8:return 15;case 9:E.getLogger().trace("end icon"),this.popState();break;case 10:return E.getLogger().trace("Exploding node"),this.begin("NODE"),19;break;case 11:return E.getLogger().trace("Cloud"),this.begin("NODE"),19;break;case 12:return E.getLogger().trace("Explosion Bang"),this.begin("NODE"),19;break;case 13:return E.getLogger().trace("Cloud Bang"),this.begin("NODE"),19;break;case 14:return this.begin("NODE"),19;break;case 15:return this.begin("NODE"),19;break;case 16:return this.begin("NODE"),19;break;case 17:return this.begin("NODE"),19;break;case 18:return 13;case 19:return 22;case 20:return 11;case 21:this.begin("NSTR2");break;case 22:return"NODE_DESCR";case 23:this.popState();break;case 24:E.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 25:return E.getLogger().trace("description:",A.yytext),"NODE_DESCR";break;case 26:this.popState();break;case 27:return this.popState(),E.getLogger().trace("node end ))"),"NODE_DEND";break;case 28:return this.popState(),E.getLogger().trace("node end )"),"NODE_DEND";break;case 29:return this.popState(),E.getLogger().trace("node end ...",A.yytext),"NODE_DEND";break;case 30:return this.popState(),E.getLogger().trace("node end (("),"NODE_DEND";break;case 31:return this.popState(),E.getLogger().trace("node end (-"),"NODE_DEND";break;case 32:return this.popState(),E.getLogger().trace("node end (-"),"NODE_DEND";break;case 33:return this.popState(),E.getLogger().trace("node end (("),"NODE_DEND";break;case 34:return this.popState(),E.getLogger().trace("node end (("),"NODE_DEND";break;case 35:return E.getLogger().trace("Long description:",A.yytext),20;break;case 36:return E.getLogger().trace("Long description:",A.yytext),20;break}},"anonymous"),rules:[/^(?:\s*%%.*)/i,/^(?:mindmap\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{CLASS:{rules:[3,4],inclusive:!1},ICON:{rules:[8,9],inclusive:!1},NSTR2:{rules:[22,23],inclusive:!1},NSTR:{rules:[25,26],inclusive:!1},NODE:{rules:[21,24,27,28,29,30,31,32,33,34,35,36],inclusive:!1},INITIAL:{rules:[0,1,2,5,6,7,10,11,12,13,14,15,16,17,18,19,20],inclusive:!0}}};return C}();x.lexer=b;function w(){this.yy={}}return o(w,"Parser"),w.prototype=x,x.Parser=w,new w}();yP.parser=yP;Epe=yP});var $l,Cpe,vP,NHe,MHe,IHe,OHe,Vi,PHe,BHe,FHe,$He,zHe,GHe,VHe,Ape,_pe=N(()=>{"use strict";zt();gr();vt();Ya();$l=[],Cpe=0,vP={},NHe=o(()=>{$l=[],Cpe=0,vP={}},"clear"),MHe=o(function(t){for(let e=$l.length-1;e>=0;e--)if($l[e].level$l.length>0?$l[0]:null,"getMindmap"),OHe=o((t,e,r,n)=>{Y.info("addNode",t,e,r,n);let i=me(),a=i.mindmap?.padding??or.mindmap.padding;switch(n){case Vi.ROUNDED_RECT:case Vi.RECT:case Vi.HEXAGON:a*=2}let s={id:Cpe++,nodeId:Tr(e,i),level:t,descr:Tr(r,i),type:n,children:[],width:i.mindmap?.maxNodeWidth??or.mindmap.maxNodeWidth,padding:a},l=MHe(t);if(l)l.children.push(s),$l.push(s);else if($l.length===0)$l.push(s);else throw new Error('There can be only one root. No parent could be found for ("'+s.descr+'")')},"addNode"),Vi={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},PHe=o((t,e)=>{switch(Y.debug("In get type",t,e),t){case"[":return Vi.RECT;case"(":return e===")"?Vi.ROUNDED_RECT:Vi.CLOUD;case"((":return Vi.CIRCLE;case")":return Vi.CLOUD;case"))":return Vi.BANG;case"{{":return Vi.HEXAGON;default:return Vi.DEFAULT}},"getType"),BHe=o((t,e)=>{vP[t]=e},"setElementForId"),FHe=o(t=>{if(!t)return;let e=me(),r=$l[$l.length-1];t.icon&&(r.icon=Tr(t.icon,e)),t.class&&(r.class=Tr(t.class,e))},"decorateNode"),$He=o(t=>{switch(t){case Vi.DEFAULT:return"no-border";case Vi.RECT:return"rect";case Vi.ROUNDED_RECT:return"rounded-rect";case Vi.CIRCLE:return"circle";case Vi.CLOUD:return"cloud";case Vi.BANG:return"bang";case Vi.HEXAGON:return"hexgon";default:return"no-border"}},"type2Str"),zHe=o(()=>Y,"getLogger"),GHe=o(t=>vP[t],"getElementById"),VHe={clear:NHe,addNode:OHe,getMindmap:IHe,nodeType:Vi,getType:PHe,setElementForId:BHe,decorateNode:FHe,type2Str:$He,getLogger:zHe,getElementById:GHe},Ape=VHe});function Wi(t){"@babel/helpers - typeof";return Wi=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(e){return typeof e}:function(e){return e&&typeof Symbol=="function"&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Wi(t)}function Mf(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function Dpe(t,e){for(var r=0;rt.length)&&(e=t.length);for(var r=0,n=new Array(e);r=t.length?{done:!0}:{done:!1,value:t[n++]}},"n"),e:o(function(u){throw u},"e"),f:i}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var a=!0,s=!1,l;return{s:o(function(){r=r.call(t)},"s"),n:o(function(){var u=r.next();return a=u.done,u},"n"),e:o(function(u){s=!0,l=u},"e"),f:o(function(){try{!a&&r.return!=null&&r.return()}finally{if(s)throw l}},"f")}}function yWe(t){var e=typeof t;return t!=null&&(e=="object"||e=="function")}function vWe(t,e){return e={exports:{}},t(e,e.exports),e.exports}function SWe(t){for(var e=t.length;e--&&EWe.test(t.charAt(e)););return e}function _We(t){return t&&t.slice(0,CWe(t)+1).replace(AWe,"")}function MWe(t){var e=RWe.call(t,Ab),r=t[Ab];try{t[Ab]=void 0;var n=!0}catch{}var i=NWe.call(t);return n&&(e?t[Ab]=r:delete t[Ab]),i}function BWe(t){return PWe.call(t)}function GWe(t){return t==null?t===void 0?zWe:$We:Npe&&Npe in Object(t)?IWe(t):FWe(t)}function VWe(t){return t!=null&&typeof t=="object"}function WWe(t){return typeof t=="symbol"||UWe(t)&&ame(t)==HWe}function KWe(t){if(typeof t=="number")return t;if(r4(t))return Mpe;if(zp(t)){var e=typeof t.valueOf=="function"?t.valueOf():t;t=zp(e)?e+"":e}if(typeof t!="string")return t===0?t:+t;t=DWe(t);var r=YWe.test(t);return r||XWe.test(t)?jWe(t.slice(2),r?2:8):qWe.test(t)?Mpe:+t}function eqe(t,e,r){var n,i,a,s,l,u,h=0,f=!1,d=!1,p=!0;if(typeof t!="function")throw new TypeError(QWe);e=Ipe(e)||0,zp(r)&&(f=!!r.leading,d="maxWait"in r,a=d?ZWe(Ipe(r.maxWait)||0,e):a,p="trailing"in r?!!r.trailing:p);function m(E){var A=n,S=i;return n=i=void 0,h=E,s=t.apply(S,A),s}o(m,"invokeFunc");function g(E){return h=E,l=setTimeout(x,e),f?m(E):s}o(g,"leadingEdge");function y(E){var A=E-u,S=E-h,_=e-A;return d?JWe(_,a-S):_}o(y,"remainingWait");function v(E){var A=E-u,S=E-h;return u===void 0||A>=e||A<0||d&&S>=a}o(v,"shouldInvoke");function x(){var E=xP();if(v(E))return b(E);l=setTimeout(x,y(E))}o(x,"timerExpired");function b(E){return l=void 0,p&&n?m(E):(n=i=void 0,s)}o(b,"trailingEdge");function w(){l!==void 0&&clearTimeout(l),h=0,n=u=i=l=void 0}o(w,"cancel");function C(){return l===void 0?s:b(xP())}o(C,"flush");function T(){var E=xP(),A=v(E);if(n=arguments,i=this,u=E,A){if(l===void 0)return g(u);if(d)return clearTimeout(l),l=setTimeout(x,e),m(u)}return l===void 0&&(l=setTimeout(x,e)),s}return o(T,"debounced"),T.cancel=w,T.flush=C,T}function IS(t,e,r,n,i,a){var s;return si(t)?s=t:s=Q1[t]||Q1.euclidean,e===0&&si(t)?s(i,a):s(e,r,n,i,a)}function qYe(t,e){if(OS(t))return!1;var r=typeof t;return r=="number"||r=="symbol"||r=="boolean"||t==null||r4(t)?!0:WYe.test(t)||!HYe.test(t)||e!=null&&t in Object(e)}function ZYe(t){if(!zp(t))return!1;var e=ame(t);return e==jYe||e==KYe||e==XYe||e==QYe}function tXe(t){return!!e0e&&e0e in t}function aXe(t){if(t!=null){try{return iXe.call(t)}catch{}try{return t+""}catch{}}return""}function pXe(t){if(!zp(t)||rXe(t))return!1;var e=JYe(t)?dXe:lXe;return e.test(sXe(t))}function gXe(t,e){return t?.[e]}function vXe(t,e){var r=yXe(t,e);return mXe(r)?r:void 0}function bXe(){this.__data__=jb?jb(null):{},this.size=0}function TXe(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}function AXe(t){var e=this.__data__;if(jb){var r=e[t];return r===EXe?void 0:r}return CXe.call(e,t)?e[t]:void 0}function RXe(t){var e=this.__data__;return jb?e[t]!==void 0:LXe.call(e,t)}function IXe(t,e){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=jb&&e===void 0?MXe:e,this}function ty(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e-1}function XXe(t,e){var r=this.__data__,n=PS(r,t);return n<0?(++this.size,r.push([t,e])):r[n][1]=e,this}function ry(t){var e=-1,r=t==null?0:t.length;for(this.clear();++e-1&&t%1==0&&t0;){var f=i.shift();e(f),a.add(f.id()),l&&n(i,a,f)}return t}function Fme(t,e,r){if(r.isParent())for(var n=r._private.children,i=0;i0&&arguments[0]!==void 0?arguments[0]:NKe,e=arguments.length>1?arguments[1]:void 0,r=0;r0?k=R:D=R;while(Math.abs(L)>s&&++O=a?b(I,O):M===0?O:C(I,D,D+h)}o(T,"getTForX");var E=!1;function A(){E=!0,(t!==e||r!==n)&&w()}o(A,"precompute");var S=o(function(D){return E||A(),t===e&&r===n?D:D===0?0:D===1?1:v(T(D),e,n)},"f");S.getControlPoints=function(){return[{x:t,y:e},{x:r,y:n}]};var _="generateBezier("+[t,e,r,n]+")";return S.toString=function(){return _},S}function x0e(t,e,r,n,i){if(n===1||e===r)return r;var a=i(e,r,n);return t==null||((t.roundValue||t.color)&&(a=Math.round(a)),t.min!==void 0&&(a=Math.max(a,t.min)),t.max!==void 0&&(a=Math.min(a,t.max))),a}function b0e(t,e){return t.pfValue!=null||t.value!=null?t.pfValue!=null&&(e==null||e.type.units!=="%")?t.pfValue:t.value:t}function $1(t,e,r,n,i){var a=i!=null?i.type:null;r<0?r=0:r>1&&(r=1);var s=b0e(t,i),l=b0e(e,i);if(Ct(s)&&Ct(l))return x0e(a,s,l,r,n);if(En(s)&&En(l)){for(var u=[],h=0;h0?(m==="spring"&&g.push(s.duration),s.easingImpl=dS[m].apply(null,g)):s.easingImpl=dS[m]}var y=s.easingImpl,v;if(s.duration===0?v=1:v=(r-u)/s.duration,s.applying&&(v=s.progress),v<0?v=0:v>1&&(v=1),s.delay==null){var x=s.startPosition,b=s.position;if(b&&i&&!t.locked()){var w={};Rb(x.x,b.x)&&(w.x=$1(x.x,b.x,v,y)),Rb(x.y,b.y)&&(w.y=$1(x.y,b.y,v,y)),t.position(w)}var C=s.startPan,T=s.pan,E=a.pan,A=T!=null&&n;A&&(Rb(C.x,T.x)&&(E.x=$1(C.x,T.x,v,y)),Rb(C.y,T.y)&&(E.y=$1(C.y,T.y,v,y)),t.emit("pan"));var S=s.startZoom,_=s.zoom,I=_!=null&&n;I&&(Rb(S,_)&&(a.zoom=Yb(a.minZoom,$1(S,_,v,y),a.maxZoom)),t.emit("zoom")),(A||I)&&t.emit("viewport");var D=s.style;if(D&&D.length>0&&i){for(var k=0;k=0;A--){var S=E[A];S()}E.splice(0,E.length)},"callbacks"),b=m.length-1;b>=0;b--){var w=m[b],C=w._private;if(C.stopped){m.splice(b,1),C.hooked=!1,C.playing=!1,C.started=!1,x(C.frames);continue}!C.playing&&!C.applying||(C.playing&&C.applying&&(C.applying=!1),C.started||qKe(f,w,t),WKe(f,w,t,d),C.applying&&(C.applying=!1),x(C.frames),C.step!=null&&C.step(t),w.completed()&&(m.splice(b,1),C.hooked=!1,C.playing=!1,C.started=!1,x(C.completes)),y=!0)}return!d&&m.length===0&&g.length===0&&n.push(f),y}o(i,"stepOne");for(var a=!1,s=0;s0?e.notify("draw",r):e.notify("draw")),r.unmerge(n),e.emit("step")}function tge(t){this.options=rr({},eQe,tQe,t)}function rge(t){this.options=rr({},rQe,t)}function nge(t){this.options=rr({},nQe,t)}function HS(t){this.options=rr({},iQe,t),this.options.layout=this;var e=this.options.eles.nodes(),r=this.options.eles.edges(),n=r.filter(function(i){var a=i.source().data("id"),s=i.target().data("id"),l=e.some(function(h){return h.data("id")===a}),u=e.some(function(h){return h.data("id")===s});return!l||!u});this.options.eles=this.options.eles.not(n)}function age(t){this.options=rr({},wQe,t)}function gB(t){this.options=rr({},TQe,t)}function sge(t){this.options=rr({},kQe,t)}function oge(t){this.options=rr({},EQe,t)}function lge(t){this.options=t,this.notifications=0}function hge(t,e){e.radius===0?t.lineTo(e.cx,e.cy):t.arc(e.cx,e.cy,e.radius,e.startAngle,e.endAngle,e.counterClockwise)}function vB(t,e,r,n){var i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0;return n===0||e.radius===0?{cx:e.x,cy:e.y,radius:0,startX:e.x,startY:e.y,stopX:e.x,stopY:e.y,startAngle:void 0,endAngle:void 0,counterClockwise:void 0}:(AQe(t,e,r,n,i),{cx:HP,cy:WP,radius:Bp,startX:cge,startY:uge,stopX:qP,stopY:YP,startAngle:qc.ang+Math.PI/2*Fp,endAngle:Jo.ang-Math.PI/2*Fp,counterClockwise:gS})}function fge(t){var e=[];if(t!=null){for(var r=0;r5&&arguments[5]!==void 0?arguments[5]:5,s=arguments.length>6?arguments[6]:void 0;t.beginPath(),t.moveTo(e+a,r),t.lineTo(e+n-a,r),t.quadraticCurveTo(e+n,r,e+n,r+a),t.lineTo(e+n,r+i-a),t.quadraticCurveTo(e+n,r+i,e+n-a,r+i),t.lineTo(e+a,r+i),t.quadraticCurveTo(e,r+i,e,r+i-a),t.lineTo(e,r+a),t.quadraticCurveTo(e,r,e+a,r),t.closePath(),s?t.stroke():t.fill()}function z0e(t,e,r){var n=t.createShader(e);if(t.shaderSource(n,r),t.compileShader(n),!t.getShaderParameter(n,t.COMPILE_STATUS))throw new Error(t.getShaderInfoLog(n));return n}function pZe(t,e,r){var n=z0e(t,t.VERTEX_SHADER,e),i=z0e(t,t.FRAGMENT_SHADER,r),a=t.createProgram();if(t.attachShader(a,n),t.attachShader(a,i),t.linkProgram(a),!t.getProgramParameter(a,t.LINK_STATUS))throw new Error("Could not initialize shaders");return a}function mZe(t,e,r){r===void 0&&(r=e);var n=t.makeOffscreenCanvas(e,r),i=n.context=n.getContext("2d");return n.clear=function(){return i.clearRect(0,0,n.width,n.height)},n.clear(),n}function wB(t){var e=t.pixelRatio,r=t.cy.zoom(),n=t.cy.pan();return{zoom:r*e,pan:{x:n.x*e,y:n.y*e}}}function NP(t,e,r,n,i){var a=n*r+e.x,s=i*r+e.y;return s=Math.round(t.canvasHeight-s),[a,s]}function oS(t,e,r){var n=t[0]/255,i=t[1]/255,a=t[2]/255,s=e,l=r||new Array(4);return l[0]=n*s,l[1]=i*s,l[2]=a*s,l[3]=s,l}function lS(t,e){var r=e||new Array(4);return r[0]=(t>>0&255)/255,r[1]=(t>>8&255)/255,r[2]=(t>>16&255)/255,r[3]=(t>>24&255)/255,r}function gZe(t){return t[0]+(t[1]<<8)+(t[2]<<16)+(t[3]<<24)}function yZe(t,e){var r=t.createTexture();return r.buffer=function(n){t.bindTexture(t.TEXTURE_2D,r),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR_MIPMAP_NEAREST),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!0),t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,n),t.generateMipmap(t.TEXTURE_2D),t.bindTexture(t.TEXTURE_2D,null)},r.deleteTexture=function(){t.deleteTexture(r)},r}function Sge(t,e){switch(e){case"float":return[1,t.FLOAT,4];case"vec2":return[2,t.FLOAT,4];case"vec3":return[3,t.FLOAT,4];case"vec4":return[4,t.FLOAT,4];case"int":return[1,t.INT,4];case"ivec2":return[2,t.INT,4]}}function Cge(t,e,r){switch(e){case t.FLOAT:return new Float32Array(r);case t.INT:return new Int32Array(r)}}function vZe(t,e,r,n,i,a){switch(e){case t.FLOAT:return new Float32Array(r.buffer,a*n,i);case t.INT:return new Int32Array(r.buffer,a*n,i)}}function xZe(t,e,r,n){var i=Sge(t,e),a=_i(i,2),s=a[0],l=a[1],u=Cge(t,l,n),h=t.createBuffer();return t.bindBuffer(t.ARRAY_BUFFER,h),t.bufferData(t.ARRAY_BUFFER,u,t.STATIC_DRAW),l===t.FLOAT?t.vertexAttribPointer(r,s,l,!1,0,0):l===t.INT&&t.vertexAttribIPointer(r,s,l,0,0),t.enableVertexAttribArray(r),t.bindBuffer(t.ARRAY_BUFFER,null),h}function po(t,e,r,n){var i=Sge(t,r),a=_i(i,3),s=a[0],l=a[1],u=a[2],h=Cge(t,l,e*s),f=s*u,d=t.createBuffer();t.bindBuffer(t.ARRAY_BUFFER,d),t.bufferData(t.ARRAY_BUFFER,e*f,t.DYNAMIC_DRAW),t.enableVertexAttribArray(n),l===t.FLOAT?t.vertexAttribPointer(n,s,l,!1,f,0):l===t.INT&&t.vertexAttribIPointer(n,s,l,f,0),t.vertexAttribDivisor(n,1),t.bindBuffer(t.ARRAY_BUFFER,null);for(var p=new Array(e),m=0;mbge?(RZe(t),e.call(t,a)):(NZe(t),Rge(t,a,Vb.SCREEN)))}}{var r=t.matchCanvasSize;t.matchCanvasSize=function(a){r.call(t,a),t.pickingFrameBuffer.setFramebufferAttachmentSizes(t.canvasWidth,t.canvasHeight),t.pickingFrameBuffer.needsDraw=!0}}t.findNearestElements=function(a,s,l,u){return FZe(t,a,s)};{var n=t.invalidateCachedZSortedEles;t.invalidateCachedZSortedEles=function(){n.call(t),t.pickingFrameBuffer.needsDraw=!0}}{var i=t.notify;t.notify=function(a,s){i.call(t,a,s),a==="viewport"||a==="bounds"?t.pickingFrameBuffer.needsDraw=!0:a==="background"&&t.eleDrawing.invalidate(s,{type:"node-body"})}}}function RZe(t){var e=t.data.contexts[t.WEBGL];e.clear(e.COLOR_BUFFER_BIT|e.DEPTH_BUFFER_BIT)}function NZe(t){var e=o(function(n){n.save(),n.setTransform(1,0,0,1,0,0),n.clearRect(0,0,t.canvasWidth,t.canvasHeight),n.restore()},"clear");e(t.data.contexts[t.NODE]),e(t.data.contexts[t.DRAG])}function MZe(t){var e=t.canvasWidth,r=t.canvasHeight,n=wB(t),i=n.pan,a=n.zoom,s=Gb();DS(s,s,[i.x,i.y]),TB(s,s,[a,a]);var l=Gb();TZe(l,e,r);var u=Gb();return wZe(u,l,s),u}function Lge(t,e){var r=t.canvasWidth,n=t.canvasHeight,i=wB(t),a=i.pan,s=i.zoom;e.setTransform(1,0,0,1,0,0),e.clearRect(0,0,r,n),e.translate(a.x,a.y),e.scale(s,s)}function IZe(t,e){t.drawSelectionRectangle(e,function(r){return Lge(t,r)})}function OZe(t){var e=t.data.contexts[t.NODE];e.save(),Lge(t,e),e.strokeStyle="rgba(0, 0, 0, 0.3)",e.beginPath(),e.moveTo(-1e3,0),e.lineTo(1e3,0),e.stroke(),e.beginPath(),e.moveTo(0,-1e3),e.lineTo(0,1e3),e.stroke(),e.restore()}function PZe(t){var e=o(function(i,a,s){for(var l=i.atlasManager.getRenderTypeOpts(a),u=t.data.contexts[t.NODE],h=.125,f=l.atlasCollection.atlases,d=0;d=0&&k.add(O)}return k}function FZe(t,e,r){var n=BZe(t,e,r),i=t.getCachedZSortedEles(),a,s,l=mo(n),u;try{for(l.s();!(u=l.n()).done;){var h=u.value,f=i[h];if(!a&&f.isNode()&&(a=f),!s&&f.isEdge()&&(s=f),a&&s)break}}catch(d){l.e(d)}finally{l.f()}return[a,s].filter(Boolean)}function Rge(t,e,r){var n,i;t.webglDebug&&(i=[],n=performance.now());var a=t.eleDrawing,s=0;if(r.screen&&t.data.canvasNeedsRedraw[t.SELECT_BOX]&&IZe(t,e),t.data.canvasNeedsRedraw[t.NODE]||r.picking){var l=o(function(k,L){L+=1,k.isNode()?(a.drawTexture(k,L,"node-underlay"),a.drawTexture(k,L,"node-body"),a.drawTexture(k,L,"node-label"),a.drawTexture(k,L,"node-overlay")):(a.drawEdgeLine(k,L),a.drawEdgeArrow(k,L,"source"),a.drawEdgeArrow(k,L,"target"),a.drawTexture(k,L,"edge-label"))},"draw"),u=t.data.contexts[t.WEBGL];r.screen?(u.clearColor(0,0,0,0),u.enable(u.BLEND),u.blendFunc(u.ONE,u.ONE_MINUS_SRC_ALPHA)):u.disable(u.BLEND),u.clear(u.COLOR_BUFFER_BIT|u.DEPTH_BUFFER_BIT),u.viewport(0,0,u.canvas.width,u.canvas.height);var h=MZe(t),f=t.getCachedZSortedEles();if(s=f.length,a.startFrame(h,i,r),r.screen){for(var d=0;d{"use strict";o(Wi,"_typeof");o(Mf,"_classCallCheck");o(Dpe,"_defineProperties");o(If,"_createClass");o(X0e,"_defineProperty$1");o(_i,"_slicedToArray");o(j0e,"_toConsumableArray");o(UHe,"_arrayWithoutHoles");o(HHe,"_arrayWithHoles");o(WHe,"_iterableToArray");o(qHe,"_iterableToArrayLimit");o(ZP,"_unsupportedIterableToArray");o(OP,"_arrayLikeToArray");o(YHe,"_nonIterableSpread");o(XHe,"_nonIterableRest");o(mo,"_createForOfIteratorHelper");Ui=typeof window>"u"?null:window,Lpe=Ui?Ui.navigator:null;Ui&&Ui.document;jHe=Wi(""),K0e=Wi({}),KHe=Wi(function(){}),QHe=typeof HTMLElement>"u"?"undefined":Wi(HTMLElement),e4=o(function(e){return e&&e.instanceString&&si(e.instanceString)?e.instanceString():null},"instanceStr"),Zt=o(function(e){return e!=null&&Wi(e)==jHe},"string"),si=o(function(e){return e!=null&&Wi(e)===KHe},"fn"),En=o(function(e){return!go(e)&&(Array.isArray?Array.isArray(e):e!=null&&e instanceof Array)},"array"),Ur=o(function(e){return e!=null&&Wi(e)===K0e&&!En(e)&&e.constructor===Object},"plainObject"),ZHe=o(function(e){return e!=null&&Wi(e)===K0e},"object"),Ct=o(function(e){return e!=null&&Wi(e)===Wi(1)&&!isNaN(e)},"number"),JHe=o(function(e){return Ct(e)&&Math.floor(e)===e},"integer"),vS=o(function(e){if(QHe!=="undefined")return e!=null&&e instanceof HTMLElement},"htmlElement"),go=o(function(e){return t4(e)||Q0e(e)},"elementOrCollection"),t4=o(function(e){return e4(e)==="collection"&&e._private.single},"element"),Q0e=o(function(e){return e4(e)==="collection"&&!e._private.single},"collection"),JP=o(function(e){return e4(e)==="core"},"core"),Z0e=o(function(e){return e4(e)==="stylesheet"},"stylesheet"),eWe=o(function(e){return e4(e)==="event"},"event"),Af=o(function(e){return e==null?!0:!!(e===""||e.match(/^\s+$/))},"emptyString"),tWe=o(function(e){return typeof HTMLElement>"u"?!1:e instanceof HTMLElement},"domElement"),rWe=o(function(e){return Ur(e)&&Ct(e.x1)&&Ct(e.x2)&&Ct(e.y1)&&Ct(e.y2)},"boundingBox"),nWe=o(function(e){return ZHe(e)&&si(e.then)},"promise"),iWe=o(function(){return Lpe&&Lpe.userAgent.match(/msie|trident|edge/i)},"ms"),Ub=o(function(e,r){r||(r=o(function(){if(arguments.length===1)return arguments[0];if(arguments.length===0)return"undefined";for(var a=[],s=0;sr?1:0},"ascending"),hWe=o(function(e,r){return-1*eme(e,r)},"descending"),rr=Object.assign!=null?Object.assign.bind(Object):function(t){for(var e=arguments,r=1;r1&&(v-=1),v<1/6?g+(y-g)*6*v:v<1/2?y:v<2/3?g+(y-g)*(2/3-v)*6:g}o(f,"hue2rgb");var d=new RegExp("^"+oWe+"$").exec(e);if(d){if(n=parseInt(d[1]),n<0?n=(360- -1*n%360)%360:n>360&&(n=n%360),n/=360,i=parseFloat(d[2]),i<0||i>100||(i=i/100,a=parseFloat(d[3]),a<0||a>100)||(a=a/100,s=d[4],s!==void 0&&(s=parseFloat(s),s<0||s>1)))return;if(i===0)l=u=h=Math.round(a*255);else{var p=a<.5?a*(1+i):a+i-a*i,m=2*a-p;l=Math.round(255*f(m,p,n+1/3)),u=Math.round(255*f(m,p,n)),h=Math.round(255*f(m,p,n-1/3))}r=[l,u,h,s]}return r},"hsl2tuple"),pWe=o(function(e){var r,n=new RegExp("^"+aWe+"$").exec(e);if(n){r=[];for(var i=[],a=1;a<=3;a++){var s=n[a];if(s[s.length-1]==="%"&&(i[a]=!0),s=parseFloat(s),i[a]&&(s=s/100*255),s<0||s>255)return;r.push(Math.floor(s))}var l=i[1]||i[2]||i[3],u=i[1]&&i[2]&&i[3];if(l&&!u)return;var h=n[4];if(h!==void 0){if(h=parseFloat(h),h<0||h>1)return;r.push(h)}}return r},"rgb2tuple"),mWe=o(function(e){return gWe[e.toLowerCase()]},"colorname2tuple"),tme=o(function(e){return(En(e)?e:null)||mWe(e)||fWe(e)||pWe(e)||dWe(e)},"color2tuple"),gWe={transparent:[0,0,0,0],aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],grey:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},rme=o(function(e){for(var r=e.map,n=e.keys,i=n.length,a=0;a1&&arguments[1]!==void 0?arguments[1]:V1,n=r,i;i=e.next(),!i.done;)n=n*ome+i.value|0;return n},"hashIterableInts"),Hb=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:V1;return r*ome+e|0},"hashInt"),Wb=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:Ob;return(r<<5)+r+e|0},"hashIntAlt"),rqe=o(function(e,r){return e*2097152+r},"combineHashes"),wf=o(function(e){return e[0]*2097152+e[1]},"combineHashesArray"),j6=o(function(e,r){return[Hb(e[0],r[0]),Wb(e[1],r[1])]},"hashArrays"),nqe=o(function(e,r){var n={value:0,done:!1},i=0,a=e.length,s={next:o(function(){return i=0&&!(e[i]===r&&(e.splice(i,1),n));i--);},"removeFromArray"),nB=o(function(e){e.splice(0,e.length)},"clearArray"),uqe=o(function(e,r){for(var n=0;n"u"?"undefined":Wi(Set))!==fqe?Set:dqe,NS=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!0;if(e===void 0||r===void 0||!JP(e)){ai("An element must have a core reference and parameters set");return}var i=r.group;if(i==null&&(r.data&&r.data.source!=null&&r.data.target!=null?i="edges":i="nodes"),i!=="nodes"&&i!=="edges"){ai("An element must be of type `nodes` or `edges`; you specified `"+i+"`");return}this.length=1,this[0]=this;var a=this._private={cy:e,single:!0,data:r.data||{},position:r.position||{x:0,y:0},autoWidth:void 0,autoHeight:void 0,autoPadding:void 0,compoundBoundsClean:!1,listeners:[],group:i,style:{},rstyle:{},styleCxts:[],styleKeys:{},removed:!0,selected:!!r.selected,selectable:r.selectable===void 0?!0:!!r.selectable,locked:!!r.locked,grabbed:!1,grabbable:r.grabbable===void 0?!0:!!r.grabbable,pannable:r.pannable===void 0?i==="edges":!!r.pannable,active:!1,classes:new J1,animation:{current:[],queue:[]},rscratch:{},scratch:r.scratch||{},edges:[],children:[],parent:r.parent&&r.parent.isNode()?r.parent:null,traversalCache:{},backgrounding:!1,bbCache:null,bbCacheShift:{x:0,y:0},bodyBounds:null,overlayBounds:null,labelBounds:{all:null,source:null,target:null,main:null},arrowBounds:{source:null,target:null,"mid-source":null,"mid-target":null}};if(a.position.x==null&&(a.position.x=0),a.position.y==null&&(a.position.y=0),r.renderedPosition){var s=r.renderedPosition,l=e.pan(),u=e.zoom();a.position={x:(s.x-l.x)/u,y:(s.y-l.y)/u}}var h=[];En(r.classes)?h=r.classes:Zt(r.classes)&&(h=r.classes.split(/\s+/));for(var f=0,d=h.length;fb?1:0},"defaultCmp"),f=o(function(x,b,w,C,T){var E;if(w==null&&(w=0),T==null&&(T=n),w<0)throw new Error("lo must be non-negative");for(C==null&&(C=x.length);wI;0<=I?_++:_--)S.push(_);return S}.apply(this).reverse(),A=[],C=0,T=E.length;CD;0<=D?++S:--S)k.push(s(x,w));return k},"nsmallest"),y=o(function(x,b,w,C){var T,E,A;for(C==null&&(C=n),T=x[w];w>b;){if(A=w-1>>1,E=x[A],C(T,E)<0){x[w]=E,w=A;continue}break}return x[w]=T},"_siftdown"),v=o(function(x,b,w){var C,T,E,A,S;for(w==null&&(w=n),T=x.length,S=b,E=x[b],C=2*b+1;C0;){var E=b.pop(),A=v(E),S=E.id();if(p[S]=A,A!==1/0)for(var _=E.neighborhood().intersect(g),I=0;I<_.length;I++){var D=_[I],k=D.id(),L=T(E,D),R=A+L.dist;R0)for(F.unshift(B);d[z];){var $=d[z];F.unshift($.edge),F.unshift($.node),P=$.node,z=P.id()}return l.spawn(F)},"pathTo")}},"dijkstra")},yqe={kruskal:o(function(e){e=e||function(w){return 1};for(var r=this.byGroup(),n=r.nodes,i=r.edges,a=n.length,s=new Array(a),l=n,u=o(function(C){for(var T=0;T0;){if(T(),A++,C===f){for(var S=[],_=a,I=f,D=x[I];S.unshift(_),D!=null&&S.unshift(D),_=v[I],_!=null;)I=_.id(),D=x[I];return{found:!0,distance:d[C],path:this.spawn(S),steps:A}}m[C]=!0;for(var k=w._private.edges,L=0;LD&&(g[I]=D,b[I]=_,w[I]=T),!a){var k=_*f+S;!a&&g[k]>D&&(g[k]=D,b[k]=S,w[k]=T)}}}for(var L=0;L1&&arguments[1]!==void 0?arguments[1]:s,ge=w(ae),ze=[],He=ge;;){if(He==null)return r.spawn();var $e=b(He),Re=$e.edge,Ie=$e.pred;if(ze.unshift(He[0]),He.same(Oe)&&ze.length>0)break;Re!=null&&ze.unshift(Re),He=Ie}return u.spawn(ze)},"pathTo"),E=0;E=0;f--){var d=h[f],p=d[1],m=d[2];(r[p]===l&&r[m]===u||r[p]===u&&r[m]===l)&&h.splice(f,1)}for(var g=0;gi;){var a=Math.floor(Math.random()*r.length);r=Sqe(a,e,r),n--}return r},"contractUntil"),Cqe={kargerStein:o(function(){var e=this,r=this.byGroup(),n=r.nodes,i=r.edges;i.unmergeBy(function(F){return F.isLoop()});var a=n.length,s=i.length,l=Math.ceil(Math.pow(Math.log(a)/Math.LN2,2)),u=Math.floor(a/Eqe);if(a<2){ai("At least 2 nodes are required for Karger-Stein algorithm");return}for(var h=[],f=0;f1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=-1/0,a=r;a1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=0,a=0,s=r;s1&&arguments[1]!==void 0?arguments[1]:0,n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:e.length,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,a=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,s=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0;i?e=e.slice(r,n):(n0&&e.splice(0,r));for(var l=0,u=e.length-1;u>=0;u--){var h=e[u];s?isFinite(h)||(e[u]=-1/0,l++):e.splice(u,1)}a&&e.sort(function(p,m){return p-m});var f=e.length,d=Math.floor(f/2);return f%2!==0?e[d+1+l]:(e[d-1+l]+e[d+l])/2},"median"),Nqe=o(function(e){return Math.PI*e/180},"deg2rad"),K6=o(function(e,r){return Math.atan2(r,e)-Math.PI/2},"getAngleFromDisp"),iB=Math.log2||function(t){return Math.log(t)/Math.log(2)},mme=o(function(e){return e>0?1:e<0?-1:0},"signum"),Gp=o(function(e,r){return Math.sqrt(Op(e,r))},"dist"),Op=o(function(e,r){var n=r.x-e.x,i=r.y-e.y;return n*n+i*i},"sqdist"),Mqe=o(function(e){for(var r=e.length,n=0,i=0;i=e.x1&&e.y2>=e.y1)return{x1:e.x1,y1:e.y1,x2:e.x2,y2:e.y2,w:e.x2-e.x1,h:e.y2-e.y1};if(e.w!=null&&e.h!=null&&e.w>=0&&e.h>=0)return{x1:e.x1,y1:e.y1,x2:e.x1+e.w,y2:e.y1+e.h,w:e.w,h:e.h}}},"makeBoundingBox"),Oqe=o(function(e){return{x1:e.x1,x2:e.x2,w:e.w,y1:e.y1,y2:e.y2,h:e.h}},"copyBoundingBox"),Pqe=o(function(e){e.x1=1/0,e.y1=1/0,e.x2=-1/0,e.y2=-1/0,e.w=0,e.h=0},"clearBoundingBox"),Bqe=o(function(e,r,n){return{x1:e.x1+r,x2:e.x2+r,y1:e.y1+n,y2:e.y2+n,w:e.w,h:e.h}},"shiftBoundingBox"),gme=o(function(e,r){e.x1=Math.min(e.x1,r.x1),e.x2=Math.max(e.x2,r.x2),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,r.y1),e.y2=Math.max(e.y2,r.y2),e.h=e.y2-e.y1},"updateBoundingBox"),Fqe=o(function(e,r,n){e.x1=Math.min(e.x1,r),e.x2=Math.max(e.x2,r),e.w=e.x2-e.x1,e.y1=Math.min(e.y1,n),e.y2=Math.max(e.y2,n),e.h=e.y2-e.y1},"expandBoundingBoxByPoint"),cS=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:0;return e.x1-=r,e.x2+=r,e.y1-=r,e.y2+=r,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBox"),uS=o(function(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:[0],n,i,a,s;if(r.length===1)n=i=a=s=r[0];else if(r.length===2)n=a=r[0],s=i=r[1];else if(r.length===4){var l=_i(r,4);n=l[0],i=l[1],a=l[2],s=l[3]}return e.x1-=s,e.x2+=i,e.y1-=n,e.y2+=a,e.w=e.x2-e.x1,e.h=e.y2-e.y1,e},"expandBoundingBoxSides"),Fpe=o(function(e,r){e.x1=r.x1,e.y1=r.y1,e.x2=r.x2,e.y2=r.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1},"assignBoundingBox"),aB=o(function(e,r){return!(e.x1>r.x2||r.x1>e.x2||e.x2r.y2||r.y1>e.y2)},"boundingBoxesIntersect"),K1=o(function(e,r,n){return e.x1<=r&&r<=e.x2&&e.y1<=n&&n<=e.y2},"inBoundingBox"),$qe=o(function(e,r){return K1(e,r.x,r.y)},"pointInBoundingBox"),yme=o(function(e,r){return K1(e,r.x1,r.y1)&&K1(e,r.x2,r.y2)},"boundingBoxInBoundingBox"),vme=o(function(e,r,n,i,a,s,l){var u=arguments.length>7&&arguments[7]!==void 0?arguments[7]:"auto",h=u==="auto"?Vp(a,s):u,f=a/2,d=s/2;h=Math.min(h,f,d);var p=h!==f,m=h!==d,g;if(p){var y=n-f+h-l,v=i-d-l,x=n+f-h+l,b=v;if(g=Ef(e,r,n,i,y,v,x,b,!1),g.length>0)return g}if(m){var w=n+f+l,C=i-d+h-l,T=w,E=i+d-h+l;if(g=Ef(e,r,n,i,w,C,T,E,!1),g.length>0)return g}if(p){var A=n-f+h-l,S=i+d+l,_=n+f-h+l,I=S;if(g=Ef(e,r,n,i,A,S,_,I,!1),g.length>0)return g}if(m){var D=n-f-l,k=i-d+h-l,L=D,R=i+d-h+l;if(g=Ef(e,r,n,i,D,k,L,R,!1),g.length>0)return g}var O;{var M=n-f+h,B=i-d+h;if(O=Pb(e,r,n,i,M,B,h+l),O.length>0&&O[0]<=M&&O[1]<=B)return[O[0],O[1]]}{var F=n+f-h,P=i-d+h;if(O=Pb(e,r,n,i,F,P,h+l),O.length>0&&O[0]>=F&&O[1]<=P)return[O[0],O[1]]}{var z=n+f-h,$=i+d-h;if(O=Pb(e,r,n,i,z,$,h+l),O.length>0&&O[0]>=z&&O[1]>=$)return[O[0],O[1]]}{var H=n-f+h,Q=i+d-h;if(O=Pb(e,r,n,i,H,Q,h+l),O.length>0&&O[0]<=H&&O[1]>=Q)return[O[0],O[1]]}return[]},"roundRectangleIntersectLine"),zqe=o(function(e,r,n,i,a,s,l){var u=l,h=Math.min(n,a),f=Math.max(n,a),d=Math.min(i,s),p=Math.max(i,s);return h-u<=e&&e<=f+u&&d-u<=r&&r<=p+u},"inLineVicinity"),Gqe=o(function(e,r,n,i,a,s,l,u,h){var f={x1:Math.min(n,l,a)-h,x2:Math.max(n,l,a)+h,y1:Math.min(i,u,s)-h,y2:Math.max(i,u,s)+h};return!(ef.x2||rf.y2)},"inBezierVicinity"),Vqe=o(function(e,r,n,i){n-=i;var a=r*r-4*e*n;if(a<0)return[];var s=Math.sqrt(a),l=2*e,u=(-r+s)/l,h=(-r-s)/l;return[u,h]},"solveQuadratic"),Uqe=o(function(e,r,n,i,a){var s=1e-5;e===0&&(e=s),r/=e,n/=e,i/=e;var l,u,h,f,d,p,m,g;if(u=(3*n-r*r)/9,h=-(27*i)+r*(9*n-2*(r*r)),h/=54,l=u*u*u+h*h,a[1]=0,m=r/3,l>0){d=h+Math.sqrt(l),d=d<0?-Math.pow(-d,1/3):Math.pow(d,1/3),p=h-Math.sqrt(l),p=p<0?-Math.pow(-p,1/3):Math.pow(p,1/3),a[0]=-m+d+p,m+=(d+p)/2,a[4]=a[2]=-m,m=Math.sqrt(3)*(-p+d)/2,a[3]=m,a[5]=-m;return}if(a[5]=a[3]=0,l===0){g=h<0?-Math.pow(-h,1/3):Math.pow(h,1/3),a[0]=-m+2*g,a[4]=a[2]=-(g+m);return}u=-u,f=u*u*u,f=Math.acos(h/Math.sqrt(f)),g=2*Math.sqrt(u),a[0]=-m+g*Math.cos(f/3),a[2]=-m+g*Math.cos((f+2*Math.PI)/3),a[4]=-m+g*Math.cos((f+4*Math.PI)/3)},"solveCubic"),Hqe=o(function(e,r,n,i,a,s,l,u){var h=1*n*n-4*n*a+2*n*l+4*a*a-4*a*l+l*l+i*i-4*i*s+2*i*u+4*s*s-4*s*u+u*u,f=1*9*n*a-3*n*n-3*n*l-6*a*a+3*a*l+9*i*s-3*i*i-3*i*u-6*s*s+3*s*u,d=1*3*n*n-6*n*a+n*l-n*e+2*a*a+2*a*e-l*e+3*i*i-6*i*s+i*u-i*r+2*s*s+2*s*r-u*r,p=1*n*a-n*n+n*e-a*e+i*s-i*i+i*r-s*r,m=[];Uqe(h,f,d,p,m);for(var g=1e-7,y=[],v=0;v<6;v+=2)Math.abs(m[v+1])=0&&m[v]<=1&&y.push(m[v]);y.push(1),y.push(0);for(var x=-1,b,w,C,T=0;T=0?Ch?(e-a)*(e-a)+(r-s)*(r-s):f-p},"sqdistToFiniteLine"),Us=o(function(e,r,n){for(var i,a,s,l,u,h=0,f=0;f=e&&e>=s||i<=e&&e<=s)u=(e-i)/(s-i)*(l-a)+a,u>r&&h++;else continue;return h%2!==0},"pointInsidePolygonPoints"),Zu=o(function(e,r,n,i,a,s,l,u,h){var f=new Array(n.length),d;u[0]!=null?(d=Math.atan(u[1]/u[0]),u[0]<0?d=d+Math.PI/2:d=-d-Math.PI/2):d=u;for(var p=Math.cos(-d),m=Math.sin(-d),g=0;g0){var v=TS(f,-h);y=wS(v)}else y=f;return Us(e,r,y)},"pointInsidePolygon"),qqe=o(function(e,r,n,i,a,s,l,u){for(var h=new Array(n.length*2),f=0;f=0&&v<=1&&b.push(v),x>=0&&x<=1&&b.push(x),b.length===0)return[];var w=b[0]*u[0]+e,C=b[0]*u[1]+r;if(b.length>1){if(b[0]==b[1])return[w,C];var T=b[1]*u[0]+e,E=b[1]*u[1]+r;return[w,C,T,E]}else return[w,C]},"intersectLineCircle"),TP=o(function(e,r,n){return r<=e&&e<=n||n<=e&&e<=r?e:e<=r&&r<=n||n<=r&&r<=e?r:n},"midOfThree"),Ef=o(function(e,r,n,i,a,s,l,u,h){var f=e-a,d=n-e,p=l-a,m=r-s,g=i-r,y=u-s,v=p*m-y*f,x=d*m-g*f,b=y*d-p*g;if(b!==0){var w=v/b,C=x/b,T=.001,E=0-T,A=1+T;return E<=w&&w<=A&&E<=C&&C<=A?[e+w*d,r+w*g]:h?[e+w*d,r+w*g]:[]}else return v===0||x===0?TP(e,n,l)===l?[l,u]:TP(e,n,a)===a?[a,s]:TP(a,l,n)===n?[n,i]:[]:[]},"finiteLinesIntersect"),Xb=o(function(e,r,n,i,a,s,l,u){var h=[],f,d=new Array(n.length),p=!0;s==null&&(p=!1);var m;if(p){for(var g=0;g0){var y=TS(d,-u);m=wS(y)}else m=d}else m=n;for(var v,x,b,w,C=0;C2){for(var g=[f[0],f[1]],y=Math.pow(g[0]-e,2)+Math.pow(g[1]-r,2),v=1;vf&&(f=C)},"set"),get:o(function(w){return h[w]},"get")},p=0;p0?M=O.edgesTo(R)[0]:M=R.edgesTo(O)[0];var B=i(M);R=R.id(),S[R]>S[k]+B&&(S[R]=S[k]+B,_.nodes.indexOf(R)<0?_.push(R):_.updateItem(R),A[R]=0,E[R]=[]),S[R]==S[k]+B&&(A[R]=A[R]+A[k],E[R].push(k))}else for(var F=0;F0;){for(var H=T.pop(),Q=0;Q0&&l.push(n[u]);l.length!==0&&a.push(i.collection(l))}return a},"assign"),lYe=o(function(e,r){for(var n=0;n5&&arguments[5]!==void 0?arguments[5]:hYe,l=i,u,h,f=0;f=2?_b(e,r,n,0,Upe,fYe):_b(e,r,n,0,Vpe)},"euclidean"),squaredEuclidean:o(function(e,r,n){return _b(e,r,n,0,Upe)},"squaredEuclidean"),manhattan:o(function(e,r,n){return _b(e,r,n,0,Vpe)},"manhattan"),max:o(function(e,r,n){return _b(e,r,n,-1/0,dYe)},"max")};Q1["squared-euclidean"]=Q1.squaredEuclidean;Q1.squaredeuclidean=Q1.squaredEuclidean;o(IS,"clusteringDistance");pYe=la({k:2,m:2,sensitivityThreshold:1e-4,distance:"euclidean",maxIterations:10,attributes:[],testMode:!1,testCentroids:null}),oB=o(function(e){return pYe(e)},"setOptions"),kS=o(function(e,r,n,i,a){var s=a!=="kMedoids",l=s?function(d){return n[d]}:function(d){return i[d](n)},u=o(function(p){return i[p](r)},"getQ"),h=n,f=r;return IS(e,i.length,l,u,h,f)},"getDist"),kP=o(function(e,r,n){for(var i=n.length,a=new Array(i),s=new Array(i),l=new Array(r),u=null,h=0;hn)return!1}return!0},"haveMatricesConverged"),yYe=o(function(e,r,n){for(var i=0;il&&(l=r[h][f],u=f);a[u].push(e[h])}for(var d=0;d=a.threshold||a.mode==="dendrogram"&&e.length===1)return!1;var g=r[s],y=r[i[s]],v;a.mode==="dendrogram"?v={left:g,right:y,key:g.key}:v={value:g.value.concat(y.value),key:g.key},e[g.index]=v,e.splice(y.index,1),r[g.key]=v;for(var x=0;xn[y.key][b.key]&&(u=n[y.key][b.key])):a.linkage==="max"?(u=n[g.key][b.key],n[g.key][b.key]0&&i.push(a);return i},"findExemplars"),jpe=o(function(e,r,n){for(var i=[],a=0;al&&(s=h,l=r[a*e+h])}s>0&&i.push(s)}for(var f=0;fh&&(u=f,h=d)}n[a]=s[u]}return i=jpe(e,r,n),i},"assign"),Kpe=o(function(e){for(var r=this.cy(),n=this.nodes(),i=RYe(e),a={},s=0;s=D?(k=D,D=R,L=O):R>k&&(k=R);for(var M=0;M0?1:0;A[_%i.minIterations*l+H]=Q,$+=Q}if($>0&&(_>=i.minIterations-1||_==i.maxIterations-1)){for(var j=0,ie=0;ie1||E>1)&&(l=!0),d[w]=[],b.outgoers().forEach(function(S){S.isEdge()&&d[w].push(S.id())})}else p[w]=[void 0,b.target().id()]}):s.forEach(function(b){var w=b.id();if(b.isNode()){var C=b.degree(!0);C%2&&(u?h?l=!0:h=w:u=w),d[w]=[],b.connectedEdges().forEach(function(T){return d[w].push(T.id())})}else p[w]=[b.source().id(),b.target().id()]});var m={found:!1,trail:void 0};if(l)return m;if(h&&u)if(a){if(f&&h!=f)return m;f=h}else{if(f&&h!=f&&u!=f)return m;f||(f=h)}else f||(f=s[0].id());var g=o(function(w){for(var C=w,T=[w],E,A,S;d[C].length;)E=d[C].shift(),A=p[E][0],S=p[E][1],C!=S?(d[S]=d[S].filter(function(_){return _!=E}),C=S):!a&&C!=A&&(d[A]=d[A].filter(function(_){return _!=E}),C=A),T.unshift(E),T.unshift(C);return T},"walk"),y=[],v=[];for(v=g(f);v.length!=1;)d[v[0]].length==0?(y.unshift(s.getElementById(v.shift())),y.unshift(s.getElementById(v.shift()))):v=g(v.shift()).concat(v);y.unshift(s.getElementById(v.shift()));for(var x in d)if(d[x].length)return m;return m.found=!0,m.trail=this.spawn(y,!0),m},"hierholzer")},J6=o(function(){var e=this,r={},n=0,i=0,a=[],s=[],l={},u=o(function(p,m){for(var g=s.length-1,y=[],v=e.spawn();s[g].x!=p||s[g].y!=m;)y.push(s.pop().edge),g--;y.push(s.pop().edge),y.forEach(function(x){var b=x.connectedNodes().intersection(e);v.merge(x),b.forEach(function(w){var C=w.id(),T=w.connectedEdges().intersection(e);v.merge(w),r[C].cutVertex?v.merge(T.filter(function(E){return E.isLoop()})):v.merge(T)})}),a.push(v)},"buildComponent"),h=o(function d(p,m,g){p===g&&(i+=1),r[m]={id:n,low:n++,cutVertex:!1};var y=e.getElementById(m).connectedEdges().intersection(e);if(y.size()===0)a.push(e.spawn(e.getElementById(m)));else{var v,x,b,w;y.forEach(function(C){v=C.source().id(),x=C.target().id(),b=v===m?x:v,b!==g&&(w=C.id(),l[w]||(l[w]=!0,s.push({x:m,y:b,edge:C})),b in r?r[m].low=Math.min(r[m].low,r[b].id):(d(p,b,m),r[m].low=Math.min(r[m].low,r[b].low),r[m].id<=r[b].low&&(r[m].cutVertex=!0,u(m,b))))})}},"biconnectedSearch");e.forEach(function(d){if(d.isNode()){var p=d.id();p in r||(i=0,h(p,p),r[p].cutVertex=i>1)}});var f=Object.keys(r).filter(function(d){return r[d].cutVertex}).map(function(d){return e.getElementById(d)});return{cut:e.spawn(f),components:a}},"hopcroftTarjanBiconnected"),$Ye={hopcroftTarjanBiconnected:J6,htbc:J6,htb:J6,hopcroftTarjanBiconnectedComponents:J6},eS=o(function(){var e=this,r={},n=0,i=[],a=[],s=e.spawn(e),l=o(function u(h){a.push(h),r[h]={index:n,low:n++,explored:!1};var f=e.getElementById(h).connectedEdges().intersection(e);if(f.forEach(function(y){var v=y.target().id();v!==h&&(v in r||u(v),r[v].explored||(r[h].low=Math.min(r[h].low,r[v].low)))}),r[h].index===r[h].low){for(var d=e.spawn();;){var p=a.pop();if(d.merge(e.getElementById(p)),r[p].low=r[h].index,r[p].explored=!0,p===h)break}var m=d.edgesWith(d),g=d.merge(m);i.push(g),s=s.difference(g)}},"stronglyConnectedSearch");return e.forEach(function(u){if(u.isNode()){var h=u.id();h in r||l(h)}}),{cut:s,components:i}},"tarjanStronglyConnected"),zYe={tarjanStronglyConnected:eS,tsc:eS,tscc:eS,tarjanStronglyConnectedComponents:eS},Sme={};[qb,gqe,yqe,xqe,wqe,kqe,Cqe,Qqe,q1,Y1,FP,uYe,kYe,DYe,PYe,FYe,$Ye,zYe].forEach(function(t){rr(Sme,t)});Cme=0,Ame=1,_me=2,Ju=o(function t(e){if(!(this instanceof t))return new t(e);this.id="Thenable/1.0.7",this.state=Cme,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},typeof e=="function"&&e.call(this,this.fulfill.bind(this),this.reject.bind(this))},"api");Ju.prototype={fulfill:o(function(e){return Qpe(this,Ame,"fulfillValue",e)},"fulfill"),reject:o(function(e){return Qpe(this,_me,"rejectReason",e)},"reject"),then:o(function(e,r){var n=this,i=new Ju;return n.onFulfilled.push(Jpe(e,i,"fulfill")),n.onRejected.push(Jpe(r,i,"reject")),Dme(n),i.proxy},"then")};Qpe=o(function(e,r,n,i){return e.state===Cme&&(e.state=r,e[n]=i,Dme(e)),e},"deliver"),Dme=o(function(e){e.state===Ame?Zpe(e,"onFulfilled",e.fulfillValue):e.state===_me&&Zpe(e,"onRejected",e.rejectReason)},"execute"),Zpe=o(function(e,r,n){if(e[r].length!==0){var i=e[r];e[r]=[];var a=o(function(){for(var l=0;l0},"animatedImpl")},"animated"),clearQueue:o(function(){return o(function(){var r=this,n=r.length!==void 0,i=n?r:[r],a=this._private.cy||this;if(!a.styleEnabled())return this;for(var s=0;s0&&this.spawn(i).updateStyle().emit("class"),r},"classes"),addClass:o(function(e){return this.toggleClass(e,!0)},"addClass"),hasClass:o(function(e){var r=this[0];return r!=null&&r._private.classes.has(e)},"hasClass"),toggleClass:o(function(e,r){En(e)||(e=e.match(/\S+/g)||[]);for(var n=this,i=r===void 0,a=[],s=0,l=n.length;s0&&this.spawn(a).updateStyle().emit("class"),n},"toggleClass"),removeClass:o(function(e){return this.toggleClass(e,!1)},"removeClass"),flashClass:o(function(e,r){var n=this;if(r==null)r=250;else if(r===0)return n;return n.addClass(e),setTimeout(function(){n.removeClass(e)},r),n},"flashClass")};hS.className=hS.classNames=hS.classes;Vr={metaChar:"[\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\]\\^\\`\\{\\|\\}\\~]",comparatorOp:"=|\\!=|>|>=|<|<=|\\$=|\\^=|\\*=",boolOp:"\\?|\\!|\\^",string:`"(?:\\\\"|[^"])*"|'(?:\\\\'|[^'])*'`,number:Hi,meta:"degree|indegree|outdegree",separator:"\\s*,\\s*",descendant:"\\s+",child:"\\s+>\\s+",subject:"\\$",group:"node|edge|\\*",directedEdge:"\\s+->\\s+",undirectedEdge:"\\s+<->\\s+"};Vr.variable="(?:[\\w-.]|(?:\\\\"+Vr.metaChar+"))+";Vr.className="(?:[\\w-]|(?:\\\\"+Vr.metaChar+"))+";Vr.value=Vr.string+"|"+Vr.number;Vr.id=Vr.variable;(function(){var t,e,r;for(t=Vr.comparatorOp.split("|"),r=0;r=0)&&e!=="="&&(Vr.comparatorOp+="|\\!"+e)})();mn=o(function(){return{checks:[]}},"newQuery"),$t={GROUP:0,COLLECTION:1,FILTER:2,DATA_COMPARE:3,DATA_EXIST:4,DATA_BOOL:5,META_COMPARE:6,STATE:7,ID:8,CLASS:9,UNDIRECTED_EDGE:10,DIRECTED_EDGE:11,NODE_SOURCE:12,NODE_TARGET:13,NODE_NEIGHBOR:14,CHILD:15,DESCENDANT:16,PARENT:17,ANCESTOR:18,COMPOUND_SPLIT:19,TRUE:20},zP=[{selector:":selected",matches:o(function(e){return e.selected()},"matches")},{selector:":unselected",matches:o(function(e){return!e.selected()},"matches")},{selector:":selectable",matches:o(function(e){return e.selectable()},"matches")},{selector:":unselectable",matches:o(function(e){return!e.selectable()},"matches")},{selector:":locked",matches:o(function(e){return e.locked()},"matches")},{selector:":unlocked",matches:o(function(e){return!e.locked()},"matches")},{selector:":visible",matches:o(function(e){return e.visible()},"matches")},{selector:":hidden",matches:o(function(e){return!e.visible()},"matches")},{selector:":transparent",matches:o(function(e){return e.transparent()},"matches")},{selector:":grabbed",matches:o(function(e){return e.grabbed()},"matches")},{selector:":free",matches:o(function(e){return!e.grabbed()},"matches")},{selector:":removed",matches:o(function(e){return e.removed()},"matches")},{selector:":inside",matches:o(function(e){return!e.removed()},"matches")},{selector:":grabbable",matches:o(function(e){return e.grabbable()},"matches")},{selector:":ungrabbable",matches:o(function(e){return!e.grabbable()},"matches")},{selector:":animated",matches:o(function(e){return e.animated()},"matches")},{selector:":unanimated",matches:o(function(e){return!e.animated()},"matches")},{selector:":parent",matches:o(function(e){return e.isParent()},"matches")},{selector:":childless",matches:o(function(e){return e.isChildless()},"matches")},{selector:":child",matches:o(function(e){return e.isChild()},"matches")},{selector:":orphan",matches:o(function(e){return e.isOrphan()},"matches")},{selector:":nonorphan",matches:o(function(e){return e.isChild()},"matches")},{selector:":compound",matches:o(function(e){return e.isNode()?e.isParent():e.source().isParent()||e.target().isParent()},"matches")},{selector:":loop",matches:o(function(e){return e.isLoop()},"matches")},{selector:":simple",matches:o(function(e){return e.isSimple()},"matches")},{selector:":active",matches:o(function(e){return e.active()},"matches")},{selector:":inactive",matches:o(function(e){return!e.active()},"matches")},{selector:":backgrounding",matches:o(function(e){return e.backgrounding()},"matches")},{selector:":nonbackgrounding",matches:o(function(e){return!e.backgrounding()},"matches")}].sort(function(t,e){return hWe(t.selector,e.selector)}),Jje=function(){for(var t={},e,r=0;r0&&f.edgeCount>0)return un("The selector `"+e+"` is invalid because it uses both a compound selector and an edge selector"),!1;if(f.edgeCount>1)return un("The selector `"+e+"` is invalid because it uses multiple edge selectors"),!1;f.edgeCount===1&&un("The selector `"+e+"` is deprecated. Edge selectors do not take effect on changes to source and target nodes after an edge is added, for performance reasons. Use a class or data selector on edges instead, updating the class or data of an edge when your app detects a change in source or target nodes.")}return!0},"parse"),aKe=o(function(){if(this.toStringCache!=null)return this.toStringCache;for(var e=o(function(f){return f??""},"clean"),r=o(function(f){return Zt(f)?'"'+f+'"':e(f)},"cleanVal"),n=o(function(f){return" "+f+" "},"space"),i=o(function(f,d){var p=f.type,m=f.value;switch(p){case $t.GROUP:{var g=e(m);return g.substring(0,g.length-1)}case $t.DATA_COMPARE:{var y=f.field,v=f.operator;return"["+y+n(e(v))+r(m)+"]"}case $t.DATA_BOOL:{var x=f.operator,b=f.field;return"["+e(x)+b+"]"}case $t.DATA_EXIST:{var w=f.field;return"["+w+"]"}case $t.META_COMPARE:{var C=f.operator,T=f.field;return"[["+T+n(e(C))+r(m)+"]]"}case $t.STATE:return m;case $t.ID:return"#"+m;case $t.CLASS:return"."+m;case $t.PARENT:case $t.CHILD:return a(f.parent,d)+n(">")+a(f.child,d);case $t.ANCESTOR:case $t.DESCENDANT:return a(f.ancestor,d)+" "+a(f.descendant,d);case $t.COMPOUND_SPLIT:{var E=a(f.left,d),A=a(f.subject,d),S=a(f.right,d);return E+(E.length>0?" ":"")+A+S}case $t.TRUE:return""}},"checkToString"),a=o(function(f,d){return f.checks.reduce(function(p,m,g){return p+(d===f&&g===0?"$":"")+i(m,d)},"")},"queryToString"),s="",l=0;l1&&l=0&&(r=r.replace("!",""),d=!0),r.indexOf("@")>=0&&(r=r.replace("@",""),f=!0),(a||l||f)&&(u=!a&&!s?"":""+e,h=""+n),f&&(e=u=u.toLowerCase(),n=h=h.toLowerCase()),r){case"*=":i=u.indexOf(h)>=0;break;case"$=":i=u.indexOf(h,u.length-h.length)>=0;break;case"^=":i=u.indexOf(h)===0;break;case"=":i=e===n;break;case">":p=!0,i=e>n;break;case">=":p=!0,i=e>=n;break;case"<":p=!0,i=e1&&arguments[1]!==void 0?arguments[1]:!0;return fB(this,t,e,Fme)};o($me,"addParent");Z1.forEachUp=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return fB(this,t,e,$me)};o(dKe,"addParentAndChildren");Z1.forEachUpAndDown=function(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0;return fB(this,t,e,dKe)};Z1.ancestors=Z1.parents;Kb=zme={data:cn.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),removeData:cn.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,immutableKeys:{id:!0,source:!0,target:!0,parent:!0},updateStyle:!0}),scratch:cn.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:cn.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),rscratch:cn.data({field:"rscratch",allowBinding:!1,allowSetting:!0,settingTriggersEvent:!1,allowGetting:!0}),removeRscratch:cn.removeData({field:"rscratch",triggerEvent:!1}),id:o(function(){var e=this[0];if(e)return e._private.data.id},"id")};Kb.attr=Kb.data;Kb.removeAttr=Kb.removeData;pKe=zme,FS={};o(SP,"defineDegreeFunction");rr(FS,{degree:SP(function(t,e){return e.source().same(e.target())?2:1}),indegree:SP(function(t,e){return e.target().same(t)?1:0}),outdegree:SP(function(t,e){return e.source().same(t)?1:0})});o(F1,"defineDegreeBoundsFunction");rr(FS,{minDegree:F1("degree",function(t,e){return te}),minIndegree:F1("indegree",function(t,e){return te}),minOutdegree:F1("outdegree",function(t,e){return te})});rr(FS,{totalDegree:o(function(e){for(var r=0,n=this.nodes(),i=0;i0,p=d;d&&(f=f[0]);var m=p?f.position():{x:0,y:0};r!==void 0?h.position(e,r+m[e]):a!==void 0&&h.position({x:a.x+m.x,y:a.y+m.y})}else{var g=n.position(),y=l?n.parent():null,v=y&&y.length>0,x=v;v&&(y=y[0]);var b=x?y.position():{x:0,y:0};return a={x:g.x-b.x,y:g.y-b.y},e===void 0?a:a[e]}else if(!s)return;return this},"relativePosition")};Vl.modelPosition=Vl.point=Vl.position;Vl.modelPositions=Vl.points=Vl.positions;Vl.renderedPoint=Vl.renderedPosition;Vl.relativePoint=Vl.relativePosition;mKe=Gme;X1=Of={};Of.renderedBoundingBox=function(t){var e=this.boundingBox(t),r=this.cy(),n=r.zoom(),i=r.pan(),a=e.x1*n+i.x,s=e.x2*n+i.x,l=e.y1*n+i.y,u=e.y2*n+i.y;return{x1:a,x2:s,y1:l,y2:u,w:s-a,h:u-l}};Of.dirtyCompoundBoundsCache=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();return!e.styleEnabled()||!e.hasCompoundNodes()?this:(this.forEachUp(function(r){if(r.isParent()){var n=r._private;n.compoundBoundsClean=!1,n.bbCache=null,t||r.emitAndNotify("bounds")}}),this)};Of.updateCompoundBounds=function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:!1,e=this.cy();if(!e.styleEnabled()||!e.hasCompoundNodes())return this;if(!t&&e.batching())return this;function r(s){if(!s.isParent())return;var l=s._private,u=s.children(),h=s.pstyle("compound-sizing-wrt-labels").value==="include",f={width:{val:s.pstyle("min-width").pfValue,left:s.pstyle("min-width-bias-left"),right:s.pstyle("min-width-bias-right")},height:{val:s.pstyle("min-height").pfValue,top:s.pstyle("min-height-bias-top"),bottom:s.pstyle("min-height-bias-bottom")}},d=u.boundingBox({includeLabels:h,includeOverlays:!1,useCache:!1}),p=l.position;(d.w===0||d.h===0)&&(d={w:s.pstyle("width").pfValue,h:s.pstyle("height").pfValue},d.x1=p.x-d.w/2,d.x2=p.x+d.w/2,d.y1=p.y-d.h/2,d.y2=p.y+d.h/2);function m(_,I,D){var k=0,L=0,R=I+D;return _>0&&R>0&&(k=I/R*_,L=D/R*_),{biasDiff:k,biasComplementDiff:L}}o(m,"computeBiasValues");function g(_,I,D,k){if(D.units==="%")switch(k){case"width":return _>0?D.pfValue*_:0;case"height":return I>0?D.pfValue*I:0;case"average":return _>0&&I>0?D.pfValue*(_+I)/2:0;case"min":return _>0&&I>0?_>I?D.pfValue*I:D.pfValue*_:0;case"max":return _>0&&I>0?_>I?D.pfValue*_:D.pfValue*I:0;default:return 0}else return D.units==="px"?D.pfValue:0}o(g,"computePaddingValues");var y=f.width.left.value;f.width.left.units==="px"&&f.width.val>0&&(y=y*100/f.width.val);var v=f.width.right.value;f.width.right.units==="px"&&f.width.val>0&&(v=v*100/f.width.val);var x=f.height.top.value;f.height.top.units==="px"&&f.height.val>0&&(x=x*100/f.height.val);var b=f.height.bottom.value;f.height.bottom.units==="px"&&f.height.val>0&&(b=b*100/f.height.val);var w=m(f.width.val-d.w,y,v),C=w.biasDiff,T=w.biasComplementDiff,E=m(f.height.val-d.h,x,b),A=E.biasDiff,S=E.biasComplementDiff;l.autoPadding=g(d.w,d.h,s.pstyle("padding"),s.pstyle("padding-relative-to").value),l.autoWidth=Math.max(d.w,f.width.val),p.x=(-C+d.x1+d.x2+T)/2,l.autoHeight=Math.max(d.h,f.height.val),p.y=(-A+d.y1+d.y2+S)/2}o(r,"update");for(var n=0;ne.x2?i:e.x2,e.y1=ne.y2?a:e.y2,e.w=e.x2-e.x1,e.h=e.y2-e.y1)},"updateBounds"),Pp=o(function(e,r){return r==null?e:zl(e,r.x1,r.y1,r.x2,r.y2)},"updateBoundsFromBox"),Db=o(function(e,r,n){return Gl(e,r,n)},"prefixedProperty"),tS=o(function(e,r,n){if(!r.cy().headless()){var i=r._private,a=i.rstyle,s=a.arrowWidth/2,l=r.pstyle(n+"-arrow-shape").value,u,h;if(l!=="none"){n==="source"?(u=a.srcX,h=a.srcY):n==="target"?(u=a.tgtX,h=a.tgtY):(u=a.midX,h=a.midY);var f=i.arrowBounds=i.arrowBounds||{},d=f[n]=f[n]||{};d.x1=u-s,d.y1=h-s,d.x2=u+s,d.y2=h+s,d.w=d.x2-d.x1,d.h=d.y2-d.y1,cS(d,1),zl(e,d.x1,d.y1,d.x2,d.y2)}}},"updateBoundsFromArrow"),CP=o(function(e,r,n){if(!r.cy().headless()){var i;n?i=n+"-":i="";var a=r._private,s=a.rstyle,l=r.pstyle(i+"label").strValue;if(l){var u=r.pstyle("text-halign"),h=r.pstyle("text-valign"),f=Db(s,"labelWidth",n),d=Db(s,"labelHeight",n),p=Db(s,"labelX",n),m=Db(s,"labelY",n),g=r.pstyle(i+"text-margin-x").pfValue,y=r.pstyle(i+"text-margin-y").pfValue,v=r.isEdge(),x=r.pstyle(i+"text-rotation"),b=r.pstyle("text-outline-width").pfValue,w=r.pstyle("text-border-width").pfValue,C=w/2,T=r.pstyle("text-background-padding").pfValue,E=2,A=d,S=f,_=S/2,I=A/2,D,k,L,R;if(v)D=p-_,k=p+_,L=m-I,R=m+I;else{switch(u.value){case"left":D=p-S,k=p;break;case"center":D=p-_,k=p+_;break;case"right":D=p,k=p+S;break}switch(h.value){case"top":L=m-A,R=m;break;case"center":L=m-I,R=m+I;break;case"bottom":L=m,R=m+A;break}}var O=g-Math.max(b,C)-T-E,M=g+Math.max(b,C)+T+E,B=y-Math.max(b,C)-T-E,F=y+Math.max(b,C)+T+E;D+=O,k+=M,L+=B,R+=F;var P=n||"main",z=a.labelBounds,$=z[P]=z[P]||{};$.x1=D,$.y1=L,$.x2=k,$.y2=R,$.w=k-D,$.h=R-L,$.leftPad=O,$.rightPad=M,$.topPad=B,$.botPad=F;var H=v&&x.strValue==="autorotate",Q=x.pfValue!=null&&x.pfValue!==0;if(H||Q){var j=H?Db(a.rstyle,"labelAngle",n):x.pfValue,ie=Math.cos(j),ne=Math.sin(j),le=(D+k)/2,he=(L+R)/2;if(!v){switch(u.value){case"left":le=k;break;case"right":le=D;break}switch(h.value){case"top":he=R;break;case"bottom":he=L;break}}var K=o(function(ce,ae){return ce=ce-le,ae=ae-he,{x:ce*ie-ae*ne+le,y:ce*ne+ae*ie+he}},"rotate"),X=K(D,L),te=K(D,R),J=K(k,L),se=K(k,R);D=Math.min(X.x,te.x,J.x,se.x),k=Math.max(X.x,te.x,J.x,se.x),L=Math.min(X.y,te.y,J.y,se.y),R=Math.max(X.y,te.y,J.y,se.y)}var ue=P+"Rot",Z=z[ue]=z[ue]||{};Z.x1=D,Z.y1=L,Z.x2=k,Z.y2=R,Z.w=k-D,Z.h=R-L,zl(e,D,L,k,R),zl(a.labelBounds.all,D,L,k,R)}return e}},"updateBoundsFromLabel"),gKe=o(function(e,r){if(!r.cy().headless()){var n=r.pstyle("outline-opacity").value,i=r.pstyle("outline-width").value;if(n>0&&i>0){var a=r.pstyle("outline-offset").value,s=r.pstyle("shape").value,l=i+a,u=(e.w+l*2)/e.w,h=(e.h+l*2)/e.h,f=0,d=0;["diamond","pentagon","round-triangle"].includes(s)?(u=(e.w+l*2.4)/e.w,d=-l/3.6):["concave-hexagon","rhomboid","right-rhomboid"].includes(s)?u=(e.w+l*2.4)/e.w:s==="star"?(u=(e.w+l*2.8)/e.w,h=(e.h+l*2.6)/e.h,d=-l/3.8):s==="triangle"?(u=(e.w+l*2.8)/e.w,h=(e.h+l*2.4)/e.h,d=-l/1.4):s==="vee"&&(u=(e.w+l*4.4)/e.w,h=(e.h+l*3.8)/e.h,d=-l*.5);var p=e.h*h-e.h,m=e.w*u-e.w;if(uS(e,[Math.ceil(p/2),Math.ceil(m/2)]),f!=0||d!==0){var g=Bqe(e,f,d);gme(e,g)}}}},"updateBoundsFromOutline"),yKe=o(function(e,r){var n=e._private.cy,i=n.styleEnabled(),a=n.headless(),s=Hs(),l=e._private,u=e.isNode(),h=e.isEdge(),f,d,p,m,g,y,v=l.rstyle,x=u&&i?e.pstyle("bounds-expansion").pfValue:[0],b=o(function(Se){return Se.pstyle("display").value!=="none"},"isDisplayed"),w=!i||b(e)&&(!h||b(e.source())&&b(e.target()));if(w){var C=0,T=0;i&&r.includeOverlays&&(C=e.pstyle("overlay-opacity").value,C!==0&&(T=e.pstyle("overlay-padding").value));var E=0,A=0;i&&r.includeUnderlays&&(E=e.pstyle("underlay-opacity").value,E!==0&&(A=e.pstyle("underlay-padding").value));var S=Math.max(T,A),_=0,I=0;if(i&&(_=e.pstyle("width").pfValue,I=_/2),u&&r.includeNodes){var D=e.position();g=D.x,y=D.y;var k=e.outerWidth(),L=k/2,R=e.outerHeight(),O=R/2;f=g-L,d=g+L,p=y-O,m=y+O,zl(s,f,p,d,m),i&&r.includeOutlines&&gKe(s,e)}else if(h&&r.includeEdges)if(i&&!a){var M=e.pstyle("curve-style").strValue;if(f=Math.min(v.srcX,v.midX,v.tgtX),d=Math.max(v.srcX,v.midX,v.tgtX),p=Math.min(v.srcY,v.midY,v.tgtY),m=Math.max(v.srcY,v.midY,v.tgtY),f-=I,d+=I,p-=I,m+=I,zl(s,f,p,d,m),M==="haystack"){var B=v.haystackPts;if(B&&B.length===2){if(f=B[0].x,p=B[0].y,d=B[1].x,m=B[1].y,f>d){var F=f;f=d,d=F}if(p>m){var P=p;p=m,m=P}zl(s,f-I,p-I,d+I,m+I)}}else if(M==="bezier"||M==="unbundled-bezier"||M.endsWith("segments")||M.endsWith("taxi")){var z;switch(M){case"bezier":case"unbundled-bezier":z=v.bezierPts;break;case"segments":case"taxi":case"round-segments":case"round-taxi":z=v.linePts;break}if(z!=null)for(var $=0;$d){var le=f;f=d,d=le}if(p>m){var he=p;p=m,m=he}f-=I,d+=I,p-=I,m+=I,zl(s,f,p,d,m)}if(i&&r.includeEdges&&h&&(tS(s,e,"mid-source"),tS(s,e,"mid-target"),tS(s,e,"source"),tS(s,e,"target")),i){var K=e.pstyle("ghost").value==="yes";if(K){var X=e.pstyle("ghost-offset-x").pfValue,te=e.pstyle("ghost-offset-y").pfValue;zl(s,s.x1+X,s.y1+te,s.x2+X,s.y2+te)}}var J=l.bodyBounds=l.bodyBounds||{};Fpe(J,s),uS(J,x),cS(J,1),i&&(f=s.x1,d=s.x2,p=s.y1,m=s.y2,zl(s,f-S,p-S,d+S,m+S));var se=l.overlayBounds=l.overlayBounds||{};Fpe(se,s),uS(se,x),cS(se,1);var ue=l.labelBounds=l.labelBounds||{};ue.all!=null?Pqe(ue.all):ue.all=Hs(),i&&r.includeLabels&&(r.includeMainLabels&&CP(s,e,null),h&&(r.includeSourceLabels&&CP(s,e,"source"),r.includeTargetLabels&&CP(s,e,"target")))}return s.x1=el(s.x1),s.y1=el(s.y1),s.x2=el(s.x2),s.y2=el(s.y2),s.w=el(s.x2-s.x1),s.h=el(s.y2-s.y1),s.w>0&&s.h>0&&w&&(uS(s,x),cS(s,1)),s},"boundingBoxImpl"),Ume=o(function(e){var r=0,n=o(function(s){return(s?1:0)<=0;l--)s(l);return this};Nf.removeAllListeners=function(){return this.removeListener("*")};Nf.emit=Nf.trigger=function(t,e,r){var n=this.listeners,i=n.length;return this.emitting++,En(e)||(e=[e]),MKe(this,function(a,s){r!=null&&(n=[{event:s.event,type:s.type,namespace:s.namespace,callback:r}],i=n.length);for(var l=o(function(f){var d=n[f];if(d.type===s.type&&(!d.namespace||d.namespace===s.namespace||d.namespace===RKe)&&a.eventMatches(a.context,d,s)){var p=[s];e!=null&&uqe(p,e),a.beforeEmit(a.context,d,s),d.conf&&d.conf.one&&(a.listeners=a.listeners.filter(function(y){return y!==d}));var m=a.callbackContext(a.context,d,s),g=d.callback.apply(m,p);a.afterEmit(a.context,d,s),g===!1&&(s.stopPropagation(),s.preventDefault())}},"_loop2"),u=0;u1&&!s){var l=this.length-1,u=this[l],h=u._private.data.id;this[l]=void 0,this[e]=u,a.set(h,{ele:u,index:e})}return this.length--,this},"unmergeAt"),unmergeOne:o(function(e){e=e[0];var r=this._private,n=e._private.data.id,i=r.map,a=i.get(n);if(!a)return this;var s=a.index;return this.unmergeAt(s),this},"unmergeOne"),unmerge:o(function(e){var r=this._private.cy;if(!e)return this;if(e&&Zt(e)){var n=e;e=r.mutableElements().filter(n)}for(var i=0;i=0;r--){var n=this[r];e(n)&&this.unmergeAt(r)}return this},"unmergeBy"),map:o(function(e,r){for(var n=[],i=this,a=0;an&&(n=u,i=l)}return{value:n,ele:i}},"max"),min:o(function(e,r){for(var n=1/0,i,a=this,s=0;s=0&&a"u"?"undefined":Wi(Symbol))!=e&&Wi(Symbol.iterator)!=e;r&&(ES[Symbol.iterator]=function(){var n=this,i={value:void 0,done:!1},a=0,s=this.length;return X0e({next:o(function(){return a1&&arguments[1]!==void 0?arguments[1]:!0,n=this[0],i=n.cy();if(i.styleEnabled()&&n){n._private.styleDirty&&(n._private.styleDirty=!1,i.style().apply(n));var a=n._private.style[e];return a??(r?i.style().getDefaultProperty(e):null)}},"parsedStyle"),numericStyle:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r){var n=r.pstyle(e);return n.pfValue!==void 0?n.pfValue:n.value}},"numericStyle"),numericStyleUnits:o(function(e){var r=this[0];if(r.cy().styleEnabled()&&r)return r.pstyle(e).units},"numericStyleUnits"),renderedStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=this[0];if(n)return r.style().getRenderedStyle(n,e)},"renderedStyle"),style:o(function(e,r){var n=this.cy();if(!n.styleEnabled())return this;var i=!1,a=n.style();if(Ur(e)){var s=e;a.applyBypass(this,s,i),this.emitAndNotify("style")}else if(Zt(e))if(r===void 0){var l=this[0];return l?a.getStylePropertyValue(l,e):void 0}else a.applyBypass(this,e,r,i),this.emitAndNotify("style");else if(e===void 0){var u=this[0];return u?a.getRawStyle(u):void 0}return this},"style"),removeStyle:o(function(e){var r=this.cy();if(!r.styleEnabled())return this;var n=!1,i=r.style(),a=this;if(e===void 0)for(var s=0;s0&&e.push(f[0]),e.push(l[0])}return this.spawn(e,!0).filter(t)},"neighborhood"),closedNeighborhood:o(function(e){return this.neighborhood().add(this).filter(e)},"closedNeighborhood"),openNeighborhood:o(function(e){return this.neighborhood(e)},"openNeighborhood")});$a.neighbourhood=$a.neighborhood;$a.closedNeighbourhood=$a.closedNeighborhood;$a.openNeighbourhood=$a.openNeighborhood;rr($a,{source:tl(o(function(e){var r=this[0],n;return r&&(n=r._private.source||r.cy().collection()),n&&e?n.filter(e):n},"sourceImpl"),"source"),target:tl(o(function(e){var r=this[0],n;return r&&(n=r._private.target||r.cy().collection()),n&&e?n.filter(e):n},"targetImpl"),"target"),sources:g0e({attr:"source"}),targets:g0e({attr:"target"})});o(g0e,"defineSourceFunction");rr($a,{edgesWith:tl(y0e(),"edgesWith"),edgesTo:tl(y0e({thisIsSrc:!0}),"edgesTo")});o(y0e,"defineEdgesWithFunction");rr($a,{connectedEdges:tl(function(t){for(var e=[],r=this,n=0;n0);return s},"components"),component:o(function(){var e=this[0];return e.cy().mutableElements().components(e)[0]},"component")});$a.componentsOf=$a.components;ka=o(function(e,r){var n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!1,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!1;if(e===void 0){ai("A collection must have a reference to the core");return}var a=new Xc,s=!1;if(!r)r=[];else if(r.length>0&&Ur(r[0])&&!t4(r[0])){s=!0;for(var l=[],u=new J1,h=0,f=r.length;h0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=r.cy(),i=n._private,a=[],s=[],l,u=0,h=r.length;u0){for(var P=l.length===r.length?r:new ka(n,l),z=0;z0&&arguments[0]!==void 0?arguments[0]:!0,e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,r=this,n=[],i={},a=r._private.cy;function s(R){for(var O=R._private.edges,M=0;M0&&(t?D.emitAndNotify("remove"):e&&D.emit("remove"));for(var k=0;kf&&Math.abs(g.v)>f;);return p?function(y){return u[y*(u.length-1)|0]}:h},"springRK4Factory")}(),Nn=o(function(e,r,n,i){var a=UKe(e,r,n,i);return function(s,l,u){return s+(l-s)*a(u)}},"cubicBezier"),dS={linear:o(function(e,r,n){return e+(r-e)*n},"linear"),ease:Nn(.25,.1,.25,1),"ease-in":Nn(.42,0,1,1),"ease-out":Nn(0,0,.58,1),"ease-in-out":Nn(.42,0,.58,1),"ease-in-sine":Nn(.47,0,.745,.715),"ease-out-sine":Nn(.39,.575,.565,1),"ease-in-out-sine":Nn(.445,.05,.55,.95),"ease-in-quad":Nn(.55,.085,.68,.53),"ease-out-quad":Nn(.25,.46,.45,.94),"ease-in-out-quad":Nn(.455,.03,.515,.955),"ease-in-cubic":Nn(.55,.055,.675,.19),"ease-out-cubic":Nn(.215,.61,.355,1),"ease-in-out-cubic":Nn(.645,.045,.355,1),"ease-in-quart":Nn(.895,.03,.685,.22),"ease-out-quart":Nn(.165,.84,.44,1),"ease-in-out-quart":Nn(.77,0,.175,1),"ease-in-quint":Nn(.755,.05,.855,.06),"ease-out-quint":Nn(.23,1,.32,1),"ease-in-out-quint":Nn(.86,0,.07,1),"ease-in-expo":Nn(.95,.05,.795,.035),"ease-out-expo":Nn(.19,1,.22,1),"ease-in-out-expo":Nn(1,0,0,1),"ease-in-circ":Nn(.6,.04,.98,.335),"ease-out-circ":Nn(.075,.82,.165,1),"ease-in-out-circ":Nn(.785,.135,.15,.86),spring:o(function(e,r,n){if(n===0)return dS.linear;var i=HKe(e,r,n);return function(a,s,l){return a+(s-a)*i(l)}},"spring"),"cubic-bezier":Nn};o(x0e,"getEasedValue");o(b0e,"getValue");o($1,"ease");o(WKe,"step$1");o(Rb,"valid");o(qKe,"startAnimation");o(w0e,"stepAll");YKe={animate:cn.animate(),animation:cn.animation(),animated:cn.animated(),clearQueue:cn.clearQueue(),delay:cn.delay(),delayAnimation:cn.delayAnimation(),stop:cn.stop(),addToAnimationPool:o(function(e){var r=this;r.styleEnabled()&&r._private.aniEles.merge(e)},"addToAnimationPool"),stopAnimationLoop:o(function(){this._private.animationsRunning=!1},"stopAnimationLoop"),startAnimationLoop:o(function(){var e=this;if(e._private.animationsRunning=!0,!e.styleEnabled())return;function r(){e._private.animationsRunning&&xS(o(function(a){w0e(a,e),r()},"animationStep"))}o(r,"headlessStep");var n=e.renderer();n&&n.beforeRender?n.beforeRender(o(function(a,s){w0e(s,e)},"rendererAnimationStep"),n.beforeRenderPriorities.animations):r()},"startAnimationLoop")},XKe={qualifierCompare:o(function(e,r){return e==null||r==null?e==null&&r==null:e.sameText(r)},"qualifierCompare"),eventMatches:o(function(e,r,n){var i=r.qualifier;return i!=null?e!==n.target&&t4(n.target)&&i.matches(n.target):!0},"eventMatches"),addEventFields:o(function(e,r){r.cy=e,r.target=e},"addEventFields"),callbackContext:o(function(e,r,n){return r.qualifier!=null?n.target:e},"callbackContext")},iS=o(function(e){return Zt(e)?new Lf(e):e},"argSelector"),ege={createEmitter:o(function(){var e=this._private;return e.emitter||(e.emitter=new $S(XKe,this)),this},"createEmitter"),emitter:o(function(){return this._private.emitter},"emitter"),on:o(function(e,r,n){return this.emitter().on(e,iS(r),n),this},"on"),removeListener:o(function(e,r,n){return this.emitter().removeListener(e,iS(r),n),this},"removeListener"),removeAllListeners:o(function(){return this.emitter().removeAllListeners(),this},"removeAllListeners"),one:o(function(e,r,n){return this.emitter().one(e,iS(r),n),this},"one"),once:o(function(e,r,n){return this.emitter().one(e,iS(r),n),this},"once"),emit:o(function(e,r){return this.emitter().emit(e,r),this},"emit"),emitAndNotify:o(function(e,r){return this.emit(e),this.notify(e,r),this},"emitAndNotify")};cn.eventAliasesOn(ege);VP={png:o(function(e){var r=this._private.renderer;return e=e||{},r.png(e)},"png"),jpg:o(function(e){var r=this._private.renderer;return e=e||{},e.bg=e.bg||"#fff",r.jpg(e)},"jpg")};VP.jpeg=VP.jpg;pS={layout:o(function(e){var r=this;if(e==null){ai("Layout options must be specified to make a layout");return}if(e.name==null){ai("A `name` must be specified to make a layout");return}var n=e.name,i=r.extension("layout",n);if(i==null){ai("No such layout `"+n+"` found. Did you forget to import it and `cytoscape.use()` it?");return}var a;Zt(e.eles)?a=r.$(e.eles):a=e.eles!=null?e.eles:r.$();var s=new i(rr({},e,{cy:r,eles:a}));return s},"layout")};pS.createLayout=pS.makeLayout=pS.layout;jKe={notify:o(function(e,r){var n=this._private;if(this.batching()){n.batchNotifications=n.batchNotifications||{};var i=n.batchNotifications[e]=n.batchNotifications[e]||this.collection();r!=null&&i.merge(r);return}if(n.notificationsEnabled){var a=this.renderer();this.destroyed()||!a||a.notify(e,r)}},"notify"),notifications:o(function(e){var r=this._private;return e===void 0?r.notificationsEnabled:(r.notificationsEnabled=!!e,this)},"notifications"),noNotifications:o(function(e){this.notifications(!1),e(),this.notifications(!0)},"noNotifications"),batching:o(function(){return this._private.batchCount>0},"batching"),startBatch:o(function(){var e=this._private;return e.batchCount==null&&(e.batchCount=0),e.batchCount===0&&(e.batchStyleEles=this.collection(),e.batchNotifications={}),e.batchCount++,this},"startBatch"),endBatch:o(function(){var e=this._private;if(e.batchCount===0)return this;if(e.batchCount--,e.batchCount===0){e.batchStyleEles.updateStyle();var r=this.renderer();Object.keys(e.batchNotifications).forEach(function(n){var i=e.batchNotifications[n];i.empty()?r.notify(n):r.notify(n,i)})}return this},"endBatch"),batch:o(function(e){return this.startBatch(),e(),this.endBatch(),this},"batch"),batchData:o(function(e){var r=this;return this.batch(function(){for(var n=Object.keys(e),i=0;i0;)r.removeChild(r.childNodes[0]);e._private.renderer=null,e.mutableElements().forEach(function(n){var i=n._private;i.rscratch={},i.rstyle={},i.animation.current=[],i.animation.queue=[]})},"destroyRenderer"),onRender:o(function(e){return this.on("render",e)},"onRender"),offRender:o(function(e){return this.off("render",e)},"offRender")};UP.invalidateDimensions=UP.resize;mS={collection:o(function(e,r){return Zt(e)?this.$(e):go(e)?e.collection():En(e)?(r||(r={}),new ka(this,e,r.unique,r.removed)):new ka(this)},"collection"),nodes:o(function(e){var r=this.$(function(n){return n.isNode()});return e?r.filter(e):r},"nodes"),edges:o(function(e){var r=this.$(function(n){return n.isEdge()});return e?r.filter(e):r},"edges"),$:o(function(e){var r=this._private.elements;return e?r.filter(e):r.spawnSelf()},"$"),mutableElements:o(function(){return this._private.elements},"mutableElements")};mS.elements=mS.filter=mS.$;Ga={},$b="t",QKe="f";Ga.apply=function(t){for(var e=this,r=e._private,n=r.cy,i=n.collection(),a=0;a0;if(p||d&&m){var g=void 0;p&&m||p?g=h.properties:m&&(g=h.mappedProperties);for(var y=0;y1&&(C=1),l.color){var E=n.valueMin[0],A=n.valueMax[0],S=n.valueMin[1],_=n.valueMax[1],I=n.valueMin[2],D=n.valueMax[2],k=n.valueMin[3]==null?1:n.valueMin[3],L=n.valueMax[3]==null?1:n.valueMax[3],R=[Math.round(E+(A-E)*C),Math.round(S+(_-S)*C),Math.round(I+(D-I)*C),Math.round(k+(L-k)*C)];a={bypass:n.bypass,name:n.name,value:R,strValue:"rgb("+R[0]+", "+R[1]+", "+R[2]+")"}}else if(l.number){var O=n.valueMin+(n.valueMax-n.valueMin)*C;a=this.parse(n.name,O,n.bypass,p)}else return!1;if(!a)return y(),!1;a.mapping=n,n=a;break}case s.data:{for(var M=n.field.split("."),B=d.data,F=0;F0&&a>0){for(var l={},u=!1,h=0;h0?t.delayAnimation(s).play().promise().then(w):w()}).then(function(){return t.animation({style:l,duration:a,easing:t.pstyle("transition-timing-function").value,queue:!1}).play().promise()}).then(function(){r.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1})}else n.transitioning&&(this.removeBypasses(t,i),t.emitAndNotify("style"),n.transitioning=!1)};Ga.checkTrigger=function(t,e,r,n,i,a){var s=this.properties[e],l=i(s);l!=null&&l(r,n)&&a(s)};Ga.checkZOrderTrigger=function(t,e,r,n){var i=this;this.checkTrigger(t,e,r,n,function(a){return a.triggersZOrder},function(){i._private.cy.notify("zorder",t)})};Ga.checkBoundsTrigger=function(t,e,r,n){this.checkTrigger(t,e,r,n,function(i){return i.triggersBounds},function(i){t.dirtyCompoundBoundsCache(),t.dirtyBoundingBoxCache(),i.triggersBoundsOfParallelBeziers&&e==="curve-style"&&(r==="bezier"||n==="bezier")&&t.parallelEdges().forEach(function(a){a.dirtyBoundingBoxCache()}),i.triggersBoundsOfConnectedEdges&&e==="display"&&(r==="none"||n==="none")&&t.connectedEdges().forEach(function(a){a.dirtyBoundingBoxCache()})})};Ga.checkTriggers=function(t,e,r,n){t.dirtyStyleCache(),this.checkZOrderTrigger(t,e,r,n),this.checkBoundsTrigger(t,e,r,n)};s4={};s4.applyBypass=function(t,e,r,n){var i=this,a=[],s=!0;if(e==="*"||e==="**"){if(r!==void 0)for(var l=0;li.length?n=n.substr(i.length):n=""}o(l,"removeSelAndBlockFromRemaining");function u(){a.length>s.length?a=a.substr(s.length):a=""}for(o(u,"removePropAndValFromRem");;){var h=n.match(/^\s*$/);if(h)break;var f=n.match(/^\s*((?:.|\s)+?)\s*\{((?:.|\s)+?)\}/);if(!f){un("Halting stylesheet parsing: String stylesheet contains more to parse but no selector and block found in: "+n);break}i=f[0];var d=f[1];if(d!=="core"){var p=new Lf(d);if(p.invalid){un("Skipping parsing of block: Invalid selector found in string stylesheet: "+d),l();continue}}var m=f[2],g=!1;a=m;for(var y=[];;){var v=a.match(/^\s*$/);if(v)break;var x=a.match(/^\s*(.+?)\s*:\s*(.+?)(?:\s*;|\s*$)/);if(!x){un("Skipping parsing of block: Invalid formatting of style property and value definitions found in:"+m),g=!0;break}s=x[0];var b=x[1],w=x[2],C=e.properties[b];if(!C){un("Skipping property: Invalid property name in: "+s),u();continue}var T=r.parse(b,w);if(!T){un("Skipping property: Invalid property definition in: "+s),u();continue}y.push({name:b,val:w}),u()}if(g){l();break}r.selector(d);for(var E=0;E=7&&e[0]==="d"&&(f=new RegExp(l.data.regex).exec(e))){if(r)return!1;var p=l.data;return{name:t,value:f,strValue:""+e,mapped:p,field:f[1],bypass:r}}else if(e.length>=10&&e[0]==="m"&&(d=new RegExp(l.mapData.regex).exec(e))){if(r||h.multiple)return!1;var m=l.mapData;if(!(h.color||h.number))return!1;var g=this.parse(t,d[4]);if(!g||g.mapped)return!1;var y=this.parse(t,d[5]);if(!y||y.mapped)return!1;if(g.pfValue===y.pfValue||g.strValue===y.strValue)return un("`"+t+": "+e+"` is not a valid mapper because the output range is zero; converting to `"+t+": "+g.strValue+"`"),this.parse(t,g.strValue);if(h.color){var v=g.value,x=y.value,b=v[0]===x[0]&&v[1]===x[1]&&v[2]===x[2]&&(v[3]===x[3]||(v[3]==null||v[3]===1)&&(x[3]==null||x[3]===1));if(b)return!1}return{name:t,value:d,strValue:""+e,mapped:m,field:d[1],fieldMin:parseFloat(d[2]),fieldMax:parseFloat(d[3]),valueMin:g.value,valueMax:y.value,bypass:r}}}if(h.multiple&&n!=="multiple"){var w;if(u?w=e.split(/\s+/):En(e)?w=e:w=[e],h.evenMultiple&&w.length%2!==0)return null;for(var C=[],T=[],E=[],A="",S=!1,_=0;_0?" ":"")+I.strValue}return h.validate&&!h.validate(C,T)?null:h.singleEnum&&S?C.length===1&&Zt(C[0])?{name:t,value:C[0],strValue:C[0],bypass:r}:null:{name:t,value:C,pfValue:E,strValue:A,bypass:r,units:T}}var D=o(function(){for(var K=0;Kh.max||h.strictMax&&e===h.max))return null;var M={name:t,value:e,strValue:""+e+(k||""),units:k,bypass:r};return h.unitless||k!=="px"&&k!=="em"?M.pfValue=e:M.pfValue=k==="px"||!k?e:this.getEmSizeInPixels()*e,(k==="ms"||k==="s")&&(M.pfValue=k==="ms"?e:1e3*e),(k==="deg"||k==="rad")&&(M.pfValue=k==="rad"?e:Nqe(e)),k==="%"&&(M.pfValue=e/100),M}else if(h.propList){var B=[],F=""+e;if(F!=="none"){for(var P=F.split(/\s*,\s*|\s+/),z=0;z0&&l>0&&!isNaN(n.w)&&!isNaN(n.h)&&n.w>0&&n.h>0){u=Math.min((s-2*r)/n.w,(l-2*r)/n.h),u=u>this._private.maxZoom?this._private.maxZoom:u,u=u=n.minZoom&&(n.maxZoom=r),this},"zoomRange"),minZoom:o(function(e){return e===void 0?this._private.minZoom:this.zoomRange({min:e})},"minZoom"),maxZoom:o(function(e){return e===void 0?this._private.maxZoom:this.zoomRange({max:e})},"maxZoom"),getZoomedViewport:o(function(e){var r=this._private,n=r.pan,i=r.zoom,a,s,l=!1;if(r.zoomingEnabled||(l=!0),Ct(e)?s=e:Ur(e)&&(s=e.level,e.position!=null?a=MS(e.position,i,n):e.renderedPosition!=null&&(a=e.renderedPosition),a!=null&&!r.panningEnabled&&(l=!0)),s=s>r.maxZoom?r.maxZoom:s,s=sr.maxZoom||!r.zoomingEnabled?s=!0:(r.zoom=u,a.push("zoom"))}if(i&&(!s||!e.cancelOnFailedZoom)&&r.panningEnabled){var h=e.pan;Ct(h.x)&&(r.pan.x=h.x,l=!1),Ct(h.y)&&(r.pan.y=h.y,l=!1),l||a.push("pan")}return a.length>0&&(a.push("viewport"),this.emit(a.join(" ")),this.notify("viewport")),this},"viewport"),center:o(function(e){var r=this.getCenterPan(e);return r&&(this._private.pan=r,this.emit("pan viewport"),this.notify("viewport")),this},"center"),getCenterPan:o(function(e,r){if(this._private.panningEnabled){if(Zt(e)){var n=e;e=this.mutableElements().filter(n)}else go(e)||(e=this.mutableElements());if(e.length!==0){var i=e.boundingBox(),a=this.width(),s=this.height();r=r===void 0?this._private.zoom:r;var l={x:(a-r*(i.x1+i.x2))/2,y:(s-r*(i.y1+i.y2))/2};return l}}},"getCenterPan"),reset:o(function(){return!this._private.panningEnabled||!this._private.zoomingEnabled?this:(this.viewport({pan:{x:0,y:0},zoom:1}),this)},"reset"),invalidateSize:o(function(){this._private.sizeCache=null},"invalidateSize"),size:o(function(){var e=this._private,r=e.container,n=this;return e.sizeCache=e.sizeCache||(r?function(){var i=n.window().getComputedStyle(r),a=o(function(l){return parseFloat(i.getPropertyValue(l))},"val");return{width:r.clientWidth-a("padding-left")-a("padding-right"),height:r.clientHeight-a("padding-top")-a("padding-bottom")}}():{width:1,height:1})},"size"),width:o(function(){return this.size().width},"width"),height:o(function(){return this.size().height},"height"),extent:o(function(){var e=this._private.pan,r=this._private.zoom,n=this.renderedExtent(),i={x1:(n.x1-e.x)/r,x2:(n.x2-e.x)/r,y1:(n.y1-e.y)/r,y2:(n.y2-e.y)/r};return i.w=i.x2-i.x1,i.h=i.y2-i.y1,i},"extent"),renderedExtent:o(function(){var e=this.width(),r=this.height();return{x1:0,y1:0,x2:e,y2:r,w:e,h:r}},"renderedExtent"),multiClickDebounceTime:o(function(e){if(e)this._private.multiClickDebounceTime=e;else return this._private.multiClickDebounceTime;return this},"multiClickDebounceTime")};Hp.centre=Hp.center;Hp.autolockNodes=Hp.autolock;Hp.autoungrabifyNodes=Hp.autoungrabify;Zb={data:cn.data({field:"data",bindingEvent:"data",allowBinding:!0,allowSetting:!0,settingEvent:"data",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeData:cn.removeData({field:"data",event:"data",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0}),scratch:cn.data({field:"scratch",bindingEvent:"scratch",allowBinding:!0,allowSetting:!0,settingEvent:"scratch",settingTriggersEvent:!0,triggerFnName:"trigger",allowGetting:!0,updateStyle:!0}),removeScratch:cn.removeData({field:"scratch",event:"scratch",triggerFnName:"trigger",triggerEvent:!0,updateStyle:!0})};Zb.attr=Zb.data;Zb.removeAttr=Zb.removeData;Jb=o(function(e){var r=this;e=rr({},e);var n=e.container;n&&!vS(n)&&vS(n[0])&&(n=n[0]);var i=n?n._cyreg:null;i=i||{},i&&i.cy&&(i.cy.destroy(),i={});var a=i.readies=i.readies||[];n&&(n._cyreg=i),i.cy=r;var s=Ui!==void 0&&n!==void 0&&!e.headless,l=e;l.layout=rr({name:s?"grid":"null"},l.layout),l.renderer=rr({name:s?"canvas":"null"},l.renderer);var u=o(function(g,y,v){return y!==void 0?y:v!==void 0?v:g},"defVal"),h=this._private={container:n,ready:!1,options:l,elements:new ka(this),listeners:[],aniEles:new ka(this),data:l.data||{},scratch:{},layout:null,renderer:null,destroyed:!1,notificationsEnabled:!0,minZoom:1e-50,maxZoom:1e50,zoomingEnabled:u(!0,l.zoomingEnabled),userZoomingEnabled:u(!0,l.userZoomingEnabled),panningEnabled:u(!0,l.panningEnabled),userPanningEnabled:u(!0,l.userPanningEnabled),boxSelectionEnabled:u(!0,l.boxSelectionEnabled),autolock:u(!1,l.autolock,l.autolockNodes),autoungrabify:u(!1,l.autoungrabify,l.autoungrabifyNodes),autounselectify:u(!1,l.autounselectify),styleEnabled:l.styleEnabled===void 0?s:l.styleEnabled,zoom:Ct(l.zoom)?l.zoom:1,pan:{x:Ur(l.pan)&&Ct(l.pan.x)?l.pan.x:0,y:Ur(l.pan)&&Ct(l.pan.y)?l.pan.y:0},animation:{current:[],queue:[]},hasCompoundNodes:!1,multiClickDebounceTime:u(250,l.multiClickDebounceTime)};this.createEmitter(),this.selectionType(l.selectionType),this.zoomRange({min:l.minZoom,max:l.maxZoom});var f=o(function(g,y){var v=g.some(nWe);if(v)return ey.all(g).then(y);y(g)},"loadExtData");h.styleEnabled&&r.setStyle([]);var d=rr({},l,l.renderer);r.initRenderer(d);var p=o(function(g,y,v){r.notifications(!1);var x=r.mutableElements();x.length>0&&x.remove(),g!=null&&(Ur(g)||En(g))&&r.add(g),r.one("layoutready",function(w){r.notifications(!0),r.emit(w),r.one("load",y),r.emitAndNotify("load")}).one("layoutstop",function(){r.one("done",v),r.emit("done")});var b=rr({},r._private.options.layout);b.eles=r.elements(),r.layout(b).run()},"setElesAndLayout");f([l.style,l.elements],function(m){var g=m[0],y=m[1];h.styleEnabled&&r.style().append(g),p(y,function(){r.startAnimationLoop(),h.ready=!0,si(l.ready)&&r.on("ready",l.ready);for(var v=0;v0,l=!!t.boundingBox,u=e.extent(),h=Hs(l?t.boundingBox:{x1:u.x1,y1:u.y1,w:u.w,h:u.h}),f;if(go(t.roots))f=t.roots;else if(En(t.roots)){for(var d=[],p=0;p0;){var O=R(),M=I(O,k);if(M)O.outgoers().filter(function(ae){return ae.isNode()&&r.has(ae)}).forEach(L);else if(M===null){un("Detected double maximal shift for node `"+O.id()+"`. Bailing maximal adjustment due to cycle. Use `options.maximal: true` only on DAGs.");break}}}var B=0;if(t.avoidOverlap)for(var F=0;F0&&b[0].length<=3?$e/2:0),Ie=2*Math.PI/b[ze].length*He;return ze===0&&b[0].length===1&&(Re=1),{x:se.x+Re*Math.cos(Ie),y:se.y+Re*Math.sin(Ie)}}else{var be=b[ze].length,W=Math.max(be===1?0:l?(h.w-t.padding*2-ue.w)/((t.grid?Se:be)-1):(h.w-t.padding*2-ue.w)/((t.grid?Se:be)+1),B),de={x:se.x+(He+1-(be+1)/2)*W,y:se.y+(ze+1-(ne+1)/2)*Z};return de}},"getPosition");return r.nodes().layoutPositions(this,t,ce),this};rQe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,radius:void 0,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(rge,"CircleLayout");rge.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,a=n.nodes().not(":parent");e.sort&&(a=a.sort(e.sort));for(var s=Hs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=e.sweep===void 0?2*Math.PI-2*Math.PI/a.length:e.sweep,h=u/Math.max(1,a.length-1),f,d=0,p=0;p1&&e.avoidOverlap){d*=1.75;var x=Math.cos(h)-Math.cos(0),b=Math.sin(h)-Math.sin(0),w=Math.sqrt(d*d/(x*x+b*b));f=Math.max(w,f)}var C=o(function(E,A){var S=e.startAngle+A*h*(i?1:-1),_=f*Math.cos(S),I=f*Math.sin(S),D={x:l.x+_,y:l.y+I};return D},"getPos");return n.nodes().layoutPositions(this,e,C),this};nQe={fit:!0,padding:30,startAngle:3/2*Math.PI,sweep:void 0,clockwise:!0,equidistant:!1,minNodeSpacing:10,boundingBox:void 0,avoidOverlap:!0,nodeDimensionsIncludeLabels:!1,height:void 0,width:void 0,spacingFactor:void 0,concentric:o(function(e){return e.degree()},"concentric"),levelWidth:o(function(e){return e.maxDegree()/4},"levelWidth"),animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(nge,"ConcentricLayout");nge.prototype.run=function(){for(var t=this.options,e=t,r=e.counterclockwise!==void 0?!e.counterclockwise:e.clockwise,n=t.cy,i=e.eles,a=i.nodes().not(":parent"),s=Hs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:n.width(),h:n.height()}),l={x:s.x1+s.w/2,y:s.y1+s.h/2},u=[],h=0,f=0;f0){var T=Math.abs(b[0].value-C.value);T>=v&&(b=[],x.push(b))}b.push(C)}var E=h+e.minNodeSpacing;if(!e.avoidOverlap){var A=x.length>0&&x[0].length>1,S=Math.min(s.w,s.h)/2-E,_=S/(x.length+A?1:0);E=Math.min(E,_)}for(var I=0,D=0;D1&&e.avoidOverlap){var O=Math.cos(R)-Math.cos(0),M=Math.sin(R)-Math.sin(0),B=Math.sqrt(E*E/(O*O+M*M));I=Math.max(B,I)}k.r=I,I+=E}if(e.equidistant){for(var F=0,P=0,z=0;z=t.numIter||(hQe(n,t),n.temperature=n.temperature*t.coolingFactor,n.temperature=t.animationThreshold&&a(),xS(d)}},"frame");f()}else{for(;h;)h=s(u),u++;E0e(n,t),l()}return this};HS.prototype.stop=function(){return this.stopped=!0,this.thread&&this.thread.stop(),this.emit("layoutstop"),this};HS.prototype.destroy=function(){return this.thread&&this.thread.stop(),this};aQe=o(function(e,r,n){for(var i=n.eles.edges(),a=n.eles.nodes(),s=Hs(n.boundingBox?n.boundingBox:{x1:0,y1:0,w:e.width(),h:e.height()}),l={isCompound:e.hasCompoundNodes(),layoutNodes:[],idToIndex:{},nodeSize:a.size(),graphSet:[],indexToGraph:[],layoutEdges:[],edgeSize:i.size(),temperature:n.initialTemp,clientWidth:s.w,clientHeight:s.h,boundingBox:s},u=n.eles.components(),h={},f=0;f0){l.graphSet.push(S);for(var f=0;fi.count?0:i.graph},"findLCA"),oQe=o(function t(e,r,n,i){var a=i.graphSet[n];if(-10)var d=i.nodeOverlap*f,p=Math.sqrt(l*l+u*u),m=d*l/p,g=d*u/p;else var y=CS(e,l,u),v=CS(r,-1*l,-1*u),x=v.x-y.x,b=v.y-y.y,w=x*x+b*b,p=Math.sqrt(w),d=(e.nodeRepulsion+r.nodeRepulsion)/w,m=d*x/p,g=d*b/p;e.isLocked||(e.offsetX-=m,e.offsetY-=g),r.isLocked||(r.offsetX+=m,r.offsetY+=g)}},"nodeRepulsion"),pQe=o(function(e,r,n,i){if(n>0)var a=e.maxX-r.minX;else var a=r.maxX-e.minX;if(i>0)var s=e.maxY-r.minY;else var s=r.maxY-e.minY;return a>=0&&s>=0?Math.sqrt(a*a+s*s):0},"nodesOverlap"),CS=o(function(e,r,n){var i=e.positionX,a=e.positionY,s=e.height||1,l=e.width||1,u=n/r,h=s/l,f={};return r===0&&0n?(f.x=i,f.y=a+s/2,f):0r&&-1*h<=u&&u<=h?(f.x=i-l/2,f.y=a-l*n/2/r,f):0=h)?(f.x=i+s*r/2/n,f.y=a+s/2,f):(0>n&&(u<=-1*h||u>=h)&&(f.x=i-s*r/2/n,f.y=a-s/2),f)},"findClippingPoint"),mQe=o(function(e,r){for(var n=0;nn){var v=r.gravity*m/y,x=r.gravity*g/y;p.offsetX+=v,p.offsetY+=x}}}}},"calculateGravityForces"),yQe=o(function(e,r){var n=[],i=0,a=-1;for(n.push.apply(n,e.graphSet[0]),a+=e.graphSet[0].length;i<=a;){var s=n[i++],l=e.idToIndex[s],u=e.layoutNodes[l],h=u.children;if(0n)var a={x:n*e/i,y:n*r/i};else var a={x:e,y:r};return a},"limitForce"),bQe=o(function t(e,r){var n=e.parentId;if(n!=null){var i=r.layoutNodes[r.idToIndex[n]],a=!1;if((i.maxX==null||e.maxX+i.padRight>i.maxX)&&(i.maxX=e.maxX+i.padRight,a=!0),(i.minX==null||e.minX-i.padLefti.maxY)&&(i.maxY=e.maxY+i.padBottom,a=!0),(i.minY==null||e.minY-i.padTopx&&(g+=v+r.componentSpacing,m=0,y=0,v=0)}}},"separateComponents"),wQe={fit:!0,padding:30,boundingBox:void 0,avoidOverlap:!0,avoidOverlapPadding:10,nodeDimensionsIncludeLabels:!1,spacingFactor:void 0,condense:!1,rows:void 0,cols:void 0,position:o(function(e){},"position"),sort:void 0,animate:!1,animationDuration:500,animationEasing:void 0,animateFilter:o(function(e,r){return!0},"animateFilter"),ready:void 0,stop:void 0,transform:o(function(e,r){return r},"transform")};o(age,"GridLayout");age.prototype.run=function(){var t=this.options,e=t,r=t.cy,n=e.eles,i=n.nodes().not(":parent");e.sort&&(i=i.sort(e.sort));var a=Hs(e.boundingBox?e.boundingBox:{x1:0,y1:0,w:r.width(),h:r.height()});if(a.h===0||a.w===0)n.nodes().layoutPositions(this,e,function(Q){return{x:a.x1,y:a.y1}});else{var s=i.size(),l=Math.sqrt(s*a.h/a.w),u=Math.round(l),h=Math.round(a.w/a.h*l),f=o(function(j){if(j==null)return Math.min(u,h);var ie=Math.min(u,h);ie==u?u=j:h=j},"small"),d=o(function(j){if(j==null)return Math.max(u,h);var ie=Math.max(u,h);ie==u?u=j:h=j},"large"),p=e.rows,m=e.cols!=null?e.cols:e.columns;if(p!=null&&m!=null)u=p,h=m;else if(p!=null&&m==null)u=p,h=Math.ceil(s/u);else if(p==null&&m!=null)h=m,u=Math.ceil(s/h);else if(h*u>s){var g=f(),y=d();(g-1)*y>=s?f(g-1):(y-1)*g>=s&&d(y-1)}else for(;h*u=s?d(x+1):f(v+1)}var b=a.w/h,w=a.h/u;if(e.condense&&(b=0,w=0),e.avoidOverlap)for(var C=0;C=h&&(O=0,R++)},"moveToNextCell"),B={},F=0;F(O=Wqe(t,e,M[B],M[B+1],M[B+2],M[B+3])))return v(A,O),!0}else if(_.edgeType==="bezier"||_.edgeType==="multibezier"||_.edgeType==="self"||_.edgeType==="compound"){for(var M=_.allpts,B=0;B+5<_.allpts.length;B+=4)if(Gqe(t,e,M[B],M[B+1],M[B+2],M[B+3],M[B+4],M[B+5],R)&&L>(O=Hqe(t,e,M[B],M[B+1],M[B+2],M[B+3],M[B+4],M[B+5])))return v(A,O),!0}for(var F=F||S.source,P=P||S.target,z=i.getArrowWidth(I,D),$=[{name:"source",x:_.arrowStartX,y:_.arrowStartY,angle:_.srcArrowAngle},{name:"target",x:_.arrowEndX,y:_.arrowEndY,angle:_.tgtArrowAngle},{name:"mid-source",x:_.midX,y:_.midY,angle:_.midsrcArrowAngle},{name:"mid-target",x:_.midX,y:_.midY,angle:_.midtgtArrowAngle}],B=0;B<$.length;B++){var H=$[B],Q=a.arrowShapes[A.pstyle(H.name+"-arrow-shape").value],j=A.pstyle("width").pfValue;if(Q.roughCollide(t,e,z,H.angle,{x:H.x,y:H.y},j,f)&&Q.collide(t,e,z,H.angle,{x:H.x,y:H.y},j,f))return v(A),!0}h&&l.length>0&&(x(F),x(P))}o(b,"checkEdge");function w(A,S,_){return Gl(A,S,_)}o(w,"preprop");function C(A,S){var _=A._private,I=p,D;S?D=S+"-":D="",A.boundingBox();var k=_.labelBounds[S||"main"],L=A.pstyle(D+"label").value,R=A.pstyle("text-events").strValue==="yes";if(!(!R||!L)){var O=w(_.rscratch,"labelX",S),M=w(_.rscratch,"labelY",S),B=w(_.rscratch,"labelAngle",S),F=A.pstyle(D+"text-margin-x").pfValue,P=A.pstyle(D+"text-margin-y").pfValue,z=k.x1-I-F,$=k.x2+I-F,H=k.y1-I-P,Q=k.y2+I-P;if(B){var j=Math.cos(B),ie=Math.sin(B),ne=o(function(se,ue){return se=se-O,ue=ue-M,{x:se*j-ue*ie+O,y:se*ie+ue*j+M}},"rotate"),le=ne(z,H),he=ne(z,Q),K=ne($,H),X=ne($,Q),te=[le.x+F,le.y+P,K.x+F,K.y+P,X.x+F,X.y+P,he.x+F,he.y+P];if(Us(t,e,te))return v(A),!0}else if(K1(k,t,e))return v(A),!0}}o(C,"checkLabel");for(var T=s.length-1;T>=0;T--){var E=s[T];E.isNode()?x(E)||C(E):b(E)||C(E)||C(E,"source")||C(E,"target")}return l};qp.getAllInBox=function(t,e,r,n){var i=this.getCachedZSortedEles().interactive,a=[],s=Math.min(t,r),l=Math.max(t,r),u=Math.min(e,n),h=Math.max(e,n);t=s,r=l,e=u,n=h;for(var f=Hs({x1:t,y1:e,x2:r,y2:n}),d=0;d0?-(Math.PI-e.ang):Math.PI+e.ang},"invertVec"),AQe=o(function(e,r,n,i,a){if(e!==D0e?L0e(r,e,qc):CQe(Jo,qc),L0e(r,n,Jo),A0e=qc.nx*Jo.ny-qc.ny*Jo.nx,_0e=qc.nx*Jo.nx-qc.ny*-Jo.ny,Ku=Math.asin(Math.max(-1,Math.min(1,A0e))),Math.abs(Ku)<1e-6){HP=r.x,WP=r.y,Bp=G1=0;return}Fp=1,gS=!1,_0e<0?Ku<0?Ku=Math.PI+Ku:(Ku=Math.PI-Ku,Fp=-1,gS=!0):Ku>0&&(Fp=-1,gS=!0),r.radius!==void 0?G1=r.radius:G1=i,Mp=Ku/2,aS=Math.min(qc.len/2,Jo.len/2),a?(Wc=Math.abs(Math.cos(Mp)*G1/Math.sin(Mp)),Wc>aS?(Wc=aS,Bp=Math.abs(Wc*Math.sin(Mp)/Math.cos(Mp))):Bp=G1):(Wc=Math.min(aS,G1),Bp=Math.abs(Wc*Math.sin(Mp)/Math.cos(Mp))),qP=r.x+Jo.nx*Wc,YP=r.y+Jo.ny*Wc,HP=qP-Jo.ny*Bp*Fp,WP=YP+Jo.nx*Bp*Fp,cge=r.x+qc.nx*Wc,uge=r.y+qc.ny*Wc,D0e=r},"calcCornerArc");o(hge,"drawPreparedRoundCorner");o(vB,"getRoundCorner");Va={};Va.findMidptPtsEtc=function(t,e){var r=e.posPts,n=e.intersectionPts,i=e.vectorNormInverse,a,s=t.pstyle("source-endpoint"),l=t.pstyle("target-endpoint"),u=s.units!=null&&l.units!=null,h=o(function(T,E,A,S){var _=S-E,I=A-T,D=Math.sqrt(I*I+_*_);return{x:-_/D,y:I/D}},"recalcVectorNormInverse"),f=t.pstyle("edge-distances").value;switch(f){case"node-position":a=r;break;case"intersection":a=n;break;case"endpoints":{if(u){var d=this.manualEndptToPx(t.source()[0],s),p=_i(d,2),m=p[0],g=p[1],y=this.manualEndptToPx(t.target()[0],l),v=_i(y,2),x=v[0],b=v[1],w={x1:m,y1:g,x2:x,y2:b};i=h(m,g,x,b),a=w}else un("Edge ".concat(t.id()," has edge-distances:endpoints specified without manual endpoints specified via source-endpoint and target-endpoint. Falling back on edge-distances:intersection (default).")),a=n;break}}return{midptPts:a,vectorNormInverse:i}};Va.findHaystackPoints=function(t){for(var e=0;e0?Math.max(q-pe,0):Math.min(q+pe,0)},"subDWH"),L=k(I,S),R=k(D,_),O=!1;b===h?x=Math.abs(L)>Math.abs(R)?i:n:b===u||b===l?(x=n,O=!0):(b===a||b===s)&&(x=i,O=!0);var M=x===n,B=M?R:L,F=M?D:I,P=mme(F),z=!1;!(O&&(C||E))&&(b===l&&F<0||b===u&&F>0||b===a&&F>0||b===s&&F<0)&&(P*=-1,B=P*Math.abs(B),z=!0);var $;if(C){var H=T<0?1+T:T;$=H*B}else{var Q=T<0?B:0;$=Q+T*P}var j=o(function(q){return Math.abs(q)=Math.abs(B)},"getIsTooClose"),ie=j($),ne=j(Math.abs(B)-Math.abs($)),le=ie||ne;if(le&&!z)if(M){var he=Math.abs(F)<=p/2,K=Math.abs(I)<=m/2;if(he){var X=(f.x1+f.x2)/2,te=f.y1,J=f.y2;r.segpts=[X,te,X,J]}else if(K){var se=(f.y1+f.y2)/2,ue=f.x1,Z=f.x2;r.segpts=[ue,se,Z,se]}else r.segpts=[f.x1,f.y2]}else{var Se=Math.abs(F)<=d/2,ce=Math.abs(D)<=g/2;if(Se){var ae=(f.y1+f.y2)/2,Oe=f.x1,ge=f.x2;r.segpts=[Oe,ae,ge,ae]}else if(ce){var ze=(f.x1+f.x2)/2,He=f.y1,$e=f.y2;r.segpts=[ze,He,ze,$e]}else r.segpts=[f.x2,f.y1]}else if(M){var Re=f.y1+$+(v?p/2*P:0),Ie=f.x1,be=f.x2;r.segpts=[Ie,Re,be,Re]}else{var W=f.x1+$+(v?d/2*P:0),de=f.y1,re=f.y2;r.segpts=[W,de,W,re]}if(r.isRound){var oe=t.pstyle("taxi-radius").value,V=t.pstyle("radius-type").value[0]==="arc-radius";r.radii=new Array(r.segpts.length/2).fill(oe),r.isArcRadius=new Array(r.segpts.length/2).fill(V)}};Va.tryToCorrectInvalidPoints=function(t,e){var r=t._private.rscratch;if(r.edgeType==="bezier"){var n=e.srcPos,i=e.tgtPos,a=e.srcW,s=e.srcH,l=e.tgtW,u=e.tgtH,h=e.srcShape,f=e.tgtShape,d=e.srcCornerRadius,p=e.tgtCornerRadius,m=e.srcRs,g=e.tgtRs,y=!Ct(r.startX)||!Ct(r.startY),v=!Ct(r.arrowStartX)||!Ct(r.arrowStartY),x=!Ct(r.endX)||!Ct(r.endY),b=!Ct(r.arrowEndX)||!Ct(r.arrowEndY),w=3,C=this.getArrowWidth(t.pstyle("width").pfValue,t.pstyle("arrow-scale").value)*this.arrowShapeWidth,T=w*C,E=Gp({x:r.ctrlpts[0],y:r.ctrlpts[1]},{x:r.startX,y:r.startY}),A=ER.poolIndex()){var O=L;L=R,R=O}var M=_.srcPos=L.position(),B=_.tgtPos=R.position(),F=_.srcW=L.outerWidth(),P=_.srcH=L.outerHeight(),z=_.tgtW=R.outerWidth(),$=_.tgtH=R.outerHeight(),H=_.srcShape=r.nodeShapes[e.getNodeShape(L)],Q=_.tgtShape=r.nodeShapes[e.getNodeShape(R)],j=_.srcCornerRadius=L.pstyle("corner-radius").value==="auto"?"auto":L.pstyle("corner-radius").pfValue,ie=_.tgtCornerRadius=R.pstyle("corner-radius").value==="auto"?"auto":R.pstyle("corner-radius").pfValue,ne=_.tgtRs=R._private.rscratch,le=_.srcRs=L._private.rscratch;_.dirCounts={north:0,west:0,south:0,east:0,northwest:0,southwest:0,northeast:0,southeast:0};for(var he=0;he<_.eles.length;he++){var K=_.eles[he],X=K[0]._private.rscratch,te=K.pstyle("curve-style").value,J=te==="unbundled-bezier"||te.endsWith("segments")||te.endsWith("taxi"),se=!L.same(K.source());if(!_.calculatedIntersection&&L!==R&&(_.hasBezier||_.hasUnbundled)){_.calculatedIntersection=!0;var ue=H.intersectLine(M.x,M.y,F,P,B.x,B.y,0,j,le),Z=_.srcIntn=ue,Se=Q.intersectLine(B.x,B.y,z,$,M.x,M.y,0,ie,ne),ce=_.tgtIntn=Se,ae=_.intersectionPts={x1:ue[0],x2:Se[0],y1:ue[1],y2:Se[1]},Oe=_.posPts={x1:M.x,x2:B.x,y1:M.y,y2:B.y},ge=Se[1]-ue[1],ze=Se[0]-ue[0],He=Math.sqrt(ze*ze+ge*ge),$e=_.vector={x:ze,y:ge},Re=_.vectorNorm={x:$e.x/He,y:$e.y/He},Ie={x:-Re.y,y:Re.x};_.nodesOverlap=!Ct(He)||Q.checkPoint(ue[0],ue[1],0,z,$,B.x,B.y,ie,ne)||H.checkPoint(Se[0],Se[1],0,F,P,M.x,M.y,j,le),_.vectorNormInverse=Ie,I={nodesOverlap:_.nodesOverlap,dirCounts:_.dirCounts,calculatedIntersection:!0,hasBezier:_.hasBezier,hasUnbundled:_.hasUnbundled,eles:_.eles,srcPos:B,srcRs:ne,tgtPos:M,tgtRs:le,srcW:z,srcH:$,tgtW:F,tgtH:P,srcIntn:ce,tgtIntn:Z,srcShape:Q,tgtShape:H,posPts:{x1:Oe.x2,y1:Oe.y2,x2:Oe.x1,y2:Oe.y1},intersectionPts:{x1:ae.x2,y1:ae.y2,x2:ae.x1,y2:ae.y1},vector:{x:-$e.x,y:-$e.y},vectorNorm:{x:-Re.x,y:-Re.y},vectorNormInverse:{x:-Ie.x,y:-Ie.y}}}var be=se?I:_;X.nodesOverlap=be.nodesOverlap,X.srcIntn=be.srcIntn,X.tgtIntn=be.tgtIntn,X.isRound=te.startsWith("round"),i&&(L.isParent()||L.isChild()||R.isParent()||R.isChild())&&(L.parents().anySame(R)||R.parents().anySame(L)||L.same(R)&&L.isParent())?e.findCompoundLoopPoints(K,be,he,J):L===R?e.findLoopPoints(K,be,he,J):te.endsWith("segments")?e.findSegmentsPoints(K,be):te.endsWith("taxi")?e.findTaxiPoints(K,be):te==="straight"||!J&&_.eles.length%2===1&&he===Math.floor(_.eles.length/2)?e.findStraightEdgePoints(K):e.findBezierPoints(K,be,he,J,se),e.findEndpoints(K),e.tryToCorrectInvalidPoints(K,be),e.checkForInvalidEdgeWarning(K),e.storeAllpts(K),e.storeEdgeProjections(K),e.calculateArrowAngles(K),e.recalculateEdgeLabelProjections(K),e.calculateLabelAngles(K)}},"_loop"),T=0;T0){var J=a,se=Op(J,U1(r)),ue=Op(J,U1(te)),Z=se;if(ue2){var Se=Op(J,{x:te[2],y:te[3]});Se0){var re=s,oe=Op(re,U1(r)),V=Op(re,U1(de)),xe=oe;if(V2){var q=Op(re,{x:de[2],y:de[3]});q=g||A){v={cp:C,segment:E};break}}if(v)break}var S=v.cp,_=v.segment,I=(g-x)/_.length,D=_.t1-_.t0,k=m?_.t0+D*I:_.t1-D*I;k=Yb(0,k,1),e=W1(S.p0,S.p1,S.p2,k),p=DQe(S.p0,S.p1,S.p2,k);break}case"straight":case"segments":case"haystack":{for(var L=0,R,O,M,B,F=n.allpts.length,P=0;P+3=g));P+=2);var z=g-O,$=z/R;$=Yb(0,$,1),e=Iqe(M,B,$),p=pge(M,B);break}}s("labelX",d,e.x),s("labelY",d,e.y),s("labelAutoAngle",d,p)}},"calculateEndProjection");h("source"),h("target"),this.applyLabelDimensions(t)}};Kc.applyLabelDimensions=function(t){this.applyPrefixedLabelDimensions(t),t.isEdge()&&(this.applyPrefixedLabelDimensions(t,"source"),this.applyPrefixedLabelDimensions(t,"target"))};Kc.applyPrefixedLabelDimensions=function(t,e){var r=t._private,n=this.getLabelText(t,e),i=this.calculateLabelDimensions(t,n),a=t.pstyle("line-height").pfValue,s=t.pstyle("text-wrap").strValue,l=Gl(r.rscratch,"labelWrapCachedLines",e)||[],u=s!=="wrap"?1:Math.max(l.length,1),h=i.height/u,f=h*a,d=i.width,p=i.height+(u-1)*(a-1)*h;kf(r.rstyle,"labelWidth",e,d),kf(r.rscratch,"labelWidth",e,d),kf(r.rstyle,"labelHeight",e,p),kf(r.rscratch,"labelHeight",e,p),kf(r.rscratch,"labelLineHeight",e,f)};Kc.getLabelText=function(t,e){var r=t._private,n=e?e+"-":"",i=t.pstyle(n+"label").strValue,a=t.pstyle("text-transform").value,s=o(function(Q,j){return j?(kf(r.rscratch,Q,e,j),j):Gl(r.rscratch,Q,e)},"rscratch");if(!i)return"";a=="none"||(a=="uppercase"?i=i.toUpperCase():a=="lowercase"&&(i=i.toLowerCase()));var l=t.pstyle("text-wrap").value;if(l==="wrap"){var u=s("labelKey");if(u!=null&&s("labelWrapKey")===u)return s("labelWrapCachedText");for(var h="\u200B",f=i.split(` +`),d=t.pstyle("text-max-width").pfValue,p=t.pstyle("text-overflow-wrap").value,m=p==="anywhere",g=[],y=/[\s\u200b]+|$/g,v=0;vd){var T=x.matchAll(y),E="",A=0,S=mo(T),_;try{for(S.s();!(_=S.n()).done;){var I=_.value,D=I[0],k=x.substring(A,I.index);A=I.index+D.length;var L=E.length===0?k:E+k+D,R=this.calculateLabelDimensions(t,L),O=R.width;O<=d?E+=k+D:(E&&g.push(E),E=k+D)}}catch(H){S.e(H)}finally{S.f()}E.match(/^[\s\u200b]+$/)||g.push(E)}else g.push(x)}s("labelWrapCachedLines",g),i=s("labelWrapCachedText",g.join(` +`)),s("labelWrapKey",u)}else if(l==="ellipsis"){var M=t.pstyle("text-max-width").pfValue,B="",F="\u2026",P=!1;if(this.calculateLabelDimensions(t,i).widthM)break;B+=i[z],z===i.length-1&&(P=!0)}return P||(B+=F),B}return i};Kc.getLabelJustification=function(t){var e=t.pstyle("text-justification").strValue,r=t.pstyle("text-halign").strValue;if(e==="auto")if(t.isNode())switch(r){case"left":return"right";case"right":return"left";default:return"center"}else return"center";else return e};Kc.calculateLabelDimensions=function(t,e){var r=this,n=r.cy.window(),i=n.document,a=_f(e,t._private.labelDimsKey),s=r.labelDimCache||(r.labelDimCache=[]),l=s[a];if(l!=null)return l;var u=0,h=t.pstyle("font-style").strValue,f=t.pstyle("font-size").pfValue,d=t.pstyle("font-family").strValue,p=t.pstyle("font-weight").strValue,m=this.labelCalcCanvas,g=this.labelCalcCanvasContext;if(!m){m=this.labelCalcCanvas=i.createElement("canvas"),g=this.labelCalcCanvasContext=m.getContext("2d");var y=m.style;y.position="absolute",y.left="-9999px",y.top="-9999px",y.zIndex="-1",y.visibility="hidden",y.pointerEvents="none"}g.font="".concat(h," ").concat(p," ").concat(f,"px ").concat(d);for(var v=0,x=0,b=e.split(` +`),w=0;w1&&arguments[1]!==void 0?arguments[1]:!0;if(e.merge(s),l)for(var u=0;u=t.desktopTapThreshold2}var ot=a(W);at&&(t.hoverData.tapholdCancelled=!0);var Yt=o(function(){var Tt=t.hoverData.dragDelta=t.hoverData.dragDelta||[];Tt.length===0?(Tt.push(De[0]),Tt.push(De[1])):(Tt[0]+=De[0],Tt[1]+=De[1])},"updateDragDelta");re=!0,i(_e,["mousemove","vmousemove","tapdrag"],W,{x:q[0],y:q[1]});var bt=o(function(){t.data.bgActivePosistion=void 0,t.hoverData.selecting||oe.emit({originalEvent:W,type:"boxstart",position:{x:q[0],y:q[1]}}),Pe[4]=1,t.hoverData.selecting=!0,t.redrawHint("select",!0),t.redraw()},"goIntoBoxMode");if(t.hoverData.which===3){if(at){var Mt={originalEvent:W,type:"cxtdrag",position:{x:q[0],y:q[1]}};Ve?Ve.emit(Mt):oe.emit(Mt),t.hoverData.cxtDragged=!0,(!t.hoverData.cxtOver||_e!==t.hoverData.cxtOver)&&(t.hoverData.cxtOver&&t.hoverData.cxtOver.emit({originalEvent:W,type:"cxtdragout",position:{x:q[0],y:q[1]}}),t.hoverData.cxtOver=_e,_e&&_e.emit({originalEvent:W,type:"cxtdragover",position:{x:q[0],y:q[1]}}))}}else if(t.hoverData.dragging){if(re=!0,oe.panningEnabled()&&oe.userPanningEnabled()){var xt;if(t.hoverData.justStartedPan){var ut=t.hoverData.mdownPos;xt={x:(q[0]-ut[0])*V,y:(q[1]-ut[1])*V},t.hoverData.justStartedPan=!1}else xt={x:De[0]*V,y:De[1]*V};oe.panBy(xt),oe.emit("dragpan"),t.hoverData.dragged=!0}q=t.projectIntoViewport(W.clientX,W.clientY)}else if(Pe[4]==1&&(Ve==null||Ve.pannable())){if(at){if(!t.hoverData.dragging&&oe.boxSelectionEnabled()&&(ot||!oe.panningEnabled()||!oe.userPanningEnabled()))bt();else if(!t.hoverData.selecting&&oe.panningEnabled()&&oe.userPanningEnabled()){var Et=s(Ve,t.hoverData.downs);Et&&(t.hoverData.dragging=!0,t.hoverData.justStartedPan=!0,Pe[4]=0,t.data.bgActivePosistion=U1(pe),t.redrawHint("select",!0),t.redraw())}Ve&&Ve.pannable()&&Ve.active()&&Ve.unactivate()}}else{if(Ve&&Ve.pannable()&&Ve.active()&&Ve.unactivate(),(!Ve||!Ve.grabbed())&&_e!=we&&(we&&i(we,["mouseout","tapdragout"],W,{x:q[0],y:q[1]}),_e&&i(_e,["mouseover","tapdragover"],W,{x:q[0],y:q[1]}),t.hoverData.last=_e),Ve)if(at){if(oe.boxSelectionEnabled()&&ot)Ve&&Ve.grabbed()&&(x(qe),Ve.emit("freeon"),qe.emit("free"),t.dragData.didDrag&&(Ve.emit("dragfreeon"),qe.emit("dragfree"))),bt();else if(Ve&&Ve.grabbed()&&t.nodeIsDraggable(Ve)){var ft=!t.dragData.didDrag;ft&&t.redrawHint("eles",!0),t.dragData.didDrag=!0,t.hoverData.draggingEles||y(qe,{inDragLayer:!0});var yt={x:0,y:0};if(Ct(De[0])&&Ct(De[1])&&(yt.x+=De[0],yt.y+=De[1],ft)){var nt=t.hoverData.dragDelta;nt&&Ct(nt[0])&&Ct(nt[1])&&(yt.x+=nt[0],yt.y+=nt[1])}t.hoverData.draggingEles=!0,qe.silentShift(yt).emit("position drag"),t.redrawHint("drag",!0),t.redraw()}}else Yt();re=!0}if(Pe[2]=q[0],Pe[3]=q[1],re)return W.stopPropagation&&W.stopPropagation(),W.preventDefault&&W.preventDefault(),!1}},"mousemoveHandler"),!1);var k,L,R;t.registerBinding(e,"mouseup",o(function(W){if(!(t.hoverData.which===1&&W.which!==1&&t.hoverData.capture)){var de=t.hoverData.capture;if(de){t.hoverData.capture=!1;var re=t.cy,oe=t.projectIntoViewport(W.clientX,W.clientY),V=t.selection,xe=t.findNearestElement(oe[0],oe[1],!0,!1),q=t.dragData.possibleDragElements,pe=t.hoverData.down,ve=a(W);if(t.data.bgActivePosistion&&(t.redrawHint("select",!0),t.redraw()),t.hoverData.tapholdCancelled=!0,t.data.bgActivePosistion=void 0,pe&&pe.unactivate(),t.hoverData.which===3){var Pe={originalEvent:W,type:"cxttapend",position:{x:oe[0],y:oe[1]}};if(pe?pe.emit(Pe):re.emit(Pe),!t.hoverData.cxtDragged){var _e={originalEvent:W,type:"cxttap",position:{x:oe[0],y:oe[1]}};pe?pe.emit(_e):re.emit(_e)}t.hoverData.cxtDragged=!1,t.hoverData.which=null}else if(t.hoverData.which===1){if(i(xe,["mouseup","tapend","vmouseup"],W,{x:oe[0],y:oe[1]}),!t.dragData.didDrag&&!t.hoverData.dragged&&!t.hoverData.selecting&&!t.hoverData.isOverThresholdDrag&&(i(pe,["click","tap","vclick"],W,{x:oe[0],y:oe[1]}),L=!1,W.timeStamp-R<=re.multiClickDebounceTime()?(k&&clearTimeout(k),L=!0,R=null,i(pe,["dblclick","dbltap","vdblclick"],W,{x:oe[0],y:oe[1]})):(k=setTimeout(function(){L||i(pe,["oneclick","onetap","voneclick"],W,{x:oe[0],y:oe[1]})},re.multiClickDebounceTime()),R=W.timeStamp)),pe==null&&!t.dragData.didDrag&&!t.hoverData.selecting&&!t.hoverData.dragged&&!a(W)&&(re.$(r).unselect(["tapunselect"]),q.length>0&&t.redrawHint("eles",!0),t.dragData.possibleDragElements=q=re.collection()),xe==pe&&!t.dragData.didDrag&&!t.hoverData.selecting&&xe!=null&&xe._private.selectable&&(t.hoverData.dragging||(re.selectionType()==="additive"||ve?xe.selected()?xe.unselect(["tapunselect"]):xe.select(["tapselect"]):ve||(re.$(r).unmerge(xe).unselect(["tapunselect"]),xe.select(["tapselect"]))),t.redrawHint("eles",!0)),t.hoverData.selecting){var we=re.collection(t.getAllInBox(V[0],V[1],V[2],V[3]));t.redrawHint("select",!0),we.length>0&&t.redrawHint("eles",!0),re.emit({type:"boxend",originalEvent:W,position:{x:oe[0],y:oe[1]}});var Ve=o(function(at){return at.selectable()&&!at.selected()},"eleWouldBeSelected");re.selectionType()==="additive"||ve||re.$(r).unmerge(we).unselect(),we.emit("box").stdFilter(Ve).select().emit("boxselect"),t.redraw()}if(t.hoverData.dragging&&(t.hoverData.dragging=!1,t.redrawHint("select",!0),t.redrawHint("eles",!0),t.redraw()),!V[4]){t.redrawHint("drag",!0),t.redrawHint("eles",!0);var De=pe&&pe.grabbed();x(q),De&&(pe.emit("freeon"),q.emit("free"),t.dragData.didDrag&&(pe.emit("dragfreeon"),q.emit("dragfree")))}}V[4]=0,t.hoverData.down=null,t.hoverData.cxtStarted=!1,t.hoverData.draggingEles=!1,t.hoverData.selecting=!1,t.hoverData.isOverThresholdDrag=!1,t.dragData.didDrag=!1,t.hoverData.dragged=!1,t.hoverData.dragDelta=[],t.hoverData.mdownPos=null,t.hoverData.mdownGPos=null,t.hoverData.which=null}}},"mouseupHandler"),!1);var O=o(function(W){if(!t.scrollingPage){var de=t.cy,re=de.zoom(),oe=de.pan(),V=t.projectIntoViewport(W.clientX,W.clientY),xe=[V[0]*re+oe.x,V[1]*re+oe.y];if(t.hoverData.draggingEles||t.hoverData.dragging||t.hoverData.cxtStarted||_()){W.preventDefault();return}if(de.panningEnabled()&&de.userPanningEnabled()&&de.zoomingEnabled()&&de.userZoomingEnabled()){W.preventDefault(),t.data.wheelZooming=!0,clearTimeout(t.data.wheelTimeout),t.data.wheelTimeout=setTimeout(function(){t.data.wheelZooming=!1,t.redrawHint("eles",!0),t.redraw()},150);var q;W.deltaY!=null?q=W.deltaY/-250:W.wheelDeltaY!=null?q=W.wheelDeltaY/1e3:q=W.wheelDelta/1e3,q=q*t.wheelSensitivity;var pe=W.deltaMode===1;pe&&(q*=33);var ve=de.zoom()*Math.pow(10,q);W.type==="gesturechange"&&(ve=t.gestureStartZoom*W.scale),de.zoom({level:ve,renderedPosition:{x:xe[0],y:xe[1]}}),de.emit(W.type==="gesturechange"?"pinchzoom":"scrollzoom")}}},"wheelHandler");t.registerBinding(t.container,"wheel",O,!0),t.registerBinding(e,"scroll",o(function(W){t.scrollingPage=!0,clearTimeout(t.scrollingPageTimeout),t.scrollingPageTimeout=setTimeout(function(){t.scrollingPage=!1},250)},"scrollHandler"),!0),t.registerBinding(t.container,"gesturestart",o(function(W){t.gestureStartZoom=t.cy.zoom(),t.hasTouchStarted||W.preventDefault()},"gestureStartHandler"),!0),t.registerBinding(t.container,"gesturechange",function(be){t.hasTouchStarted||O(be)},!0),t.registerBinding(t.container,"mouseout",o(function(W){var de=t.projectIntoViewport(W.clientX,W.clientY);t.cy.emit({originalEvent:W,type:"mouseout",position:{x:de[0],y:de[1]}})},"mouseOutHandler"),!1),t.registerBinding(t.container,"mouseover",o(function(W){var de=t.projectIntoViewport(W.clientX,W.clientY);t.cy.emit({originalEvent:W,type:"mouseover",position:{x:de[0],y:de[1]}})},"mouseOverHandler"),!1);var M,B,F,P,z,$,H,Q,j,ie,ne,le,he,K=o(function(W,de,re,oe){return Math.sqrt((re-W)*(re-W)+(oe-de)*(oe-de))},"distance"),X=o(function(W,de,re,oe){return(re-W)*(re-W)+(oe-de)*(oe-de)},"distanceSq"),te;t.registerBinding(t.container,"touchstart",te=o(function(W){if(t.hasTouchStarted=!0,!!I(W)){w(),t.touchData.capture=!0,t.data.bgActivePosistion=void 0;var de=t.cy,re=t.touchData.now,oe=t.touchData.earlier;if(W.touches[0]){var V=t.projectIntoViewport(W.touches[0].clientX,W.touches[0].clientY);re[0]=V[0],re[1]=V[1]}if(W.touches[1]){var V=t.projectIntoViewport(W.touches[1].clientX,W.touches[1].clientY);re[2]=V[0],re[3]=V[1]}if(W.touches[2]){var V=t.projectIntoViewport(W.touches[2].clientX,W.touches[2].clientY);re[4]=V[0],re[5]=V[1]}if(W.touches[1]){t.touchData.singleTouchMoved=!0,x(t.dragData.touchDragEles);var xe=t.findContainerClientCoords();j=xe[0],ie=xe[1],ne=xe[2],le=xe[3],M=W.touches[0].clientX-j,B=W.touches[0].clientY-ie,F=W.touches[1].clientX-j,P=W.touches[1].clientY-ie,he=0<=M&&M<=ne&&0<=F&&F<=ne&&0<=B&&B<=le&&0<=P&&P<=le;var q=de.pan(),pe=de.zoom();z=K(M,B,F,P),$=X(M,B,F,P),H=[(M+F)/2,(B+P)/2],Q=[(H[0]-q.x)/pe,(H[1]-q.y)/pe];var ve=200,Pe=ve*ve;if($=1){for(var st=t.touchData.startPosition=[null,null,null,null,null,null],Ue=0;Ue=t.touchTapThreshold2}if(de&&t.touchData.cxt){W.preventDefault();var st=W.touches[0].clientX-j,Ue=W.touches[0].clientY-ie,ct=W.touches[1].clientX-j,We=W.touches[1].clientY-ie,ot=X(st,Ue,ct,We),Yt=ot/$,bt=150,Mt=bt*bt,xt=1.5,ut=xt*xt;if(Yt>=ut||ot>=Mt){t.touchData.cxt=!1,t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var Et={originalEvent:W,type:"cxttapend",position:{x:V[0],y:V[1]}};t.touchData.start?(t.touchData.start.unactivate().emit(Et),t.touchData.start=null):oe.emit(Et)}}if(de&&t.touchData.cxt){var Et={originalEvent:W,type:"cxtdrag",position:{x:V[0],y:V[1]}};t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.touchData.start?t.touchData.start.emit(Et):oe.emit(Et),t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxtDragged=!0;var ft=t.findNearestElement(V[0],V[1],!0,!0);(!t.touchData.cxtOver||ft!==t.touchData.cxtOver)&&(t.touchData.cxtOver&&t.touchData.cxtOver.emit({originalEvent:W,type:"cxtdragout",position:{x:V[0],y:V[1]}}),t.touchData.cxtOver=ft,ft&&ft.emit({originalEvent:W,type:"cxtdragover",position:{x:V[0],y:V[1]}}))}else if(de&&W.touches[2]&&oe.boxSelectionEnabled())W.preventDefault(),t.data.bgActivePosistion=void 0,this.lastThreeTouch=+new Date,t.touchData.selecting||oe.emit({originalEvent:W,type:"boxstart",position:{x:V[0],y:V[1]}}),t.touchData.selecting=!0,t.touchData.didSelect=!0,re[4]=1,!re||re.length===0||re[0]===void 0?(re[0]=(V[0]+V[2]+V[4])/3,re[1]=(V[1]+V[3]+V[5])/3,re[2]=(V[0]+V[2]+V[4])/3+1,re[3]=(V[1]+V[3]+V[5])/3+1):(re[2]=(V[0]+V[2]+V[4])/3,re[3]=(V[1]+V[3]+V[5])/3),t.redrawHint("select",!0),t.redraw();else if(de&&W.touches[1]&&!t.touchData.didSelect&&oe.zoomingEnabled()&&oe.panningEnabled()&&oe.userZoomingEnabled()&&oe.userPanningEnabled()){W.preventDefault(),t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var yt=t.dragData.touchDragEles;if(yt){t.redrawHint("drag",!0);for(var nt=0;nt0&&!t.hoverData.draggingEles&&!t.swipePanning&&t.data.bgActivePosistion!=null&&(t.data.bgActivePosistion=void 0,t.redrawHint("select",!0),t.redraw())}},"touchmoveHandler"),!1);var se;t.registerBinding(e,"touchcancel",se=o(function(W){var de=t.touchData.start;t.touchData.capture=!1,de&&de.unactivate()},"touchcancelHandler"));var ue,Z,Se,ce;if(t.registerBinding(e,"touchend",ue=o(function(W){var de=t.touchData.start,re=t.touchData.capture;if(re)W.touches.length===0&&(t.touchData.capture=!1),W.preventDefault();else return;var oe=t.selection;t.swipePanning=!1,t.hoverData.draggingEles=!1;var V=t.cy,xe=V.zoom(),q=t.touchData.now,pe=t.touchData.earlier;if(W.touches[0]){var ve=t.projectIntoViewport(W.touches[0].clientX,W.touches[0].clientY);q[0]=ve[0],q[1]=ve[1]}if(W.touches[1]){var ve=t.projectIntoViewport(W.touches[1].clientX,W.touches[1].clientY);q[2]=ve[0],q[3]=ve[1]}if(W.touches[2]){var ve=t.projectIntoViewport(W.touches[2].clientX,W.touches[2].clientY);q[4]=ve[0],q[5]=ve[1]}de&&de.unactivate();var Pe;if(t.touchData.cxt){if(Pe={originalEvent:W,type:"cxttapend",position:{x:q[0],y:q[1]}},de?de.emit(Pe):V.emit(Pe),!t.touchData.cxtDragged){var _e={originalEvent:W,type:"cxttap",position:{x:q[0],y:q[1]}};de?de.emit(_e):V.emit(_e)}t.touchData.start&&(t.touchData.start._private.grabbed=!1),t.touchData.cxt=!1,t.touchData.start=null,t.redraw();return}if(!W.touches[2]&&V.boxSelectionEnabled()&&t.touchData.selecting){t.touchData.selecting=!1;var we=V.collection(t.getAllInBox(oe[0],oe[1],oe[2],oe[3]));oe[0]=void 0,oe[1]=void 0,oe[2]=void 0,oe[3]=void 0,oe[4]=0,t.redrawHint("select",!0),V.emit({type:"boxend",originalEvent:W,position:{x:q[0],y:q[1]}});var Ve=o(function(Mt){return Mt.selectable()&&!Mt.selected()},"eleWouldBeSelected");we.emit("box").stdFilter(Ve).select().emit("boxselect"),we.nonempty()&&t.redrawHint("eles",!0),t.redraw()}if(de?.unactivate(),W.touches[2])t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);else if(!W.touches[1]){if(!W.touches[0]){if(!W.touches[0]){t.data.bgActivePosistion=void 0,t.redrawHint("select",!0);var De=t.dragData.touchDragEles;if(de!=null){var qe=de._private.grabbed;x(De),t.redrawHint("drag",!0),t.redrawHint("eles",!0),qe&&(de.emit("freeon"),De.emit("free"),t.dragData.didDrag&&(de.emit("dragfreeon"),De.emit("dragfree"))),i(de,["touchend","tapend","vmouseup","tapdragout"],W,{x:q[0],y:q[1]}),de.unactivate(),t.touchData.start=null}else{var at=t.findNearestElement(q[0],q[1],!0,!0);i(at,["touchend","tapend","vmouseup","tapdragout"],W,{x:q[0],y:q[1]})}var Rt=t.touchData.startPosition[0]-q[0],st=Rt*Rt,Ue=t.touchData.startPosition[1]-q[1],ct=Ue*Ue,We=st+ct,ot=We*xe*xe;t.touchData.singleTouchMoved||(de||V.$(":selected").unselect(["tapunselect"]),i(de,["tap","vclick"],W,{x:q[0],y:q[1]}),Z=!1,W.timeStamp-ce<=V.multiClickDebounceTime()?(Se&&clearTimeout(Se),Z=!0,ce=null,i(de,["dbltap","vdblclick"],W,{x:q[0],y:q[1]})):(Se=setTimeout(function(){Z||i(de,["onetap","voneclick"],W,{x:q[0],y:q[1]})},V.multiClickDebounceTime()),ce=W.timeStamp)),de!=null&&!t.dragData.didDrag&&de._private.selectable&&ot"u"){var ae=[],Oe=o(function(W){return{clientX:W.clientX,clientY:W.clientY,force:1,identifier:W.pointerId,pageX:W.pageX,pageY:W.pageY,radiusX:W.width/2,radiusY:W.height/2,screenX:W.screenX,screenY:W.screenY,target:W.target}},"makeTouch"),ge=o(function(W){return{event:W,touch:Oe(W)}},"makePointer"),ze=o(function(W){ae.push(ge(W))},"addPointer"),He=o(function(W){for(var de=0;de0)return H[0]}return null},"getCurveT"),g=Object.keys(p),y=0;y0?m:vme(a,s,e,r,n,i,l,u)},"intersectLine"),checkPoint:o(function(e,r,n,i,a,s,l,u){u=u==="auto"?Vp(i,a):u;var h=2*u;if(Zu(e,r,this.points,s,l,i,a-h,[0,-1],n)||Zu(e,r,this.points,s,l,i-h,a,[0,-1],n))return!0;var f=i/2+2*n,d=a/2+2*n,p=[s-f,l-d,s-f,l,s+f,l,s+f,l-d];return!!(Us(e,r,p)||$p(e,r,h,h,s+i/2-u,l+a/2-u,n)||$p(e,r,h,h,s-i/2+u,l+a/2-u,n))},"checkPoint")}};eh.registerNodeShapes=function(){var t=this.nodeShapes={},e=this;this.generateEllipse(),this.generatePolygon("triangle",gs(3,0)),this.generateRoundPolygon("round-triangle",gs(3,0)),this.generatePolygon("rectangle",gs(4,0)),t.square=t.rectangle,this.generateRoundRectangle(),this.generateCutRectangle(),this.generateBarrel(),this.generateBottomRoundrectangle();{var r=[0,1,1,0,0,-1,-1,0];this.generatePolygon("diamond",r),this.generateRoundPolygon("round-diamond",r)}this.generatePolygon("pentagon",gs(5,0)),this.generateRoundPolygon("round-pentagon",gs(5,0)),this.generatePolygon("hexagon",gs(6,0)),this.generateRoundPolygon("round-hexagon",gs(6,0)),this.generatePolygon("heptagon",gs(7,0)),this.generateRoundPolygon("round-heptagon",gs(7,0)),this.generatePolygon("octagon",gs(8,0)),this.generateRoundPolygon("round-octagon",gs(8,0));var n=new Array(20);{var i=PP(5,0),a=PP(5,Math.PI/5),s=.5*(3-Math.sqrt(5));s*=1.57;for(var l=0;l=e.deqFastCost*C)break}else if(h){if(b>=e.deqCost*m||b>=e.deqAvgCost*p)break}else if(w>=e.deqNoDrawCost*DP)break;var T=e.deq(n,v,y);if(T.length>0)for(var E=0;E0&&(e.onDeqd(n,g),!h&&e.shouldRedraw(n,g,v,y)&&a())},"dequeue"),l=e.priority||rB;i.beforeRender(s,l(n))}},"setupDequeueingImpl")},"setupDequeueing")},RQe=function(){function t(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:bS;Mf(this,t),this.idsByKey=new Xc,this.keyForId=new Xc,this.cachesByLvl=new Xc,this.lvls=[],this.getKey=e,this.doesEleInvalidateKey=r}return o(t,"ElementTextureCacheLookup"),If(t,[{key:"getIdsFor",value:o(function(r){r==null&&ai("Can not get id list for null key");var n=this.idsByKey,i=this.idsByKey.get(r);return i||(i=new J1,n.set(r,i)),i},"getIdsFor")},{key:"addIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).add(n)},"addIdForKey")},{key:"deleteIdForKey",value:o(function(r,n){r!=null&&this.getIdsFor(r).delete(n)},"deleteIdForKey")},{key:"getNumberOfIdsForKey",value:o(function(r){return r==null?0:this.getIdsFor(r).size},"getNumberOfIdsForKey")},{key:"updateKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);this.deleteIdForKey(i,n),this.addIdForKey(a,n),this.keyForId.set(n,a)},"updateKeyMappingFor")},{key:"deleteKeyMappingFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteIdForKey(i,n),this.keyForId.delete(n)},"deleteKeyMappingFor")},{key:"keyHasChangedFor",value:o(function(r){var n=r.id(),i=this.keyForId.get(n),a=this.getKey(r);return i!==a},"keyHasChangedFor")},{key:"isInvalid",value:o(function(r){return this.keyHasChangedFor(r)||this.doesEleInvalidateKey(r)},"isInvalid")},{key:"getCachesAt",value:o(function(r){var n=this.cachesByLvl,i=this.lvls,a=n.get(r);return a||(a=new Xc,n.set(r,a),i.push(r)),a},"getCachesAt")},{key:"getCache",value:o(function(r,n){return this.getCachesAt(n).get(r)},"getCache")},{key:"get",value:o(function(r,n){var i=this.getKey(r),a=this.getCache(i,n);return a!=null&&this.updateKeyMappingFor(r),a},"get")},{key:"getForCachedKey",value:o(function(r,n){var i=this.keyForId.get(r.id()),a=this.getCache(i,n);return a},"getForCachedKey")},{key:"hasCache",value:o(function(r,n){return this.getCachesAt(n).has(r)},"hasCache")},{key:"has",value:o(function(r,n){var i=this.getKey(r);return this.hasCache(i,n)},"has")},{key:"setCache",value:o(function(r,n,i){i.key=r,this.getCachesAt(n).set(r,i)},"setCache")},{key:"set",value:o(function(r,n,i){var a=this.getKey(r);this.setCache(a,n,i),this.updateKeyMappingFor(r)},"set")},{key:"deleteCache",value:o(function(r,n){this.getCachesAt(n).delete(r)},"deleteCache")},{key:"delete",value:o(function(r,n){var i=this.getKey(r);this.deleteCache(i,n)},"_delete")},{key:"invalidateKey",value:o(function(r){var n=this;this.lvls.forEach(function(i){return n.deleteCache(r,i)})},"invalidateKey")},{key:"invalidate",value:o(function(r){var n=r.id(),i=this.keyForId.get(n);this.deleteKeyMappingFor(r);var a=this.doesEleInvalidateKey(r);return a&&this.invalidateKey(i),a||this.getNumberOfIdsForKey(i)===0},"invalidate")}]),t}(),I0e=25,sS=50,yS=-4,XP=3,bge=7.99,NQe=8,MQe=1024,IQe=1024,OQe=1024,PQe=.2,BQe=.8,FQe=10,$Qe=.15,zQe=.1,GQe=.9,VQe=.9,UQe=100,HQe=1,H1={dequeue:"dequeue",downscale:"downscale",highQuality:"highQuality"},WQe=la({getKey:null,doesEleInvalidateKey:bS,drawElement:null,getBoundingBox:null,getRotationPoint:null,getRotationOffset:null,isVisible:ume,allowEdgeTxrCaching:!0,allowParentTxrCaching:!0}),Fb=o(function(e,r){var n=this;n.renderer=e,n.onDequeues=[];var i=WQe(r);rr(n,i),n.lookup=new RQe(i.getKey,i.doesEleInvalidateKey),n.setupDequeueing()},"ElementTextureCache"),qi=Fb.prototype;qi.reasons=H1;qi.getTextureQueue=function(t){var e=this;return e.eleImgCaches=e.eleImgCaches||{},e.eleImgCaches[t]=e.eleImgCaches[t]||[]};qi.getRetiredTextureQueue=function(t){var e=this,r=e.eleImgCaches.retired=e.eleImgCaches.retired||{},n=r[t]=r[t]||[];return n};qi.getElementQueue=function(){var t=this,e=t.eleCacheQueue=t.eleCacheQueue||new i4(function(r,n){return n.reqs-r.reqs});return e};qi.getElementKeyToQueue=function(){var t=this,e=t.eleKeyToCacheQueue=t.eleKeyToCacheQueue||{};return e};qi.getElement=function(t,e,r,n,i){var a=this,s=this.renderer,l=s.cy.zoom(),u=this.lookup;if(!e||e.w===0||e.h===0||isNaN(e.w)||isNaN(e.h)||!t.visible()||t.removed()||!a.allowEdgeTxrCaching&&t.isEdge()||!a.allowParentTxrCaching&&t.isParent())return null;if(n==null&&(n=Math.ceil(iB(l*r))),n=bge||n>XP)return null;var h=Math.pow(2,n),f=e.h*h,d=e.w*h,p=s.eleTextBiggerThanMin(t,h);if(!this.isVisible(t,p))return null;var m=u.get(t,n);if(m&&m.invalidated&&(m.invalidated=!1,m.texture.invalidatedWidth-=m.width),m)return m;var g;if(f<=I0e?g=I0e:f<=sS?g=sS:g=Math.ceil(f/sS)*sS,f>OQe||d>IQe)return null;var y=a.getTextureQueue(g),v=y[y.length-2],x=o(function(){return a.recycleTexture(g,d)||a.addTexture(g,d)},"addNewTxr");v||(v=y[y.length-1]),v||(v=x()),v.width-v.usedWidthn;D--)_=a.getElement(t,e,r,D,H1.downscale);I()}else return a.queueElement(t,E.level-1),E;else{var k;if(!w&&!C&&!T)for(var L=n-1;L>=yS;L--){var R=u.get(t,L);if(R){k=R;break}}if(b(k))return a.queueElement(t,n),k;v.context.translate(v.usedWidth,0),v.context.scale(h,h),this.drawElement(v.context,t,e,p,!1),v.context.scale(1/h,1/h),v.context.translate(-v.usedWidth,0)}return m={x:v.usedWidth,texture:v,level:n,scale:h,width:d,height:f,scaledLabelShown:p},v.usedWidth+=Math.ceil(d+NQe),v.eleCaches.push(m),u.set(t,n,m),a.checkTextureFullness(v),m};qi.invalidateElements=function(t){for(var e=0;e=PQe*t.width&&this.retireTexture(t)};qi.checkTextureFullness=function(t){var e=this,r=e.getTextureQueue(t.height);t.usedWidth/t.width>BQe&&t.fullnessChecks>=FQe?Df(r,t):t.fullnessChecks++};qi.retireTexture=function(t){var e=this,r=t.height,n=e.getTextureQueue(r),i=this.lookup;Df(n,t),t.retired=!0;for(var a=t.eleCaches,s=0;s=e)return s.retired=!1,s.usedWidth=0,s.invalidatedWidth=0,s.fullnessChecks=0,nB(s.eleCaches),s.context.setTransform(1,0,0,1,0,0),s.context.clearRect(0,0,s.width,s.height),Df(i,s),n.push(s),s}};qi.queueElement=function(t,e){var r=this,n=r.getElementQueue(),i=r.getElementKeyToQueue(),a=this.getKey(t),s=i[a];if(s)s.level=Math.max(s.level,e),s.eles.merge(t),s.reqs++,n.updateItem(s);else{var l={eles:t.spawn().merge(t),level:e,reqs:1,key:a};n.push(l),i[a]=l}};qi.dequeue=function(t){for(var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=[],a=e.lookup,s=0;s0;s++){var l=r.pop(),u=l.key,h=l.eles[0],f=a.hasCache(h,l.level);if(n[u]=null,f)continue;i.push(l);var d=e.getBoundingBox(h);e.getElement(h,d,t,l.level,H1.dequeue)}return i};qi.removeFromQueue=function(t){var e=this,r=e.getElementQueue(),n=e.getElementKeyToQueue(),i=this.getKey(t),a=n[i];a!=null&&(a.eles.length===1?(a.reqs=tB,r.updateItem(a),r.pop(),n[i]=null):a.eles.unmerge(t))};qi.onDequeue=function(t){this.onDequeues.push(t)};qi.offDequeue=function(t){Df(this.onDequeues,t)};qi.setupDequeueing=xge.setupDequeueing({deqRedrawThreshold:UQe,deqCost:$Qe,deqAvgCost:zQe,deqNoDrawCost:GQe,deqFastCost:VQe,deq:o(function(e,r,n){return e.dequeue(r,n)},"deq"),onDeqd:o(function(e,r){for(var n=0;n=YQe||r>_S)return null}n.validateLayersElesOrdering(r,t);var u=n.layersByLevel,h=Math.pow(2,r),f=u[r]=u[r]||[],d,p=n.levelIsComplete(r,t),m,g=o(function(){var I=o(function(O){if(n.validateLayersElesOrdering(O,t),n.levelIsComplete(O,t))return m=u[O],!0},"canUseAsTmpLvl"),D=o(function(O){if(!m)for(var M=r+O;zb<=M&&M<=_S&&!I(M);M+=O);},"checkLvls");D(1),D(-1);for(var k=f.length-1;k>=0;k--){var L=f[k];L.invalid&&Df(f,L)}},"checkTempLevels");if(!p)g();else return f;var y=o(function(){if(!d){d=Hs();for(var I=0;IP0e||L>P0e)return null;var R=k*L;if(R>tZe)return null;var O=n.makeLayer(d,r);if(D!=null){var M=f.indexOf(D)+1;f.splice(M,0,O)}else(I.insert===void 0||I.insert)&&f.unshift(O);return O},"makeLayer");if(n.skipping&&!l)return null;for(var x=null,b=t.length/qQe,w=!l,C=0;C=b||!yme(x.bb,T.boundingBox()))&&(x=v({insert:!0,after:x}),!x))return null;m||w?n.queueLayer(x,T):n.drawEleInLayer(x,T,r,e),x.eles.push(T),A[r]=x}return m||(w?null:f)};Ea.getEleLevelForLayerLevel=function(t,e){return t};Ea.drawEleInLayer=function(t,e,r,n){var i=this,a=this.renderer,s=t.context,l=e.boundingBox();l.w===0||l.h===0||!e.visible()||(r=i.getEleLevelForLayerLevel(r,n),a.setImgSmoothing(s,!1),a.drawCachedElement(s,e,null,null,r,rZe),a.setImgSmoothing(s,!0))};Ea.levelIsComplete=function(t,e){var r=this,n=r.layersByLevel[t];if(!n||n.length===0)return!1;for(var i=0,a=0;a0||s.invalid)return!1;i+=s.eles.length}return i===e.length};Ea.validateLayersElesOrdering=function(t,e){var r=this.layersByLevel[t];if(r)for(var n=0;n0){e=!0;break}}return e};Ea.invalidateElements=function(t){var e=this;t.length!==0&&(e.lastInvalidationTime=Qu(),!(t.length===0||!e.haveLayers())&&e.updateElementsInLayers(t,o(function(n,i,a){e.invalidateLayer(n)},"invalAssocLayers")))};Ea.invalidateLayer=function(t){if(this.lastInvalidationTime=Qu(),!t.invalid){var e=t.level,r=t.eles,n=this.layersByLevel[e];Df(n,t),t.elesQueue=[],t.invalid=!0,t.replacement&&(t.replacement.invalid=!0);for(var i=0;i3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l=e._private.rscratch;if(!(a&&!e.visible())&&!(l.badLine||l.allpts==null||isNaN(l.allpts[0]))){var u;r&&(u=r,t.translate(-u.x1,-u.y1));var h=a?e.pstyle("opacity").value:1,f=a?e.pstyle("line-opacity").value:1,d=e.pstyle("curve-style").value,p=e.pstyle("line-style").value,m=e.pstyle("width").pfValue,g=e.pstyle("line-cap").value,y=e.pstyle("line-outline-width").value,v=e.pstyle("line-outline-color").value,x=h*f,b=h*f,w=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;d==="straight-triangle"?(s.eleStrokeStyle(t,e,O),s.drawEdgeTrianglePath(e,t,l.allpts)):(t.lineWidth=m,t.lineCap=g,s.eleStrokeStyle(t,e,O),s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLine"),C=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:x;if(t.lineWidth=m+y,t.lineCap=g,y>0)s.colorStrokeStyle(t,v[0],v[1],v[2],O);else{t.lineCap="butt";return}d==="straight-triangle"?s.drawEdgeTrianglePath(e,t,l.allpts):(s.drawEdgePath(e,t,l.allpts,p),t.lineCap="butt")},"drawLineOutline"),T=o(function(){i&&s.drawEdgeOverlay(t,e)},"drawOverlay"),E=o(function(){i&&s.drawEdgeUnderlay(t,e)},"drawUnderlay"),A=o(function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:b;s.drawArrowheads(t,e,O)},"drawArrows"),S=o(function(){s.drawElementText(t,e,null,n)},"drawText");t.lineJoin="round";var _=e.pstyle("ghost").value==="yes";if(_){var I=e.pstyle("ghost-offset-x").pfValue,D=e.pstyle("ghost-offset-y").pfValue,k=e.pstyle("ghost-opacity").value,L=x*k;t.translate(I,D),w(L),A(L),t.translate(-I,-D)}else C();E(),w(),A(),T(),S(),r&&t.translate(u.x1,u.y1)}};kge=o(function(e){if(!["overlay","underlay"].includes(e))throw new Error("Invalid state");return function(r,n){if(n.visible()){var i=n.pstyle("".concat(e,"-opacity")).value;if(i!==0){var a=this,s=a.usePaths(),l=n._private.rscratch,u=n.pstyle("".concat(e,"-padding")).pfValue,h=2*u,f=n.pstyle("".concat(e,"-color")).value;r.lineWidth=h,l.edgeType==="self"&&!s?r.lineCap="butt":r.lineCap="round",a.colorStrokeStyle(r,f[0],f[1],f[2],i),a.drawEdgePath(n,r,l.allpts,"solid")}}}},"drawEdgeOverlayUnderlay");th.drawEdgeOverlay=kge("overlay");th.drawEdgeUnderlay=kge("underlay");th.drawEdgePath=function(t,e,r,n){var i=t._private.rscratch,a=e,s,l=!1,u=this.usePaths(),h=t.pstyle("line-dash-pattern").pfValue,f=t.pstyle("line-dash-offset").pfValue;if(u){var d=r.join("$"),p=i.pathCacheKey&&i.pathCacheKey===d;p?(s=e=i.pathCache,l=!0):(s=e=new Path2D,i.pathCacheKey=d,i.pathCache=s)}if(a.setLineDash)switch(n){case"dotted":a.setLineDash([1,1]);break;case"dashed":a.setLineDash(h),a.lineDashOffset=f;break;case"solid":a.setLineDash([]);break}if(!l&&!i.badLine)switch(e.beginPath&&e.beginPath(),e.moveTo(r[0],r[1]),i.edgeType){case"bezier":case"self":case"compound":case"multibezier":for(var m=2;m+35&&arguments[5]!==void 0?arguments[5]:!0,s=this;if(n==null){if(a&&!s.eleTextBiggerThanMin(e))return}else if(n===!1)return;if(e.isNode()){var l=e.pstyle("label");if(!l||!l.value)return;var u=s.getLabelJustification(e);t.textAlign=u,t.textBaseline="bottom"}else{var h=e.element()._private.rscratch.badLine,f=e.pstyle("label"),d=e.pstyle("source-label"),p=e.pstyle("target-label");if(h||(!f||!f.value)&&(!d||!d.value)&&(!p||!p.value))return;t.textAlign="center",t.textBaseline="bottom"}var m=!r,g;r&&(g=r,t.translate(-g.x1,-g.y1)),i==null?(s.drawText(t,e,null,m,a),e.isEdge()&&(s.drawText(t,e,"source",m,a),s.drawText(t,e,"target",m,a))):s.drawText(t,e,i,m,a),r&&t.translate(g.x1,g.y1)};Yp.getFontCache=function(t){var e;this.fontCaches=this.fontCaches||[];for(var r=0;r2&&arguments[2]!==void 0?arguments[2]:!0,n=e.pstyle("font-style").strValue,i=e.pstyle("font-size").pfValue+"px",a=e.pstyle("font-family").strValue,s=e.pstyle("font-weight").strValue,l=r?e.effectiveOpacity()*e.pstyle("text-opacity").value:1,u=e.pstyle("text-outline-opacity").value*l,h=e.pstyle("color").value,f=e.pstyle("text-outline-color").value;t.font=n+" "+s+" "+i+" "+a,t.lineJoin="round",this.colorFillStyle(t,h[0],h[1],h[2],l),this.colorStrokeStyle(t,f[0],f[1],f[2],u)};o(RP,"roundRect");Yp.getTextAngle=function(t,e){var r,n=t._private,i=n.rscratch,a=e?e+"-":"",s=t.pstyle(a+"text-rotation");if(s.strValue==="autorotate"){var l=Gl(i,"labelAngle",e);r=t.isEdge()?l:0}else s.strValue==="none"?r=0:r=s.pfValue;return r};Yp.drawText=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=e._private,s=a.rscratch,l=i?e.effectiveOpacity():1;if(!(i&&(l===0||e.pstyle("text-opacity").value===0))){r==="main"&&(r=null);var u=Gl(s,"labelX",r),h=Gl(s,"labelY",r),f,d,p=this.getLabelText(e,r);if(p!=null&&p!==""&&!isNaN(u)&&!isNaN(h)){this.setupTextStyle(t,e,i);var m=r?r+"-":"",g=Gl(s,"labelWidth",r),y=Gl(s,"labelHeight",r),v=e.pstyle(m+"text-margin-x").pfValue,x=e.pstyle(m+"text-margin-y").pfValue,b=e.isEdge(),w=e.pstyle("text-halign").value,C=e.pstyle("text-valign").value;b&&(w="center",C="center"),u+=v,h+=x;var T;switch(n?T=this.getTextAngle(e,r):T=0,T!==0&&(f=u,d=h,t.translate(f,d),t.rotate(T),u=0,h=0),C){case"top":break;case"center":h+=y/2;break;case"bottom":h+=y;break}var E=e.pstyle("text-background-opacity").value,A=e.pstyle("text-border-opacity").value,S=e.pstyle("text-border-width").pfValue,_=e.pstyle("text-background-padding").pfValue,I=e.pstyle("text-background-shape").strValue,D=I.indexOf("round")===0,k=2;if(E>0||S>0&&A>0){var L=u-_;switch(w){case"left":L-=g;break;case"center":L-=g/2;break}var R=h-y-_,O=g+2*_,M=y+2*_;if(E>0){var B=t.fillStyle,F=e.pstyle("text-background-color").value;t.fillStyle="rgba("+F[0]+","+F[1]+","+F[2]+","+E*l+")",D?RP(t,L,R,O,M,k):t.fillRect(L,R,O,M),t.fillStyle=B}if(S>0&&A>0){var P=t.strokeStyle,z=t.lineWidth,$=e.pstyle("text-border-color").value,H=e.pstyle("text-border-style").value;if(t.strokeStyle="rgba("+$[0]+","+$[1]+","+$[2]+","+A*l+")",t.lineWidth=S,t.setLineDash)switch(H){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"double":t.lineWidth=S/4,t.setLineDash([]);break;case"solid":t.setLineDash([]);break}if(D?RP(t,L,R,O,M,k,"stroke"):t.strokeRect(L,R,O,M),H==="double"){var Q=S/2;D?RP(t,L+Q,R+Q,O-Q*2,M-Q*2,k,"stroke"):t.strokeRect(L+Q,R+Q,O-Q*2,M-Q*2)}t.setLineDash&&t.setLineDash([]),t.lineWidth=z,t.strokeStyle=P}}var j=2*e.pstyle("text-outline-width").pfValue;if(j>0&&(t.lineWidth=j),e.pstyle("text-wrap").value==="wrap"){var ie=Gl(s,"labelWrapCachedLines",r),ne=Gl(s,"labelLineHeight",r),le=g/2,he=this.getLabelJustification(e);switch(he==="auto"||(w==="left"?he==="left"?u+=-g:he==="center"&&(u+=-le):w==="center"?he==="left"?u+=-le:he==="right"&&(u+=le):w==="right"&&(he==="center"?u+=le:he==="right"&&(u+=g))),C){case"top":h-=(ie.length-1)*ne;break;case"center":case"bottom":h-=(ie.length-1)*ne;break}for(var K=0;K0&&t.strokeText(ie[K],u,h),t.fillText(ie[K],u,h),h+=ne}else j>0&&t.strokeText(p,u,h),t.fillText(p,u,h);T!==0&&(t.rotate(-T),t.translate(-f,-d))}}};ly={};ly.drawNode=function(t,e,r){var n=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:!0,a=arguments.length>5&&arguments[5]!==void 0?arguments[5]:!0,s=this,l,u,h=e._private,f=h.rscratch,d=e.position();if(!(!Ct(d.x)||!Ct(d.y))&&!(a&&!e.visible())){var p=a?e.effectiveOpacity():1,m=s.usePaths(),g,y=!1,v=e.padding();l=e.width()+2*v,u=e.height()+2*v;var x;r&&(x=r,t.translate(-x.x1,-x.y1));for(var b=e.pstyle("background-image"),w=b.value,C=new Array(w.length),T=new Array(w.length),E=0,A=0;A0&&arguments[0]!==void 0?arguments[0]:L;s.eleFillStyle(t,e,oe)},"setupShapeColor"),K=o(function(){var oe=arguments.length>0&&arguments[0]!==void 0?arguments[0]:$;s.colorStrokeStyle(t,R[0],R[1],R[2],oe)},"setupBorderColor"),X=o(function(){var oe=arguments.length>0&&arguments[0]!==void 0?arguments[0]:ie;s.colorStrokeStyle(t,Q[0],Q[1],Q[2],oe)},"setupOutlineColor"),te=o(function(oe,V,xe,q){var pe=s.nodePathCache=s.nodePathCache||[],ve=cme(xe==="polygon"?xe+","+q.join(","):xe,""+V,""+oe,""+le),Pe=pe[ve],_e,we=!1;return Pe!=null?(_e=Pe,we=!0,f.pathCache=_e):(_e=new Path2D,pe[ve]=f.pathCache=_e),{path:_e,cacheHit:we}},"getPath"),J=e.pstyle("shape").strValue,se=e.pstyle("shape-polygon-points").pfValue;if(m){t.translate(d.x,d.y);var ue=te(l,u,J,se);g=ue.path,y=ue.cacheHit}var Z=o(function(){if(!y){var oe=d;m&&(oe={x:0,y:0}),s.nodeShapes[s.getNodeShape(e)].draw(g||t,oe.x,oe.y,l,u,le,f)}m?t.fill(g):t.fill()},"drawShape"),Se=o(function(){for(var oe=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,V=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!0,xe=h.backgrounding,q=0,pe=0;pe0&&arguments[0]!==void 0?arguments[0]:!1,V=arguments.length>1&&arguments[1]!==void 0?arguments[1]:p;s.hasPie(e)&&(s.drawPie(t,e,V),oe&&(m||s.nodeShapes[s.getNodeShape(e)].draw(t,d.x,d.y,l,u,le,f)))},"drawPie"),ae=o(function(){var oe=arguments.length>0&&arguments[0]!==void 0?arguments[0]:p,V=(D>0?D:-D)*oe,xe=D>0?0:255;D!==0&&(s.colorFillStyle(t,xe,xe,xe,V),m?t.fill(g):t.fill())},"darken"),Oe=o(function(){if(k>0){if(t.lineWidth=k,t.lineCap=B,t.lineJoin=M,t.setLineDash)switch(O){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash(P),t.lineDashOffset=z;break;case"solid":case"double":t.setLineDash([]);break}if(F!=="center"){if(t.save(),t.lineWidth*=2,F==="inside")m?t.clip(g):t.clip();else{var oe=new Path2D;oe.rect(-l/2-k,-u/2-k,l+2*k,u+2*k),oe.addPath(g),t.clip(oe,"evenodd")}m?t.stroke(g):t.stroke(),t.restore()}else m?t.stroke(g):t.stroke();if(O==="double"){t.lineWidth=k/3;var V=t.globalCompositeOperation;t.globalCompositeOperation="destination-out",m?t.stroke(g):t.stroke(),t.globalCompositeOperation=V}t.setLineDash&&t.setLineDash([])}},"drawBorder"),ge=o(function(){if(H>0){if(t.lineWidth=H,t.lineCap="butt",t.setLineDash)switch(j){case"dotted":t.setLineDash([1,1]);break;case"dashed":t.setLineDash([4,2]);break;case"solid":case"double":t.setLineDash([]);break}var oe=d;m&&(oe={x:0,y:0});var V=s.getNodeShape(e),xe=k;F==="inside"&&(xe=0),F==="outside"&&(xe*=2);var q=(l+xe+(H+ne))/l,pe=(u+xe+(H+ne))/u,ve=l*q,Pe=u*pe,_e=s.nodeShapes[V].points,we;if(m){var Ve=te(ve,Pe,V,_e);we=Ve.path}if(V==="ellipse")s.drawEllipsePath(we||t,oe.x,oe.y,ve,Pe);else if(["round-diamond","round-heptagon","round-hexagon","round-octagon","round-pentagon","round-polygon","round-triangle","round-tag"].includes(V)){var De=0,qe=0,at=0;V==="round-diamond"?De=(xe+ne+H)*1.4:V==="round-heptagon"?(De=(xe+ne+H)*1.075,at=-(xe/2+ne+H)/35):V==="round-hexagon"?De=(xe+ne+H)*1.12:V==="round-pentagon"?(De=(xe+ne+H)*1.13,at=-(xe/2+ne+H)/15):V==="round-tag"?(De=(xe+ne+H)*1.12,qe=(xe/2+H+ne)*.07):V==="round-triangle"&&(De=(xe+ne+H)*(Math.PI/2),at=-(xe+ne/2+H)/Math.PI),De!==0&&(q=(l+De)/l,ve=l*q,["round-hexagon","round-tag"].includes(V)||(pe=(u+De)/u,Pe=u*pe)),le=le==="auto"?bme(ve,Pe):le;for(var Rt=ve/2,st=Pe/2,Ue=le+(xe+H+ne)/2,ct=new Array(_e.length/2),We=new Array(_e.length/2),ot=0;ot<_e.length/2;ot++)ct[ot]={x:oe.x+qe+Rt*_e[ot*2],y:oe.y+at+st*_e[ot*2+1]};var Yt,bt,Mt,xt,ut=ct.length;for(bt=ct[ut-1],Yt=0;Yt0){if(i=i||n.position(),a==null||s==null){var m=n.padding();a=n.width()+2*m,s=n.height()+2*m}l.colorFillStyle(r,f[0],f[1],f[2],h),l.nodeShapes[d].draw(r,i.x,i.y,a+u*2,s+u*2,p),r.fill()}}}},"drawNodeOverlayUnderlay");ly.drawNodeOverlay=Ege("overlay");ly.drawNodeUnderlay=Ege("underlay");ly.hasPie=function(t){return t=t[0],t._private.hasPie};ly.drawPie=function(t,e,r,n){e=e[0],n=n||e.position();var i=e.cy().style(),a=e.pstyle("pie-size"),s=n.x,l=n.y,u=e.width(),h=e.height(),f=Math.min(u,h)/2,d=0,p=this.usePaths();p&&(s=0,l=0),a.units==="%"?f=f*a.pfValue:a.pfValue!==void 0&&(f=a.pfValue/2);for(var m=1;m<=i.pieBackgroundN;m++){var g=e.pstyle("pie-"+m+"-background-size").value,y=e.pstyle("pie-"+m+"-background-color").value,v=e.pstyle("pie-"+m+"-background-opacity").value*r,x=g/100;x+d>1&&(x=1-d);var b=1.5*Math.PI+2*Math.PI*d,w=2*Math.PI*x,C=b+w;g===0||d>=1||d+x>1||(t.beginPath(),t.moveTo(s,l),t.arc(s,l,f,b,C),t.closePath(),this.colorFillStyle(t,y[0],y[1],y[2],v),t.fill(),d+=x)}};ys={},dZe=100;ys.getPixelRatio=function(){var t=this.data.contexts[0];if(this.forcedPixelRatio!=null)return this.forcedPixelRatio;var e=this.cy.window(),r=t.backingStorePixelRatio||t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1;return(e.devicePixelRatio||1)/r};ys.paintCache=function(t){for(var e=this.paintCaches=this.paintCaches||[],r=!0,n,i=0;ie.minMbLowQualFrames&&(e.motionBlurPxRatio=e.mbPxRBlurry)),e.clearingMotionBlur&&(e.motionBlurPxRatio=1),e.textureDrawLastFrame&&!d&&(f[e.NODE]=!0,f[e.SELECT_BOX]=!0);var b=r.style(),w=r.zoom(),C=s!==void 0?s:w,T=r.pan(),E={x:T.x,y:T.y},A={zoom:w,pan:{x:T.x,y:T.y}},S=e.prevViewport,_=S===void 0||A.zoom!==S.zoom||A.pan.x!==S.pan.x||A.pan.y!==S.pan.y;!_&&!(y&&!g)&&(e.motionBlurPxRatio=1),l&&(E=l),C*=u,E.x*=u,E.y*=u;var I=e.getCachedZSortedEles();function D(K,X,te,J,se){var ue=K.globalCompositeOperation;K.globalCompositeOperation="destination-out",e.colorFillStyle(K,255,255,255,e.motionBlurTransparency),K.fillRect(X,te,J,se),K.globalCompositeOperation=ue}o(D,"mbclear");function k(K,X){var te,J,se,ue;!e.clearingMotionBlur&&(K===h.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]||K===h.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG])?(te={x:T.x*m,y:T.y*m},J=w*m,se=e.canvasWidth*m,ue=e.canvasHeight*m):(te=E,J=C,se=e.canvasWidth,ue=e.canvasHeight),K.setTransform(1,0,0,1,0,0),X==="motionBlur"?D(K,0,0,se,ue):!n&&(X===void 0||X)&&K.clearRect(0,0,se,ue),i||(K.translate(te.x,te.y),K.scale(J,J)),l&&K.translate(l.x,l.y),s&&K.scale(s,s)}if(o(k,"setContextTransform"),d||(e.textureDrawLastFrame=!1),d){if(e.textureDrawLastFrame=!0,!e.textureCache){e.textureCache={},e.textureCache.bb=r.mutableElements().boundingBox(),e.textureCache.texture=e.data.bufferCanvases[e.TEXTURE_BUFFER];var L=e.data.bufferContexts[e.TEXTURE_BUFFER];L.setTransform(1,0,0,1,0,0),L.clearRect(0,0,e.canvasWidth*e.textureMult,e.canvasHeight*e.textureMult),e.render({forcedContext:L,drawOnlyNodeLayer:!0,forcedPxRatio:u*e.textureMult});var A=e.textureCache.viewport={zoom:r.zoom(),pan:r.pan(),width:e.canvasWidth,height:e.canvasHeight};A.mpan={x:(0-A.pan.x)/A.zoom,y:(0-A.pan.y)/A.zoom}}f[e.DRAG]=!1,f[e.NODE]=!1;var R=h.contexts[e.NODE],O=e.textureCache.texture,A=e.textureCache.viewport;R.setTransform(1,0,0,1,0,0),p?D(R,0,0,A.width,A.height):R.clearRect(0,0,A.width,A.height);var M=b.core("outside-texture-bg-color").value,B=b.core("outside-texture-bg-opacity").value;e.colorFillStyle(R,M[0],M[1],M[2],B),R.fillRect(0,0,A.width,A.height);var w=r.zoom();k(R,!1),R.clearRect(A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u),R.drawImage(O,A.mpan.x,A.mpan.y,A.width/A.zoom/u,A.height/A.zoom/u)}else e.textureOnViewport&&!n&&(e.textureCache=null);var F=r.extent(),P=e.pinching||e.hoverData.dragging||e.swipePanning||e.data.wheelZooming||e.hoverData.draggingEles||e.cy.animated(),z=e.hideEdgesOnViewport&&P,$=[];if($[e.NODE]=!f[e.NODE]&&p&&!e.clearedForMotionBlur[e.NODE]||e.clearingMotionBlur,$[e.NODE]&&(e.clearedForMotionBlur[e.NODE]=!0),$[e.DRAG]=!f[e.DRAG]&&p&&!e.clearedForMotionBlur[e.DRAG]||e.clearingMotionBlur,$[e.DRAG]&&(e.clearedForMotionBlur[e.DRAG]=!0),f[e.NODE]||i||a||$[e.NODE]){var H=p&&!$[e.NODE]&&m!==1,R=n||(H?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_NODE]:h.contexts[e.NODE]),Q=p&&!H?"motionBlur":void 0;k(R,Q),z?e.drawCachedNodes(R,I.nondrag,u,F):e.drawLayeredElements(R,I.nondrag,u,F),e.debug&&e.drawDebugPoints(R,I.nondrag),!i&&!p&&(f[e.NODE]=!1)}if(!a&&(f[e.DRAG]||i||$[e.DRAG])){var H=p&&!$[e.DRAG]&&m!==1,R=n||(H?e.data.bufferContexts[e.MOTIONBLUR_BUFFER_DRAG]:h.contexts[e.DRAG]);k(R,p&&!H?"motionBlur":void 0),z?e.drawCachedNodes(R,I.drag,u,F):e.drawCachedElements(R,I.drag,u,F),e.debug&&e.drawDebugPoints(R,I.drag),!i&&!p&&(f[e.DRAG]=!1)}if(this.drawSelectionRectangle(t,k),p&&m!==1){var j=h.contexts[e.NODE],ie=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_NODE],ne=h.contexts[e.DRAG],le=e.data.bufferCanvases[e.MOTIONBLUR_BUFFER_DRAG],he=o(function(X,te,J){X.setTransform(1,0,0,1,0,0),J||!x?X.clearRect(0,0,e.canvasWidth,e.canvasHeight):D(X,0,0,e.canvasWidth,e.canvasHeight);var se=m;X.drawImage(te,0,0,e.canvasWidth*se,e.canvasHeight*se,0,0,e.canvasWidth,e.canvasHeight)},"drawMotionBlur");(f[e.NODE]||$[e.NODE])&&(he(j,ie,$[e.NODE]),f[e.NODE]=!1),(f[e.DRAG]||$[e.DRAG])&&(he(ne,le,$[e.DRAG]),f[e.DRAG]=!1)}e.prevViewport=A,e.clearingMotionBlur&&(e.clearingMotionBlur=!1,e.motionBlurCleared=!0,e.motionBlur=!0),p&&(e.motionBlurTimeout=setTimeout(function(){e.motionBlurTimeout=null,e.clearedForMotionBlur[e.NODE]=!1,e.clearedForMotionBlur[e.DRAG]=!1,e.motionBlur=!1,e.clearingMotionBlur=!d,e.mbFrames=0,f[e.NODE]=!0,f[e.DRAG]=!0,e.redraw()},dZe)),n||r.emit("render")};ys.drawSelectionRectangle=function(t,e){var r=this,n=r.cy,i=r.data,a=n.style(),s=t.drawOnlyNodeLayer,l=t.drawAllLayers,u=i.canvasNeedsRedraw,h=t.forcedContext;if(r.showFps||!s&&u[r.SELECT_BOX]&&!l){var f=h||i.contexts[r.SELECT_BOX];if(e(f),r.selection[4]==1&&(r.hoverData.selecting||r.touchData.selecting)){var d=r.cy.zoom(),p=a.core("selection-box-border-width").value/d;f.lineWidth=p,f.fillStyle="rgba("+a.core("selection-box-color").value[0]+","+a.core("selection-box-color").value[1]+","+a.core("selection-box-color").value[2]+","+a.core("selection-box-opacity").value+")",f.fillRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]),p>0&&(f.strokeStyle="rgba("+a.core("selection-box-border-color").value[0]+","+a.core("selection-box-border-color").value[1]+","+a.core("selection-box-border-color").value[2]+","+a.core("selection-box-opacity").value+")",f.strokeRect(r.selection[0],r.selection[1],r.selection[2]-r.selection[0],r.selection[3]-r.selection[1]))}if(i.bgActivePosistion&&!r.hoverData.selecting){var d=r.cy.zoom(),m=i.bgActivePosistion;f.fillStyle="rgba("+a.core("active-bg-color").value[0]+","+a.core("active-bg-color").value[1]+","+a.core("active-bg-color").value[2]+","+a.core("active-bg-opacity").value+")",f.beginPath(),f.arc(m.x,m.y,a.core("active-bg-size").pfValue/d,0,2*Math.PI),f.fill()}var g=r.lastRedrawTime;if(r.showFps&&g){g=Math.round(g);var y=Math.round(1e3/g),v="1 frame = "+g+" ms = "+y+" fps";if(f.setTransform(1,0,0,1,0,0),f.fillStyle="rgba(255, 0, 0, 0.75)",f.strokeStyle="rgba(255, 0, 0, 0.75)",f.font="30px Arial",!Nb){var x=f.measureText(v);Nb=x.actualBoundingBoxAscent}f.fillText(v,0,Nb);var b=60;f.strokeRect(0,Nb+10,250,20),f.fillRect(0,Nb+10,250*Math.min(y/b,1),20)}l||(u[r.SELECT_BOX]=!1)}};o(z0e,"compileShader");o(pZe,"createProgram");o(mZe,"createTextureCanvas");o(wB,"getEffectivePanZoom");o(NP,"modelToRenderedPosition");o(oS,"toWebGLColor");o(lS,"indexToVec4");o(gZe,"vec4ToIndex");o(yZe,"createTexture");o(Sge,"getTypeInfo");o(Cge,"createTypedArray");o(vZe,"createTypedArrayView");o(xZe,"createBufferStaticDraw");o(po,"createBufferDynamicDraw");o(bZe,"createPickingFrameBuffer");G0e=typeof Float32Array<"u"?Float32Array:Array;Math.hypot||(Math.hypot=function(){for(var t=0,e=arguments.length;e--;)t+=arguments[e]*arguments[e];return Math.sqrt(t)});o(Gb,"create");o(Age,"identity");o(wZe,"multiply");o(DS,"translate");o(_ge,"rotate");o(TB,"scale");o(TZe,"projection");Vb={SCREEN:{name:"screen",screen:!0},PICKING:{name:"picking",picking:!0}},Mb=la({getKey:null,drawElement:null,getBoundingBox:null,getRotation:null,getRotationPoint:null,getRotationOffset:null,isVisible:null,getPadding:null}),kZe=function(){function t(e,r){Mf(this,t),this.debugID=Math.floor(Math.random()*1e4),this.r=e,this.atlasSize=r.webglTexSize,this.rows=r.webglTexRows,this.enableWrapping=r.enableWrapping,this.texHeight=Math.floor(this.atlasSize/this.rows),this.maxTexWidth=this.atlasSize,this.texture=null,this.canvas=null,this.needsBuffer=!0,this.freePointer={x:0,row:0},this.keyToLocation=new Map,this.canvas=r.createTextureCanvas(e,this.atlasSize,this.atlasSize),this.scratch=r.createTextureCanvas(e,this.atlasSize,this.texHeight,"scratch")}return o(t,"Atlas"),If(t,[{key:"getKeys",value:o(function(){return new Set(this.keyToLocation.keys())},"getKeys")},{key:"getScale",value:o(function(r){var n=r.w,i=r.h,a=this.texHeight,s=this.maxTexWidth,l=a/i,u=n*l,h=i*l;return u>s&&(l=s/n,u=n*l,h=i*l),{scale:l,texW:u,texH:h}},"getScale")},{key:"draw",value:o(function(r,n,i){var a=this,s=this.atlasSize,l=this.rows,u=this.texHeight,h=this.getScale(n),f=h.scale,d=h.texW,p=h.texH,m=[null,null],g=o(function(w,C){if(i&&C){var T=C.context,E=w.x,A=w.row,S=E,_=u*A;T.save(),T.translate(S,_),T.scale(f,f),i(T,n),T.restore()}},"drawAt"),y=o(function(){g(a.freePointer,a.canvas),m[0]={x:a.freePointer.x,y:a.freePointer.row*u,w:d,h:p},m[1]={x:a.freePointer.x+d,y:a.freePointer.row*u,w:0,h:p},a.freePointer.x+=d,a.freePointer.x==s&&(a.freePointer.x=0,a.freePointer.row++)},"drawNormal"),v=o(function(){var w=a.scratch,C=a.canvas;w.clear(),g({x:0,row:0},w);var T=s-a.freePointer.x,E=d-T,A=u;{var S=a.freePointer.x,_=a.freePointer.row*u,I=T;C.context.drawImage(w,0,0,I,A,S,_,I,A),m[0]={x:S,y:_,w:I,h:p}}{var D=T,k=(a.freePointer.row+1)*u,L=E;C&&C.context.drawImage(w,D,0,L,A,0,k,L,A),m[1]={x:0,y:k,w:L,h:p}}a.freePointer.x=E,a.freePointer.row++},"drawWrapped"),x=o(function(){a.freePointer.x=0,a.freePointer.row++},"moveToStartOfNextRow");if(this.freePointer.x+d<=s)y();else{if(this.freePointer.row>=l-1)return!1;this.freePointer.x===s?(x(),y()):this.enableWrapping?v():(x(),y())}return this.keyToLocation.set(r,m),this.needsBuffer=!0,m},"draw")},{key:"getOffsets",value:o(function(r){return this.keyToLocation.get(r)},"getOffsets")},{key:"isEmpty",value:o(function(){return this.freePointer.x===0&&this.freePointer.row===0},"isEmpty")},{key:"canFit",value:o(function(r){var n=this.atlasSize,i=this.rows,a=this.getScale(r),s=a.texW;return this.freePointer.x+s>n?this.freePointer.row1&&arguments[1]!==void 0?arguments[1]:{},i=n.forceRedraw,a=i===void 0?!1:i,s=n.filterEle,l=s===void 0?function(){return!0}:s,u=n.filterType,h=u===void 0?function(){return!0}:u,f=!1,d=mo(r),p;try{for(d.s();!(p=d.n()).done;){var m=p.value;if(l(m)){var g=m.id(),y=mo(this.getRenderTypes()),v;try{for(y.s();!(v=y.n()).done;){var x=v.value;if(h(x.type)){var b=x.getKey(m);a?(x.atlasCollection.deleteKey(g,b),x.atlasCollection.styleKeyNeedsRedraw.add(b),f=!0):f|=x.atlasCollection.checkKeyIsInvalid(g,b)}}}catch(w){y.e(w)}finally{y.f()}}}}catch(w){d.e(w)}finally{d.f()}return f},"invalidate")},{key:"gc",value:o(function(){var r=mo(this.getRenderTypes()),n;try{for(r.s();!(n=r.n()).done;){var i=n.value;i.atlasCollection.gc()}}catch(a){r.e(a)}finally{r.f()}},"gc")},{key:"isRenderable",value:o(function(r,n){var i=this.getRenderTypeOpts(n);return i&&i.isVisible(r)},"isRenderable")},{key:"startBatch",value:o(function(){this.batchAtlases=[]},"startBatch")},{key:"getAtlasCount",value:o(function(){return this.batchAtlases.length},"getAtlasCount")},{key:"getAtlases",value:o(function(){return this.batchAtlases},"getAtlases")},{key:"getOrCreateAtlas",value:o(function(r,n,i){var a=this.renderTypes.get(i),s=a.getKey(r),l=r.id();return a.atlasCollection.draw(l,s,n,function(u){a.drawElement(u,r,n,!0,!0)})},"getOrCreateAtlas")},{key:"getAtlasIndexForBatch",value:o(function(r){var n=this.batchAtlases.indexOf(r);if(n<0){if(this.batchAtlases.length===this.maxAtlasesPerBatch)return;this.batchAtlases.push(r),n=this.batchAtlases.length-1}return n},"getAtlasIndexForBatch")},{key:"getIndexArray",value:o(function(){return Array.from({length:this.maxAtlases},function(r,n){return n})},"getIndexArray")},{key:"getAtlasInfo",value:o(function(r,n){var i=this.renderTypes.get(n),a=i.getBoundingBox(r),s=this.getOrCreateAtlas(r,a,n),l=this.getAtlasIndexForBatch(s);if(l!==void 0){var u=i.getKey(r),h=s.getOffsets(u),f=_i(h,2),d=f[0],p=f[1];return{atlasID:l,tex:d,tex1:d,tex2:p,bb:a,type:n,styleKey:u}}},"getAtlasInfo")},{key:"canAddToCurrentBatch",value:o(function(r,n){if(this.batchAtlases.length===this.maxAtlasesPerBatch){var i=this.renderTypes.get(n),a=i.getKey(r),s=i.atlasCollection.getAtlas(a);return s&&this.batchAtlases.includes(s)}return!0},"canAddToCurrentBatch")},{key:"setTransformMatrix",value:o(function(r,n,i){var a=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!0,s=n.bb,l=n.type,u=n.tex1,h=n.tex2,f=this.getRenderTypeOpts(l),d=f.getPadding?f.getPadding(i):0,p=u.w/(u.w+h.w);a||(p=1-p);var m=this.getAdjustedBB(s,d,a,p),g,y;Age(r);var v=f.getRotation?f.getRotation(i):0;if(v!==0){var x=f.getRotationPoint(i),b=x.x,w=x.y;DS(r,r,[b,w]),_ge(r,r,v);var C=f.getRotationOffset(i);g=C.x+m.xOffset,y=C.y}else g=m.x1,y=m.y1;DS(r,r,[g,y]),TB(r,r,[m.w,m.h])},"setTransformMatrix")},{key:"getTransformMatrix",value:o(function(r,n){var i=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!0,a=Gb();return this.setTransformMatrix(a,r,n,i),a},"getTransformMatrix")},{key:"getAdjustedBB",value:o(function(r,n,i,a){var s=r.x1,l=r.y1,u=r.w,h=r.h;n&&(s-=n,l-=n,u+=2*n,h+=2*n);var f=0,d=u*a;return i&&a<1?u=d:!i&&a<1&&(f=u-d,s+=f,u=d),{x1:s,y1:l,w:u,h,xOffset:f}},"getAdjustedBB")},{key:"getDebugInfo",value:o(function(){var r=[],n=mo(this.renderTypes),i;try{for(n.s();!(i=n.n()).done;){var a=_i(i.value,2),s=a[0],l=a[1],u=l.atlasCollection.getCounts(),h=u.keyCount,f=u.atlasCount;r.push({type:s,keyCount:h,atlasCount:f})}}catch(d){n.e(d)}finally{n.f()}return r},"getDebugInfo")}]),t}(),MP=0,V0e=1,U0e=2,IP=3,AZe=function(){function t(e,r,n){Mf(this,t),this.r=e,this.gl=r,this.maxInstances=n.webglBatchSize,this.maxAtlases=n.webglTexPerBatch,this.atlasSize=n.webglTexSize,this.bgColor=n.bgColor,n.enableWrapping=!0,n.createTextureCanvas=mZe,this.atlasManager=new CZe(e,n),this.program=this.createShaderProgram(Vb.SCREEN),this.pickingProgram=this.createShaderProgram(Vb.PICKING),this.vao=this.createVAO(),this.debugInfo=[]}return o(t,"ElementDrawingWebGL"),If(t,[{key:"addTextureRenderType",value:o(function(r,n){this.atlasManager.addRenderType(r,n)},"addTextureRenderType")},{key:"invalidate",value:o(function(r){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},i=n.type,a=this.atlasManager;return i?a.invalidate(r,{filterType:o(function(l){return l===i},"filterType"),forceRedraw:!0}):a.invalidate(r)},"invalidate")},{key:"gc",value:o(function(){this.atlasManager.gc()},"gc")},{key:"createShaderProgram",value:o(function(r){var n=this.gl,i=`#version 300 es + precision highp float; + + uniform mat3 uPanZoomMatrix; + uniform int uAtlasSize; + + // instanced + in vec2 aPosition; + + // what are we rendering? + in int aVertType; + + // for picking + in vec4 aIndex; + + // For textures + in int aAtlasId; // which shader unit/atlas to use + in vec4 aTex1; // x/y/w/h of texture in atlas + in vec4 aTex2; + + // for any transforms that are needed + in vec4 aScaleRotate1; // vectors use fewer attributes than matrices + in vec2 aTranslate1; + in vec4 aScaleRotate2; + in vec2 aTranslate2; + + // for edges + in vec4 aPointAPointB; + in vec4 aPointCPointD; + in float aLineWidth; + in vec4 aEdgeColor; + + out vec2 vTexCoord; + out vec4 vEdgeColor; + flat out int vAtlasId; + flat out vec4 vIndex; + flat out int vVertType; + + void main(void) { + int vid = gl_VertexID; + vec2 position = aPosition; + + if(aVertType == `.concat(MP,`) { + float texX; + float texY; + float texW; + float texH; + mat3 texMatrix; + + int vid = gl_VertexID; + if(vid <= 5) { + texX = aTex1.x; + texY = aTex1.y; + texW = aTex1.z; + texH = aTex1.w; + texMatrix = mat3( + vec3(aScaleRotate1.xy, 0.0), + vec3(aScaleRotate2.zw, 0.0), + vec3(aTranslate1, 1.0) + ); + } else { + texX = aTex2.x; + texY = aTex2.y; + texW = aTex2.z; + texH = aTex2.w; + texMatrix = mat3( + vec3(aScaleRotate2.xy, 0.0), + vec3(aScaleRotate2.zw, 0.0), + vec3(aTranslate2, 1.0) + ); + } + + if(vid == 1 || vid == 2 || vid == 4 || vid == 7 || vid == 8 || vid == 10) { + texX += texW; + } + if(vid == 2 || vid == 4 || vid == 5 || vid == 8 || vid == 10 || vid == 11) { + texY += texH; + } + + float d = float(uAtlasSize); + vTexCoord = vec2(texX / d, texY / d); // tex coords must be between 0 and 1 + + gl_Position = vec4(uPanZoomMatrix * texMatrix * vec3(position, 1.0), 1.0); + } + else if(aVertType == `).concat(V0e,` && vid < 6) { + vec2 source = aPointAPointB.xy; + vec2 target = aPointAPointB.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + vec2 xBasis = target - source; + vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); + vec2 point = source + xBasis * position.x + yBasis * aLineWidth * position.y; + + gl_Position = vec4(uPanZoomMatrix * vec3(point, 1.0), 1.0); + vEdgeColor = aEdgeColor; + } + else if(aVertType == `).concat(U0e,` && vid < 6) { + vec2 pointA = aPointAPointB.xy; + vec2 pointB = aPointAPointB.zw; + vec2 pointC = aPointCPointD.xy; + vec2 pointD = aPointCPointD.zw; + + // adjust the geometry so that the line is centered on the edge + position.y = position.y - 0.5; + + vec2 p0 = pointA; + vec2 p1 = pointB; + vec2 p2 = pointC; + vec2 pos = position; + if(position.x == 1.0) { + p0 = pointD; + p1 = pointC; + p2 = pointB; + pos = vec2(0.0, -position.y); + } + + vec2 p01 = p1 - p0; + vec2 p12 = p2 - p1; + vec2 p21 = p1 - p2; + + // Find the normal vector. + vec2 tangent = normalize(normalize(p12) + normalize(p01)); + vec2 normal = vec2(-tangent.y, tangent.x); + + // Find the vector perpendicular to p0 -> p1. + vec2 p01Norm = normalize(vec2(-p01.y, p01.x)); + + // Determine the bend direction. + float sigma = sign(dot(p01 + p21, normal)); + float width = aLineWidth; + + if(sign(pos.y) == -sigma) { + // This is an intersecting vertex. Adjust the position so that there's no overlap. + vec2 point = 0.5 * width * normal * -sigma / dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } else { + // This is a non-intersecting vertex. Treat it like a mitre join. + vec2 point = 0.5 * width * normal * sigma * dot(normal, p01Norm); + gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); + } + + vEdgeColor = aEdgeColor; + } + else if(aVertType == `).concat(IP,` && vid < 3) { + // massage the first triangle into an edge arrow + if(vid == 0) + position = vec2(-0.15, -0.3); + if(vid == 1) + position = vec2( 0.0, 0.0); + if(vid == 2) + position = vec2( 0.15, -0.3); + + mat3 transform = mat3( + vec3(aScaleRotate1.xy, 0.0), + vec3(aScaleRotate1.zw, 0.0), + vec3(aTranslate1, 1.0) + ); + gl_Position = vec4(uPanZoomMatrix * transform * vec3(position, 1.0), 1.0); + vEdgeColor = aEdgeColor; + } else { + gl_Position = vec4(2.0, 0.0, 0.0, 1.0); // discard vertex by putting it outside webgl clip space + } + + vAtlasId = aAtlasId; + vIndex = aIndex; + vVertType = aVertType; + } + `),a=this.atlasManager.getIndexArray(),s=`#version 300 es + precision highp float; + + // define texture unit for each node in the batch + `.concat(a.map(function(h){return"uniform sampler2D uTexture".concat(h,";")}).join(` + `),` + + uniform vec4 uBGColor; + + in vec2 vTexCoord; + in vec4 vEdgeColor; + flat in int vAtlasId; + flat in vec4 vIndex; + flat in int vVertType; + + out vec4 outColor; + + void main(void) { + if(vVertType == `).concat(MP,`) { + `).concat(a.map(function(h){return"if(vAtlasId == ".concat(h,") outColor = texture(uTexture").concat(h,", vTexCoord);")}).join(` + else `),` + } else if(vVertType == `).concat(IP,`) { + // blend arrow color with background (using premultiplied alpha) + outColor.rgb = vEdgeColor.rgb + (uBGColor.rgb * (1.0 - vEdgeColor.a)); + outColor.a = 1.0; // make opaque, masks out line under arrow + } else { + outColor = vEdgeColor; + } + + `).concat(r.picking?`if(outColor.a == 0.0) discard; + else outColor = vIndex;`:"",` + } + `),l=pZe(n,i,s);l.aPosition=n.getAttribLocation(l,"aPosition"),l.aIndex=n.getAttribLocation(l,"aIndex"),l.aVertType=n.getAttribLocation(l,"aVertType"),l.aAtlasId=n.getAttribLocation(l,"aAtlasId"),l.aTex1=n.getAttribLocation(l,"aTex1"),l.aTex2=n.getAttribLocation(l,"aTex2"),l.aScaleRotate1=n.getAttribLocation(l,"aScaleRotate1"),l.aTranslate1=n.getAttribLocation(l,"aTranslate1"),l.aScaleRotate2=n.getAttribLocation(l,"aScaleRotate2"),l.aTranslate2=n.getAttribLocation(l,"aTranslate2"),l.aPointAPointB=n.getAttribLocation(l,"aPointAPointB"),l.aPointCPointD=n.getAttribLocation(l,"aPointCPointD"),l.aLineWidth=n.getAttribLocation(l,"aLineWidth"),l.aEdgeColor=n.getAttribLocation(l,"aEdgeColor"),l.uPanZoomMatrix=n.getUniformLocation(l,"uPanZoomMatrix"),l.uAtlasSize=n.getUniformLocation(l,"uAtlasSize"),l.uBGColor=n.getUniformLocation(l,"uBGColor"),l.uTextures=[];for(var u=0;u2&&arguments[2]!==void 0?arguments[2]:Vb.SCREEN;this.panZoomMatrix=r,this.debugInfo=n,this.renderTarget=i,this.startBatch()},"startFrame")},{key:"startBatch",value:o(function(){this.instanceCount=0,this.atlasManager.startBatch()},"startBatch")},{key:"endFrame",value:o(function(){this.endBatch()},"endFrame")},{key:"getTempMatrix",value:o(function(){return this.tempMatrix=this.tempMatrix||Gb()},"getTempMatrix")},{key:"drawTexture",value:o(function(r,n,i){var a=this.atlasManager;if(a.isRenderable(r,i)){a.canAddToCurrentBatch(r,i)||this.endBatch();var s=this.instanceCount;this.vertTypeBuffer.getView(s)[0]=MP;var l=this.indexBuffer.getView(s);lS(n,l);var u=a.getAtlasInfo(r,i,u),h=u.atlasID,f=u.tex1,d=u.tex2,p=this.atlasIdBuffer.getView(s);p[0]=h;var m=this.tex1Buffer.getView(s);m[0]=f.x,m[1]=f.y,m[2]=f.w,m[3]=f.h;var g=this.tex2Buffer.getView(s);g[0]=d.x,g[1]=d.y,g[2]=d.w,g[3]=d.h;for(var y=this.getTempMatrix(),v=0,x=[1,2];v=this.maxInstances&&this.endBatch()}},"drawTexture")},{key:"drawEdgeArrow",value:o(function(r,n,i){var a=r._private.rscratch,s,l,u;if(i==="source"?(s=a.arrowStartX,l=a.arrowStartY,u=a.srcArrowAngle):(s=a.arrowEndX,l=a.arrowEndY,u=a.tgtArrowAngle),!(isNaN(s)||s==null||isNaN(l)||l==null||isNaN(u)||u==null)){var h=r.pstyle(i+"-arrow-shape").value;if(h!=="none"){var f=r.pstyle(i+"-arrow-color").value,d=r.pstyle("opacity").value,p=r.pstyle("line-opacity").value,m=d*p,g=r.pstyle("width").pfValue,y=r.pstyle("arrow-scale").value,v=this.r.getArrowWidth(g,y),x=this.getTempMatrix();Age(x),DS(x,x,[s,l]),TB(x,x,[v,v]),_ge(x,x,u);var b=this.instanceCount;this.vertTypeBuffer.getView(b)[0]=IP;var w=this.indexBuffer.getView(b);lS(n,w);var C=this.edgeColorBuffer.getView(b);oS(f,m,C);var T=this.scaleRotate1Buffer.getView(b);T[0]=x[0],T[1]=x[1],T[2]=x[3],T[3]=x[4];var E=this.translate1Buffer.getView(b);E[0]=x[6],E[1]=x[7],this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}}},"drawEdgeArrow")},{key:"drawEdgeLine",value:o(function(r,n){var i=r.pstyle("opacity").value,a=r.pstyle("line-opacity").value,s=r.pstyle("width").pfValue,l=r.pstyle("line-color").value,u=i*a,h=this.getEdgePoints(r);if(h.length/2+this.instanceCount>this.maxInstances&&this.endBatch(),h.length==4){var f=this.instanceCount;this.vertTypeBuffer.getView(f)[0]=V0e;var d=this.indexBuffer.getView(f);lS(n,d);var p=this.edgeColorBuffer.getView(f);oS(l,u,p);var m=this.lineWidthBuffer.getView(f);m[0]=s;var g=this.pointAPointBBuffer.getView(f);g[0]=h[0],g[1]=h[1],g[2]=h[2],g[3]=h[3],this.instanceCount++,this.instanceCount>=this.maxInstances&&this.endBatch()}else for(var y=0;y=this.maxInstances&&this.endBatch()}},"drawEdgeLine")},{key:"getEdgePoints",value:o(function(r){var n=r._private.rscratch,i=n.allpts;if(i.length==4)return i;var a=this.getNumSegments(r);return this.getCurveSegmentPoints(i,a)},"getEdgePoints")},{key:"getNumSegments",value:o(function(r){var n=15;return Math.min(Math.max(n,5),this.maxInstances)},"getNumSegments")},{key:"getCurveSegmentPoints",value:o(function(r,n){if(r.length==4)return r;for(var i=Array((n+1)*2),a=0;a<=n;a++)if(a==0)i[0]=r[0],i[1]=r[1];else if(a==n)i[a*2]=r[r.length-2],i[a*2+1]=r[r.length-1];else{var s=a/n;this.setCurvePoint(r,s,i,a*2)}return i},"getCurveSegmentPoints")},{key:"setCurvePoint",value:o(function(r,n,i,a){if(r.length<=2)i[a]=r[0],i[a+1]=r[1];else{for(var s=Array(r.length-2),l=0;l0},"isVisible")},{key:"getStyle",value:o(function(r,n){var i=n.pstyle("".concat(r,"-opacity")).value,a=n.pstyle("".concat(r,"-color")).value,s=n.pstyle("".concat(r,"-shape")).value;return{opacity:i,color:a,shape:s}},"getStyle")},{key:"getPadding",value:o(function(r,n){return n.pstyle("".concat(r,"-padding")).pfValue},"getPadding")},{key:"draw",value:o(function(r,n,i,a){if(this.isVisible(r,i)){var s=this.r,l=a.w,u=a.h,h=l/2,f=u/2,d=this.getStyle(r,i),p=d.shape,m=d.color,g=d.opacity;n.save(),n.fillStyle=H0e(m,g),p==="round-rectangle"||p==="roundrectangle"?s.drawRoundRectanglePath(n,h,f,l,u,"auto"):p==="ellipse"&&s.drawEllipsePath(n,h,f,l,u),n.fill(),n.restore()}},"draw")}]),t}();o(DZe,"getBGColor");Dge={};Dge.initWebgl=function(t,e){var r=this,n=r.data.contexts[r.WEBGL],i=t.cy.container();t.bgColor=DZe(i),t.webglTexSize=Math.min(t.webglTexSize,n.getParameter(n.MAX_TEXTURE_SIZE)),t.webglTexRows=Math.min(t.webglTexRows,54),t.webglBatchSize=Math.min(t.webglBatchSize,16384),t.webglTexPerBatch=Math.min(t.webglTexPerBatch,n.getParameter(n.MAX_TEXTURE_IMAGE_UNITS)),r.webglDebug=t.webglDebug,r.webglDebugShowAtlases=t.webglDebugShowAtlases,console.log("max texture units",n.getParameter(n.MAX_TEXTURE_IMAGE_UNITS)),console.log("max texture size",n.getParameter(n.MAX_TEXTURE_SIZE)),console.log("webgl options",t),r.pickingFrameBuffer=bZe(n),r.pickingFrameBuffer.needsDraw=!0;var a=o(function(f){return r.getTextAngle(f,null)},"getLabelRotation"),s=o(function(f){var d=f.pstyle("label");return d&&d.value},"isLabelVisible");r.eleDrawing=new AZe(r,n,t);var l=new _Ze(r);r.eleDrawing.addTextureRenderType("node-body",Mb({getKey:e.getStyleKey,getBoundingBox:e.getElementBox,drawElement:e.drawElement,isVisible:o(function(f){return f.visible()},"isVisible")})),r.eleDrawing.addTextureRenderType("node-label",Mb({getKey:e.getLabelKey,getBoundingBox:e.getLabelBox,drawElement:e.drawLabel,getRotation:a,getRotationPoint:e.getLabelRotationPoint,getRotationOffset:e.getLabelRotationOffset,isVisible:s})),r.eleDrawing.addTextureRenderType("node-overlay",Mb({getBoundingBox:e.getElementBox,getKey:o(function(f){return l.getStyleKey("overlay",f)},"getKey"),drawElement:o(function(f,d,p){return l.draw("overlay",f,d,p)},"drawElement"),isVisible:o(function(f){return l.isVisible("overlay",f)},"isVisible"),getPadding:o(function(f){return l.getPadding("overlay",f)},"getPadding")})),r.eleDrawing.addTextureRenderType("node-underlay",Mb({getBoundingBox:e.getElementBox,getKey:o(function(f){return l.getStyleKey("underlay",f)},"getKey"),drawElement:o(function(f,d,p){return l.draw("underlay",f,d,p)},"drawElement"),isVisible:o(function(f){return l.isVisible("underlay",f)},"isVisible"),getPadding:o(function(f){return l.getPadding("underlay",f)},"getPadding")})),r.eleDrawing.addTextureRenderType("edge-label",Mb({getKey:e.getLabelKey,getBoundingBox:e.getLabelBox,drawElement:e.drawLabel,getRotation:a,getRotationPoint:e.getLabelRotationPoint,getRotationOffset:e.getLabelRotationOffset,isVisible:s}));var u=n4(function(){console.log("garbage collect flag set"),r.data.gc=!0},1e4);r.onUpdateEleCalcs(function(h,f){var d=!1;f&&f.length>0&&(d|=r.eleDrawing.invalidate(f)),d&&u()}),LZe(r)};o(LZe,"overrideCanvasRendererFunctions");o(RZe,"clearWebgl");o(NZe,"clearCanvas");o(MZe,"createPanZoomMatrix");o(Lge,"setContextTransform");o(IZe,"drawSelectionRectangle");o(OZe,"drawAxes");o(PZe,"drawAtlases");o(BZe,"getPickingIndexes");o(FZe,"findNearestElementsWebgl");o(Rge,"renderWebgl");Pf={};Pf.drawPolygonPath=function(t,e,r,n,i,a){var s=n/2,l=i/2;t.beginPath&&t.beginPath(),t.moveTo(e+s*a[0],r+l*a[1]);for(var u=1;u0&&s>0){m.clearRect(0,0,a,s),m.globalCompositeOperation="source-over";var g=this.getCachedZSortedEles();if(t.full)m.translate(-n.x1*h,-n.y1*h),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(n.x1*h,n.y1*h);else{var y=e.pan(),v={x:y.x*h,y:y.y*h};h*=e.zoom(),m.translate(v.x,v.y),m.scale(h,h),this.drawElements(m,g),m.scale(1/h,1/h),m.translate(-v.x,-v.y)}t.bg&&(m.globalCompositeOperation="destination-over",m.fillStyle=t.bg,m.rect(0,0,a,s),m.fill())}return p};o($Ze,"b64ToBlob");o(Y0e,"b64UriToB64");o(Mge,"output");c4.png=function(t){return Mge(t,this.bufferCanvasImage(t),"image/png")};c4.jpg=function(t){return Mge(t,this.bufferCanvasImage(t),"image/jpeg")};Ige={};Ige.nodeShapeImpl=function(t,e,r,n,i,a,s,l){switch(t){case"ellipse":return this.drawEllipsePath(e,r,n,i,a);case"polygon":return this.drawPolygonPath(e,r,n,i,a,s);case"round-polygon":return this.drawRoundPolygonPath(e,r,n,i,a,s,l);case"roundrectangle":case"round-rectangle":return this.drawRoundRectanglePath(e,r,n,i,a,l);case"cutrectangle":case"cut-rectangle":return this.drawCutRectanglePath(e,r,n,i,a,s,l);case"bottomroundrectangle":case"bottom-round-rectangle":return this.drawBottomRoundRectanglePath(e,r,n,i,a,l);case"barrel":return this.drawBarrelPath(e,r,n,i,a)}};zZe=Oge,Er=Oge.prototype;Er.CANVAS_LAYERS=3;Er.SELECT_BOX=0;Er.DRAG=1;Er.NODE=2;Er.WEBGL=3;Er.CANVAS_TYPES=["2d","2d","2d","webgl2"];Er.BUFFER_COUNT=3;Er.TEXTURE_BUFFER=0;Er.MOTIONBLUR_BUFFER_NODE=1;Er.MOTIONBLUR_BUFFER_DRAG=2;o(Oge,"CanvasRenderer");Er.redrawHint=function(t,e){var r=this;switch(t){case"eles":r.data.canvasNeedsRedraw[Er.NODE]=e;break;case"drag":r.data.canvasNeedsRedraw[Er.DRAG]=e;break;case"select":r.data.canvasNeedsRedraw[Er.SELECT_BOX]=e;break;case"gc":r.data.gc=!0;break}};GZe=typeof Path2D<"u";Er.path2dEnabled=function(t){if(t===void 0)return this.pathsEnabled;this.pathsEnabled=!!t};Er.usePaths=function(){return GZe&&this.pathsEnabled};Er.setImgSmoothing=function(t,e){t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled=e:(t.webkitImageSmoothingEnabled=e,t.mozImageSmoothingEnabled=e,t.msImageSmoothingEnabled=e)};Er.getImgSmoothing=function(t){return t.imageSmoothingEnabled!=null?t.imageSmoothingEnabled:t.webkitImageSmoothingEnabled||t.mozImageSmoothingEnabled||t.msImageSmoothingEnabled};Er.makeOffscreenCanvas=function(t,e){var r;if((typeof OffscreenCanvas>"u"?"undefined":Wi(OffscreenCanvas))!=="undefined")r=new OffscreenCanvas(t,e);else{var n=this.cy.window(),i=n.document;r=i.createElement("canvas"),r.width=t,r.height=e}return r};[Tge,Qc,th,bB,Yp,ly,ys,Dge,Pf,c4,Ige].forEach(function(t){rr(Er,t)});VZe=[{name:"null",impl:lge},{name:"base",impl:vge},{name:"canvas",impl:zZe}],UZe=[{type:"layout",extensions:SQe},{type:"renderer",extensions:VZe}],Pge={},Bge={};o(Fge,"setExtension");o($ge,"getExtension");o(HZe,"setModule");o(WZe,"getModule");QP=o(function(){if(arguments.length===2)return $ge.apply(null,arguments);if(arguments.length===3)return Fge.apply(null,arguments);if(arguments.length===4)return WZe.apply(null,arguments);if(arguments.length===5)return HZe.apply(null,arguments);ai("Invalid extension access syntax")},"extension");Jb.prototype.extension=QP;UZe.forEach(function(t){t.extensions.forEach(function(e){Fge(t.type,e.name,e.impl)})});zge=o(function t(){if(!(this instanceof t))return new t;this.length=0},"Stylesheet"),Wp=zge.prototype;Wp.instanceString=function(){return"stylesheet"};Wp.selector=function(t){var e=this.length++;return this[e]={selector:t,properties:[]},this};Wp.css=function(t,e){var r=this.length-1;if(Zt(t))this[r].properties.push({name:t,value:e});else if(Ur(t))for(var n=t,i=Object.keys(n),a=0;a{"use strict";o(function(e,r){typeof u4=="object"&&typeof EB=="object"?EB.exports=r():typeof define=="function"&&define.amd?define([],r):typeof u4=="object"?u4.layoutBase=r():e.layoutBase=r()},"webpackUniversalModuleDefinition")(u4,function(){return function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=26)}([function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n},function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s},function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n},function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(4);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp&&(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)),this.labelHeight>m&&(this.labelPos=="center"?this.rect.y-=(this.labelHeight-m)/2:this.labelPos=="top"&&(this.rect.y-=this.labelHeight-m),this.setHeight(this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h},function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n},function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(6),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,w=0;w-1&&E>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(T,1),x.target!=x.source&&x.target.edges.splice(E,1);var C=x.source.owner.getEdges().indexOf(x);if(C==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(C,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,w=this.getNodes(),C=w.length,T=0;Tv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(w[0].getParent().paddingLeft!=null?b=w[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,w,C,T,E,A,S=this.nodes,_=S.length,I=0;I<_;I++){var D=S[I];g&&D.child!=null&&D.updateBounds(),w=D.getLeft(),C=D.getRight(),T=D.getTop(),E=D.getBottom(),y>w&&(y=w),vT&&(x=T),bw&&(y=w),vT&&(x=T),b=this.nodes.length){var _=0;v.forEach(function(I){I.owner==g&&_++}),_==this.nodes.length&&(this.isConnected=!0)}},t.exports=p},function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(5),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=S,!1;if(hf)return l[0]=p,l[1]=h,l[2]=E,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,k=!0):(l[0]=g,l[1]=m,k=!0):R===M&&(u>f?(l[0]=p,l[1]=m,k=!0):(l[0]=x,l[1]=v,k=!0)),-O===M?f>u?(l[2]=A,l[3]=S,L=!0):(l[2]=E,l[3]=T,L=!0):O===M&&(f>u?(l[2]=C,l[3]=T,L=!0):(l[2]=_,l[3]=S,L=!0)),k&&L)return!1;if(u>f?h>d?(B=this.getCardinalDirection(R,M,4),F=this.getCardinalDirection(O,M,2)):(B=this.getCardinalDirection(-R,M,3),F=this.getCardinalDirection(-O,M,1)):h>d?(B=this.getCardinalDirection(-R,M,1),F=this.getCardinalDirection(-O,M,3)):(B=this.getCardinalDirection(R,M,2),F=this.getCardinalDirection(O,M,4)),!k)switch(B){case 1:z=m,P=u+-w/M,l[0]=P,l[1]=z;break;case 2:P=x,z=h+b*M,l[0]=P,l[1]=z;break;case 3:z=v,P=u+w/M,l[0]=P,l[1]=z;break;case 4:P=y,z=h+-b*M,l[0]=P,l[1]=z;break}if(!L)switch(F){case 1:H=T,$=f+-D/M,l[2]=$,l[3]=H;break;case 2:$=_,H=d+I*M,l[2]=$,l[3]=H;break;case 3:H=S,$=f+D/M,l[2]=$,l[3]=H;break;case 4:$=A,H=d+-I*M,l[2]=$,l[3]=H;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,w=void 0,C=void 0,T=void 0,E=void 0,A=void 0,S=void 0,_=void 0;return w=p-f,T=h-d,A=d*f-h*p,C=v-g,E=m-y,S=y*g-m*v,_=w*E-C*T,_===0?null:(x=(T*S-E*A)/_,b=(C*A-w*S)/_,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n},function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n},function(t,e,r){"use strict";var n=function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i},function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(w.push(T[0]);w.length>0&&g;){var E=w[0];w.splice(0,1),b.add(E);for(var A=E.getEdges(),x=0;x-1&&T.splice(D,1)}b=new Set,C=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(S,1);var _=C.getNeighborsList();_.forEach(function(k){if(y.indexOf(k)<0){var L=v.get(k),R=L-1;R==1&&E.push(k),v.set(k,R)}})}y=y.concat(E),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p},function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n},function(t,e,r){"use strict";var n=r(4);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i},function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mw||b>w)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(w=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>w||b>w)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||w>=x[0].length)){for(var C=0;Ch},"_defaultCompareFunction")}]),l}();t.exports=s},function(t,e,r){"use strict";var n=function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o(function(e,r){typeof h4=="object"&&typeof CB=="object"?CB.exports=r(SB()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof h4=="object"?h4.coseBase=r(SB()):e.coseBase=r(e.layoutBase)},"webpackUniversalModuleDefinition")(h4,function(t){return function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=7)}([function(e,r){e.exports=t},function(e,r,n){"use strict";var i=n(0).FDLayoutConstants;function a(){}o(a,"CoSEConstants");for(var s in i)a[s]=i[s];a.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,a.DEFAULT_RADIAL_SEPARATION=i.DEFAULT_EDGE_LENGTH,a.DEFAULT_COMPONENT_SEPERATION=60,a.TILE=!0,a.TILING_PADDING_VERTICAL=10,a.TILING_PADDING_HORIZONTAL=10,a.TREE_REDUCTION_ON_INCREMENTAL=!1,e.exports=a},function(e,r,n){"use strict";var i=n(0).FDLayoutEdge;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEEdge"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a},function(e,r,n){"use strict";var i=n(0).LGraph;function a(l,u,h){i.call(this,l,u,h)}o(a,"CoSEGraph"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a},function(e,r,n){"use strict";var i=n(0).LGraphManager;function a(l){i.call(this,l)}o(a,"CoSEGraphManager"),a.prototype=Object.create(i.prototype);for(var s in i)a[s]=i[s];e.exports=a},function(e,r,n){"use strict";var i=n(0).FDLayoutNode,a=n(0).IMath;function s(u,h,f,d){i.call(this,u,h,f,d)}o(s,"CoSENode"),s.prototype=Object.create(i.prototype);for(var l in i)s[l]=i[l];s.prototype.move=function(){var u=this.graphManager.getLayout();this.displacementX=u.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY=u.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren,Math.abs(this.displacementX)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementX=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementX)),Math.abs(this.displacementY)>u.coolingFactor*u.maxNodeDisplacement&&(this.displacementY=u.coolingFactor*u.maxNodeDisplacement*a.sign(this.displacementY)),this.child==null?this.moveBy(this.displacementX,this.displacementY):this.child.getNodes().length==0?this.moveBy(this.displacementX,this.displacementY):this.propogateDisplacementToChildren(this.displacementX,this.displacementY),u.totalDisplacement+=Math.abs(this.displacementX)+Math.abs(this.displacementY),this.springForceX=0,this.springForceY=0,this.repulsionForceX=0,this.repulsionForceY=0,this.gravitationForceX=0,this.gravitationForceY=0,this.displacementX=0,this.displacementY=0},s.prototype.propogateDisplacementToChildren=function(u,h){for(var f=this.getChild().getNodes(),d,p=0;p0)this.positionNodesRadially(T);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var E=new Set(this.getAllNodes()),A=this.nodesWithGravity.filter(function(S){return E.has(S)});this.graphManager.setAllNodesToApplyGravitation(A),this.positionNodesRandomly()}}return this.initSpringEmbedder(),this.runSpringEmbedder(),!0},w.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%f.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var T=new Set(this.getAllNodes()),E=this.nodesWithGravity.filter(function(_){return T.has(_)});this.graphManager.setAllNodesToApplyGravitation(E),this.graphManager.updateBounds(),this.updateGrid(),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),this.coolingFactor=f.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var A=!this.isTreeGrowing&&!this.isGrowthFinished,S=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(A,S),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},w.prototype.getPositionsData=function(){for(var T=this.graphManager.getAllNodes(),E={},A=0;A1){var k;for(k=0;kS&&(S=Math.floor(D.y)),I=Math.floor(D.x+h.DEFAULT_COMPONENT_SEPERATION)}this.transform(new m(d.WORLD_CENTER_X-D.x/2,d.WORLD_CENTER_Y-D.y/2))},w.radialLayout=function(T,E,A){var S=Math.max(this.maxDiagonalInTree(T),h.DEFAULT_RADIAL_SEPARATION);w.branchRadialLayout(E,null,0,359,0,S);var _=x.calculateBounds(T),I=new b;I.setDeviceOrgX(_.getMinX()),I.setDeviceOrgY(_.getMinY()),I.setWorldOrgX(A.x),I.setWorldOrgY(A.y);for(var D=0;D1;){var Q=H[0];H.splice(0,1);var j=B.indexOf(Q);j>=0&&B.splice(j,1),z--,F--}E!=null?$=(B.indexOf(H[0])+1)%z:$=0;for(var ie=Math.abs(S-A)/F,ne=$;P!=F;ne=++ne%z){var le=B[ne].getOtherEnd(T);if(le!=E){var he=(A+P*ie)%360,K=(he+ie)%360;w.branchRadialLayout(le,T,he,K,_+I,I),P++}}},w.maxDiagonalInTree=function(T){for(var E=y.MIN_VALUE,A=0;AE&&(E=_)}return E},w.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},w.prototype.groupZeroDegreeMembers=function(){var T=this,E={};this.memberGroups={},this.idToDummyNode={};for(var A=[],S=this.graphManager.getAllNodes(),_=0;_"u"&&(E[k]=[]),E[k]=E[k].concat(I)}Object.keys(E).forEach(function(L){if(E[L].length>1){var R="DummyCompound_"+L;T.memberGroups[R]=E[L];var O=E[L][0].getParent(),M=new l(T.graphManager);M.id=R,M.paddingLeft=O.paddingLeft||0,M.paddingRight=O.paddingRight||0,M.paddingBottom=O.paddingBottom||0,M.paddingTop=O.paddingTop||0,T.idToDummyNode[R]=M;var B=T.getGraphManager().add(T.newGraph(),M),F=O.getChild();F.add(M);for(var P=0;P=0;T--){var E=this.compoundOrder[T],A=E.id,S=E.paddingLeft,_=E.paddingTop;this.adjustLocations(this.tiledMemberPack[A],E.rect.x,E.rect.y,S,_)}},w.prototype.repopulateZeroDegreeMembers=function(){var T=this,E=this.tiledZeroDegreePack;Object.keys(E).forEach(function(A){var S=T.idToDummyNode[A],_=S.paddingLeft,I=S.paddingTop;T.adjustLocations(E[A],S.rect.x,S.rect.y,_,I)})},w.prototype.getToBeTiled=function(T){var E=T.id;if(this.toBeTiled[E]!=null)return this.toBeTiled[E];var A=T.getChild();if(A==null)return this.toBeTiled[E]=!1,!1;for(var S=A.getNodes(),_=0;_0)return this.toBeTiled[E]=!1,!1;if(I.getChild()==null){this.toBeTiled[I.id]=!1;continue}if(!this.getToBeTiled(I))return this.toBeTiled[E]=!1,!1}return this.toBeTiled[E]=!0,!0},w.prototype.getNodeDegree=function(T){for(var E=T.id,A=T.getEdges(),S=0,_=0;_L&&(L=O.rect.height)}A+=L+T.verticalPadding}},w.prototype.tileCompoundMembers=function(T,E){var A=this;this.tiledMemberPack=[],Object.keys(T).forEach(function(S){var _=E[S];A.tiledMemberPack[S]=A.tileNodes(T[S],_.paddingLeft+_.paddingRight),_.rect.width=A.tiledMemberPack[S].width,_.rect.height=A.tiledMemberPack[S].height})},w.prototype.tileNodes=function(T,E){var A=h.TILING_PADDING_VERTICAL,S=h.TILING_PADDING_HORIZONTAL,_={rows:[],rowWidth:[],rowHeight:[],width:0,height:E,verticalPadding:A,horizontalPadding:S};T.sort(function(k,L){return k.rect.width*k.rect.height>L.rect.width*L.rect.height?-1:k.rect.width*k.rect.height0&&(D+=T.horizontalPadding),T.rowWidth[A]=D,T.width0&&(k+=T.verticalPadding);var L=0;k>T.rowHeight[A]&&(L=T.rowHeight[A],T.rowHeight[A]=k,L=T.rowHeight[A]-L),T.height+=L,T.rows[A].push(E)},w.prototype.getShortestRowIndex=function(T){for(var E=-1,A=Number.MAX_VALUE,S=0;SA&&(E=S,A=T.rowWidth[S]);return E},w.prototype.canAddHorizontal=function(T,E,A){var S=this.getShortestRowIndex(T);if(S<0)return!0;var _=T.rowWidth[S];if(_+T.horizontalPadding+E<=T.width)return!0;var I=0;T.rowHeight[S]0&&(I=A+T.verticalPadding-T.rowHeight[S]);var D;T.width-_>=E+T.horizontalPadding?D=(T.height+I)/(_+E+T.horizontalPadding):D=(T.height+I)/T.width,I=A+T.verticalPadding;var k;return T.widthI&&E!=A){S.splice(-1,1),T.rows[A].push(_),T.rowWidth[E]=T.rowWidth[E]-I,T.rowWidth[A]=T.rowWidth[A]+I,T.width=T.rowWidth[instance.getLongestRowIndex(T)];for(var D=Number.MIN_VALUE,k=0;kD&&(D=S[k].height);E>0&&(D+=T.verticalPadding);var L=T.rowHeight[E]+T.rowHeight[A];T.rowHeight[E]=D,T.rowHeight[A]<_.height+T.verticalPadding&&(T.rowHeight[A]=_.height+T.verticalPadding);var R=T.rowHeight[E]+T.rowHeight[A];T.height+=R-L,this.shiftToLastRow(T)}},w.prototype.tilingPreLayout=function(){h.TILE&&(this.groupZeroDegreeMembers(),this.clearCompounds(),this.clearZeroDegreeMembers())},w.prototype.tilingPostLayout=function(){h.TILE&&(this.repopulateZeroDegreeMembers(),this.repopulateCompounds())},w.prototype.reduceTrees=function(){for(var T=[],E=!0,A;E;){var S=this.graphManager.getAllNodes(),_=[];E=!1;for(var I=0;I0)for(var F=_;F<=I;F++)B[0]+=this.grid[F][D-1].length+this.grid[F][D].length-1;if(I0)for(var F=D;F<=k;F++)B[3]+=this.grid[_-1][F].length+this.grid[_][F].length-1;for(var P=y.MAX_VALUE,z,$,H=0;H{"use strict";o(function(e,r){typeof f4=="object"&&typeof _B=="object"?_B.exports=r(AB()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof f4=="object"?f4.cytoscapeCoseBilkent=r(AB()):e.cytoscapeCoseBilkent=r(e.coseBase)},"webpackUniversalModuleDefinition")(f4,function(t){return function(e){var r={};function n(i){if(r[i])return r[i].exports;var a=r[i]={i,l:!1,exports:{}};return e[i].call(a.exports,a,a.exports,n),a.l=!0,a.exports}return o(n,"__webpack_require__"),n.m=e,n.c=r,n.i=function(i){return i},n.d=function(i,a,s){n.o(i,a)||Object.defineProperty(i,a,{configurable:!1,enumerable:!0,get:s})},n.n=function(i){var a=i&&i.__esModule?o(function(){return i.default},"getDefault"):o(function(){return i},"getModuleExports");return n.d(a,"a",a),a},n.o=function(i,a){return Object.prototype.hasOwnProperty.call(i,a)},n.p="",n(n.s=1)}([function(e,r){e.exports=t},function(e,r,n){"use strict";var i=n(0).layoutBase.LayoutConstants,a=n(0).layoutBase.FDLayoutConstants,s=n(0).CoSEConstants,l=n(0).CoSELayout,u=n(0).CoSENode,h=n(0).layoutBase.PointD,f=n(0).layoutBase.DimensionD,d={ready:o(function(){},"ready"),stop:o(function(){},"stop"),quality:"default",nodeDimensionsIncludeLabels:!1,refresh:30,fit:!0,padding:10,randomize:!0,nodeRepulsion:4500,idealEdgeLength:50,edgeElasticity:.45,nestingFactor:.1,gravity:.25,numIter:2500,tile:!0,animate:"end",animationDuration:500,tilingPaddingVertical:10,tilingPaddingHorizontal:10,gravityRangeCompound:1.5,gravityCompound:1,gravityRange:3.8,initialEnergyOnIncremental:.5};function p(v,x){var b={};for(var w in v)b[w]=v[w];for(var w in x)b[w]=x[w];return b}o(p,"extend");function m(v){this.options=p(d,v),g(this.options)}o(m,"_CoSELayout");var g=o(function(x){x.nodeRepulsion!=null&&(s.DEFAULT_REPULSION_STRENGTH=a.DEFAULT_REPULSION_STRENGTH=x.nodeRepulsion),x.idealEdgeLength!=null&&(s.DEFAULT_EDGE_LENGTH=a.DEFAULT_EDGE_LENGTH=x.idealEdgeLength),x.edgeElasticity!=null&&(s.DEFAULT_SPRING_STRENGTH=a.DEFAULT_SPRING_STRENGTH=x.edgeElasticity),x.nestingFactor!=null&&(s.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=a.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=x.nestingFactor),x.gravity!=null&&(s.DEFAULT_GRAVITY_STRENGTH=a.DEFAULT_GRAVITY_STRENGTH=x.gravity),x.numIter!=null&&(s.MAX_ITERATIONS=a.MAX_ITERATIONS=x.numIter),x.gravityRange!=null&&(s.DEFAULT_GRAVITY_RANGE_FACTOR=a.DEFAULT_GRAVITY_RANGE_FACTOR=x.gravityRange),x.gravityCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_STRENGTH=a.DEFAULT_COMPOUND_GRAVITY_STRENGTH=x.gravityCompound),x.gravityRangeCompound!=null&&(s.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=a.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=x.gravityRangeCompound),x.initialEnergyOnIncremental!=null&&(s.DEFAULT_COOLING_FACTOR_INCREMENTAL=a.DEFAULT_COOLING_FACTOR_INCREMENTAL=x.initialEnergyOnIncremental),x.quality=="draft"?i.QUALITY=0:x.quality=="proof"?i.QUALITY=2:i.QUALITY=1,s.NODE_DIMENSIONS_INCLUDE_LABELS=a.NODE_DIMENSIONS_INCLUDE_LABELS=i.NODE_DIMENSIONS_INCLUDE_LABELS=x.nodeDimensionsIncludeLabels,s.DEFAULT_INCREMENTAL=a.DEFAULT_INCREMENTAL=i.DEFAULT_INCREMENTAL=!x.randomize,s.ANIMATE=a.ANIMATE=i.ANIMATE=x.animate,s.TILE=x.tile,s.TILING_PADDING_VERTICAL=typeof x.tilingPaddingVertical=="function"?x.tilingPaddingVertical.call():x.tilingPaddingVertical,s.TILING_PADDING_HORIZONTAL=typeof x.tilingPaddingHorizontal=="function"?x.tilingPaddingHorizontal.call():x.tilingPaddingHorizontal},"getUserOptions");m.prototype.run=function(){var v,x,b=this.options,w=this.idToLNode={},C=this.layout=new l,T=this;T.stopped=!1,this.cy=this.options.cy,this.cy.trigger({type:"layoutstart",layout:this});var E=C.newGraphManager();this.gm=E;var A=this.options.eles.nodes(),S=this.options.eles.edges();this.root=E.addRoot(),this.processChildrenList(this.root,this.getTopMostNodes(A),C);for(var _=0;_0){var k;k=b.getGraphManager().add(b.newGraph(),A),this.processChildrenList(k,E,b)}}},m.prototype.stop=function(){return this.stopped=!0,this};var y=o(function(x){x("layout","cose-bilkent",m)},"register");typeof cytoscape<"u"&&y(cytoscape),e.exports=y}])})});function JZe(t,e,r,n,i){return t.insert("polygon",":first-child").attr("points",n.map(function(a){return a.x+","+a.y}).join(" ")).attr("transform","translate("+(i.width-e)/2+", "+r+")")}var YZe,XZe,jZe,KZe,QZe,ZZe,eJe,tJe,Vge,Uge,Hge=N(()=>{"use strict";to();ir();YZe=12,XZe=o(function(t,e,r,n){e.append("path").attr("id","node-"+r.id).attr("class","node-bkg node-"+t.type2Str(r.type)).attr("d",`M0 ${r.height-5} v${-r.height+2*5} q0,-5 5,-5 h${r.width-2*5} q5,0 5,5 v${r.height-5} H0 Z`),e.append("line").attr("class","node-line-"+n).attr("x1",0).attr("y1",r.height).attr("x2",r.width).attr("y2",r.height)},"defaultBkg"),jZe=o(function(t,e,r){e.append("rect").attr("id","node-"+r.id).attr("class","node-bkg node-"+t.type2Str(r.type)).attr("height",r.height).attr("width",r.width)},"rectBkg"),KZe=o(function(t,e,r){let n=r.width,i=r.height,a=.15*n,s=.25*n,l=.35*n,u=.2*n;e.append("path").attr("id","node-"+r.id).attr("class","node-bkg node-"+t.type2Str(r.type)).attr("d",`M0 0 a${a},${a} 0 0,1 ${n*.25},${-1*n*.1} + a${l},${l} 1 0,1 ${n*.4},${-1*n*.1} + a${s},${s} 1 0,1 ${n*.35},${1*n*.2} + + a${a},${a} 1 0,1 ${n*.15},${1*i*.35} + a${u},${u} 1 0,1 ${-1*n*.15},${1*i*.65} + + a${s},${a} 1 0,1 ${-1*n*.25},${n*.15} + a${l},${l} 1 0,1 ${-1*n*.5},0 + a${a},${a} 1 0,1 ${-1*n*.25},${-1*n*.15} + + a${a},${a} 1 0,1 ${-1*n*.1},${-1*i*.35} + a${u},${u} 1 0,1 ${n*.1},${-1*i*.65} + + H0 V0 Z`)},"cloudBkg"),QZe=o(function(t,e,r){let n=r.width,i=r.height,a=.15*n;e.append("path").attr("id","node-"+r.id).attr("class","node-bkg node-"+t.type2Str(r.type)).attr("d",`M0 0 a${a},${a} 1 0,0 ${n*.25},${-1*i*.1} + a${a},${a} 1 0,0 ${n*.25},0 + a${a},${a} 1 0,0 ${n*.25},0 + a${a},${a} 1 0,0 ${n*.25},${1*i*.1} + + a${a},${a} 1 0,0 ${n*.15},${1*i*.33} + a${a*.8},${a*.8} 1 0,0 0,${1*i*.34} + a${a},${a} 1 0,0 ${-1*n*.15},${1*i*.33} + + a${a},${a} 1 0,0 ${-1*n*.25},${i*.15} + a${a},${a} 1 0,0 ${-1*n*.25},0 + a${a},${a} 1 0,0 ${-1*n*.25},0 + a${a},${a} 1 0,0 ${-1*n*.25},${-1*i*.15} + + a${a},${a} 1 0,0 ${-1*n*.1},${-1*i*.33} + a${a*.8},${a*.8} 1 0,0 0,${-1*i*.34} + a${a},${a} 1 0,0 ${n*.1},${-1*i*.33} + + H0 V0 Z`)},"bangBkg"),ZZe=o(function(t,e,r){e.append("circle").attr("id","node-"+r.id).attr("class","node-bkg node-"+t.type2Str(r.type)).attr("r",r.width/2)},"circleBkg");o(JZe,"insertPolygonShape");eJe=o(function(t,e,r){let n=r.height,a=n/4,s=r.width-r.padding+2*a,l=[{x:a,y:0},{x:s-a,y:0},{x:s,y:-n/2},{x:s-a,y:-n},{x:a,y:-n},{x:0,y:-n/2}];JZe(e,s,n,l,r)},"hexagonBkg"),tJe=o(function(t,e,r){e.append("rect").attr("id","node-"+r.id).attr("class","node-bkg node-"+t.type2Str(r.type)).attr("height",r.height).attr("rx",r.padding).attr("ry",r.padding).attr("width",r.width)},"roundedRectBkg"),Vge=o(async function(t,e,r,n,i){let a=i.htmlLabels,s=n%(YZe-1),l=e.append("g");r.section=s;let u="section-"+s;s<0&&(u+=" section-root"),l.attr("class",(r.class?r.class+" ":"")+"mindmap-node "+u);let h=l.append("g"),f=l.append("g"),d=r.descr.replace(/()/g,` +`);await Hn(f,d,{useHtmlLabels:a,width:r.width,classes:"mindmap-node-label"},i),a||f.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle");let p=f.node().getBBox(),[m]=Bo(i.fontSize);if(r.height=p.height+m*1.1*.5+r.padding,r.width=p.width+2*r.padding,r.icon)if(r.type===t.nodeType.CIRCLE)r.height+=50,r.width+=50,l.append("foreignObject").attr("height","50px").attr("width",r.width).attr("style","text-align: center;").append("div").attr("class","icon-container").append("i").attr("class","node-icon-"+s+" "+r.icon),f.attr("transform","translate("+r.width/2+", "+(r.height/2-1.5*r.padding)+")");else{r.width+=50;let g=r.height;r.height=Math.max(g,60);let y=Math.abs(r.height-g);l.append("foreignObject").attr("width","60px").attr("height",r.height).attr("style","text-align: center;margin-top:"+y/2+"px;").append("div").attr("class","icon-container").append("i").attr("class","node-icon-"+s+" "+r.icon),f.attr("transform","translate("+(25+r.width/2)+", "+(y/2+r.padding/2)+")")}else if(a){let g=(r.width-p.width)/2,y=(r.height-p.height)/2;f.attr("transform","translate("+g+", "+y+")")}else{let g=r.width/2,y=r.padding/2;f.attr("transform","translate("+g+", "+y+")")}switch(r.type){case t.nodeType.DEFAULT:XZe(t,h,r,s);break;case t.nodeType.ROUNDED_RECT:tJe(t,h,r,s);break;case t.nodeType.RECT:jZe(t,h,r,s);break;case t.nodeType.CIRCLE:h.attr("transform","translate("+r.width/2+", "+ +r.height/2+")"),ZZe(t,h,r,s);break;case t.nodeType.CLOUD:KZe(t,h,r,s);break;case t.nodeType.BANG:QZe(t,h,r,s);break;case t.nodeType.HEXAGON:eJe(t,h,r,s);break}return t.setElementForId(r.id,l),r.height},"drawNode"),Uge=o(function(t,e){let r=t.getElementById(e.id),n=e.x||0,i=e.y||0;r.attr("transform","translate("+n+","+i+")")},"positionNode")});async function qge(t,e,r,n,i){await Vge(t,e,r,n,i),r.children&&await Promise.all(r.children.map((a,s)=>qge(t,e,a,n<0?s:n,i)))}function rJe(t,e){e.edges().map((r,n)=>{let i=r.data();if(r[0]._private.bodyBounds){let a=r[0]._private.rscratch;Y.trace("Edge: ",n,i),t.insert("path").attr("d",`M ${a.startX},${a.startY} L ${a.midX},${a.midY} L${a.endX},${a.endY} `).attr("class","edge section-edge-"+i.section+" edge-depth-"+i.depth)}})}function Yge(t,e,r,n){e.add({group:"nodes",data:{id:t.id.toString(),labelText:t.descr,height:t.height,width:t.width,level:n,nodeId:t.id,padding:t.padding,type:t.type},position:{x:t.x,y:t.y}}),t.children&&t.children.forEach(i=>{Yge(i,e,r,n+1),e.add({group:"edges",data:{id:`${t.id}_${i.id}`,source:t.id,target:i.id,depth:n,section:i.section}})})}function nJe(t,e){return new Promise(r=>{let n=Ge("body").append("div").attr("id","cy").attr("style","display:none"),i=rl({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"bezier"}}]});n.remove(),Yge(t,i,e,0),i.nodes().forEach(function(a){a.layoutDimensions=()=>{let s=a.data();return{w:s.width,h:s.height}}}),i.layout({name:"cose-bilkent",quality:"proof",styleEnabled:!1,animate:!1}).run(),i.ready(a=>{Y.info("Ready",a),r(i)})})}function iJe(t,e){e.nodes().map((r,n)=>{let i=r.data();i.x=r.position().x,i.y=r.position().y,Uge(t,i);let a=t.getElementById(i.nodeId);Y.info("Id:",n,"Position: (",r.position().x,", ",r.position().y,")",i),a.attr("transform",`translate(${r.position().x-i.width/2}, ${r.position().y-i.height/2})`),a.attr("attr",`apa-${n})`)})}var Wge,aJe,Xge,jge=N(()=>{"use strict";kB();Wge=Sa(Gge(),1);dr();zt();vt();Vc();Ei();Hge();Ya();rl.use(Wge.default);o(qge,"drawNodes");o(rJe,"drawEdges");o(Yge,"addNodes");o(nJe,"layoutMindmap");o(iJe,"positionNodes");aJe=o(async(t,e,r,n)=>{Y.debug(`Rendering mindmap diagram +`+t);let i=n.db,a=i.getMindmap();if(!a)return;let s=me();s.htmlLabels=!1;let l=sa(e),u=l.append("g");u.attr("class","mindmap-edges");let h=l.append("g");h.attr("class","mindmap-nodes"),await qge(i,h,a,-1,s);let f=await nJe(a,s);rJe(u,f),iJe(i,f),Ao(void 0,l,s.mindmap?.padding??or.mindmap.padding,s.mindmap?.useMaxWidth??or.mindmap.useMaxWidth)},"draw"),Xge={draw:aJe}});var sJe,oJe,Kge,Qge=N(()=>{"use strict";Ys();sJe=o(t=>{let e="";for(let r=0;r` + .edge { + stroke-width: 3; + } + ${sJe(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .mindmap-node-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } +`,"getStyles"),Kge=oJe});var Zge={};hr(Zge,{diagram:()=>lJe});var lJe,Jge=N(()=>{"use strict";Spe();_pe();jge();Qge();lJe={db:Ape,renderer:Xge,parser:Epe,styles:Kge}});var DB,r1e,n1e=N(()=>{"use strict";DB=function(){var t=o(function(A,S,_,I){for(_=_||{},I=A.length;I--;_[A[I]]=S);return _},"o"),e=[1,4],r=[1,13],n=[1,12],i=[1,15],a=[1,16],s=[1,20],l=[1,19],u=[6,7,8],h=[1,26],f=[1,24],d=[1,25],p=[6,7,11],m=[1,31],g=[6,7,11,24],y=[1,6,13,16,17,20,23],v=[1,35],x=[1,36],b=[1,6,7,11,13,16,17,20,23],w=[1,38],C={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,KANBAN:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,shapeData:15,ICON:16,CLASS:17,nodeWithId:18,nodeWithoutId:19,NODE_DSTART:20,NODE_DESCR:21,NODE_DEND:22,NODE_ID:23,SHAPE_DATA:24,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"KANBAN",11:"EOF",13:"SPACELIST",16:"ICON",17:"CLASS",20:"NODE_DSTART",21:"NODE_DESCR",22:"NODE_DEND",23:"NODE_ID",24:"SHAPE_DATA"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,3],[12,2],[12,2],[12,2],[12,1],[12,2],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[19,3],[18,1],[18,4],[15,2],[15,1]],performAction:o(function(S,_,I,D,k,L,R){var O=L.length-1;switch(k){case 6:case 7:return D;case 8:D.getLogger().trace("Stop NL ");break;case 9:D.getLogger().trace("Stop EOF ");break;case 11:D.getLogger().trace("Stop NL2 ");break;case 12:D.getLogger().trace("Stop EOF2 ");break;case 15:D.getLogger().info("Node: ",L[O-1].id),D.addNode(L[O-2].length,L[O-1].id,L[O-1].descr,L[O-1].type,L[O]);break;case 16:D.getLogger().info("Node: ",L[O].id),D.addNode(L[O-1].length,L[O].id,L[O].descr,L[O].type);break;case 17:D.getLogger().trace("Icon: ",L[O]),D.decorateNode({icon:L[O]});break;case 18:case 23:D.decorateNode({class:L[O]});break;case 19:D.getLogger().trace("SPACELIST");break;case 20:D.getLogger().trace("Node: ",L[O-1].id),D.addNode(0,L[O-1].id,L[O-1].descr,L[O-1].type,L[O]);break;case 21:D.getLogger().trace("Node: ",L[O].id),D.addNode(0,L[O].id,L[O].descr,L[O].type);break;case 22:D.decorateNode({icon:L[O]});break;case 27:D.getLogger().trace("node found ..",L[O-2]),this.$={id:L[O-1],descr:L[O-1],type:D.getType(L[O-2],L[O])};break;case 28:this.$={id:L[O],descr:L[O],type:0};break;case 29:D.getLogger().trace("node found ..",L[O-3]),this.$={id:L[O-3],descr:L[O-1],type:D.getType(L[O-2],L[O])};break;case 30:this.$=L[O-1]+L[O];break;case 31:this.$=L[O];break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:r,7:[1,10],9:9,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:r,9:22,12:11,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},{6:h,7:f,10:23,11:d},t(p,[2,24],{18:17,19:18,14:27,16:[1,28],17:[1,29],20:s,23:l}),t(p,[2,19]),t(p,[2,21],{15:30,24:m}),t(p,[2,22]),t(p,[2,23]),t(g,[2,25]),t(g,[2,26]),t(g,[2,28],{20:[1,32]}),{21:[1,33]},{6:h,7:f,10:34,11:d},{1:[2,7],6:r,12:21,13:n,14:14,16:i,17:a,18:17,19:18,20:s,23:l},t(y,[2,14],{7:v,11:x}),t(b,[2,8]),t(b,[2,9]),t(b,[2,10]),t(p,[2,16],{15:37,24:m}),t(p,[2,17]),t(p,[2,18]),t(p,[2,20],{24:w}),t(g,[2,31]),{21:[1,39]},{22:[1,40]},t(y,[2,13],{7:v,11:x}),t(b,[2,11]),t(b,[2,12]),t(p,[2,15],{24:w}),t(g,[2,30]),{22:[1,41]},t(g,[2,27]),t(g,[2,29])],defaultActions:{2:[2,1],6:[2,2]},parseError:o(function(S,_){if(_.recoverable)this.trace(S);else{var I=new Error(S);throw I.hash=_,I}},"parseError"),parse:o(function(S){var _=this,I=[0],D=[],k=[null],L=[],R=this.table,O="",M=0,B=0,F=0,P=2,z=1,$=L.slice.call(arguments,1),H=Object.create(this.lexer),Q={yy:{}};for(var j in this.yy)Object.prototype.hasOwnProperty.call(this.yy,j)&&(Q.yy[j]=this.yy[j]);H.setInput(S,Q.yy),Q.yy.lexer=H,Q.yy.parser=this,typeof H.yylloc>"u"&&(H.yylloc={});var ie=H.yylloc;L.push(ie);var ne=H.options&&H.options.ranges;typeof Q.yy.parseError=="function"?this.parseError=Q.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function le(ze){I.length=I.length-2*ze,k.length=k.length-ze,L.length=L.length-ze}o(le,"popStack");function he(){var ze;return ze=D.pop()||H.lex()||z,typeof ze!="number"&&(ze instanceof Array&&(D=ze,ze=D.pop()),ze=_.symbols_[ze]||ze),ze}o(he,"lex");for(var K,X,te,J,se,ue,Z={},Se,ce,ae,Oe;;){if(te=I[I.length-1],this.defaultActions[te]?J=this.defaultActions[te]:((K===null||typeof K>"u")&&(K=he()),J=R[te]&&R[te][K]),typeof J>"u"||!J.length||!J[0]){var ge="";Oe=[];for(Se in R[te])this.terminals_[Se]&&Se>P&&Oe.push("'"+this.terminals_[Se]+"'");H.showPosition?ge="Parse error on line "+(M+1)+`: +`+H.showPosition()+` +Expecting `+Oe.join(", ")+", got '"+(this.terminals_[K]||K)+"'":ge="Parse error on line "+(M+1)+": Unexpected "+(K==z?"end of input":"'"+(this.terminals_[K]||K)+"'"),this.parseError(ge,{text:H.match,token:this.terminals_[K]||K,line:H.yylineno,loc:ie,expected:Oe})}if(J[0]instanceof Array&&J.length>1)throw new Error("Parse Error: multiple actions possible at state: "+te+", token: "+K);switch(J[0]){case 1:I.push(K),k.push(H.yytext),L.push(H.yylloc),I.push(J[1]),K=null,X?(K=X,X=null):(B=H.yyleng,O=H.yytext,M=H.yylineno,ie=H.yylloc,F>0&&F--);break;case 2:if(ce=this.productions_[J[1]][1],Z.$=k[k.length-ce],Z._$={first_line:L[L.length-(ce||1)].first_line,last_line:L[L.length-1].last_line,first_column:L[L.length-(ce||1)].first_column,last_column:L[L.length-1].last_column},ne&&(Z._$.range=[L[L.length-(ce||1)].range[0],L[L.length-1].range[1]]),ue=this.performAction.apply(Z,[O,B,M,Q.yy,J[1],k,L].concat($)),typeof ue<"u")return ue;ce&&(I=I.slice(0,-1*ce*2),k=k.slice(0,-1*ce),L=L.slice(0,-1*ce)),I.push(this.productions_[J[1]][0]),k.push(Z.$),L.push(Z._$),ae=R[I[I.length-2]][I[I.length-1]],I.push(ae);break;case 3:return!0}}return!0},"parse")},T=function(){var A={EOF:1,parseError:o(function(_,I){if(this.yy.parser)this.yy.parser.parseError(_,I);else throw new Error(_)},"parseError"),setInput:o(function(S,_){return this.yy=_||this.yy||{},this._input=S,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var S=this._input[0];this.yytext+=S,this.yyleng++,this.offset++,this.match+=S,this.matched+=S;var _=S.match(/(?:\r\n?|\n).*/g);return _?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),S},"input"),unput:o(function(S){var _=S.length,I=S.split(/(?:\r\n?|\n)/g);this._input=S+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-_),this.offset-=_;var D=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),I.length-1&&(this.yylineno-=I.length-1);var k=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:I?(I.length===D.length?this.yylloc.first_column:0)+D[D.length-I.length].length-I[0].length:this.yylloc.first_column-_},this.options.ranges&&(this.yylloc.range=[k[0],k[0]+this.yyleng-_]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(S){this.unput(this.match.slice(S))},"less"),pastInput:o(function(){var S=this.matched.substr(0,this.matched.length-this.match.length);return(S.length>20?"...":"")+S.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var S=this.match;return S.length<20&&(S+=this._input.substr(0,20-S.length)),(S.substr(0,20)+(S.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var S=this.pastInput(),_=new Array(S.length+1).join("-");return S+this.upcomingInput()+` +`+_+"^"},"showPosition"),test_match:o(function(S,_){var I,D,k;if(this.options.backtrack_lexer&&(k={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(k.yylloc.range=this.yylloc.range.slice(0))),D=S[0].match(/(?:\r\n?|\n).*/g),D&&(this.yylineno+=D.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:D?D[D.length-1].length-D[D.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+S[0].length},this.yytext+=S[0],this.match+=S[0],this.matches=S,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(S[0].length),this.matched+=S[0],I=this.performAction.call(this,this.yy,this,_,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),I)return I;if(this._backtrack){for(var L in k)this[L]=k[L];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var S,_,I,D;this._more||(this.yytext="",this.match="");for(var k=this._currentRules(),L=0;L_[0].length)){if(_=I,D=L,this.options.backtrack_lexer){if(S=this.test_match(I,k[L]),S!==!1)return S;if(this._backtrack){_=!1;continue}else return!1}else if(!this.options.flex)break}return _?(S=this.test_match(_,k[D]),S!==!1?S:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var _=this.next();return _||this.lex()},"lex"),begin:o(function(_){this.conditionStack.push(_)},"begin"),popState:o(function(){var _=this.conditionStack.length-1;return _>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(_){return _=this.conditionStack.length-1-Math.abs(_||0),_>=0?this.conditionStack[_]:"INITIAL"},"topState"),pushState:o(function(_){this.begin(_)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(_,I,D,k){var L=k;switch(D){case 0:return this.pushState("shapeData"),I.yytext="",24;break;case 1:return this.pushState("shapeDataStr"),24;break;case 2:return this.popState(),24;break;case 3:let R=/\n\s*/g;return I.yytext=I.yytext.replace(R,"
    "),24;break;case 4:return 24;case 5:this.popState();break;case 6:return _.getLogger().trace("Found comment",I.yytext),6;break;case 7:return 8;case 8:this.begin("CLASS");break;case 9:return this.popState(),17;break;case 10:this.popState();break;case 11:_.getLogger().trace("Begin icon"),this.begin("ICON");break;case 12:return _.getLogger().trace("SPACELINE"),6;break;case 13:return 7;case 14:return 16;case 15:_.getLogger().trace("end icon"),this.popState();break;case 16:return _.getLogger().trace("Exploding node"),this.begin("NODE"),20;break;case 17:return _.getLogger().trace("Cloud"),this.begin("NODE"),20;break;case 18:return _.getLogger().trace("Explosion Bang"),this.begin("NODE"),20;break;case 19:return _.getLogger().trace("Cloud Bang"),this.begin("NODE"),20;break;case 20:return this.begin("NODE"),20;break;case 21:return this.begin("NODE"),20;break;case 22:return this.begin("NODE"),20;break;case 23:return this.begin("NODE"),20;break;case 24:return 13;case 25:return 23;case 26:return 11;case 27:this.begin("NSTR2");break;case 28:return"NODE_DESCR";case 29:this.popState();break;case 30:_.getLogger().trace("Starting NSTR"),this.begin("NSTR");break;case 31:return _.getLogger().trace("description:",I.yytext),"NODE_DESCR";break;case 32:this.popState();break;case 33:return this.popState(),_.getLogger().trace("node end ))"),"NODE_DEND";break;case 34:return this.popState(),_.getLogger().trace("node end )"),"NODE_DEND";break;case 35:return this.popState(),_.getLogger().trace("node end ...",I.yytext),"NODE_DEND";break;case 36:return this.popState(),_.getLogger().trace("node end (("),"NODE_DEND";break;case 37:return this.popState(),_.getLogger().trace("node end (-"),"NODE_DEND";break;case 38:return this.popState(),_.getLogger().trace("node end (-"),"NODE_DEND";break;case 39:return this.popState(),_.getLogger().trace("node end (("),"NODE_DEND";break;case 40:return this.popState(),_.getLogger().trace("node end (("),"NODE_DEND";break;case 41:return _.getLogger().trace("Long description:",I.yytext),21;break;case 42:return _.getLogger().trace("Long description:",I.yytext),21;break}},"anonymous"),rules:[/^(?:@\{)/i,/^(?:["])/i,/^(?:["])/i,/^(?:[^\"]+)/i,/^(?:[^}^"]+)/i,/^(?:\})/i,/^(?:\s*%%.*)/i,/^(?:kanban\b)/i,/^(?::::)/i,/^(?:.+)/i,/^(?:\n)/i,/^(?:::icon\()/i,/^(?:[\s]+[\n])/i,/^(?:[\n]+)/i,/^(?:[^\)]+)/i,/^(?:\))/i,/^(?:-\))/i,/^(?:\(-)/i,/^(?:\)\))/i,/^(?:\))/i,/^(?:\(\()/i,/^(?:\{\{)/i,/^(?:\()/i,/^(?:\[)/i,/^(?:[\s]+)/i,/^(?:[^\(\[\n\)\{\}@]+)/i,/^(?:$)/i,/^(?:["][`])/i,/^(?:[^`"]+)/i,/^(?:[`]["])/i,/^(?:["])/i,/^(?:[^"]+)/i,/^(?:["])/i,/^(?:[\)]\))/i,/^(?:[\)])/i,/^(?:[\]])/i,/^(?:\}\})/i,/^(?:\(-)/i,/^(?:-\))/i,/^(?:\(\()/i,/^(?:\()/i,/^(?:[^\)\]\(\}]+)/i,/^(?:.+(?!\(\())/i],conditions:{shapeDataEndBracket:{rules:[],inclusive:!1},shapeDataStr:{rules:[2,3],inclusive:!1},shapeData:{rules:[1,4,5],inclusive:!1},CLASS:{rules:[9,10],inclusive:!1},ICON:{rules:[14,15],inclusive:!1},NSTR2:{rules:[28,29],inclusive:!1},NSTR:{rules:[31,32],inclusive:!1},NODE:{rules:[27,30,33,34,35,36,37,38,39,40,41,42],inclusive:!1},INITIAL:{rules:[0,6,7,8,11,12,13,16,17,18,19,20,21,22,23,24,25,26],inclusive:!0}}};return A}();C.lexer=T;function E(){this.yy={}}return o(E,"Parser"),E.prototype=C,C.Parser=E,new E}();DB.parser=DB;r1e=DB});var nl,RB,LB,NB,fJe,dJe,i1e,pJe,mJe,Yi,gJe,yJe,vJe,xJe,bJe,wJe,TJe,a1e,s1e=N(()=>{"use strict";zt();gr();vt();Ya();Ew();nl=[],RB=[],LB=0,NB={},fJe=o(()=>{nl=[],RB=[],LB=0,NB={}},"clear"),dJe=o(t=>{if(nl.length===0)return null;let e=nl[0].level,r=null;for(let n=nl.length-1;n>=0;n--)if(nl[n].level===e&&!r&&(r=nl[n]),nl[n].levell.parentId===i.id);for(let l of s){let u={id:l.id,parentId:i.id,label:Tr(l.label??"",n),isGroup:!1,ticket:l?.ticket,priority:l?.priority,assigned:l?.assigned,icon:l?.icon,shape:"kanbanItem",level:l.level,rx:5,ry:5,cssStyles:["text-align: left"]};e.push(u)}}return{nodes:e,edges:t,other:{},config:me()}},"getData"),mJe=o((t,e,r,n,i)=>{let a=me(),s=a.mindmap?.padding??or.mindmap.padding;switch(n){case Yi.ROUNDED_RECT:case Yi.RECT:case Yi.HEXAGON:s*=2}let l={id:Tr(e,a)||"kbn"+LB++,level:t,label:Tr(r,a),width:a.mindmap?.maxNodeWidth??or.mindmap.maxNodeWidth,padding:s,isGroup:!1};if(i!==void 0){let h;i.includes(` +`)?h=i+` +`:h=`{ +`+i+` +}`;let f=cm(h,{schema:lm});if(f.shape&&(f.shape!==f.shape.toLowerCase()||f.shape.includes("_")))throw new Error(`No such shape: ${f.shape}. Shape names should be lowercase.`);f?.shape&&f.shape==="kanbanItem"&&(l.shape=f?.shape),f?.label&&(l.label=f?.label),f?.icon&&(l.icon=f?.icon.toString()),f?.assigned&&(l.assigned=f?.assigned.toString()),f?.ticket&&(l.ticket=f?.ticket.toString()),f?.priority&&(l.priority=f?.priority)}let u=dJe(t);u?l.parentId=u.id||"kbn"+LB++:RB.push(l),nl.push(l)},"addNode"),Yi={DEFAULT:0,NO_BORDER:0,ROUNDED_RECT:1,RECT:2,CIRCLE:3,CLOUD:4,BANG:5,HEXAGON:6},gJe=o((t,e)=>{switch(Y.debug("In get type",t,e),t){case"[":return Yi.RECT;case"(":return e===")"?Yi.ROUNDED_RECT:Yi.CLOUD;case"((":return Yi.CIRCLE;case")":return Yi.CLOUD;case"))":return Yi.BANG;case"{{":return Yi.HEXAGON;default:return Yi.DEFAULT}},"getType"),yJe=o((t,e)=>{NB[t]=e},"setElementForId"),vJe=o(t=>{if(!t)return;let e=me(),r=nl[nl.length-1];t.icon&&(r.icon=Tr(t.icon,e)),t.class&&(r.cssClasses=Tr(t.class,e))},"decorateNode"),xJe=o(t=>{switch(t){case Yi.DEFAULT:return"no-border";case Yi.RECT:return"rect";case Yi.ROUNDED_RECT:return"rounded-rect";case Yi.CIRCLE:return"circle";case Yi.CLOUD:return"cloud";case Yi.BANG:return"bang";case Yi.HEXAGON:return"hexgon";default:return"no-border"}},"type2Str"),bJe=o(()=>Y,"getLogger"),wJe=o(t=>NB[t],"getElementById"),TJe={clear:fJe,addNode:mJe,getSections:i1e,getData:pJe,nodeType:Yi,getType:gJe,setElementForId:yJe,decorateNode:vJe,type2Str:xJe,getLogger:bJe,getElementById:wJe},a1e=TJe});var kJe,o1e,l1e=N(()=>{"use strict";zt();vt();Vc();Ei();Ya();Hw();eT();kJe=o(async(t,e,r,n)=>{Y.debug(`Rendering kanban diagram +`+t);let a=n.db.getData(),s=me();s.htmlLabels=!1;let l=sa(e),u=l.append("g");u.attr("class","sections");let h=l.append("g");h.attr("class","items");let f=a.nodes.filter(v=>v.isGroup),d=0,p=10,m=[],g=25;for(let v of f){let x=s?.kanban?.sectionWidth||200;d=d+1,v.x=x*d+(d-1)*p/2,v.width=x,v.y=0,v.height=x*3,v.rx=5,v.ry=5,v.cssClasses=v.cssClasses+" section-"+d;let b=await ym(u,v);g=Math.max(g,b?.labelBBox?.height),m.push(b)}let y=0;for(let v of f){let x=m[y];y=y+1;let b=s?.kanban?.sectionWidth||200,w=-b*3/2+g,C=w,T=a.nodes.filter(S=>S.parentId===v.id);for(let S of T){if(S.isGroup)throw new Error("Groups within groups are not allowed in Kanban diagrams");S.x=v.x,S.width=b-1.5*p;let I=(await vm(h,S,{config:s})).node().getBBox();S.y=C+I.height/2,await k2(S),C=S.y+I.height/2+p/2}let E=x.cluster.select("rect"),A=Math.max(C-w+3*p,50)+(g-25);E.attr("height",A)}Ao(void 0,l,s.mindmap?.padding??or.kanban.padding,s.mindmap?.useMaxWidth??or.kanban.useMaxWidth)},"draw"),o1e={draw:kJe}});var EJe,SJe,c1e,u1e=N(()=>{"use strict";Ys();EJe=o(t=>{let e="";for(let n=0;nt.darkMode?Ot(n,i):Dt(n,i),"adjuster");for(let n=0;n` + .edge { + stroke-width: 3; + } + ${EJe(t)} + .section-root rect, .section-root path, .section-root circle, .section-root polygon { + fill: ${t.git0}; + } + .section-root text { + fill: ${t.gitBranchLabel0}; + } + .icon-container { + height:100%; + display: flex; + justify-content: center; + align-items: center; + } + .edge { + fill: none; + } + .cluster-label, .label { + color: ${t.textColor}; + fill: ${t.textColor}; + } + .kanban-label { + dy: 1em; + alignment-baseline: middle; + text-anchor: middle; + dominant-baseline: middle; + text-align: center; + } +`,"getStyles"),c1e=SJe});var h1e={};hr(h1e,{diagram:()=>CJe});var CJe,f1e=N(()=>{"use strict";n1e();s1e();l1e();u1e();CJe={db:a1e,renderer:o1e,parser:r1e,styles:c1e}});var MB,d4,m1e=N(()=>{"use strict";MB=function(){var t=o(function(l,u,h,f){for(h=h||{},f=l.length;f--;h[l[f]]=u);return h},"o"),e=[1,9],r=[1,10],n=[1,5,10,12],i={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SANKEY:4,NEWLINE:5,csv:6,opt_eof:7,record:8,csv_tail:9,EOF:10,"field[source]":11,COMMA:12,"field[target]":13,"field[value]":14,field:15,escaped:16,non_escaped:17,DQUOTE:18,ESCAPED_TEXT:19,NON_ESCAPED_TEXT:20,$accept:0,$end:1},terminals_:{2:"error",4:"SANKEY",5:"NEWLINE",10:"EOF",11:"field[source]",12:"COMMA",13:"field[target]",14:"field[value]",18:"DQUOTE",19:"ESCAPED_TEXT",20:"NON_ESCAPED_TEXT"},productions_:[0,[3,4],[6,2],[9,2],[9,0],[7,1],[7,0],[8,5],[15,1],[15,1],[16,3],[17,1]],performAction:o(function(u,h,f,d,p,m,g){var y=m.length-1;switch(p){case 7:let v=d.findOrCreateNode(m[y-4].trim().replaceAll('""','"')),x=d.findOrCreateNode(m[y-2].trim().replaceAll('""','"')),b=parseFloat(m[y].trim());d.addLink(v,x,b);break;case 8:case 9:case 11:this.$=m[y];break;case 10:this.$=m[y-1];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},{5:[1,3]},{6:4,8:5,15:6,16:7,17:8,18:e,20:r},{1:[2,6],7:11,10:[1,12]},t(r,[2,4],{9:13,5:[1,14]}),{12:[1,15]},t(n,[2,8]),t(n,[2,9]),{19:[1,16]},t(n,[2,11]),{1:[2,1]},{1:[2,5]},t(r,[2,2]),{6:17,8:5,15:6,16:7,17:8,18:e,20:r},{15:18,16:7,17:8,18:e,20:r},{18:[1,19]},t(r,[2,3]),{12:[1,20]},t(n,[2,10]),{15:21,16:7,17:8,18:e,20:r},t([1,5,10],[2,7])],defaultActions:{11:[2,1],12:[2,5]},parseError:o(function(u,h){if(h.recoverable)this.trace(u);else{var f=new Error(u);throw f.hash=h,f}},"parseError"),parse:o(function(u){var h=this,f=[0],d=[],p=[null],m=[],g=this.table,y="",v=0,x=0,b=0,w=2,C=1,T=m.slice.call(arguments,1),E=Object.create(this.lexer),A={yy:{}};for(var S in this.yy)Object.prototype.hasOwnProperty.call(this.yy,S)&&(A.yy[S]=this.yy[S]);E.setInput(u,A.yy),A.yy.lexer=E,A.yy.parser=this,typeof E.yylloc>"u"&&(E.yylloc={});var _=E.yylloc;m.push(_);var I=E.options&&E.options.ranges;typeof A.yy.parseError=="function"?this.parseError=A.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function D(ie){f.length=f.length-2*ie,p.length=p.length-ie,m.length=m.length-ie}o(D,"popStack");function k(){var ie;return ie=d.pop()||E.lex()||C,typeof ie!="number"&&(ie instanceof Array&&(d=ie,ie=d.pop()),ie=h.symbols_[ie]||ie),ie}o(k,"lex");for(var L,R,O,M,B,F,P={},z,$,H,Q;;){if(O=f[f.length-1],this.defaultActions[O]?M=this.defaultActions[O]:((L===null||typeof L>"u")&&(L=k()),M=g[O]&&g[O][L]),typeof M>"u"||!M.length||!M[0]){var j="";Q=[];for(z in g[O])this.terminals_[z]&&z>w&&Q.push("'"+this.terminals_[z]+"'");E.showPosition?j="Parse error on line "+(v+1)+`: +`+E.showPosition()+` +Expecting `+Q.join(", ")+", got '"+(this.terminals_[L]||L)+"'":j="Parse error on line "+(v+1)+": Unexpected "+(L==C?"end of input":"'"+(this.terminals_[L]||L)+"'"),this.parseError(j,{text:E.match,token:this.terminals_[L]||L,line:E.yylineno,loc:_,expected:Q})}if(M[0]instanceof Array&&M.length>1)throw new Error("Parse Error: multiple actions possible at state: "+O+", token: "+L);switch(M[0]){case 1:f.push(L),p.push(E.yytext),m.push(E.yylloc),f.push(M[1]),L=null,R?(L=R,R=null):(x=E.yyleng,y=E.yytext,v=E.yylineno,_=E.yylloc,b>0&&b--);break;case 2:if($=this.productions_[M[1]][1],P.$=p[p.length-$],P._$={first_line:m[m.length-($||1)].first_line,last_line:m[m.length-1].last_line,first_column:m[m.length-($||1)].first_column,last_column:m[m.length-1].last_column},I&&(P._$.range=[m[m.length-($||1)].range[0],m[m.length-1].range[1]]),F=this.performAction.apply(P,[y,x,v,A.yy,M[1],p,m].concat(T)),typeof F<"u")return F;$&&(f=f.slice(0,-1*$*2),p=p.slice(0,-1*$),m=m.slice(0,-1*$)),f.push(this.productions_[M[1]][0]),p.push(P.$),m.push(P._$),H=g[f[f.length-2]][f[f.length-1]],f.push(H);break;case 3:return!0}}return!0},"parse")},a=function(){var l={EOF:1,parseError:o(function(h,f){if(this.yy.parser)this.yy.parser.parseError(h,f);else throw new Error(h)},"parseError"),setInput:o(function(u,h){return this.yy=h||this.yy||{},this._input=u,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var u=this._input[0];this.yytext+=u,this.yyleng++,this.offset++,this.match+=u,this.matched+=u;var h=u.match(/(?:\r\n?|\n).*/g);return h?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),u},"input"),unput:o(function(u){var h=u.length,f=u.split(/(?:\r\n?|\n)/g);this._input=u+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-h),this.offset-=h;var d=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),f.length-1&&(this.yylineno-=f.length-1);var p=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:f?(f.length===d.length?this.yylloc.first_column:0)+d[d.length-f.length].length-f[0].length:this.yylloc.first_column-h},this.options.ranges&&(this.yylloc.range=[p[0],p[0]+this.yyleng-h]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(u){this.unput(this.match.slice(u))},"less"),pastInput:o(function(){var u=this.matched.substr(0,this.matched.length-this.match.length);return(u.length>20?"...":"")+u.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var u=this.match;return u.length<20&&(u+=this._input.substr(0,20-u.length)),(u.substr(0,20)+(u.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var u=this.pastInput(),h=new Array(u.length+1).join("-");return u+this.upcomingInput()+` +`+h+"^"},"showPosition"),test_match:o(function(u,h){var f,d,p;if(this.options.backtrack_lexer&&(p={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(p.yylloc.range=this.yylloc.range.slice(0))),d=u[0].match(/(?:\r\n?|\n).*/g),d&&(this.yylineno+=d.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:d?d[d.length-1].length-d[d.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+u[0].length},this.yytext+=u[0],this.match+=u[0],this.matches=u,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(u[0].length),this.matched+=u[0],f=this.performAction.call(this,this.yy,this,h,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),f)return f;if(this._backtrack){for(var m in p)this[m]=p[m];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var u,h,f,d;this._more||(this.yytext="",this.match="");for(var p=this._currentRules(),m=0;mh[0].length)){if(h=f,d=m,this.options.backtrack_lexer){if(u=this.test_match(f,p[m]),u!==!1)return u;if(this._backtrack){h=!1;continue}else return!1}else if(!this.options.flex)break}return h?(u=this.test_match(h,p[d]),u!==!1?u:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var h=this.next();return h||this.lex()},"lex"),begin:o(function(h){this.conditionStack.push(h)},"begin"),popState:o(function(){var h=this.conditionStack.length-1;return h>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(h){return h=this.conditionStack.length-1-Math.abs(h||0),h>=0?this.conditionStack[h]:"INITIAL"},"topState"),pushState:o(function(h){this.begin(h)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{"case-insensitive":!0},performAction:o(function(h,f,d,p){var m=p;switch(d){case 0:return this.pushState("csv"),4;break;case 1:return 10;case 2:return 5;case 3:return 12;case 4:return this.pushState("escaped_text"),18;break;case 5:return 20;case 6:return this.popState("escaped_text"),18;break;case 7:return 19}},"anonymous"),rules:[/^(?:sankey-beta\b)/i,/^(?:$)/i,/^(?:((\u000D\u000A)|(\u000A)))/i,/^(?:(\u002C))/i,/^(?:(\u0022))/i,/^(?:([\u0020-\u0021\u0023-\u002B\u002D-\u007E])*)/i,/^(?:(\u0022)(?!(\u0022)))/i,/^(?:(([\u0020-\u0021\u0023-\u002B\u002D-\u007E])|(\u002C)|(\u000D)|(\u000A)|(\u0022)(\u0022))*)/i],conditions:{csv:{rules:[1,2,3,4,5,6,7],inclusive:!1},escaped_text:{rules:[6,7],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7],inclusive:!0}}};return l}();i.lexer=a;function s(){this.yy={}}return o(s,"Parser"),s.prototype=i,i.Parser=s,new s}();MB.parser=MB;d4=MB});var XS,jS,YS,LJe,IB,RJe,OB,NJe,MJe,IJe,OJe,g1e,y1e=N(()=>{"use strict";zt();gr();mi();XS=[],jS=[],YS=new Map,LJe=o(()=>{XS=[],jS=[],YS=new Map,Ar()},"clear"),IB=class{constructor(e,r,n=0){this.source=e;this.target=r;this.value=n}static{o(this,"SankeyLink")}},RJe=o((t,e,r)=>{XS.push(new IB(t,e,r))},"addLink"),OB=class{constructor(e){this.ID=e}static{o(this,"SankeyNode")}},NJe=o(t=>{t=Ze.sanitizeText(t,me());let e=YS.get(t);return e===void 0&&(e=new OB(t),YS.set(t,e),jS.push(e)),e},"findOrCreateNode"),MJe=o(()=>jS,"getNodes"),IJe=o(()=>XS,"getLinks"),OJe=o(()=>({nodes:jS.map(t=>({id:t.ID})),links:XS.map(t=>({source:t.source.ID,target:t.target.ID,value:t.value}))}),"getGraph"),g1e={nodesMap:YS,getConfig:o(()=>me().sankey,"getConfig"),getNodes:MJe,getLinks:IJe,getGraph:OJe,addLink:RJe,findOrCreateNode:NJe,getAccTitle:Rr,setAccTitle:Lr,getAccDescription:Mr,setAccDescription:Nr,getDiagramTitle:Ir,setDiagramTitle:$r,clear:LJe}});function p4(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r=i)&&(r=i)}return r}var v1e=N(()=>{"use strict";o(p4,"max")});function cy(t,e){let r;if(e===void 0)for(let n of t)n!=null&&(r>n||r===void 0&&n>=n)&&(r=n);else{let n=-1;for(let i of t)(i=e(i,++n,t))!=null&&(r>i||r===void 0&&i>=i)&&(r=i)}return r}var x1e=N(()=>{"use strict";o(cy,"min")});function uy(t,e){let r=0;if(e===void 0)for(let n of t)(n=+n)&&(r+=n);else{let n=-1;for(let i of t)(i=+e(i,++n,t))&&(r+=i)}return r}var b1e=N(()=>{"use strict";o(uy,"sum")});var PB=N(()=>{"use strict";v1e();x1e();b1e()});function PJe(t){return t.target.depth}function BB(t){return t.depth}function FB(t,e){return e-1-t.height}function m4(t,e){return t.sourceLinks.length?t.depth:e-1}function $B(t){return t.targetLinks.length?t.depth:t.sourceLinks.length?cy(t.sourceLinks,PJe)-1:0}var zB=N(()=>{"use strict";PB();o(PJe,"targetDepth");o(BB,"left");o(FB,"right");o(m4,"justify");o($B,"center")});function hy(t){return function(){return t}}var w1e=N(()=>{"use strict";o(hy,"constant")});function T1e(t,e){return KS(t.source,e.source)||t.index-e.index}function k1e(t,e){return KS(t.target,e.target)||t.index-e.index}function KS(t,e){return t.y0-e.y0}function GB(t){return t.value}function BJe(t){return t.index}function FJe(t){return t.nodes}function $Je(t){return t.links}function E1e(t,e){let r=t.get(e);if(!r)throw new Error("missing: "+e);return r}function S1e({nodes:t}){for(let e of t){let r=e.y0,n=r;for(let i of e.sourceLinks)i.y0=r+i.width/2,r+=i.width;for(let i of e.targetLinks)i.y1=n+i.width/2,n+=i.width}}function QS(){let t=0,e=0,r=1,n=1,i=24,a=8,s,l=BJe,u=m4,h,f,d=FJe,p=$Je,m=6;function g(){let O={nodes:d.apply(null,arguments),links:p.apply(null,arguments)};return y(O),v(O),x(O),b(O),T(O),S1e(O),O}o(g,"sankey"),g.update=function(O){return S1e(O),O},g.nodeId=function(O){return arguments.length?(l=typeof O=="function"?O:hy(O),g):l},g.nodeAlign=function(O){return arguments.length?(u=typeof O=="function"?O:hy(O),g):u},g.nodeSort=function(O){return arguments.length?(h=O,g):h},g.nodeWidth=function(O){return arguments.length?(i=+O,g):i},g.nodePadding=function(O){return arguments.length?(a=s=+O,g):a},g.nodes=function(O){return arguments.length?(d=typeof O=="function"?O:hy(O),g):d},g.links=function(O){return arguments.length?(p=typeof O=="function"?O:hy(O),g):p},g.linkSort=function(O){return arguments.length?(f=O,g):f},g.size=function(O){return arguments.length?(t=e=0,r=+O[0],n=+O[1],g):[r-t,n-e]},g.extent=function(O){return arguments.length?(t=+O[0][0],r=+O[1][0],e=+O[0][1],n=+O[1][1],g):[[t,e],[r,n]]},g.iterations=function(O){return arguments.length?(m=+O,g):m};function y({nodes:O,links:M}){for(let[F,P]of O.entries())P.index=F,P.sourceLinks=[],P.targetLinks=[];let B=new Map(O.map((F,P)=>[l(F,P,O),F]));for(let[F,P]of M.entries()){P.index=F;let{source:z,target:$}=P;typeof z!="object"&&(z=P.source=E1e(B,z)),typeof $!="object"&&($=P.target=E1e(B,$)),z.sourceLinks.push(P),$.targetLinks.push(P)}if(f!=null)for(let{sourceLinks:F,targetLinks:P}of O)F.sort(f),P.sort(f)}o(y,"computeNodeLinks");function v({nodes:O}){for(let M of O)M.value=M.fixedValue===void 0?Math.max(uy(M.sourceLinks,GB),uy(M.targetLinks,GB)):M.fixedValue}o(v,"computeNodeValues");function x({nodes:O}){let M=O.length,B=new Set(O),F=new Set,P=0;for(;B.size;){for(let z of B){z.depth=P;for(let{target:$}of z.sourceLinks)F.add($)}if(++P>M)throw new Error("circular link");B=F,F=new Set}}o(x,"computeNodeDepths");function b({nodes:O}){let M=O.length,B=new Set(O),F=new Set,P=0;for(;B.size;){for(let z of B){z.height=P;for(let{source:$}of z.targetLinks)F.add($)}if(++P>M)throw new Error("circular link");B=F,F=new Set}}o(b,"computeNodeHeights");function w({nodes:O}){let M=p4(O,P=>P.depth)+1,B=(r-t-i)/(M-1),F=new Array(M);for(let P of O){let z=Math.max(0,Math.min(M-1,Math.floor(u.call(null,P,M))));P.layer=z,P.x0=t+z*B,P.x1=P.x0+i,F[z]?F[z].push(P):F[z]=[P]}if(h)for(let P of F)P.sort(h);return F}o(w,"computeNodeLayers");function C(O){let M=cy(O,B=>(n-e-(B.length-1)*s)/uy(B,GB));for(let B of O){let F=e;for(let P of B){P.y0=F,P.y1=F+P.value*M,F=P.y1+s;for(let z of P.sourceLinks)z.width=z.value*M}F=(n-F+s)/(B.length+1);for(let P=0;PB.length)-1)),C(M);for(let B=0;B0))continue;let j=(H/Q-$.y0)*M;$.y0+=j,$.y1+=j,D($)}h===void 0&&z.sort(KS),S(z,B)}}o(E,"relaxLeftToRight");function A(O,M,B){for(let F=O.length,P=F-2;P>=0;--P){let z=O[P];for(let $ of z){let H=0,Q=0;for(let{target:ie,value:ne}of $.sourceLinks){let le=ne*(ie.layer-$.layer);H+=R($,ie)*le,Q+=le}if(!(Q>0))continue;let j=(H/Q-$.y0)*M;$.y0+=j,$.y1+=j,D($)}h===void 0&&z.sort(KS),S(z,B)}}o(A,"relaxRightToLeft");function S(O,M){let B=O.length>>1,F=O[B];I(O,F.y0-s,B-1,M),_(O,F.y1+s,B+1,M),I(O,n,O.length-1,M),_(O,e,0,M)}o(S,"resolveCollisions");function _(O,M,B,F){for(;B1e-6&&(P.y0+=z,P.y1+=z),M=P.y1+s}}o(_,"resolveCollisionsTopToBottom");function I(O,M,B,F){for(;B>=0;--B){let P=O[B],z=(P.y1-M)*F;z>1e-6&&(P.y0-=z,P.y1-=z),M=P.y0-s}}o(I,"resolveCollisionsBottomToTop");function D({sourceLinks:O,targetLinks:M}){if(f===void 0){for(let{source:{sourceLinks:B}}of M)B.sort(k1e);for(let{target:{targetLinks:B}}of O)B.sort(T1e)}}o(D,"reorderNodeLinks");function k(O){if(f===void 0)for(let{sourceLinks:M,targetLinks:B}of O)M.sort(k1e),B.sort(T1e)}o(k,"reorderLinks");function L(O,M){let B=O.y0-(O.sourceLinks.length-1)*s/2;for(let{target:F,width:P}of O.sourceLinks){if(F===M)break;B+=P+s}for(let{source:F,width:P}of M.targetLinks){if(F===O)break;B-=P}return B}o(L,"targetTop");function R(O,M){let B=M.y0-(M.targetLinks.length-1)*s/2;for(let{source:F,width:P}of M.targetLinks){if(F===O)break;B+=P+s}for(let{target:F,width:P}of O.sourceLinks){if(F===M)break;B-=P}return B}return o(R,"sourceTop"),g}var C1e=N(()=>{"use strict";PB();zB();w1e();o(T1e,"ascendingSourceBreadth");o(k1e,"ascendingTargetBreadth");o(KS,"ascendingBreadth");o(GB,"value");o(BJe,"defaultId");o(FJe,"defaultNodes");o($Je,"defaultLinks");o(E1e,"find");o(S1e,"computeLinkBreadths");o(QS,"Sankey")});function HB(){this._x0=this._y0=this._x1=this._y1=null,this._=""}function A1e(){return new HB}var VB,UB,Xp,zJe,WB,_1e=N(()=>{"use strict";VB=Math.PI,UB=2*VB,Xp=1e-6,zJe=UB-Xp;o(HB,"Path");o(A1e,"path");HB.prototype=A1e.prototype={constructor:HB,moveTo:o(function(t,e){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)},"moveTo"),closePath:o(function(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")},"closePath"),lineTo:o(function(t,e){this._+="L"+(this._x1=+t)+","+(this._y1=+e)},"lineTo"),quadraticCurveTo:o(function(t,e,r,n){this._+="Q"+ +t+","+ +e+","+(this._x1=+r)+","+(this._y1=+n)},"quadraticCurveTo"),bezierCurveTo:o(function(t,e,r,n,i,a){this._+="C"+ +t+","+ +e+","+ +r+","+ +n+","+(this._x1=+i)+","+(this._y1=+a)},"bezierCurveTo"),arcTo:o(function(t,e,r,n,i){t=+t,e=+e,r=+r,n=+n,i=+i;var a=this._x1,s=this._y1,l=r-t,u=n-e,h=a-t,f=s-e,d=h*h+f*f;if(i<0)throw new Error("negative radius: "+i);if(this._x1===null)this._+="M"+(this._x1=t)+","+(this._y1=e);else if(d>Xp)if(!(Math.abs(f*l-u*h)>Xp)||!i)this._+="L"+(this._x1=t)+","+(this._y1=e);else{var p=r-a,m=n-s,g=l*l+u*u,y=p*p+m*m,v=Math.sqrt(g),x=Math.sqrt(d),b=i*Math.tan((VB-Math.acos((g+d-y)/(2*v*x)))/2),w=b/x,C=b/v;Math.abs(w-1)>Xp&&(this._+="L"+(t+w*h)+","+(e+w*f)),this._+="A"+i+","+i+",0,0,"+ +(f*p>h*m)+","+(this._x1=t+C*l)+","+(this._y1=e+C*u)}},"arcTo"),arc:o(function(t,e,r,n,i,a){t=+t,e=+e,r=+r,a=!!a;var s=r*Math.cos(n),l=r*Math.sin(n),u=t+s,h=e+l,f=1^a,d=a?n-i:i-n;if(r<0)throw new Error("negative radius: "+r);this._x1===null?this._+="M"+u+","+h:(Math.abs(this._x1-u)>Xp||Math.abs(this._y1-h)>Xp)&&(this._+="L"+u+","+h),r&&(d<0&&(d=d%UB+UB),d>zJe?this._+="A"+r+","+r+",0,1,"+f+","+(t-s)+","+(e-l)+"A"+r+","+r+",0,1,"+f+","+(this._x1=u)+","+(this._y1=h):d>Xp&&(this._+="A"+r+","+r+",0,"+ +(d>=VB)+","+f+","+(this._x1=t+r*Math.cos(i))+","+(this._y1=e+r*Math.sin(i))))},"arc"),rect:o(function(t,e,r,n){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)+"h"+ +r+"v"+ +n+"h"+-r+"Z"},"rect"),toString:o(function(){return this._},"toString")};WB=A1e});var D1e=N(()=>{"use strict";_1e()});function ZS(t){return o(function(){return t},"constant")}var L1e=N(()=>{"use strict";o(ZS,"default")});function R1e(t){return t[0]}function N1e(t){return t[1]}var M1e=N(()=>{"use strict";o(R1e,"x");o(N1e,"y")});var I1e,O1e=N(()=>{"use strict";I1e=Array.prototype.slice});function GJe(t){return t.source}function VJe(t){return t.target}function UJe(t){var e=GJe,r=VJe,n=R1e,i=N1e,a=null;function s(){var l,u=I1e.call(arguments),h=e.apply(this,u),f=r.apply(this,u);if(a||(a=l=WB()),t(a,+n.apply(this,(u[0]=h,u)),+i.apply(this,u),+n.apply(this,(u[0]=f,u)),+i.apply(this,u)),l)return a=null,l+""||null}return o(s,"link"),s.source=function(l){return arguments.length?(e=l,s):e},s.target=function(l){return arguments.length?(r=l,s):r},s.x=function(l){return arguments.length?(n=typeof l=="function"?l:ZS(+l),s):n},s.y=function(l){return arguments.length?(i=typeof l=="function"?l:ZS(+l),s):i},s.context=function(l){return arguments.length?(a=l??null,s):a},s}function HJe(t,e,r,n,i){t.moveTo(e,r),t.bezierCurveTo(e=(e+n)/2,r,e,i,n,i)}function qB(){return UJe(HJe)}var P1e=N(()=>{"use strict";D1e();O1e();L1e();M1e();o(GJe,"linkSource");o(VJe,"linkTarget");o(UJe,"link");o(HJe,"curveHorizontal");o(qB,"linkHorizontal")});var B1e=N(()=>{"use strict";P1e()});function WJe(t){return[t.source.x1,t.y0]}function qJe(t){return[t.target.x0,t.y1]}function JS(){return qB().source(WJe).target(qJe)}var F1e=N(()=>{"use strict";B1e();o(WJe,"horizontalSource");o(qJe,"horizontalTarget");o(JS,"default")});var $1e=N(()=>{"use strict";C1e();zB();F1e()});var g4,z1e=N(()=>{"use strict";g4=class t{static{o(this,"Uid")}static{this.count=0}static next(e){return new t(e+ ++t.count)}constructor(e){this.id=e,this.href=`#${e}`}toString(){return"url("+this.href+")"}}});var YJe,XJe,G1e,V1e=N(()=>{"use strict";zt();dr();$1e();Ei();z1e();YJe={left:BB,right:FB,center:$B,justify:m4},XJe=o(function(t,e,r,n){let{securityLevel:i,sankey:a}=me(),s=A3.sankey,l;i==="sandbox"&&(l=Ge("#i"+e));let u=i==="sandbox"?Ge(l.nodes()[0].contentDocument.body):Ge("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):Ge(`[id="${e}"]`),f=a?.width??s.width,d=a?.height??s.width,p=a?.useMaxWidth??s.useMaxWidth,m=a?.nodeAlignment??s.nodeAlignment,g=a?.prefix??s.prefix,y=a?.suffix??s.suffix,v=a?.showValues??s.showValues,x=n.db.getGraph(),b=YJe[m];QS().nodeId(I=>I.id).nodeWidth(10).nodePadding(10+(v?15:0)).nodeAlign(b).extent([[0,0],[f,d]])(x);let T=gu(e9);h.append("g").attr("class","nodes").selectAll(".node").data(x.nodes).join("g").attr("class","node").attr("id",I=>(I.uid=g4.next("node-")).id).attr("transform",function(I){return"translate("+I.x0+","+I.y0+")"}).attr("x",I=>I.x0).attr("y",I=>I.y0).append("rect").attr("height",I=>I.y1-I.y0).attr("width",I=>I.x1-I.x0).attr("fill",I=>T(I.id));let E=o(({id:I,value:D})=>v?`${I} +${g}${Math.round(D*100)/100}${y}`:I,"getText");h.append("g").attr("class","node-labels").attr("font-size",14).selectAll("text").data(x.nodes).join("text").attr("x",I=>I.x0(I.y1+I.y0)/2).attr("dy",`${v?"0":"0.35"}em`).attr("text-anchor",I=>I.x0(D.uid=g4.next("linearGradient-")).id).attr("gradientUnits","userSpaceOnUse").attr("x1",D=>D.source.x1).attr("x2",D=>D.target.x0);I.append("stop").attr("offset","0%").attr("stop-color",D=>T(D.source.id)),I.append("stop").attr("offset","100%").attr("stop-color",D=>T(D.target.id))}let _;switch(S){case"gradient":_=o(I=>I.uid,"coloring");break;case"source":_=o(I=>T(I.source.id),"coloring");break;case"target":_=o(I=>T(I.target.id),"coloring");break;default:_=S}A.append("path").attr("d",JS()).attr("stroke",_).attr("stroke-width",I=>Math.max(1,I.width)),Ao(void 0,h,0,p)},"draw"),G1e={draw:XJe}});var U1e,H1e=N(()=>{"use strict";U1e=o(t=>t.replaceAll(/^[^\S\n\r]+|[^\S\n\r]+$/g,"").replaceAll(/([\n\r])+/g,` +`).trim(),"prepareTextForParsing")});var jJe,W1e,q1e=N(()=>{"use strict";jJe=o(t=>`.label { + font-family: ${t.fontFamily}; + }`,"getStyles"),W1e=jJe});var Y1e={};hr(Y1e,{diagram:()=>QJe});var KJe,QJe,X1e=N(()=>{"use strict";m1e();y1e();V1e();H1e();q1e();KJe=d4.parse.bind(d4);d4.parse=t=>KJe(U1e(t));QJe={styles:W1e,parser:d4,db:g1e,renderer:G1e}});var Q1e,YB,tet,ret,net,iet,aet,Bf,XB=N(()=>{"use strict";ji();Ya();ir();mi();Q1e={packet:[]},YB=structuredClone(Q1e),tet=or.packet,ret=o(()=>{let t=Fi({...tet,...cr().packet});return t.showBits&&(t.paddingY+=10),t},"getConfig"),net=o(()=>YB.packet,"getPacket"),iet=o(t=>{t.length>0&&YB.packet.push(t)},"pushWord"),aet=o(()=>{Ar(),YB=structuredClone(Q1e)},"clear"),Bf={pushWord:iet,getPacket:net,getConfig:ret,clear:aet,setAccTitle:Lr,getAccTitle:Rr,setDiagramTitle:$r,getDiagramTitle:Ir,getAccDescription:Mr,setAccDescription:Nr}});var set,oet,cet,Z1e,J1e=N(()=>{"use strict";kp();vt();T1();XB();set=1e4,oet=o(t=>{$c(t,Bf);let e=-1,r=[],n=1,{bitsPerRow:i}=Bf.getConfig();for(let{start:a,end:s,label:l}of t.blocks){if(s&&s{if(t.end===void 0&&(t.end=t.start),t.start>t.end)throw new Error(`Block start ${t.start} is greater than block end ${t.end}.`);return t.end+1<=e*r?[t,void 0]:[{start:t.start,end:e*r-1,label:t.label},{start:e*r,end:t.end,label:t.label}]},"getNextFittingBlock"),Z1e={parse:o(async t=>{let e=await uo("packet",t);Y.debug(e),oet(e)},"parse")}});var uet,het,eye,tye=N(()=>{"use strict";Vc();Ei();uet=o((t,e,r,n)=>{let i=n.db,a=i.getConfig(),{rowHeight:s,paddingY:l,bitWidth:u,bitsPerRow:h}=a,f=i.getPacket(),d=i.getDiagramTitle(),p=s+l,m=p*(f.length+1)-(d?0:s),g=u*h+2,y=sa(e);y.attr("viewbox",`0 0 ${g} ${m}`),vn(y,m,g,a.useMaxWidth);for(let[v,x]of f.entries())het(y,x,v,a);y.append("text").text(d).attr("x",g/2).attr("y",m-p/2).attr("dominant-baseline","middle").attr("text-anchor","middle").attr("class","packetTitle")},"draw"),het=o((t,e,r,{rowHeight:n,paddingX:i,paddingY:a,bitWidth:s,bitsPerRow:l,showBits:u})=>{let h=t.append("g"),f=r*(n+a)+a;for(let d of e){let p=d.start%l*s+1,m=(d.end-d.start+1)*s-i;if(h.append("rect").attr("x",p).attr("y",f).attr("width",m).attr("height",n).attr("class","packetBlock"),h.append("text").attr("x",p+m/2).attr("y",f+n/2).attr("class","packetLabel").attr("dominant-baseline","middle").attr("text-anchor","middle").text(d.label),!u)continue;let g=d.end===d.start,y=f-2;h.append("text").attr("x",p+(g?m/2:0)).attr("y",y).attr("class","packetByte start").attr("dominant-baseline","auto").attr("text-anchor",g?"middle":"start").text(d.start),g||h.append("text").attr("x",p+m).attr("y",y).attr("class","packetByte end").attr("dominant-baseline","auto").attr("text-anchor","end").text(d.end)}},"drawWord"),eye={draw:uet}});var fet,rye,nye=N(()=>{"use strict";ir();fet={byteFontSize:"10px",startByteColor:"black",endByteColor:"black",labelColor:"black",labelFontSize:"12px",titleColor:"black",titleFontSize:"14px",blockStrokeColor:"black",blockStrokeWidth:"1",blockFillColor:"#efefef"},rye=o(({packet:t}={})=>{let e=Fi(fet,t);return` + .packetByte { + font-size: ${e.byteFontSize}; + } + .packetByte.start { + fill: ${e.startByteColor}; + } + .packetByte.end { + fill: ${e.endByteColor}; + } + .packetLabel { + fill: ${e.labelColor}; + font-size: ${e.labelFontSize}; + } + .packetTitle { + fill: ${e.titleColor}; + font-size: ${e.titleFontSize}; + } + .packetBlock { + stroke: ${e.blockStrokeColor}; + stroke-width: ${e.blockStrokeWidth}; + fill: ${e.blockFillColor}; + } + `},"styles")});var iye={};hr(iye,{diagram:()=>det});var det,aye=N(()=>{"use strict";XB();J1e();tye();nye();det={parser:Z1e,db:Bf,renderer:eye,styles:rye}});var fy,lye,jp,get,yet,cye,vet,xet,bet,wet,Tet,ket,Eet,Kp,jB=N(()=>{"use strict";ji();Ya();ir();mi();fy={showLegend:!0,ticks:5,max:null,min:0,graticule:"circle"},lye={axes:[],curves:[],options:fy},jp=structuredClone(lye),get=or.radar,yet=o(()=>Fi({...get,...cr().radar}),"getConfig"),cye=o(()=>jp.axes,"getAxes"),vet=o(()=>jp.curves,"getCurves"),xet=o(()=>jp.options,"getOptions"),bet=o(t=>{jp.axes=t.map(e=>({name:e.name,label:e.label??e.name}))},"setAxes"),wet=o(t=>{jp.curves=t.map(e=>({name:e.name,label:e.label??e.name,entries:Tet(e.entries)}))},"setCurves"),Tet=o(t=>{if(t[0].axis==null)return t.map(r=>r.value);let e=cye();if(e.length===0)throw new Error("Axes must be populated before curves for reference entries");return e.map(r=>{let n=t.find(i=>i.axis?.$refText===r.name);if(n===void 0)throw new Error("Missing entry for axis "+r.label);return n.value})},"computeCurveEntries"),ket=o(t=>{let e=t.reduce((r,n)=>(r[n.name]=n,r),{});jp.options={showLegend:e.showLegend?.value??fy.showLegend,ticks:e.ticks?.value??fy.ticks,max:e.max?.value??fy.max,min:e.min?.value??fy.min,graticule:e.graticule?.value??fy.graticule}},"setOptions"),Eet=o(()=>{Ar(),jp=structuredClone(lye)},"clear"),Kp={getAxes:cye,getCurves:vet,getOptions:xet,setAxes:bet,setCurves:wet,setOptions:ket,getConfig:yet,clear:Eet,setAccTitle:Lr,getAccTitle:Rr,setDiagramTitle:$r,getDiagramTitle:Ir,getAccDescription:Mr,setAccDescription:Nr}});var Cet,uye,hye=N(()=>{"use strict";kp();vt();T1();jB();Cet=o(t=>{$c(t,Kp);let{axes:e,curves:r,options:n}=t;Kp.setAxes(e),Kp.setCurves(r),Kp.setOptions(n)},"populate"),uye={parse:o(async t=>{let e=await uo("radar",t);Y.debug(e),Cet(e)},"parse")}});function Ret(t,e,r,n,i,a,s){let l=e.length,u=Math.min(s.width,s.height)/2;r.forEach((h,f)=>{if(h.entries.length!==l)return;let d=h.entries.map((p,m)=>{let g=2*Math.PI*m/l-Math.PI/2,y=Net(p,n,i,u),v=y*Math.cos(g),x=y*Math.sin(g);return{x:v,y:x}});a==="circle"?t.append("path").attr("d",Met(d,s.curveTension)).attr("class",`radarCurve-${f}`):a==="polygon"&&t.append("polygon").attr("points",d.map(p=>`${p.x},${p.y}`).join(" ")).attr("class",`radarCurve-${f}`)})}function Net(t,e,r,n){let i=Math.min(Math.max(t,e),r);return n*(i-e)/(r-e)}function Met(t,e){let r=t.length,n=`M${t[0].x},${t[0].y}`;for(let i=0;i{let h=t.append("g").attr("transform",`translate(${i}, ${a+u*s})`);h.append("rect").attr("width",12).attr("height",12).attr("class",`radarLegendBox-${u}`),h.append("text").attr("x",16).attr("y",0).attr("class","radarLegendText").text(l.label)})}var Aet,_et,Det,Let,fye,dye=N(()=>{"use strict";Vc();Aet=o((t,e,r,n)=>{let i=n.db,a=i.getAxes(),s=i.getCurves(),l=i.getOptions(),u=i.getConfig(),h=i.getDiagramTitle(),f=sa(e),d=_et(f,u),p=l.max??Math.max(...s.map(y=>Math.max(...y.entries))),m=l.min,g=Math.min(u.width,u.height)/2;Det(d,a,g,l.ticks,l.graticule),Let(d,a,g,u),Ret(d,a,s,m,p,l.graticule,u),Iet(d,s,l.showLegend,u),d.append("text").attr("class","radarTitle").text(h).attr("x",0).attr("y",-u.height/2-u.marginTop)},"draw"),_et=o((t,e)=>{let r=e.width+e.marginLeft+e.marginRight,n=e.height+e.marginTop+e.marginBottom,i={x:e.marginLeft+e.width/2,y:e.marginTop+e.height/2};return t.attr("viewbox",`0 0 ${r} ${n}`).attr("width",r).attr("height",n),t.append("g").attr("transform",`translate(${i.x}, ${i.y})`)},"drawFrame"),Det=o((t,e,r,n,i)=>{if(i==="circle")for(let a=0;a{let d=2*f*Math.PI/a-Math.PI/2,p=l*Math.cos(d),m=l*Math.sin(d);return`${p},${m}`}).join(" ");t.append("polygon").attr("points",u).attr("class","radarGraticule")}}},"drawGraticule"),Let=o((t,e,r,n)=>{let i=e.length;for(let a=0;a{"use strict";ir();_y();ji();Oet=o((t,e)=>{let r="";for(let n=0;n{let e=oh(),r=cr(),n=Fi(e,r.themeVariables),i=Fi(n.radar,t);return{themeVariables:n,radarOptions:i}},"buildRadarStyleOptions"),pye=o(({radar:t}={})=>{let{themeVariables:e,radarOptions:r}=Pet(t);return` + .radarTitle { + font-size: ${e.fontSize}; + color: ${e.titleColor}; + dominant-baseline: hanging; + text-anchor: middle; + } + .radarAxisLine { + stroke: ${r.axisColor}; + stroke-width: ${r.axisStrokeWidth}; + } + .radarAxisLabel { + dominant-baseline: middle; + text-anchor: middle; + font-size: ${r.axisLabelFontSize}px; + color: ${r.axisColor}; + } + .radarGraticule { + fill: ${r.graticuleColor}; + fill-opacity: ${r.graticuleOpacity}; + stroke: ${r.graticuleColor}; + stroke-width: ${r.graticuleStrokeWidth}; + } + .radarLegendText { + text-anchor: start; + font-size: ${r.legendFontSize}px; + dominant-baseline: hanging; + } + ${Oet(e,r)} + `},"styles")});var gye={};hr(gye,{diagram:()=>Bet});var Bet,yye=N(()=>{"use strict";jB();hye();dye();mye();Bet={parser:uye,db:Kp,renderer:fye,styles:pye}});var KB,bye,wye=N(()=>{"use strict";KB=function(){var t=o(function(w,C,T,E){for(T=T||{},E=w.length;E--;T[w[E]]=C);return T},"o"),e=[1,7],r=[1,13],n=[1,14],i=[1,15],a=[1,19],s=[1,16],l=[1,17],u=[1,18],h=[8,30],f=[8,21,28,29,30,31,32,40,44,47],d=[1,23],p=[1,24],m=[8,15,16,21,28,29,30,31,32,40,44,47],g=[8,15,16,21,27,28,29,30,31,32,40,44,47],y=[1,49],v={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,spaceLines:3,SPACELINE:4,NL:5,separator:6,SPACE:7,EOF:8,start:9,BLOCK_DIAGRAM_KEY:10,document:11,stop:12,statement:13,link:14,LINK:15,START_LINK:16,LINK_LABEL:17,STR:18,nodeStatement:19,columnsStatement:20,SPACE_BLOCK:21,blockStatement:22,classDefStatement:23,cssClassStatement:24,styleStatement:25,node:26,SIZE:27,COLUMNS:28,"id-block":29,end:30,block:31,NODE_ID:32,nodeShapeNLabel:33,dirList:34,DIR:35,NODE_DSTART:36,NODE_DEND:37,BLOCK_ARROW_START:38,BLOCK_ARROW_END:39,classDef:40,CLASSDEF_ID:41,CLASSDEF_STYLEOPTS:42,DEFAULT:43,class:44,CLASSENTITY_IDS:45,STYLECLASS:46,style:47,STYLE_ENTITY_IDS:48,STYLE_DEFINITION_DATA:49,$accept:0,$end:1},terminals_:{2:"error",4:"SPACELINE",5:"NL",7:"SPACE",8:"EOF",10:"BLOCK_DIAGRAM_KEY",15:"LINK",16:"START_LINK",17:"LINK_LABEL",18:"STR",21:"SPACE_BLOCK",27:"SIZE",28:"COLUMNS",29:"id-block",30:"end",31:"block",32:"NODE_ID",35:"DIR",36:"NODE_DSTART",37:"NODE_DEND",38:"BLOCK_ARROW_START",39:"BLOCK_ARROW_END",40:"classDef",41:"CLASSDEF_ID",42:"CLASSDEF_STYLEOPTS",43:"DEFAULT",44:"class",45:"CLASSENTITY_IDS",46:"STYLECLASS",47:"style",48:"STYLE_ENTITY_IDS",49:"STYLE_DEFINITION_DATA"},productions_:[0,[3,1],[3,2],[3,2],[6,1],[6,1],[6,1],[9,3],[12,1],[12,1],[12,2],[12,2],[11,1],[11,2],[14,1],[14,4],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[19,3],[19,2],[19,1],[20,1],[22,4],[22,3],[26,1],[26,2],[34,1],[34,2],[33,3],[33,4],[23,3],[23,3],[24,3],[25,3]],performAction:o(function(C,T,E,A,S,_,I){var D=_.length-1;switch(S){case 4:A.getLogger().debug("Rule: separator (NL) ");break;case 5:A.getLogger().debug("Rule: separator (Space) ");break;case 6:A.getLogger().debug("Rule: separator (EOF) ");break;case 7:A.getLogger().debug("Rule: hierarchy: ",_[D-1]),A.setHierarchy(_[D-1]);break;case 8:A.getLogger().debug("Stop NL ");break;case 9:A.getLogger().debug("Stop EOF ");break;case 10:A.getLogger().debug("Stop NL2 ");break;case 11:A.getLogger().debug("Stop EOF2 ");break;case 12:A.getLogger().debug("Rule: statement: ",_[D]),typeof _[D].length=="number"?this.$=_[D]:this.$=[_[D]];break;case 13:A.getLogger().debug("Rule: statement #2: ",_[D-1]),this.$=[_[D-1]].concat(_[D]);break;case 14:A.getLogger().debug("Rule: link: ",_[D],C),this.$={edgeTypeStr:_[D],label:""};break;case 15:A.getLogger().debug("Rule: LABEL link: ",_[D-3],_[D-1],_[D]),this.$={edgeTypeStr:_[D],label:_[D-1]};break;case 18:let k=parseInt(_[D]),L=A.generateId();this.$={id:L,type:"space",label:"",width:k,children:[]};break;case 23:A.getLogger().debug("Rule: (nodeStatement link node) ",_[D-2],_[D-1],_[D]," typestr: ",_[D-1].edgeTypeStr);let R=A.edgeStrToEdgeData(_[D-1].edgeTypeStr);this.$=[{id:_[D-2].id,label:_[D-2].label,type:_[D-2].type,directions:_[D-2].directions},{id:_[D-2].id+"-"+_[D].id,start:_[D-2].id,end:_[D].id,label:_[D-1].label,type:"edge",directions:_[D].directions,arrowTypeEnd:R,arrowTypeStart:"arrow_open"},{id:_[D].id,label:_[D].label,type:A.typeStr2Type(_[D].typeStr),directions:_[D].directions}];break;case 24:A.getLogger().debug("Rule: nodeStatement (abc88 node size) ",_[D-1],_[D]),this.$={id:_[D-1].id,label:_[D-1].label,type:A.typeStr2Type(_[D-1].typeStr),directions:_[D-1].directions,widthInColumns:parseInt(_[D],10)};break;case 25:A.getLogger().debug("Rule: nodeStatement (node) ",_[D]),this.$={id:_[D].id,label:_[D].label,type:A.typeStr2Type(_[D].typeStr),directions:_[D].directions,widthInColumns:1};break;case 26:A.getLogger().debug("APA123",this?this:"na"),A.getLogger().debug("COLUMNS: ",_[D]),this.$={type:"column-setting",columns:_[D]==="auto"?-1:parseInt(_[D])};break;case 27:A.getLogger().debug("Rule: id-block statement : ",_[D-2],_[D-1]);let O=A.generateId();this.$={..._[D-2],type:"composite",children:_[D-1]};break;case 28:A.getLogger().debug("Rule: blockStatement : ",_[D-2],_[D-1],_[D]);let M=A.generateId();this.$={id:M,type:"composite",label:"",children:_[D-1]};break;case 29:A.getLogger().debug("Rule: node (NODE_ID separator): ",_[D]),this.$={id:_[D]};break;case 30:A.getLogger().debug("Rule: node (NODE_ID nodeShapeNLabel separator): ",_[D-1],_[D]),this.$={id:_[D-1],label:_[D].label,typeStr:_[D].typeStr,directions:_[D].directions};break;case 31:A.getLogger().debug("Rule: dirList: ",_[D]),this.$=[_[D]];break;case 32:A.getLogger().debug("Rule: dirList: ",_[D-1],_[D]),this.$=[_[D-1]].concat(_[D]);break;case 33:A.getLogger().debug("Rule: nodeShapeNLabel: ",_[D-2],_[D-1],_[D]),this.$={typeStr:_[D-2]+_[D],label:_[D-1]};break;case 34:A.getLogger().debug("Rule: BLOCK_ARROW nodeShapeNLabel: ",_[D-3],_[D-2]," #3:",_[D-1],_[D]),this.$={typeStr:_[D-3]+_[D],label:_[D-2],directions:_[D-1]};break;case 35:case 36:this.$={type:"classDef",id:_[D-1].trim(),css:_[D].trim()};break;case 37:this.$={type:"applyClass",id:_[D-1].trim(),styleClass:_[D].trim()};break;case 38:this.$={type:"applyStyles",id:_[D-1].trim(),stylesStr:_[D].trim()};break}},"anonymous"),table:[{9:1,10:[1,2]},{1:[3]},{11:3,13:4,19:5,20:6,21:e,22:8,23:9,24:10,25:11,26:12,28:r,29:n,31:i,32:a,40:s,44:l,47:u},{8:[1,20]},t(h,[2,12],{13:4,19:5,20:6,22:8,23:9,24:10,25:11,26:12,11:21,21:e,28:r,29:n,31:i,32:a,40:s,44:l,47:u}),t(f,[2,16],{14:22,15:d,16:p}),t(f,[2,17]),t(f,[2,18]),t(f,[2,19]),t(f,[2,20]),t(f,[2,21]),t(f,[2,22]),t(m,[2,25],{27:[1,25]}),t(f,[2,26]),{19:26,26:12,32:a},{11:27,13:4,19:5,20:6,21:e,22:8,23:9,24:10,25:11,26:12,28:r,29:n,31:i,32:a,40:s,44:l,47:u},{41:[1,28],43:[1,29]},{45:[1,30]},{48:[1,31]},t(g,[2,29],{33:32,36:[1,33],38:[1,34]}),{1:[2,7]},t(h,[2,13]),{26:35,32:a},{32:[2,14]},{17:[1,36]},t(m,[2,24]),{11:37,13:4,14:22,15:d,16:p,19:5,20:6,21:e,22:8,23:9,24:10,25:11,26:12,28:r,29:n,31:i,32:a,40:s,44:l,47:u},{30:[1,38]},{42:[1,39]},{42:[1,40]},{46:[1,41]},{49:[1,42]},t(g,[2,30]),{18:[1,43]},{18:[1,44]},t(m,[2,23]),{18:[1,45]},{30:[1,46]},t(f,[2,28]),t(f,[2,35]),t(f,[2,36]),t(f,[2,37]),t(f,[2,38]),{37:[1,47]},{34:48,35:y},{15:[1,50]},t(f,[2,27]),t(g,[2,33]),{39:[1,51]},{34:52,35:y,39:[2,31]},{32:[2,15]},t(g,[2,34]),{39:[2,32]}],defaultActions:{20:[2,7],23:[2,14],50:[2,15],52:[2,32]},parseError:o(function(C,T){if(T.recoverable)this.trace(C);else{var E=new Error(C);throw E.hash=T,E}},"parseError"),parse:o(function(C){var T=this,E=[0],A=[],S=[null],_=[],I=this.table,D="",k=0,L=0,R=0,O=2,M=1,B=_.slice.call(arguments,1),F=Object.create(this.lexer),P={yy:{}};for(var z in this.yy)Object.prototype.hasOwnProperty.call(this.yy,z)&&(P.yy[z]=this.yy[z]);F.setInput(C,P.yy),P.yy.lexer=F,P.yy.parser=this,typeof F.yylloc>"u"&&(F.yylloc={});var $=F.yylloc;_.push($);var H=F.options&&F.options.ranges;typeof P.yy.parseError=="function"?this.parseError=P.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Q(ce){E.length=E.length-2*ce,S.length=S.length-ce,_.length=_.length-ce}o(Q,"popStack");function j(){var ce;return ce=A.pop()||F.lex()||M,typeof ce!="number"&&(ce instanceof Array&&(A=ce,ce=A.pop()),ce=T.symbols_[ce]||ce),ce}o(j,"lex");for(var ie,ne,le,he,K,X,te={},J,se,ue,Z;;){if(le=E[E.length-1],this.defaultActions[le]?he=this.defaultActions[le]:((ie===null||typeof ie>"u")&&(ie=j()),he=I[le]&&I[le][ie]),typeof he>"u"||!he.length||!he[0]){var Se="";Z=[];for(J in I[le])this.terminals_[J]&&J>O&&Z.push("'"+this.terminals_[J]+"'");F.showPosition?Se="Parse error on line "+(k+1)+`: +`+F.showPosition()+` +Expecting `+Z.join(", ")+", got '"+(this.terminals_[ie]||ie)+"'":Se="Parse error on line "+(k+1)+": Unexpected "+(ie==M?"end of input":"'"+(this.terminals_[ie]||ie)+"'"),this.parseError(Se,{text:F.match,token:this.terminals_[ie]||ie,line:F.yylineno,loc:$,expected:Z})}if(he[0]instanceof Array&&he.length>1)throw new Error("Parse Error: multiple actions possible at state: "+le+", token: "+ie);switch(he[0]){case 1:E.push(ie),S.push(F.yytext),_.push(F.yylloc),E.push(he[1]),ie=null,ne?(ie=ne,ne=null):(L=F.yyleng,D=F.yytext,k=F.yylineno,$=F.yylloc,R>0&&R--);break;case 2:if(se=this.productions_[he[1]][1],te.$=S[S.length-se],te._$={first_line:_[_.length-(se||1)].first_line,last_line:_[_.length-1].last_line,first_column:_[_.length-(se||1)].first_column,last_column:_[_.length-1].last_column},H&&(te._$.range=[_[_.length-(se||1)].range[0],_[_.length-1].range[1]]),X=this.performAction.apply(te,[D,L,k,P.yy,he[1],S,_].concat(B)),typeof X<"u")return X;se&&(E=E.slice(0,-1*se*2),S=S.slice(0,-1*se),_=_.slice(0,-1*se)),E.push(this.productions_[he[1]][0]),S.push(te.$),_.push(te._$),ue=I[E[E.length-2]][E[E.length-1]],E.push(ue);break;case 3:return!0}}return!0},"parse")},x=function(){var w={EOF:1,parseError:o(function(T,E){if(this.yy.parser)this.yy.parser.parseError(T,E);else throw new Error(T)},"parseError"),setInput:o(function(C,T){return this.yy=T||this.yy||{},this._input=C,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var C=this._input[0];this.yytext+=C,this.yyleng++,this.offset++,this.match+=C,this.matched+=C;var T=C.match(/(?:\r\n?|\n).*/g);return T?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),C},"input"),unput:o(function(C){var T=C.length,E=C.split(/(?:\r\n?|\n)/g);this._input=C+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-T),this.offset-=T;var A=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),E.length-1&&(this.yylineno-=E.length-1);var S=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:E?(E.length===A.length?this.yylloc.first_column:0)+A[A.length-E.length].length-E[0].length:this.yylloc.first_column-T},this.options.ranges&&(this.yylloc.range=[S[0],S[0]+this.yyleng-T]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). +`+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(C){this.unput(this.match.slice(C))},"less"),pastInput:o(function(){var C=this.matched.substr(0,this.matched.length-this.match.length);return(C.length>20?"...":"")+C.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var C=this.match;return C.length<20&&(C+=this._input.substr(0,20-C.length)),(C.substr(0,20)+(C.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var C=this.pastInput(),T=new Array(C.length+1).join("-");return C+this.upcomingInput()+` +`+T+"^"},"showPosition"),test_match:o(function(C,T){var E,A,S;if(this.options.backtrack_lexer&&(S={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(S.yylloc.range=this.yylloc.range.slice(0))),A=C[0].match(/(?:\r\n?|\n).*/g),A&&(this.yylineno+=A.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:A?A[A.length-1].length-A[A.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+C[0].length},this.yytext+=C[0],this.match+=C[0],this.matches=C,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(C[0].length),this.matched+=C[0],E=this.performAction.call(this,this.yy,this,T,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),E)return E;if(this._backtrack){for(var _ in S)this[_]=S[_];return!1}return!1},"test_match"),next:o(function(){if(this.done)return this.EOF;this._input||(this.done=!0);var C,T,E,A;this._more||(this.yytext="",this.match="");for(var S=this._currentRules(),_=0;_T[0].length)){if(T=E,A=_,this.options.backtrack_lexer){if(C=this.test_match(E,S[_]),C!==!1)return C;if(this._backtrack){T=!1;continue}else return!1}else if(!this.options.flex)break}return T?(C=this.test_match(T,S[A]),C!==!1?C:!1):this._input===""?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+`. Unrecognized text. +`+this.showPosition(),{text:"",token:null,line:this.yylineno})},"next"),lex:o(function(){var T=this.next();return T||this.lex()},"lex"),begin:o(function(T){this.conditionStack.push(T)},"begin"),popState:o(function(){var T=this.conditionStack.length-1;return T>0?this.conditionStack.pop():this.conditionStack[0]},"popState"),_currentRules:o(function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},"_currentRules"),topState:o(function(T){return T=this.conditionStack.length-1-Math.abs(T||0),T>=0?this.conditionStack[T]:"INITIAL"},"topState"),pushState:o(function(T){this.begin(T)},"pushState"),stateStackSize:o(function(){return this.conditionStack.length},"stateStackSize"),options:{},performAction:o(function(T,E,A,S){var _=S;switch(A){case 0:return 10;case 1:return T.getLogger().debug("Found space-block"),31;break;case 2:return T.getLogger().debug("Found nl-block"),31;break;case 3:return T.getLogger().debug("Found space-block"),29;break;case 4:T.getLogger().debug(".",E.yytext);break;case 5:T.getLogger().debug("_",E.yytext);break;case 6:return 5;case 7:return E.yytext=-1,28;break;case 8:return E.yytext=E.yytext.replace(/columns\s+/,""),T.getLogger().debug("COLUMNS (LEX)",E.yytext),28;break;case 9:this.pushState("md_string");break;case 10:return"MD_STR";case 11:this.popState();break;case 12:this.pushState("string");break;case 13:T.getLogger().debug("LEX: POPPING STR:",E.yytext),this.popState();break;case 14:return T.getLogger().debug("LEX: STR end:",E.yytext),"STR";break;case 15:return E.yytext=E.yytext.replace(/space\:/,""),T.getLogger().debug("SPACE NUM (LEX)",E.yytext),21;break;case 16:return E.yytext="1",T.getLogger().debug("COLUMNS (LEX)",E.yytext),21;break;case 17:return 43;case 18:return"LINKSTYLE";case 19:return"INTERPOLATE";case 20:return this.pushState("CLASSDEF"),40;break;case 21:return this.popState(),this.pushState("CLASSDEFID"),"DEFAULT_CLASSDEF_ID";break;case 22:return this.popState(),this.pushState("CLASSDEFID"),41;break;case 23:return this.popState(),42;break;case 24:return this.pushState("CLASS"),44;break;case 25:return this.popState(),this.pushState("CLASS_STYLE"),45;break;case 26:return this.popState(),46;break;case 27:return this.pushState("STYLE_STMNT"),47;break;case 28:return this.popState(),this.pushState("STYLE_DEFINITION"),48;break;case 29:return this.popState(),49;break;case 30:return this.pushState("acc_title"),"acc_title";break;case 31:return this.popState(),"acc_title_value";break;case 32:return this.pushState("acc_descr"),"acc_descr";break;case 33:return this.popState(),"acc_descr_value";break;case 34:this.pushState("acc_descr_multiline");break;case 35:this.popState();break;case 36:return"acc_descr_multiline_value";case 37:return 30;case 38:return this.popState(),T.getLogger().debug("Lex: (("),"NODE_DEND";break;case 39:return this.popState(),T.getLogger().debug("Lex: (("),"NODE_DEND";break;case 40:return this.popState(),T.getLogger().debug("Lex: ))"),"NODE_DEND";break;case 41:return this.popState(),T.getLogger().debug("Lex: (("),"NODE_DEND";break;case 42:return this.popState(),T.getLogger().debug("Lex: (("),"NODE_DEND";break;case 43:return this.popState(),T.getLogger().debug("Lex: (-"),"NODE_DEND";break;case 44:return this.popState(),T.getLogger().debug("Lex: -)"),"NODE_DEND";break;case 45:return this.popState(),T.getLogger().debug("Lex: (("),"NODE_DEND";break;case 46:return this.popState(),T.getLogger().debug("Lex: ]]"),"NODE_DEND";break;case 47:return this.popState(),T.getLogger().debug("Lex: ("),"NODE_DEND";break;case 48:return this.popState(),T.getLogger().debug("Lex: ])"),"NODE_DEND";break;case 49:return this.popState(),T.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 50:return this.popState(),T.getLogger().debug("Lex: /]"),"NODE_DEND";break;case 51:return this.popState(),T.getLogger().debug("Lex: )]"),"NODE_DEND";break;case 52:return this.popState(),T.getLogger().debug("Lex: )"),"NODE_DEND";break;case 53:return this.popState(),T.getLogger().debug("Lex: ]>"),"NODE_DEND";break;case 54:return this.popState(),T.getLogger().debug("Lex: ]"),"NODE_DEND";break;case 55:return T.getLogger().debug("Lexa: -)"),this.pushState("NODE"),36;break;case 56:return T.getLogger().debug("Lexa: (-"),this.pushState("NODE"),36;break;case 57:return T.getLogger().debug("Lexa: ))"),this.pushState("NODE"),36;break;case 58:return T.getLogger().debug("Lexa: )"),this.pushState("NODE"),36;break;case 59:return T.getLogger().debug("Lex: ((("),this.pushState("NODE"),36;break;case 60:return T.getLogger().debug("Lexa: )"),this.pushState("NODE"),36;break;case 61:return T.getLogger().debug("Lexa: )"),this.pushState("NODE"),36;break;case 62:return T.getLogger().debug("Lexa: )"),this.pushState("NODE"),36;break;case 63:return T.getLogger().debug("Lexc: >"),this.pushState("NODE"),36;break;case 64:return T.getLogger().debug("Lexa: (["),this.pushState("NODE"),36;break;case 65:return T.getLogger().debug("Lexa: )"),this.pushState("NODE"),36;break;case 66:return this.pushState("NODE"),36;break;case 67:return this.pushState("NODE"),36;break;case 68:return this.pushState("NODE"),36;break;case 69:return this.pushState("NODE"),36;break;case 70:return this.pushState("NODE"),36;break;case 71:return this.pushState("NODE"),36;break;case 72:return this.pushState("NODE"),36;break;case 73:return T.getLogger().debug("Lexa: ["),this.pushState("NODE"),36;break;case 74:return this.pushState("BLOCK_ARROW"),T.getLogger().debug("LEX ARR START"),38;break;case 75:return T.getLogger().debug("Lex: NODE_ID",E.yytext),32;break;case 76:return T.getLogger().debug("Lex: EOF",E.yytext),8;break;case 77:this.pushState("md_string");break;case 78:this.pushState("md_string");break;case 79:return"NODE_DESCR";case 80:this.popState();break;case 81:T.getLogger().debug("Lex: Starting string"),this.pushState("string");break;case 82:T.getLogger().debug("LEX ARR: Starting string"),this.pushState("string");break;case 83:return T.getLogger().debug("LEX: NODE_DESCR:",E.yytext),"NODE_DESCR";break;case 84:T.getLogger().debug("LEX POPPING"),this.popState();break;case 85:T.getLogger().debug("Lex: =>BAE"),this.pushState("ARROW_DIR");break;case 86:return E.yytext=E.yytext.replace(/^,\s*/,""),T.getLogger().debug("Lex (right): dir:",E.yytext),"DIR";break;case 87:return E.yytext=E.yytext.replace(/^,\s*/,""),T.getLogger().debug("Lex (left):",E.yytext),"DIR";break;case 88:return E.yytext=E.yytext.replace(/^,\s*/,""),T.getLogger().debug("Lex (x):",E.yytext),"DIR";break;case 89:return E.yytext=E.yytext.replace(/^,\s*/,""),T.getLogger().debug("Lex (y):",E.yytext),"DIR";break;case 90:return E.yytext=E.yytext.replace(/^,\s*/,""),T.getLogger().debug("Lex (up):",E.yytext),"DIR";break;case 91:return E.yytext=E.yytext.replace(/^,\s*/,""),T.getLogger().debug("Lex (down):",E.yytext),"DIR";break;case 92:return E.yytext="]>",T.getLogger().debug("Lex (ARROW_DIR end):",E.yytext),this.popState(),this.popState(),"BLOCK_ARROW_END";break;case 93:return T.getLogger().debug("Lex: LINK","#"+E.yytext+"#"),15;break;case 94:return T.getLogger().debug("Lex: LINK",E.yytext),15;break;case 95:return T.getLogger().debug("Lex: LINK",E.yytext),15;break;case 96:return T.getLogger().debug("Lex: LINK",E.yytext),15;break;case 97:return T.getLogger().debug("Lex: START_LINK",E.yytext),this.pushState("LLABEL"),16;break;case 98:return T.getLogger().debug("Lex: START_LINK",E.yytext),this.pushState("LLABEL"),16;break;case 99:return T.getLogger().debug("Lex: START_LINK",E.yytext),this.pushState("LLABEL"),16;break;case 100:this.pushState("md_string");break;case 101:return T.getLogger().debug("Lex: Starting string"),this.pushState("string"),"LINK_LABEL";break;case 102:return this.popState(),T.getLogger().debug("Lex: LINK","#"+E.yytext+"#"),15;break;case 103:return this.popState(),T.getLogger().debug("Lex: LINK",E.yytext),15;break;case 104:return this.popState(),T.getLogger().debug("Lex: LINK",E.yytext),15;break;case 105:return T.getLogger().debug("Lex: COLON",E.yytext),E.yytext=E.yytext.slice(1),27;break}},"anonymous"),rules:[/^(?:block-beta\b)/,/^(?:block\s+)/,/^(?:block\n+)/,/^(?:block:)/,/^(?:[\s]+)/,/^(?:[\n]+)/,/^(?:((\u000D\u000A)|(\u000A)))/,/^(?:columns\s+auto\b)/,/^(?:columns\s+[\d]+)/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]*)/,/^(?:space[:]\d+)/,/^(?:space\b)/,/^(?:default\b)/,/^(?:linkStyle\b)/,/^(?:interpolate\b)/,/^(?:classDef\s+)/,/^(?:DEFAULT\s+)/,/^(?:\w+\s+)/,/^(?:[^\n]*)/,/^(?:class\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:style\s+)/,/^(?:(\w+)+((,\s*\w+)*))/,/^(?:[^\n]*)/,/^(?:accTitle\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*:\s*)/,/^(?:(?!\n||)*[^\n]*)/,/^(?:accDescr\s*\{\s*)/,/^(?:[\}])/,/^(?:[^\}]*)/,/^(?:end\b\s*)/,/^(?:\(\(\()/,/^(?:\)\)\))/,/^(?:[\)]\))/,/^(?:\}\})/,/^(?:\})/,/^(?:\(-)/,/^(?:-\))/,/^(?:\(\()/,/^(?:\]\])/,/^(?:\()/,/^(?:\]\))/,/^(?:\\\])/,/^(?:\/\])/,/^(?:\)\])/,/^(?:[\)])/,/^(?:\]>)/,/^(?:[\]])/,/^(?:-\))/,/^(?:\(-)/,/^(?:\)\))/,/^(?:\))/,/^(?:\(\(\()/,/^(?:\(\()/,/^(?:\{\{)/,/^(?:\{)/,/^(?:>)/,/^(?:\(\[)/,/^(?:\()/,/^(?:\[\[)/,/^(?:\[\|)/,/^(?:\[\()/,/^(?:\)\)\))/,/^(?:\[\\)/,/^(?:\[\/)/,/^(?:\[\\)/,/^(?:\[)/,/^(?:<\[)/,/^(?:[^\(\[\n\-\)\{\}\s\<\>:]+)/,/^(?:$)/,/^(?:["][`])/,/^(?:["][`])/,/^(?:[^`"]+)/,/^(?:[`]["])/,/^(?:["])/,/^(?:["])/,/^(?:[^"]+)/,/^(?:["])/,/^(?:\]>\s*\()/,/^(?:,?\s*right\s*)/,/^(?:,?\s*left\s*)/,/^(?:,?\s*x\s*)/,/^(?:,?\s*y\s*)/,/^(?:,?\s*up\s*)/,/^(?:,?\s*down\s*)/,/^(?:\)\s*)/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?:\s*~~[\~]+\s*)/,/^(?:\s*[xo<]?--\s*)/,/^(?:\s*[xo<]?==\s*)/,/^(?:\s*[xo<]?-\.\s*)/,/^(?:["][`])/,/^(?:["])/,/^(?:\s*[xo<]?--+[-xo>]\s*)/,/^(?:\s*[xo<]?==+[=xo>]\s*)/,/^(?:\s*[xo<]?-?\.+-[xo>]?\s*)/,/^(?::\d+)/],conditions:{STYLE_DEFINITION:{rules:[29],inclusive:!1},STYLE_STMNT:{rules:[28],inclusive:!1},CLASSDEFID:{rules:[23],inclusive:!1},CLASSDEF:{rules:[21,22],inclusive:!1},CLASS_STYLE:{rules:[26],inclusive:!1},CLASS:{rules:[25],inclusive:!1},LLABEL:{rules:[100,101,102,103,104],inclusive:!1},ARROW_DIR:{rules:[86,87,88,89,90,91,92],inclusive:!1},BLOCK_ARROW:{rules:[77,82,85],inclusive:!1},NODE:{rules:[38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,78,81],inclusive:!1},md_string:{rules:[10,11,79,80],inclusive:!1},space:{rules:[],inclusive:!1},string:{rules:[13,14,83,84],inclusive:!1},acc_descr_multiline:{rules:[35,36],inclusive:!1},acc_descr:{rules:[33],inclusive:!1},acc_title:{rules:[31],inclusive:!1},INITIAL:{rules:[0,1,2,3,4,5,6,7,8,9,12,15,16,17,18,19,20,24,27,30,32,34,37,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,93,94,95,96,97,98,99,105],inclusive:!0}}};return w}();v.lexer=x;function b(){this.yy={}}return o(b,"Parser"),b.prototype=v,v.Parser=b,new b}();KB.parser=KB;bye=KB});function Yet(t){switch(Y.debug("typeStr2Type",t),t){case"[]":return"square";case"()":return Y.debug("we have a round"),"round";case"(())":return"circle";case">]":return"rect_left_inv_arrow";case"{}":return"diamond";case"{{}}":return"hexagon";case"([])":return"stadium";case"[[]]":return"subroutine";case"[()]":return"cylinder";case"((()))":return"doublecircle";case"[//]":return"lean_right";case"[\\\\]":return"lean_left";case"[/\\]":return"trapezoid";case"[\\/]":return"inv_trapezoid";case"<[]>":return"block_arrow";default:return"na"}}function Xet(t){switch(Y.debug("typeStr2Type",t),t){case"==":return"thick";default:return"normal"}}function jet(t){switch(t.trim()){case"--x":return"arrow_cross";case"--o":return"arrow_circle";default:return"arrow_point"}}var Ul,ZB,QB,Tye,kye,zet,Sye,Get,eC,Vet,Uet,Het,Wet,Cye,JB,y4,qet,Eye,Ket,Qet,Zet,Jet,ett,ttt,rtt,ntt,itt,att,stt,Aye,_ye=N(()=>{"use strict";gL();ji();zt();vt();gr();mi();Ul=new Map,ZB=[],QB=new Map,Tye="color",kye="fill",zet="bgFill",Sye=",",Get=me(),eC=new Map,Vet=o(t=>Ze.sanitizeText(t,Get),"sanitizeText"),Uet=o(function(t,e=""){let r=eC.get(t);r||(r={id:t,styles:[],textStyles:[]},eC.set(t,r)),e?.split(Sye).forEach(n=>{let i=n.replace(/([^;]*);/,"$1").trim();if(RegExp(Tye).exec(n)){let s=i.replace(kye,zet).replace(Tye,kye);r.textStyles.push(s)}r.styles.push(i)})},"addStyleClass"),Het=o(function(t,e=""){let r=Ul.get(t);e!=null&&(r.styles=e.split(Sye))},"addStyle2Node"),Wet=o(function(t,e){t.split(",").forEach(function(r){let n=Ul.get(r);if(n===void 0){let i=r.trim();n={id:i,type:"na",children:[]},Ul.set(i,n)}n.classes||(n.classes=[]),n.classes.push(e)})},"setCssClass"),Cye=o((t,e)=>{let r=t.flat(),n=[];for(let i of r){if(i.label&&(i.label=Vet(i.label)),i.type==="classDef"){Uet(i.id,i.css);continue}if(i.type==="applyClass"){Wet(i.id,i?.styleClass??"");continue}if(i.type==="applyStyles"){i?.stylesStr&&Het(i.id,i?.stylesStr);continue}if(i.type==="column-setting")e.columns=i.columns??-1;else if(i.type==="edge"){let a=(QB.get(i.id)??0)+1;QB.set(i.id,a),i.id=a+"-"+i.id,ZB.push(i)}else{i.label||(i.type==="composite"?i.label="":i.label=i.id);let a=Ul.get(i.id);if(a===void 0?Ul.set(i.id,i):(i.type!=="na"&&(a.type=i.type),i.label!==i.id&&(a.label=i.label)),i.children&&Cye(i.children,i),i.type==="space"){let s=i.width??1;for(let l=0;l{Y.debug("Clear called"),Ar(),y4={id:"root",type:"composite",children:[],columns:-1},Ul=new Map([["root",y4]]),JB=[],eC=new Map,ZB=[],QB=new Map},"clear");o(Yet,"typeStr2Type");o(Xet,"edgeTypeStr2Type");o(jet,"edgeStrToEdgeData");Eye=0,Ket=o(()=>(Eye++,"id-"+Math.random().toString(36).substr(2,12)+"-"+Eye),"generateId"),Qet=o(t=>{y4.children=t,Cye(t,y4),JB=y4.children},"setHierarchy"),Zet=o(t=>{let e=Ul.get(t);return e?e.columns?e.columns:e.children?e.children.length:-1:-1},"getColumns"),Jet=o(()=>[...Ul.values()],"getBlocksFlat"),ett=o(()=>JB||[],"getBlocks"),ttt=o(()=>ZB,"getEdges"),rtt=o(t=>Ul.get(t),"getBlock"),ntt=o(t=>{Ul.set(t.id,t)},"setBlock"),itt=o(()=>console,"getLogger"),att=o(function(){return eC},"getClasses"),stt={getConfig:o(()=>cr().block,"getConfig"),typeStr2Type:Yet,edgeTypeStr2Type:Xet,edgeStrToEdgeData:jet,getLogger:itt,getBlocksFlat:Jet,getBlocks:ett,getEdges:ttt,setHierarchy:Qet,getBlock:rtt,setBlock:ntt,getColumns:Zet,getClasses:att,clear:qet,generateId:Ket},Aye=stt});var tC,ott,Dye,Lye=N(()=>{"use strict";Ys();tC=o((t,e)=>{let r=Kf,n=r(t,"r"),i=r(t,"g"),a=r(t,"b");return qa(n,i,a,e)},"fade"),ott=o(t=>`.label { + font-family: ${t.fontFamily}; + color: ${t.nodeTextColor||t.textColor}; + } + .cluster-label text { + fill: ${t.titleColor}; + } + .cluster-label span,p { + color: ${t.titleColor}; + } + + + + .label text,span,p { + fill: ${t.nodeTextColor||t.textColor}; + color: ${t.nodeTextColor||t.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${t.mainBkg}; + stroke: ${t.nodeBorder}; + stroke-width: 1px; + } + .flowchart-label text { + text-anchor: middle; + } + // .flowchart-label .text-outer-tspan { + // text-anchor: middle; + // } + // .flowchart-label .text-inner-tspan { + // text-anchor: start; + // } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${t.arrowheadColor}; + } + + .edgePath .path { + stroke: ${t.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${t.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${t.edgeLabelBackground}; + rect { + opacity: 0.5; + background-color: ${t.edgeLabelBackground}; + fill: ${t.edgeLabelBackground}; + } + text-align: center; + } + + /* For html labels only */ + .labelBkg { + background-color: ${tC(t.edgeLabelBackground,.5)}; + // background-color: + } + + .node .cluster { + // fill: ${tC(t.mainBkg,.5)}; + fill: ${tC(t.clusterBkg,.5)}; + stroke: ${tC(t.clusterBorder,.2)}; + box-shadow: rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px; + stroke-width: 1px; + } + + .cluster text { + fill: ${t.titleColor}; + } + + .cluster span,p { + color: ${t.titleColor}; + } + /* .cluster div { + color: ${t.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${t.fontFamily}; + font-size: 12px; + background: ${t.tertiaryColor}; + border: 1px solid ${t.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${t.textColor}; + } +`,"getStyles"),Dye=ott});var ltt,ctt,utt,htt,ftt,dtt,ptt,mtt,gtt,ytt,vtt,Rye,Nye=N(()=>{"use strict";vt();ltt=o((t,e,r,n)=>{e.forEach(i=>{vtt[i](t,r,n)})},"insertMarkers"),ctt=o((t,e,r)=>{Y.trace("Making markers for ",r),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionStart").attr("class","marker extension "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 1,7 L18,13 V 1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-extensionEnd").attr("class","marker extension "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 1,1 V 13 L18,7 Z")},"extension"),utt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionStart").attr("class","marker composition "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-compositionEnd").attr("class","marker composition "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"composition"),htt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationStart").attr("class","marker aggregation "+e).attr("refX",18).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-aggregationEnd").attr("class","marker aggregation "+e).attr("refX",1).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L1,7 L9,1 Z")},"aggregation"),ftt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyStart").attr("class","marker dependency "+e).attr("refX",6).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("path").attr("d","M 5,7 L9,13 L1,7 L9,1 Z"),t.append("defs").append("marker").attr("id",r+"_"+e+"-dependencyEnd").attr("class","marker dependency "+e).attr("refX",13).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 18,7 L9,13 L14,7 L9,1 Z")},"dependency"),dtt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopStart").attr("class","marker lollipop "+e).attr("refX",13).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6),t.append("defs").append("marker").attr("id",r+"_"+e+"-lollipopEnd").attr("class","marker lollipop "+e).attr("refX",1).attr("refY",7).attr("markerWidth",190).attr("markerHeight",240).attr("orient","auto").append("circle").attr("stroke","black").attr("fill","transparent").attr("cx",7).attr("cy",7).attr("r",6)},"lollipop"),ptt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-pointEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",6).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 0 L 10 5 L 0 10 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-pointStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",4.5).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",12).attr("markerHeight",12).attr("orient","auto").append("path").attr("d","M 0 5 L 10 10 L 10 0 z").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"point"),mtt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-circleEnd").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",11).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-circleStart").attr("class","marker "+e).attr("viewBox","0 0 10 10").attr("refX",-1).attr("refY",5).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("circle").attr("cx","5").attr("cy","5").attr("r","5").attr("class","arrowMarkerPath").style("stroke-width",1).style("stroke-dasharray","1,0")},"circle"),gtt=o((t,e,r)=>{t.append("marker").attr("id",r+"_"+e+"-crossEnd").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",12).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0"),t.append("marker").attr("id",r+"_"+e+"-crossStart").attr("class","marker cross "+e).attr("viewBox","0 0 11 11").attr("refX",-1).attr("refY",5.2).attr("markerUnits","userSpaceOnUse").attr("markerWidth",11).attr("markerHeight",11).attr("orient","auto").append("path").attr("d","M 1,1 l 9,9 M 10,1 l -9,9").attr("class","arrowMarkerPath").style("stroke-width",2).style("stroke-dasharray","1,0")},"cross"),ytt=o((t,e,r)=>{t.append("defs").append("marker").attr("id",r+"_"+e+"-barbEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",14).attr("markerUnits","strokeWidth").attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"barb"),vtt={extension:ctt,composition:utt,aggregation:htt,dependency:ftt,lollipop:dtt,point:ptt,circle:mtt,cross:gtt,barb:ytt},Rye=ltt});function xtt(t,e){if(t===0||!Number.isInteger(t))throw new Error("Columns must be an integer !== 0.");if(e<0||!Number.isInteger(e))throw new Error("Position must be a non-negative integer."+e);if(t<0)return{px:e,py:0};if(t===1)return{px:0,py:e};let r=e%t,n=Math.floor(e/t);return{px:r,py:n}}function eF(t,e,r=0,n=0){Y.debug("setBlockSizes abc95 (start)",t.id,t?.size?.x,"block width =",t?.size,"sieblingWidth",r),t?.size?.width||(t.size={width:r,height:n,x:0,y:0});let i=0,a=0;if(t.children?.length>0){for(let m of t.children)eF(m,e);let s=btt(t);i=s.width,a=s.height,Y.debug("setBlockSizes abc95 maxWidth of",t.id,":s children is ",i,a);for(let m of t.children)m.size&&(Y.debug(`abc95 Setting size of children of ${t.id} id=${m.id} ${i} ${a} ${JSON.stringify(m.size)}`),m.size.width=i*(m.widthInColumns??1)+bi*((m.widthInColumns??1)-1),m.size.height=a,m.size.x=0,m.size.y=0,Y.debug(`abc95 updating size of ${t.id} children child:${m.id} maxWidth:${i} maxHeight:${a}`));for(let m of t.children)eF(m,e,i,a);let l=t.columns??-1,u=0;for(let m of t.children)u+=m.widthInColumns??1;let h=t.children.length;l>0&&l0?Math.min(t.children.length,l):t.children.length;if(m>0){let g=(d-m*bi-bi)/m;Y.debug("abc95 (growing to fit) width",t.id,d,t.size?.width,g);for(let y of t.children)y.size&&(y.size.width=g)}}t.size={width:d,height:p,x:0,y:0}}Y.debug("setBlockSizes abc94 (done)",t.id,t?.size?.x,t?.size?.width,t?.size?.y,t?.size?.height)}function Mye(t,e){Y.debug(`abc85 layout blocks (=>layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`);let r=t.columns??-1;if(Y.debug("layoutBlocks columns abc95",t.id,"=>",r,t),t.children&&t.children.length>0){let n=t?.children[0]?.size?.width??0,i=t.children.length*n+(t.children.length-1)*bi;Y.debug("widthOfChildren 88",i,"posX");let a=0;Y.debug("abc91 block?.size?.x",t.id,t?.size?.x);let s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-bi,l=0;for(let u of t.children){let h=t;if(!u.size)continue;let{width:f,height:d}=u.size,{px:p,py:m}=xtt(r,a);if(m!=l&&(l=m,s=t?.size?.x?t?.size?.x+(-t?.size?.width/2||0):-bi,Y.debug("New row in layout for block",t.id," and child ",u.id,l)),Y.debug(`abc89 layout blocks (child) id: ${u.id} Pos: ${a} (px, py) ${p},${m} (${h?.size?.x},${h?.size?.y}) parent: ${h.id} width: ${f}${bi}`),h.size){let g=f/2;u.size.x=s+bi+g,Y.debug(`abc91 layout blocks (calc) px, pyid:${u.id} startingPos=X${s} new startingPosX${u.size.x} ${g} padding=${bi} width=${f} halfWidth=${g} => x:${u.size.x} y:${u.size.y} ${u.widthInColumns} (width * (child?.w || 1)) / 2 ${f*(u?.widthInColumns??1)/2}`),s=u.size.x+g,u.size.y=h.size.y-h.size.height/2+m*(d+bi)+d/2+bi,Y.debug(`abc88 layout blocks (calc) px, pyid:${u.id}startingPosX${s}${bi}${g}=>x:${u.size.x}y:${u.size.y}${u.widthInColumns}(width * (child?.w || 1)) / 2${f*(u?.widthInColumns??1)/2}`)}u.children&&Mye(u,e),a+=u?.widthInColumns??1,Y.debug("abc88 columnsPos",u,a)}}Y.debug(`layout blocks (<==layoutBlocks) ${t.id} x: ${t?.size?.x} y: ${t?.size?.y} width: ${t?.size?.width}`)}function Iye(t,{minX:e,minY:r,maxX:n,maxY:i}={minX:0,minY:0,maxX:0,maxY:0}){if(t.size&&t.id!=="root"){let{x:a,y:s,width:l,height:u}=t.size;a-l/2n&&(n=a+l/2),s+u/2>i&&(i=s+u/2)}if(t.children)for(let a of t.children)({minX:e,minY:r,maxX:n,maxY:i}=Iye(a,{minX:e,minY:r,maxX:n,maxY:i}));return{minX:e,minY:r,maxX:n,maxY:i}}function Oye(t){let e=t.getBlock("root");if(!e)return;eF(e,t,0,0),Mye(e,t),Y.debug("getBlocks",JSON.stringify(e,null,2));let{minX:r,minY:n,maxX:i,maxY:a}=Iye(e),s=a-n,l=i-r;return{x:r,y:n,width:l,height:s}}var bi,btt,Pye=N(()=>{"use strict";vt();zt();bi=me()?.block?.padding??8;o(xtt,"calculateBlockPosition");btt=o(t=>{let e=0,r=0;for(let n of t.children){let{width:i,height:a,x:s,y:l}=n.size??{width:0,height:0,x:0,y:0};Y.debug("getMaxChildSize abc95 child:",n.id,"width:",i,"height:",a,"x:",s,"y:",l,n.type),n.type!=="space"&&(i>e&&(e=i/(t.widthInColumns??1)),a>r&&(r=a))}return{width:e,height:r}},"getMaxChildSize");o(eF,"setBlockSizes");o(Mye,"layoutBlocks");o(Iye,"findBounds");o(Oye,"layout")});function Bye(t,e){e&&t.attr("style",e)}function wtt(t){let e=Ge(document.createElementNS("http://www.w3.org/2000/svg","foreignObject")),r=e.append("xhtml:div"),n=t.label,i=t.isNode?"nodeLabel":"edgeLabel",a=r.append("span");return a.html(n),Bye(a,t.labelStyle),a.attr("class",i),Bye(r,t.labelStyle),r.style("display","inline-block"),r.style("white-space","nowrap"),r.attr("xmlns","http://www.w3.org/1999/xhtml"),e.node()}var Ttt,vs,rC=N(()=>{"use strict";dr();vt();zt();gr();ir();to();o(Bye,"applyStyle");o(wtt,"addHtmlLabel");Ttt=o((t,e,r,n)=>{let i=t||"";if(typeof i=="object"&&(i=i[0]),fr(me().flowchart.htmlLabels)){i=i.replace(/\\n|\n/g,"
    "),Y.debug("vertexText"+i);let a={isNode:n,label:DD(na(i)),labelStyle:e.replace("fill:","color:")};return wtt(a)}else{let a=document.createElementNS("http://www.w3.org/2000/svg","text");a.setAttribute("style",e.replace("color:","fill:"));let s=[];typeof i=="string"?s=i.split(/\\n|\n|/gi):Array.isArray(i)?s=i:s=[];for(let l of s){let u=document.createElementNS("http://www.w3.org/2000/svg","tspan");u.setAttributeNS("http://www.w3.org/XML/1998/namespace","xml:space","preserve"),u.setAttribute("dy","1em"),u.setAttribute("x","0"),r?u.setAttribute("class","title-row"):u.setAttribute("class","row"),u.textContent=l.trim(),a.appendChild(u)}return a}},"createLabel"),vs=Ttt});var $ye,ktt,Fye,zye=N(()=>{"use strict";vt();$ye=o((t,e,r,n,i)=>{e.arrowTypeStart&&Fye(t,"start",e.arrowTypeStart,r,n,i),e.arrowTypeEnd&&Fye(t,"end",e.arrowTypeEnd,r,n,i)},"addEdgeMarkers"),ktt={arrow_cross:"cross",arrow_point:"point",arrow_barb:"barb",arrow_circle:"circle",aggregation:"aggregation",extension:"extension",composition:"composition",dependency:"dependency",lollipop:"lollipop"},Fye=o((t,e,r,n,i,a)=>{let s=ktt[r];if(!s){Y.warn(`Unknown arrow type: ${r}`);return}let l=e==="start"?"Start":"End";t.attr(`marker-${e}`,`url(${n}#${i}_${a}-${s}${l})`)},"addEdgeMarker")});function nC(t,e){me().flowchart.htmlLabels&&t&&(t.style.width=e.length*9+"px",t.style.height="12px")}var tF,Ua,Vye,Uye,Ett,Stt,Gye,Hye,Wye=N(()=>{"use strict";vt();rC();to();dr();zt();ir();gr();JD();w2();zye();tF={},Ua={},Vye=o((t,e)=>{let r=me(),n=fr(r.flowchart.htmlLabels),i=e.labelType==="markdown"?Hn(t,e.label,{style:e.labelStyle,useHtmlLabels:n,addSvgBackground:!0},r):vs(e.label,e.labelStyle),a=t.insert("g").attr("class","edgeLabel"),s=a.insert("g").attr("class","label");s.node().appendChild(i);let l=i.getBBox();if(n){let h=i.children[0],f=Ge(i);l=h.getBoundingClientRect(),f.attr("width",l.width),f.attr("height",l.height)}s.attr("transform","translate("+-l.width/2+", "+-l.height/2+")"),tF[e.id]=a,e.width=l.width,e.height=l.height;let u;if(e.startLabelLeft){let h=vs(e.startLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Ua[e.id]||(Ua[e.id]={}),Ua[e.id].startLeft=f,nC(u,e.startLabelLeft)}if(e.startLabelRight){let h=vs(e.startLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=f.node().appendChild(h),d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),Ua[e.id]||(Ua[e.id]={}),Ua[e.id].startRight=f,nC(u,e.startLabelRight)}if(e.endLabelLeft){let h=vs(e.endLabelLeft,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Ua[e.id]||(Ua[e.id]={}),Ua[e.id].endLeft=f,nC(u,e.endLabelLeft)}if(e.endLabelRight){let h=vs(e.endLabelRight,e.labelStyle),f=t.insert("g").attr("class","edgeTerminals"),d=f.insert("g").attr("class","inner");u=d.node().appendChild(h);let p=h.getBBox();d.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),f.node().appendChild(h),Ua[e.id]||(Ua[e.id]={}),Ua[e.id].endRight=f,nC(u,e.endLabelRight)}return i},"insertEdgeLabel");o(nC,"setTerminalWidth");Uye=o((t,e)=>{Y.debug("Moving label abc88 ",t.id,t.label,tF[t.id],e);let r=e.updatedPath?e.updatedPath:e.originalPath,n=me(),{subGraphTitleTotalMargin:i}=Ru(n);if(t.label){let a=tF[t.id],s=t.x,l=t.y;if(r){let u=Gt.calcLabelPosition(r);Y.debug("Moving label "+t.label+" from (",s,",",l,") to (",u.x,",",u.y,") abc88"),e.updatedPath&&(s=u.x,l=u.y)}a.attr("transform",`translate(${s}, ${l+i/2})`)}if(t.startLabelLeft){let a=Ua[t.id].startLeft,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.startLabelRight){let a=Ua[t.id].startRight,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeStart?10:0,"start_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelLeft){let a=Ua[t.id].endLeft,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_left",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}if(t.endLabelRight){let a=Ua[t.id].endRight,s=t.x,l=t.y;if(r){let u=Gt.calcTerminalLabelPosition(t.arrowTypeEnd?10:0,"end_right",r);s=u.x,l=u.y}a.attr("transform",`translate(${s}, ${l})`)}},"positionEdgeLabel"),Ett=o((t,e)=>{let r=t.x,n=t.y,i=Math.abs(e.x-r),a=Math.abs(e.y-n),s=t.width/2,l=t.height/2;return i>=s||a>=l},"outsideNode"),Stt=o((t,e,r)=>{Y.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(e)} + insidePoint : ${JSON.stringify(r)} + node : x:${t.x} y:${t.y} w:${t.width} h:${t.height}`);let n=t.x,i=t.y,a=Math.abs(n-r.x),s=t.width/2,l=r.xMath.abs(n-e.x)*u){let d=r.y{Y.debug("abc88 cutPathAtIntersect",t,e);let r=[],n=t[0],i=!1;return t.forEach(a=>{if(!Ett(e,a)&&!i){let s=Stt(e,n,a),l=!1;r.forEach(u=>{l=l||u.x===s.x&&u.y===s.y}),r.some(u=>u.x===s.x&&u.y===s.y)||r.push(s),i=!0}else n=a,i||r.push(a)}),r},"cutPathAtIntersect"),Hye=o(function(t,e,r,n,i,a,s){let l=r.points;Y.debug("abc88 InsertEdge: edge=",r,"e=",e);let u=!1,h=a.node(e.v);var f=a.node(e.w);f?.intersect&&h?.intersect&&(l=l.slice(1,r.points.length-1),l.unshift(h.intersect(l[0])),l.push(f.intersect(l[l.length-1]))),r.toCluster&&(Y.debug("to cluster abc88",n[r.toCluster]),l=Gye(r.points,n[r.toCluster].node),u=!0),r.fromCluster&&(Y.debug("from cluster abc88",n[r.fromCluster]),l=Gye(l.reverse(),n[r.fromCluster].node).reverse(),u=!0);let d=l.filter(C=>!Number.isNaN(C.y)),p=Do;r.curve&&(i==="graph"||i==="flowchart")&&(p=r.curve);let{x:m,y:g}=qw(r),y=wl().x(m).y(g).curve(p),v;switch(r.thickness){case"normal":v="edge-thickness-normal";break;case"thick":v="edge-thickness-thick";break;case"invisible":v="edge-thickness-thick";break;default:v=""}switch(r.pattern){case"solid":v+=" edge-pattern-solid";break;case"dotted":v+=" edge-pattern-dotted";break;case"dashed":v+=" edge-pattern-dashed";break}let x=t.append("path").attr("d",y(d)).attr("id",r.id).attr("class"," "+v+(r.classes?" "+r.classes:"")).attr("style",r.style),b="";(me().flowchart.arrowMarkerAbsolute||me().state.arrowMarkerAbsolute)&&(b=window.location.protocol+"//"+window.location.host+window.location.pathname+window.location.search,b=b.replace(/\(/g,"\\("),b=b.replace(/\)/g,"\\)")),$ye(x,r,b,s,i);let w={};return u&&(w.updatedPath=l),w.originalPath=r.points,w},"insertEdge")});var Ctt,qye,Yye=N(()=>{"use strict";Ctt=o(t=>{let e=new Set;for(let r of t)switch(r){case"x":e.add("right"),e.add("left");break;case"y":e.add("up"),e.add("down");break;default:e.add(r);break}return e},"expandAndDeduplicateDirections"),qye=o((t,e,r)=>{let n=Ctt(t),i=2,a=e.height+2*r.padding,s=a/i,l=e.width+2*s+r.padding,u=r.padding/2;return n.has("right")&&n.has("left")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:s,y:0},{x:l/2,y:2*u},{x:l-s,y:0},{x:l,y:0},{x:l,y:-a/3},{x:l+2*u,y:-a/2},{x:l,y:-2*a/3},{x:l,y:-a},{x:l-s,y:-a},{x:l/2,y:-a-2*u},{x:s,y:-a},{x:0,y:-a},{x:0,y:-2*a/3},{x:-2*u,y:-a/2},{x:0,y:-a/3}]:n.has("right")&&n.has("left")&&n.has("up")?[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}]:n.has("right")&&n.has("left")&&n.has("down")?[{x:0,y:0},{x:s,y:-a},{x:l-s,y:-a},{x:l,y:0}]:n.has("right")&&n.has("up")&&n.has("down")?[{x:0,y:0},{x:l,y:-s},{x:l,y:-a+s},{x:0,y:-a}]:n.has("left")&&n.has("up")&&n.has("down")?[{x:l,y:0},{x:0,y:-s},{x:0,y:-a+s},{x:l,y:-a}]:n.has("right")&&n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")&&n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:n.has("right")&&n.has("up")?[{x:0,y:0},{x:l,y:-s},{x:0,y:-a}]:n.has("right")&&n.has("down")?[{x:0,y:0},{x:l,y:0},{x:0,y:-a}]:n.has("left")&&n.has("up")?[{x:l,y:0},{x:0,y:-s},{x:l,y:-a}]:n.has("left")&&n.has("down")?[{x:l,y:0},{x:0,y:0},{x:l,y:-a}]:n.has("right")?[{x:s,y:-u},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a+u}]:n.has("left")?[{x:s,y:0},{x:s,y:-u},{x:l-s,y:-u},{x:l-s,y:-a+u},{x:s,y:-a+u},{x:s,y:-a},{x:0,y:-a/2}]:n.has("up")?[{x:s,y:-u},{x:s,y:-a+u},{x:0,y:-a+u},{x:l/2,y:-a},{x:l,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u}]:n.has("down")?[{x:l/2,y:0},{x:0,y:-u},{x:s,y:-u},{x:s,y:-a+u},{x:l-s,y:-a+u},{x:l-s,y:-u},{x:l,y:-u}]:[{x:0,y:0}]},"getArrowPoints")});function Att(t,e){return t.intersect(e)}var Xye,jye=N(()=>{"use strict";o(Att,"intersectNode");Xye=Att});function _tt(t,e,r,n){var i=t.x,a=t.y,s=i-n.x,l=a-n.y,u=Math.sqrt(e*e*l*l+r*r*s*s),h=Math.abs(e*r*s/u);n.x{"use strict";o(_tt,"intersectEllipse");iC=_tt});function Dtt(t,e,r){return iC(t,e,e,r)}var Kye,Qye=N(()=>{"use strict";rF();o(Dtt,"intersectCircle");Kye=Dtt});function Ltt(t,e,r,n){var i,a,s,l,u,h,f,d,p,m,g,y,v,x,b;if(i=e.y-t.y,s=t.x-e.x,u=e.x*t.y-t.x*e.y,p=i*r.x+s*r.y+u,m=i*n.x+s*n.y+u,!(p!==0&&m!==0&&Zye(p,m))&&(a=n.y-r.y,l=r.x-n.x,h=n.x*r.y-r.x*n.y,f=a*t.x+l*t.y+h,d=a*e.x+l*e.y+h,!(f!==0&&d!==0&&Zye(f,d))&&(g=i*l-a*s,g!==0)))return y=Math.abs(g/2),v=s*h-l*u,x=v<0?(v-y)/g:(v+y)/g,v=a*u-i*h,b=v<0?(v-y)/g:(v+y)/g,{x,y:b}}function Zye(t,e){return t*e>0}var Jye,eve=N(()=>{"use strict";o(Ltt,"intersectLine");o(Zye,"sameSign");Jye=Ltt});function Rtt(t,e,r){var n=t.x,i=t.y,a=[],s=Number.POSITIVE_INFINITY,l=Number.POSITIVE_INFINITY;typeof e.forEach=="function"?e.forEach(function(g){s=Math.min(s,g.x),l=Math.min(l,g.y)}):(s=Math.min(s,e.x),l=Math.min(l,e.y));for(var u=n-t.width/2-s,h=i-t.height/2-l,f=0;f1&&a.sort(function(g,y){var v=g.x-r.x,x=g.y-r.y,b=Math.sqrt(v*v+x*x),w=y.x-r.x,C=y.y-r.y,T=Math.sqrt(w*w+C*C);return b{"use strict";eve();tve=Rtt;o(Rtt,"intersectPolygon")});var Ntt,nve,ive=N(()=>{"use strict";Ntt=o((t,e)=>{var r=t.x,n=t.y,i=e.x-r,a=e.y-n,s=t.width/2,l=t.height/2,u,h;return Math.abs(a)*s>Math.abs(i)*l?(a<0&&(l=-l),u=a===0?0:l*i/a,h=l):(i<0&&(s=-s),u=s,h=i===0?0:s*a/i),{x:r+u,y:n+h}},"intersectRect"),nve=Ntt});var In,nF=N(()=>{"use strict";jye();Qye();rF();rve();ive();In={node:Xye,circle:Kye,ellipse:iC,polygon:tve,rect:nve}});function Hl(t,e,r,n){return t.insert("polygon",":first-child").attr("points",n.map(function(i){return i.x+","+i.y}).join(" ")).attr("class","label-container").attr("transform","translate("+-e/2+","+r/2+")")}var Di,Qn,iF=N(()=>{"use strict";rC();to();zt();dr();gr();ir();Di=o(async(t,e,r,n)=>{let i=me(),a,s=e.useHtmlLabels||fr(i.flowchart.htmlLabels);r?a=r:a="node default";let l=t.insert("g").attr("class",a).attr("id",e.domId||e.id),u=l.insert("g").attr("class","label").attr("style",e.labelStyle),h;e.labelText===void 0?h="":h=typeof e.labelText=="string"?e.labelText:e.labelText[0];let f=u.node(),d;e.labelType==="markdown"?d=Hn(u,Tr(na(h),i),{useHtmlLabels:s,width:e.width||i.flowchart.wrappingWidth,classes:"markdown-node-label"},i):d=f.appendChild(vs(Tr(na(h),i),e.labelStyle,!1,n));let p=d.getBBox(),m=e.padding/2;if(fr(i.flowchart.htmlLabels)){let g=d.children[0],y=Ge(d),v=g.getElementsByTagName("img");if(v){let x=h.replace(/]*>/g,"").trim()==="";await Promise.all([...v].map(b=>new Promise(w=>{function C(){if(b.style.display="flex",b.style.flexDirection="column",x){let T=i.fontSize?i.fontSize:window.getComputedStyle(document.body).fontSize,A=parseInt(T,10)*5+"px";b.style.minWidth=A,b.style.maxWidth=A}else b.style.width="100%";w(b)}o(C,"setupImage"),setTimeout(()=>{b.complete&&C()}),b.addEventListener("error",C),b.addEventListener("load",C)})))}p=g.getBoundingClientRect(),y.attr("width",p.width),y.attr("height",p.height)}return s?u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"):u.attr("transform","translate(0, "+-p.height/2+")"),e.centerLabel&&u.attr("transform","translate("+-p.width/2+", "+-p.height/2+")"),u.insert("rect",":first-child"),{shapeSvg:l,bbox:p,halfPadding:m,label:u}},"labelHelper"),Qn=o((t,e)=>{let r=e.node().getBBox();t.width=r.width,t.height=r.height},"updateNodeBounds");o(Hl,"insertPolygonShape")});var Mtt,ave,sve=N(()=>{"use strict";iF();vt();zt();nF();Mtt=o(async(t,e)=>{e.useHtmlLabels||me().flowchart.htmlLabels||(e.centerLabel=!0);let{shapeSvg:n,bbox:i,halfPadding:a}=await Di(t,e,"node "+e.classes,!0);Y.info("Classes = ",e.classes);let s=n.insert("rect",":first-child");return s.attr("rx",e.rx).attr("ry",e.ry).attr("x",-i.width/2-a).attr("y",-i.height/2-a).attr("width",i.width+e.padding).attr("height",i.height+e.padding),Qn(e,s),e.intersect=function(l){return In.rect(e,l)},n},"note"),ave=Mtt});function aF(t,e,r,n){let i=[],a=o(l=>{i.push(l,0)},"addBorder"),s=o(l=>{i.push(0,l)},"skipBorder");e.includes("t")?(Y.debug("add top border"),a(r)):s(r),e.includes("r")?(Y.debug("add right border"),a(n)):s(n),e.includes("b")?(Y.debug("add bottom border"),a(r)):s(r),e.includes("l")?(Y.debug("add left border"),a(n)):s(n),t.attr("stroke-dasharray",i.join(" "))}var ove,yo,lve,Itt,Ott,Ptt,Btt,Ftt,$tt,ztt,Gtt,Vtt,Utt,Htt,Wtt,qtt,Ytt,Xtt,jtt,Ktt,Qtt,Ztt,cve,Jtt,ert,uve,aC,sF,hve,fve=N(()=>{"use strict";dr();zt();gr();vt();Yye();rC();nF();sve();iF();ove=o(t=>t?" "+t:"","formatClass"),yo=o((t,e)=>`${e||"node default"}${ove(t.classes)} ${ove(t.class)}`,"getClassesFromNode"),lve=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=i+a,l=[{x:s/2,y:0},{x:s,y:-s/2},{x:s/2,y:-s},{x:0,y:-s/2}];Y.info("Question main (Circle)");let u=Hl(r,s,s,l);return u.attr("style",e.style),Qn(e,u),e.intersect=function(h){return Y.warn("Intersect called"),In.polygon(e,l,h)},r},"question"),Itt=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=28,i=[{x:0,y:n/2},{x:n/2,y:0},{x:0,y:-n/2},{x:-n/2,y:0}];return r.insert("polygon",":first-child").attr("points",i.map(function(s){return s.x+","+s.y}).join(" ")).attr("class","state-start").attr("r",7).attr("width",28).attr("height",28),e.width=28,e.height=28,e.intersect=function(s){return In.circle(e,14,s)},r},"choice"),Ott=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=4,a=n.height+e.padding,s=a/i,l=n.width+2*s+e.padding,u=[{x:s,y:0},{x:l-s,y:0},{x:l,y:-a/2},{x:l-s,y:-a},{x:s,y:-a},{x:0,y:-a/2}],h=Hl(r,l,a,u);return h.attr("style",e.style),Qn(e,h),e.intersect=function(f){return In.polygon(e,u,f)},r},"hexagon"),Ptt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,void 0,!0),i=2,a=n.height+2*e.padding,s=a/i,l=n.width+2*s+e.padding,u=qye(e.directions,n,e),h=Hl(r,l,a,u);return h.attr("style",e.style),Qn(e,h),e.intersect=function(f){return In.polygon(e,u,f)},r},"block_arrow"),Btt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-a/2,y:0},{x:i,y:0},{x:i,y:-a},{x:-a/2,y:-a},{x:0,y:-a/2}];return Hl(r,i,a,s).attr("style",e.style),e.width=i+a,e.height=a,e.intersect=function(u){return In.polygon(e,s,u)},r},"rect_left_inv_arrow"),Ftt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:a/6,y:-a}],l=Hl(r,i,a,s);return l.attr("style",e.style),Qn(e,l),e.intersect=function(u){return In.polygon(e,s,u)},r},"lean_right"),$tt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:2*a/6,y:0},{x:i+a/6,y:0},{x:i-2*a/6,y:-a},{x:-a/6,y:-a}],l=Hl(r,i,a,s);return l.attr("style",e.style),Qn(e,l),e.intersect=function(u){return In.polygon(e,s,u)},r},"lean_left"),ztt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:-2*a/6,y:0},{x:i+2*a/6,y:0},{x:i-a/6,y:-a},{x:a/6,y:-a}],l=Hl(r,i,a,s);return l.attr("style",e.style),Qn(e,l),e.intersect=function(u){return In.polygon(e,s,u)},r},"trapezoid"),Gtt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:a/6,y:0},{x:i-a/6,y:0},{x:i+2*a/6,y:-a},{x:-2*a/6,y:-a}],l=Hl(r,i,a,s);return l.attr("style",e.style),Qn(e,l),e.intersect=function(u){return In.polygon(e,s,u)},r},"inv_trapezoid"),Vtt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i+a/2,y:0},{x:i,y:-a/2},{x:i+a/2,y:-a},{x:0,y:-a}],l=Hl(r,i,a,s);return l.attr("style",e.style),Qn(e,l),e.intersect=function(u){return In.polygon(e,s,u)},r},"rect_right_inv_arrow"),Utt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=i/2,s=a/(2.5+i/50),l=n.height+s+e.padding,u="M 0,"+s+" a "+a+","+s+" 0,0,0 "+i+" 0 a "+a+","+s+" 0,0,0 "+-i+" 0 l 0,"+l+" a "+a+","+s+" 0,0,0 "+i+" 0 l 0,"+-l,h=r.attr("label-offset-y",s).insert("path",":first-child").attr("style",e.style).attr("d",u).attr("transform","translate("+-i/2+","+-(l/2+s)+")");return Qn(e,h),e.intersect=function(f){let d=In.rect(e,f),p=d.x-e.x;if(a!=0&&(Math.abs(p)e.height/2-s)){let m=s*s*(1-p*p/(a*a));m!=0&&(m=Math.sqrt(m)),m=s-m,f.y-e.y>0&&(m=-m),d.y+=m}return d},r},"cylinder"),Htt=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Di(t,e,"node "+e.classes+" "+e.class,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(aF(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{Y.warn(`Unknown node property ${d}`)})}return Qn(e,a),e.intersect=function(f){return In.rect(e,f)},r},"rect"),Wtt=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Di(t,e,"node "+e.classes,!0),a=r.insert("rect",":first-child"),s=e.positioned?e.width:n.width+e.padding,l=e.positioned?e.height:n.height+e.padding,u=e.positioned?-s/2:-n.width/2-i,h=e.positioned?-l/2:-n.height/2-i;if(a.attr("class","basic cluster composite label-container").attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("x",u).attr("y",h).attr("width",s).attr("height",l),e.props){let f=new Set(Object.keys(e.props));e.props.borders&&(aF(a,e.props.borders,s,l),f.delete("borders")),f.forEach(d=>{Y.warn(`Unknown node property ${d}`)})}return Qn(e,a),e.intersect=function(f){return In.rect(e,f)},r},"composite"),qtt=o(async(t,e)=>{let{shapeSvg:r}=await Di(t,e,"label",!0);Y.trace("Classes = ",e.class);let n=r.insert("rect",":first-child"),i=0,a=0;if(n.attr("width",i).attr("height",a),r.attr("class","label edgeLabel"),e.props){let s=new Set(Object.keys(e.props));e.props.borders&&(aF(n,e.props.borders,i,a),s.delete("borders")),s.forEach(l=>{Y.warn(`Unknown node property ${l}`)})}return Qn(e,n),e.intersect=function(s){return In.rect(e,s)},r},"labelRect");o(aF,"applyNodePropertyBorders");Ytt=o((t,e)=>{let r;e.classes?r="node "+e.classes:r="node default";let n=t.insert("g").attr("class",r).attr("id",e.domId||e.id),i=n.insert("rect",":first-child"),a=n.insert("line"),s=n.insert("g").attr("class","label"),l=e.labelText.flat?e.labelText.flat():e.labelText,u="";typeof l=="object"?u=l[0]:u=l,Y.info("Label text abc79",u,l,typeof l=="object");let h=s.node().appendChild(vs(u,e.labelStyle,!0,!0)),f={width:0,height:0};if(fr(me().flowchart.htmlLabels)){let y=h.children[0],v=Ge(h);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}Y.info("Text 2",l);let d=l.slice(1,l.length),p=h.getBBox(),m=s.node().appendChild(vs(d.join?d.join("
    "):d,e.labelStyle,!0,!0));if(fr(me().flowchart.htmlLabels)){let y=m.children[0],v=Ge(m);f=y.getBoundingClientRect(),v.attr("width",f.width),v.attr("height",f.height)}let g=e.padding/2;return Ge(m).attr("transform","translate( "+(f.width>p.width?0:(p.width-f.width)/2)+", "+(p.height+g+5)+")"),Ge(h).attr("transform","translate( "+(f.width{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.height+e.padding,a=n.width+i/4+e.padding,s=r.insert("rect",":first-child").attr("style",e.style).attr("rx",i/2).attr("ry",i/2).attr("x",-a/2).attr("y",-i/2).attr("width",a).attr("height",i);return Qn(e,s),e.intersect=function(l){return In.rect(e,l)},r},"stadium"),jtt=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Di(t,e,yo(e,void 0),!0),a=r.insert("circle",":first-child");return a.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),Y.info("Circle main"),Qn(e,a),e.intersect=function(s){return Y.info("Circle intersect",e,n.width/2+i,s),In.circle(e,n.width/2+i,s)},r},"circle"),Ktt=o(async(t,e)=>{let{shapeSvg:r,bbox:n,halfPadding:i}=await Di(t,e,yo(e,void 0),!0),a=5,s=r.insert("g",":first-child"),l=s.insert("circle"),u=s.insert("circle");return s.attr("class",e.class),l.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i+a).attr("width",n.width+e.padding+a*2).attr("height",n.height+e.padding+a*2),u.attr("style",e.style).attr("rx",e.rx).attr("ry",e.ry).attr("r",n.width/2+i).attr("width",n.width+e.padding).attr("height",n.height+e.padding),Y.info("DoubleCircle main"),Qn(e,l),e.intersect=function(h){return Y.info("DoubleCircle intersect",e,n.width/2+i+a,h),In.circle(e,n.width/2+i+a,h)},r},"doublecircle"),Qtt=o(async(t,e)=>{let{shapeSvg:r,bbox:n}=await Di(t,e,yo(e,void 0),!0),i=n.width+e.padding,a=n.height+e.padding,s=[{x:0,y:0},{x:i,y:0},{x:i,y:-a},{x:0,y:-a},{x:0,y:0},{x:-8,y:0},{x:i+8,y:0},{x:i+8,y:-a},{x:-8,y:-a},{x:-8,y:0}],l=Hl(r,i,a,s);return l.attr("style",e.style),Qn(e,l),e.intersect=function(u){return In.polygon(e,s,u)},r},"subroutine"),Ztt=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child");return n.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),Qn(e,n),e.intersect=function(i){return In.circle(e,7,i)},r},"start"),cve=o((t,e,r)=>{let n=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),i=70,a=10;r==="LR"&&(i=10,a=70);let s=n.append("rect").attr("x",-1*i/2).attr("y",-1*a/2).attr("width",i).attr("height",a).attr("class","fork-join");return Qn(e,s),e.height=e.height+e.padding/2,e.width=e.width+e.padding/2,e.intersect=function(l){return In.rect(e,l)},n},"forkJoin"),Jtt=o((t,e)=>{let r=t.insert("g").attr("class","node default").attr("id",e.domId||e.id),n=r.insert("circle",":first-child"),i=r.insert("circle",":first-child");return i.attr("class","state-start").attr("r",7).attr("width",14).attr("height",14),n.attr("class","state-end").attr("r",5).attr("width",10).attr("height",10),Qn(e,i),e.intersect=function(a){return In.circle(e,7,a)},r},"end"),ert=o((t,e)=>{let r=e.padding/2,n=4,i=8,a;e.classes?a="node "+e.classes:a="node default";let s=t.insert("g").attr("class",a).attr("id",e.domId||e.id),l=s.insert("rect",":first-child"),u=s.insert("line"),h=s.insert("line"),f=0,d=n,p=s.insert("g").attr("class","label"),m=0,g=e.classData.annotations?.[0],y=e.classData.annotations[0]?"\xAB"+e.classData.annotations[0]+"\xBB":"",v=p.node().appendChild(vs(y,e.labelStyle,!0,!0)),x=v.getBBox();if(fr(me().flowchart.htmlLabels)){let S=v.children[0],_=Ge(v);x=S.getBoundingClientRect(),_.attr("width",x.width),_.attr("height",x.height)}e.classData.annotations[0]&&(d+=x.height+n,f+=x.width);let b=e.classData.label;e.classData.type!==void 0&&e.classData.type!==""&&(me().flowchart.htmlLabels?b+="<"+e.classData.type+">":b+="<"+e.classData.type+">");let w=p.node().appendChild(vs(b,e.labelStyle,!0,!0));Ge(w).attr("class","classTitle");let C=w.getBBox();if(fr(me().flowchart.htmlLabels)){let S=w.children[0],_=Ge(w);C=S.getBoundingClientRect(),_.attr("width",C.width),_.attr("height",C.height)}d+=C.height+n,C.width>f&&(f=C.width);let T=[];e.classData.members.forEach(S=>{let _=S.getDisplayDetails(),I=_.displayText;me().flowchart.htmlLabels&&(I=I.replace(//g,">"));let D=p.node().appendChild(vs(I,_.cssStyle?_.cssStyle:e.labelStyle,!0,!0)),k=D.getBBox();if(fr(me().flowchart.htmlLabels)){let L=D.children[0],R=Ge(D);k=L.getBoundingClientRect(),R.attr("width",k.width),R.attr("height",k.height)}k.width>f&&(f=k.width),d+=k.height+n,T.push(D)}),d+=i;let E=[];if(e.classData.methods.forEach(S=>{let _=S.getDisplayDetails(),I=_.displayText;me().flowchart.htmlLabels&&(I=I.replace(//g,">"));let D=p.node().appendChild(vs(I,_.cssStyle?_.cssStyle:e.labelStyle,!0,!0)),k=D.getBBox();if(fr(me().flowchart.htmlLabels)){let L=D.children[0],R=Ge(D);k=L.getBoundingClientRect(),R.attr("width",k.width),R.attr("height",k.height)}k.width>f&&(f=k.width),d+=k.height+n,E.push(D)}),d+=i,g){let S=(f-x.width)/2;Ge(v).attr("transform","translate( "+(-1*f/2+S)+", "+-1*d/2+")"),m=x.height+n}let A=(f-C.width)/2;return Ge(w).attr("transform","translate( "+(-1*f/2+A)+", "+(-1*d/2+m)+")"),m+=C.height+n,u.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,T.forEach(S=>{Ge(S).attr("transform","translate( "+-f/2+", "+(-1*d/2+m+i/2)+")");let _=S?.getBBox();m+=(_?.height??0)+n}),m+=i,h.attr("class","divider").attr("x1",-f/2-r).attr("x2",f/2+r).attr("y1",-d/2-r+i+m).attr("y2",-d/2-r+i+m),m+=i,E.forEach(S=>{Ge(S).attr("transform","translate( "+-f/2+", "+(-1*d/2+m)+")");let _=S?.getBBox();m+=(_?.height??0)+n}),l.attr("style",e.style).attr("class","outer title-state").attr("x",-f/2-r).attr("y",-(d/2)-r).attr("width",f+e.padding).attr("height",d+e.padding),Qn(e,l),e.intersect=function(S){return In.rect(e,S)},s},"class_box"),uve={rhombus:lve,composite:Wtt,question:lve,rect:Htt,labelRect:qtt,rectWithTitle:Ytt,choice:Itt,circle:jtt,doublecircle:Ktt,stadium:Xtt,hexagon:Ott,block_arrow:Ptt,rect_left_inv_arrow:Btt,lean_right:Ftt,lean_left:$tt,trapezoid:ztt,inv_trapezoid:Gtt,rect_right_inv_arrow:Vtt,cylinder:Utt,start:Ztt,end:Jtt,note:ave,subroutine:Qtt,fork:cve,join:cve,class_box:ert},aC={},sF=o(async(t,e,r)=>{let n,i;if(e.link){let a;me().securityLevel==="sandbox"?a="_top":e.linkTarget&&(a=e.linkTarget||"_blank"),n=t.insert("svg:a").attr("xlink:href",e.link).attr("target",a),i=await uve[e.shape](n,e,r)}else i=await uve[e.shape](t,e,r),n=i;return e.tooltip&&i.attr("title",e.tooltip),e.class&&i.attr("class","node default "+e.class),aC[e.id]=n,e.haveCallback&&aC[e.id].attr("class",aC[e.id].attr("class")+" clickable"),n},"insertNode"),hve=o(t=>{let e=aC[t.id];Y.trace("Transforming node",t.diff,t,"translate("+(t.x-t.width/2-5)+", "+t.width/2+")");let r=8,n=t.diff||0;return t.clusterNode?e.attr("transform","translate("+(t.x+n-t.width/2)+", "+(t.y-t.height/2-r)+")"):e.attr("transform","translate("+t.x+", "+t.y+")"),n},"positionNode")});function dve(t,e,r=!1){let n=t,i="default";(n?.classes?.length||0)>0&&(i=(n?.classes??[]).join(" ")),i=i+" flowchart-label";let a=0,s="",l;switch(n.type){case"round":a=5,s="rect";break;case"composite":a=0,s="composite",l=0;break;case"square":s="rect";break;case"diamond":s="question";break;case"hexagon":s="hexagon";break;case"block_arrow":s="block_arrow";break;case"odd":s="rect_left_inv_arrow";break;case"lean_right":s="lean_right";break;case"lean_left":s="lean_left";break;case"trapezoid":s="trapezoid";break;case"inv_trapezoid":s="inv_trapezoid";break;case"rect_left_inv_arrow":s="rect_left_inv_arrow";break;case"circle":s="circle";break;case"ellipse":s="ellipse";break;case"stadium":s="stadium";break;case"subroutine":s="subroutine";break;case"cylinder":s="cylinder";break;case"group":s="rect";break;case"doublecircle":s="doublecircle";break;default:s="rect"}let u=Y9(n?.styles??[]),h=n.label,f=n.size??{width:0,height:0,x:0,y:0};return{labelStyle:u.labelStyle,shape:s,labelText:h,rx:a,ry:a,class:i,style:u.style,id:n.id,directions:n.directions,width:f.width,height:f.height,x:f.x,y:f.y,positioned:r,intersect:void 0,type:n.type,padding:l??cr()?.block?.padding??0}}async function trt(t,e,r){let n=dve(e,r,!1);if(n.type==="group")return;let i=cr(),a=await sF(t,n,{config:i}),s=a.node().getBBox(),l=r.getBlock(n.id);l.size={width:s.width,height:s.height,x:0,y:0,node:a},r.setBlock(l),a.remove()}async function rrt(t,e,r){let n=dve(e,r,!0);if(r.getBlock(n.id).type!=="space"){let a=cr();await sF(t,n,{config:a}),e.intersect=n?.intersect,hve(n)}}async function oF(t,e,r,n){for(let i of e)await n(t,i,r),i.children&&await oF(t,i.children,r,n)}async function pve(t,e,r){await oF(t,e,r,trt)}async function mve(t,e,r){await oF(t,e,r,rrt)}async function gve(t,e,r,n,i){let a=new sn({multigraph:!0,compound:!0});a.setGraph({rankdir:"TB",nodesep:10,ranksep:10,marginx:8,marginy:8});for(let s of r)s.size&&a.setNode(s.id,{width:s.size.width,height:s.size.height,intersect:s.intersect});for(let s of e)if(s.start&&s.end){let l=n.getBlock(s.start),u=n.getBlock(s.end);if(l?.size&&u?.size){let h=l.size,f=u.size,d=[{x:h.x,y:h.y},{x:h.x+(f.x-h.x)/2,y:h.y+(f.y-h.y)/2},{x:f.x,y:f.y}];Hye(t,{v:s.start,w:s.end,name:s.id},{...s,arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"},void 0,"block",a,i),s.label&&(await Vye(t,{...s,label:s.label,labelStyle:"stroke: #333; stroke-width: 1.5px;fill:none;",arrowTypeEnd:s.arrowTypeEnd,arrowTypeStart:s.arrowTypeStart,points:d,classes:"edge-thickness-normal edge-pattern-solid flowchart-link LS-a1 LE-b1"}),Uye({...s,x:d[1].x,y:d[1].y},{originalPath:d}))}}}var yve=N(()=>{"use strict";Vo();ji();Wye();fve();ir();o(dve,"getNodeFromBlock");o(trt,"calculateBlockSize");o(rrt,"insertBlockPositioned");o(oF,"performOperations");o(pve,"calculateBlockSizes");o(mve,"insertBlocks");o(gve,"insertEdges")});var nrt,irt,vve,xve=N(()=>{"use strict";dr();ji();Nye();vt();Ei();Pye();yve();nrt=o(function(t,e){return e.db.getClasses()},"getClasses"),irt=o(async function(t,e,r,n){let{securityLevel:i,block:a}=cr(),s=n.db,l;i==="sandbox"&&(l=Ge("#i"+e));let u=i==="sandbox"?Ge(l.nodes()[0].contentDocument.body):Ge("body"),h=i==="sandbox"?u.select(`[id="${e}"]`):Ge(`[id="${e}"]`);Rye(h,["point","circle","cross"],n.type,e);let d=s.getBlocks(),p=s.getBlocksFlat(),m=s.getEdges(),g=h.insert("g").attr("class","block");await pve(g,d,s);let y=Oye(s);if(await mve(g,d,s),await gve(g,m,p,s,e),y){let v=y,x=Math.max(1,Math.round(.125*(v.width/v.height))),b=v.height+x+10,w=v.width+10,{useMaxWidth:C}=a;vn(h,b,w,!!C),Y.debug("Here Bounds",y,v),h.attr("viewBox",`${v.x-5} ${v.y-5} ${v.width+10} ${v.height+10}`)}},"draw"),vve={draw:irt,getClasses:nrt}});var bve={};hr(bve,{diagram:()=>art});var art,wve=N(()=>{"use strict";wye();_ye();Lye();xve();art={parser:bye,db:Aye,renderer:vve,styles:Dye}});var lF,cF,v4,Eve,uF,Ha,Zc,x4,Sve,crt,b4,Cve,Ave,_ve,Dve,Lve,sC,Ff,oC=N(()=>{"use strict";lF={L:"left",R:"right",T:"top",B:"bottom"},cF={L:o(t=>`${t},${t/2} 0,${t} 0,0`,"L"),R:o(t=>`0,${t/2} ${t},0 ${t},${t}`,"R"),T:o(t=>`0,0 ${t},0 ${t/2},${t}`,"T"),B:o(t=>`${t/2},0 ${t},${t} 0,${t}`,"B")},v4={L:o((t,e)=>t-e+2,"L"),R:o((t,e)=>t-2,"R"),T:o((t,e)=>t-e+2,"T"),B:o((t,e)=>t-2,"B")},Eve=o(function(t){return Ha(t)?t==="L"?"R":"L":t==="T"?"B":"T"},"getOppositeArchitectureDirection"),uF=o(function(t){let e=t;return e==="L"||e==="R"||e==="T"||e==="B"},"isArchitectureDirection"),Ha=o(function(t){let e=t;return e==="L"||e==="R"},"isArchitectureDirectionX"),Zc=o(function(t){let e=t;return e==="T"||e==="B"},"isArchitectureDirectionY"),x4=o(function(t,e){let r=Ha(t)&&Zc(e),n=Zc(t)&&Ha(e);return r||n},"isArchitectureDirectionXY"),Sve=o(function(t){let e=t[0],r=t[1],n=Ha(e)&&Zc(r),i=Zc(e)&&Ha(r);return n||i},"isArchitecturePairXY"),crt=o(function(t){return t!=="LL"&&t!=="RR"&&t!=="TT"&&t!=="BB"},"isValidArchitectureDirectionPair"),b4=o(function(t,e){let r=`${t}${e}`;return crt(r)?r:void 0},"getArchitectureDirectionPair"),Cve=o(function([t,e],r){let n=r[0],i=r[1];return Ha(n)?Zc(i)?[t+(n==="L"?-1:1),e+(i==="T"?1:-1)]:[t+(n==="L"?-1:1),e]:Ha(i)?[t+(i==="L"?1:-1),e+(n==="T"?1:-1)]:[t,e+(n==="T"?1:-1)]},"shiftPositionByArchitectureDirectionPair"),Ave=o(function(t){return t==="LT"||t==="TL"?[1,1]:t==="BL"||t==="LB"?[1,-1]:t==="BR"||t==="RB"?[-1,-1]:[-1,1]},"getArchitectureDirectionXYFactors"),_ve=o(function(t,e){return x4(t,e)?"bend":Ha(t)?"horizontal":"vertical"},"getArchitectureDirectionAlignment"),Dve=o(function(t){return t.type==="service"},"isArchitectureService"),Lve=o(function(t){return t.type==="junction"},"isArchitectureJunction"),sC=o(t=>t.data(),"edgeData"),Ff=o(t=>t.data(),"nodeData")});function Li(t){let e=me().architecture;return e?.[t]?e[t]:Rve[t]}var Rve,vr,urt,hrt,frt,drt,prt,mrt,hF,grt,yrt,vrt,xrt,brt,wrt,Trt,Qp,w4=N(()=>{"use strict";Ya();zt();s6();mi();oC();Rve=or.architecture,vr=new pf(()=>({nodes:{},groups:{},edges:[],registeredIds:{},config:Rve,dataStructures:void 0,elements:{}})),urt=o(()=>{vr.reset(),Ar()},"clear"),hrt=o(function({id:t,icon:e,in:r,title:n,iconText:i}){if(vr.records.registeredIds[t]!==void 0)throw new Error(`The service id [${t}] is already in use by another ${vr.records.registeredIds[t]}`);if(r!==void 0){if(t===r)throw new Error(`The service [${t}] cannot be placed within itself`);if(vr.records.registeredIds[r]===void 0)throw new Error(`The service [${t}]'s parent does not exist. Please make sure the parent is created before this service`);if(vr.records.registeredIds[r]==="node")throw new Error(`The service [${t}]'s parent is not a group`)}vr.records.registeredIds[t]="node",vr.records.nodes[t]={id:t,type:"service",icon:e,iconText:i,title:n,edges:[],in:r}},"addService"),frt=o(()=>Object.values(vr.records.nodes).filter(Dve),"getServices"),drt=o(function({id:t,in:e}){vr.records.registeredIds[t]="node",vr.records.nodes[t]={id:t,type:"junction",edges:[],in:e}},"addJunction"),prt=o(()=>Object.values(vr.records.nodes).filter(Lve),"getJunctions"),mrt=o(()=>Object.values(vr.records.nodes),"getNodes"),hF=o(t=>vr.records.nodes[t],"getNode"),grt=o(function({id:t,icon:e,in:r,title:n}){if(vr.records.registeredIds[t]!==void 0)throw new Error(`The group id [${t}] is already in use by another ${vr.records.registeredIds[t]}`);if(r!==void 0){if(t===r)throw new Error(`The group [${t}] cannot be placed within itself`);if(vr.records.registeredIds[r]===void 0)throw new Error(`The group [${t}]'s parent does not exist. Please make sure the parent is created before this group`);if(vr.records.registeredIds[r]==="node")throw new Error(`The group [${t}]'s parent is not a group`)}vr.records.registeredIds[t]="group",vr.records.groups[t]={id:t,icon:e,title:n,in:r}},"addGroup"),yrt=o(()=>Object.values(vr.records.groups),"getGroups"),vrt=o(function({lhsId:t,rhsId:e,lhsDir:r,rhsDir:n,lhsInto:i,rhsInto:a,lhsGroup:s,rhsGroup:l,title:u}){if(!uF(r))throw new Error(`Invalid direction given for left hand side of edge ${t}--${e}. Expected (L,R,T,B) got ${r}`);if(!uF(n))throw new Error(`Invalid direction given for right hand side of edge ${t}--${e}. Expected (L,R,T,B) got ${n}`);if(vr.records.nodes[t]===void 0&&vr.records.groups[t]===void 0)throw new Error(`The left-hand id [${t}] does not yet exist. Please create the service/group before declaring an edge to it.`);if(vr.records.nodes[e]===void 0&&vr.records.groups[t]===void 0)throw new Error(`The right-hand id [${e}] does not yet exist. Please create the service/group before declaring an edge to it.`);let h=vr.records.nodes[t].in,f=vr.records.nodes[e].in;if(s&&h&&f&&h==f)throw new Error(`The left-hand id [${t}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);if(l&&h&&f&&h==f)throw new Error(`The right-hand id [${e}] is modified to traverse the group boundary, but the edge does not pass through two groups.`);let d={lhsId:t,lhsDir:r,lhsInto:i,lhsGroup:s,rhsId:e,rhsDir:n,rhsInto:a,rhsGroup:l,title:u};vr.records.edges.push(d),vr.records.nodes[t]&&vr.records.nodes[e]&&(vr.records.nodes[t].edges.push(vr.records.edges[vr.records.edges.length-1]),vr.records.nodes[e].edges.push(vr.records.edges[vr.records.edges.length-1]))},"addEdge"),xrt=o(()=>vr.records.edges,"getEdges"),brt=o(()=>{if(vr.records.dataStructures===void 0){let t={},e=Object.entries(vr.records.nodes).reduce((l,[u,h])=>(l[u]=h.edges.reduce((f,d)=>{let p=hF(d.lhsId)?.in,m=hF(d.rhsId)?.in;if(p&&m&&p!==m){let g=_ve(d.lhsDir,d.rhsDir);g!=="bend"&&(t[p]??={},t[p][m]=g,t[m]??={},t[m][p]=g)}if(d.lhsId===u){let g=b4(d.lhsDir,d.rhsDir);g&&(f[g]=d.rhsId)}else{let g=b4(d.rhsDir,d.lhsDir);g&&(f[g]=d.lhsId)}return f},{}),l),{}),r=Object.keys(e)[0],n={[r]:1},i=Object.keys(e).reduce((l,u)=>u===r?l:{...l,[u]:1},{}),a=o(l=>{let u={[l]:[0,0]},h=[l];for(;h.length>0;){let f=h.shift();if(f){n[f]=1,delete i[f];let d=e[f],[p,m]=u[f];Object.entries(d).forEach(([g,y])=>{n[y]||(u[y]=Cve([p,m],g),h.push(y))})}}return u},"BFS"),s=[a(r)];for(;Object.keys(i).length>0;)s.push(a(Object.keys(i)[0]));vr.records.dataStructures={adjList:e,spatialMaps:s,groupAlignments:t}}return vr.records.dataStructures},"getDataStructures"),wrt=o((t,e)=>{vr.records.elements[t]=e},"setElementForId"),Trt=o(t=>vr.records.elements[t],"getElementById"),Qp={clear:urt,setDiagramTitle:$r,getDiagramTitle:Ir,setAccTitle:Lr,getAccTitle:Rr,setAccDescription:Nr,getAccDescription:Mr,addService:hrt,getServices:frt,addJunction:drt,getJunctions:prt,getNodes:mrt,getNode:hF,addGroup:grt,getGroups:yrt,addEdge:vrt,getEdges:xrt,setElementForId:wrt,getElementById:Trt,getDataStructures:brt};o(Li,"getConfigField")});var krt,Nve,Mve=N(()=>{"use strict";kp();vt();T1();w4();krt=o((t,e)=>{$c(t,e),t.groups.map(e.addGroup),t.services.map(r=>e.addService({...r,type:"service"})),t.junctions.map(r=>e.addJunction({...r,type:"junction"})),t.edges.map(e.addEdge)},"populateDb"),Nve={parse:o(async t=>{let e=await uo("architecture",t);Y.debug(e),krt(e,Qp)},"parse")}});var Ert,Ive,Ove=N(()=>{"use strict";Ert=o(t=>` + .edge { + stroke-width: ${t.archEdgeWidth}; + stroke: ${t.archEdgeColor}; + fill: none; + } + + .arrow { + fill: ${t.archEdgeArrowColor}; + } + + .node-bkg { + fill: none; + stroke: ${t.archGroupBorderColor}; + stroke-width: ${t.archGroupBorderWidth}; + stroke-dasharray: 8; + } + .node-icon-text { + display: flex; + align-items: center; + } + + .node-icon-text > div { + color: #fff; + margin: 1px; + height: fit-content; + text-align: center; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + } +`,"getStyles"),Ive=Ert});var dF=Mi((T4,fF)=>{"use strict";o(function(e,r){typeof T4=="object"&&typeof fF=="object"?fF.exports=r():typeof define=="function"&&define.amd?define([],r):typeof T4=="object"?T4.layoutBase=r():e.layoutBase=r()},"webpackUniversalModuleDefinition")(T4,function(){return function(t){var e={};function r(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,r),i.l=!0,i.exports}return o(r,"__webpack_require__"),r.m=t,r.c=e,r.i=function(n){return n},r.d=function(n,i,a){r.o(n,i)||Object.defineProperty(n,i,{configurable:!1,enumerable:!0,get:a})},r.n=function(n){var i=n&&n.__esModule?o(function(){return n.default},"getDefault"):o(function(){return n},"getModuleExports");return r.d(i,"a",i),i},r.o=function(n,i){return Object.prototype.hasOwnProperty.call(n,i)},r.p="",r(r.s=28)}([function(t,e,r){"use strict";function n(){}o(n,"LayoutConstants"),n.QUALITY=1,n.DEFAULT_CREATE_BENDS_AS_NEEDED=!1,n.DEFAULT_INCREMENTAL=!1,n.DEFAULT_ANIMATION_ON_LAYOUT=!0,n.DEFAULT_ANIMATION_DURING_LAYOUT=!1,n.DEFAULT_ANIMATION_PERIOD=50,n.DEFAULT_UNIFORM_LEAF_NODE_SIZES=!1,n.DEFAULT_GRAPH_MARGIN=15,n.NODE_DIMENSIONS_INCLUDE_LABELS=!1,n.SIMPLE_NODE_SIZE=40,n.SIMPLE_NODE_HALF_SIZE=n.SIMPLE_NODE_SIZE/2,n.EMPTY_COMPOUND_NODE_SIZE=40,n.MIN_EDGE_LENGTH=1,n.WORLD_BOUNDARY=1e6,n.INITIAL_WORLD_BOUNDARY=n.WORLD_BOUNDARY/1e3,n.WORLD_CENTER_X=1200,n.WORLD_CENTER_Y=900,t.exports=n},function(t,e,r){"use strict";var n=r(2),i=r(8),a=r(9);function s(u,h,f){n.call(this,f),this.isOverlapingSourceAndTarget=!1,this.vGraphObject=f,this.bendpoints=[],this.source=u,this.target=h}o(s,"LEdge"),s.prototype=Object.create(n.prototype);for(var l in n)s[l]=n[l];s.prototype.getSource=function(){return this.source},s.prototype.getTarget=function(){return this.target},s.prototype.isInterGraph=function(){return this.isInterGraph},s.prototype.getLength=function(){return this.length},s.prototype.isOverlapingSourceAndTarget=function(){return this.isOverlapingSourceAndTarget},s.prototype.getBendpoints=function(){return this.bendpoints},s.prototype.getLca=function(){return this.lca},s.prototype.getSourceInLca=function(){return this.sourceInLca},s.prototype.getTargetInLca=function(){return this.targetInLca},s.prototype.getOtherEnd=function(u){if(this.source===u)return this.target;if(this.target===u)return this.source;throw"Node is not incident with this edge"},s.prototype.getOtherEndInGraph=function(u,h){for(var f=this.getOtherEnd(u),d=h.getGraphManager().getRoot();;){if(f.getOwner()==h)return f;if(f.getOwner()==d)break;f=f.getOwner().getParent()}return null},s.prototype.updateLength=function(){var u=new Array(4);this.isOverlapingSourceAndTarget=i.getIntersection(this.target.getRect(),this.source.getRect(),u),this.isOverlapingSourceAndTarget||(this.lengthX=u[0]-u[2],this.lengthY=u[1]-u[3],Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY))},s.prototype.updateLengthSimple=function(){this.lengthX=this.target.getCenterX()-this.source.getCenterX(),this.lengthY=this.target.getCenterY()-this.source.getCenterY(),Math.abs(this.lengthX)<1&&(this.lengthX=a.sign(this.lengthX)),Math.abs(this.lengthY)<1&&(this.lengthY=a.sign(this.lengthY)),this.length=Math.sqrt(this.lengthX*this.lengthX+this.lengthY*this.lengthY)},t.exports=s},function(t,e,r){"use strict";function n(i){this.vGraphObject=i}o(n,"LGraphObject"),t.exports=n},function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(13),s=r(0),l=r(16),u=r(5);function h(d,p,m,g){m==null&&g==null&&(g=p),n.call(this,g),d.graphManager!=null&&(d=d.graphManager),this.estimatedSize=i.MIN_VALUE,this.inclusionTreeDepth=i.MAX_VALUE,this.vGraphObject=g,this.edges=[],this.graphManager=d,m!=null&&p!=null?this.rect=new a(p.x,p.y,m.width,m.height):this.rect=new a}o(h,"LNode"),h.prototype=Object.create(n.prototype);for(var f in n)h[f]=n[f];h.prototype.getEdges=function(){return this.edges},h.prototype.getChild=function(){return this.child},h.prototype.getOwner=function(){return this.owner},h.prototype.getWidth=function(){return this.rect.width},h.prototype.setWidth=function(d){this.rect.width=d},h.prototype.getHeight=function(){return this.rect.height},h.prototype.setHeight=function(d){this.rect.height=d},h.prototype.getCenterX=function(){return this.rect.x+this.rect.width/2},h.prototype.getCenterY=function(){return this.rect.y+this.rect.height/2},h.prototype.getCenter=function(){return new u(this.rect.x+this.rect.width/2,this.rect.y+this.rect.height/2)},h.prototype.getLocation=function(){return new u(this.rect.x,this.rect.y)},h.prototype.getRect=function(){return this.rect},h.prototype.getDiagonal=function(){return Math.sqrt(this.rect.width*this.rect.width+this.rect.height*this.rect.height)},h.prototype.getHalfTheDiagonal=function(){return Math.sqrt(this.rect.height*this.rect.height+this.rect.width*this.rect.width)/2},h.prototype.setRect=function(d,p){this.rect.x=d.x,this.rect.y=d.y,this.rect.width=p.width,this.rect.height=p.height},h.prototype.setCenter=function(d,p){this.rect.x=d-this.rect.width/2,this.rect.y=p-this.rect.height/2},h.prototype.setLocation=function(d,p){this.rect.x=d,this.rect.y=p},h.prototype.moveBy=function(d,p){this.rect.x+=d,this.rect.y+=p},h.prototype.getEdgeListToNode=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(y.target==d){if(y.source!=g)throw"Incorrect edge source!";p.push(y)}}),p},h.prototype.getEdgesBetween=function(d){var p=[],m,g=this;return g.edges.forEach(function(y){if(!(y.source==g||y.target==g))throw"Incorrect edge source and/or target";(y.target==d||y.source==d)&&p.push(y)}),p},h.prototype.getNeighborsList=function(){var d=new Set,p=this;return p.edges.forEach(function(m){if(m.source==p)d.add(m.target);else{if(m.target!=p)throw"Incorrect incidency!";d.add(m.source)}}),d},h.prototype.withChildren=function(){var d=new Set,p,m;if(d.add(this),this.child!=null)for(var g=this.child.getNodes(),y=0;yp?(this.rect.x-=(this.labelWidth-p)/2,this.setWidth(this.labelWidth)):this.labelPosHorizontal=="right"&&this.setWidth(p+this.labelWidth)),this.labelHeight&&(this.labelPosVertical=="top"?(this.rect.y-=this.labelHeight,this.setHeight(m+this.labelHeight)):this.labelPosVertical=="center"&&this.labelHeight>m?(this.rect.y-=(this.labelHeight-m)/2,this.setHeight(this.labelHeight)):this.labelPosVertical=="bottom"&&this.setHeight(m+this.labelHeight))}}},h.prototype.getInclusionTreeDepth=function(){if(this.inclusionTreeDepth==i.MAX_VALUE)throw"assert failed";return this.inclusionTreeDepth},h.prototype.transform=function(d){var p=this.rect.x;p>s.WORLD_BOUNDARY?p=s.WORLD_BOUNDARY:p<-s.WORLD_BOUNDARY&&(p=-s.WORLD_BOUNDARY);var m=this.rect.y;m>s.WORLD_BOUNDARY?m=s.WORLD_BOUNDARY:m<-s.WORLD_BOUNDARY&&(m=-s.WORLD_BOUNDARY);var g=new u(p,m),y=d.inverseTransformPoint(g);this.setLocation(y.x,y.y)},h.prototype.getLeft=function(){return this.rect.x},h.prototype.getRight=function(){return this.rect.x+this.rect.width},h.prototype.getTop=function(){return this.rect.y},h.prototype.getBottom=function(){return this.rect.y+this.rect.height},h.prototype.getParent=function(){return this.owner==null?null:this.owner.getParent()},t.exports=h},function(t,e,r){"use strict";var n=r(0);function i(){}o(i,"FDLayoutConstants");for(var a in n)i[a]=n[a];i.MAX_ITERATIONS=2500,i.DEFAULT_EDGE_LENGTH=50,i.DEFAULT_SPRING_STRENGTH=.45,i.DEFAULT_REPULSION_STRENGTH=4500,i.DEFAULT_GRAVITY_STRENGTH=.4,i.DEFAULT_COMPOUND_GRAVITY_STRENGTH=1,i.DEFAULT_GRAVITY_RANGE_FACTOR=3.8,i.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=1.5,i.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION=!0,i.DEFAULT_USE_SMART_REPULSION_RANGE_CALCULATION=!0,i.DEFAULT_COOLING_FACTOR_INCREMENTAL=.3,i.COOLING_ADAPTATION_FACTOR=.33,i.ADAPTATION_LOWER_NODE_LIMIT=1e3,i.ADAPTATION_UPPER_NODE_LIMIT=5e3,i.MAX_NODE_DISPLACEMENT_INCREMENTAL=100,i.MAX_NODE_DISPLACEMENT=i.MAX_NODE_DISPLACEMENT_INCREMENTAL*3,i.MIN_REPULSION_DIST=i.DEFAULT_EDGE_LENGTH/10,i.CONVERGENCE_CHECK_PERIOD=100,i.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=.1,i.MIN_EDGE_LENGTH=1,i.GRID_CALCULATION_CHECK_PERIOD=10,t.exports=i},function(t,e,r){"use strict";function n(i,a){i==null&&a==null?(this.x=0,this.y=0):(this.x=i,this.y=a)}o(n,"PointD"),n.prototype.getX=function(){return this.x},n.prototype.getY=function(){return this.y},n.prototype.setX=function(i){this.x=i},n.prototype.setY=function(i){this.y=i},n.prototype.getDifference=function(i){return new DimensionD(this.x-i.x,this.y-i.y)},n.prototype.getCopy=function(){return new n(this.x,this.y)},n.prototype.translate=function(i){return this.x+=i.width,this.y+=i.height,this},t.exports=n},function(t,e,r){"use strict";var n=r(2),i=r(10),a=r(0),s=r(7),l=r(3),u=r(1),h=r(13),f=r(12),d=r(11);function p(g,y,v){n.call(this,v),this.estimatedSize=i.MIN_VALUE,this.margin=a.DEFAULT_GRAPH_MARGIN,this.edges=[],this.nodes=[],this.isConnected=!1,this.parent=g,y!=null&&y instanceof s?this.graphManager=y:y!=null&&y instanceof Layout&&(this.graphManager=y.graphManager)}o(p,"LGraph"),p.prototype=Object.create(n.prototype);for(var m in n)p[m]=n[m];p.prototype.getNodes=function(){return this.nodes},p.prototype.getEdges=function(){return this.edges},p.prototype.getGraphManager=function(){return this.graphManager},p.prototype.getParent=function(){return this.parent},p.prototype.getLeft=function(){return this.left},p.prototype.getRight=function(){return this.right},p.prototype.getTop=function(){return this.top},p.prototype.getBottom=function(){return this.bottom},p.prototype.isConnected=function(){return this.isConnected},p.prototype.add=function(g,y,v){if(y==null&&v==null){var x=g;if(this.graphManager==null)throw"Graph has no graph mgr!";if(this.getNodes().indexOf(x)>-1)throw"Node already in graph!";return x.owner=this,this.getNodes().push(x),x}else{var b=g;if(!(this.getNodes().indexOf(y)>-1&&this.getNodes().indexOf(v)>-1))throw"Source or target not in graph!";if(!(y.owner==v.owner&&y.owner==this))throw"Both owners must be this graph!";return y.owner!=v.owner?null:(b.source=y,b.target=v,b.isInterGraph=!1,this.getEdges().push(b),y.edges.push(b),v!=y&&v.edges.push(b),b)}},p.prototype.remove=function(g){var y=g;if(g instanceof l){if(y==null)throw"Node is null!";if(!(y.owner!=null&&y.owner==this))throw"Owner graph is invalid!";if(this.graphManager==null)throw"Owner graph manager is invalid!";for(var v=y.edges.slice(),x,b=v.length,w=0;w-1&&E>-1))throw"Source and/or target doesn't know this edge!";x.source.edges.splice(T,1),x.target!=x.source&&x.target.edges.splice(E,1);var C=x.source.owner.getEdges().indexOf(x);if(C==-1)throw"Not in owner's edge list!";x.source.owner.getEdges().splice(C,1)}},p.prototype.updateLeftTop=function(){for(var g=i.MAX_VALUE,y=i.MAX_VALUE,v,x,b,w=this.getNodes(),C=w.length,T=0;Tv&&(g=v),y>x&&(y=x)}return g==i.MAX_VALUE?null:(w[0].getParent().paddingLeft!=null?b=w[0].getParent().paddingLeft:b=this.margin,this.left=y-b,this.top=g-b,new f(this.left,this.top))},p.prototype.updateBounds=function(g){for(var y=i.MAX_VALUE,v=-i.MAX_VALUE,x=i.MAX_VALUE,b=-i.MAX_VALUE,w,C,T,E,A,S=this.nodes,_=S.length,I=0;I<_;I++){var D=S[I];g&&D.child!=null&&D.updateBounds(),w=D.getLeft(),C=D.getRight(),T=D.getTop(),E=D.getBottom(),y>w&&(y=w),vT&&(x=T),bw&&(y=w),vT&&(x=T),b=this.nodes.length){var _=0;v.forEach(function(I){I.owner==g&&_++}),_==this.nodes.length&&(this.isConnected=!0)}},t.exports=p},function(t,e,r){"use strict";var n,i=r(1);function a(s){n=r(6),this.layout=s,this.graphs=[],this.edges=[]}o(a,"LGraphManager"),a.prototype.addRoot=function(){var s=this.layout.newGraph(),l=this.layout.newNode(null),u=this.add(s,l);return this.setRootGraph(u),this.rootGraph},a.prototype.add=function(s,l,u,h,f){if(u==null&&h==null&&f==null){if(s==null)throw"Graph is null!";if(l==null)throw"Parent node is null!";if(this.graphs.indexOf(s)>-1)throw"Graph already in this graph mgr!";if(this.graphs.push(s),s.parent!=null)throw"Already has a parent!";if(l.child!=null)throw"Already has a child!";return s.parent=l,l.child=s,s}else{f=u,h=l,u=s;var d=h.getOwner(),p=f.getOwner();if(!(d!=null&&d.getGraphManager()==this))throw"Source not in this graph mgr!";if(!(p!=null&&p.getGraphManager()==this))throw"Target not in this graph mgr!";if(d==p)return u.isInterGraph=!1,d.add(u,h,f);if(u.isInterGraph=!0,u.source=h,u.target=f,this.edges.indexOf(u)>-1)throw"Edge already in inter-graph edge list!";if(this.edges.push(u),!(u.source!=null&&u.target!=null))throw"Edge source and/or target is null!";if(!(u.source.edges.indexOf(u)==-1&&u.target.edges.indexOf(u)==-1))throw"Edge already in source and/or target incidency list!";return u.source.edges.push(u),u.target.edges.push(u),u}},a.prototype.remove=function(s){if(s instanceof n){var l=s;if(l.getGraphManager()!=this)throw"Graph not in this graph mgr";if(!(l==this.rootGraph||l.parent!=null&&l.parent.graphManager==this))throw"Invalid parent node!";var u=[];u=u.concat(l.getEdges());for(var h,f=u.length,d=0;d=s.getRight()?l[0]+=Math.min(s.getX()-a.getX(),a.getRight()-s.getRight()):s.getX()<=a.getX()&&s.getRight()>=a.getRight()&&(l[0]+=Math.min(a.getX()-s.getX(),s.getRight()-a.getRight())),a.getY()<=s.getY()&&a.getBottom()>=s.getBottom()?l[1]+=Math.min(s.getY()-a.getY(),a.getBottom()-s.getBottom()):s.getY()<=a.getY()&&s.getBottom()>=a.getBottom()&&(l[1]+=Math.min(a.getY()-s.getY(),s.getBottom()-a.getBottom()));var f=Math.abs((s.getCenterY()-a.getCenterY())/(s.getCenterX()-a.getCenterX()));s.getCenterY()===a.getCenterY()&&s.getCenterX()===a.getCenterX()&&(f=1);var d=f*l[0],p=l[1]/f;l[0]d)return l[0]=u,l[1]=m,l[2]=f,l[3]=S,!1;if(hf)return l[0]=p,l[1]=h,l[2]=E,l[3]=d,!1;if(uf?(l[0]=y,l[1]=v,k=!0):(l[0]=g,l[1]=m,k=!0):R===M&&(u>f?(l[0]=p,l[1]=m,k=!0):(l[0]=x,l[1]=v,k=!0)),-O===M?f>u?(l[2]=A,l[3]=S,L=!0):(l[2]=E,l[3]=T,L=!0):O===M&&(f>u?(l[2]=C,l[3]=T,L=!0):(l[2]=_,l[3]=S,L=!0)),k&&L)return!1;if(u>f?h>d?(B=this.getCardinalDirection(R,M,4),F=this.getCardinalDirection(O,M,2)):(B=this.getCardinalDirection(-R,M,3),F=this.getCardinalDirection(-O,M,1)):h>d?(B=this.getCardinalDirection(-R,M,1),F=this.getCardinalDirection(-O,M,3)):(B=this.getCardinalDirection(R,M,2),F=this.getCardinalDirection(O,M,4)),!k)switch(B){case 1:z=m,P=u+-w/M,l[0]=P,l[1]=z;break;case 2:P=x,z=h+b*M,l[0]=P,l[1]=z;break;case 3:z=v,P=u+w/M,l[0]=P,l[1]=z;break;case 4:P=y,z=h+-b*M,l[0]=P,l[1]=z;break}if(!L)switch(F){case 1:H=T,$=f+-D/M,l[2]=$,l[3]=H;break;case 2:$=_,H=d+I*M,l[2]=$,l[3]=H;break;case 3:H=S,$=f+D/M,l[2]=$,l[3]=H;break;case 4:$=A,H=d+-I*M,l[2]=$,l[3]=H;break}}return!1},i.getCardinalDirection=function(a,s,l){return a>s?l:1+l%4},i.getIntersection=function(a,s,l,u){if(u==null)return this.getIntersection2(a,s,l);var h=a.x,f=a.y,d=s.x,p=s.y,m=l.x,g=l.y,y=u.x,v=u.y,x=void 0,b=void 0,w=void 0,C=void 0,T=void 0,E=void 0,A=void 0,S=void 0,_=void 0;return w=p-f,T=h-d,A=d*f-h*p,C=v-g,E=m-y,S=y*g-m*v,_=w*E-C*T,_===0?null:(x=(T*S-E*A)/_,b=(C*A-w*S)/_,new n(x,b))},i.angleOfVector=function(a,s,l,u){var h=void 0;return a!==l?(h=Math.atan((u-s)/(l-a)),l=0){var v=(-m+Math.sqrt(m*m-4*p*g))/(2*p),x=(-m-Math.sqrt(m*m-4*p*g))/(2*p),b=null;return v>=0&&v<=1?[v]:x>=0&&x<=1?[x]:b}else return null},i.HALF_PI=.5*Math.PI,i.ONE_AND_HALF_PI=1.5*Math.PI,i.TWO_PI=2*Math.PI,i.THREE_PI=3*Math.PI,t.exports=i},function(t,e,r){"use strict";function n(){}o(n,"IMath"),n.sign=function(i){return i>0?1:i<0?-1:0},n.floor=function(i){return i<0?Math.ceil(i):Math.floor(i)},n.ceil=function(i){return i<0?Math.floor(i):Math.ceil(i)},t.exports=n},function(t,e,r){"use strict";function n(){}o(n,"Integer"),n.MAX_VALUE=2147483647,n.MIN_VALUE=-2147483648,t.exports=n},function(t,e,r){"use strict";var n=function(){function h(f,d){for(var p=0;p"u"?"undefined":n(a);return a==null||s!="object"&&s!="function"},t.exports=i},function(t,e,r){"use strict";function n(m){if(Array.isArray(m)){for(var g=0,y=Array(m.length);g0&&g;){for(w.push(T[0]);w.length>0&&g;){var E=w[0];w.splice(0,1),b.add(E);for(var A=E.getEdges(),x=0;x-1&&T.splice(D,1)}b=new Set,C=new Map}}return m},p.prototype.createDummyNodesForBendpoints=function(m){for(var g=[],y=m.source,v=this.graphManager.calcLowestCommonAncestor(m.source,m.target),x=0;x0){for(var v=this.edgeToDummyNodes.get(y),x=0;x=0&&g.splice(S,1);var _=C.getNeighborsList();_.forEach(function(k){if(y.indexOf(k)<0){var L=v.get(k),R=L-1;R==1&&E.push(k),v.set(k,R)}})}y=y.concat(E),(g.length==1||g.length==2)&&(x=!0,b=g[0])}return b},p.prototype.setGraphManager=function(m){this.graphManager=m},t.exports=p},function(t,e,r){"use strict";function n(){}o(n,"RandomSeed"),n.seed=1,n.x=0,n.nextDouble=function(){return n.x=Math.sin(n.seed++)*1e4,n.x-Math.floor(n.x)},t.exports=n},function(t,e,r){"use strict";var n=r(5);function i(a,s){this.lworldOrgX=0,this.lworldOrgY=0,this.ldeviceOrgX=0,this.ldeviceOrgY=0,this.lworldExtX=1,this.lworldExtY=1,this.ldeviceExtX=1,this.ldeviceExtY=1}o(i,"Transform"),i.prototype.getWorldOrgX=function(){return this.lworldOrgX},i.prototype.setWorldOrgX=function(a){this.lworldOrgX=a},i.prototype.getWorldOrgY=function(){return this.lworldOrgY},i.prototype.setWorldOrgY=function(a){this.lworldOrgY=a},i.prototype.getWorldExtX=function(){return this.lworldExtX},i.prototype.setWorldExtX=function(a){this.lworldExtX=a},i.prototype.getWorldExtY=function(){return this.lworldExtY},i.prototype.setWorldExtY=function(a){this.lworldExtY=a},i.prototype.getDeviceOrgX=function(){return this.ldeviceOrgX},i.prototype.setDeviceOrgX=function(a){this.ldeviceOrgX=a},i.prototype.getDeviceOrgY=function(){return this.ldeviceOrgY},i.prototype.setDeviceOrgY=function(a){this.ldeviceOrgY=a},i.prototype.getDeviceExtX=function(){return this.ldeviceExtX},i.prototype.setDeviceExtX=function(a){this.ldeviceExtX=a},i.prototype.getDeviceExtY=function(){return this.ldeviceExtY},i.prototype.setDeviceExtY=function(a){this.ldeviceExtY=a},i.prototype.transformX=function(a){var s=0,l=this.lworldExtX;return l!=0&&(s=this.ldeviceOrgX+(a-this.lworldOrgX)*this.ldeviceExtX/l),s},i.prototype.transformY=function(a){var s=0,l=this.lworldExtY;return l!=0&&(s=this.ldeviceOrgY+(a-this.lworldOrgY)*this.ldeviceExtY/l),s},i.prototype.inverseTransformX=function(a){var s=0,l=this.ldeviceExtX;return l!=0&&(s=this.lworldOrgX+(a-this.ldeviceOrgX)*this.lworldExtX/l),s},i.prototype.inverseTransformY=function(a){var s=0,l=this.ldeviceExtY;return l!=0&&(s=this.lworldOrgY+(a-this.ldeviceOrgY)*this.lworldExtY/l),s},i.prototype.inverseTransformPoint=function(a){var s=new n(this.inverseTransformX(a.x),this.inverseTransformY(a.y));return s},t.exports=i},function(t,e,r){"use strict";function n(d){if(Array.isArray(d)){for(var p=0,m=Array(d.length);pa.ADAPTATION_LOWER_NODE_LIMIT&&(this.coolingFactor=Math.max(this.coolingFactor*a.COOLING_ADAPTATION_FACTOR,this.coolingFactor-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*this.coolingFactor*(1-a.COOLING_ADAPTATION_FACTOR))),this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT_INCREMENTAL):(d>a.ADAPTATION_LOWER_NODE_LIMIT?this.coolingFactor=Math.max(a.COOLING_ADAPTATION_FACTOR,1-(d-a.ADAPTATION_LOWER_NODE_LIMIT)/(a.ADAPTATION_UPPER_NODE_LIMIT-a.ADAPTATION_LOWER_NODE_LIMIT)*(1-a.COOLING_ADAPTATION_FACTOR)):this.coolingFactor=1,this.initialCoolingFactor=this.coolingFactor,this.maxNodeDisplacement=a.MAX_NODE_DISPLACEMENT),this.maxIterations=Math.max(this.getAllNodes().length*5,this.maxIterations),this.displacementThresholdPerNode=3*a.DEFAULT_EDGE_LENGTH/100,this.totalDisplacementThreshold=this.displacementThresholdPerNode*this.getAllNodes().length,this.repulsionRange=this.calcRepulsionRange()},h.prototype.calcSpringForces=function(){for(var d=this.getAllEdges(),p,m=0;m0&&arguments[0]!==void 0?arguments[0]:!0,p=arguments.length>1&&arguments[1]!==void 0?arguments[1]:!1,m,g,y,v,x=this.getAllNodes(),b;if(this.useFRGridVariant)for(this.totalIterations%a.GRID_CALCULATION_CHECK_PERIOD==1&&d&&this.updateGrid(),b=new Set,m=0;mw||b>w)&&(d.gravitationForceX=-this.gravityConstant*y,d.gravitationForceY=-this.gravityConstant*v)):(w=p.getEstimatedSize()*this.compoundGravityRangeFactor,(x>w||b>w)&&(d.gravitationForceX=-this.gravityConstant*y*this.compoundGravityConstant,d.gravitationForceY=-this.gravityConstant*v*this.compoundGravityConstant))},h.prototype.isConverged=function(){var d,p=!1;return this.totalIterations>this.maxIterations/3&&(p=Math.abs(this.totalDisplacement-this.oldTotalDisplacement)<2),d=this.totalDisplacement=x.length||w>=x[0].length)){for(var C=0;Ch},"_defaultCompareFunction")}]),l}();t.exports=s},function(t,e,r){"use strict";function n(){}o(n,"SVD"),n.svd=function(i){this.U=null,this.V=null,this.s=null,this.m=0,this.n=0,this.m=i.length,this.n=i[0].length;var a=Math.min(this.m,this.n);this.s=function(xt){for(var ut=[];xt-- >0;)ut.push(0);return ut}(Math.min(this.m+1,this.n)),this.U=function(xt){var ut=o(function Et(ft){if(ft.length==0)return 0;for(var yt=[],nt=0;nt0;)ut.push(0);return ut}(this.n),l=function(xt){for(var ut=[];xt-- >0;)ut.push(0);return ut}(this.m),u=!0,h=!0,f=Math.min(this.m-1,this.n),d=Math.max(0,Math.min(this.n-2,this.m)),p=0;p=0;M--)if(this.s[M]!==0){for(var B=M+1;B=0;j--){if(function(xt,ut){return xt&&ut}(j0;){var ue=void 0,Z=void 0;for(ue=L-2;ue>=-1&&ue!==-1;ue--)if(Math.abs(s[ue])<=se+J*(Math.abs(this.s[ue])+Math.abs(this.s[ue+1]))){s[ue]=0;break}if(ue===L-2)Z=4;else{var Se=void 0;for(Se=L-1;Se>=ue&&Se!==ue;Se--){var ce=(Se!==L?Math.abs(s[Se]):0)+(Se!==ue+1?Math.abs(s[Se-1]):0);if(Math.abs(this.s[Se])<=se+J*ce){this.s[Se]=0;break}}Se===ue?Z=3:Se===L-1?Z=1:(Z=2,ue=Se)}switch(ue++,Z){case 1:{var ae=s[L-2];s[L-2]=0;for(var Oe=L-2;Oe>=ue;Oe--){var ge=n.hypot(this.s[Oe],ae),ze=this.s[Oe]/ge,He=ae/ge;if(this.s[Oe]=ge,Oe!==ue&&(ae=-He*s[Oe-1],s[Oe-1]=ze*s[Oe-1]),h)for(var $e=0;$e=this.s[ue+1]);){var ot=this.s[ue];if(this.s[ue]=this.s[ue+1],this.s[ue+1]=ot,h&&ueMath.abs(a)?(s=a/i,s=Math.abs(i)*Math.sqrt(1+s*s)):a!=0?(s=i/a,s=Math.abs(a)*Math.sqrt(1+s*s)):s=0,s},t.exports=n},function(t,e,r){"use strict";var n=function(){function s(l,u){for(var h=0;h2&&arguments[2]!==void 0?arguments[2]:1,f=arguments.length>3&&arguments[3]!==void 0?arguments[3]:-1,d=arguments.length>4&&arguments[4]!==void 0?arguments[4]:-1;i(this,s),this.sequence1=l,this.sequence2=u,this.match_score=h,this.mismatch_penalty=f,this.gap_penalty=d,this.iMax=l.length+1,this.jMax=u.length+1,this.grid=new Array(this.iMax);for(var p=0;p=0;l--){var u=this.listeners[l];u.event===a&&u.callback===s&&this.listeners.splice(l,1)}},i.emit=function(a,s){for(var l=0;l{"use strict";o(function(e,r){typeof k4=="object"&&typeof pF=="object"?pF.exports=r(dF()):typeof define=="function"&&define.amd?define(["layout-base"],r):typeof k4=="object"?k4.coseBase=r(dF()):e.coseBase=r(e.layoutBase)},"webpackUniversalModuleDefinition")(k4,function(t){return(()=>{"use strict";var e={45:(a,s,l)=>{var u={};u.layoutBase=l(551),u.CoSEConstants=l(806),u.CoSEEdge=l(767),u.CoSEGraph=l(880),u.CoSEGraphManager=l(578),u.CoSELayout=l(765),u.CoSENode=l(991),u.ConstraintHandler=l(902),a.exports=u},806:(a,s,l)=>{var u=l(551).FDLayoutConstants;function h(){}o(h,"CoSEConstants");for(var f in u)h[f]=u[f];h.DEFAULT_USE_MULTI_LEVEL_SCALING=!1,h.DEFAULT_RADIAL_SEPARATION=u.DEFAULT_EDGE_LENGTH,h.DEFAULT_COMPONENT_SEPERATION=60,h.TILE=!0,h.TILING_PADDING_VERTICAL=10,h.TILING_PADDING_HORIZONTAL=10,h.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,h.ENFORCE_CONSTRAINTS=!0,h.APPLY_LAYOUT=!0,h.RELAX_MOVEMENT_ON_CONSTRAINTS=!0,h.TREE_REDUCTION_ON_INCREMENTAL=!0,h.PURE_INCREMENTAL=h.DEFAULT_INCREMENTAL,a.exports=h},767:(a,s,l)=>{var u=l(551).FDLayoutEdge;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEEdge"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h},880:(a,s,l)=>{var u=l(551).LGraph;function h(d,p,m){u.call(this,d,p,m)}o(h,"CoSEGraph"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h},578:(a,s,l)=>{var u=l(551).LGraphManager;function h(d){u.call(this,d)}o(h,"CoSEGraphManager"),h.prototype=Object.create(u.prototype);for(var f in u)h[f]=u[f];a.exports=h},765:(a,s,l)=>{var u=l(551).FDLayout,h=l(578),f=l(880),d=l(991),p=l(767),m=l(806),g=l(902),y=l(551).FDLayoutConstants,v=l(551).LayoutConstants,x=l(551).Point,b=l(551).PointD,w=l(551).DimensionD,C=l(551).Layout,T=l(551).Integer,E=l(551).IGeometry,A=l(551).LGraph,S=l(551).Transform,_=l(551).LinkedList;function I(){u.call(this),this.toBeTiled={},this.constraints={}}o(I,"CoSELayout"),I.prototype=Object.create(u.prototype);for(var D in u)I[D]=u[D];I.prototype.newGraphManager=function(){var k=new h(this);return this.graphManager=k,k},I.prototype.newGraph=function(k){return new f(null,this.graphManager,k)},I.prototype.newNode=function(k){return new d(this.graphManager,k)},I.prototype.newEdge=function(k){return new p(null,null,k)},I.prototype.initParameters=function(){u.prototype.initParameters.call(this,arguments),this.isSubLayout||(m.DEFAULT_EDGE_LENGTH<10?this.idealEdgeLength=10:this.idealEdgeLength=m.DEFAULT_EDGE_LENGTH,this.useSmartIdealEdgeLengthCalculation=m.DEFAULT_USE_SMART_IDEAL_EDGE_LENGTH_CALCULATION,this.gravityConstant=y.DEFAULT_GRAVITY_STRENGTH,this.compoundGravityConstant=y.DEFAULT_COMPOUND_GRAVITY_STRENGTH,this.gravityRangeFactor=y.DEFAULT_GRAVITY_RANGE_FACTOR,this.compoundGravityRangeFactor=y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR,this.prunedNodesAll=[],this.growTreeIterations=0,this.afterGrowthIterations=0,this.isTreeGrowing=!1,this.isGrowthFinished=!1)},I.prototype.initSpringEmbedder=function(){u.prototype.initSpringEmbedder.call(this),this.coolingCycle=0,this.maxCoolingCycle=this.maxIterations/y.CONVERGENCE_CHECK_PERIOD,this.finalTemperature=.04,this.coolingAdjuster=1},I.prototype.layout=function(){var k=v.DEFAULT_CREATE_BENDS_AS_NEEDED;return k&&(this.createBendpoints(),this.graphManager.resetAllEdges()),this.level=0,this.classicLayout()},I.prototype.classicLayout=function(){if(this.nodesWithGravity=this.calculateNodesToApplyGravitationTo(),this.graphManager.setAllNodesToApplyGravitation(this.nodesWithGravity),this.calcNoOfChildrenForAllNodes(),this.graphManager.calcLowestCommonAncestors(),this.graphManager.calcInclusionTreeDepths(),this.graphManager.getRoot().calcEstimatedSize(),this.calcIdealEdgeLengths(),this.incremental){if(m.TREE_REDUCTION_ON_INCREMENTAL){this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var L=new Set(this.getAllNodes()),R=this.nodesWithGravity.filter(function(B){return L.has(B)});this.graphManager.setAllNodesToApplyGravitation(R)}}else{var k=this.getFlatForest();if(k.length>0)this.positionNodesRadially(k);else{this.reduceTrees(),this.graphManager.resetAllNodesToApplyGravitation();var L=new Set(this.getAllNodes()),R=this.nodesWithGravity.filter(function(O){return L.has(O)});this.graphManager.setAllNodesToApplyGravitation(R),this.positionNodesRandomly()}}return Object.keys(this.constraints).length>0&&(g.handleConstraints(this),this.initConstraintVariables()),this.initSpringEmbedder(),m.APPLY_LAYOUT&&this.runSpringEmbedder(),!0},I.prototype.tick=function(){if(this.totalIterations++,this.totalIterations===this.maxIterations&&!this.isTreeGrowing&&!this.isGrowthFinished)if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;if(this.totalIterations%y.CONVERGENCE_CHECK_PERIOD==0&&!this.isTreeGrowing&&!this.isGrowthFinished){if(this.isConverged())if(this.prunedNodesAll.length>0)this.isTreeGrowing=!0;else return!0;this.coolingCycle++,this.layoutQuality==0?this.coolingAdjuster=this.coolingCycle:this.layoutQuality==1&&(this.coolingAdjuster=this.coolingCycle/3),this.coolingFactor=Math.max(this.initialCoolingFactor-Math.pow(this.coolingCycle,Math.log(100*(this.initialCoolingFactor-this.finalTemperature))/Math.log(this.maxCoolingCycle))/100*this.coolingAdjuster,this.finalTemperature),this.animationPeriod=Math.ceil(this.initialAnimationPeriod*Math.sqrt(this.coolingFactor))}if(this.isTreeGrowing){if(this.growTreeIterations%10==0)if(this.prunedNodesAll.length>0){this.graphManager.updateBounds(),this.updateGrid(),this.growTree(this.prunedNodesAll),this.graphManager.resetAllNodesToApplyGravitation();var k=new Set(this.getAllNodes()),L=this.nodesWithGravity.filter(function(M){return k.has(M)});this.graphManager.setAllNodesToApplyGravitation(L),this.graphManager.updateBounds(),this.updateGrid(),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2:this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL}else this.isTreeGrowing=!1,this.isGrowthFinished=!0;this.growTreeIterations++}if(this.isGrowthFinished){if(this.isConverged())return!0;this.afterGrowthIterations%10==0&&(this.graphManager.updateBounds(),this.updateGrid()),m.PURE_INCREMENTAL?this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL/2*((100-this.afterGrowthIterations)/100):this.coolingFactor=y.DEFAULT_COOLING_FACTOR_INCREMENTAL*((100-this.afterGrowthIterations)/100),this.afterGrowthIterations++}var R=!this.isTreeGrowing&&!this.isGrowthFinished,O=this.growTreeIterations%10==1&&this.isTreeGrowing||this.afterGrowthIterations%10==1&&this.isGrowthFinished;return this.totalDisplacement=0,this.graphManager.updateBounds(),this.calcSpringForces(),this.calcRepulsionForces(R,O),this.calcGravitationalForces(),this.moveNodes(),this.animate(),!1},I.prototype.getPositionsData=function(){for(var k=this.graphManager.getAllNodes(),L={},R=0;R0&&this.updateDisplacements();for(var R=0;R0&&(O.fixedNodeWeight=B)}}if(this.constraints.relativePlacementConstraint){var F=new Map,P=new Map;if(this.dummyToNodeForVerticalAlignment=new Map,this.dummyToNodeForHorizontalAlignment=new Map,this.fixedNodesOnHorizontal=new Set,this.fixedNodesOnVertical=new Set,this.fixedNodeSet.forEach(function(le){k.fixedNodesOnHorizontal.add(le),k.fixedNodesOnVertical.add(le)}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var z=this.constraints.alignmentConstraint.vertical,R=0;R=2*le.length/3;X--)he=Math.floor(Math.random()*(X+1)),K=le[X],le[X]=le[he],le[he]=K;return le},this.nodesInRelativeHorizontal=[],this.nodesInRelativeVertical=[],this.nodeToRelativeConstraintMapHorizontal=new Map,this.nodeToRelativeConstraintMapVertical=new Map,this.nodeToTempPositionMapHorizontal=new Map,this.nodeToTempPositionMapVertical=new Map,this.constraints.relativePlacementConstraint.forEach(function(le){if(le.left){var he=F.has(le.left)?F.get(le.left):le.left,K=F.has(le.right)?F.get(le.right):le.right;k.nodesInRelativeHorizontal.includes(he)||(k.nodesInRelativeHorizontal.push(he),k.nodeToRelativeConstraintMapHorizontal.set(he,[]),k.dummyToNodeForVerticalAlignment.has(he)?k.nodeToTempPositionMapHorizontal.set(he,k.idToNodeMap.get(k.dummyToNodeForVerticalAlignment.get(he)[0]).getCenterX()):k.nodeToTempPositionMapHorizontal.set(he,k.idToNodeMap.get(he).getCenterX())),k.nodesInRelativeHorizontal.includes(K)||(k.nodesInRelativeHorizontal.push(K),k.nodeToRelativeConstraintMapHorizontal.set(K,[]),k.dummyToNodeForVerticalAlignment.has(K)?k.nodeToTempPositionMapHorizontal.set(K,k.idToNodeMap.get(k.dummyToNodeForVerticalAlignment.get(K)[0]).getCenterX()):k.nodeToTempPositionMapHorizontal.set(K,k.idToNodeMap.get(K).getCenterX())),k.nodeToRelativeConstraintMapHorizontal.get(he).push({right:K,gap:le.gap}),k.nodeToRelativeConstraintMapHorizontal.get(K).push({left:he,gap:le.gap})}else{var X=P.has(le.top)?P.get(le.top):le.top,te=P.has(le.bottom)?P.get(le.bottom):le.bottom;k.nodesInRelativeVertical.includes(X)||(k.nodesInRelativeVertical.push(X),k.nodeToRelativeConstraintMapVertical.set(X,[]),k.dummyToNodeForHorizontalAlignment.has(X)?k.nodeToTempPositionMapVertical.set(X,k.idToNodeMap.get(k.dummyToNodeForHorizontalAlignment.get(X)[0]).getCenterY()):k.nodeToTempPositionMapVertical.set(X,k.idToNodeMap.get(X).getCenterY())),k.nodesInRelativeVertical.includes(te)||(k.nodesInRelativeVertical.push(te),k.nodeToRelativeConstraintMapVertical.set(te,[]),k.dummyToNodeForHorizontalAlignment.has(te)?k.nodeToTempPositionMapVertical.set(te,k.idToNodeMap.get(k.dummyToNodeForHorizontalAlignment.get(te)[0]).getCenterY()):k.nodeToTempPositionMapVertical.set(te,k.idToNodeMap.get(te).getCenterY())),k.nodeToRelativeConstraintMapVertical.get(X).push({bottom:te,gap:le.gap}),k.nodeToRelativeConstraintMapVertical.get(te).push({top:X,gap:le.gap})}});else{var H=new Map,Q=new Map;this.constraints.relativePlacementConstraint.forEach(function(le){if(le.left){var he=F.has(le.left)?F.get(le.left):le.left,K=F.has(le.right)?F.get(le.right):le.right;H.has(he)?H.get(he).push(K):H.set(he,[K]),H.has(K)?H.get(K).push(he):H.set(K,[he])}else{var X=P.has(le.top)?P.get(le.top):le.top,te=P.has(le.bottom)?P.get(le.bottom):le.bottom;Q.has(X)?Q.get(X).push(te):Q.set(X,[te]),Q.has(te)?Q.get(te).push(X):Q.set(te,[X])}});var j=o(function(he,K){var X=[],te=[],J=new _,se=new Set,ue=0;return he.forEach(function(Z,Se){if(!se.has(Se)){X[ue]=[],te[ue]=!1;var ce=Se;for(J.push(ce),se.add(ce),X[ue].push(ce);J.length!=0;){ce=J.shift(),K.has(ce)&&(te[ue]=!0);var ae=he.get(ce);ae.forEach(function(Oe){se.has(Oe)||(J.push(Oe),se.add(Oe),X[ue].push(Oe))})}ue++}}),{components:X,isFixed:te}},"constructComponents"),ie=j(H,k.fixedNodesOnHorizontal);this.componentsOnHorizontal=ie.components,this.fixedComponentsOnHorizontal=ie.isFixed;var ne=j(Q,k.fixedNodesOnVertical);this.componentsOnVertical=ne.components,this.fixedComponentsOnVertical=ne.isFixed}}},I.prototype.updateDisplacements=function(){var k=this;if(this.constraints.fixedNodeConstraint&&this.constraints.fixedNodeConstraint.forEach(function(ne){var le=k.idToNodeMap.get(ne.nodeId);le.displacementX=0,le.displacementY=0}),this.constraints.alignmentConstraint){if(this.constraints.alignmentConstraint.vertical)for(var L=this.constraints.alignmentConstraint.vertical,R=0;R1){var P;for(P=0;PO&&(O=Math.floor(F.y)),B=Math.floor(F.x+m.DEFAULT_COMPONENT_SEPERATION)}this.transform(new b(v.WORLD_CENTER_X-F.x/2,v.WORLD_CENTER_Y-F.y/2))},I.radialLayout=function(k,L,R){var O=Math.max(this.maxDiagonalInTree(k),m.DEFAULT_RADIAL_SEPARATION);I.branchRadialLayout(L,null,0,359,0,O);var M=A.calculateBounds(k),B=new S;B.setDeviceOrgX(M.getMinX()),B.setDeviceOrgY(M.getMinY()),B.setWorldOrgX(R.x),B.setWorldOrgY(R.y);for(var F=0;F1;){var X=K[0];K.splice(0,1);var te=j.indexOf(X);te>=0&&j.splice(te,1),le--,ie--}L!=null?he=(j.indexOf(K[0])+1)%le:he=0;for(var J=Math.abs(O-R)/ie,se=he;ne!=ie;se=++se%le){var ue=j[se].getOtherEnd(k);if(ue!=L){var Z=(R+ne*J)%360,Se=(Z+J)%360;I.branchRadialLayout(ue,k,Z,Se,M+B,B),ne++}}},I.maxDiagonalInTree=function(k){for(var L=T.MIN_VALUE,R=0;RL&&(L=M)}return L},I.prototype.calcRepulsionRange=function(){return 2*(this.level+1)*this.idealEdgeLength},I.prototype.groupZeroDegreeMembers=function(){var k=this,L={};this.memberGroups={},this.idToDummyNode={};for(var R=[],O=this.graphManager.getAllNodes(),M=0;M"u"&&(L[P]=[]),L[P]=L[P].concat(B)}Object.keys(L).forEach(function(z){if(L[z].length>1){var $="DummyCompound_"+z;k.memberGroups[$]=L[z];var H=L[z][0].getParent(),Q=new d(k.graphManager);Q.id=$,Q.paddingLeft=H.paddingLeft||0,Q.paddingRight=H.paddingRight||0,Q.paddingBottom=H.paddingBottom||0,Q.paddingTop=H.paddingTop||0,k.idToDummyNode[$]=Q;var j=k.getGraphManager().add(k.newGraph(),Q),ie=H.getChild();ie.add(Q);for(var ne=0;neM?(O.rect.x-=(O.labelWidth-M)/2,O.setWidth(O.labelWidth),O.labelMarginLeft=(O.labelWidth-M)/2):O.labelPosHorizontal=="right"&&O.setWidth(M+O.labelWidth)),O.labelHeight&&(O.labelPosVertical=="top"?(O.rect.y-=O.labelHeight,O.setHeight(B+O.labelHeight),O.labelMarginTop=O.labelHeight):O.labelPosVertical=="center"&&O.labelHeight>B?(O.rect.y-=(O.labelHeight-B)/2,O.setHeight(O.labelHeight),O.labelMarginTop=(O.labelHeight-B)/2):O.labelPosVertical=="bottom"&&O.setHeight(B+O.labelHeight))}})},I.prototype.repopulateCompounds=function(){for(var k=this.compoundOrder.length-1;k>=0;k--){var L=this.compoundOrder[k],R=L.id,O=L.paddingLeft,M=L.paddingTop,B=L.labelMarginLeft,F=L.labelMarginTop;this.adjustLocations(this.tiledMemberPack[R],L.rect.x,L.rect.y,O,M,B,F)}},I.prototype.repopulateZeroDegreeMembers=function(){var k=this,L=this.tiledZeroDegreePack;Object.keys(L).forEach(function(R){var O=k.idToDummyNode[R],M=O.paddingLeft,B=O.paddingTop,F=O.labelMarginLeft,P=O.labelMarginTop;k.adjustLocations(L[R],O.rect.x,O.rect.y,M,B,F,P)})},I.prototype.getToBeTiled=function(k){var L=k.id;if(this.toBeTiled[L]!=null)return this.toBeTiled[L];var R=k.getChild();if(R==null)return this.toBeTiled[L]=!1,!1;for(var O=R.getNodes(),M=0;M0)return this.toBeTiled[L]=!1,!1;if(B.getChild()==null){this.toBeTiled[B.id]=!1;continue}if(!this.getToBeTiled(B))return this.toBeTiled[L]=!1,!1}return this.toBeTiled[L]=!0,!0},I.prototype.getNodeDegree=function(k){for(var L=k.id,R=k.getEdges(),O=0,M=0;MH&&(H=j.rect.height)}R+=H+k.verticalPadding}},I.prototype.tileCompoundMembers=function(k,L){var R=this;this.tiledMemberPack=[],Object.keys(k).forEach(function(O){var M=L[O];if(R.tiledMemberPack[O]=R.tileNodes(k[O],M.paddingLeft+M.paddingRight),M.rect.width=R.tiledMemberPack[O].width,M.rect.height=R.tiledMemberPack[O].height,M.setCenter(R.tiledMemberPack[O].centerX,R.tiledMemberPack[O].centerY),M.labelMarginLeft=0,M.labelMarginTop=0,m.NODE_DIMENSIONS_INCLUDE_LABELS){var B=M.rect.width,F=M.rect.height;M.labelWidth&&(M.labelPosHorizontal=="left"?(M.rect.x-=M.labelWidth,M.setWidth(B+M.labelWidth),M.labelMarginLeft=M.labelWidth):M.labelPosHorizontal=="center"&&M.labelWidth>B?(M.rect.x-=(M.labelWidth-B)/2,M.setWidth(M.labelWidth),M.labelMarginLeft=(M.labelWidth-B)/2):M.labelPosHorizontal=="right"&&M.setWidth(B+M.labelWidth)),M.labelHeight&&(M.labelPosVertical=="top"?(M.rect.y-=M.labelHeight,M.setHeight(F+M.labelHeight),M.labelMarginTop=M.labelHeight):M.labelPosVertical=="center"&&M.labelHeight>F?(M.rect.y-=(M.labelHeight-F)/2,M.setHeight(M.labelHeight),M.labelMarginTop=(M.labelHeight-F)/2):M.labelPosVertical=="bottom"&&M.setHeight(F+M.labelHeight))}})},I.prototype.tileNodes=function(k,L){var R=this.tileNodesByFavoringDim(k,L,!0),O=this.tileNodesByFavoringDim(k,L,!1),M=this.getOrgRatio(R),B=this.getOrgRatio(O),F;return BP&&(P=ne.getWidth())});var z=B/M,$=F/M,H=Math.pow(R-O,2)+4*(z+O)*($+R)*M,Q=(O-R+Math.sqrt(H))/(2*(z+O)),j;L?(j=Math.ceil(Q),j==Q&&j++):j=Math.floor(Q);var ie=j*(z+O)-O;return P>ie&&(ie=P),ie+=O*2,ie},I.prototype.tileNodesByFavoringDim=function(k,L,R){var O=m.TILING_PADDING_VERTICAL,M=m.TILING_PADDING_HORIZONTAL,B=m.TILING_COMPARE_BY,F={rows:[],rowWidth:[],rowHeight:[],width:0,height:L,verticalPadding:O,horizontalPadding:M,centerX:0,centerY:0};B&&(F.idealRowWidth=this.calcIdealRowWidth(k,R));var P=o(function(le){return le.rect.width*le.rect.height},"getNodeArea"),z=o(function(le,he){return P(he)-P(le)},"areaCompareFcn");k.sort(function(ne,le){var he=z;return F.idealRowWidth?(he=B,he(ne.id,le.id)):he(ne,le)});for(var $=0,H=0,Q=0;Q0&&(F+=k.horizontalPadding),k.rowWidth[R]=F,k.width0&&(P+=k.verticalPadding);var z=0;P>k.rowHeight[R]&&(z=k.rowHeight[R],k.rowHeight[R]=P,z=k.rowHeight[R]-z),k.height+=z,k.rows[R].push(L)},I.prototype.getShortestRowIndex=function(k){for(var L=-1,R=Number.MAX_VALUE,O=0;OR&&(L=O,R=k.rowWidth[O]);return L},I.prototype.canAddHorizontal=function(k,L,R){if(k.idealRowWidth){var O=k.rows.length-1,M=k.rowWidth[O];return M+L+k.horizontalPadding<=k.idealRowWidth}var B=this.getShortestRowIndex(k);if(B<0)return!0;var F=k.rowWidth[B];if(F+k.horizontalPadding+L<=k.width)return!0;var P=0;k.rowHeight[B]0&&(P=R+k.verticalPadding-k.rowHeight[B]);var z;k.width-F>=L+k.horizontalPadding?z=(k.height+P)/(F+L+k.horizontalPadding):z=(k.height+P)/k.width,P=R+k.verticalPadding;var $;return k.widthB&&L!=R){O.splice(-1,1),k.rows[R].push(M),k.rowWidth[L]=k.rowWidth[L]-B,k.rowWidth[R]=k.rowWidth[R]+B,k.width=k.rowWidth[instance.getLongestRowIndex(k)];for(var F=Number.MIN_VALUE,P=0;PF&&(F=O[P].height);L>0&&(F+=k.verticalPadding);var z=k.rowHeight[L]+k.rowHeight[R];k.rowHeight[L]=F,k.rowHeight[R]0)for(var ie=M;ie<=B;ie++)j[0]+=this.grid[ie][F-1].length+this.grid[ie][F].length-1;if(B0)for(var ie=F;ie<=P;ie++)j[3]+=this.grid[M-1][ie].length+this.grid[M][ie].length-1;for(var ne=T.MAX_VALUE,le,he,K=0;K{var u=l(551).FDLayoutNode,h=l(551).IMath;function f(p,m,g,y){u.call(this,p,m,g,y)}o(f,"CoSENode"),f.prototype=Object.create(u.prototype);for(var d in u)f[d]=u[d];f.prototype.calculateDisplacement=function(){var p=this.graphManager.getLayout();this.getChild()!=null&&this.fixedNodeWeight?(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.fixedNodeWeight,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.fixedNodeWeight):(this.displacementX+=p.coolingFactor*(this.springForceX+this.repulsionForceX+this.gravitationForceX)/this.noOfChildren,this.displacementY+=p.coolingFactor*(this.springForceY+this.repulsionForceY+this.gravitationForceY)/this.noOfChildren),Math.abs(this.displacementX)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementX=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementX)),Math.abs(this.displacementY)>p.coolingFactor*p.maxNodeDisplacement&&(this.displacementY=p.coolingFactor*p.maxNodeDisplacement*h.sign(this.displacementY)),this.child&&this.child.getNodes().length>0&&this.propogateDisplacementToChildren(this.displacementX,this.displacementY)},f.prototype.propogateDisplacementToChildren=function(p,m){for(var g=this.getChild().getNodes(),y,v=0;v{function u(g){if(Array.isArray(g)){for(var y=0,v=Array(g.length);y0){var ct=0;Ue.forEach(function(ot){xe=="horizontal"?(we.set(ot,x.has(ot)?b[x.get(ot)]:pe.get(ot)),ct+=we.get(ot)):(we.set(ot,x.has(ot)?w[x.get(ot)]:pe.get(ot)),ct+=we.get(ot))}),ct=ct/Ue.length,st.forEach(function(ot){q.has(ot)||we.set(ot,ct)})}else{var We=0;st.forEach(function(ot){xe=="horizontal"?We+=x.has(ot)?b[x.get(ot)]:pe.get(ot):We+=x.has(ot)?w[x.get(ot)]:pe.get(ot)}),We=We/st.length,st.forEach(function(ot){we.set(ot,We)})}});for(var qe=o(function(){var Ue=De.shift(),ct=V.get(Ue);ct.forEach(function(We){if(we.get(We.id)ot&&(ot=yt),ntYt&&(Yt=nt)}}catch(At){Mt=!0,xt=At}finally{try{!bt&&ut.return&&ut.return()}finally{if(Mt)throw xt}}var dn=(ct+ot)/2-(We+Yt)/2,Tt=!0,On=!1,tn=void 0;try{for(var _r=st[Symbol.iterator](),Dr;!(Tt=(Dr=_r.next()).done);Tt=!0){var Pn=Dr.value;we.set(Pn,we.get(Pn)+dn)}}catch(At){On=!0,tn=At}finally{try{!Tt&&_r.return&&_r.return()}finally{if(On)throw tn}}})}return we},"findAppropriatePositionForRelativePlacement"),D=o(function(V){var xe=0,q=0,pe=0,ve=0;if(V.forEach(function(Ve){Ve.left?b[x.get(Ve.left)]-b[x.get(Ve.right)]>=0?xe++:q++:w[x.get(Ve.top)]-w[x.get(Ve.bottom)]>=0?pe++:ve++}),xe>q&&pe>ve)for(var Pe=0;Peq)for(var _e=0;_eve)for(var we=0;we1)y.fixedNodeConstraint.forEach(function(oe,V){O[V]=[oe.position.x,oe.position.y],M[V]=[b[x.get(oe.nodeId)],w[x.get(oe.nodeId)]]}),B=!0;else if(y.alignmentConstraint)(function(){var oe=0;if(y.alignmentConstraint.vertical){for(var V=y.alignmentConstraint.vertical,xe=o(function(we){var Ve=new Set;V[we].forEach(function(at){Ve.add(at)});var De=new Set([].concat(u(Ve)).filter(function(at){return P.has(at)})),qe=void 0;De.size>0?qe=b[x.get(De.values().next().value)]:qe=_(Ve).x,V[we].forEach(function(at){O[oe]=[qe,w[x.get(at)]],M[oe]=[b[x.get(at)],w[x.get(at)]],oe++})},"_loop2"),q=0;q0?qe=b[x.get(De.values().next().value)]:qe=_(Ve).y,pe[we].forEach(function(at){O[oe]=[b[x.get(at)],qe],M[oe]=[b[x.get(at)],w[x.get(at)]],oe++})},"_loop3"),Pe=0;PeQ&&(Q=H[ie].length,j=ie);if(Q<$.size/2)D(y.relativePlacementConstraint),B=!1,F=!1;else{var ne=new Map,le=new Map,he=[];H[j].forEach(function(oe){z.get(oe).forEach(function(V){V.direction=="horizontal"?(ne.has(oe)?ne.get(oe).push(V):ne.set(oe,[V]),ne.has(V.id)||ne.set(V.id,[]),he.push({left:oe,right:V.id})):(le.has(oe)?le.get(oe).push(V):le.set(oe,[V]),le.has(V.id)||le.set(V.id,[]),he.push({top:oe,bottom:V.id}))})}),D(he),F=!1;var K=I(ne,"horizontal"),X=I(le,"vertical");H[j].forEach(function(oe,V){M[V]=[b[x.get(oe)],w[x.get(oe)]],O[V]=[],K.has(oe)?O[V][0]=K.get(oe):O[V][0]=b[x.get(oe)],X.has(oe)?O[V][1]=X.get(oe):O[V][1]=w[x.get(oe)]}),B=!0}}if(B){for(var te=void 0,J=d.transpose(O),se=d.transpose(M),ue=0;ue0){var ze={x:0,y:0};y.fixedNodeConstraint.forEach(function(oe,V){var xe={x:b[x.get(oe.nodeId)],y:w[x.get(oe.nodeId)]},q=oe.position,pe=S(q,xe);ze.x+=pe.x,ze.y+=pe.y}),ze.x/=y.fixedNodeConstraint.length,ze.y/=y.fixedNodeConstraint.length,b.forEach(function(oe,V){b[V]+=ze.x}),w.forEach(function(oe,V){w[V]+=ze.y}),y.fixedNodeConstraint.forEach(function(oe){b[x.get(oe.nodeId)]=oe.position.x,w[x.get(oe.nodeId)]=oe.position.y})}if(y.alignmentConstraint){if(y.alignmentConstraint.vertical)for(var He=y.alignmentConstraint.vertical,$e=o(function(V){var xe=new Set;He[V].forEach(function(ve){xe.add(ve)});var q=new Set([].concat(u(xe)).filter(function(ve){return P.has(ve)})),pe=void 0;q.size>0?pe=b[x.get(q.values().next().value)]:pe=_(xe).x,xe.forEach(function(ve){P.has(ve)||(b[x.get(ve)]=pe)})},"_loop4"),Re=0;Re0?pe=w[x.get(q.values().next().value)]:pe=_(xe).y,xe.forEach(function(ve){P.has(ve)||(w[x.get(ve)]=pe)})},"_loop5"),W=0;W{a.exports=t}},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(45);return i})()})});var Pve=Mi((E4,gF)=>{"use strict";o(function(e,r){typeof E4=="object"&&typeof gF=="object"?gF.exports=r(mF()):typeof define=="function"&&define.amd?define(["cose-base"],r):typeof E4=="object"?E4.cytoscapeFcose=r(mF()):e.cytoscapeFcose=r(e.coseBase)},"webpackUniversalModuleDefinition")(E4,function(t){return(()=>{"use strict";var e={658:a=>{a.exports=Object.assign!=null?Object.assign.bind(Object):function(s){for(var l=arguments.length,u=Array(l>1?l-1:0),h=1;h{var u=function(){function d(p,m){var g=[],y=!0,v=!1,x=void 0;try{for(var b=p[Symbol.iterator](),w;!(y=(w=b.next()).done)&&(g.push(w.value),!(m&&g.length===m));y=!0);}catch(C){v=!0,x=C}finally{try{!y&&b.return&&b.return()}finally{if(v)throw x}}return g}return o(d,"sliceIterator"),function(p,m){if(Array.isArray(p))return p;if(Symbol.iterator in Object(p))return d(p,m);throw new TypeError("Invalid attempt to destructure non-iterable instance")}}(),h=l(140).layoutBase.LinkedList,f={};f.getTopMostNodes=function(d){for(var p={},m=0;m0&&B.merge($)});for(var F=0;F1){w=x[0],C=w.connectedEdges().length,x.forEach(function(M){M.connectedEdges().length0&&g.set("dummy"+(g.size+1),A),S},f.relocateComponent=function(d,p,m){if(!m.fixedNodeConstraint){var g=Number.POSITIVE_INFINITY,y=Number.NEGATIVE_INFINITY,v=Number.POSITIVE_INFINITY,x=Number.NEGATIVE_INFINITY;if(m.quality=="draft"){var b=!0,w=!1,C=void 0;try{for(var T=p.nodeIndexes[Symbol.iterator](),E;!(b=(E=T.next()).done);b=!0){var A=E.value,S=u(A,2),_=S[0],I=S[1],D=m.cy.getElementById(_);if(D){var k=D.boundingBox(),L=p.xCoords[I]-k.w/2,R=p.xCoords[I]+k.w/2,O=p.yCoords[I]-k.h/2,M=p.yCoords[I]+k.h/2;Ly&&(y=R),Ox&&(x=M)}}}catch($){w=!0,C=$}finally{try{!b&&T.return&&T.return()}finally{if(w)throw C}}var B=d.x-(y+g)/2,F=d.y-(x+v)/2;p.xCoords=p.xCoords.map(function($){return $+B}),p.yCoords=p.yCoords.map(function($){return $+F})}else{Object.keys(p).forEach(function($){var H=p[$],Q=H.getRect().x,j=H.getRect().x+H.getRect().width,ie=H.getRect().y,ne=H.getRect().y+H.getRect().height;Qy&&(y=j),iex&&(x=ne)});var P=d.x-(y+g)/2,z=d.y-(x+v)/2;Object.keys(p).forEach(function($){var H=p[$];H.setCenter(H.getCenterX()+P,H.getCenterY()+z)})}}},f.calcBoundingBox=function(d,p,m,g){for(var y=Number.MAX_SAFE_INTEGER,v=Number.MIN_SAFE_INTEGER,x=Number.MAX_SAFE_INTEGER,b=Number.MIN_SAFE_INTEGER,w=void 0,C=void 0,T=void 0,E=void 0,A=d.descendants().not(":parent"),S=A.length,_=0;_w&&(y=w),vT&&(x=T),b{var u=l(548),h=l(140).CoSELayout,f=l(140).CoSENode,d=l(140).layoutBase.PointD,p=l(140).layoutBase.DimensionD,m=l(140).layoutBase.LayoutConstants,g=l(140).layoutBase.FDLayoutConstants,y=l(140).CoSEConstants,v=o(function(b,w){var C=b.cy,T=b.eles,E=T.nodes(),A=T.edges(),S=void 0,_=void 0,I=void 0,D={};b.randomize&&(S=w.nodeIndexes,_=w.xCoords,I=w.yCoords);var k=o(function($){return typeof $=="function"},"isFn"),L=o(function($,H){return k($)?$(H):$},"optFn"),R=u.calcParentsWithoutChildren(C,T),O=o(function z($,H,Q,j){for(var ie=H.length,ne=0;ne0){var J=void 0;J=Q.getGraphManager().add(Q.newGraph(),K),z(J,he,Q,j)}}},"processChildrenList"),M=o(function($,H,Q){for(var j=0,ie=0,ne=0;ne0?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=j/ie:k(b.idealEdgeLength)?y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=50:y.DEFAULT_EDGE_LENGTH=g.DEFAULT_EDGE_LENGTH=b.idealEdgeLength,y.MIN_REPULSION_DIST=g.MIN_REPULSION_DIST=g.DEFAULT_EDGE_LENGTH/10,y.DEFAULT_RADIAL_SEPARATION=g.DEFAULT_EDGE_LENGTH)},"processEdges"),B=o(function($,H){H.fixedNodeConstraint&&($.constraints.fixedNodeConstraint=H.fixedNodeConstraint),H.alignmentConstraint&&($.constraints.alignmentConstraint=H.alignmentConstraint),H.relativePlacementConstraint&&($.constraints.relativePlacementConstraint=H.relativePlacementConstraint)},"processConstraints");b.nestingFactor!=null&&(y.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=g.PER_LEVEL_IDEAL_EDGE_LENGTH_FACTOR=b.nestingFactor),b.gravity!=null&&(y.DEFAULT_GRAVITY_STRENGTH=g.DEFAULT_GRAVITY_STRENGTH=b.gravity),b.numIter!=null&&(y.MAX_ITERATIONS=g.MAX_ITERATIONS=b.numIter),b.gravityRange!=null&&(y.DEFAULT_GRAVITY_RANGE_FACTOR=g.DEFAULT_GRAVITY_RANGE_FACTOR=b.gravityRange),b.gravityCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_STRENGTH=g.DEFAULT_COMPOUND_GRAVITY_STRENGTH=b.gravityCompound),b.gravityRangeCompound!=null&&(y.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=g.DEFAULT_COMPOUND_GRAVITY_RANGE_FACTOR=b.gravityRangeCompound),b.initialEnergyOnIncremental!=null&&(y.DEFAULT_COOLING_FACTOR_INCREMENTAL=g.DEFAULT_COOLING_FACTOR_INCREMENTAL=b.initialEnergyOnIncremental),b.tilingCompareBy!=null&&(y.TILING_COMPARE_BY=b.tilingCompareBy),b.quality=="proof"?m.QUALITY=2:m.QUALITY=0,y.NODE_DIMENSIONS_INCLUDE_LABELS=g.NODE_DIMENSIONS_INCLUDE_LABELS=m.NODE_DIMENSIONS_INCLUDE_LABELS=b.nodeDimensionsIncludeLabels,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!b.randomize,y.ANIMATE=g.ANIMATE=m.ANIMATE=b.animate,y.TILE=b.tile,y.TILING_PADDING_VERTICAL=typeof b.tilingPaddingVertical=="function"?b.tilingPaddingVertical.call():b.tilingPaddingVertical,y.TILING_PADDING_HORIZONTAL=typeof b.tilingPaddingHorizontal=="function"?b.tilingPaddingHorizontal.call():b.tilingPaddingHorizontal,y.DEFAULT_INCREMENTAL=g.DEFAULT_INCREMENTAL=m.DEFAULT_INCREMENTAL=!0,y.PURE_INCREMENTAL=!b.randomize,m.DEFAULT_UNIFORM_LEAF_NODE_SIZES=b.uniformNodeDimensions,b.step=="transformed"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!1),b.step=="enforced"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!1),b.step=="cose"&&(y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!1,y.APPLY_LAYOUT=!0),b.step=="all"&&(b.randomize?y.TRANSFORM_ON_CONSTRAINT_HANDLING=!0:y.TRANSFORM_ON_CONSTRAINT_HANDLING=!1,y.ENFORCE_CONSTRAINTS=!0,y.APPLY_LAYOUT=!0),b.fixedNodeConstraint||b.alignmentConstraint||b.relativePlacementConstraint?y.TREE_REDUCTION_ON_INCREMENTAL=!1:y.TREE_REDUCTION_ON_INCREMENTAL=!0;var F=new h,P=F.newGraphManager();return O(P.addRoot(),u.getTopMostNodes(E),F,b),M(F,P,A),B(F,b),F.runLayout(),D},"coseLayout");a.exports={coseLayout:v}},212:(a,s,l)=>{var u=function(){function b(w,C){for(var T=0;T0)if(M){var P=d.getTopMostNodes(T.eles.nodes());if(k=d.connectComponents(E,T.eles,P),k.forEach(function(ce){var ae=ce.boundingBox();L.push({x:ae.x1+ae.w/2,y:ae.y1+ae.h/2})}),T.randomize&&k.forEach(function(ce){T.eles=ce,S.push(m(T))}),T.quality=="default"||T.quality=="proof"){var z=E.collection();if(T.tile){var $=new Map,H=[],Q=[],j=0,ie={nodeIndexes:$,xCoords:H,yCoords:Q},ne=[];if(k.forEach(function(ce,ae){ce.edges().length==0&&(ce.nodes().forEach(function(Oe,ge){z.merge(ce.nodes()[ge]),Oe.isParent()||(ie.nodeIndexes.set(ce.nodes()[ge].id(),j++),ie.xCoords.push(ce.nodes()[0].position().x),ie.yCoords.push(ce.nodes()[0].position().y))}),ne.push(ae))}),z.length>1){var le=z.boundingBox();L.push({x:le.x1+le.w/2,y:le.y1+le.h/2}),k.push(z),S.push(ie);for(var he=ne.length-1;he>=0;he--)k.splice(ne[he],1),S.splice(ne[he],1),L.splice(ne[he],1)}}k.forEach(function(ce,ae){T.eles=ce,D.push(y(T,S[ae])),d.relocateComponent(L[ae],D[ae],T)})}else k.forEach(function(ce,ae){d.relocateComponent(L[ae],S[ae],T)});var K=new Set;if(k.length>1){var X=[],te=A.filter(function(ce){return ce.css("display")=="none"});k.forEach(function(ce,ae){var Oe=void 0;if(T.quality=="draft"&&(Oe=S[ae].nodeIndexes),ce.nodes().not(te).length>0){var ge={};ge.edges=[],ge.nodes=[];var ze=void 0;ce.nodes().not(te).forEach(function(He){if(T.quality=="draft")if(!He.isParent())ze=Oe.get(He.id()),ge.nodes.push({x:S[ae].xCoords[ze]-He.boundingbox().w/2,y:S[ae].yCoords[ze]-He.boundingbox().h/2,width:He.boundingbox().w,height:He.boundingbox().h});else{var $e=d.calcBoundingBox(He,S[ae].xCoords,S[ae].yCoords,Oe);ge.nodes.push({x:$e.topLeftX,y:$e.topLeftY,width:$e.width,height:$e.height})}else D[ae][He.id()]&&ge.nodes.push({x:D[ae][He.id()].getLeft(),y:D[ae][He.id()].getTop(),width:D[ae][He.id()].getWidth(),height:D[ae][He.id()].getHeight()})}),ce.edges().forEach(function(He){var $e=He.source(),Re=He.target();if($e.css("display")!="none"&&Re.css("display")!="none")if(T.quality=="draft"){var Ie=Oe.get($e.id()),be=Oe.get(Re.id()),W=[],de=[];if($e.isParent()){var re=d.calcBoundingBox($e,S[ae].xCoords,S[ae].yCoords,Oe);W.push(re.topLeftX+re.width/2),W.push(re.topLeftY+re.height/2)}else W.push(S[ae].xCoords[Ie]),W.push(S[ae].yCoords[Ie]);if(Re.isParent()){var oe=d.calcBoundingBox(Re,S[ae].xCoords,S[ae].yCoords,Oe);de.push(oe.topLeftX+oe.width/2),de.push(oe.topLeftY+oe.height/2)}else de.push(S[ae].xCoords[be]),de.push(S[ae].yCoords[be]);ge.edges.push({startX:W[0],startY:W[1],endX:de[0],endY:de[1]})}else D[ae][$e.id()]&&D[ae][Re.id()]&&ge.edges.push({startX:D[ae][$e.id()].getCenterX(),startY:D[ae][$e.id()].getCenterY(),endX:D[ae][Re.id()].getCenterX(),endY:D[ae][Re.id()].getCenterY()})}),ge.nodes.length>0&&(X.push(ge),K.add(ae))}});var J=O.packComponents(X,T.randomize).shifts;if(T.quality=="draft")S.forEach(function(ce,ae){var Oe=ce.xCoords.map(function(ze){return ze+J[ae].dx}),ge=ce.yCoords.map(function(ze){return ze+J[ae].dy});ce.xCoords=Oe,ce.yCoords=ge});else{var se=0;K.forEach(function(ce){Object.keys(D[ce]).forEach(function(ae){var Oe=D[ce][ae];Oe.setCenter(Oe.getCenterX()+J[se].dx,Oe.getCenterY()+J[se].dy)}),se++})}}}else{var B=T.eles.boundingBox();if(L.push({x:B.x1+B.w/2,y:B.y1+B.h/2}),T.randomize){var F=m(T);S.push(F)}T.quality=="default"||T.quality=="proof"?(D.push(y(T,S[0])),d.relocateComponent(L[0],D[0],T)):d.relocateComponent(L[0],S[0],T)}var ue=o(function(ae,Oe){if(T.quality=="default"||T.quality=="proof"){typeof ae=="number"&&(ae=Oe);var ge=void 0,ze=void 0,He=ae.data("id");return D.forEach(function(Re){He in Re&&(ge={x:Re[He].getRect().getCenterX(),y:Re[He].getRect().getCenterY()},ze=Re[He])}),T.nodeDimensionsIncludeLabels&&(ze.labelWidth&&(ze.labelPosHorizontal=="left"?ge.x+=ze.labelWidth/2:ze.labelPosHorizontal=="right"&&(ge.x-=ze.labelWidth/2)),ze.labelHeight&&(ze.labelPosVertical=="top"?ge.y+=ze.labelHeight/2:ze.labelPosVertical=="bottom"&&(ge.y-=ze.labelHeight/2))),ge==null&&(ge={x:ae.position("x"),y:ae.position("y")}),{x:ge.x,y:ge.y}}else{var $e=void 0;return S.forEach(function(Re){var Ie=Re.nodeIndexes.get(ae.id());Ie!=null&&($e={x:Re.xCoords[Ie],y:Re.yCoords[Ie]})}),$e==null&&($e={x:ae.position("x"),y:ae.position("y")}),{x:$e.x,y:$e.y}}},"getPositions");if(T.quality=="default"||T.quality=="proof"||T.randomize){var Z=d.calcParentsWithoutChildren(E,A),Se=A.filter(function(ce){return ce.css("display")=="none"});T.eles=A.not(Se),A.nodes().not(":parent").not(Se).layoutPositions(C,T,ue),Z.length>0&&Z.forEach(function(ce){ce.position(ue(ce))})}else console.log("If randomize option is set to false, then quality option must be 'default' or 'proof'.")},"run")}]),b}();a.exports=x},657:(a,s,l)=>{var u=l(548),h=l(140).layoutBase.Matrix,f=l(140).layoutBase.SVD,d=o(function(m){var g=m.cy,y=m.eles,v=y.nodes(),x=y.nodes(":parent"),b=new Map,w=new Map,C=new Map,T=[],E=[],A=[],S=[],_=[],I=[],D=[],k=[],L=void 0,R=void 0,O=1e8,M=1e-9,B=m.piTol,F=m.samplingType,P=m.nodeSeparation,z=void 0,$=o(function(){for(var xe=0,q=0,pe=!1;q=Pe;){we=ve[Pe++];for(var st=T[we],Ue=0;Ueqe&&(qe=_[We],at=We)}return at},"BFS"),Q=o(function(xe){var q=void 0;if(xe){q=Math.floor(Math.random()*R),L=q;for(var ve=0;ve=1)break;qe=De}for(var st=0;st=1)break;qe=De}for(var ct=0;ct0&&(q.isParent()?T[xe].push(C.get(q.id())):T[xe].push(q.id()))})});var Z=o(function(xe){var q=w.get(xe),pe=void 0;b.get(xe).forEach(function(ve){g.getElementById(ve).isParent()?pe=C.get(ve):pe=ve,T[q].push(pe),T[w.get(pe)].push(xe)})},"_loop"),Se=!0,ce=!1,ae=void 0;try{for(var Oe=b.keys()[Symbol.iterator](),ge;!(Se=(ge=Oe.next()).done);Se=!0){var ze=ge.value;Z(ze)}}catch(V){ce=!0,ae=V}finally{try{!Se&&Oe.return&&Oe.return()}finally{if(ce)throw ae}}R=w.size;var He=void 0;if(R>2){z=R{var u=l(212),h=o(function(d){d&&d("layout","fcose",u)},"register");typeof cytoscape<"u"&&h(cytoscape),a.exports=h},140:a=>{a.exports=t}},r={};function n(a){var s=r[a];if(s!==void 0)return s.exports;var l=r[a]={exports:{}};return e[a](l,l.exports,n),l.exports}o(n,"__webpack_require__");var i=n(579);return i})()})});var dy,Zp,yF=N(()=>{"use strict";tu();dy=o(t=>`${t}`,"wrapIcon"),Zp={prefix:"mermaid-architecture",height:80,width:80,icons:{database:{body:dy('')},server:{body:dy('')},disk:{body:dy('')},internet:{body:dy('')},cloud:{body:dy('')},unknown:OC,blank:{body:dy("")}}}});var Bve,Fve,$ve,zve,Gve=N(()=>{"use strict";tu();zt();to();w4();yF();oC();Bve=o(async function(t,e){let r=Li("padding"),n=Li("iconSize"),i=n/2,a=n/6,s=a/2;await Promise.all(e.edges().map(async l=>{let{source:u,sourceDir:h,sourceArrow:f,sourceGroup:d,target:p,targetDir:m,targetArrow:g,targetGroup:y,label:v}=sC(l),{x,y:b}=l[0].sourceEndpoint(),{x:w,y:C}=l[0].midpoint(),{x:T,y:E}=l[0].targetEndpoint(),A=r+4;if(d&&(Ha(h)?x+=h==="L"?-A:A:b+=h==="T"?-A:A+18),y&&(Ha(m)?T+=m==="L"?-A:A:E+=m==="T"?-A:A+18),!d&&Qp.getNode(u)?.type==="junction"&&(Ha(h)?x+=h==="L"?i:-i:b+=h==="T"?i:-i),!y&&Qp.getNode(p)?.type==="junction"&&(Ha(m)?T+=m==="L"?i:-i:E+=m==="T"?i:-i),l[0]._private.rscratch){let S=t.insert("g");if(S.insert("path").attr("d",`M ${x},${b} L ${w},${C} L${T},${E} `).attr("class","edge"),f){let _=Ha(h)?v4[h](x,a):x-s,I=Zc(h)?v4[h](b,a):b-s;S.insert("polygon").attr("points",cF[h](a)).attr("transform",`translate(${_},${I})`).attr("class","arrow")}if(g){let _=Ha(m)?v4[m](T,a):T-s,I=Zc(m)?v4[m](E,a):E-s;S.insert("polygon").attr("points",cF[m](a)).attr("transform",`translate(${_},${I})`).attr("class","arrow")}if(v){let _=x4(h,m)?"XY":Ha(h)?"X":"Y",I=0;_==="X"?I=Math.abs(x-T):_==="Y"?I=Math.abs(b-E)/1.5:I=Math.abs(x-T)/2;let D=S.append("g");if(await Hn(D,v,{useHtmlLabels:!1,width:I,classes:"architecture-service-label"},me()),D.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),_==="X")D.attr("transform","translate("+w+", "+C+")");else if(_==="Y")D.attr("transform","translate("+w+", "+C+") rotate(-90)");else if(_==="XY"){let k=b4(h,m);if(k&&Sve(k)){let L=D.node().getBoundingClientRect(),[R,O]=Ave(k);D.attr("dominant-baseline","auto").attr("transform",`rotate(${-1*R*O*45})`);let M=D.node().getBoundingClientRect();D.attr("transform",` + translate(${w}, ${C-L.height/2}) + translate(${R*M.width/2}, ${O*M.height/2}) + rotate(${-1*R*O*45}, 0, ${L.height/2}) + `)}}}}}))},"drawEdges"),Fve=o(async function(t,e){let n=Li("padding")*.75,i=Li("fontSize"),s=Li("iconSize")/2;await Promise.all(e.nodes().map(async l=>{let u=Ff(l);if(u.type==="group"){let{h,w:f,x1:d,y1:p}=l.boundingBox();t.append("rect").attr("x",d+s).attr("y",p+s).attr("width",f).attr("height",h).attr("class","node-bkg");let m=t.append("g"),g=d,y=p;if(u.icon){let v=m.append("g");v.html(`${await wo(u.icon,{height:n,width:n,fallbackPrefix:Zp.prefix})}`),v.attr("transform","translate("+(g+s+1)+", "+(y+s+1)+")"),g+=n,y+=i/2-1-2}if(u.label){let v=m.append("g");await Hn(v,u.label,{useHtmlLabels:!1,width:f,classes:"architecture-service-label"},me()),v.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","start").attr("text-anchor","start"),v.attr("transform","translate("+(g+s+4)+", "+(y+s+2)+")")}}}))},"drawGroups"),$ve=o(async function(t,e,r){for(let n of r){let i=e.append("g"),a=Li("iconSize");if(n.title){let h=i.append("g");await Hn(h,n.title,{useHtmlLabels:!1,width:a*1.5,classes:"architecture-service-label"},me()),h.attr("dy","1em").attr("alignment-baseline","middle").attr("dominant-baseline","middle").attr("text-anchor","middle"),h.attr("transform","translate("+a/2+", "+a+")")}let s=i.append("g");if(n.icon)s.html(`${await wo(n.icon,{height:a,width:a,fallbackPrefix:Zp.prefix})}`);else if(n.iconText){s.html(`${await wo("blank",{height:a,width:a,fallbackPrefix:Zp.prefix})}`);let d=s.append("g").append("foreignObject").attr("width",a).attr("height",a).append("div").attr("class","node-icon-text").attr("style",`height: ${a}px;`).append("div").html(n.iconText),p=parseInt(window.getComputedStyle(d.node(),null).getPropertyValue("font-size").replace(/\D/g,""))??16;d.attr("style",`-webkit-line-clamp: ${Math.floor((a-2)/p)};`)}else s.append("path").attr("class","node-bkg").attr("id","node-"+n.id).attr("d",`M0 ${a} v${-a} q0,-5 5,-5 h${a} q5,0 5,5 v${a} H0 Z`);i.attr("class","architecture-service");let{width:l,height:u}=i._groups[0][0].getBBox();n.width=l,n.height=u,t.setElementForId(n.id,i)}return 0},"drawServices"),zve=o(function(t,e,r){r.forEach(n=>{let i=e.append("g"),a=Li("iconSize");i.append("g").append("rect").attr("id","node-"+n.id).attr("fill-opacity","0").attr("width",a).attr("height",a),i.attr("class","architecture-junction");let{width:l,height:u}=i._groups[0][0].getBBox();i.width=l,i.height=u,t.setElementForId(n.id,i)})},"drawJunctions")});function Srt(t,e){t.forEach(r=>{e.add({group:"nodes",data:{type:"service",id:r.id,icon:r.icon,label:r.title,parent:r.in,width:Li("iconSize"),height:Li("iconSize")},classes:"node-service"})})}function Crt(t,e){t.forEach(r=>{e.add({group:"nodes",data:{type:"junction",id:r.id,parent:r.in,width:Li("iconSize"),height:Li("iconSize")},classes:"node-junction"})})}function Art(t,e){e.nodes().map(r=>{let n=Ff(r);if(n.type==="group")return;n.x=r.position().x,n.y=r.position().y,t.getElementById(n.id).attr("transform","translate("+(n.x||0)+","+(n.y||0)+")")})}function _rt(t,e){t.forEach(r=>{e.add({group:"nodes",data:{type:"group",id:r.id,icon:r.icon,label:r.title,parent:r.in},classes:"node-group"})})}function Drt(t,e){t.forEach(r=>{let{lhsId:n,rhsId:i,lhsInto:a,lhsGroup:s,rhsInto:l,lhsDir:u,rhsDir:h,rhsGroup:f,title:d}=r,p=x4(r.lhsDir,r.rhsDir)?"segments":"straight",m={id:`${n}-${i}`,label:d,source:n,sourceDir:u,sourceArrow:a,sourceGroup:s,sourceEndpoint:u==="L"?"0 50%":u==="R"?"100% 50%":u==="T"?"50% 0":"50% 100%",target:i,targetDir:h,targetArrow:l,targetGroup:f,targetEndpoint:h==="L"?"0 50%":h==="R"?"100% 50%":h==="T"?"50% 0":"50% 100%"};e.add({group:"edges",data:m,classes:p})})}function Lrt(t,e,r){let n=o((l,u)=>Object.entries(l).reduce((h,[f,d])=>{let p=0,m=Object.entries(d);if(m.length===1)return h[f]=m[0][1],h;for(let g=0;g{let u={},h={};return Object.entries(l).forEach(([f,[d,p]])=>{let m=t.getNode(f)?.in??"default";u[p]??={},u[p][m]??=[],u[p][m].push(f),h[d]??={},h[d][m]??=[],h[d][m].push(f)}),{horiz:Object.values(n(u,"horizontal")).filter(f=>f.length>1),vert:Object.values(n(h,"vertical")).filter(f=>f.length>1)}}),[a,s]=i.reduce(([l,u],{horiz:h,vert:f})=>[[...l,...h],[...u,...f]],[[],[]]);return{horizontal:a,vertical:s}}function Rrt(t){let e=[],r=o(i=>`${i[0]},${i[1]}`,"posToStr"),n=o(i=>i.split(",").map(a=>parseInt(a)),"strToPos");return t.forEach(i=>{let a=Object.fromEntries(Object.entries(i).map(([h,f])=>[r(f),h])),s=[r([0,0])],l={},u={L:[-1,0],R:[1,0],T:[0,1],B:[0,-1]};for(;s.length>0;){let h=s.shift();if(h){l[h]=1;let f=a[h];if(f){let d=n(h);Object.entries(u).forEach(([p,m])=>{let g=r([d[0]+m[0],d[1]+m[1]]),y=a[g];y&&!l[g]&&(s.push(g),e.push({[lF[p]]:y,[lF[Eve(p)]]:f,gap:1.5*Li("iconSize")}))})}}}}),e}function Nrt(t,e,r,n,i,{spatialMaps:a,groupAlignments:s}){return new Promise(l=>{let u=Ge("body").append("div").attr("id","cy").attr("style","display:none"),h=rl({container:document.getElementById("cy"),style:[{selector:"edge",style:{"curve-style":"straight",label:"data(label)","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"edge.segments",style:{"curve-style":"segments","segment-weights":"0","segment-distances":[.5],"edge-distances":"endpoints","source-endpoint":"data(sourceEndpoint)","target-endpoint":"data(targetEndpoint)"}},{selector:"node",style:{"compound-sizing-wrt-labels":"include"}},{selector:"node[label]",style:{"text-valign":"bottom","text-halign":"center","font-size":`${Li("fontSize")}px`}},{selector:".node-service",style:{label:"data(label)",width:"data(width)",height:"data(height)"}},{selector:".node-junction",style:{width:"data(width)",height:"data(height)"}},{selector:".node-group",style:{padding:`${Li("padding")}px`}}]});u.remove(),_rt(r,h),Srt(t,h),Crt(e,h),Drt(n,h);let f=Lrt(i,a,s),d=Rrt(a),p=h.layout({name:"fcose",quality:"proof",styleEnabled:!1,animate:!1,nodeDimensionsIncludeLabels:!1,idealEdgeLength(m){let[g,y]=m.connectedNodes(),{parent:v}=Ff(g),{parent:x}=Ff(y);return v===x?1.5*Li("iconSize"):.5*Li("iconSize")},edgeElasticity(m){let[g,y]=m.connectedNodes(),{parent:v}=Ff(g),{parent:x}=Ff(y);return v===x?.45:.001},alignmentConstraint:f,relativePlacementConstraint:d});p.one("layoutstop",()=>{function m(g,y,v,x){let b,w,{x:C,y:T}=g,{x:E,y:A}=y;w=(x-T+(C-v)*(T-A)/(C-E))/Math.sqrt(1+Math.pow((T-A)/(C-E),2)),b=Math.sqrt(Math.pow(x-T,2)+Math.pow(v-C,2)-Math.pow(w,2));let S=Math.sqrt(Math.pow(E-C,2)+Math.pow(A-T,2));b=b/S;let _=(E-C)*(x-T)-(A-T)*(v-C);switch(!0){case _>=0:_=1;break;case _<0:_=-1;break}let I=(E-C)*(v-C)+(A-T)*(x-T);switch(!0){case I>=0:I=1;break;case I<0:I=-1;break}return w=Math.abs(w)*_,b=b*I,{distances:w,weights:b}}o(m,"getSegmentWeights"),h.startBatch();for(let g of Object.values(h.edges()))if(g.data?.()){let{x:y,y:v}=g.source().position(),{x,y:b}=g.target().position();if(y!==x&&v!==b){let w=g.sourceEndpoint(),C=g.targetEndpoint(),{sourceDir:T}=sC(g),[E,A]=Zc(T)?[w.x,C.y]:[C.x,w.y],{weights:S,distances:_}=m(w,C,E,A);g.style("segment-distances",_),g.style("segment-weights",S)}}h.endBatch(),p.run()}),p.run(),h.ready(m=>{Y.info("Ready",m),l(h)})})}var Vve,Mrt,Uve,Hve=N(()=>{"use strict";tu();kB();Vve=Sa(Pve(),1);dr();vt();Vc();Ei();w4();yF();oC();Gve();P4([{name:Zp.prefix,icons:Zp}]);rl.use(Vve.default);o(Srt,"addServices");o(Crt,"addJunctions");o(Art,"positionNodes");o(_rt,"addGroups");o(Drt,"addEdges");o(Lrt,"getAlignments");o(Rrt,"getRelativeConstraints");o(Nrt,"layoutArchitecture");Mrt=o(async(t,e,r,n)=>{let i=n.db,a=i.getServices(),s=i.getJunctions(),l=i.getGroups(),u=i.getEdges(),h=i.getDataStructures(),f=sa(e),d=f.append("g");d.attr("class","architecture-edges");let p=f.append("g");p.attr("class","architecture-services");let m=f.append("g");m.attr("class","architecture-groups"),await $ve(i,p,a),zve(i,p,s);let g=await Nrt(a,s,l,u,i,h);await Bve(d,g),await Fve(m,g),Art(i,g),Ao(void 0,f,Li("padding"),Li("useMaxWidth"))},"draw"),Uve={draw:Mrt}});var Wve={};hr(Wve,{diagram:()=>Irt});var Irt,qve=N(()=>{"use strict";Mve();w4();Ove();Hve();Irt={parser:Nve,db:Qp,renderer:Uve,styles:Ive}});var bnt={};hr(bnt,{default:()=>xnt});tu();PC();Xf();var YX="c4",PCe=o(t=>/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/.test(t),"detector"),BCe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(qX(),WX));return{id:YX,diagram:t}},"loader"),FCe={id:YX,detector:PCe,loader:BCe},XX=FCe;var Xie="flowchart",xOe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-wrapper"||e?.flowchart?.defaultRenderer==="elk"?!1:/^\s*graph/.test(t),"detector"),bOe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(ak(),ik));return{id:Xie,diagram:t}},"loader"),wOe={id:Xie,detector:xOe,loader:bOe},jie=wOe;var Kie="flowchart-v2",TOe=o((t,e)=>e?.flowchart?.defaultRenderer==="dagre-d3"?!1:(e?.flowchart?.defaultRenderer==="elk"&&(e.layout="elk"),/^\s*graph/.test(t)&&e?.flowchart?.defaultRenderer==="dagre-wrapper"?!0:/^\s*flowchart/.test(t)),"detector"),kOe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(ak(),ik));return{id:Kie,diagram:t}},"loader"),EOe={id:Kie,detector:TOe,loader:kOe},Qie=EOe;var sae="er",DOe=o(t=>/^\s*erDiagram/.test(t),"detector"),LOe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(aae(),iae));return{id:sae,diagram:t}},"loader"),ROe={id:sae,detector:DOe,loader:LOe},oae=ROe;var uue="gitGraph",tze=o(t=>/^\s*gitGraph/.test(t),"detector"),rze=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(cue(),lue));return{id:uue,diagram:t}},"loader"),nze={id:uue,detector:tze,loader:rze},hue=nze;var Gue="gantt",Hze=o(t=>/^\s*gantt/.test(t),"detector"),Wze=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(zue(),$ue));return{id:Gue,diagram:t}},"loader"),qze={id:Gue,detector:Hze,loader:Wze},Vue=qze;var Que="info",Zze=o(t=>/^\s*info/.test(t),"detector"),Jze=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Kue(),jue));return{id:Que,diagram:t}},"loader"),Zue={id:Que,detector:Zze,loader:Jze};var lhe="pie",fGe=o(t=>/^\s*pie/.test(t),"detector"),dGe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(ohe(),she));return{id:lhe,diagram:t}},"loader"),che={id:lhe,detector:fGe,loader:dGe};var The="quadrantChart",RGe=o(t=>/^\s*quadrantChart/.test(t),"detector"),NGe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(whe(),bhe));return{id:The,diagram:t}},"loader"),MGe={id:The,detector:RGe,loader:NGe},khe=MGe;var Khe="xychart",jGe=o(t=>/^\s*xychart-beta/.test(t),"detector"),KGe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(jhe(),Xhe));return{id:Khe,diagram:t}},"loader"),QGe={id:Khe,detector:jGe,loader:KGe},Qhe=QGe;var sfe="requirement",tVe=o(t=>/^\s*requirement(Diagram)?/.test(t),"detector"),rVe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(afe(),ife));return{id:sfe,diagram:t}},"loader"),nVe={id:sfe,detector:tVe,loader:rVe},ofe=nVe;var Afe="sequence",zVe=o(t=>/^\s*sequenceDiagram/.test(t),"detector"),GVe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Cfe(),Sfe));return{id:Afe,diagram:t}},"loader"),VVe={id:Afe,detector:zVe,loader:GVe},_fe=VVe;var Ife="class",XVe=o((t,e)=>e?.class?.defaultRenderer==="dagre-wrapper"?!1:/^\s*classDiagram/.test(t),"detector"),jVe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Mfe(),Nfe));return{id:Ife,diagram:t}},"loader"),KVe={id:Ife,detector:XVe,loader:jVe},Ofe=KVe;var Ffe="classDiagram",ZVe=o((t,e)=>/^\s*classDiagram/.test(t)&&e?.class?.defaultRenderer==="dagre-wrapper"?!0:/^\s*classDiagram-v2/.test(t),"detector"),JVe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Bfe(),Pfe));return{id:Ffe,diagram:t}},"loader"),eUe={id:Ffe,detector:ZVe,loader:JVe},$fe=eUe;var Ede="state",LUe=o((t,e)=>e?.state?.defaultRenderer==="dagre-wrapper"?!1:/^\s*stateDiagram/.test(t),"detector"),RUe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(kde(),Tde));return{id:Ede,diagram:t}},"loader"),NUe={id:Ede,detector:LUe,loader:RUe},Sde=NUe;var _de="stateDiagram",IUe=o((t,e)=>!!(/^\s*stateDiagram-v2/.test(t)||/^\s*stateDiagram/.test(t)&&e?.state?.defaultRenderer==="dagre-wrapper"),"detector"),OUe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Ade(),Cde));return{id:_de,diagram:t}},"loader"),PUe={id:_de,detector:IUe,loader:OUe},Dde=PUe;var Wde="journey",nHe=o(t=>/^\s*journey/.test(t),"detector"),iHe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Hde(),Ude));return{id:Wde,diagram:t}},"loader"),aHe={id:Wde,detector:nHe,loader:iHe},qde=aHe;vt();Vc();Ei();var sHe=o((t,e,r)=>{Y.debug(`rendering svg for syntax error +`);let n=sa(e),i=n.append("g");n.attr("viewBox","0 0 2412 512"),vn(n,100,512,!0),i.append("path").attr("class","error-icon").attr("d","m411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z"),i.append("path").attr("class","error-icon").attr("d","m459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z"),i.append("path").attr("class","error-icon").attr("d","m340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z"),i.append("path").attr("class","error-icon").attr("d","m400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z"),i.append("path").attr("class","error-icon").attr("d","m496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z"),i.append("path").attr("class","error-icon").attr("d","m436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z"),i.append("text").attr("class","error-text").attr("x",1440).attr("y",250).attr("font-size","150px").style("text-anchor","middle").text("Syntax error in text"),i.append("text").attr("class","error-text").attr("x",1250).attr("y",400).attr("font-size","100px").style("text-anchor","middle").text(`mermaid version ${r}`)},"draw"),fP={draw:sHe},Yde=fP;var oHe={db:{},renderer:fP,parser:{parse:o(()=>{},"parse")}},Xde=oHe;var jde="flowchart-elk",lHe=o((t,e={})=>/^\s*flowchart-elk/.test(t)||/^\s*flowchart|graph/.test(t)&&e?.flowchart?.defaultRenderer==="elk"?(e.layout="elk",!0):!1,"detector"),cHe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(ak(),ik));return{id:jde,diagram:t}},"loader"),uHe={id:jde,detector:lHe,loader:cHe},Kde=uHe;var Tpe="timeline",DHe=o(t=>/^\s*timeline/.test(t),"detector"),LHe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(wpe(),bpe));return{id:Tpe,diagram:t}},"loader"),RHe={id:Tpe,detector:DHe,loader:LHe},kpe=RHe;var e1e="mindmap",cJe=o(t=>/^\s*mindmap/.test(t),"detector"),uJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(Jge(),Zge));return{id:e1e,diagram:t}},"loader"),hJe={id:e1e,detector:cJe,loader:uJe},t1e=hJe;var d1e="kanban",AJe=o(t=>/^\s*kanban/.test(t),"detector"),_Je=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(f1e(),h1e));return{id:d1e,diagram:t}},"loader"),DJe={id:d1e,detector:AJe,loader:_Je},p1e=DJe;var j1e="sankey",ZJe=o(t=>/^\s*sankey-beta/.test(t),"detector"),JJe=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(X1e(),Y1e));return{id:j1e,diagram:t}},"loader"),eet={id:j1e,detector:ZJe,loader:JJe},K1e=eet;var sye="packet",pet=o(t=>/^\s*packet-beta/.test(t),"detector"),met=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(aye(),iye));return{id:sye,diagram:t}},"loader"),oye={id:sye,detector:pet,loader:met};var vye="radar",Fet=o(t=>/^\s*radar-beta/.test(t),"detector"),$et=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(yye(),gye));return{id:vye,diagram:t}},"loader"),xye={id:vye,detector:Fet,loader:$et};var Tve="block",srt=o(t=>/^\s*block-beta/.test(t),"detector"),ort=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(wve(),bve));return{id:Tve,diagram:t}},"loader"),lrt={id:Tve,detector:srt,loader:ort},kve=lrt;var Yve="architecture",Ort=o(t=>/^\s*architecture/.test(t),"detector"),Prt=o(async()=>{let{diagram:t}=await Promise.resolve().then(()=>(qve(),Wve));return{id:Yve,diagram:t}},"loader"),Brt={id:Yve,detector:Ort,loader:Prt},Xve=Brt;Xf();zt();var jve=!1,py=o(()=>{jve||(jve=!0,ad("error",Xde,t=>t.toLowerCase().trim()==="error"),ad("---",{db:{clear:o(()=>{},"clear")},styles:{},renderer:{draw:o(()=>{},"draw")},parser:{parse:o(()=>{throw new Error("Diagrams beginning with --- are not valid. If you were trying to use a YAML front-matter, please ensure that you've correctly opened and closed the YAML front-matter with un-indented `---` blocks")},"parse")},init:o(()=>null,"init")},t=>t.toLowerCase().trimStart().startsWith("---")),z4(XX,p1e,$fe,Ofe,oae,Vue,Zue,che,ofe,_fe,Kde,Qie,jie,t1e,kpe,hue,Dde,Sde,qde,khe,K1e,oye,Qhe,kve,Xve,xye))},"addDiagrams");vt();Xf();zt();var Kve=o(async()=>{Y.debug("Loading registered diagrams");let e=(await Promise.allSettled(Object.entries(Yf).map(async([r,{detector:n,loader:i}])=>{if(i)try{jy(r)}catch{try{let{diagram:a,id:s}=await i();ad(s,a,n)}catch(a){throw Y.error(`Failed to load external diagram with key ${r}. Removing from detectors.`),delete Yf[r],a}}}))).filter(r=>r.status==="rejected");if(e.length>0){Y.error(`Failed to load ${e.length} external diagrams`);for(let r of e)Y.error(r);throw new Error(`Failed to load ${e.length} external diagrams`)}},"loadRegisteredDiagrams");vt();dr();var lC="comm",cC="rule",uC="decl";var Qve="@import";var Zve="@namespace",Jve="@keyframes";var e2e="@layer";var vF=Math.abs,S4=String.fromCharCode;function hC(t){return t.trim()}o(hC,"trim");function C4(t,e,r){return t.replace(e,r)}o(C4,"replace");function t2e(t,e,r){return t.indexOf(e,r)}o(t2e,"indexof");function $f(t,e){return t.charCodeAt(e)|0}o($f,"charat");function zf(t,e,r){return t.slice(e,r)}o(zf,"substr");function vo(t){return t.length}o(vo,"strlen");function r2e(t){return t.length}o(r2e,"sizeof");function my(t,e){return e.push(t),t}o(my,"append");var fC=1,gy=1,n2e=0,il=0,Ri=0,vy="";function dC(t,e,r,n,i,a,s,l){return{value:t,root:e,parent:r,type:n,props:i,children:a,line:fC,column:gy,length:s,return:"",siblings:l}}o(dC,"node");function i2e(){return Ri}o(i2e,"char");function a2e(){return Ri=il>0?$f(vy,--il):0,gy--,Ri===10&&(gy=1,fC--),Ri}o(a2e,"prev");function al(){return Ri=il2||yy(Ri)>3?"":" "}o(l2e,"whitespace");function c2e(t,e){for(;--e&&al()&&!(Ri<48||Ri>102||Ri>57&&Ri<65||Ri>70&&Ri<97););return pC(t,A4()+(e<6&&rh()==32&&al()==32))}o(c2e,"escaping");function xF(t){for(;al();)switch(Ri){case t:return il;case 34:case 39:t!==34&&t!==39&&xF(Ri);break;case 40:t===41&&xF(t);break;case 92:al();break}return il}o(xF,"delimiter");function u2e(t,e){for(;al()&&t+Ri!==57;)if(t+Ri===84&&rh()===47)break;return"/*"+pC(e,il-1)+"*"+S4(t===47?t:al())}o(u2e,"commenter");function h2e(t){for(;!yy(rh());)al();return pC(t,il)}o(h2e,"identifier");function p2e(t){return o2e(gC("",null,null,null,[""],t=s2e(t),0,[0],t))}o(p2e,"compile");function gC(t,e,r,n,i,a,s,l,u){for(var h=0,f=0,d=s,p=0,m=0,g=0,y=1,v=1,x=1,b=0,w="",C=i,T=a,E=n,A=w;v;)switch(g=b,b=al()){case 40:if(g!=108&&$f(A,d-1)==58){t2e(A+=C4(mC(b),"&","&\f"),"&\f",vF(h?l[h-1]:0))!=-1&&(x=-1);break}case 34:case 39:case 91:A+=mC(b);break;case 9:case 10:case 13:case 32:A+=l2e(g);break;case 92:A+=c2e(A4()-1,7);continue;case 47:switch(rh()){case 42:case 47:my(Frt(u2e(al(),A4()),e,r,u),u),(yy(g||1)==5||yy(rh()||1)==5)&&vo(A)&&zf(A,-1,void 0)!==" "&&(A+=" ");break;default:A+="/"}break;case 123*y:l[h++]=vo(A)*x;case 125*y:case 59:case 0:switch(b){case 0:case 125:v=0;case 59+f:x==-1&&(A=C4(A,/\f/g,"")),m>0&&(vo(A)-d||y===0&&g===47)&&my(m>32?d2e(A+";",n,r,d-1,u):d2e(C4(A," ","")+";",n,r,d-2,u),u);break;case 59:A+=";";default:if(my(E=f2e(A,e,r,h,f,i,l,w,C=[],T=[],d,a),a),b===123)if(f===0)gC(A,e,E,E,C,a,d,l,T);else{switch(p){case 99:if($f(A,3)===110)break;case 108:if($f(A,2)===97)break;default:f=0;case 100:case 109:case 115:}f?gC(t,E,E,n&&my(f2e(t,E,E,0,0,i,l,w,i,C=[],d,T),T),i,T,d,l,n?C:T):gC(A,E,E,E,[""],T,0,l,T)}}h=f=m=0,y=x=1,w=A="",d=s;break;case 58:d=1+vo(A),m=g;default:if(y<1){if(b==123)--y;else if(b==125&&y++==0&&a2e()==125)continue}switch(A+=S4(b),b*y){case 38:x=f>0?1:(A+="\f",-1);break;case 44:l[h++]=(vo(A)-1)*x,x=1;break;case 64:rh()===45&&(A+=mC(al())),p=rh(),f=d=vo(w=A+=h2e(A4())),b++;break;case 45:g===45&&vo(A)==2&&(y=0)}}return a}o(gC,"parse");function f2e(t,e,r,n,i,a,s,l,u,h,f,d){for(var p=i-1,m=i===0?a:[""],g=r2e(m),y=0,v=0,x=0;y0?m[b]+" "+w:C4(w,/&\f/g,m[b])))&&(u[x++]=C);return dC(t,e,r,i===0?cC:l,u,h,f,d)}o(f2e,"ruleset");function Frt(t,e,r,n){return dC(t,e,r,lC,S4(i2e()),zf(t,2,-2),0,n)}o(Frt,"comment");function d2e(t,e,r,n,i){return dC(t,e,r,uC,zf(t,0,n),zf(t,n+1,-1),n,i)}o(d2e,"declaration");function yC(t,e){for(var r="",n=0;n{v2e.forEach(t=>{t()}),v2e=[]},"attachFunctions");vt();var b2e=o(t=>t.replace(/^\s*%%(?!{)[^\n]+\n?/gm,"").trimStart(),"cleanupComments");$4();Ew();function w2e(t){let e=t.match(F4);if(!e)return{text:t,metadata:{}};let r=cm(e[1],{schema:lm})??{};r=typeof r=="object"&&!Array.isArray(r)?r:{};let n={};return r.displayMode&&(n.displayMode=r.displayMode.toString()),r.title&&(n.title=r.title.toString()),r.config&&(n.config=r.config),{text:t.slice(e[0].length),metadata:n}}o(w2e,"extractFrontMatter");ir();var zrt=o(t=>t.replace(/\r\n?/g,` +`).replace(/<(\w+)([^>]*)>/g,(e,r,n)=>"<"+r+n.replace(/="([^"]*)"/g,"='$1'")+">"),"cleanupText"),Grt=o(t=>{let{text:e,metadata:r}=w2e(t),{displayMode:n,title:i,config:a={}}=r;return n&&(a.gantt||(a.gantt={}),a.gantt.displayMode=n),{title:i,config:a,text:e}},"processFrontmatter"),Vrt=o(t=>{let e=Gt.detectInit(t)??{},r=Gt.detectDirective(t,"wrap");return Array.isArray(r)?e.wrap=r.some(({type:n})=>n==="wrap"):r?.type==="wrap"&&(e.wrap=!0),{text:IX(t),directive:e}},"processDirectives");function bF(t){let e=zrt(t),r=Grt(e),n=Vrt(r.text),i=Fi(r.config,n.directive);return t=b2e(n.text),{code:t,title:r.title,config:i}}o(bF,"preprocessDiagram");tA();q4();ir();function T2e(t){let e=new TextEncoder().encode(t),r=Array.from(e,n=>String.fromCodePoint(n)).join("");return btoa(r)}o(T2e,"toBase64");var Urt=5e4,Hrt="graph TB;a[Maximum text size in diagram exceeded];style a fill:#faa",Wrt="sandbox",qrt="loose",Yrt="http://www.w3.org/2000/svg",Xrt="http://www.w3.org/1999/xlink",jrt="http://www.w3.org/1999/xhtml",Krt="100%",Qrt="100%",Zrt="border:0;margin:0;",Jrt="margin:0",ent="allow-top-navigation-by-user-activation allow-popups",tnt='The "iframe" tag is not supported by your browser.',rnt=["foreignobject"],nnt=["dominant-baseline"];function C2e(t){let e=bF(t);return Ly(),W$(e.config??{}),e}o(C2e,"processAndSetConfigs");async function int(t,e){py();try{let{code:r,config:n}=C2e(t);return{diagramType:(await A2e(r)).type,config:n}}catch(r){if(e?.suppressErrors)return!1;throw r}}o(int,"parse");var k2e=o((t,e,r=[])=>` +.${t} ${e} { ${r.join(" !important; ")} !important; }`,"cssImportantStyles"),ant=o((t,e=new Map)=>{let r="";if(t.themeCSS!==void 0&&(r+=` +${t.themeCSS}`),t.fontFamily!==void 0&&(r+=` +:root { --mermaid-font-family: ${t.fontFamily}}`),t.altFontFamily!==void 0&&(r+=` +:root { --mermaid-alt-font-family: ${t.altFontFamily}}`),e instanceof Map){let s=t.htmlLabels??t.flowchart?.htmlLabels?["> *","span"]:["rect","polygon","ellipse","circle","path"];e.forEach(l=>{ur(l.styles)||s.forEach(u=>{r+=k2e(l.id,u,l.styles)}),ur(l.textStyles)||(r+=k2e(l.id,"tspan",(l?.textStyles||[]).map(u=>u.replace("color","fill"))))})}return r},"createCssStyles"),snt=o((t,e,r,n)=>{let i=ant(t,r),a=zG(e,i,t.themeVariables);return yC(p2e(`${n}{${a}}`),m2e)},"createUserStyles"),ont=o((t="",e,r)=>{let n=t;return!r&&!e&&(n=n.replace(/marker-end="url\([\d+./:=?A-Za-z-]*?#/g,'marker-end="url(#')),n=na(n),n=n.replace(/
    /g,"
    "),n},"cleanUpSvgCode"),lnt=o((t="",e)=>{let r=e?.viewBox?.baseVal?.height?e.viewBox.baseVal.height+"px":Qrt,n=T2e(`${t}`);return``},"putIntoIFrame"),E2e=o((t,e,r,n,i)=>{let a=t.append("div");a.attr("id",r),n&&a.attr("style",n);let s=a.append("svg").attr("id",e).attr("width","100%").attr("xmlns",Yrt);return i&&s.attr("xmlns:xlink",i),s.append("g"),t},"appendDivSvgG");function S2e(t,e){return t.append("iframe").attr("id",e).attr("style","width: 100%; height: 100%;").attr("sandbox","")}o(S2e,"sandboxedIframe");var cnt=o((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},"removeExistingElements"),unt=o(async function(t,e,r){py();let n=C2e(e);e=n.code;let i=cr();Y.debug(i),e.length>(i?.maxTextSize??Urt)&&(e=Hrt);let a="#"+t,s="i"+t,l="#"+s,u="d"+t,h="#"+u,f=o(()=>{let L=Ge(p?l:h).node();L&&"remove"in L&&L.remove()},"removeTempElements"),d=Ge("body"),p=i.securityLevel===Wrt,m=i.securityLevel===qrt,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=""),p){let k=S2e(Ge(r),s);d=Ge(k.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=Ge(r);E2e(d,t,u,`font-family: ${g}`,Xrt)}else{if(cnt(document,t,u,s),p){let k=S2e(Ge("body"),s);d=Ge(k.nodes()[0].contentDocument.body),d.node().style.margin=0}else d=Ge("body");E2e(d,t,u)}let y,v;try{y=await xy.fromText(e,{title:n.title})}catch(k){if(i.suppressErrorRendering)throw f(),k;y=await xy.fromText("error"),v=k}let x=d.select(h).node(),b=y.type,w=x.firstChild,C=w.firstChild,T=y.renderer.getClasses?.(e,y),E=snt(i,b,T,a),A=document.createElement("style");A.innerHTML=E,w.insertBefore(A,C);try{await y.renderer.draw(e,t,vb.version,y)}catch(k){throw i.suppressErrorRendering?f():Yde.draw(e,t,vb.version),k}let S=d.select(`${h} svg`),_=y.db.getAccTitle?.(),I=y.db.getAccDescription?.();fnt(b,S,_,I),d.select(`[id="${t}"]`).selectAll("foreignobject > *").attr("xmlns",jrt);let D=d.select(h).node().innerHTML;if(Y.debug("config.arrowMarkerAbsolute",i.arrowMarkerAbsolute),D=ont(D,p,fr(i.arrowMarkerAbsolute)),p){let k=d.select(h+" svg").node();D=lnt(D,k)}else m||(D=ch.sanitize(D,{ADD_TAGS:rnt,ADD_ATTR:nnt,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(x2e(),v)throw v;return f(),{diagramType:b,svg:D,bindFunctions:y.db.bindFunctions}},"render");function hnt(t={}){let e=Gn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),V$(e),e?.theme&&e.theme in To?e.themeVariables=To[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=To.default.getThemeVariables(e.themeVariables));let r=typeof e=="object"?t7(e):r7();wy(r.logLevel),py()}o(hnt,"initialize");var A2e=o((t,e={})=>{let{code:r}=bF(t);return xy.fromText(r,e)},"getDiagramFromText");function fnt(t,e,r,n){g2e(e,t),y2e(e,r,n,e.attr("id"))}o(fnt,"addA11yInfo");var Gf=Object.freeze({render:unt,parse:int,getDiagramFromText:A2e,initialize:hnt,getConfig:cr,setConfig:X4,getSiteConfig:r7,updateSiteConfig:U$,reset:o(()=>{Ly()},"reset"),globalReset:o(()=>{Ly(lh)},"globalReset"),defaultConfig:lh});wy(cr().logLevel);Ly(cr());Yd();ir();var dnt=o((t,e,r)=>{Y.warn(t),Z9(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},"handleError"),_2e=o(async function(t={querySelector:".mermaid"}){try{await pnt(t)}catch(e){if(Z9(e)&&Y.error(e.str),nh.parseError&&nh.parseError(e),!t.suppressErrors)throw Y.error("Use the suppressErrors option to suppress these errors"),e}},"run"),pnt=o(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:".mermaid"}){let n=Gf.getConfig();Y.debug(`${t?"":"No "}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error("Nodes and querySelector are both undefined");Y.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(Y.debug("Start On Load: "+n?.startOnLoad),Gf.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new Gt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,l=[];for(let u of Array.from(i)){Y.info("Rendering diagram: "+u.id);if(u.getAttribute("data-processed"))continue;u.setAttribute("data-processed","true");let h=`mermaid-${a.next()}`;s=u.innerHTML,s=B4(Gt.entityDecode(s)).trim().replace(//gi,"
    ");let f=Gt.detectInit(s);f&&Y.debug("Detected early reinit: ",f);try{let{svg:d,bindFunctions:p}=await N2e(h,s,u);u.innerHTML=d,t&&await t(h),p&&p(u)}catch(d){dnt(d,l,nh.parseError)}}if(l.length>0)throw l[0]},"runThrowsErrors"),D2e=o(function(t){Gf.initialize(t)},"initialize"),mnt=o(async function(t,e,r){Y.warn("mermaid.init is deprecated. Please use run instead."),t&&D2e(t);let n={postRenderCallback:r,querySelector:".mermaid"};typeof e=="string"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await _2e(n)},"init"),gnt=o(async(t,{lazyLoad:e=!0}={})=>{py(),z4(...t),e===!1&&await Kve()},"registerExternalDiagrams"),L2e=o(function(){if(nh.startOnLoad){let{startOnLoad:t}=Gf.getConfig();t&&nh.run().catch(e=>Y.error("Mermaid failed to initialize",e))}},"contentLoaded");if(typeof document<"u"){window.addEventListener("load",L2e,!1)}var ynt=o(function(t){nh.parseError=t},"setParseErrorHandler"),vC=[],wF=!1,R2e=o(async()=>{if(!wF){for(wF=!0;vC.length>0;){let t=vC.shift();if(t)try{await t()}catch(e){Y.error("Error executing queue",e)}}wF=!1}},"executeQueue"),vnt=o(async(t,e)=>new Promise((r,n)=>{let i=o(()=>new Promise((a,s)=>{Gf.parse(t,e).then(l=>{a(l),r(l)},l=>{Y.error("Error parsing",l),nh.parseError?.(l),s(l),n(l)})}),"performCall");vC.push(i),R2e().catch(n)}),"parse"),N2e=o((t,e,r)=>new Promise((n,i)=>{let a=o(()=>new Promise((s,l)=>{Gf.render(t,e,r).then(u=>{s(u),n(u)},u=>{Y.error("Error parsing",u),nh.parseError?.(u),l(u),i(u)})}),"performCall");vC.push(a),R2e().catch(i)}),"render"),nh={startOnLoad:!0,mermaidAPI:Gf,parse:vnt,render:N2e,init:mnt,run:_2e,registerExternalDiagrams:gnt,registerLayoutLoaders:vR,initialize:D2e,parseError:void 0,contentLoaded:L2e,setParseErrorHandler:ynt,detectType:a0,registerIconPacks:P4},xnt=nh;return V2e(bnt);})(); +/*! Check if previously processed */ +/*! + * Wait for document loaded before starting the execution + */ +/*! Bundled license information: + +dompurify/dist/purify.es.mjs: + (*! @license DOMPurify 3.2.4 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.4/LICENSE *) + +js-yaml/dist/js-yaml.mjs: + (*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT *) + +lodash-es/lodash.js: + (** + * @license + * Lodash (Custom Build) + * Build: `lodash modularize exports="es" -o ./` + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + *) + +cytoscape/dist/cytoscape.esm.mjs: + (*! + Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable + Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com) + Licensed under The MIT License (http://opensource.org/licenses/MIT) + *) + (*! + Event object based on jQuery events, MIT license + + https://jquery.org/license/ + https://tldrlegal.com/license/mit-license + https://github.com/jquery/jquery/blob/master/src/event.js + *) + (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *) + (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *) +*/ +globalThis.mermaid = globalThis.__esbuild_esm_mermaid.default; diff --git a/docs/assets/theme/navigation.css b/docs/assets/theme/navigation.css new file mode 100644 index 0000000..a5c3dc3 --- /dev/null +++ b/docs/assets/theme/navigation.css @@ -0,0 +1,13 @@ +.sidebar-scrollbox .section a[href*="adr"]:not([href*="index.html"]) strong { + display: none; +} +.sidebar-scrollbox .section a[href*="adr"]:not([href*="index.html"]) .number { + font-weight: 550; +} + +.chapter li .chapter-item div { + display: block; + padding: 0; + text-decoration: none; + color: var(--sidebar-fg); +} diff --git a/docs/assets/theme/navigation.js b/docs/assets/theme/navigation.js new file mode 100644 index 0000000..3dfee26 --- /dev/null +++ b/docs/assets/theme/navigation.js @@ -0,0 +1,22 @@ +document.querySelectorAll('.sidebar-scrollbox .section a[href*="adr"]').forEach(el => { + if (el.getAttribute('href').includes('index.html')) { + return; // Skip processing for index.html + } + + let textNodes = [...el.childNodes].filter(node => node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0); + + if (textNodes.length > 0) { + let textNode = textNodes[0]; // First text node (ignoring elements like ) + let text = textNode.nodeValue.trim(); + + if (text.length >= 4) { + let span = document.createElement("span"); + span.classList.add("number"); + span.textContent = text.substring(0, 4); + + textNode.nodeValue = text.substring(4); // Remove first 4 chars from original text node + + el.insertBefore(span, textNode); // Insert the styled first 4 characters before the rest + } + } +}); diff --git a/docs/assets/theme/pagetoc.css b/docs/assets/theme/pagetoc.css new file mode 100644 index 0000000..7f083c1 --- /dev/null +++ b/docs/assets/theme/pagetoc.css @@ -0,0 +1,61 @@ +a[class^='pagetoc-H']:only-child { + display: none; +} + +@media only screen and (max-width:1439px) { + .sidetoc { + display: none; + } +} + +@media only screen and (min-width:1440px) { + main { + position: relative; + } + .sidetoc { + margin-left: auto; + margin-right: auto; + left: calc(100% + (var(--content-max-width))/4 - 140px); + position: absolute; + } + .pagetoc { + position: fixed; + width: 200px; + height: calc(100vh - var(--menu-bar-height) - 0.67em * 4); + overflow: auto; + } + .pagetoc a { + border-left: 1px solid var(--sidebar-bg); + color: var(--fg) !important; + display: block; + padding-bottom: 5px; + padding-top: 5px; + padding-left: 10px; + text-align: left; + text-decoration: none; + } + .pagetoc a:hover, + .pagetoc a.active { + background: var(--sidebar-bg); + color: var(--sidebar-fg) !important; + } + .pagetoc .active { + background: var(--sidebar-bg); + color: var(--sidebar-fg); + } + .pagetoc .pagetoc-H2 { + padding-left: 20px; + } + .pagetoc .pagetoc-H3 { + padding-left: 40px; + } + .pagetoc .pagetoc-H4 { + padding-left: 60px; + } + .pagetoc .pagetoc-H5 { + display: none; + } + .pagetoc .pagetoc-H6 { + display: none; + } +} diff --git a/docs/assets/theme/pagetoc.js b/docs/assets/theme/pagetoc.js new file mode 100644 index 0000000..5962db9 --- /dev/null +++ b/docs/assets/theme/pagetoc.js @@ -0,0 +1,68 @@ +let scrollTimeout; + +const listenActive = () => { + const elems = document.querySelector(".pagetoc").children; + [...elems].forEach(el => { + el.addEventListener("click", (event) => { + clearTimeout(scrollTimeout); + [...elems].forEach(el => el.classList.remove("active")); + el.classList.add("active"); + // Prevent scroll updates for a short period + scrollTimeout = setTimeout(() => { + scrollTimeout = null; + }, 100); // Adjust timing as needed + }); + }); +}; + +const getPagetoc = () => document.querySelector(".pagetoc") || autoCreatePagetoc(); + +const autoCreatePagetoc = () => { + const main = document.querySelector("#content > main"); + const content = Object.assign(document.createElement("div"), { + className: "content-wrap" + }); + content.append(...main.childNodes); + main.prepend(content); + main.insertAdjacentHTML("afterbegin", '
    '); + return document.querySelector(".pagetoc"); +}; +const updateFunction = () => { + if (scrollTimeout) return; // Skip updates if within the cooldown period from a click + const headers = [...document.getElementsByClassName("header")]; + const scrolledY = window.scrollY; + let lastHeader = null; + + // Find the last header that is above the current scroll position + for (let i = headers.length - 1; i >= 0; i--) { + if (scrolledY >= headers[i].offsetTop) { + lastHeader = headers[i]; + break; + } + } + + const pagetocLinks = [...document.querySelector(".pagetoc").children]; + pagetocLinks.forEach(link => link.classList.remove("active")); + + if (lastHeader) { + const activeLink = pagetocLinks.find(link => lastHeader.href === link.href); + if (activeLink) activeLink.classList.add("active"); + } +}; + +window.addEventListener('load', () => { + const pagetoc = getPagetoc(); + const headers = [...document.getElementsByClassName("header")]; + headers.forEach(header => { + const link = Object.assign(document.createElement("a"), { + textContent: header.text, + href: header.href, + className: `pagetoc-${header.parentElement.tagName}` + }); + pagetoc.appendChild(link); + }); + updateFunction(); + listenActive(); + window.addEventListener("scroll", updateFunction); +}); + diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 0000000..ce5a104 --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,38 @@ +[book] +title = "Thunderbird for Android - Developer Documentation" +authors = ["Thunderbird Mobile Team"] +description = "Developer Documentation for the Thunderbird for Android project." +language = "en" +multilingual = false +src = "." + +[rust] +edition = "2018" + +[build] +build-dir = "../book/docs/latest" +create-missing = false + +[output.html] +git-repository-url = "https://github.com/thunderbird/thunderbird-android" +edit-url-template = "https://github.com/thunderbird/thunderbird-android/edit/main/{path}" +git-branch = "main" +fold.enable = true +theme = "assets/theme" +additional-js = ["assets/theme/mermaid.min.js", "assets/theme/mermaid-init.js", "assets/theme/navigation.js", "assets/theme/pagetoc.js"] +additional-css = ["assets/theme/last-changed.css", "assets/theme/navigation.css", "assets/theme/pagetoc.css"] + +[preprocessor] + +[preprocessor.alerts] + +[preprocessor.external-links] + +[preprocessor.last-changed] +command = "mdbook-last-changed" +renderer = ["html"] + +[preprocessor.mermaid] +command = "mdbook-mermaid" + +[preprocessor.pagetoc] diff --git a/docs/ci/AUTOMATION.md b/docs/ci/AUTOMATION.md new file mode 100644 index 0000000..0aa0dcd --- /dev/null +++ b/docs/ci/AUTOMATION.md @@ -0,0 +1,122 @@ +# Release Automation Setup + +Release automation is triggered by the workflow_dispatch event on the "Shippable Build & Signing" +workflow. GitHub environments are used to set configuration variables and secrets for each +application and release type. + +## Automatic setup + +There is a script available for automatic setup, which is helpful if you want to replicate this on +your own repository for devlopment. Please see /scripts/ci/setup_release_automation. + +You can run it using: + +```bash +python -m venv venv +source venv/bin/activate +pip install requests pynacl +cd .signing +python ../scripts/ci/setup_release_automation -r yourfork/thunderbird-android +``` + +You will need the following files: + +- The signing keys with their default filenames +- A matrix-account.json with the following keys: + +```json +{ + "homeserver": "matrix-client.matrix.org", + "room": "room id here", + "token": "matrix token here", + "userMap": { + "github_username": "@matrix_id:mozilla.org" + } +} +``` + +- `play-store-account.json` with the service account json that will do the uploads +- `thunderbird-mobile-gh-releaser-bot.clientid.txt` as a simple file with the client ID of the releaser bot (you can skip this to use GitHub Actions as the user) +- `thunderbird-mobile-gh-releaser-bot.pem` with the private key of the releaser bot + +## Build Environments + +Build environments determine the configuration for the respective release channel. The following are +available: + +- thunderbird_beta +- thunderbird_daily +- thunderbird_release + +The following (non-sensitive) variables have been set: + +- RELEASE_TYPE: daily | beta | release +- MATRIX_INCLUDES: A JSON string to determine the packages to be built + +The following MATRIX_INCLUDES would build an apk and aab for Thunderbird, and an apk for K-9 Mail. + +```json +[ + { "appName": "thunderbird", "packageFormat": "apk", "packageFlavor": "foss" }, + { + "appName": "thunderbird", + "packageFormat": "bundle", + "packageFlavor": "full" + }, + { "appName": "k9mail", "packageFormat": "apk", "packageFlavor": "foss" } +] +``` + +The environments are locked to the respective branch they belong to. + +## Signing Environments + +These environments contain the secrets for signing. Their names follow this pattern: + +```text +__ +thunderbird_beta_full +thunderbird_beta_foss +k9mail_beta_foss +``` + +The following secrets are needed: + +- SIGNING_KEY: The base64 encoded signing key, see https://github.com/noriban/sign-android-release for details +- KEY_ALIAS: The alias of your signing key +- KEY_PASSWORD: The private key password for your signing keystore +- KEY_STORE_PASSWORD: The password to your signing keystore + +The environments are locked to the respective branch they belong to. + +## Publishing Hold Environment + +The "publish_hold" is shared by all application variants and is used by the "pre_publish" job. +It has no secrets or variables, but "Required Reviewers" is set to trusted team members who oversee releases. The +effect is that after package signing completes, the publishing jobs that depend on it will not run until released +manually. + +![publish hold](assets/publish_hold.png) + +## Github Releases Environment + +This environment will create the github release. It uses [actions/create-github-app-token](https://github.com/actions/create-github-app-token) +to upload the release with limited permissions. + +- RELEASER_APP_CLIENT_ID: Environment variable with the OAuth Client ID of the GitHub app +- RELEASER_APP_PRIVATE_KEY: Secret with the private key of the app + +The releases environment is locked to the release, beta and main branches. + +If you leave out the environment, the Github Actions user will be used. + +## Matrix Notify Environment + +This environment will notify about build updates. It requires the following keys: + +- MATRIX_NOTIFY_TOKEN: The Matrix token of the user +- MATRIX_NOTIFY_HOMESERVER: The homeserver for the account +- MATRIX_NOTIFY_ROOM: The room id to notify in +- MATRIX_NOTIFY_USER_MAP: A json object that maps github usernames to matrix ids + +If you leave out this environment, no notifications will be sent. diff --git a/docs/ci/HISTORICAL_RELEASE.md b/docs/ci/HISTORICAL_RELEASE.md new file mode 100644 index 0000000..3938a03 --- /dev/null +++ b/docs/ci/HISTORICAL_RELEASE.md @@ -0,0 +1,242 @@ +# Create K-9 Mail releases + + + +## One-time setup + +1. Create a `.signing` folder in the root of the Git repository, if it doesn't exist yet. +2. Download the `k9-release-signing.jks` and `k9.release.signing.properties` files from 1Password and place them in the `.signing` folder. + +Example `..signing.properties` file: + +```properties +..storeFile= +..storePassword= +..keyAlias= +..keyPassword= +``` + +- `` is the short name of the app, e.g. `k9` +- `` is the type of release, e.g. `release` + +### One-time setup for F-Droid builds + +1. Install _fdroidserver_ by following + the [installation instructions](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools). + 1. On MacOS, it's best to install the latest version from source, because the version in Homebrew has some issues. + 1. Install the android command line tools if not available already. + + ```shell + brew install --cask android-commandlinetools + ``` + 2. Install latest _fdroidserver_ from source: + + ```shell + python -m venv fdroidserver-env + source fdroidserver-env/bin/activate + pip install git+https://gitlab.com/fdroid/fdroidserver.git + ``` + 3. To use _fdroidserver_ from the command line, you need to activate the virtual environment before each use: + + ```shell + source fdroidserver-env/bin/activate + ``` + 4. To deactivate the virtual environment: + + ```shell + deactivate + ``` +2. [Sign up for a Gitlab account](https://gitlab.com/users/sign_up) and fork + the [fdroiddata](https://gitlab.com/fdroid/fdroiddata) repository. +3. Clone your fork of the _fdroiddata_ repository. + +## Release a beta version + +1. Update versionCode and versionName in `app-k9mail/build.gradle.kts` +2. Create change log entries in + - `app-k9mail/src/main/res/raw/changelog_master.xml` + - `app-metadata/com.fsck.k9/en-US/changelogs/${versionCode}.txt` + Use past tense. Try to keep them high level. Focus on the user (experience). +3. Update the metadata link to point to K-9 Mail's data: + `ln --symbolic --no-dereference --force app-metadata/com.fsck.k9 metadata` +4. Commit the changes. Message: "Version $versionName" +5. Run `./gradlew clean :app-k9mail:assembleRelease --no-build-cache --no-configuration-cache` +6. Update an existing installation to make sure the app is signed with the proper key and runs on a real device. + + ```shell + adb install -r app-k9mail/build/outputs/apk/release/app-k9mail-release.apk + ``` +7. Tag as $versionName, e.g. `6.508` +8. Copy `app-k9mail/build/outputs/apk/release/app-k9mail-release.apk` as `k9-${versionName}.apk` to Google Drive (MZLA + Team > K9 > APKs) +9. Change versionName in `app-k9mail/build.gradle.kts` to next version name followed by `-SNAPSHOT` +10. Commit the changes. Message: "Prepare for version $newVersionName" +11. Update `gh-pages` branch with the new change log +12. Push `main` branch +13. Push tags +14. Push `gh-pages` branch + +### Create release on GitHub + +1. Go to https://github.com/thunderbird/thunderbird-android/tags and select the appropriate tag +2. Click "Create release from tag" +3. Fill out the form + - Click "Generate release notes" + - Replace contents under "What's changed" with change log entries + - Add GitHub handles in parentheses to change log entries + - If necessary, add another entry "Internal changes" (or similar) so people who contributed changes outside of the + entries mentioned in the change log can be mentioned via GitHub handle. + - Attach the APK + - Select "Set as a pre-release" + - Click "Publish release" + +### Create release on F-Droid + +1. Fetch the latest changes from the _fdroiddata_ repository. +2. Switch to a new branch in your copy of the _fdroiddata_ repository. +3. Edit `metadata/com.fsck.k9.yml` to create a new entry for the version you want to release. Usually it's copy & paste + of the previous entry and adjusting `versionName`, `versionCode`, and `commit` (use the tag name). + Leave `CurrentVersion` and `CurrentVersionCode` unchanged. Those specify which version is the stable/recommended + build. + + Example: + + ```yaml + - versionName: "${versionName}" + versionCode: ${versionCode} + commit: "${tagName}" + subdir: app-k9mail + gradle: + - yes + scandelete: + - build-plugin/build + ``` +4. Commit the changes. Message: "Update K-9 Mail to $newVersionName (beta)" +5. Run `fdroid build --latest com.fsck.k9` to build the project using F-Droid's toolchain. +6. Push the changes to your fork of the _fdroiddata_ repository. +7. Open a merge request on Gitlab. (The message from the server after the push in the previous step should contain a + URL) +8. Select the _App update_ template and fill it out. +9. Create merge request and the F-Droid team will do the rest. + +### Create release on Google Play + +1. Go to the [Google Play Console](https://play.google.com/console/) +2. Select the _K-9 Mail_ app +3. Click on _Open testing_ in the left sidebar +4. Click on _Create new release_ +5. Upload the APK to _App bundles_ +6. Fill out Release name (e.g. "$versionCode ($versionName)") +7. Fill out Release notes (copy from `app-metadata/com.fsck.k9/en-US/changelogs/${versionCode}.txt`) +8. Click _Next_ +9. Review the release +10. Configure a full rollout for beta versions +11. On the Publishing overview page, click _Send change for review_ +12. Wait for the review to complete +13. In case of a rejection, fix the issues and repeat the process + +## Release a stable version + +When the team decides the `main` branch is stable enough and it's time to release a new stable version, create a new +maintenance branch (off `main`) using the desired version number with the last two digits dropped followed by `-MAINT`. +Example: `6.8-MAINT` when the first stable release is K-9 Mail 6.800. + +Ideally the first stable release contains no code changes when compared to the last beta version built from `main`. +That way the new release won't contain any changes that weren't exposed to user testing in a beta version before. + +1. Switch to the appropriate maintenance branch, e.g. `6.8-MAINT` +2. Update versionCode and versionName in `app-k9mail/build.gradle.kts` (stable releases use an even digit after the + dot, e.g. `5.400`, `6.603`) +3. Create change log entries in + - `app-k9mail/src/main/res/raw/changelog_master.xml` + - `app-k9mail/fastlane/metadata/android/en-US/changelogs/${versionCode}.txt` + Use past tense. Try to keep them high level. Focus on the user (experience). +4. Update the metadata link to point to K-9 Mail's data: + `ln --symbolic --no-dereference --force app-metadata/com.fsck.k9 metadata` +5. Commit the changes. Message: "Version $versionName" +6. Run `./gradlew clean :app-k9mail:assembleRelease --no-build-cache --no-configuration-cache` +7. Update an existing installation to make sure the app is signed with the proper key and runs on a real device. + + ```shell + adb install -r app-k9mail/build/outputs/apk/release/app-k9mail-release.apk + ``` +8. Tag as $versionName, e.g. `6.800` +9. Copy `app-k9mail/build/outputs/apk/release/app-k9mail-release.apk` as `k9-${versionName}.apk` to Google Drive (MZLA + Team > K9 > APKs) +10. Update `gh-pages` branch with the new change log. Create a new file if it's the first stable release in a series. +11. Push maintenance branch +12. Push tags +13. Push `gh-pages` branch + +### Create release on GitHub + +1. Go to https://github.com/thunderbird/thunderbird-android/tags and select the appropriate tag +2. Click "Create release from tag" +3. Fill out the form + - Click "Generate release notes" + - Replace contents under "What's changed" with change log entries + - Add GitHub handles in parentheses to change log entries + - If necessary, add another entry "Internal changes" (or similar) so people who contributed changes outside of the + entries mentioned in the change log can be mentioned via GitHub handle. + - Attach the APK + - Select "Set as the latest release" + - Click "Publish release" + +### Create release on F-Droid + +1. Fetch the latest changes from the _fdroiddata_ repository. +2. Switch to a new branch in your copy of the _fdroiddata_ repository. +3. Edit `metadata/com.fsck.k9.yml` to create a new entry for the version you want to release. Usually it's copy & paste + of the previous entry and adjusting `versionName`, `versionCode`, and `commit` (use the tag name). + Change `CurrentVersion` and `CurrentVersionCode` to the new values, making this the new stable/recommended build. + + Example: + + ```yaml + - versionName: "${versionName}" + versionCode: ${versionCode} + commit: "${tagName}" + subdir: app-k9mail + gradle: + - yes + scandelete: + - build-plugin/build + ``` +4. Commit the changes. Message: "Update K-9 Mail to $newVersionName" +5. Run `fdroid build --latest com.fsck.k9` to build the project using F-Droid's toolchain. +6. Push the changes to your fork of the _fdroiddata_ repository. +7. Open a merge request on Gitlab. (The message from the server after the push in the previous step should contain a + URL) +8. Select the _App update_ template and fill it out. +9. Create merge request and the F-Droid team will do the rest. + +### Create release on Google Play + +1. Go to the [Google Play Console](https://play.google.com/console/) +2. Select the _K-9 Mail_ app +3. Click on _Production_ in the left sidebar +4. Click on _Create new release_ +5. Upload the APK to _App bundles_ +6. Fill out Release name (e.g. "$versionCode ($versionName)") +7. Fill out Release notes (copy from `app-k9mail/fastlane/metadata/android/en-US/changelogs/${versionCode}.txt`) +8. Click _Next_ +9. Review the release +10. Start with a staged rollout (usually 20%) +11. On the Publishing overview page, click _Send change for review_ +12. Wait for the review to complete +13. In case of a rejection, fix the issues and repeat the process +14. Once the review is complete, monitor the staged rollout for issues and increase the rollout percentage as necessary + +## Troubleshooting + +### F-Droid + +If the app doesn't show up in the F-Droid client: + +- Check the build cycle, maybe you just missed it and it will be available in the next cycle. (The cycle is usually every 5 days.) +- Check [F-Droid Status](https://fdroidstatus.org/status/fdroid) for any issues. +- Check [F-Droid Monitor](https://monitor.f-droid.org/builds) for any errors mentioning `com.fsck.k9`. + diff --git a/docs/ci/README.md b/docs/ci/README.md new file mode 100644 index 0000000..5f671ab --- /dev/null +++ b/docs/ci/README.md @@ -0,0 +1,3 @@ +# Thunderbird for Android Release Documentation + +Please see the sub-pages for release documentation diff --git a/docs/ci/RELEASE.md b/docs/ci/RELEASE.md new file mode 100644 index 0000000..30991cc --- /dev/null +++ b/docs/ci/RELEASE.md @@ -0,0 +1,259 @@ +# Releases + +Thunderbird for Android follows a release train model to ensure timely and predictable releases. This model allows for regular feature rollouts, stability improvements, and bug fixes. + +## Branches in the Release Train Model + +### Daily + +Daily builds are used for initial testing of new features and changes. Feature flags are used to work on features that are not yet ready for consumption. + +- **Branch:** `main` +- **Purpose:** Active development of new features and improvements +- **Release Cadence:** Daily +- **Audience:** Developers and highly technical users who want to test the bleeding edge of Thunderbird. Daily builds are unstable and not recommended for production use. +- **Availability:** Daily builds are available on the Play Store internal channel. APKs are available on [ftp.mozilla.org](https://ftp.mozilla.org/pub/thunderbird-mobile/). + +### Beta + +After features are stabilized in Daily, they are merged into the Beta branch for broader testing. Uplifts are limited to bug/security fixes only. The Beta branch serves as a preview of what will be included in the next stable release, allowing for user feedback and final adjustments before general availability. + +- **Branch:** `beta` +- **Purpose:** Pre-release testing +- **Release Cadence:** Weekly with the option to skip if not needed +- **Merge Cadence:** Every 4 weeks +- **Audience:** Early adopters and testers. Testers are encouraged to provide error logs and help reproduce issues filed. +- **Availability:** Beta builds are available from the [Play Store](https://play.google.com/store/apps/details?id=net.thunderbird.android.beta) and [F-Droid](https://f-droid.org/packages/net.thunderbird.android.beta). + +### Release + +This branch represents the stable version of Thunderbird. It is tested and suitable for general use. Uplifts to Release are limited to stability/security fixes only. + +- **Branch:** `release` +- **Purpose:** Stable releases +- **Release Cadence:** Major releases every 4 weeks. Minor release 2 weeks after a major release with the option to skip if not needed. +- **Merge Cadence:** Every 4 weeks +- **Audience:** General users. Users may be filing bug reports or leaving reviews to express their level of satisfaction. +- **Availability:** Release builds are available from the [Play Store](https://play.google.com/store/apps/details?id=net.thunderbird.android) and [F-Droid](https://f-droid.org/packages/net.thunderbird.android). + +## Sample Release Timeline + +| Milestone | Details | Date | +|-------------------------------|-----------|--------| +| TfA 14.0a1 starts | | Aug 28 | +| TfA 12.0 | | Sep 1 | +| TfA 13.0b1 | | Sep 1 | +| TfA 13.0bX | If needed | Sep 8 | +| TfA 12.1 | If needed | Sep 15 | +| TfA 13.0bX | If needed | Sep 15 | +| TfA 14.0a1 soft freeze starts | | Sep 18 | +| TfA 13.0bX | If needed | Sep 22 | +| TfA merge 13.0 beta->release | | Sep 22 | +| TfA merge 14.0 main->beta | | Sep 25 | +| TfA 15.0a1 starts | | Sep 25 | +| TfA 13.0 | | Sep 29 | +| TfA 14.0b1 | | Sep 29 | + +## Soft Freeze + +A week long soft freeze occurs for the `main` branch prior to merging into the `beta` branch. During this time: + +- Risky code should not land +- Disabled feature flags should not be enabled + +## Feature Flags + +Thunderbird for Android uses Feature Flags to disable features not yet ready for consumption. + +- On `main`, feature flags are enabled as soon as developers have completed all pull requests related to the feature. +- On `beta`, feature flags remain enabled unless the feature has not been fully completed and the developers would like to pause the feature. +- On `release`, feature flags are disabled until an explicit decision has been made to enable the feature for all users. + +## Uplifts + +Uplifts should be avoided if possible and fixes should ride the train. There are cases, however, where a bug is severe enough to warrant an uplift. +If the urgency of a fix requires it to uplifted to the Beta or Release channel before the next merge, the uplift process must be followed. + +### Uplift Criteria + +Beta uplifts should: + +- Be limited to bug/security fixes only. Features ride the train. +- Not change any localizable strings. +- Have tests, or a strong statement of what can be done in the absence of tests. +- Have landed in main and stabilized on the daily channel. +- Have a comment in the GitHub issue assessing the reasons the patch is needed and risks involved in taking the patch. + +Release uplifts should additionally: + +- Be limited to stability/security fixes only. Features ride the train. +- Have landed in beta and stabilized on the beta channel. + +Examples: Fixes for security vulnerabilies, dataloss, or a crash that affects a large number of users. + +### Uplift Process + +1. The requestor creates a pull request against the target uplift branch. +2. The requestor adds a comment to the pull request with the Approval Request template filled out. +3. The release driver reviews the uplift request, merging if approved, or closing with a comment if rejected. + +Template for uplift requests: + +```sh +[Approval Request] +Original Issue/Pull request: +Regression caused by (issue #): +User impact if declined: +Testing completed (on daily, etc.): +Risk to taking this patch (and alternatives if risky): +``` + +## Versioning System + +### Version Names + +Thunderbird for Android stable release versions follow the `X.Y` format, where: + +- **X (Major version):** Incremented for each new release cycle. +- **Y (Patch version):** Incremented when changes are added to an existing major version. + +For beta builds, the suffix `b1` is appended, where the number increments for each beta. For daily builds, the suffix `a1` is appended, which remains constant. + +### Version Codes + +The version code is an internal version number for Android that helps determine whether one version is more recent than another. + +The version code for beta and release is an integer value that increments for each new release. + +The version code for daily is calculated based on the date and has the format `yDDDHHmm`: + +- **y**: The number of years since a base year, with 2023 as the starting point (e.g., 2024 is 1) +- **DDD**: The day of the year in 3 digits, zero-padded +- **HH**: The hour of the day in 2 digits (00–23) +- **mm**: The minute of the hour in 2 digits + +For example: + +- `2024-02-09 16:45` → `1 | 040 | 16 | 45` → `10401645` +- `2025-10-12 09:23` → `2 | 285 | 09 | 23` → `22850923` +- `2122-02-09 16:45` → `99 | 040 | 16 | 45` → `990401645` + +## Merge Days + +Active development occurs on the `main` branch and becomes part of the daily build. Every 4 weeks: + +1. `main` is merged into `beta`, for testing. +2. `beta` is merged into `release`, making it publicly available. + +On the former, `main` carries over to `beta`, where the community can test the changes as part of “Thunderbird Beta for Testers” (`net.thunderbird.android.beta`) until the next merge day. +On the latter, code that was in beta goes to release, where the general population receives product updates (`net.thunderbird.android`). + +When a merge occurs, the version name is carried forward to the next branch, and the alpha/beta suffixes are removed/reset accordingly. For example, let’s say we are shortly before the Thunderbird 9.0 release. The latest releases were Thunderbird 8.1, Thunderbird Beta 9.0b4, and Thunderbird Daily 10.0a1. Here is what happens: + +- The `beta` branch is merged to `release`. The resulting version on release changes from 8.1 to 9.0. +- The `main` branch is merged to `beta`. The resulting version on beta changes from 9.0b4 to 10.0b1 +- The `main` branch version number is changed from 10.0a1 to 11.0a1 + +While the version name changes, it must be ensured that the version code remains on the same sequence for each branch. For example: + +- If the version code on the beta branch is 20 at 9.0b4, it will be 21 at 10.0b1. +- If the version code on the release branch is 12 at 8.1, it will be 13 at 9.0. + +Our application IDs are specific to the branch they are on. For example: + +- Beta always uses `net.thunderbird.android.beta` as the app ID for TfA. +- Release always uses `net.thunderbird.android` as the app ID for TfA. +- Release always uses `com.fsck.k9` as the app ID for K-9. + +## Milestones + +We use GitHub Milestones to track work for each major release. There is only one milestone for the whole major release, so work going into 9.0 and 9.1 would both be in the "Thunderbird 9" milestone. Each milestone has the due date set to the anticipated release date. + +There are exactly three open milestones at any given time, some of our automation depends on this being the case. The milestone with the date furthest into the future is the target for the `main` branch, the one closest is the target for the `release` branch. When an uplift occurs, the milestone is changed to the respective next target. + +Learn more on the [milestones page](https://github.com/thunderbird/thunderbird-android/milestones) + +## Merge Process + +The merge process enables various benefits, including: + +- Carrying forward main branch history to beta, and beta branch history to release. +- No branch history is lost. +- Git tags are retained in the git log. +- Files/code that is unique per branch can remain that way (e.g. notes files such as changelog_master.xml, version codes). + +The following steps are taken when merging main into beta: +1. Lock the main branch with the 'CLOSED TREE (main)' ruleset +2. Send a message to the #tb-mobile-dev:mozilla.org matrix channel to let them know: +- You will be performing the merge from main into beta +- The main branch is locked and cannot be changed during the merge +- You will let them know when the merge is complete and main is re-opened +3. Review merge results and ensure correctness +4. Ensure feature flags are following the rules +5. Push the merge +6. Submit a pull request that increments the version in main +7. Open a new milestone for the new version on github +8. Once the version increment is merged into main, unlock the branch +9. Send a message to the #tb-mobile-dev:mozilla.org channel to notify of merge completion and that main is re-opened + +The following steps are taken when merging beta into release: +1. Send a message to the #tb-mobile-dev:mozilla.org matrix channel to let them know: +- You will be performing the merge from beta into release +- You will let them know when the merge is complete +2. Review merge results and ensure correctness +3. Ensure feature flags are following the rules +4. Push the merge +5. Close the milestone for the version that was previously in release +6. Send a message to the #tb-mobile-dev:mozilla.org channel to notify of merge completion + +Merges are performed with the `do_merge.sh` script. + +The following will merge main into beta: +`scripts/ci/merges/do_merge.sh beta` + +And the following will merge beta into release: +`scripts/ci/merges/do_merge.sh release` + +Be sure to review merge results and ensure correctness before pushing to the repository. + +Files of particular importance are: + +- app-k9mail/build.gradle.kts +- app-thunderbird/build.gradle.kts +- app-k9mail/src/main/res/raw/changelog_master.xml + +These build.gradle.kts files must be handled as described in "Merge Days" section above. This is part of the do_merge.sh automation. +The app-k9mail/src/main/res/raw/changelog_master.xml should not include any beta notes in the release branch. + +## Releases + +Releases for both K-9 and Thunderbird for Android are automated with github actions. +Daily builds are scheduled with the [Daily Builds](https://github.com/thunderbird/thunderbird-android/actions/workflows/daily_builds.yml) action and all builds are performed by the [Shippable Build & Signing](https://github.com/thunderbird/thunderbird-android/actions/workflows/shippable_builds.yml) action. + +For the historical manual release process, see [Releasing](HISTORICAL_RELEASE.md). + +### Release Process + +These are the general steps for a release: + +1. Perform merge or uplifts. Each release is the result of either a merge or uplift. +2. Draft release notes at [thunderbird-notes](https://github.com/thunderbird/thunderbird-notes). +3. Trigger build via the [Shippable Build & Signing](https://github.com/thunderbird/thunderbird-android/actions/workflows/shippable_builds.yml) action. +4. Review the build results by reviewing the action summary and the git commits resulting from the build. + - Make sure the version code is incremented properly and not wildly off + - Ensure the commits are correct + - Ensure the symlink `app-metadata` points to the right product at this commit +5. Test the build in the internal testing track + - Release versions should be thoroughly tested with the test plan in Testrail + - Beta versions only require a basic smoke test to ensure it installs +6. Promote TfA and K-9 releases to production track in Play Store. + - Set rollout to a low rate (generally 10-30%). + - Betas are only released for TfA. K-9 beta users are advised to use Thunderbird. +7. Wait for Play Store review to complete. + - Release versions of TfA and K-9 have managed publishing enabled. Once the review has completed you need to publish the release + - Beta versions of TfA do not have managed publishing enabled. It will be available once Google has reviewed, even on a weekend. +8. Update F-Droid to new TfA and K-9 releases by sending a pull request to [fdroiddata](https://gitlab.com/fdroid/fdroiddata) +9. Send community updates to Matrix channels, and beta or planning mailing lists as needed. +10. Approximately 24 hours after initial release to production, assess the following before updating rollout to a higher rate: + - Crash rates, GitHub issues, install base, and reviews. + diff --git a/docs/ci/assets/publish_hold.png b/docs/ci/assets/publish_hold.png new file mode 100644 index 0000000..343e489 Binary files /dev/null and b/docs/ci/assets/publish_hold.png differ diff --git a/docs/contributing/git-commit-guide.md b/docs/contributing/git-commit-guide.md new file mode 100644 index 0000000..85dd3a6 --- /dev/null +++ b/docs/contributing/git-commit-guide.md @@ -0,0 +1,106 @@ +# Git Commit Guide + +Use [Conventional Commits](https://www.conventionalcommits.org/) to write consistent and meaningful commit messages. +This makes your work easier to review, track, and maintain for everyone involved in the project. + +## ✍️ Commit Message Format + +```git +(): + + + + +``` + +Components: + +- ``: The [type of change](#-commit-types) being made (e.g., feat, fix, docs). +- `` **(optional)**: The [scope](#optional-scope) indicates the area of the codebase affected by the change (e.g., auth, ui). +- ``: Short description of the change (50 characters or less) +- `` **(optional)**: Explain what changed and why, include context if helpful. +- `` **(optional)**: Include issue references, breaking changes, etc. + +### Examples + +Basic: + +```git +feat: add QR code scanner +``` + +With scope: + +```git +feat(auth): add login functionality +``` + +With body and issue reference: + +```git +fix(api): handle null response from login endpoint + +Checks for missing tokens to prevent app crash during login. + +Fixes #123 +``` + +### 🏷️ Commit Types + +| Type | Use for... | Example | +|------------|----------------------------------|-------------------------------------------| +| `feat` | New features | `feat(camera): add zoom support` | +| `fix` | Bug fixes | `fix(auth): handle empty username crash` | +| `docs` | Documentation only | `docs(readme): update setup instructions` | +| `style` | Code style (no logic changes) | `style: reformat settings screen` | +| `refactor` | Code changes (no features/fixes) | `refactor(nav): simplify stack setup` | +| `test` | Adding/editing tests | `test(api): add unit test for login` | +| `chore` | Tooling, CI, dependencies | `chore(ci): update GitHub Actions config` | +| `revert` | Reverting previous commits | `revert: remove feature flag` | + +### 📍Optional Scope + +The **scope** is optional but recommended for clarity, especially for large changes or or when multiple areas of the +codebase are involved. + +| Scope | Use for... | Example | +|------------|----------------|------------------------------------------| +| `auth` | Authentication | `feat(auth): add login functionality` | +| `settings` | User settings | `feat(settings): add dark mode toggle` | +| `build` | Build system | `fix(build): improve build performance` | +| `ui` | UI/theme | `refactor(ui): split theme into modules` | +| `deps` | Dependencies | `chore(deps): bump Kotlin to 2.0.0` | + +## 🧠 Best Practices + +### 1. One Commit, One Purpose + +- ✅ Each commit should represent a single logical change or addition to the codebase. +- ❌ Don’t mix unrelated changes together (e.g., fixing a bug and updating docs, or changing a style and ) + adding a feature). + +### 2. Keep It Manageable + +- ✅ Break up large changes into smaller, more manageable commits. +- ✅ If a commit changes more than 200 lines of code, consider breaking it up. +- ❌ Avoid massive, hard-to-review commits. + +### 3. Keep It Working + +- ✅ Each commit should leave the codebase in a buildable and testable state. +- ❌ Never commit broken code or failing tests. + +### 4. Think About Reviewers (and Future You) + +- ✅ Write messages for your teammates and future self, assuming they have no context. +- ✅ Explain non-obvious changes or decisions in the message body. +- ✅ Consider the commit as a documentation tool. +- ❌ Avoid jargon, acronyms, or vague messages like `update stuff`. + +## Summary + +- Use [Conventional Commits](#-conventional-commits) for consistency. +- Keep commit messages short, structured, and focused. +- Make each commit purposeful and self-contained. +- Write commits that make collaboration and future development easier for everyone—including you. + diff --git a/docs/contributing/java-to-kotlin-conversion-guide.md b/docs/contributing/java-to-kotlin-conversion-guide.md new file mode 100644 index 0000000..854013f --- /dev/null +++ b/docs/contributing/java-to-kotlin-conversion-guide.md @@ -0,0 +1,40 @@ +# Java to Kotlin Conversion Guide + +This guide describes our process for converting Java code to Kotlin. + +## Why Convert to Kotlin? + +Java and Kotlin are compatible languages, but we decided to convert our codebase to Kotlin for the following reasons: + +- Kotlin is more concise and expressive than Java. +- Kotlin has better support for null safety. +- Kotlin has a number of modern language features that make it easier to write maintainable code. + +See our [ADR-0001](../architecture/adr/0001-switch-from-java-to-kotlin.md) for more information. + +## How to Convert Java Code to Kotlin + +1. Write tests for any code that is not adequately covered by tests. +2. Use the "Convert Java File to Kotlin File" action in IntelliJ or Android Studio to convert the Java code. +3. Fix any issues that prevent the code from compiling after the automatic conversion. +4. Commit the changes as separate commits: + 1. The change of file extension (e.g. `example.java` -> `example.kt`). + 2. The conversion of the Java file to Kotlin. + - This can be automated by IntelliJ/Android Studio if you use their VCS integration and enable the option to commit changes separately. +5. Refactor the code to improve readability and maintainability. This includes: + 1. Removing unnecessary code. + 2. Using Kotlin's standard library functions, language features, null safety and coding conventions. + +## Additional Tips + +- Use `when` expressions instead of `if-else` statements. +- Use `apply` and `also` to perform side effects on objects. +- Use `@JvmField` to expose a Kotlin property as a field in Java. + +## Resources + +- [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) +- [Calling Kotlin from Java](https://kotlinlang.org/docs/java-to-kotlin-interop.html) +- [Calling Java from Kotlin](https://kotlinlang.org/docs/java-interop.html) +- [Kotlin and Android | Android Developers](https://developer.android.com/kotlin?hl=en) + diff --git a/docs/contributing/testing-guide.md b/docs/contributing/testing-guide.md new file mode 100644 index 0000000..28247b2 --- /dev/null +++ b/docs/contributing/testing-guide.md @@ -0,0 +1,373 @@ +# 🧪 Testing Guide + +This document outlines the testing practices and guidelines for the Thunderbird for Android project. + +**Key Testing Principles:** +- Follow the Arrange-Act-Assert (AAA) pattern +- Use descriptive test names +- Prefer fake implementations over mocks +- Name the object under test as `testSubject` +- Use [AssertK](https://github.com/willowtreeapps/assertk) for assertions + +## 📐 Test Structure + +### 🔍 Arrange-Act-Assert Pattern + +Tests in this project should follow the Arrange-Act-Assert (AAA) pattern: + +1. **Arrange**: Set up the test conditions and inputs +2. **Act**: Perform the action being tested +3. **Assert**: Verify the expected outcomes + +Example: + +```kotlin +@Test +fun `example test using AAA pattern`() { + // Arrange + val input = "test input" + val expectedOutput = "expected result" + val testSubject = SystemUnderTest() + + // Act + val result = testSubject.processInput(input) + + // Assert + assertThat(result).isEqualTo(expectedOutput) +} +``` + +Use comments to clearly separate these sections in your tests: + +```kotlin +// Arrange +// Act +// Assert +``` + +### 📝 Test Naming + +Use descriptive test names that clearly indicate what is being tested. For JVM tests, use backticks: + +```kotlin +@Test +fun `method should return expected result when given valid input`() { + // Test implementation +} +``` + +Note: Android instrumentation tests do not support backticks in test names. For these tests, use camelCase instead: + +```kotlin +@Test +fun methodShouldReturnExpectedResultWhenGivenValidInput() { + // Test implementation +} +``` + +## 💻 Test Implementation + +### 🎭 Fakes over Mocks + +In this project, we prefer using fake implementations over mocks: + +- ✅ **Preferred**: Create fake/test implementations of interfaces or classes +- ❌ **Avoid**: Using mocking libraries to create mock objects + +Fakes provide better test reliability and are more maintainable in the long run. They also make tests more readable +and less prone to breaking when implementation details change. + +Mocks can lead to brittle tests that are tightly coupled to the implementation details, making them harder to maintain. +They also negatively impact test performance, particularly during test initialization. Which can quickly become overwhelming +when an excessive number of tests includes mock implementations. + +Example of a fake implementation: + +```kotlin +// Interface +interface DataRepository { + fun getData(): List +} + +// Fake implementation for testing +class FakeDataRepository( + // Allow passing initial data during construction + initialData: List = emptyList() +) : DataRepository { + // Mutable property to allow changing data between tests + var dataToReturn = initialData + + override fun getData(): List { + return dataToReturn + } +} + +// In test +@Test +fun `processor should transform data correctly`() { + // Arrange + val fakeRepo = FakeDataRepository(listOf("item1", "item2")) + val testSubject = DataProcessor(fakeRepo) + + // Act + val result = testSubject.process() + + // Assert + assertThat(result).containsExactly("ITEM1", "ITEM2") +} +``` + +### 📋 Naming Conventions + +When writing tests, use the following naming conventions: + +- Name the object under test as `testSubject` (not "sut" or other abbreviations) +- Name fake implementations with a "Fake" prefix (e.g., `FakeDataRepository`) +- Use descriptive variable names that clearly indicate their purpose + +### ✅ Assertions + +Use [AssertK](https://github.com/willowtreeapps/assertk) for assertions in tests: + +```kotlin +@Test +fun `example test`() { + // Arrange + val list = listOf("apple", "banana") + + // Act + val result = list.contains("apple") + + // Assert + assertThat(result).isTrue() + assertThat(list).contains("banana") +} +``` + +Note: You'll need to import the appropriate [AssertK](https://github.com/willowtreeapps/assertk) assertions: +- `assertk.assertThat` for the base assertion function +- Functions from the `assertk.assertions` namespace for specific assertion types (e.g., `import assertk.assertions.isEqualTo`, `import assertk.assertions.contains`, `import assertk.assertions.isTrue`, etc.) + +## 🧮 Test Types + +This section describes the different types of tests we use in the project. Each type serves a specific purpose in our testing strategy, and together they help ensure the quality and reliability of our codebase. + +### 🔬 Unit Tests + +> **Unit tests verify that individual components work correctly in isolation.** + +**What to Test:** +- Single units of functionality +- Individual methods or functions +- Classes in isolation +- Business logic +- Edge cases and error handling + +**Key Characteristics:** +- Independent (no external dependencies) +- No reliance on external resources +- Uses fake implementations for dependencies + +**Frameworks:** +- JUnit 4 +- [AssertK](https://github.com/willowtreeapps/assertk) for assertions +- [Robolectric](https://robolectric.org/) (for Android framework classes) + +**Location:** +- Tests should be in the same module as the code being tested +- Should be in the `src/test` directory or `src/{platformTarget}Test` for Kotlin Multiplatform +- Tests should be in the same package as the code being tested + +**Contributor Expectations:** +- ✅ All new code should be covered by unit tests +- ✅ Add tests that reproduce bugs when fixing issues +- ✅ Follow the AAA pattern (Arrange-Act-Assert) +- ✅ Use descriptive test names +- ✅ Prefer fake implementations over mocks + +### 🔌 Integration Tests + +> **Integration tests verify that components work correctly together.** + +**What to Test:** +- Interactions between components +- Communication between layers +- Data flow across multiple units +- Component integration points + +**Key Characteristics:** +- Tests multiple components together +- May use real implementations when appropriate +- Focuses on component boundaries + +**Frameworks:** +- JUnit 4 (for tests in `src/test`) +- [AssertK](https://github.com/willowtreeapps/assertk) for assertions +- [Robolectric](https://robolectric.org/) (for Android framework classes in `src/test`) +- Espresso (for UI testing in `src/androidTest`) + +**Location:** +- Preferably in the `src/test` or `src/commonTest`, `src/{platformTarget}Test` for Kotlin Multiplatform +- Only use `src/androidTest`, when there's a specific need for Android dependencies + +**Why prefer `test` over `androidTest`:** +- JUnit tests run faster (on JVM instead of emulator/device) +- Easier to set up and maintain +- Better integration with CI/CD pipelines +- Lower resource requirements +- Faster feedback during development + +**When to use androidTest:** +- When testing functionality that depends on Android-specific APIs that are not available with Robolectric +- When tests need to interact with the Android framework directly + +**Contributor Expectations:** +- ✅ Add tests for features involving multiple components +- ✅ Focus on critical paths and user flows +- ✅ Be mindful of test execution time +- ✅ Follow the AAA pattern +- ✅ Use descriptive test names + +### 📱 UI Tests + +> **UI tests verify the application from a user's perspective.** + +**What to Test:** +- User interface behavior +- UI component interactions +- Complete user flows +- Screen transitions +- Input handling and validation + +**Key Characteristics:** +- Tests from user perspective +- Verifies visual elements and interactions +- Covers end-to-end scenarios + +**Frameworks:** +- Espresso for Android UI testing +- Compose UI testing for Jetpack Compose +- JUnit 4 as the test runner + +**Location:** +- In the `src/test` directory for Compose UI tests +- In the `src/androidTest` directory for Espresso tests + +**Contributor Expectations:** +- ✅ Add tests for new UI components and screens +- ✅ Focus on critical user flows +- ✅ Consider different device configurations +- ✅ Test both positive and negative scenarios +- ✅ Follow the AAA pattern +- ✅ Use descriptive test names + +### 📸 Screenshot Tests + +**⚠️ Work in Progress ⚠️** + +> **Screenshot tests verify the visual appearance of UI components.** + +**What to Test:** +- Visual appearance of UI components +- Layout correctness +- Visual regressions +- Theme and style application + +**Key Characteristics:** +- Captures visual snapshots +- Compares against reference images (`golden` images) +- Detects unintended visual changes + +**Frameworks:** +- JUnit 4 as the test runner +- Compose UI testing +- Screenshot comparison tools (TBD) + +**Location:** +- Same module as the code being tested +- In the `src/test` directory + +**Contributor Expectations:** +- ✅ Add tests for new Composable UI components +- ✅ Verify correct rendering in different states +- ✅ Update reference screenshots for intentional changes + +## 🚫 Test Types We Don't Currently Have + +> **This section helps contributors understand our testing strategy and future plans.** + +**End-to-End Tests** ✨ +- Full system tests verifying complete user journeys +- Tests across multiple screens and features +- Validates entire application workflows + +**Performance Tests** ⚡ +- Measures startup time, memory usage, responsiveness +- Validates app performance under various conditions +- Identifies performance bottlenecks + +**Accessibility Tests** ♿ +- Verifies proper content descriptions +- Checks contrast ratios and keyboard navigation +- Ensures app is usable by people with disabilities + +**Localization Tests** 🌐 +- Verifies correct translation display +- Tests right-to-left language support +- Validates date, time, and number formatting + +**Manual Test Scripts** 📝 +- Manual testing by QA team for exploratory testing +- Ensures repeatable test execution +- Documents expected behavior for manual tests + +## 🏃 Running Tests + +> **Quick commands to run tests in the project.** + +Run all tests: + +```bash +./gradlew test +``` + +Run tests for a specific module: + +```bash +./gradlew :module-name:test +``` + +Run Android instrumentation tests: + +```bash +./gradlew connectedAndroidTest +``` + +Run tests with coverage: + +```bash +./gradlew testDebugUnitTestCoverage +``` + +## 📊 Code Coverage + +> **⚠️ Work in Progress ⚠️** +> +> This section is currently under development and will be updated with specific code coverage rules and guidelines. + +Code coverage helps us understand how much of our codebase is being tested. While we don't currently have strict requirements, we aim for high coverage in critical components. + +**Current Approach:** +- Focus on critical business logic +- Prioritize user-facing features +- No strict percentage requirements +- Quality of tests over quantity + +**Future Guidelines (Coming Soon):** +- Code coverage targets by component type +- Coverage report generation instructions +- Interpretation guidelines +- Exemptions for generated/simple code +- CI/CD integration details + +**Remember:** High code coverage doesn't guarantee high-quality tests. Focus on writing meaningful tests that verify correct behavior, not just increasing coverage numbers. diff --git a/docs/install.sh b/docs/install.sh new file mode 100755 index 0000000..be0e180 --- /dev/null +++ b/docs/install.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +## This script installs mdbook and extensions, additionally it downloads the latest Mermaid.js version. +## If the script is run with the "--force" argument, it will force the installation of mdbook and it's extensions. + +set -e + +# Define installation paths +BASE_DIR=$(dirname -- "${BASH_SOURCE[0]}") +MERMAID_JS_DIR="${BASE_DIR}/assets/theme/" +MERMAID_JS_PATH="${MERMAID_JS_DIR}mermaid.min.js" + +# Check if the script was run with "force" argument +FORCE_UPDATE=false +if [ "$1" == "--force" ]; then + FORCE_UPDATE=true + echo "Force update mode enabled." +fi + +# Ensure Cargo (Rust) is installed +if ! command -v cargo &> /dev/null; then + echo "Cargo (Rust) is required to install mdbook" + echo "Please install Rust from https://www.rust-lang.org/tools/install." + exit 1 +fi + +# Install mdbook +if $FORCE_UPDATE; then + echo "Forcing mdbook installation..." + cargo install --force mdbook + cargo install --force mdbook-alerts + cargo install --force mdbook-external-links + cargo install --force mdbook-last-changed + cargo install --force mdbook-mermaid + cargo install --force mdbook-pagetoc +else + cargo install mdbook + cargo install mdbook-alerts + cargo install mdbook-external-links + cargo install mdbook-last-changed + cargo install mdbook-mermaid + cargo install mdbook-pagetoc +fi + +# Fetch latest releases from GitHub +LATEST_RELEASES=$(curl -s "https://api.github.com/repos/mermaid-js/mermaid/releases" | jq -r '.[].tag_name') + +# Extract the latest valid mermaid version (filtering out layout-elk) +LATEST_MERMAID_VERSION=$(echo "$LATEST_RELEASES" | grep -E '^mermaid@[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/mermaid@//' | sort -V | tail -n 1) + +if [ -z "$LATEST_MERMAID_VERSION" ]; then + echo "Failed to fetch the latest Mermaid.js version." + exit 1 +fi + +mkdir -p "$MERMAID_JS_DIR" + +# Download the latest Mermaid.js +echo "Downloading Mermaid.js version $LATEST_MERMAID_VERSION..." +curl -L "https://cdn.jsdelivr.net/npm/mermaid@$LATEST_MERMAID_VERSION/dist/mermaid.min.js" -o "$MERMAID_JS_PATH" + +echo "Installation and update complete!" diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000..3da5bea --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,114 @@ +# Managing strings + +We use Android's [resource system](https://developer.android.com/guide/topics/resources/localization) to localize +user-visible strings in our apps. + +Our source language is English (American English to be more precise, but simply "English" (en) on Weblate). + +Translations of source strings happen exclusively in our +[Weblate project](https://hosted.weblate.org/projects/tb-android/). This means the source language is only modified by +changes to this repository, i.e. via pull requests. Translations are only updated on Weblate and then merged into this +repository by the Thunderbird team. This is to avoid overlapping changes in both repositories that will lead to merge +conflicts. + +## Adding a string + +Add a new string to the appropriate `res/values/strings.xml` file. + +Please don't add any translations for this new string to this repository. If you can also provide a translation for the +new string, wait until the change is merged into this repository and propagated to Weblate. Then translate the new +string on Weblate. + +## Changing a string + +Changing a string is only acceptable if you are fixing typos or grammar in English. If you need to change the meaning of +a string, or otherwise make a change that would affect other strings, please instead remove the string and add a new one +with a different key. + +## Removing a string + +Remove the source string from `res/values/strings.xml`. Don't modify translations under `res/values-/strings.xml`. +The next merge from Weblate will automatically get rid of the translated strings. + +## Changing translations in this repository + +This should be avoided whenever possible, as it can create merge conflicts between Weblate and this repository. If you +need to change individual strings, please translate them on Weblate instead. If a mechanical change is necessary across +all languages, this should be discussed with the core team who will use this procedure: + +1. Lock all components on Weblate by clicking the "Lock" button in the + [repository maintenance](https://hosted.weblate.org/projects/tb-android/#repository) screen. +2. Commit all outstanding changes by clicking the "Commit" button in the same screen. +3. Trigger creating a pull request containing translation updates from Weblate by clicking the "Push" button in the + repository maintenance screen. +4. Merge that pull request containing updates from Weblate into this repository. +5. Create a pull request to change the translated files, following the established procedures to get it merged. Make + sure you've rebased against the latest changes. +6. Wait for the changes in this repository to be automatically propagated to and processed by Weblate. +7. Unlock components on Weblate by clicking the "Unlock" button in the + [repository maintenance](https://hosted.weblate.org/projects/tb-android/#repository) screen. + +## Merging Weblate pull requests + +When merging a pull request from Weblate, please check the following: + +* If the PR contains changes to the cs, lt or sk locales, make sure that plural forms are correctly + maintained. Weblate does not manage this automatically due to https://github.com/WeblateOrg/weblate/issues/7520 . You + will need to manually add a patch that makes sure the strings have both a `many` and an `other` translation. If you + don't speak the language, using the same value for the two variants is an acceptable trade-off. + +# Managing translations + +Right now we're using the `androidResources.localeFilters` mechanism provided by the Android Gradle Plugin to limit +which languages are included in builds of the app, +See [localFilters](). + +This list needs to be kept in sync with the string array `supported_languages`, so the in-app language picker offers +exactly the languages that are included in the app. + +## Removing a language + +1. Remove the language code from the `androidResources.localeFilters` list in `app-thunderbird/build.gradle.kts` and + `app-k9mail/build.gradle.kts`. +2. Remove the entry from `supported_languages` in `app/core/src/main/res/values/arrays_general_settings_values.xml`. + +## Adding a language + +1. Add the language code to the `androidResources.localeFilters` list in `app-thunderbird/build.gradle.kts` and + `app-k9mail/build.gradle.kts`. +2. Add an entry to `supported_languages` in `app/core/src/main/res/values/arrays_general_settings_values.xml`. +3. Make sure that `language_values` in `app/core/src/main/res/values/arrays_general_settings_values.xml` contains an + entry for the language code you just added. If not: + 1. Add the language name (in its native script) to `language_entries` in + `app/ui/legacy/src/main/res/values/arrays_general_settings_strings.xml`. Please note that this list should be + ordered using the Unicode default collation order. + 2. Add the language code to `language_values` in `app/core/src/main/res/values/arrays_general_settings_values.xml` + so that the index in the list matches that of the newly added entry in `language_entries`. + +## Adding a component on Weblate + +When adding a new code module that is including translatable strings, a new components needs to be added to Weblate. + +1. Go the the Weblate page to [add a component](https://hosted.weblate.org/create/component/?project=3696). +2. Switch to the "From existing component" tab. +3. Enter a name for the component. +4. For "Component", select "K-9 Mail/Thunderbird/ui-legacy". +5. Press the "Continue" button. +6. Under "Choose translation files to import", select "Specify configuration manually". +7. Press the "Continue" button. +8. For "File format", select "Android String Resource". +9. Under "File mask", enter the path to the string resource files with a wildcard, + e.g. `feature/account/common/src/main/res/values-*/strings.xml`. +10. Under "Monolingual base language file", enter the path to the string source file, + e.g. `feature/account/common/src/main/res/values/strings.xml`. +11. Uncheck "Edit base file". +12. For "Translation license", select "Apache License 2.0". +13. Press the "Save" button. + +## Things to note + +For some languages Android uses different language codes than typical translation tools, e.g. Hebrew's code is _he_ on +Weblate, but _iw_ on Android. When writing automation tools, there needs to be a mapping step involved. + +See [translation-cli](https://github.com/thunderbird/thunderbird-android/blob/ed07da8be5513ac74aabb1c934a4545aaae4f5a3/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt#L12-L13) +for an example. diff --git a/feature/account/api/build.gradle.kts b/feature/account/api/build.gradle.kts new file mode 100644 index 0000000..31ba52d --- /dev/null +++ b/feature/account/api/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.feature.account" +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(projects.core.architecture.api) + } + } +} diff --git a/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/Account.kt b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/Account.kt new file mode 100644 index 0000000..e6f5e36 --- /dev/null +++ b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/Account.kt @@ -0,0 +1,8 @@ +package net.thunderbird.feature.account + +import net.thunderbird.core.architecture.model.Identifiable + +/** + * Interface representing an account by its unique identifier [AccountId]. + */ +interface Account : Identifiable diff --git a/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/AccountId.kt b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/AccountId.kt new file mode 100644 index 0000000..b0b3832 --- /dev/null +++ b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/AccountId.kt @@ -0,0 +1,8 @@ +package net.thunderbird.feature.account + +import net.thunderbird.core.architecture.model.Id + +/** + * Represents a unique identifier for an [Account]. + */ +typealias AccountId = Id diff --git a/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/AccountIdFactory.kt b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/AccountIdFactory.kt new file mode 100644 index 0000000..4f58a3d --- /dev/null +++ b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/AccountIdFactory.kt @@ -0,0 +1,8 @@ +package net.thunderbird.feature.account + +import net.thunderbird.core.architecture.model.BaseIdFactory + +/** + * Factory object for creating unique identifiers for [Account] instances. + */ +object AccountIdFactory : BaseIdFactory() diff --git a/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountAvatar.kt b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountAvatar.kt new file mode 100644 index 0000000..9c1c7bc --- /dev/null +++ b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountAvatar.kt @@ -0,0 +1,18 @@ +package net.thunderbird.feature.account.profile + +/** + * Sealed interface representing the avatar of an account. + */ +sealed interface AccountAvatar { + data class Monogram( + val value: String, + ) : AccountAvatar + + data class Image( + val uri: String, + ) : AccountAvatar + + data class Icon( + val name: String, + ) : AccountAvatar +} diff --git a/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountProfile.kt b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountProfile.kt new file mode 100644 index 0000000..c23645c --- /dev/null +++ b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountProfile.kt @@ -0,0 +1,19 @@ +package net.thunderbird.feature.account.profile + +import net.thunderbird.feature.account.Account +import net.thunderbird.feature.account.AccountId + +/** + * Data class representing an account profile. + * + * @property id The unique identifier of the account profile. + * @property name The name of the account. + * @property color The color associated with the account. + * @property avatar The [AccountAvatar] representing the avatar of the account. + */ +data class AccountProfile( + override val id: AccountId, + val name: String, + val color: Int, + val avatar: AccountAvatar, +) : Account diff --git a/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountProfileRepository.kt b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountProfileRepository.kt new file mode 100644 index 0000000..4272db3 --- /dev/null +++ b/feature/account/api/src/commonMain/kotlin/net/thunderbird/feature/account/profile/AccountProfileRepository.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.account.profile + +import kotlinx.coroutines.flow.Flow +import net.thunderbird.feature.account.AccountId + +interface AccountProfileRepository { + + fun getById(accountId: AccountId): Flow + + suspend fun update(accountProfile: AccountProfile) +} diff --git a/feature/account/api/src/commonTest/kotlin/net/thunderbird/feature/account/AccountIdFactoryTest.kt b/feature/account/api/src/commonTest/kotlin/net/thunderbird/feature/account/AccountIdFactoryTest.kt new file mode 100644 index 0000000..b5b6431 --- /dev/null +++ b/feature/account/api/src/commonTest/kotlin/net/thunderbird/feature/account/AccountIdFactoryTest.kt @@ -0,0 +1,62 @@ +package net.thunderbird.feature.account + +import assertk.Assert +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotEqualTo +import kotlin.test.Test +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class AccountIdFactoryTest { + + @Test + fun `create should return AccountId with the same id`() { + val id = "123e4567-e89b-12d3-a456-426614174000" + + val result = AccountIdFactory.of(id) + + assertThat(result.asRaw()).isEqualTo(id) + } + + @Test + fun `create should throw IllegalArgumentException when id is invalid`() { + val id = "invalid" + + val result = assertFailure { + AccountIdFactory.of(id) + } + + result.hasMessage( + "Expected either a 36-char string in the standard hex-and-dash UUID format or a 32-char " + + "hexadecimal string, but was \"invalid\" of length 7", + ) + result.isInstanceOf() + } + + @Test + fun `new should return AccountId with a uuid`() { + val result = AccountIdFactory.create() + + assertThat(result.asRaw()).isUuid() + } + + @Test + fun `create should return AccountId with unique ids`() { + val ids = List(10) { AccountIdFactory.create().asRaw() } + + ids.forEachIndexed { index, id -> + ids.drop(index + 1).forEach { otherId -> + assertThat(id).isNotEqualTo(otherId) + } + } + } + + @OptIn(ExperimentalUuidApi::class) + private fun Assert.isUuid() = given { actual -> + Uuid.parse(actual) + } +} diff --git a/feature/account/avatar/api/build.gradle.kts b/feature/account/avatar/api/build.gradle.kts new file mode 100644 index 0000000..117e388 --- /dev/null +++ b/feature/account/avatar/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.feature.account.avatar" +} diff --git a/feature/account/avatar/api/src/commonMain/kotlin/net/thunderbird/feature/account/avatar/AvatarMonogramCreator.kt b/feature/account/avatar/api/src/commonMain/kotlin/net/thunderbird/feature/account/avatar/AvatarMonogramCreator.kt new file mode 100644 index 0000000..8a9d607 --- /dev/null +++ b/feature/account/avatar/api/src/commonMain/kotlin/net/thunderbird/feature/account/avatar/AvatarMonogramCreator.kt @@ -0,0 +1,18 @@ +package net.thunderbird.feature.account.avatar + +/** + * Interface for creating a monogram based on a name or email address. + * + * This interface is used to generate a monogram, which is typically the initials of a person's name, + * or a representation based on an email address. Implementations should handle null or empty inputs gracefully. + */ +fun interface AvatarMonogramCreator { + /** + * Creates a monogram for the given name or email. + * + * @param name The name to generate a monogram for. + * @param email The email address to generate a monogram for. + * @return A string representing the monogram, or an empty string if the name or email is null or empty. + */ + fun create(name: String?, email: String?): String +} diff --git a/feature/account/avatar/impl/build.gradle.kts b/feature/account/avatar/impl/build.gradle.kts new file mode 100644 index 0000000..a6dfbf7 --- /dev/null +++ b/feature/account/avatar/impl/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "net.thunderbird.feature.account.avatar.impl" + resourcePrefix = "account_avatar_" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.common) + + implementation(projects.feature.account.avatar.api) + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/feature/account/avatar/impl/src/debug/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarOutlinedPreview.kt b/feature/account/avatar/impl/src/debug/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarOutlinedPreview.kt new file mode 100644 index 0000000..8071c49 --- /dev/null +++ b/feature/account/avatar/impl/src/debug/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarOutlinedPreview.kt @@ -0,0 +1,29 @@ +package net.thunderbird.feature.account.avatar.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun AvatarOutlinedPreview() { + PreviewWithThemes { + AvatarOutlined( + color = Color(0xFFe57373), + name = "example", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AvatarOutlinedLargePreview() { + PreviewWithThemes { + AvatarOutlined( + color = Color(0xFFe57373), + name = "example", + size = AvatarSize.LARGE, + ) + } +} diff --git a/feature/account/avatar/impl/src/debug/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarPreview.kt b/feature/account/avatar/impl/src/debug/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarPreview.kt new file mode 100644 index 0000000..7da64b0 --- /dev/null +++ b/feature/account/avatar/impl/src/debug/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarPreview.kt @@ -0,0 +1,30 @@ +package net.thunderbird.feature.account.avatar.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun AvatarPreview() { + PreviewWithThemes { + Avatar( + color = Color(0xFFe57373), + name = "example", + selected = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AvatarSelectedPreview() { + PreviewWithThemes { + Avatar( + color = Color(0xFFe57373), + name = "example", + selected = true, + ) + } +} diff --git a/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/DefaultAvatarMonogramCreator.kt b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/DefaultAvatarMonogramCreator.kt new file mode 100644 index 0000000..e0ae23c --- /dev/null +++ b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/DefaultAvatarMonogramCreator.kt @@ -0,0 +1,27 @@ +package net.thunderbird.feature.account.avatar + +/** + * Creates a monogram based on a name or email address. + * + * This implementation generates a monogram by taking the first two characters of the name or email, + * removing spaces, and converting them to uppercase. + */ +class DefaultAvatarMonogramCreator : AvatarMonogramCreator { + override fun create(name: String?, email: String?): String { + return if (name != null && name.isNotEmpty()) { + composeAvatarMonogram(name) + } else if (email != null && email.isNotEmpty()) { + composeAvatarMonogram(email) + } else { + AVATAR_MONOGRAM_DEFAULT + } + } + + private fun composeAvatarMonogram(name: String): String { + return name.replace(" ", "").take(2).uppercase() + } + + private companion object { + private const val AVATAR_MONOGRAM_DEFAULT = "XX" + } +} diff --git a/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/Avatar.kt b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/Avatar.kt new file mode 100644 index 0000000..c41d601 --- /dev/null +++ b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/Avatar.kt @@ -0,0 +1,92 @@ +package net.thunderbird.feature.account.avatar.ui + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +val selectedAvatarSize = 40.dp + +@Composable +fun Avatar( + color: Color, + name: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val avatarSize by animateDpAsState( + targetValue = if (selected) selectedAvatarSize else MainTheme.sizes.iconAvatar, + label = "Avatar size", + ) + + Box( + modifier = modifier + .clip(CircleShape) + .clickable(enabled = onClick != null && !selected, onClick = { onClick?.invoke() }), + contentAlignment = Alignment.Center, + ) { + AvatarOutline( + color = color, + modifier = Modifier.size(avatarSize), + ) { + AvatarPlaceholder( + displayName = name, + ) + // TODO: Add image loading + } + } +} + +@Composable +private fun AvatarOutline( + color: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Surface( + modifier = modifier + .clip(CircleShape) + .border(2.dp, color, CircleShape) + .padding(2.dp), + color = color.copy(alpha = 0.3f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .border(2.dp, MainTheme.colors.surfaceContainerLowest, CircleShape), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +@Composable +private fun AvatarPlaceholder( + displayName: String, + modifier: Modifier = Modifier, +) { + TextTitleMedium( + text = extractNameInitials(displayName).uppercase(), + modifier = modifier, + ) +} + +private fun extractNameInitials(displayName: String): String { + return displayName.take(2) +} diff --git a/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarOutlined.kt b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarOutlined.kt new file mode 100644 index 0000000..7a04864 --- /dev/null +++ b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarOutlined.kt @@ -0,0 +1,117 @@ +package net.thunderbird.feature.account.avatar.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.core.ui.compose.theme2.toSurfaceContainer + +private const val AVATAR_ALPHA = 0.2f + +@Composable +fun AvatarOutlined( + color: Color, + name: String, + modifier: Modifier = Modifier, + size: AvatarSize = AvatarSize.MEDIUM, + onClick: (() -> Unit)? = null, +) { + val avatarColor = calculateAvatarColor(color) + val containerColor = avatarColor.toSurfaceContainer(alpha = AVATAR_ALPHA) + + AvatarLayout( + color = containerColor, + borderColor = avatarColor, + onClick = onClick, + modifier = modifier.size(getAvatarSize(size)), + ) { + AvatarPlaceholder( + color = avatarColor, + displayName = name, + size = size, + ) + // TODO: Add image loading + } +} + +@Composable +private fun AvatarLayout( + color: Color, + borderColor: Color, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + color = color, + shape = CircleShape, + modifier = modifier + .border( + width = 2.dp, + shape = CircleShape, + color = borderColor, + ) + .clickable( + enabled = onClick != null, + onClick = { onClick?.invoke() }, + ), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +@Composable +private fun AvatarPlaceholder( + color: Color, + displayName: String, + size: AvatarSize, + modifier: Modifier = Modifier, +) { + when (size) { + AvatarSize.MEDIUM -> { + TextTitleMedium( + text = extractNameInitials(displayName).uppercase(), + color = color, + modifier = modifier, + ) + } + + AvatarSize.LARGE -> { + TextTitleLarge( + text = extractNameInitials(displayName).uppercase(), + color = color, + modifier = modifier, + ) + } + } +} + +@Composable +private fun getAvatarSize(size: AvatarSize): Dp { + return when (size) { + AvatarSize.MEDIUM -> MainTheme.sizes.iconAvatar + AvatarSize.LARGE -> MainTheme.sizes.large + } +} + +private fun extractNameInitials(displayName: String): String { + return displayName.take(2) +} diff --git a/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarSize.kt b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarSize.kt new file mode 100644 index 0000000..66eff28 --- /dev/null +++ b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/AvatarSize.kt @@ -0,0 +1,6 @@ +package net.thunderbird.feature.account.avatar.ui + +enum class AvatarSize { + MEDIUM, + LARGE, +} diff --git a/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/CalculateAvatarColor.kt b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/CalculateAvatarColor.kt new file mode 100644 index 0000000..97b1ce3 --- /dev/null +++ b/feature/account/avatar/impl/src/main/kotlin/net/thunderbird/feature/account/avatar/ui/CalculateAvatarColor.kt @@ -0,0 +1,15 @@ +package net.thunderbird.feature.account.avatar.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.core.ui.compose.theme2.toHarmonizedColor + +@Composable +internal fun calculateAvatarColor(accountColor: Color): Color { + return if (accountColor == Color.Unspecified) { + MainTheme.colors.tertiary + } else { + accountColor.toHarmonizedColor(MainTheme.colors.surface) + } +} diff --git a/feature/account/avatar/impl/src/test/kotlin/net/thunderbird/feature/account/avatar/DefaultAvatarMonogramCreatorTest.kt b/feature/account/avatar/impl/src/test/kotlin/net/thunderbird/feature/account/avatar/DefaultAvatarMonogramCreatorTest.kt new file mode 100644 index 0000000..74a3ca9 --- /dev/null +++ b/feature/account/avatar/impl/src/test/kotlin/net/thunderbird/feature/account/avatar/DefaultAvatarMonogramCreatorTest.kt @@ -0,0 +1,41 @@ +package net.thunderbird.feature.account.avatar + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class DefaultAvatarMonogramCreatorTest { + + private val testSubject = DefaultAvatarMonogramCreator() + + @Test + fun `create returns correct monogram for name`() { + val name = "John Doe" + val expectedMonogram = "JO" + + val result = testSubject.create(name, null) + + assertThat(result).isEqualTo(expectedMonogram) + } + + @Test + fun `create returns correct monogram for email`() { + val email = "test@example.com" + val expectedMonogram = "TE" + + val result = testSubject.create(null, email) + + assertThat(result).isEqualTo(expectedMonogram) + } + + @Test + fun `create returns default monogram for null or empty inputs`() { + val expectedMonogram = "XX" + + val resultWithNulls = testSubject.create(null, null) + assertThat(resultWithNulls).isEqualTo(expectedMonogram) + + val resultWithEmptyStrings = testSubject.create("", "") + assertThat(resultWithEmptyStrings).isEqualTo(expectedMonogram) + } +} diff --git a/feature/account/common/build.gradle.kts b/feature/account/common/build.gradle.kts new file mode 100644 index 0000000..aa32047 --- /dev/null +++ b/feature/account/common/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.account.common" + resourcePrefix = "account_common_" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.common) + + implementation(projects.mail.common) + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/AccountTopAppBarPreview.kt b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/AccountTopAppBarPreview.kt new file mode 100644 index 0000000..f6175a8 --- /dev/null +++ b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/AccountTopAppBarPreview.kt @@ -0,0 +1,15 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@PreviewDevices +internal fun AccountTopAppBarPreview() { + PreviewWithThemes { + AccountTopAppBar( + title = "Title", + ) + } +} diff --git a/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/AppTitleTopHeaderPreview.kt b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/AppTitleTopHeaderPreview.kt new file mode 100644 index 0000000..55cd184 --- /dev/null +++ b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/AppTitleTopHeaderPreview.kt @@ -0,0 +1,13 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun AppTitleTopHeaderPreview() { + PreviewWithThemes { + AppTitleTopHeader(title = "Title") + } +} diff --git a/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/ContentListViewPreview.kt b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/ContentListViewPreview.kt new file mode 100644 index 0000000..033e9e3 --- /dev/null +++ b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/ContentListViewPreview.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium + +@Composable +@Preview(showBackground = true) +internal fun ContentListViewPreview() { + PreviewWithThemes { + ContentListView { + item { + TextTitleMedium("Item 1") + } + item { + TextTitleMedium("Item 2") + } + item { + TextTitleMedium("Item 3") + } + } + } +} diff --git a/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBarPreview.kt b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBarPreview.kt new file mode 100644 index 0000000..e3a6758 --- /dev/null +++ b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBarPreview.kt @@ -0,0 +1,59 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@PreviewDevices +internal fun WizardNavigationBarPreview() { + PreviewWithThemes { + WizardNavigationBar( + onNextClick = {}, + onBackClick = {}, + ) + } +} + +@Composable +@PreviewDevices +internal fun WizardNavigationBarDisabledPreview() { + PreviewWithThemes { + WizardNavigationBar( + onNextClick = {}, + onBackClick = {}, + state = WizardNavigationBarState( + isNextEnabled = false, + isBackEnabled = false, + ), + ) + } +} + +@Composable +@PreviewDevices +internal fun WizardNavigationBarHideNextPreview() { + PreviewWithThemes { + WizardNavigationBar( + onNextClick = {}, + onBackClick = {}, + state = WizardNavigationBarState( + showNext = false, + ), + ) + } +} + +@Composable +@PreviewDevices +internal fun WizardNavigationBarHideBackPreview() { + PreviewWithThemes { + WizardNavigationBar( + onNextClick = {}, + onBackClick = {}, + state = WizardNavigationBarState( + showBack = false, + ), + ) + } +} diff --git a/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/fake/FakeAccountStateRepository.kt b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/fake/FakeAccountStateRepository.kt new file mode 100644 index 0000000..3eb532b --- /dev/null +++ b/feature/account/common/src/debug/kotlin/app/k9mail/feature/account/common/ui/fake/FakeAccountStateRepository.kt @@ -0,0 +1,57 @@ +package app.k9mail.feature.account.common.ui.fake + +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings + +@Suppress("TooManyFunctions") +class FakeAccountStateRepository : AccountDomainContract.AccountStateRepository { + + override fun getState(): AccountState = AccountState( + emailAddress = "test@example.com", + incomingServerSettings = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ), + outgoingServerSettings = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ), + ) + + override fun setState(accountState: AccountState) = Unit + + override fun setEmailAddress(emailAddress: String) = Unit + + override fun setIncomingServerSettings(serverSettings: ServerSettings) = Unit + + override fun setOutgoingServerSettings(serverSettings: ServerSettings) = Unit + + override fun setAuthorizationState(authorizationState: AuthorizationState) = Unit + + override fun setSpecialFolderSettings(specialFolderSettings: SpecialFolderSettings) = Unit + + override fun setDisplayOptions(displayOptions: AccountDisplayOptions) = Unit + + override fun setSyncOptions(syncOptions: AccountSyncOptions) = Unit + + override fun clear() = Unit +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/AccountCommonExternalContract.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/AccountCommonExternalContract.kt new file mode 100644 index 0000000..3d8fe3c --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/AccountCommonExternalContract.kt @@ -0,0 +1,10 @@ +package app.k9mail.feature.account.common + +import app.k9mail.feature.account.common.domain.entity.AccountState + +interface AccountCommonExternalContract { + + fun interface AccountStateLoader { + suspend fun loadAccountState(accountUuid: String): AccountState? + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/AccountCommonModule.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/AccountCommonModule.kt new file mode 100644 index 0000000..c3776cf --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/AccountCommonModule.kt @@ -0,0 +1,17 @@ +package app.k9mail.feature.account.common + +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import com.fsck.k9.mail.oauth.AuthStateStorage +import net.thunderbird.core.common.coreCommonModule +import org.koin.core.module.Module +import org.koin.dsl.binds +import org.koin.dsl.module + +val featureAccountCommonModule: Module = module { + includes(coreCommonModule) + + single { + InMemoryAccountStateRepository() + }.binds(arrayOf(AccountDomainContract.AccountStateRepository::class, AuthStateStorage::class)) +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/data/InMemoryAccountStateRepository.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/data/InMemoryAccountStateRepository.kt new file mode 100644 index 0000000..be713c1 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/data/InMemoryAccountStateRepository.kt @@ -0,0 +1,64 @@ +package app.k9mail.feature.account.common.data + +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.AuthStateStorage + +@Suppress("TooManyFunctions") +class InMemoryAccountStateRepository( + private var state: AccountState = AccountState(), +) : AccountDomainContract.AccountStateRepository, AuthStateStorage { + + override fun getState(): AccountState { + return state + } + + override fun setState(accountState: AccountState) { + state = accountState + } + + override fun setEmailAddress(emailAddress: String) { + state = state.copy(emailAddress = emailAddress) + } + + override fun setIncomingServerSettings(serverSettings: ServerSettings) { + state = state.copy(incomingServerSettings = serverSettings) + } + + override fun setOutgoingServerSettings(serverSettings: ServerSettings) { + state = state.copy(outgoingServerSettings = serverSettings) + } + + override fun setAuthorizationState(authorizationState: AuthorizationState) { + state = state.copy(authorizationState = authorizationState) + } + + override fun setSpecialFolderSettings(specialFolderSettings: SpecialFolderSettings) { + state = state.copy(specialFolderSettings = specialFolderSettings) + } + + override fun setDisplayOptions(displayOptions: AccountDisplayOptions) { + state = state.copy(displayOptions = displayOptions) + } + + override fun setSyncOptions(syncOptions: AccountSyncOptions) { + state = state.copy(syncOptions = syncOptions) + } + + override fun clear() { + state = AccountState() + } + + override fun getAuthorizationState(): String? { + return state.authorizationState?.value + } + + override fun updateAuthorizationState(authorizationState: String?) { + state = state.copy(authorizationState = AuthorizationState(authorizationState)) + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/AccountDomainContract.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/AccountDomainContract.kt new file mode 100644 index 0000000..a5820f2 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/AccountDomainContract.kt @@ -0,0 +1,34 @@ +package app.k9mail.feature.account.common.domain + +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import com.fsck.k9.mail.ServerSettings + +interface AccountDomainContract { + + @Suppress("TooManyFunctions") + interface AccountStateRepository { + fun getState(): AccountState + + fun setState(accountState: AccountState) + + fun setEmailAddress(emailAddress: String) + + fun setIncomingServerSettings(serverSettings: ServerSettings) + + fun setOutgoingServerSettings(serverSettings: ServerSettings) + + fun setAuthorizationState(authorizationState: AuthorizationState) + + fun setSpecialFolderSettings(specialFolderSettings: SpecialFolderSettings) + + fun setDisplayOptions(displayOptions: AccountDisplayOptions) + + fun setSyncOptions(syncOptions: AccountSyncOptions) + + fun clear() + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/Account.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/Account.kt new file mode 100644 index 0000000..a0a6cb8 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/Account.kt @@ -0,0 +1,13 @@ +package app.k9mail.feature.account.common.domain.entity + +import com.fsck.k9.mail.ServerSettings + +data class Account( + val uuid: String, + val emailAddress: String, + val incomingServerSettings: ServerSettings, + val outgoingServerSettings: ServerSettings, + val authorizationState: String?, + val specialFolderSettings: SpecialFolderSettings?, + val options: AccountOptions, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountDisplayOptions.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountDisplayOptions.kt new file mode 100644 index 0000000..7384e33 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountDisplayOptions.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.account.common.domain.entity + +data class AccountDisplayOptions( + val accountName: String, + val displayName: String, + val emailSignature: String?, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountOptions.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountOptions.kt new file mode 100644 index 0000000..21d8ffb --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountOptions.kt @@ -0,0 +1,10 @@ +package app.k9mail.feature.account.common.domain.entity + +data class AccountOptions( + val accountName: String, + val displayName: String, + val emailSignature: String?, + val checkFrequencyInMinutes: Int, + val messageDisplayCount: Int, + val showNotification: Boolean, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountState.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountState.kt new file mode 100644 index 0000000..90ae5ce --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountState.kt @@ -0,0 +1,14 @@ +package app.k9mail.feature.account.common.domain.entity + +import com.fsck.k9.mail.ServerSettings + +data class AccountState( + val uuid: String? = null, + val emailAddress: String? = null, + val incomingServerSettings: ServerSettings? = null, + val outgoingServerSettings: ServerSettings? = null, + val authorizationState: AuthorizationState? = null, + val specialFolderSettings: SpecialFolderSettings? = null, + val displayOptions: AccountDisplayOptions? = null, + val syncOptions: AccountSyncOptions? = null, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountSyncOptions.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountSyncOptions.kt new file mode 100644 index 0000000..6d79a4d --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AccountSyncOptions.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.account.common.domain.entity + +data class AccountSyncOptions( + val checkFrequencyInMinutes: Int, + val messageDisplayCount: Int, + val showNotification: Boolean, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AuthenticationType.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AuthenticationType.kt new file mode 100644 index 0000000..5fd2a8d --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AuthenticationType.kt @@ -0,0 +1,58 @@ +package app.k9mail.feature.account.common.domain.entity + +import com.fsck.k9.mail.AuthType +import kotlinx.collections.immutable.toImmutableList + +enum class AuthenticationType( + val isUsernameRequired: Boolean, + val isPasswordRequired: Boolean, +) { + None( + isUsernameRequired = false, + isPasswordRequired = false, + ), + PasswordCleartext( + isUsernameRequired = true, + isPasswordRequired = true, + ), + PasswordEncrypted( + isUsernameRequired = true, + isPasswordRequired = true, + ), + ClientCertificate( + isUsernameRequired = true, + isPasswordRequired = false, + ), + OAuth2( + isUsernameRequired = true, + isPasswordRequired = false, + ), + ; + + companion object { + val DEFAULT = PasswordCleartext + fun all() = entries.toImmutableList() + + fun outgoing() = all() + } +} + +fun AuthenticationType.toAuthType(): AuthType { + return when (this) { + AuthenticationType.None -> AuthType.NONE + AuthenticationType.PasswordCleartext -> AuthType.PLAIN + AuthenticationType.PasswordEncrypted -> AuthType.CRAM_MD5 + AuthenticationType.ClientCertificate -> AuthType.EXTERNAL + AuthenticationType.OAuth2 -> AuthType.XOAUTH2 + } +} + +fun AuthType.toAuthenticationType(): AuthenticationType { + return when (this) { + AuthType.PLAIN -> AuthenticationType.PasswordCleartext + AuthType.CRAM_MD5 -> AuthenticationType.PasswordEncrypted + AuthType.EXTERNAL -> AuthenticationType.ClientCertificate + AuthType.XOAUTH2 -> AuthenticationType.OAuth2 + AuthType.NONE -> AuthenticationType.None + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AuthorizationState.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AuthorizationState.kt new file mode 100644 index 0000000..74e4101 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/AuthorizationState.kt @@ -0,0 +1,5 @@ +package app.k9mail.feature.account.common.domain.entity + +data class AuthorizationState( + val value: String? = null, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/ConnectionSecurity.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/ConnectionSecurity.kt new file mode 100644 index 0000000..609b7c2 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/ConnectionSecurity.kt @@ -0,0 +1,61 @@ +package app.k9mail.feature.account.common.domain.entity + +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity.None +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity.StartTLS +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity.TLS +import kotlinx.collections.immutable.toImmutableList + +enum class ConnectionSecurity { + None, + StartTLS, + TLS, + ; + + companion object { + val DEFAULT = TLS + fun all() = entries.toImmutableList() + } +} + +fun ConnectionSecurity.toMailConnectionSecurity(): MailConnectionSecurity { + return when (this) { + None -> MailConnectionSecurity.NONE + StartTLS -> MailConnectionSecurity.STARTTLS_REQUIRED + TLS -> MailConnectionSecurity.SSL_TLS_REQUIRED + } +} + +fun MailConnectionSecurity.toConnectionSecurity(): ConnectionSecurity { + return when (this) { + MailConnectionSecurity.NONE -> None + MailConnectionSecurity.STARTTLS_REQUIRED -> StartTLS + MailConnectionSecurity.SSL_TLS_REQUIRED -> TLS + } +} + +@Suppress("MagicNumber") +fun ConnectionSecurity.toSmtpDefaultPort(): Long { + return when (this) { + None -> 587 + StartTLS -> 587 + TLS -> 465 + } +} + +@Suppress("MagicNumber") +fun ConnectionSecurity.toImapDefaultPort(): Long { + return when (this) { + None -> 143 + StartTLS -> 143 + TLS -> 993 + } +} + +@Suppress("MagicNumber") +fun ConnectionSecurity.toPop3DefaultPort(): Long { + return when (this) { + None -> 110 + StartTLS -> 110 + TLS -> 995 + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/IncomingProtocolType.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/IncomingProtocolType.kt new file mode 100644 index 0000000..910e295 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/IncomingProtocolType.kt @@ -0,0 +1,29 @@ +package app.k9mail.feature.account.common.domain.entity + +import kotlinx.collections.immutable.toImmutableList + +enum class IncomingProtocolType( + val defaultName: String, + val defaultConnectionSecurity: ConnectionSecurity, +) { + IMAP("imap", ConnectionSecurity.TLS), + POP3("pop3", ConnectionSecurity.TLS), + ; + + companion object { + val DEFAULT = IMAP + + fun all() = entries.toImmutableList() + + fun fromName(name: String): IncomingProtocolType { + return entries.find { it.defaultName == name } ?: throw IllegalArgumentException("Unknown protocol: $name") + } + } +} + +fun IncomingProtocolType.toDefaultPort(connectionSecurity: ConnectionSecurity): Long { + return when (this) { + IncomingProtocolType.IMAP -> connectionSecurity.toImapDefaultPort() + IncomingProtocolType.POP3 -> connectionSecurity.toPop3DefaultPort() + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/InteractionMode.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/InteractionMode.kt new file mode 100644 index 0000000..c4ffe81 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/InteractionMode.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.account.common.domain.entity + +/** + * Enum representing the mode a user is interacting with an account or setting. + */ +enum class InteractionMode { + Create, + Edit, +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/MailConnectionSecurity.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/MailConnectionSecurity.kt new file mode 100644 index 0000000..2f01172 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/MailConnectionSecurity.kt @@ -0,0 +1,3 @@ +package app.k9mail.feature.account.common.domain.entity + +typealias MailConnectionSecurity = com.fsck.k9.mail.ConnectionSecurity diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/OutgoingProtocolType.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/OutgoingProtocolType.kt new file mode 100644 index 0000000..55b32ac --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/OutgoingProtocolType.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.common.domain.entity + +import kotlinx.collections.immutable.toImmutableList + +enum class OutgoingProtocolType( + val defaultName: String, + val defaultConnectionSecurity: ConnectionSecurity, +) { + SMTP("smtp", ConnectionSecurity.TLS), + ; + + companion object { + val DEFAULT = SMTP + + fun all() = entries.toImmutableList() + } +} + +fun OutgoingProtocolType.toDefaultPort(connectionSecurity: ConnectionSecurity): Long { + return when (this) { + OutgoingProtocolType.SMTP -> connectionSecurity.toSmtpDefaultPort() + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderOption.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderOption.kt new file mode 100644 index 0000000..babe662 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderOption.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.common.domain.entity + +import com.fsck.k9.mail.folders.RemoteFolder + +sealed interface SpecialFolderOption { + data class None( + val isAutomatic: Boolean = false, + ) : SpecialFolderOption + + data class Regular( + val remoteFolder: RemoteFolder, + ) : SpecialFolderOption + + data class Special( + val isAutomatic: Boolean = false, + val remoteFolder: RemoteFolder, + ) : SpecialFolderOption +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderOptions.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderOptions.kt new file mode 100644 index 0000000..f2d2ffd --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderOptions.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.account.common.domain.entity + +data class SpecialFolderOptions( + val archiveSpecialFolderOptions: List, + val draftsSpecialFolderOptions: List, + val sentSpecialFolderOptions: List, + val spamSpecialFolderOptions: List, + val trashSpecialFolderOptions: List, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderSettings.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderSettings.kt new file mode 100644 index 0000000..a721ec1 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/entity/SpecialFolderSettings.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.account.common.domain.entity + +data class SpecialFolderSettings( + val archiveSpecialFolderOption: SpecialFolderOption, + val draftsSpecialFolderOption: SpecialFolderOption, + val sentSpecialFolderOption: SpecialFolderOption, + val spamSpecialFolderOption: SpecialFolderOption, + val trashSpecialFolderOption: SpecialFolderOption, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/BooleanInputField.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/BooleanInputField.kt new file mode 100644 index 0000000..a81fb9c --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/BooleanInputField.kt @@ -0,0 +1,74 @@ +package app.k9mail.feature.account.common.domain.input + +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class BooleanInputField( + override val value: Boolean? = null, + override val error: ValidationError? = null, + override val isValid: Boolean = false, +) : InputField { + override fun updateValue(value: Boolean?): BooleanInputField { + return BooleanInputField( + value = value, + error = null, + isValid = false, + ) + } + + override fun updateError(error: ValidationError?): BooleanInputField { + return BooleanInputField( + value = value, + error = error, + isValid = false, + ) + } + + override fun updateValidity(isValid: Boolean): BooleanInputField { + if (isValid == this.isValid) return this + + return BooleanInputField( + value = value, + error = null, + isValid = isValid, + ) + } + + override fun updateFromValidationResult(result: ValidationResult): BooleanInputField { + return when (result) { + is ValidationResult.Success -> BooleanInputField( + value = value, + error = null, + isValid = true, + ) + + is ValidationResult.Failure -> BooleanInputField( + value = value, + error = result.error, + isValid = false, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BooleanInputField + + if (value != other.value) return false + if (error != other.error) return false + return isValid == other.isValid + } + + override fun hashCode(): Int { + var result = value?.hashCode() ?: 0 + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + isValid.hashCode() + return result + } + + override fun toString(): String { + return "BooleanInputField(value=$value, error=$error, isValid=$isValid)" + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/InputField.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/InputField.kt new file mode 100644 index 0000000..4e4b104 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/InputField.kt @@ -0,0 +1,48 @@ +package app.k9mail.feature.account.common.domain.input + +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +/** + * InputField is an interface defining the state of an input field. + * + * @param T The type of the value the input field holds. + */ +interface InputField { + val value: T + val error: ValidationError? + val isValid: Boolean + + /** + * Updates the current value of the input field. + * + * @param value The new value to be set for the input field. + * @return a new InputField instance with the updated value. + */ + fun updateValue(value: T): InputField + + /** + * Updates the current error of the input field. + * + * @param error The new error to be set for the input field. + */ + fun updateError(error: ValidationError?): InputField + + /** + * Updates the current validity of the input field. + * + * @param isValid The new validity to be set for the input field. + */ + fun updateValidity(isValid: Boolean): InputField + + /** + * Checks if the input field currently has an error. + * + * @return a Boolean indicating whether the input field has an error. + */ + fun hasError(): Boolean { + return error != null + } + + fun updateFromValidationResult(result: ValidationResult): InputField +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/NumberInputField.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/NumberInputField.kt new file mode 100644 index 0000000..75839c4 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/NumberInputField.kt @@ -0,0 +1,75 @@ +package app.k9mail.feature.account.common.domain.input + +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class NumberInputField( + override val value: Long? = null, + override val error: ValidationError? = null, + override val isValid: Boolean = false, +) : InputField { + + override fun updateValue(value: Long?): NumberInputField { + return NumberInputField( + value = value, + error = null, + isValid = false, + ) + } + + override fun updateError(error: ValidationError?): NumberInputField { + return NumberInputField( + value = value, + error = error, + isValid = false, + ) + } + + override fun updateValidity(isValid: Boolean): NumberInputField { + if (isValid == this.isValid) return this + + return NumberInputField( + value = value, + error = null, + isValid = isValid, + ) + } + + override fun updateFromValidationResult(result: ValidationResult): NumberInputField { + return when (result) { + is ValidationResult.Success -> NumberInputField( + value = value, + error = null, + isValid = true, + ) + + is ValidationResult.Failure -> NumberInputField( + value = value, + error = result.error, + isValid = false, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NumberInputField + + if (value != other.value) return false + if (error != other.error) return false + return isValid == other.isValid + } + + override fun hashCode(): Int { + var result = value?.hashCode() ?: 0 + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + isValid.hashCode() + return result + } + + override fun toString(): String { + return "NumberInputField(value=$value, error=$error, isValid=$isValid)" + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/StringInputField.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/StringInputField.kt new file mode 100644 index 0000000..32c87f7 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/domain/input/StringInputField.kt @@ -0,0 +1,75 @@ +package app.k9mail.feature.account.common.domain.input + +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class StringInputField( + override val value: String = "", + override val error: ValidationError? = null, + override val isValid: Boolean = false, +) : InputField { + + override fun updateValue(value: String): StringInputField { + return StringInputField( + value = value, + error = null, + isValid = false, + ) + } + + override fun updateError(error: ValidationError?): StringInputField { + return StringInputField( + value = value, + error = error, + isValid = false, + ) + } + + override fun updateValidity(isValid: Boolean): StringInputField { + if (isValid == this.isValid) return this + + return StringInputField( + value = value, + error = null, + isValid = isValid, + ) + } + + override fun updateFromValidationResult(result: ValidationResult): StringInputField { + return when (result) { + is ValidationResult.Success -> StringInputField( + value = value, + error = null, + isValid = true, + ) + + is ValidationResult.Failure -> StringInputField( + value = value, + error = result.error, + isValid = false, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StringInputField + + if (value != other.value) return false + if (error != other.error) return false + return isValid == other.isValid + } + + override fun hashCode(): Int { + var result = value.hashCode() + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + isValid.hashCode() + return result + } + + override fun toString(): String { + return "StringInputField(value='$value', error=$error, isValid=$isValid)" + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/AccountTopAppBar.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/AccountTopAppBar.kt new file mode 100644 index 0000000..fc5057b --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/AccountTopAppBar.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBar + +/** + * Top app bar for the account screens. + */ +@Composable +fun AccountTopAppBar( + title: String, + modifier: Modifier = Modifier, +) { + TopAppBar( + title = title, + modifier = modifier, + ) +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/AppTitleTopHeader.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/AppTitleTopHeader.kt new file mode 100644 index 0000000..d4c1a9a --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/AppTitleTopHeader.kt @@ -0,0 +1,58 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.text.TextDisplayMediumAutoResize +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme + +private const val TITLE_ICON_SIZE_DP = 56 + +@Composable +fun AppTitleTopHeader( + title: String, + modifier: Modifier = Modifier, +) { + ResponsiveWidthContainer( + modifier = Modifier + .fillMaxWidth() + .padding( + top = MainTheme.spacings.quadruple, + bottom = MainTheme.spacings.default, + ) + .then(modifier), + ) { contentPadding -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = MainTheme.spacings.half, + end = MainTheme.spacings.quadruple, + ) + .padding(contentPadding) + .then(modifier), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = MainTheme.images.logo), + modifier = Modifier + .padding(all = MainTheme.spacings.default) + .padding(end = MainTheme.spacings.default) + .size(TITLE_ICON_SIZE_DP.dp), + contentDescription = null, + ) + + TextDisplayMediumAutoResize(text = title) + } + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/ContentListView.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/ContentListView.kt new file mode 100644 index 0000000..23f8c24 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/ContentListView.kt @@ -0,0 +1,42 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun ContentListView( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(MainTheme.spacings.default), + items: LazyListScope.() -> Unit, +) { + ResponsiveWidthContainer( + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth() + .then(modifier), + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = contentPadding, + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + ) { + items() + } + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WithInteractionMode.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WithInteractionMode.kt new file mode 100644 index 0000000..f43fb94 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WithInteractionMode.kt @@ -0,0 +1,10 @@ +package app.k9mail.feature.account.common.ui + +import app.k9mail.feature.account.common.domain.entity.InteractionMode + +/** + * Interface for screens that can be used in different interaction modes. + */ +interface WithInteractionMode { + val mode: InteractionMode +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardConstants.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardConstants.kt new file mode 100644 index 0000000..9ce9f8b --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardConstants.kt @@ -0,0 +1,5 @@ +package app.k9mail.feature.account.common.ui + +object WizardConstants { + const val CONTINUE_NEXT_DELAY = 500L +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBar.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBar.kt new file mode 100644 index 0000000..4154a84 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBar.kt @@ -0,0 +1,71 @@ +package app.k9mail.feature.account.common.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonOutlined +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.R +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +fun WizardNavigationBar( + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + nextButtonText: String = stringResource(id = R.string.account_common_button_next), + backButtonText: String = stringResource(id = R.string.account_common_button_back), + state: WizardNavigationBarState = WizardNavigationBarState(), +) { + ResponsiveWidthContainer( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + ) { contentPadding -> + Row( + modifier = Modifier + .padding( + start = MainTheme.spacings.quadruple, + top = MainTheme.spacings.default, + end = MainTheme.spacings.quadruple, + bottom = MainTheme.spacings.double, + ) + .padding(contentPadding) + .fillMaxWidth(), + horizontalArrangement = getHorizontalArrangement(state), + ) { + if (state.showBack) { + ButtonOutlined( + text = backButtonText, + onClick = onBackClick, + enabled = state.isBackEnabled, + modifier = Modifier.testTagAsResourceId("account_setup_back_button"), + ) + } + if (state.showNext) { + ButtonFilled( + text = nextButtonText, + onClick = onNextClick, + enabled = state.isNextEnabled, + modifier = Modifier.testTagAsResourceId("account_setup_next_button"), + ) + } + } + } +} + +private fun getHorizontalArrangement(state: WizardNavigationBarState): Arrangement.Horizontal { + return if (state.showNext && state.showBack) { + Arrangement.SpaceBetween + } else if (state.showNext) { + Arrangement.End + } else { + Arrangement.Start + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBarState.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBarState.kt new file mode 100644 index 0000000..276a123 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/WizardNavigationBarState.kt @@ -0,0 +1,8 @@ +package app.k9mail.feature.account.common.ui + +data class WizardNavigationBarState( + val isNextEnabled: Boolean = true, + val showNext: Boolean = true, + val isBackEnabled: Boolean = true, + val showBack: Boolean = true, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ErrorItem.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ErrorItem.kt new file mode 100644 index 0000000..128f6a8 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ErrorItem.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.common.ui.item + +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView + +@Composable +fun LazyItemScope.ErrorItem( + title: String, + modifier: Modifier = Modifier, + message: String? = null, + onRetry: () -> Unit = { }, +) { + ListItem( + modifier = modifier, + ) { + ErrorView( + title = title, + message = message, + onRetry = onRetry, + ) + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ItemPadding.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ItemPadding.kt new file mode 100644 index 0000000..696b122 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ItemPadding.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.common.ui.item + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +fun defaultHeadlineItemPadding() = PaddingValues( + start = MainTheme.spacings.quadruple, + top = MainTheme.spacings.triple, + end = MainTheme.spacings.quadruple, + bottom = MainTheme.spacings.default, +) + +@Composable +fun defaultItemPadding() = PaddingValues( + horizontal = MainTheme.spacings.quadruple, + vertical = MainTheme.spacings.zero, +) diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ListItem.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ListItem.kt new file mode 100644 index 0000000..6f128dc --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/ListItem.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.common.ui.item + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun LazyItemScope.ListItem( + modifier: Modifier = Modifier, + contentPaddingValues: PaddingValues = defaultItemPadding(), + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .padding(contentPaddingValues) + .animateItem() + .fillMaxWidth() + .then(modifier), + ) { + content() + } +} diff --git a/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/LoadingItem.kt b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/LoadingItem.kt new file mode 100644 index 0000000..9dfa839 --- /dev/null +++ b/feature/account/common/src/main/kotlin/app/k9mail/feature/account/common/ui/item/LoadingItem.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.account.common.ui.item + +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView + +@Composable +fun LazyItemScope.LoadingItem( + modifier: Modifier = Modifier, + message: String? = null, +) { + ListItem( + modifier = modifier, + ) { + LoadingView( + message = message, + ) + } +} diff --git a/feature/account/common/src/main/res/values-am/strings.xml b/feature/account/common/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ar/strings.xml b/feature/account/common/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..ada0fa4 --- /dev/null +++ b/feature/account/common/src/main/res/values-ar/strings.xml @@ -0,0 +1,6 @@ + + + قام الخادم بإرجاع الرسالة التالية:\n%s + التالي + السابق + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ast/strings.xml b/feature/account/common/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-az/strings.xml b/feature/account/common/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..ebf0c07 --- /dev/null +++ b/feature/account/common/src/main/res/values-az/strings.xml @@ -0,0 +1,6 @@ + + + Növbəti + Geri + Server aşağıdakı məlumatı verdi: \n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-be/strings.xml b/feature/account/common/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..b0a34ab --- /dev/null +++ b/feature/account/common/src/main/res/values-be/strings.xml @@ -0,0 +1,6 @@ + + + Далей + Назад + Сервер вярнуў наступнае паведамленне:\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-bg/strings.xml b/feature/account/common/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..16cdb5a --- /dev/null +++ b/feature/account/common/src/main/res/values-bg/strings.xml @@ -0,0 +1,6 @@ + + + Напред + Назад + Сървърът връща следното съобщение\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-bn/strings.xml b/feature/account/common/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..40e5360 --- /dev/null +++ b/feature/account/common/src/main/res/values-bn/strings.xml @@ -0,0 +1,6 @@ + + + পিছনে + সার্ভার এই বার্তাটি পাঠিয়েছে:\n%s + পরবর্তী + diff --git a/feature/account/common/src/main/res/values-br/strings.xml b/feature/account/common/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..24f6a6d --- /dev/null +++ b/feature/account/common/src/main/res/values-br/strings.xml @@ -0,0 +1,5 @@ + + + Da heul + Kent + diff --git a/feature/account/common/src/main/res/values-bs/strings.xml b/feature/account/common/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..3de189b --- /dev/null +++ b/feature/account/common/src/main/res/values-bs/strings.xml @@ -0,0 +1,7 @@ + + + Sljedeći + Nazad + Server je odgovorio ovom porukom: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ca/strings.xml b/feature/account/common/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..b0f855b --- /dev/null +++ b/feature/account/common/src/main/res/values-ca/strings.xml @@ -0,0 +1,7 @@ + + + Següent + Enrere + El servidor ha retornat en següent missatge: +\n%s + diff --git a/feature/account/common/src/main/res/values-co/strings.xml b/feature/account/common/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..0940470 --- /dev/null +++ b/feature/account/common/src/main/res/values-co/strings.xml @@ -0,0 +1,7 @@ + + + Seguente + Ritornu + U servitore hà mandatu quessu messaghju : +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-cs/strings.xml b/feature/account/common/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..1e9021a --- /dev/null +++ b/feature/account/common/src/main/res/values-cs/strings.xml @@ -0,0 +1,7 @@ + + + Další + Zpět + Server vrátil následující zprávu: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-cy/strings.xml b/feature/account/common/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..fe07736 --- /dev/null +++ b/feature/account/common/src/main/res/values-cy/strings.xml @@ -0,0 +1,6 @@ + + + Nesaf + Nôl + Dychwelodd y gweinydd y negae ganlynol:\n%s + diff --git a/feature/account/common/src/main/res/values-da/strings.xml b/feature/account/common/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..b80141a --- /dev/null +++ b/feature/account/common/src/main/res/values-da/strings.xml @@ -0,0 +1,5 @@ + + + Næste + Tilbage + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-de/strings.xml b/feature/account/common/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..c6149dd --- /dev/null +++ b/feature/account/common/src/main/res/values-de/strings.xml @@ -0,0 +1,6 @@ + + + Weiter + Zurück + Der Server hat die folgende Nachricht zurückgegeben:\n%s + diff --git a/feature/account/common/src/main/res/values-el/strings.xml b/feature/account/common/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..64cf6c1 --- /dev/null +++ b/feature/account/common/src/main/res/values-el/strings.xml @@ -0,0 +1,7 @@ + + + Επόμενο + Πίσω + Ο διακομιστής επέστρεψε το ακόλουθο μήνυμα: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-en-rGB/strings.xml b/feature/account/common/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..4db5dea --- /dev/null +++ b/feature/account/common/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,6 @@ + + + Next + Back + The server returned the following message:\n%s + diff --git a/feature/account/common/src/main/res/values-enm/strings.xml b/feature/account/common/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-eo/strings.xml b/feature/account/common/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..da2204b --- /dev/null +++ b/feature/account/common/src/main/res/values-eo/strings.xml @@ -0,0 +1,7 @@ + + + La servilo sendis la jenan mesaĝon: +\n%s + Sekven + Reen + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-es/strings.xml b/feature/account/common/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..259e2f6 --- /dev/null +++ b/feature/account/common/src/main/res/values-es/strings.xml @@ -0,0 +1,7 @@ + + + Siguiente + Atrás + El servidor devolvió el siguiente mensaje: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-et/strings.xml b/feature/account/common/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..4b13ee3 --- /dev/null +++ b/feature/account/common/src/main/res/values-et/strings.xml @@ -0,0 +1,7 @@ + + + Edasi + Tagasi + Serveri vastuses sisaldus selline sõnum: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-eu/strings.xml b/feature/account/common/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..9cb44af --- /dev/null +++ b/feature/account/common/src/main/res/values-eu/strings.xml @@ -0,0 +1,7 @@ + + + Hurrengoa + Aurrekoa + Zerbitzariak mezu hau itzuli du: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-fa/strings.xml b/feature/account/common/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..ddfd7c8 --- /dev/null +++ b/feature/account/common/src/main/res/values-fa/strings.xml @@ -0,0 +1,7 @@ + + + بعدی + بازگشت + کارساز، پیام زیر را برگردانده است: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-fi/strings.xml b/feature/account/common/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..a242c9b --- /dev/null +++ b/feature/account/common/src/main/res/values-fi/strings.xml @@ -0,0 +1,7 @@ + + + Seuraava + Takaisin + Palvelin palautti seuraavan viestin: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-fr/strings.xml b/feature/account/common/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..1ba8581 --- /dev/null +++ b/feature/account/common/src/main/res/values-fr/strings.xml @@ -0,0 +1,6 @@ + + + Suivant + Précédent + Le message suivant a été retourné par le serveur :\n%s + diff --git a/feature/account/common/src/main/res/values-fy/strings.xml b/feature/account/common/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..cd6793e --- /dev/null +++ b/feature/account/common/src/main/res/values-fy/strings.xml @@ -0,0 +1,6 @@ + + + Folgjende + Tebek + De server joech it folgjende berjocht werom:\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ga/strings.xml b/feature/account/common/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..c96147d --- /dev/null +++ b/feature/account/common/src/main/res/values-ga/strings.xml @@ -0,0 +1,6 @@ + + + Ar aghaidh + Ar ais + Chuir an freastalaí an teachtaireacht seo a leanas ar ais:\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-gd/strings.xml b/feature/account/common/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..4bd5a2d --- /dev/null +++ b/feature/account/common/src/main/res/values-gd/strings.xml @@ -0,0 +1,6 @@ + + + Thill am frithealaiche an teachdaireachd a leanas:\n%s + Air adhart + Air ais + diff --git a/feature/account/common/src/main/res/values-gl/strings.xml b/feature/account/common/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..5d5821d --- /dev/null +++ b/feature/account/common/src/main/res/values-gl/strings.xml @@ -0,0 +1,4 @@ + + + seguinte + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-gu/strings.xml b/feature/account/common/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-hi/strings.xml b/feature/account/common/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..70ed7e8 --- /dev/null +++ b/feature/account/common/src/main/res/values-hi/strings.xml @@ -0,0 +1,6 @@ + + + अगला + पिछला + सेवक ने निम्न संदेश लौटायाः\n%s + diff --git a/feature/account/common/src/main/res/values-hr/strings.xml b/feature/account/common/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..cd08021 --- /dev/null +++ b/feature/account/common/src/main/res/values-hr/strings.xml @@ -0,0 +1,6 @@ + + + Sljedeće + Natrag + Poslužitelj je vratio sljedeću poruku:\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-hu/strings.xml b/feature/account/common/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..f5370d8 --- /dev/null +++ b/feature/account/common/src/main/res/values-hu/strings.xml @@ -0,0 +1,7 @@ + + + Következő + Vissza + A kiszolgáló a következő üzenetet küldte vissza: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-hy/strings.xml b/feature/account/common/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-in/strings.xml b/feature/account/common/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..00c2eb2 --- /dev/null +++ b/feature/account/common/src/main/res/values-in/strings.xml @@ -0,0 +1,6 @@ + + + Peladen mengembalikan pesan berikut ini:\n%s + Berikutnya + Kembali + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-is/strings.xml b/feature/account/common/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..c668cdf --- /dev/null +++ b/feature/account/common/src/main/res/values-is/strings.xml @@ -0,0 +1,7 @@ + + + Næsta + Til baka + Póstþjónninn svaraði með eftirfarandi skilaboðum: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-it/strings.xml b/feature/account/common/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..fc80f04 --- /dev/null +++ b/feature/account/common/src/main/res/values-it/strings.xml @@ -0,0 +1,7 @@ + + + Successivo + Precedente + Il server ha restituito il seguente messaggio: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-iw/strings.xml b/feature/account/common/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..585ff6f --- /dev/null +++ b/feature/account/common/src/main/res/values-iw/strings.xml @@ -0,0 +1,7 @@ + + + השרת החזיר את השגיאה הבאה: +\n%s + הבא + הקודם + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ja/strings.xml b/feature/account/common/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..4eff2d9 --- /dev/null +++ b/feature/account/common/src/main/res/values-ja/strings.xml @@ -0,0 +1,7 @@ + + + 次へ + 戻る + サーバーから以下のメッセージが返答されました: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ka/strings.xml b/feature/account/common/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-kab/strings.xml b/feature/account/common/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..56d1768 --- /dev/null +++ b/feature/account/common/src/main/res/values-kab/strings.xml @@ -0,0 +1,5 @@ + + + Uḍfir + Uɣal + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-kk/strings.xml b/feature/account/common/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..3c3fa81 --- /dev/null +++ b/feature/account/common/src/main/res/values-kk/strings.xml @@ -0,0 +1,5 @@ + + + Келесі + Артқа + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ko/strings.xml b/feature/account/common/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..e8afd2e --- /dev/null +++ b/feature/account/common/src/main/res/values-ko/strings.xml @@ -0,0 +1,7 @@ + + + 다음 + 이전 + 서버가 다음의 메시지를 보냈습니다: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-lt/strings.xml b/feature/account/common/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..173a65a --- /dev/null +++ b/feature/account/common/src/main/res/values-lt/strings.xml @@ -0,0 +1,7 @@ + + + Serverio grąžintas pranešimas: +\n%s + Toliau + Atgal + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-lv/strings.xml b/feature/account/common/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ml/strings.xml b/feature/account/common/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-nb-rNO/strings.xml b/feature/account/common/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..cf8dce6 --- /dev/null +++ b/feature/account/common/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,7 @@ + + + Neste + Tilbake + Tjeneren svarte følgende: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-nl/strings.xml b/feature/account/common/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..c28474c --- /dev/null +++ b/feature/account/common/src/main/res/values-nl/strings.xml @@ -0,0 +1,6 @@ + + + Volgende + Terug + De server gaf het volgende bericht terug: \n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-nn/strings.xml b/feature/account/common/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..243cf61 --- /dev/null +++ b/feature/account/common/src/main/res/values-nn/strings.xml @@ -0,0 +1,6 @@ + + + Serveren returnerte følgjande melding:\n%s + Neste + Tilbake + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-pl/strings.xml b/feature/account/common/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..dc4df11 --- /dev/null +++ b/feature/account/common/src/main/res/values-pl/strings.xml @@ -0,0 +1,7 @@ + + + Dalej + Cofnij + Serwer zwrócił następujący komunikat: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-pt-rBR/strings.xml b/feature/account/common/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..97f149d --- /dev/null +++ b/feature/account/common/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,7 @@ + + + Avançar + Voltar + O servidor retornou a seguinte mensagem: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-pt-rPT/strings.xml b/feature/account/common/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..883f2b9 --- /dev/null +++ b/feature/account/common/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,7 @@ + + + Seguinte + Anterior + O servidor devolveu a seguinte mensagem: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-pt/strings.xml b/feature/account/common/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/common/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ro/strings.xml b/feature/account/common/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..88b0852 --- /dev/null +++ b/feature/account/common/src/main/res/values-ro/strings.xml @@ -0,0 +1,7 @@ + + + Înainte + Înapoi + Serverul a returnat următorul mesaj: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ru/strings.xml b/feature/account/common/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..1b780be --- /dev/null +++ b/feature/account/common/src/main/res/values-ru/strings.xml @@ -0,0 +1,7 @@ + + + Далее + Назад + Сервер вернул следующее сообщение: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-sk/strings.xml b/feature/account/common/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..796dfd3 --- /dev/null +++ b/feature/account/common/src/main/res/values-sk/strings.xml @@ -0,0 +1,7 @@ + + + Ďalej + Späť + Server vrátil nasledujúcu správu: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-sl/strings.xml b/feature/account/common/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..79d96b6 --- /dev/null +++ b/feature/account/common/src/main/res/values-sl/strings.xml @@ -0,0 +1,7 @@ + + + Naslednji + Prejšnji + Strežnik je vrnil naslednje sporočilo: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-sq/strings.xml b/feature/account/common/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..30b9f3a --- /dev/null +++ b/feature/account/common/src/main/res/values-sq/strings.xml @@ -0,0 +1,6 @@ + + + Pasuesi + Mbrapsht + Shërbyesi ktheu mesazhin vijues: \n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-sr/strings.xml b/feature/account/common/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..86c55da --- /dev/null +++ b/feature/account/common/src/main/res/values-sr/strings.xml @@ -0,0 +1,7 @@ + + + Сервер је вратио следећу поруку: +\n%s + Следеће + Назад + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-sv/strings.xml b/feature/account/common/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..d85cbbb --- /dev/null +++ b/feature/account/common/src/main/res/values-sv/strings.xml @@ -0,0 +1,7 @@ + + + Nästa + Tillbaka + Servern returnerade följande meddelande: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-sw/strings.xml b/feature/account/common/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/common/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-ta/strings.xml b/feature/account/common/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..af8157c --- /dev/null +++ b/feature/account/common/src/main/res/values-ta/strings.xml @@ -0,0 +1,6 @@ + + + சேவையகம் பின்வரும் செய்தியைத் திருப்பியது:\n %s + அடுத்தது + பின் + diff --git a/feature/account/common/src/main/res/values-th/strings.xml b/feature/account/common/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..86a071e --- /dev/null +++ b/feature/account/common/src/main/res/values-th/strings.xml @@ -0,0 +1,6 @@ + + + ต่อไป + กลับ + เซิร์ฟเวอร์ตอบกลับข้อความต่อไปนี้:\n%s + diff --git a/feature/account/common/src/main/res/values-tr/strings.xml b/feature/account/common/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..0fb4f0a --- /dev/null +++ b/feature/account/common/src/main/res/values-tr/strings.xml @@ -0,0 +1,6 @@ + + + İleri + Geri + Sunucu aşağıdaki iletiyi döndürdü:\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-uk/strings.xml b/feature/account/common/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..eec6b48 --- /dev/null +++ b/feature/account/common/src/main/res/values-uk/strings.xml @@ -0,0 +1,6 @@ + + + Далі + Назад + Сервер повернув таке повідомлення:\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-vi/strings.xml b/feature/account/common/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..56ef786 --- /dev/null +++ b/feature/account/common/src/main/res/values-vi/strings.xml @@ -0,0 +1,7 @@ + + + Kế tiếp + Quay lại + Máy chủ trả lại tin nhắn sau: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-zh-rCN/strings.xml b/feature/account/common/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..97806f0 --- /dev/null +++ b/feature/account/common/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,7 @@ + + + 下一步 + 返回 + 服务器返回了以下消息: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values-zh-rTW/strings.xml b/feature/account/common/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..613d7bd --- /dev/null +++ b/feature/account/common/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,7 @@ + + + 下一步 + 返回 + 伺服器返回了以下訊息: +\n%s + \ No newline at end of file diff --git a/feature/account/common/src/main/res/values/strings.xml b/feature/account/common/src/main/res/values/strings.xml new file mode 100644 index 0000000..25622e2 --- /dev/null +++ b/feature/account/common/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Next + Back + + The server returned the following message:\n%s + diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/data/InMemoryAccountStateRepositoryTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/data/InMemoryAccountStateRepositoryTest.kt new file mode 100644 index 0000000..b8c6a2d --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/data/InMemoryAccountStateRepositoryTest.kt @@ -0,0 +1,193 @@ +package app.k9mail.feature.account.common.data + +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import org.junit.Test + +class InMemoryAccountStateRepositoryTest { + + @Test + fun `should initialize with empty state`() { + val testSubject = InMemoryAccountStateRepository() + + val result = testSubject.getState() + + assertThat(result).isEqualTo( + AccountState( + uuid = null, + emailAddress = null, + incomingServerSettings = null, + outgoingServerSettings = null, + authorizationState = null, + displayOptions = null, + syncOptions = null, + ), + ) + } + + @Test + fun `should set state`() { + val testSubject = InMemoryAccountStateRepository( + AccountState( + uuid = "uuid", + emailAddress = "emailAddress", + incomingServerSettings = INCOMING_SERVER_SETTINGS, + outgoingServerSettings = OUTGOING_SERVER_SETTINGS, + authorizationState = AuthorizationState("authorizationState"), + displayOptions = DISPLAY_OPTIONS, + syncOptions = SYNC_OPTIONS, + ), + ) + val newState = AccountState( + uuid = "uuid2", + emailAddress = "emailAddress2", + incomingServerSettings = INCOMING_SERVER_SETTINGS.copy(host = "imap2.example.org"), + outgoingServerSettings = OUTGOING_SERVER_SETTINGS.copy(host = "smtp2.example.org"), + authorizationState = AuthorizationState("authorizationState2"), + displayOptions = DISPLAY_OPTIONS.copy( + accountName = "accountName2", + displayName = "displayName2", + emailSignature = "emailSignature2", + ), + syncOptions = SYNC_OPTIONS.copy( + checkFrequencyInMinutes = 50, + messageDisplayCount = 60, + showNotification = false, + ), + ) + + testSubject.setState(newState) + + assertThat(testSubject.getState()).isEqualTo(newState) + } + + @Test + fun `should set email address`() { + val testSubject = InMemoryAccountStateRepository() + + testSubject.setEmailAddress("emailAddress") + + assertThat(testSubject.getState().emailAddress) + .isEqualTo("emailAddress") + } + + @Test + fun `should set incoming server settings`() { + val testSubject = InMemoryAccountStateRepository() + + testSubject.setIncomingServerSettings(INCOMING_SERVER_SETTINGS) + + assertThat(testSubject.getState().incomingServerSettings) + .isEqualTo(INCOMING_SERVER_SETTINGS) + } + + @Test + fun `should set outgoing server settings`() { + val testSubject = InMemoryAccountStateRepository() + + testSubject.setOutgoingServerSettings(OUTGOING_SERVER_SETTINGS) + + assertThat(testSubject.getState().outgoingServerSettings) + .isEqualTo(OUTGOING_SERVER_SETTINGS) + } + + @Test + fun `should set authorization state`() { + val testSubject = InMemoryAccountStateRepository() + + testSubject.setAuthorizationState(AuthorizationState("authorizationState")) + + assertThat(testSubject.getState().authorizationState) + .isEqualTo(AuthorizationState("authorizationState")) + } + + @Test + fun `should set display options`() { + val testSubject = InMemoryAccountStateRepository() + + testSubject.setDisplayOptions(DISPLAY_OPTIONS) + + assertThat(testSubject.getState().displayOptions).isEqualTo(DISPLAY_OPTIONS) + } + + @Test + fun `should set sync options`() { + val testSubject = InMemoryAccountStateRepository() + + testSubject.setSyncOptions(SYNC_OPTIONS) + + assertThat(testSubject.getState().syncOptions).isEqualTo(SYNC_OPTIONS) + } + + @Test + fun `should clear state`() { + val testSubject = InMemoryAccountStateRepository( + AccountState( + uuid = "uuid", + emailAddress = "emailAddress", + incomingServerSettings = INCOMING_SERVER_SETTINGS, + outgoingServerSettings = OUTGOING_SERVER_SETTINGS, + authorizationState = AuthorizationState("authorizationState"), + displayOptions = DISPLAY_OPTIONS, + syncOptions = SYNC_OPTIONS, + ), + ) + + testSubject.clear() + + assertThat(testSubject.getState()).isEqualTo( + AccountState( + uuid = null, + emailAddress = null, + incomingServerSettings = null, + outgoingServerSettings = null, + authorizationState = null, + displayOptions = null, + syncOptions = null, + ), + ) + } + + private companion object { + val INCOMING_SERVER_SETTINGS = ServerSettings( + "imap", + "imap.example.org", + 993, + ConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + null, + ) + + val OUTGOING_SERVER_SETTINGS = ServerSettings( + "smtp", + "smtp.example.org", + 465, + ConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + null, + ) + + val DISPLAY_OPTIONS = AccountDisplayOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = "emailSignature", + ) + + val SYNC_OPTIONS = AccountSyncOptions( + checkFrequencyInMinutes = 10, + messageDisplayCount = 20, + showNotification = true, + ) + } +} diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AccountStateTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AccountStateTest.kt new file mode 100644 index 0000000..6c0405f --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AccountStateTest.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.common.domain.entity + +import assertk.all +import assertk.assertThat +import assertk.assertions.isNull +import assertk.assertions.prop +import org.junit.Test + +class AccountStateTest { + + @Test + fun `should default to null state`() { + val accountState = AccountState() + + assertThat(accountState).all { + prop(AccountState::emailAddress).isNull() + prop(AccountState::incomingServerSettings).isNull() + prop(AccountState::outgoingServerSettings).isNull() + prop(AccountState::authorizationState).isNull() + prop(AccountState::specialFolderSettings).isNull() + prop(AccountState::displayOptions).isNull() + prop(AccountState::syncOptions).isNull() + } + } +} diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AuthenticationTypeTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AuthenticationTypeTest.kt new file mode 100644 index 0000000..7b28f9c --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AuthenticationTypeTest.kt @@ -0,0 +1,47 @@ +package app.k9mail.feature.account.common.domain.entity + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import org.junit.Test + +class AuthenticationTypeTest { + + @Test + fun `should map all AuthenticationType to AuthTypes`() { + val types = AuthenticationType.entries + + for (type in types) { + val authType = type.toAuthType() + + assertThat(authType).isEqualTo( + when (type) { + AuthenticationType.PasswordCleartext -> AuthType.PLAIN + AuthenticationType.PasswordEncrypted -> AuthType.CRAM_MD5 + AuthenticationType.OAuth2 -> AuthType.XOAUTH2 + AuthenticationType.ClientCertificate -> AuthType.EXTERNAL + AuthenticationType.None -> AuthType.NONE + }, + ) + } + } + + @Test + fun `should map all AuthTypes to AuthenticationTypes`() { + val types = AuthType.entries + + for (type in types) { + val authenticationType = type.toAuthenticationType() + + assertThat(authenticationType).isEqualTo( + when (type) { + AuthType.PLAIN -> AuthenticationType.PasswordCleartext + AuthType.CRAM_MD5 -> AuthenticationType.PasswordEncrypted + AuthType.EXTERNAL -> AuthenticationType.ClientCertificate + AuthType.XOAUTH2 -> AuthenticationType.OAuth2 + AuthType.NONE -> AuthenticationType.None + }, + ) + } + } +} diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AuthorizationStateTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AuthorizationStateTest.kt new file mode 100644 index 0000000..0ccdc69 --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/AuthorizationStateTest.kt @@ -0,0 +1,15 @@ +package app.k9mail.feature.account.common.domain.entity + +import assertk.assertThat +import assertk.assertions.isNull +import org.junit.Test + +class AuthorizationStateTest { + + @Test + fun `should default to null state`() { + val authorizationState = AuthorizationState() + + assertThat(authorizationState.value).isNull() + } +} diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/ConnectionSecurityTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/ConnectionSecurityTest.kt new file mode 100644 index 0000000..8a80194 --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/ConnectionSecurityTest.kt @@ -0,0 +1,93 @@ +package app.k9mail.feature.account.common.domain.entity + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class ConnectionSecurityTest { + + @Test + fun `should provide right default smtp port`() { + val connectionSecurities = ConnectionSecurity.all() + + for (security in connectionSecurities) { + val port = security.toSmtpDefaultPort() + + assertThat(port).isEqualTo( + when (security) { + ConnectionSecurity.None -> 587L + ConnectionSecurity.StartTLS -> 587L + ConnectionSecurity.TLS -> 465L + }, + ) + } + } + + @Test + fun `should provide right default imap port`() { + val connectionSecurities = ConnectionSecurity.all() + + for (security in connectionSecurities) { + val port = security.toImapDefaultPort() + + assertThat(port).isEqualTo( + when (security) { + ConnectionSecurity.None -> 143L + ConnectionSecurity.StartTLS -> 143L + ConnectionSecurity.TLS -> 993L + }, + ) + } + } + + @Test + fun `should provide right default pop3 port`() { + val connectionSecurities = ConnectionSecurity.all() + + for (security in connectionSecurities) { + val port = security.toPop3DefaultPort() + + assertThat(port).isEqualTo( + when (security) { + ConnectionSecurity.None -> 110L + ConnectionSecurity.StartTLS -> 110L + ConnectionSecurity.TLS -> 995L + }, + ) + } + } + + @Test + fun `should map all MailConnectionSecurities to ConnectionSecurities`() { + val securities = MailConnectionSecurity.entries + + for (security in securities) { + val connectionSecurity = security.toConnectionSecurity() + + assertThat(connectionSecurity).isEqualTo( + when (security) { + MailConnectionSecurity.NONE -> ConnectionSecurity.None + MailConnectionSecurity.STARTTLS_REQUIRED -> ConnectionSecurity.StartTLS + MailConnectionSecurity.SSL_TLS_REQUIRED -> ConnectionSecurity.TLS + }, + ) + } + } + + @Test + fun `should map to all ConnectionSecurities to MailConnectionSecurities`() { + val connectionSecurities = ConnectionSecurity.entries + + for (security in connectionSecurities) { + val mailConnectionSecurity = security.toMailConnectionSecurity() + + assertThat(mailConnectionSecurity).isEqualTo( + when (security) { + ConnectionSecurity.None -> MailConnectionSecurity.NONE + ConnectionSecurity.StartTLS -> MailConnectionSecurity.STARTTLS_REQUIRED + ConnectionSecurity.TLS -> MailConnectionSecurity.SSL_TLS_REQUIRED + }, + ) + } + } +} diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/IncomingProtocolTypeTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/IncomingProtocolTypeTest.kt new file mode 100644 index 0000000..3980ddc --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/IncomingProtocolTypeTest.kt @@ -0,0 +1,56 @@ +package app.k9mail.feature.account.common.domain.entity + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.assertFailsWith +import org.junit.Test + +class IncomingProtocolTypeTest { + + @Test + fun `all should contain all protocol types`() { + val protocolTypes = IncomingProtocolType.all() + + assertThat(protocolTypes).isEqualTo( + IncomingProtocolType.entries, + ) + } + + @Test + fun `fromName should return right protocol type`() { + val protocolType = IncomingProtocolType.fromName("imap") + + assertThat(protocolType).isEqualTo(IncomingProtocolType.IMAP) + } + + @Test + fun `fromName should throw IllegalArgumentException`() { + assertFailsWith { IncomingProtocolType.fromName("unknown") } + } + + @Test + fun `defaultConnectionSecurity should provide right default connection security`() { + val protocolTypeToConnectionSecurity = IncomingProtocolType.all() + .associateWith { it.defaultConnectionSecurity } + + assertThat(protocolTypeToConnectionSecurity).isEqualTo( + mapOf( + IncomingProtocolType.IMAP to ConnectionSecurity.TLS, + IncomingProtocolType.POP3 to ConnectionSecurity.TLS, + ), + ) + } + + @Test + fun `should provide right default port`() { + val protocolTypeToPort = IncomingProtocolType.all() + .associateWith { it.toDefaultPort(it.defaultConnectionSecurity) } + + assertThat(protocolTypeToPort).isEqualTo( + mapOf( + IncomingProtocolType.IMAP to 993L, + IncomingProtocolType.POP3 to 995L, + ), + ) + } +} diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/OutgoingProtocolTypeTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/OutgoingProtocolTypeTest.kt new file mode 100644 index 0000000..a5f71f6 --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/entity/OutgoingProtocolTypeTest.kt @@ -0,0 +1,40 @@ +package app.k9mail.feature.account.common.domain.entity + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class OutgoingProtocolTypeTest { + + @Test + fun `all should contain all protocol types`() { + val protocolTypes = OutgoingProtocolType.all() + + assertThat(protocolTypes).isEqualTo( + OutgoingProtocolType.entries, + ) + } + + @Test + fun `defaultConnectionSecurity should provide right default connection security`() { + val protocolTypeToConnectionSecurity = OutgoingProtocolType.all().associateWith { it.defaultConnectionSecurity } + + assertThat(protocolTypeToConnectionSecurity).isEqualTo( + mapOf( + OutgoingProtocolType.SMTP to ConnectionSecurity.TLS, + ), + ) + } + + @Test + fun `should provide right default port`() { + val protocolTypeToPort = OutgoingProtocolType.all() + .associateWith { it.toDefaultPort(it.defaultConnectionSecurity) } + + assertThat(protocolTypeToPort).isEqualTo( + mapOf( + OutgoingProtocolType.SMTP to 465L, + ), + ) + } +} diff --git a/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/input/InputFieldTest.kt b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/input/InputFieldTest.kt new file mode 100644 index 0000000..db7b11d --- /dev/null +++ b/feature/account/common/src/test/kotlin/app/k9mail/feature/account/common/domain/input/InputFieldTest.kt @@ -0,0 +1,261 @@ +package app.k9mail.feature.account.common.domain.input + +import assertk.Assert +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotSameInstanceAs +import assertk.assertions.isNull +import assertk.assertions.isSameInstanceAs +import assertk.assertions.isTrue +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +data class InputFieldTestData( + val name: String, + val initialState: InputField, + val initialValue: T, + val initialValueEmpty: T, + val initialError: ValidationError?, + val initialIsValid: Boolean, + val createInitialInput: (value: T, error: ValidationError?, isValid: Boolean) -> InputField, + val updatedValue: T, +) + +@RunWith(Parameterized::class) +class InputFieldTest( + private val data: InputFieldTestData, +) { + + @Test + fun `should set default values`() { + assertThat(data.initialState).all { + hasValue(data.initialValueEmpty) + hasNoError() + isNotValid() + } + } + + @Test + fun `should reset error and isValid when value changed`() { + val initialInput = data.createInitialInput( + data.initialValue, + TestValidationError, + true, + ) + + val result = initialInput.updateValue(data.updatedValue) + + assertThat(result).all { + isNotSameInstanceAs(initialInput) + hasValue(data.updatedValue) + hasNoError() + isNotValid() + } + } + + @Test + fun `should reset isValid when error set`() { + val initialInput = data.createInitialInput( + data.initialValue, + null, + true, + ) + + val result = initialInput.updateError(TestValidationError) + + assertThat(result).all { + isNotSameInstanceAs(initialInput) + hasValue(data.initialValue) + hasError(TestValidationError) + isNotValid() + } + } + + @Test + fun `should reset error when valid`() { + val initialInput = data.createInitialInput( + data.initialValue, + TestValidationError, + false, + ) + + val result = initialInput.updateValidity(isValid = true) + + assertThat(result).all { + isNotSameInstanceAs(initialInput) + hasValue(data.initialValue) + hasNoError() + isValid() + } + } + + @Test + fun `should not reset error when invalid`() { + val initialInput = data.createInitialInput( + data.initialValue, + TestValidationError, + false, + ) + + val result = initialInput.updateValidity(isValid = false) + + assertThat(result).all { + isSameInstanceAs(initialInput) + hasValue(data.initialValue) + hasError(TestValidationError) + isNotValid() + } + } + + @Test + fun `should change error when error changed`() { + val initialInput = data.createInitialInput( + data.initialValue, + TestValidationError, + false, + ) + + val result = initialInput.updateError(TestValidationError2) + + assertThat(result).all { + isNotSameInstanceAs(initialInput) + hasValue(data.initialValue) + hasError(TestValidationError2) + isNotValid() + } + } + + @Test + fun `should map from success ValidationResult`() { + val initialInput = data.createInitialInput( + data.initialValue, + TestValidationError, + false, + ) + + val result = initialInput.updateFromValidationResult(ValidationResult.Success) + + assertThat(result).all { + isNotSameInstanceAs(initialInput) + hasValue(data.initialValue) + hasNoError() + isValid() + } + } + + @Test + fun `should map from failure ValidationResult`() { + val initialInput = data.createInitialInput( + data.initialValue, + null, + true, + ) + + val result = initialInput.updateFromValidationResult(ValidationResult.Failure(TestValidationError)) + + assertThat(result).all { + isNotSameInstanceAs(initialInput) + hasValue(data.initialValue) + hasError(TestValidationError) + isNotValid() + } + } + + @Test + fun `should decide equality on properties`() { + val input1 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + val input2 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + + assertThat(input1.equals(input2)).isTrue() + } + + @Test + fun `should have same hashCode`() { + val input1 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + val input2 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + + assertThat(input1.hashCode()).isEqualTo(input2.hashCode()) + } + + private fun Assert>.hasValue(value: Any) { + prop("value") { InputField<*>::value.call(it) }.isEqualTo(value) + } + + private fun Assert>.hasError(error: ValidationError) { + prop("error") { InputField<*>::error.call(it) }.isEqualTo(error) + } + + private fun Assert>.hasNoError() { + prop("error") { InputField<*>::error.call(it) }.isNull() + } + + private fun Assert>.isValid() { + prop("isValid") { InputField<*>::isValid.call(it) }.isTrue() + } + + private fun Assert>.isNotValid() { + prop("isValid") { InputField<*>::isValid.call(it) }.isFalse() + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): List> = listOf( + InputFieldTestData( + name = "StringInputField", + createInitialInput = { value, error, isValid -> StringInputField(value, error, isValid) }, + initialState = StringInputField(), + initialValue = "input", + initialValueEmpty = "", + initialError = null, + initialIsValid = false, + updatedValue = "new value", + ), + InputFieldTestData( + name = "NumberInputField", + createInitialInput = { value, error, isValid -> NumberInputField(value, error, isValid) }, + initialState = NumberInputField(), + initialValue = 123L, + initialValueEmpty = null, + initialError = null, + initialIsValid = false, + updatedValue = 456L, + ), + InputFieldTestData( + name = "BooleanInputField", + createInitialInput = { value, error, isValid -> BooleanInputField(value, error, isValid) }, + initialState = BooleanInputField(), + initialValue = true, + initialValueEmpty = null, + initialError = null, + initialIsValid = false, + updatedValue = false, + ), + ) + } + + private object TestValidationError : ValidationError + private object TestValidationError2 : ValidationError +} diff --git a/feature/account/core/build.gradle.kts b/feature/account/core/build.gradle.kts new file mode 100644 index 0000000..3ed1d37 --- /dev/null +++ b/feature/account/core/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.feature.account.api) +} diff --git a/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/AccountCoreExternalContract.kt b/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/AccountCoreExternalContract.kt new file mode 100644 index 0000000..207441f --- /dev/null +++ b/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/AccountCoreExternalContract.kt @@ -0,0 +1,14 @@ +package net.thunderbird.feature.account.core + +import kotlinx.coroutines.flow.Flow +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.profile.AccountProfile + +interface AccountCoreExternalContract { + + interface AccountProfileLocalDataSource { + fun getById(accountId: AccountId): Flow + + suspend fun update(accountProfile: AccountProfile) + } +} diff --git a/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/AccountCoreModule.kt b/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/AccountCoreModule.kt new file mode 100644 index 0000000..62bd95b --- /dev/null +++ b/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/AccountCoreModule.kt @@ -0,0 +1,10 @@ +package net.thunderbird.feature.account.core + +import net.thunderbird.feature.account.core.data.DefaultAccountProfileRepository +import net.thunderbird.feature.account.profile.AccountProfileRepository +import org.koin.core.module.Module +import org.koin.dsl.module + +val featureAccountCoreModule: Module = module { + single { DefaultAccountProfileRepository(get()) } +} diff --git a/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/data/DefaultAccountProfileRepository.kt b/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/data/DefaultAccountProfileRepository.kt new file mode 100644 index 0000000..e4e6734 --- /dev/null +++ b/feature/account/core/src/main/kotlin/net/thunderbird/feature/account/core/data/DefaultAccountProfileRepository.kt @@ -0,0 +1,22 @@ +package net.thunderbird.feature.account.core.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.core.AccountCoreExternalContract.AccountProfileLocalDataSource +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.profile.AccountProfileRepository + +class DefaultAccountProfileRepository( + private val localDataSource: AccountProfileLocalDataSource, +) : AccountProfileRepository { + + override fun getById(accountId: AccountId): Flow { + return localDataSource.getById(accountId) + .distinctUntilChanged() + } + + override suspend fun update(accountProfile: AccountProfile) { + localDataSource.update(accountProfile) + } +} diff --git a/feature/account/edit/build.gradle.kts b/feature/account/edit/build.gradle.kts new file mode 100644 index 0000000..a51deed --- /dev/null +++ b/feature/account/edit/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.account.edit" + resourcePrefix = "account_edit_" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.ui.compose.navigation) + + implementation(projects.mail.common) + + implementation(projects.feature.account.common) + implementation(projects.feature.account.oauth) + implementation(projects.feature.account.server.settings) + implementation(projects.feature.account.server.certificate) + implementation(projects.feature.account.server.validation) + + testImplementation(projects.core.ui.compose.testing) + testImplementation(projects.mail.protocols.imap) +} diff --git a/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContentPreview.kt b/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContentPreview.kt new file mode 100644 index 0000000..b9e49ba --- /dev/null +++ b/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContentPreview.kt @@ -0,0 +1,51 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun SaveServerSettingsContentPreview() { + PreviewWithThemes { + SaveServerSettingsContent( + state = SaveServerSettingsContract.State( + isLoading = false, + error = null, + ), + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SaveServerSettingsContentLoadingPreview() { + PreviewWithThemes { + SaveServerSettingsContent( + state = SaveServerSettingsContract.State( + isLoading = true, + error = null, + ), + + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SaveServerSettingsContentErrorPreview() { + PreviewWithThemes { + SaveServerSettingsContent( + state = SaveServerSettingsContract.State( + isLoading = false, + error = SaveServerSettingsContract.Failure.SaveServerSettingsFailed( + message = "Error", + ), + ), + contentPadding = PaddingValues(), + ) + } +} diff --git a/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreenPreview.kt b/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreenPreview.kt new file mode 100644 index 0000000..c74fd58 --- /dev/null +++ b/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreenPreview.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.edit.ui.server.settings.save.fake.FakeSaveServerSettingsViewModel + +@Composable +@Preview(showBackground = true) +internal fun SaveServerSettingsScreenK9Preview() { + PreviewWithTheme { + SaveServerSettingsScreen( + title = "Incoming server settings", + onNext = {}, + onBack = {}, + viewModel = FakeSaveServerSettingsViewModel( + isIncoming = true, + ), + ) + } +} diff --git a/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/fake/FakeSaveServerSettingsViewModel.kt b/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/fake/FakeSaveServerSettingsViewModel.kt new file mode 100644 index 0000000..fa0a2da --- /dev/null +++ b/feature/account/edit/src/debug/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/fake/FakeSaveServerSettingsViewModel.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save.fake + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Effect +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Event +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.State +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.ViewModel + +class FakeSaveServerSettingsViewModel( + override val isIncoming: Boolean, + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/AccountEditExternalContract.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/AccountEditExternalContract.kt new file mode 100644 index 0000000..43fe979 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/AccountEditExternalContract.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.edit + +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import com.fsck.k9.mail.ServerSettings + +interface AccountEditExternalContract { + + sealed interface AccountUpdaterResult { + data class Success(val accountUuid: String) : AccountUpdaterResult + data class Failure(val error: AccountUpdaterFailure) : AccountUpdaterResult + } + + sealed interface AccountUpdaterFailure { + data class AccountNotFound(val accountUuid: String) : AccountUpdaterFailure + data class UnknownError(val error: Exception) : AccountUpdaterFailure + } + + fun interface AccountServerSettingsUpdater { + suspend fun updateServerSettings( + accountUuid: String, + isIncoming: Boolean, + serverSettings: ServerSettings, + authorizationState: AuthorizationState?, + ): AccountUpdaterResult + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/AccountEditModule.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/AccountEditModule.kt new file mode 100644 index 0000000..1e4df62 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/AccountEditModule.kt @@ -0,0 +1,83 @@ +package app.k9mail.feature.account.edit + +import app.k9mail.feature.account.common.featureAccountCommonModule +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract +import app.k9mail.feature.account.edit.domain.usecase.GetAccountState +import app.k9mail.feature.account.edit.domain.usecase.LoadAccountState +import app.k9mail.feature.account.edit.domain.usecase.SaveServerSettings +import app.k9mail.feature.account.edit.navigation.AccountEditNavigation +import app.k9mail.feature.account.edit.navigation.DefaultAccountEditNavigation +import app.k9mail.feature.account.edit.ui.server.settings.modify.ModifyIncomingServerSettingsViewModel +import app.k9mail.feature.account.edit.ui.server.settings.modify.ModifyOutgoingServerSettingsViewModel +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveIncomingServerSettingsViewModel +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveOutgoingServerSettingsViewModel +import app.k9mail.feature.account.oauth.featureAccountOAuthModule +import app.k9mail.feature.account.server.certificate.featureAccountServerCertificateModule +import app.k9mail.feature.account.server.settings.featureAccountServerSettingsModule +import app.k9mail.feature.account.server.validation.featureAccountServerValidationModule +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val featureAccountEditModule = module { + includes( + featureAccountCommonModule, + featureAccountOAuthModule, + featureAccountServerCertificateModule, + featureAccountServerSettingsModule, + featureAccountServerValidationModule, + ) + + single { DefaultAccountEditNavigation() } + + factory { + LoadAccountState( + accountStateLoader = get(), + accountStateRepository = get(), + ) + } + + factory { + GetAccountState( + accountStateRepository = get(), + ) + } + + factory { + SaveServerSettings( + getAccountState = get(), + serverSettingsUpdater = get(), + ) + } + + viewModel { (accountUuid: String) -> + ModifyIncomingServerSettingsViewModel( + accountUuid = accountUuid, + accountStateLoader = get(), + validator = get(), + accountStateRepository = get(), + ) + } + + viewModel { (accountUuid: String) -> + ModifyOutgoingServerSettingsViewModel( + accountUuid = accountUuid, + accountStateLoader = get(), + validator = get(), + accountStateRepository = get(), + ) + } + + viewModel { (accountUuid: String) -> + SaveIncomingServerSettingsViewModel( + accountUuid = accountUuid, + saveServerSettings = get(), + ) + } + + viewModel { (accountUuid: String) -> + SaveOutgoingServerSettingsViewModel( + accountUuid = accountUuid, + saveServerSettings = get(), + ) + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/AccountEditDomainContract.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/AccountEditDomainContract.kt new file mode 100644 index 0000000..19889e9 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/AccountEditDomainContract.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature.account.edit.domain + +import app.k9mail.feature.account.common.domain.entity.AccountState + +interface AccountEditDomainContract { + + interface UseCase { + + fun interface LoadAccountState { + suspend fun execute(accountUuid: String): AccountState + } + + fun interface GetAccountState { + suspend fun execute(accountUuid: String): AccountState + } + + fun interface SaveServerSettings { + suspend fun execute(accountUuid: String, isIncoming: Boolean) + } + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/GetAccountState.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/GetAccountState.kt new file mode 100644 index 0000000..97e0e1d --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/GetAccountState.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.edit.domain.usecase + +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract.UseCase + +class GetAccountState( + private val accountStateRepository: AccountDomainContract.AccountStateRepository, +) : UseCase.GetAccountState { + override suspend fun execute(accountUuid: String): AccountState { + val accountState = accountStateRepository.getState() + return if (accountState.uuid == accountUuid) { + accountState + } else { + error("Account state for $accountUuid not found") + } + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/LoadAccountState.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/LoadAccountState.kt new file mode 100644 index 0000000..7e02532 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/LoadAccountState.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.edit.domain.usecase + +import app.k9mail.feature.account.common.AccountCommonExternalContract +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract + +class LoadAccountState( + private val accountStateLoader: AccountCommonExternalContract.AccountStateLoader, + private val accountStateRepository: AccountDomainContract.AccountStateRepository, +) : AccountEditDomainContract.UseCase.LoadAccountState { + override suspend fun execute(accountUuid: String): AccountState { + val accountState = accountStateLoader.loadAccountState(accountUuid) + + if (accountState != null) { + accountStateRepository.setState(accountState) + } else { + error("Account state for $accountUuid not found") + } + + return accountState + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/SaveServerSettings.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/SaveServerSettings.kt new file mode 100644 index 0000000..186749f --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/domain/usecase/SaveServerSettings.kt @@ -0,0 +1,48 @@ +package app.k9mail.feature.account.edit.domain.usecase + +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountServerSettingsUpdater +import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountUpdaterResult +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract.UseCase +import com.fsck.k9.mail.ServerSettings + +class SaveServerSettings( + private val getAccountState: UseCase.GetAccountState, + private val serverSettingsUpdater: AccountServerSettingsUpdater, +) : UseCase.SaveServerSettings { + override suspend fun execute(accountUuid: String, isIncoming: Boolean) { + val accountState = getAccountState.execute(accountUuid) + + val serverSettings = accountState.getServerSettings(isIncoming) + val authorizationState = accountState.authorizationState + + if (serverSettings != null) { + updateServerSettings(accountUuid, isIncoming, serverSettings, authorizationState) + } else { + error("Server settings not found") + } + } + + private suspend fun updateServerSettings( + accountUuid: String, + isIncoming: Boolean, + serverSettings: ServerSettings, + authorizationState: AuthorizationState?, + ) { + val result = serverSettingsUpdater.updateServerSettings( + accountUuid = accountUuid, + isIncoming = isIncoming, + serverSettings = serverSettings, + authorizationState = authorizationState, + ) + + if (result is AccountUpdaterResult.Failure) { + error("Server settings update failed") + } + } + + private fun AccountState.getServerSettings(isIncoming: Boolean): ServerSettings? { + return if (isIncoming) incomingServerSettings else outgoingServerSettings + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/AccountEditNavigation.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/AccountEditNavigation.kt new file mode 100644 index 0000000..2a36622 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/AccountEditNavigation.kt @@ -0,0 +1,5 @@ +package app.k9mail.feature.account.edit.navigation + +import app.k9mail.core.ui.compose.navigation.Navigation + +interface AccountEditNavigation : Navigation diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/AccountEditRoute.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/AccountEditRoute.kt new file mode 100644 index 0000000..72d50d5 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/AccountEditRoute.kt @@ -0,0 +1,35 @@ +package app.k9mail.feature.account.edit.navigation + +import app.k9mail.core.ui.compose.navigation.Route +import kotlinx.serialization.Serializable + +sealed interface AccountEditRoute : Route { + + @Serializable + data class IncomingServerSettings( + val accountId: String, + ) : AccountEditRoute { + override val basePath: String = BASE_PATH + + override fun route(): String = "$basePath/$accountId" + + companion object { + const val BASE_PATH = "$ACCOUNT_EDIT_BASE_PATH/incoming" + } + } + + @Serializable + data class OutgoingServerSettings(val accountId: String) : AccountEditRoute { + override val basePath: String = BASE_PATH + + override fun route(): String = "$basePath/$accountId" + + companion object { + const val BASE_PATH = "$ACCOUNT_EDIT_BASE_PATH/outgoing" + } + } + + companion object { + const val ACCOUNT_EDIT_BASE_PATH = "app://account/edit" + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/DefaultAccountEditNavigation.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/DefaultAccountEditNavigation.kt new file mode 100644 index 0000000..a9f4601 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/navigation/DefaultAccountEditNavigation.kt @@ -0,0 +1,42 @@ +package app.k9mail.feature.account.edit.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import app.k9mail.core.ui.compose.navigation.deepLinkComposable +import app.k9mail.feature.account.edit.navigation.AccountEditRoute.IncomingServerSettings +import app.k9mail.feature.account.edit.navigation.AccountEditRoute.OutgoingServerSettings +import app.k9mail.feature.account.edit.ui.server.settings.EditIncomingServerSettingsNavHost +import app.k9mail.feature.account.edit.ui.server.settings.EditOutgoingServerSettingsNavHost + +class DefaultAccountEditNavigation : AccountEditNavigation { + + override fun registerRoutes( + navGraphBuilder: NavGraphBuilder, + onBack: () -> Unit, + onFinish: (AccountEditRoute) -> Unit, + ) = with(navGraphBuilder) { + deepLinkComposable( + basePath = IncomingServerSettings.BASE_PATH, + ) { backStackEntry -> + val incomingServerSettingsRoute = backStackEntry.toRoute() + + EditIncomingServerSettingsNavHost( + accountUuid = incomingServerSettingsRoute.accountId, + onBack = onBack, + onFinish = onFinish, + ) + } + + deepLinkComposable( + basePath = OutgoingServerSettings.BASE_PATH, + ) { backStackEntry -> + val outgoingServerSettingsRoute = backStackEntry.toRoute() + + EditOutgoingServerSettingsNavHost( + accountUuid = outgoingServerSettingsRoute.accountId, + onBack = onBack, + onFinish = onFinish, + ) + } + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/EditIncomingServerSettingsNavHost.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/EditIncomingServerSettingsNavHost.kt new file mode 100644 index 0000000..377823a --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/EditIncomingServerSettingsNavHost.kt @@ -0,0 +1,76 @@ +package app.k9mail.feature.account.edit.ui.server.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import app.k9mail.feature.account.edit.navigation.AccountEditRoute +import app.k9mail.feature.account.edit.ui.server.settings.modify.ModifyIncomingServerSettingsViewModel +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveIncomingServerSettingsViewModel +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsScreen +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsScreen +import app.k9mail.feature.account.server.validation.ui.IncomingServerValidationViewModel +import app.k9mail.feature.account.server.validation.ui.ServerValidationScreen +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf + +private const val NESTED_NAVIGATION_ROUTE_MODIFY = "modify" +private const val NESTED_NAVIGATION_ROUTE_VALIDATE = "validate" +private const val NESTED_NAVIGATION_ROUTE_SAVE = "save" + +private fun NavController.navigateToValidate() { + navigate(NESTED_NAVIGATION_ROUTE_VALIDATE) +} + +private fun NavController.navigateToSave() { + navigate(NESTED_NAVIGATION_ROUTE_SAVE) +} + +@Composable +fun EditIncomingServerSettingsNavHost( + accountUuid: String, + onFinish: (AccountEditRoute) -> Unit, + onBack: () -> Unit, +) { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = NESTED_NAVIGATION_ROUTE_MODIFY, + ) { + composable(route = NESTED_NAVIGATION_ROUTE_MODIFY) { + IncomingServerSettingsScreen( + onBack = onBack, + onNext = { navController.navigateToValidate() }, + viewModel = koinViewModel { + parametersOf(accountUuid) + }, + ) + } + composable(route = NESTED_NAVIGATION_ROUTE_VALIDATE) { + ServerValidationScreen( + title = stringResource(id = R.string.account_server_settings_incoming_top_bar_title), + onBack = { navController.popBackStack() }, + onNext = { navController.navigateToSave() }, + viewModel = koinViewModel { + parametersOf(accountUuid) + }, + brandNameProvider = koinInject(), + ) + } + composable(route = NESTED_NAVIGATION_ROUTE_SAVE) { + SaveServerSettingsScreen( + title = stringResource(id = R.string.account_server_settings_incoming_top_bar_title), + onNext = { onFinish(AccountEditRoute.IncomingServerSettings(accountUuid)) }, + onBack = { navController.popBackStack(route = NESTED_NAVIGATION_ROUTE_MODIFY, inclusive = false) }, + viewModel = koinViewModel { + parametersOf(accountUuid) + }, + ) + } + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/EditOutgoingServerSettingsNavHost.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/EditOutgoingServerSettingsNavHost.kt new file mode 100644 index 0000000..df7a154 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/EditOutgoingServerSettingsNavHost.kt @@ -0,0 +1,76 @@ +package app.k9mail.feature.account.edit.ui.server.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import app.k9mail.feature.account.edit.navigation.AccountEditRoute +import app.k9mail.feature.account.edit.ui.server.settings.modify.ModifyOutgoingServerSettingsViewModel +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveOutgoingServerSettingsViewModel +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsScreen +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsScreen +import app.k9mail.feature.account.server.validation.ui.OutgoingServerValidationViewModel +import app.k9mail.feature.account.server.validation.ui.ServerValidationScreen +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf + +private const val NESTED_NAVIGATION_ROUTE_MODIFY = "modify" +private const val NESTED_NAVIGATION_ROUTE_VALIDATE = "validate" +private const val NESTED_NAVIGATION_ROUTE_SAVE = "save" + +private fun NavController.navigateToValidate() { + navigate(NESTED_NAVIGATION_ROUTE_VALIDATE) +} + +private fun NavController.navigateToSave() { + navigate(NESTED_NAVIGATION_ROUTE_SAVE) +} + +@Composable +fun EditOutgoingServerSettingsNavHost( + accountUuid: String, + onFinish: (AccountEditRoute) -> Unit, + onBack: () -> Unit, +) { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = NESTED_NAVIGATION_ROUTE_MODIFY, + ) { + composable(route = NESTED_NAVIGATION_ROUTE_MODIFY) { + OutgoingServerSettingsScreen( + onBack = onBack, + onNext = { navController.navigateToValidate() }, + viewModel = koinViewModel { + parametersOf(accountUuid) + }, + ) + } + composable(route = NESTED_NAVIGATION_ROUTE_VALIDATE) { + ServerValidationScreen( + title = stringResource(id = R.string.account_server_settings_outgoing_top_bar_title), + onBack = { navController.popBackStack() }, + onNext = { navController.navigateToSave() }, + viewModel = koinViewModel { + parametersOf(accountUuid) + }, + brandNameProvider = koinInject(), + ) + } + composable(route = NESTED_NAVIGATION_ROUTE_SAVE) { + SaveServerSettingsScreen( + title = stringResource(id = R.string.account_server_settings_outgoing_top_bar_title), + onNext = { onFinish(AccountEditRoute.OutgoingServerSettings(accountUuid)) }, + onBack = { navController.popBackStack(route = NESTED_NAVIGATION_ROUTE_MODIFY, inclusive = false) }, + viewModel = koinViewModel { + parametersOf(accountUuid) + }, + ) + } + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyIncomingServerSettingsViewModel.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyIncomingServerSettingsViewModel.kt new file mode 100644 index 0000000..76cdb82 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyIncomingServerSettingsViewModel.kt @@ -0,0 +1,34 @@ +package app.k9mail.feature.account.edit.ui.server.settings.modify + +import androidx.lifecycle.viewModelScope +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsViewModel +import app.k9mail.feature.account.server.settings.ui.incoming.toIncomingServerSettingsState +import kotlinx.coroutines.launch + +class ModifyIncomingServerSettingsViewModel( + val accountUuid: String, + private val accountStateLoader: AccountEditDomainContract.UseCase.LoadAccountState, + validator: IncomingServerSettingsContract.Validator, + accountStateRepository: AccountDomainContract.AccountStateRepository, + initialState: IncomingServerSettingsContract.State = IncomingServerSettingsContract.State(), +) : IncomingServerSettingsViewModel( + mode = InteractionMode.Edit, + validator = validator, + accountStateRepository = accountStateRepository, + initialState = initialState, +) { + + override fun loadAccountState() { + viewModelScope.launch { + val state = accountStateLoader.execute(accountUuid) + + updateState { + state.toIncomingServerSettingsState() + } + } + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyOutgoingServerSettingsViewModel.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyOutgoingServerSettingsViewModel.kt new file mode 100644 index 0000000..0648a22 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyOutgoingServerSettingsViewModel.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.account.edit.ui.server.settings.modify + +import androidx.lifecycle.viewModelScope +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsViewModel +import app.k9mail.feature.account.server.settings.ui.outgoing.toOutgoingServerSettingsState +import kotlinx.coroutines.launch + +class ModifyOutgoingServerSettingsViewModel( + val accountUuid: String, + private val accountStateLoader: AccountEditDomainContract.UseCase.LoadAccountState, + validator: OutgoingServerSettingsContract.Validator, + accountStateRepository: AccountDomainContract.AccountStateRepository, + initialState: OutgoingServerSettingsContract.State = OutgoingServerSettingsContract.State(), +) : OutgoingServerSettingsViewModel( + mode = InteractionMode.Edit, + validator = validator, + accountStateRepository = accountStateRepository, + initialState = initialState, +) { + override fun loadAccountState() { + viewModelScope.launch { + val state = accountStateLoader.execute(accountUuid) + + updateState { + state.toOutgoingServerSettingsState() + } + } + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/BaseSaveServerSettingsViewModel.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/BaseSaveServerSettingsViewModel.kt new file mode 100644 index 0000000..c65b892 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/BaseSaveServerSettingsViewModel.kt @@ -0,0 +1,76 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.ui.WizardConstants +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Effect +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Event +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Failure +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.State +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.ViewModel +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +abstract class BaseSaveServerSettingsViewModel( + val accountUuid: String, + override val isIncoming: Boolean, + private val saveServerSettings: AccountEditDomainContract.UseCase.SaveServerSettings, + initialState: State = State(), +) : BaseViewModel(initialState), + ViewModel { + + override fun event(event: Event) { + when (event) { + Event.SaveServerSettings -> handleOneTimeEvent(event, ::onSaveServerSettings) + Event.OnBackClicked -> navigateBack() + } + } + + @Suppress("TooGenericExceptionCaught") + private fun onSaveServerSettings() { + viewModelScope.launch { + try { + saveServerSettings.execute(accountUuid, isIncoming) + updateSuccess() + } catch (e: Exception) { + updateFailure(Failure.SaveServerSettingsFailed(e.message ?: "Unknown error")) + } + } + } + + private fun updateSuccess() { + updateState { + it.copy( + isLoading = false, + ) + } + + viewModelScope.launch { + delay(WizardConstants.CONTINUE_NEXT_DELAY) + navigateNext() + } + } + + private fun updateFailure(failure: Failure) { + updateState { + it.copy( + error = failure, + isLoading = false, + ) + } + } + + private fun navigateNext() { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateNext) + } + + private fun navigateBack() { + if (state.value.isLoading || state.value.error == null) return + + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateBack) + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveIncomingServerSettingsViewModel.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveIncomingServerSettingsViewModel.kt new file mode 100644 index 0000000..a5649ef --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveIncomingServerSettingsViewModel.kt @@ -0,0 +1,15 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract.UseCase +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.State + +class SaveIncomingServerSettingsViewModel( + accountUuid: String, + saveServerSettings: UseCase.SaveServerSettings, + initialState: State = State(), +) : BaseSaveServerSettingsViewModel( + accountUuid = accountUuid, + isIncoming = true, + saveServerSettings = saveServerSettings, + initialState = initialState, +) diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveOutgoingServerSettingsViewModel.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveOutgoingServerSettingsViewModel.kt new file mode 100644 index 0000000..ba38d2c --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveOutgoingServerSettingsViewModel.kt @@ -0,0 +1,15 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract.UseCase +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.State + +class SaveOutgoingServerSettingsViewModel( + accountUuid: String, + saveServerSettings: UseCase.SaveServerSettings, + initialState: State = State(), +) : BaseSaveServerSettingsViewModel( + accountUuid = accountUuid, + isIncoming = false, + saveServerSettings = saveServerSettings, + initialState = initialState, +) diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContent.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContent.kt new file mode 100644 index 0000000..c0e2c18 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContent.kt @@ -0,0 +1,48 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.ContentLoadingErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.feature.account.edit.R +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +fun SaveServerSettingsContent( + state: SaveServerSettingsContract.State, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + ResponsiveWidthContainer( + modifier = Modifier + .testTagAsResourceId("SaveServerSettingsContent") + .padding(contentPadding) + .then(modifier), + ) { contentPadding -> + ContentLoadingErrorView( + state = state, + loading = { + LoadingView( + message = stringResource(id = R.string.account_edit_save_server_settings_loading_message), + ) + }, + error = { + ErrorView( + title = stringResource(id = R.string.account_edit_save_server_settings_error_message), + ) + }, + content = { + LoadingView( + message = stringResource(id = R.string.account_edit_save_server_settings_success_message), + ) + }, + modifier = Modifier.fillMaxSize().padding(contentPadding), + ) + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContract.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContract.kt new file mode 100644 index 0000000..59ff0a0 --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsContract.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingErrorState + +interface SaveServerSettingsContract { + + interface ViewModel : UnidirectionalViewModel { + val isIncoming: Boolean + } + + data class State( + override val error: Failure? = null, + override val isLoading: Boolean = true, + ) : LoadingErrorState + + sealed interface Event { + data object SaveServerSettings : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + data object NavigateNext : Effect + data object NavigateBack : Effect + } + + sealed interface Failure { + data class SaveServerSettingsFailed( + val message: String, + ) : Failure + } +} diff --git a/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreen.kt b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreen.kt new file mode 100644 index 0000000..50b06ab --- /dev/null +++ b/feature/account/edit/src/main/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreen.kt @@ -0,0 +1,67 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBarWithBackButton +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.common.ui.WizardNavigationBarState +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Effect +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Event +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.ViewModel + +@Composable +fun SaveServerSettingsScreen( + title: String, + onNext: () -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateNext -> onNext() + Effect.NavigateBack -> onBack() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.SaveServerSettings) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + topBar = { + TopAppBarWithBackButton( + title = title, + onBackClick = { + dispatch(Event.OnBackClicked) + }, + ) + }, + bottomBar = { + WizardNavigationBar( + onNextClick = {}, + onBackClick = { + dispatch(Event.OnBackClicked) + }, + state = WizardNavigationBarState( + showNext = false, + isBackEnabled = state.value.error != null, + ), + ) + }, + modifier = modifier, + ) { innerPadding -> + SaveServerSettingsContent( + state = state.value, + contentPadding = innerPadding, + ) + } +} diff --git a/feature/account/edit/src/main/res/values-am/strings.xml b/feature/account/edit/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ar/strings.xml b/feature/account/edit/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..69a3c57 --- /dev/null +++ b/feature/account/edit/src/main/res/values-ar/strings.xml @@ -0,0 +1,6 @@ + + + يتم الآن حفظ إعدادات الخادم… + تعذر حفظ إعدادات الخادم + تم حفظ إعدادات الخادم + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ast/strings.xml b/feature/account/edit/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-az/strings.xml b/feature/account/edit/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-be/strings.xml b/feature/account/edit/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..3523aa3 --- /dev/null +++ b/feature/account/edit/src/main/res/values-be/strings.xml @@ -0,0 +1,6 @@ + + + Захаванне налад сервера… + Не ўдалося захаваць налады сервера + Налады сервера захаваныя + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-bg/strings.xml b/feature/account/edit/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..74362ba --- /dev/null +++ b/feature/account/edit/src/main/res/values-bg/strings.xml @@ -0,0 +1,6 @@ + + + Запазване настройките на сървъра… + Неуспешно запазване на сървърните настройки + Сървърните настройки запазени + diff --git a/feature/account/edit/src/main/res/values-bn/strings.xml b/feature/account/edit/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..30e5222 --- /dev/null +++ b/feature/account/edit/src/main/res/values-bn/strings.xml @@ -0,0 +1,6 @@ + + + সার্ভার পছন্দসমূহ সংরক্ষণ ব্যর্থ হয়েছে + সার্ভার পছন্দসমূহ সংরক্ষিত + সার্ভার পছন্দসমূহ সংরক্ষণ হচ্ছে… + diff --git a/feature/account/edit/src/main/res/values-br/strings.xml b/feature/account/edit/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..3b6ec9a --- /dev/null +++ b/feature/account/edit/src/main/res/values-br/strings.xml @@ -0,0 +1,6 @@ + + + Oc\'h enrollañ arventennoù an dafariad… + Fazi oc\'h enrollañ arventennoù an dafariad + Enrollet eo bet arventennoù an dafariad + diff --git a/feature/account/edit/src/main/res/values-bs/strings.xml b/feature/account/edit/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-bs/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ca/strings.xml b/feature/account/edit/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..04b5607 --- /dev/null +++ b/feature/account/edit/src/main/res/values-ca/strings.xml @@ -0,0 +1,6 @@ + + + S\'està desant la configuració del servidor… + No s\'ha pogut desar la configuració del servidor + S\'ha desat la configuració del servidor + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-co/strings.xml b/feature/account/edit/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..312a532 --- /dev/null +++ b/feature/account/edit/src/main/res/values-co/strings.xml @@ -0,0 +1,6 @@ + + + Arregistramentu di i parametri di u servitore… + Fiascu à l’arregistramentu di i parametri di u servitore + Parametri di u servitore arregistrati + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-cs/strings.xml b/feature/account/edit/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..497e4ea --- /dev/null +++ b/feature/account/edit/src/main/res/values-cs/strings.xml @@ -0,0 +1,6 @@ + + + Nepodařilo se uložit nastavení serveru + Ukládání nastavení serveru… + Nastavení serveru uložena + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-cy/strings.xml b/feature/account/edit/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..c58cacc --- /dev/null +++ b/feature/account/edit/src/main/res/values-cy/strings.xml @@ -0,0 +1,6 @@ + + + Yn cadw gosodiadau gweinydd… + Wedi methu cadw gosodiadau gweinydd + Gosodiadau gweinydd wedi\'u cadw + diff --git a/feature/account/edit/src/main/res/values-da/strings.xml b/feature/account/edit/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-da/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-de/strings.xml b/feature/account/edit/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..e257ccf --- /dev/null +++ b/feature/account/edit/src/main/res/values-de/strings.xml @@ -0,0 +1,6 @@ + + + Servereinstellungen werden gespeichert… + Servereinstellungen gespeichert + Servereinstellungen konnten nicht gespeichert werden + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-el/strings.xml b/feature/account/edit/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..2a07026 --- /dev/null +++ b/feature/account/edit/src/main/res/values-el/strings.xml @@ -0,0 +1,6 @@ + + + Αποθήκευση ρυθμίσεων διακομιστή… + Αποτυχία αποθήκευσης ρυθμίσεων διακομιστή + Οι ρυθμίσεις διακομιστή αποθηκεύτηκαν + diff --git a/feature/account/edit/src/main/res/values-en-rGB/strings.xml b/feature/account/edit/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..5c0abf6 --- /dev/null +++ b/feature/account/edit/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,6 @@ + + + Saving server settings… + Failed to save server settings + Server settings saved + diff --git a/feature/account/edit/src/main/res/values-enm/strings.xml b/feature/account/edit/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-eo/strings.xml b/feature/account/edit/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..e5aed68 --- /dev/null +++ b/feature/account/edit/src/main/res/values-eo/strings.xml @@ -0,0 +1,6 @@ + + + Konservado de la servilaj agordoj fiaskis + Servilaj agordoj ekkonservitaj + Konservante servilajn agordojn… + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-es/strings.xml b/feature/account/edit/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..a4fab4e --- /dev/null +++ b/feature/account/edit/src/main/res/values-es/strings.xml @@ -0,0 +1,6 @@ + + + Guardando los ajustes del servidor… + Error al guardar la configuración del servidor + Configuración del servidor guardada + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-et/strings.xml b/feature/account/edit/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..628a167 --- /dev/null +++ b/feature/account/edit/src/main/res/values-et/strings.xml @@ -0,0 +1,6 @@ + + + Serveri seadistuste salvestamine ei õnnestunud + Salvestame serveri seadistusi… + Serveri seadistused on salvestatud + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-eu/strings.xml b/feature/account/edit/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..c51a36f --- /dev/null +++ b/feature/account/edit/src/main/res/values-eu/strings.xml @@ -0,0 +1,6 @@ + + + Zerbitzariaren ezarpenak gordetzen… + Akatsa zerbitzariaren ezarpenak gordetzean + Zerbitzariaren ezarpenak gordeta + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-fa/strings.xml b/feature/account/edit/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..3e2fa75 --- /dev/null +++ b/feature/account/edit/src/main/res/values-fa/strings.xml @@ -0,0 +1,6 @@ + + + ذخیره کردن تنظیمات کارساز… + شکست در ذخیرهٔ تنظیمات کارساز + تنظیمات کارساز ذخیره شد + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-fi/strings.xml b/feature/account/edit/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..00c671c --- /dev/null +++ b/feature/account/edit/src/main/res/values-fi/strings.xml @@ -0,0 +1,6 @@ + + + Tallennetaan palvelimen asetuksia… + Palvelimen asetusten tallentaminen epäonnistui + Palvelimen asetukset tallennettu + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-fr/strings.xml b/feature/account/edit/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..0593846 --- /dev/null +++ b/feature/account/edit/src/main/res/values-fr/strings.xml @@ -0,0 +1,6 @@ + + + Enregistrement des paramètres du serveur… + Échec d’enregistrement des paramètres du serveur + Les paramètres du serveur ont été enregistrés + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-fy/strings.xml b/feature/account/edit/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..feb66cc --- /dev/null +++ b/feature/account/edit/src/main/res/values-fy/strings.xml @@ -0,0 +1,6 @@ + + + Serverynstellingen bewarje… + Serverynstellingen net bewarre + Serverynstellingen bewarre + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ga/strings.xml b/feature/account/edit/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..4208209 --- /dev/null +++ b/feature/account/edit/src/main/res/values-ga/strings.xml @@ -0,0 +1,6 @@ + + + Socruithe freastalaí á sábháil… + Theip ar shábháil socruithe an fhreastalaí + Sábháladh socruithe an fhreastalaí + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-gd/strings.xml b/feature/account/edit/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..a680a2a --- /dev/null +++ b/feature/account/edit/src/main/res/values-gd/strings.xml @@ -0,0 +1,6 @@ + + + Cha b’ urrainn dhuinn roghainnean an fhrithealaiche a shàbhaladh + A’ sàbhaladh roghainnean an fhrithealaiche… + Chaidh roghainnean an fhrithealaiche a shàbhaladh + diff --git a/feature/account/edit/src/main/res/values-gl/strings.xml b/feature/account/edit/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-gl/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-gu/strings.xml b/feature/account/edit/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-hi/strings.xml b/feature/account/edit/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-hi/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-hr/strings.xml b/feature/account/edit/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..1a730c9 --- /dev/null +++ b/feature/account/edit/src/main/res/values-hr/strings.xml @@ -0,0 +1,6 @@ + + + Nije uspjelo spremanje postavki poslužitelja + Postavke poslužitelja spremljene + Spremanje postavki poslužitelja… + diff --git a/feature/account/edit/src/main/res/values-hu/strings.xml b/feature/account/edit/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..36285db --- /dev/null +++ b/feature/account/edit/src/main/res/values-hu/strings.xml @@ -0,0 +1,6 @@ + + + Kiszolgáló beállítások mentése… + Nem sikerült menteni a kiszolgáló beállításait + Kiszolgáló beállítások mentése + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-hy/strings.xml b/feature/account/edit/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-in/strings.xml b/feature/account/edit/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..d947072 --- /dev/null +++ b/feature/account/edit/src/main/res/values-in/strings.xml @@ -0,0 +1,6 @@ + + + Menyimpan pengaturan peladen… + Gagal menyimpan pengaturan peladen + Pengaturan peladen disimpan + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-is/strings.xml b/feature/account/edit/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..a05be52 --- /dev/null +++ b/feature/account/edit/src/main/res/values-is/strings.xml @@ -0,0 +1,6 @@ + + + Vista stillingar póstþjóns… + Mistókst að vista stillingar póstþjóns + Stillingar póstþjóns vistaðar + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-it/strings.xml b/feature/account/edit/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..f7707ba --- /dev/null +++ b/feature/account/edit/src/main/res/values-it/strings.xml @@ -0,0 +1,6 @@ + + + Salvataggio impostazioni server… + Salvataggio impostazioni server fallito + Impostazioni server salvate + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-iw/strings.xml b/feature/account/edit/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..966889a --- /dev/null +++ b/feature/account/edit/src/main/res/values-iw/strings.xml @@ -0,0 +1,6 @@ + + + שומר הגדרות שרת… + נכשל בשמירת הגדרות השרת + הגדרות שרת נשמרו + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ja/strings.xml b/feature/account/edit/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..02e6244 --- /dev/null +++ b/feature/account/edit/src/main/res/values-ja/strings.xml @@ -0,0 +1,6 @@ + + + サーバー設定の保存中… + サーバー設定を保存できませんでした + サーバー設定を保存しました + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ka/strings.xml b/feature/account/edit/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-kab/strings.xml b/feature/account/edit/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..bd8e66f --- /dev/null +++ b/feature/account/edit/src/main/res/values-kab/strings.xml @@ -0,0 +1,4 @@ + + + Asekles n iɣewwaṛen n useqdac… + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-kk/strings.xml b/feature/account/edit/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..5f85b21 --- /dev/null +++ b/feature/account/edit/src/main/res/values-kk/strings.xml @@ -0,0 +1,5 @@ + + + Сервер баптаулары сақталуда… + Сервер баптаулары сақталды + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ko/strings.xml b/feature/account/edit/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-ko/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-lt/strings.xml b/feature/account/edit/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..e7f4f62 --- /dev/null +++ b/feature/account/edit/src/main/res/values-lt/strings.xml @@ -0,0 +1,6 @@ + + + Įrašyti serverio nustatymai + Įrašoma serverio nustatymai… + Nepavyko įrašyti serverio nustatymų. + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-lv/strings.xml b/feature/account/edit/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ml/strings.xml b/feature/account/edit/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-nb-rNO/strings.xml b/feature/account/edit/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..ed1e9aa --- /dev/null +++ b/feature/account/edit/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,6 @@ + + + Lagrer serverinnstillinger… + Feil ved lagring av tjenerinnstillinger + Tjenerinnstillingene er lagret + diff --git a/feature/account/edit/src/main/res/values-nl/strings.xml b/feature/account/edit/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..0710aa8 --- /dev/null +++ b/feature/account/edit/src/main/res/values-nl/strings.xml @@ -0,0 +1,6 @@ + + + Serverinstellingen opslaan… + Serverinstellingen niet opgeslagen + Serverinstellingen opgeslagen + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-nn/strings.xml b/feature/account/edit/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..1cb8a10 --- /dev/null +++ b/feature/account/edit/src/main/res/values-nn/strings.xml @@ -0,0 +1,6 @@ + + + Lagrar tenarinnstillingar… + Klarte ikkje lagre tenarinnstillingar + Tenarinnstillingar lagra + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-pl/strings.xml b/feature/account/edit/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..5bb668a --- /dev/null +++ b/feature/account/edit/src/main/res/values-pl/strings.xml @@ -0,0 +1,6 @@ + + + Zapisywanie ustawień serwera… + Nie udało się zapisać ustawień serwera + Zapisano ustawienia serwera + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-pt-rBR/strings.xml b/feature/account/edit/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..36059da --- /dev/null +++ b/feature/account/edit/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,6 @@ + + + Salvando configurações do servidor… + Falha ao salvar configurações do servidor + Configurações do servidor salvas + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-pt-rPT/strings.xml b/feature/account/edit/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..d208fba --- /dev/null +++ b/feature/account/edit/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,6 @@ + + + A guardar as configurações do servidor… + Falha ao guardar as definições do servidor + Configurações do servidor guardadas + diff --git a/feature/account/edit/src/main/res/values-pt/strings.xml b/feature/account/edit/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/edit/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ro/strings.xml b/feature/account/edit/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..74ec321 --- /dev/null +++ b/feature/account/edit/src/main/res/values-ro/strings.xml @@ -0,0 +1,6 @@ + + + Salvarea configurației serverului a eșuat + Se salvează configurației serverului… + Configurația serverului a fost salvată + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ru/strings.xml b/feature/account/edit/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..6b1eab1 --- /dev/null +++ b/feature/account/edit/src/main/res/values-ru/strings.xml @@ -0,0 +1,6 @@ + + + Сохранение настроек сервера… + Невозможно сохранить настройки сервера + Настройки сервера сохранены + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-sk/strings.xml b/feature/account/edit/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..668c070 --- /dev/null +++ b/feature/account/edit/src/main/res/values-sk/strings.xml @@ -0,0 +1,6 @@ + + + Ukladám nastavenia servera… + Zlyhalo uloženie nastavení servera + Nastavenia servera boli uložené + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-sl/strings.xml b/feature/account/edit/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..80efa98 --- /dev/null +++ b/feature/account/edit/src/main/res/values-sl/strings.xml @@ -0,0 +1,6 @@ + + + Shranjevanje nastavitev strežnika … + Shranjevanje nastavitev strežnika je spodletelo. + Nastavitve strežnika so bile shranjene. + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-sq/strings.xml b/feature/account/edit/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..c95c0ea --- /dev/null +++ b/feature/account/edit/src/main/res/values-sq/strings.xml @@ -0,0 +1,6 @@ + + + Po ruhen rregullime shërbyesi… + S’u arrit të ruhen rregullime shërbyesi + Rregullimet e shërbyesit u ruajtën + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-sr/strings.xml b/feature/account/edit/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..09269c4 --- /dev/null +++ b/feature/account/edit/src/main/res/values-sr/strings.xml @@ -0,0 +1,6 @@ + + + Чување подешавања сервера… + Неуспешно чување подешавања сервера + Подешавања сервера су сачувана + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-sv/strings.xml b/feature/account/edit/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..b808652 --- /dev/null +++ b/feature/account/edit/src/main/res/values-sv/strings.xml @@ -0,0 +1,6 @@ + + + Sparar serverinställningar… + Det gick inte att spara serverinställningarna + Serverinställningar sparade + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-sw/strings.xml b/feature/account/edit/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/edit/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-ta/strings.xml b/feature/account/edit/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..7e88993 --- /dev/null +++ b/feature/account/edit/src/main/res/values-ta/strings.xml @@ -0,0 +1,6 @@ + + + சேவையக அமைப்புகள் சேமிக்கப்பட்டன + சேவையக அமைப்புகளைச் சேமிக்கிறது… + சேவையக அமைப்புகளை சேமிப்பதில் தோல்வி + diff --git a/feature/account/edit/src/main/res/values-th/strings.xml b/feature/account/edit/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/edit/src/main/res/values-th/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-tr/strings.xml b/feature/account/edit/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..0c976ed --- /dev/null +++ b/feature/account/edit/src/main/res/values-tr/strings.xml @@ -0,0 +1,6 @@ + + + Sunucu ayarları kaydediliyor… + Sunucu ayarları kaydedilemedi + Sunucu ayarları kaydedildi + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-uk/strings.xml b/feature/account/edit/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..12f27ee --- /dev/null +++ b/feature/account/edit/src/main/res/values-uk/strings.xml @@ -0,0 +1,6 @@ + + + Збереження налаштувань сервера… + Не вдалося зберегти налаштування сервера + Налаштування сервера збережено + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-vi/strings.xml b/feature/account/edit/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..0ea7d9f --- /dev/null +++ b/feature/account/edit/src/main/res/values-vi/strings.xml @@ -0,0 +1,6 @@ + + + Đang lưu các cài đặt của máy chủ… + Lưu các cài đặt của máy chủ thất bại + Đã lưu các cài đặt của máy chủ + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-zh-rCN/strings.xml b/feature/account/edit/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..863bb3f --- /dev/null +++ b/feature/account/edit/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,6 @@ + + + 正在保存服务器设置… + 保存服务器设置失败 + 服务器设置已保存 + \ No newline at end of file diff --git a/feature/account/edit/src/main/res/values-zh-rTW/strings.xml b/feature/account/edit/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..a676cb9 --- /dev/null +++ b/feature/account/edit/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,6 @@ + + + 無法儲存伺服器設定 + 已儲存伺服器設定 + 正在儲存伺服器設定… + diff --git a/feature/account/edit/src/main/res/values/strings.xml b/feature/account/edit/src/main/res/values/strings.xml new file mode 100644 index 0000000..5c0abf6 --- /dev/null +++ b/feature/account/edit/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Saving server settings… + Failed to save server settings + Server settings saved + diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/AccountEditModuleKtTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/AccountEditModuleKtTest.kt new file mode 100644 index 0000000..9ca68dd --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/AccountEditModuleKtTest.kt @@ -0,0 +1,34 @@ +package app.k9mail.feature.account.edit + +import android.content.Context +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract +import org.junit.Test +import org.koin.test.KoinTest +import org.koin.test.verify.verify + +class AccountEditModuleKtTest : KoinTest { + + @Test + fun `should have a valid di module`() { + featureAccountEditModule.verify( + extraTypes = listOf( + Context::class, + AccountState::class, + Class.forName("net.openid.appauth.AppAuthConfiguration").kotlin, + ServerValidationContract.State::class, + ServerCertificateErrorContract.State::class, + IncomingServerSettingsContract.State::class, + OutgoingServerSettingsContract.State::class, + SaveServerSettingsContract.State::class, + AccountEditExternalContract.AccountServerSettingsUpdater::class, + InteractionMode::class, + ), + ) + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/GetAccountStateTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/GetAccountStateTest.kt new file mode 100644 index 0000000..e063fac --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/GetAccountStateTest.kt @@ -0,0 +1,104 @@ +package app.k9mail.feature.account.edit.domain.usecase + +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class GetAccountStateTest { + + @Test + fun `should get account state from repository`() = runTest { + val testSubject = GetAccountState( + accountStateRepository = InMemoryAccountStateRepository(state = ACCOUNT_STATE), + ) + + val result = testSubject.execute(ACCOUNT_UUID) + + assertThat(result).isEqualTo(ACCOUNT_STATE) + } + + @Test + fun `should throw exception WHEN account state repository contains state for different account uuid`() = runTest { + val testSubject = GetAccountState( + accountStateRepository = InMemoryAccountStateRepository( + state = ACCOUNT_STATE.copy(uuid = "differentAccountUuid"), + ), + ) + + assertFailure { + testSubject.execute(ACCOUNT_UUID) + }.isInstanceOf() + .hasMessage("Account state for $ACCOUNT_UUID not found") + } + + private companion object { + const val ACCOUNT_UUID = "accountUuid" + const val EMAIL_ADDRESS = "test@example.com" + val INCOMING_SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + val OUTGOING_SERVER_SETTINGS = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val AUTHORIZATION_STATE = AuthorizationState("authorization state") + + val OPTIONS = AccountOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = null, + checkFrequencyInMinutes = 15, + messageDisplayCount = 25, + showNotification = true, + ) + + val DISPLAY_OPTIONS = AccountDisplayOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = null, + ) + + val SYNC_OPTIONS = AccountSyncOptions( + checkFrequencyInMinutes = 15, + messageDisplayCount = 25, + showNotification = true, + ) + + val ACCOUNT_STATE = AccountState( + uuid = ACCOUNT_UUID, + emailAddress = EMAIL_ADDRESS, + incomingServerSettings = INCOMING_SERVER_SETTINGS, + outgoingServerSettings = OUTGOING_SERVER_SETTINGS, + authorizationState = AUTHORIZATION_STATE, + displayOptions = DISPLAY_OPTIONS, + syncOptions = SYNC_OPTIONS, + ) + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/LoadAccountStateTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/LoadAccountStateTest.kt new file mode 100644 index 0000000..77a7786 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/LoadAccountStateTest.kt @@ -0,0 +1,98 @@ +package app.k9mail.feature.account.edit.domain.usecase + +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoadAccountStateTest { + + @Test + fun `should load account state and update account state repository`() = runTest { + val accountStateRepository = InMemoryAccountStateRepository() + val testSubject = LoadAccountState( + accountStateLoader = { _ -> + ACCOUNT_STATE + }, + accountStateRepository = accountStateRepository, + ) + + val result = testSubject.execute(ACCOUNT_UUID) + + assertThat(result).isEqualTo(ACCOUNT_STATE) + assertThat(accountStateRepository.getState()).isEqualTo(ACCOUNT_STATE) + } + + @Test + fun `should throw exception WHEN account loader returns null`() = runTest { + val testSubject = LoadAccountState( + accountStateLoader = { null }, + accountStateRepository = InMemoryAccountStateRepository(), + ) + + assertFailure { + testSubject.execute(ACCOUNT_UUID) + }.isInstanceOf() + .hasMessage("Account state for $ACCOUNT_UUID not found") + } + + private companion object { + const val ACCOUNT_UUID = "accountUuid" + const val EMAIL_ADDRESS = "test@example.com" + val INCOMING_SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + val OUTGOING_SERVER_SETTINGS = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val AUTHORIZATION_STATE = AuthorizationState("authorization state") + + val DISPLAY_OPTIONS = AccountDisplayOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = null, + ) + + val SYNC_OPTIONS = AccountSyncOptions( + checkFrequencyInMinutes = 15, + messageDisplayCount = 25, + showNotification = true, + ) + + val ACCOUNT_STATE = AccountState( + uuid = ACCOUNT_UUID, + emailAddress = EMAIL_ADDRESS, + incomingServerSettings = INCOMING_SERVER_SETTINGS, + outgoingServerSettings = OUTGOING_SERVER_SETTINGS, + authorizationState = AUTHORIZATION_STATE, + displayOptions = DISPLAY_OPTIONS, + syncOptions = SYNC_OPTIONS, + ) + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/SaveServerSettingsTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/SaveServerSettingsTest.kt new file mode 100644 index 0000000..ca414a0 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/domain/usecase/SaveServerSettingsTest.kt @@ -0,0 +1,169 @@ +package app.k9mail.feature.account.edit.domain.usecase + +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountUpdaterFailure +import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountUpdaterResult +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SaveServerSettingsTest { + + @Test + fun `should get account state and update incoming server settings`() = runTest { + var recordedAccountUuid: String? = null + var recordedIsIncoming: Boolean? = null + var recordedServerSettings: ServerSettings? = null + var recordedAuthorizationState: AuthorizationState? = null + val testSubject = SaveServerSettings( + getAccountState = { _ -> ACCOUNT_STATE }, + serverSettingsUpdater = { accountUuid, isIncoming, serverSettings, authorizationState -> + recordedAccountUuid = accountUuid + recordedIsIncoming = isIncoming + recordedServerSettings = serverSettings + recordedAuthorizationState = authorizationState + + AccountUpdaterResult.Success(accountUuid) + }, + ) + + testSubject.execute(ACCOUNT_UUID, isIncoming = true) + + assertThat(recordedAccountUuid).isEqualTo(ACCOUNT_UUID) + assertThat(recordedIsIncoming).isEqualTo(true) + assertThat(recordedServerSettings).isEqualTo(INCOMING_SERVER_SETTINGS) + assertThat(recordedAuthorizationState).isEqualTo(AUTHORIZATION_STATE) + } + + @Test + fun `should throw exception WHEN no incoming server settings present`() = runTest { + val testSubject = SaveServerSettings( + getAccountState = { _ -> ACCOUNT_STATE.copy(incomingServerSettings = null) }, + serverSettingsUpdater = { accountUuid, _, _, _ -> + AccountUpdaterResult.Success(accountUuid) + }, + ) + + assertFailure { + testSubject.execute(ACCOUNT_UUID, isIncoming = true) + }.isInstanceOf() + .hasMessage("Server settings not found") + } + + @Test + fun `should get account state and update outgoing server settings`() = runTest { + var recordedAccountUuid: String? = null + var recordedIsIncoming: Boolean? = null + var recordedServerSettings: ServerSettings? = null + var recordedAuthorizationState: AuthorizationState? = null + val testSubject = SaveServerSettings( + getAccountState = { _ -> ACCOUNT_STATE }, + serverSettingsUpdater = { accountUuid, isIncoming, serverSettings, authorizationState -> + recordedAccountUuid = accountUuid + recordedIsIncoming = isIncoming + recordedServerSettings = serverSettings + recordedAuthorizationState = authorizationState + + AccountUpdaterResult.Success(accountUuid) + }, + ) + + testSubject.execute(ACCOUNT_UUID, isIncoming = false) + + assertThat(recordedAccountUuid).isEqualTo(ACCOUNT_UUID) + assertThat(recordedIsIncoming).isEqualTo(false) + assertThat(recordedServerSettings).isEqualTo(OUTGOING_SERVER_SETTINGS) + assertThat(recordedAuthorizationState).isEqualTo(AUTHORIZATION_STATE) + } + + @Test + fun `should throw exception WHEN no outgoing server settings present`() = runTest { + val testSubject = SaveServerSettings( + getAccountState = { _ -> ACCOUNT_STATE.copy(outgoingServerSettings = null) }, + serverSettingsUpdater = { accountUuid, _, _, _ -> + AccountUpdaterResult.Success(accountUuid) + }, + ) + + assertFailure { + testSubject.execute(ACCOUNT_UUID, isIncoming = false) + }.isInstanceOf() + .hasMessage("Server settings not found") + } + + @Test + fun `should throw exception WHEN update failed`() = runTest { + val testSubject = SaveServerSettings( + getAccountState = { _ -> ACCOUNT_STATE }, + serverSettingsUpdater = { _, _, _, _ -> + AccountUpdaterResult.Failure( + AccountUpdaterFailure.AccountNotFound(ACCOUNT_UUID), + ) + }, + ) + + assertFailure { + testSubject.execute(ACCOUNT_UUID, isIncoming = true) + }.isInstanceOf() + .hasMessage("Server settings update failed") + } + + private companion object { + const val ACCOUNT_UUID = "accountUuid" + const val EMAIL_ADDRESS = "test@example.com" + val INCOMING_SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + val OUTGOING_SERVER_SETTINGS = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val AUTHORIZATION_STATE = AuthorizationState("authorization state") + + val DISPLAY_OPTIONS = AccountDisplayOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = null, + ) + + val SYNC_OPTIONS = AccountSyncOptions( + checkFrequencyInMinutes = 15, + messageDisplayCount = 25, + showNotification = true, + ) + + val ACCOUNT_STATE = AccountState( + uuid = ACCOUNT_UUID, + emailAddress = EMAIL_ADDRESS, + incomingServerSettings = INCOMING_SERVER_SETTINGS, + outgoingServerSettings = OUTGOING_SERVER_SETTINGS, + authorizationState = AUTHORIZATION_STATE, + displayOptions = DISPLAY_OPTIONS, + syncOptions = SYNC_OPTIONS, + ) + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/FakeIncomingServerSettingsValidator.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/FakeIncomingServerSettingsValidator.kt new file mode 100644 index 0000000..4feae83 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/FakeIncomingServerSettingsValidator.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.edit.ui.server.settings.modify + +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class FakeIncomingServerSettingsValidator( + private val serverAnswer: ValidationResult = ValidationResult.Success, + private val portAnswer: ValidationResult = ValidationResult.Success, + private val usernameAnswer: ValidationResult = ValidationResult.Success, + private val passwordAnswer: ValidationResult = ValidationResult.Success, + private val imapPrefixAnswer: ValidationResult = ValidationResult.Success, +) : IncomingServerSettingsContract.Validator { + override fun validateServer(server: String): ValidationResult = serverAnswer + override fun validatePort(port: Long?): ValidationResult = portAnswer + override fun validateUsername(username: String): ValidationResult = usernameAnswer + override fun validatePassword(password: String): ValidationResult = passwordAnswer + override fun validateImapPrefix(imapPrefix: String): ValidationResult = imapPrefixAnswer +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/FakeOutgoingServerSettingsValidator.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/FakeOutgoingServerSettingsValidator.kt new file mode 100644 index 0000000..e195dd2 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/FakeOutgoingServerSettingsValidator.kt @@ -0,0 +1,16 @@ +package app.k9mail.feature.account.edit.ui.server.settings.modify + +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class FakeOutgoingServerSettingsValidator( + private val serverAnswer: ValidationResult = ValidationResult.Success, + private val portAnswer: ValidationResult = ValidationResult.Success, + private val usernameAnswer: ValidationResult = ValidationResult.Success, + private val passwordAnswer: ValidationResult = ValidationResult.Success, +) : OutgoingServerSettingsContract.Validator { + override fun validateServer(server: String): ValidationResult = serverAnswer + override fun validatePort(port: Long?): ValidationResult = portAnswer + override fun validateUsername(username: String): ValidationResult = usernameAnswer + override fun validatePassword(password: String): ValidationResult = passwordAnswer +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyIncomingServerSettingsViewModelTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyIncomingServerSettingsViewModelTest.kt new file mode 100644 index 0000000..9d42592 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyIncomingServerSettingsViewModelTest.kt @@ -0,0 +1,87 @@ +package app.k9mail.feature.account.edit.ui.server.settings.modify + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndMviTurbinesConsumed +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings +import kotlinx.coroutines.delay +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class ModifyIncomingServerSettingsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should load account state from use case`() = runMviTest { + val accountUuid = "accountUuid" + val accountState = AccountState( + uuid = "accountUuid", + emailAddress = "test@example.com", + incomingServerSettings = ServerSettings( + "imap", + "imap.example.com", + 123, + MailConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = true, + pathPrefix = null, + useCompression = true, + sendClientInfo = true, + ), + ), + ) + + val testSubject = ModifyIncomingServerSettingsViewModel( + accountUuid = accountUuid, + accountStateLoader = { _ -> + delay(50) + accountState + }, + validator = FakeIncomingServerSettingsValidator(), + accountStateRepository = InMemoryAccountStateRepository(), + initialState = State(), + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.LoadAccountState) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitStateItem(), + turbines = turbines, + ) { + isEqualTo( + State( + server = StringInputField(value = "imap.example.com"), + security = ConnectionSecurity.TLS, + port = NumberInputField(value = 123L), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "username"), + password = StringInputField(value = "password"), + imapAutodetectNamespaceEnabled = true, + imapPrefix = StringInputField(value = ""), + imapUseCompression = true, + imapSendClientInfo = true, + ), + ) + } + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyOutgoingServerSettingsViewModelTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyOutgoingServerSettingsViewModelTest.kt new file mode 100644 index 0000000..f33ba6f --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/modify/ModifyOutgoingServerSettingsViewModelTest.kt @@ -0,0 +1,76 @@ +package app.k9mail.feature.account.edit.ui.server.settings.modify + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndMviTurbinesConsumed +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import kotlinx.coroutines.delay +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class ModifyOutgoingServerSettingsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should load account state from use case`() = runMviTest { + val accountUuid = "accountUuid" + val accountState = AccountState( + uuid = "accountUuid", + emailAddress = "test@example.com", + outgoingServerSettings = ServerSettings( + "smtp", + "smtp.example.com", + 123, + MailConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + clientCertificateAlias = null, + extra = emptyMap(), + ), + ) + val testSubject = ModifyOutgoingServerSettingsViewModel( + accountUuid = accountUuid, + accountStateLoader = { _ -> + delay(50) + accountState + }, + validator = FakeOutgoingServerSettingsValidator(), + accountStateRepository = InMemoryAccountStateRepository(), + initialState = State(), + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.LoadAccountState) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitStateItem(), + turbines = turbines, + ) { + isEqualTo( + State( + server = StringInputField(value = "smtp.example.com"), + security = ConnectionSecurity.TLS, + port = NumberInputField(value = 123L), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "username"), + password = StringInputField(value = "password"), + ), + ) + } + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/BaseSaveServerSettingsViewModelTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/BaseSaveServerSettingsViewModelTest.kt new file mode 100644 index 0000000..1694325 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/BaseSaveServerSettingsViewModelTest.kt @@ -0,0 +1,109 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndEffectTurbineConsumed +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndStateTurbineConsumed +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.edit.domain.AccountEditDomainContract +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Effect +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Event +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Failure +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class BaseSaveServerSettingsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should save server settings when SaveServerSettings event received and emit NavigateNext`() = runMviTest { + var recordedAccountUuid: String? = null + var recordedIsIncoming: Boolean? = null + val testSubject = TestSaveServerSettingsViewModel( + accountUuid = ACCOUNT_UUID, + saveServerSettings = { accountUuid, isIncoming -> + recordedAccountUuid = accountUuid + recordedIsIncoming = isIncoming + }, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.SaveServerSettings) + + turbines.assertThatAndStateTurbineConsumed { + isEqualTo(State(isLoading = false)) + } + + assertThat(recordedAccountUuid).isNotNull().isEqualTo(ACCOUNT_UUID) + assertThat(recordedIsIncoming).isNotNull().isEqualTo(true) + + turbines.assertThatAndEffectTurbineConsumed { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should set error state when save settings failed`() = runMviTest { + val testSubject = TestSaveServerSettingsViewModel( + accountUuid = ACCOUNT_UUID, + saveServerSettings = { _, _ -> + error("Test exception") + }, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.SaveServerSettings) + + turbines.assertThatAndStateTurbineConsumed { + isEqualTo( + State( + error = Failure.SaveServerSettingsFailed("Test exception"), + isLoading = false, + ), + ) + } + } + + @Test + fun `should allow NavigateBack when error and not loading`() = runMviTest { + val failure = Failure.SaveServerSettingsFailed("Test exception") + val testSubject = TestSaveServerSettingsViewModel( + accountUuid = ACCOUNT_UUID, + saveServerSettings = { _, _ -> + // Do nothing + }, + initialState = State( + isLoading = false, + error = failure, + ), + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State(isLoading = false, error = failure)) + + testSubject.event(Event.OnBackClicked) + + turbines.assertThatAndEffectTurbineConsumed { + isEqualTo(Effect.NavigateBack) + } + } + + private class TestSaveServerSettingsViewModel( + accountUuid: String, + saveServerSettings: AccountEditDomainContract.UseCase.SaveServerSettings, + initialState: State = State(), + ) : BaseSaveServerSettingsViewModel( + accountUuid = accountUuid, + isIncoming = true, + saveServerSettings = saveServerSettings, + initialState = initialState, + ) + + private companion object { + const val ACCOUNT_UUID = "accountUuid" + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveIncomingServerSettingsViewModelTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveIncomingServerSettingsViewModelTest.kt new file mode 100644 index 0000000..dd44ba2 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveIncomingServerSettingsViewModelTest.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import assertk.assertThat +import assertk.assertions.isTrue +import org.junit.Test + +class SaveIncomingServerSettingsViewModelTest { + + @Test + fun `should set is incoming to true`() { + val testSubject = SaveIncomingServerSettingsViewModel( + accountUuid = "accountUuid", + saveServerSettings = { _, _ -> }, + ) + + assertThat(testSubject.isIncoming).isTrue() + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveOutgoingServerSettingsViewModelTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveOutgoingServerSettingsViewModelTest.kt new file mode 100644 index 0000000..7f3205c --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveOutgoingServerSettingsViewModelTest.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import assertk.assertThat +import assertk.assertions.isFalse +import org.junit.Test + +class SaveOutgoingServerSettingsViewModelTest { + + @Test + fun `should set is incoming to true`() { + val testSubject = SaveOutgoingServerSettingsViewModel( + accountUuid = "accountUuid", + saveServerSettings = { _, _ -> }, + ) + + assertThat(testSubject.isIncoming).isFalse() + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreenKtTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreenKtTest.kt new file mode 100644 index 0000000..d0971e4 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsScreenKtTest.kt @@ -0,0 +1,50 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.Effect +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.State +import app.k9mail.feature.account.edit.ui.server.settings.save.fake.FakeSaveServerSettingsViewModel +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SaveServerSettingsScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State( + isLoading = false, + error = null, + ) + val viewModel = FakeSaveServerSettingsViewModel( + isIncoming = true, + initialState = initialState, + ) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + SaveServerSettingsScreen( + title = "irrelevant", + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsStateTest.kt b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsStateTest.kt new file mode 100644 index 0000000..5411ea3 --- /dev/null +++ b/feature/account/edit/src/test/kotlin/app/k9mail/feature/account/edit/ui/server/settings/save/SaveServerSettingsStateTest.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature.account.edit.ui.server.settings.save + +import app.k9mail.feature.account.edit.ui.server.settings.save.SaveServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class SaveServerSettingsStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + error = null, + isLoading = true, + ), + ) + } +} diff --git a/feature/account/fake/build.gradle.kts b/feature/account/fake/build.gradle.kts new file mode 100644 index 0000000..5de851c --- /dev/null +++ b/feature/account/fake/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.account.fake" +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(projects.feature.account.api) + } + } +} diff --git a/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountAvatarData.kt b/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountAvatarData.kt new file mode 100644 index 0000000..1a51729 --- /dev/null +++ b/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountAvatarData.kt @@ -0,0 +1,12 @@ +package net.thunderbird.account.fake + +import net.thunderbird.feature.account.profile.AccountAvatar + +object FakeAccountAvatarData { + + const val AVATAR_IMAGE_URI = "https://example.com/avatar.png" + + val ACCOUNT_AVATAR = AccountAvatar.Image( + uri = AVATAR_IMAGE_URI, + ) +} diff --git a/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountData.kt b/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountData.kt new file mode 100644 index 0000000..61fdee1 --- /dev/null +++ b/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountData.kt @@ -0,0 +1,11 @@ +package net.thunderbird.account.fake + +import net.thunderbird.feature.account.AccountIdFactory + +object FakeAccountData { + + const val ACCOUNT_ID_RAW = "bc722927-9197-417d-919e-6fd702038de1" + val ACCOUNT_ID = AccountIdFactory.of(ACCOUNT_ID_RAW) + const val ACCOUNT_ID_OTHER_RAW = "c2890a43-0f54-4a69-a0af-bdfce8d831ad" + val ACCOUNT_ID_OTHER = AccountIdFactory.of(ACCOUNT_ID_OTHER_RAW) +} diff --git a/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountProfileData.kt b/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountProfileData.kt new file mode 100644 index 0000000..442a6fd --- /dev/null +++ b/feature/account/fake/src/commonMain/kotlin/net/thunderbird/account/fake/FakeAccountProfileData.kt @@ -0,0 +1,26 @@ +package net.thunderbird.account.fake + +import net.thunderbird.account.fake.FakeAccountAvatarData.ACCOUNT_AVATAR +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.profile.AccountProfile + +object FakeAccountProfileData { + + const val PROFILE_NAME = "AccountProfileName" + const val PROFILE_COLOR = 0xFF0000 + + fun createAccountProfile( + id: AccountId = FakeAccountData.ACCOUNT_ID, + name: String = PROFILE_NAME, + color: Int = PROFILE_COLOR, + avatar: AccountAvatar = ACCOUNT_AVATAR, + ): AccountProfile { + return AccountProfile( + id = id, + name = name, + color = color, + avatar = avatar, + ) + } +} diff --git a/feature/account/oauth/build.gradle.kts b/feature/account/oauth/build.gradle.kts new file mode 100644 index 0000000..168cd2a --- /dev/null +++ b/feature/account/oauth/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.account.oauth" + resourcePrefix = "account_oauth_" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.common) + + implementation(projects.mail.common) + + implementation(projects.feature.account.common) + + implementation(libs.appauth) + implementation(libs.androidx.compose.material3) + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContentPreview.kt b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContentPreview.kt new file mode 100644 index 0000000..b9887ae --- /dev/null +++ b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContentPreview.kt @@ -0,0 +1,16 @@ +package app.k9mail.feature.account.oauth.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@PreviewDevices +internal fun AccountOAuthContentPreview() { + PreviewWithTheme { + AccountOAuthContent( + state = AccountOAuthContract.State(), + onEvent = {}, + ) + } +} diff --git a/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/fake/FakeAccountOAuthViewModel.kt b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/fake/FakeAccountOAuthViewModel.kt new file mode 100644 index 0000000..0a8f1a6 --- /dev/null +++ b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/fake/FakeAccountOAuthViewModel.kt @@ -0,0 +1,12 @@ +package app.k9mail.feature.account.oauth.ui.fake + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.ViewModel + +class FakeAccountOAuthViewModel : BaseViewModel(State()), ViewModel { + override fun initState(state: State) = Unit + override fun event(event: Event) = Unit +} diff --git a/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInViewPreview.kt b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInViewPreview.kt new file mode 100644 index 0000000..9fa6919 --- /dev/null +++ b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInViewPreview.kt @@ -0,0 +1,27 @@ +package app.k9mail.feature.account.oauth.ui.view + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@PreviewDevices +@Composable +internal fun SignInViewPreview() { + PreviewWithTheme { + SignInView( + onSignInClick = {}, + isGoogleSignIn = false, + ) + } +} + +@PreviewDevices +@Composable +internal fun SignInViewWithGooglePreview() { + PreviewWithTheme { + SignInView( + onSignInClick = {}, + isGoogleSignIn = true, + ) + } +} diff --git a/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInWithGoogleButtonPreview.kt b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInWithGoogleButtonPreview.kt new file mode 100644 index 0000000..4a4bd01 --- /dev/null +++ b/feature/account/oauth/src/debug/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInWithGoogleButtonPreview.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.oauth.ui.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun SignInWithGoogleButtonPreview() { + PreviewWithThemes { + SignInWithGoogleButton( + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SignInWithGoogleButtonDisabledPreview() { + PreviewWithThemes { + SignInWithGoogleButton( + onClick = {}, + enabled = false, + ) + } +} diff --git a/feature/account/oauth/src/main/AndroidManifest.xml b/feature/account/oauth/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6688d22 --- /dev/null +++ b/feature/account/oauth/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/AccountOAuthModule.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/AccountOAuthModule.kt new file mode 100644 index 0000000..94288cc --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/AccountOAuthModule.kt @@ -0,0 +1,55 @@ +package app.k9mail.feature.account.oauth + +import app.k9mail.feature.account.oauth.data.AuthorizationRepository +import app.k9mail.feature.account.oauth.data.AuthorizationStateRepository +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract.UseCase +import app.k9mail.feature.account.oauth.domain.usecase.CheckIsGoogleSignIn +import app.k9mail.feature.account.oauth.domain.usecase.FinishOAuthSignIn +import app.k9mail.feature.account.oauth.domain.usecase.GetOAuthRequestIntent +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthViewModel +import net.openid.appauth.AuthorizationService +import net.thunderbird.core.common.coreCommonModule +import org.koin.android.ext.koin.androidApplication +import org.koin.core.module.Module +import org.koin.dsl.module + +val featureAccountOAuthModule: Module = module { + includes(coreCommonModule) + + factory { + AuthorizationService( + androidApplication(), + ) + } + + factory { + AuthorizationRepository( + service = get(), + ) + } + + factory { + AuthorizationStateRepository() + } + + factory { + GetOAuthRequestIntent( + repository = get(), + configurationProvider = get(), + ) + } + + factory { FinishOAuthSignIn(repository = get()) } + + factory { CheckIsGoogleSignIn() } + + factory { + AccountOAuthViewModel( + getOAuthRequestIntent = get(), + finishOAuthSignIn = get(), + checkIsGoogleSignIn = get(), + ) + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthStateExtension.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthStateExtension.kt new file mode 100644 index 0000000..dcfd8d9 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthStateExtension.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.oauth.data + +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import net.openid.appauth.AuthState +import net.thunderbird.core.logging.legacy.Log +import org.json.JSONException + +fun AuthState.toAuthorizationState(): AuthorizationState { + return try { + AuthorizationState(value = jsonSerializeString()) + } catch (e: JSONException) { + Log.e(e, "Error serializing AuthorizationState") + AuthorizationState() + } +} + +fun AuthorizationState.toAuthState(): AuthState { + return try { + value?.let { AuthState.jsonDeserialize(it) } ?: AuthState() + } catch (e: JSONException) { + Log.e(e, "Error deserializing AuthorizationState") + AuthState() + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationRepository.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationRepository.kt new file mode 100644 index 0000000..9fd418a --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationRepository.kt @@ -0,0 +1,94 @@ +package app.k9mail.feature.account.oauth.data + +import android.content.Intent +import androidx.core.net.toUri +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.CodeVerifierUtil +import net.openid.appauth.ResponseTypeValues +import net.thunderbird.core.common.oauth.OAuthConfiguration +import net.thunderbird.core.logging.legacy.Log + +class AuthorizationRepository( + private val service: AuthorizationService, +) : AccountOAuthDomainContract.AuthorizationRepository { + + override fun getAuthorizationRequestIntent( + configuration: OAuthConfiguration, + emailAddress: String, + ): AuthorizationIntentResult { + return AuthorizationIntentResult.Success( + createAuthorizationRequestIntent(configuration, emailAddress), + ) + } + + override suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse? { + return try { + AuthorizationResponse.fromIntent(intent) + } catch (e: IllegalArgumentException) { + Log.e(e, "Error deserializing AuthorizationResponse") + null + } + } + + override suspend fun getAuthorizationException(intent: Intent): AuthorizationException? { + return try { + AuthorizationException.fromIntent(intent) + } catch (e: IllegalArgumentException) { + Log.e(e, "Error deserializing AuthorizationException") + null + } + } + + override suspend fun getExchangeToken( + response: AuthorizationResponse, + ): AuthorizationResult = suspendCoroutine { continuation -> + val tokenRequest = response.createTokenExchangeRequest() + + service.performTokenRequest(tokenRequest) { tokenResponse, authorizationException -> + val result = if (authorizationException != null) { + AuthorizationResult.Failure(authorizationException) + } else if (tokenResponse != null) { + val authState = AuthState(response, tokenResponse, null) + AuthorizationResult.Success(authState.toAuthorizationState()) + } else { + AuthorizationResult.Failure(Exception("Unknown error")) + } + + continuation.resume(result) + } + } + + private fun createAuthorizationRequestIntent(configuration: OAuthConfiguration, emailAddress: String): Intent { + val serviceConfig = AuthorizationServiceConfiguration( + configuration.authorizationEndpoint.toUri(), + configuration.tokenEndpoint.toUri(), + ) + + val authRequestBuilder = AuthorizationRequest.Builder( + serviceConfig, + configuration.clientId, + ResponseTypeValues.CODE, + configuration.redirectUri.toUri(), + ) + + val codeVerifier = CodeVerifierUtil.generateRandomCodeVerifier() + + val authRequest = authRequestBuilder + .setScope(configuration.scopes.joinToString(" ")) + .setCodeVerifier(codeVerifier) + .setLoginHint(emailAddress) + .build() + + return service.getAuthorizationRequestIntent(authRequest) + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationStateRepository.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationStateRepository.kt new file mode 100644 index 0000000..86976ae --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationStateRepository.kt @@ -0,0 +1,12 @@ +package app.k9mail.feature.account.oauth.data + +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract + +class AuthorizationStateRepository : AccountOAuthDomainContract.AuthorizationStateRepository { + override fun isAuthorized(authorizationState: AuthorizationState): Boolean { + val authState = authorizationState.toAuthState() + + return authState.isAuthorized + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/AccountOAuthDomainContract.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/AccountOAuthDomainContract.kt new file mode 100644 index 0000000..4855ee8 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/AccountOAuthDomainContract.kt @@ -0,0 +1,42 @@ +package app.k9mail.feature.account.oauth.domain + +import android.content.Intent +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import net.thunderbird.core.common.oauth.OAuthConfiguration + +interface AccountOAuthDomainContract { + + interface UseCase { + fun interface GetOAuthRequestIntent { + fun execute(hostname: String, emailAddress: String): AuthorizationIntentResult + } + + fun interface FinishOAuthSignIn { + suspend fun execute(intent: Intent): AuthorizationResult + } + + fun interface CheckIsGoogleSignIn { + fun execute(hostname: String): Boolean + } + } + + interface AuthorizationRepository { + fun getAuthorizationRequestIntent( + configuration: OAuthConfiguration, + emailAddress: String, + ): AuthorizationIntentResult + + suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse? + suspend fun getAuthorizationException(intent: Intent): AuthorizationException? + + suspend fun getExchangeToken(response: AuthorizationResponse): AuthorizationResult + } + + fun interface AuthorizationStateRepository { + fun isAuthorized(authorizationState: AuthorizationState): Boolean + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/AuthorizationIntentResult.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/AuthorizationIntentResult.kt new file mode 100644 index 0000000..2abc737 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/AuthorizationIntentResult.kt @@ -0,0 +1,11 @@ +package app.k9mail.feature.account.oauth.domain.entity + +import android.content.Intent + +sealed interface AuthorizationIntentResult { + object NotSupported : AuthorizationIntentResult + + data class Success( + val intent: Intent, + ) : AuthorizationIntentResult +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/AuthorizationResult.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/AuthorizationResult.kt new file mode 100644 index 0000000..d8497d7 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/AuthorizationResult.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.oauth.domain.entity + +import app.k9mail.feature.account.common.domain.entity.AuthorizationState + +sealed interface AuthorizationResult { + + data class Success( + val state: AuthorizationState, + ) : AuthorizationResult + + data class Failure( + val error: Exception, + ) : AuthorizationResult + + object BrowserNotAvailable : AuthorizationResult + + object Canceled : AuthorizationResult +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/OAuthResult.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/OAuthResult.kt new file mode 100644 index 0000000..34e6094 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/OAuthResult.kt @@ -0,0 +1,11 @@ +package app.k9mail.feature.account.oauth.domain.entity + +import app.k9mail.feature.account.common.domain.entity.AuthorizationState + +sealed interface OAuthResult { + data class Success( + val authorizationState: AuthorizationState, + ) : OAuthResult + + object Failure : OAuthResult +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/ServerSettingsExtension.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/ServerSettingsExtension.kt new file mode 100644 index 0000000..8d0e7f6 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/entity/ServerSettingsExtension.kt @@ -0,0 +1,6 @@ +package app.k9mail.feature.account.oauth.domain.entity + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings + +fun ServerSettings?.isOAuth() = this?.authenticationType == AuthType.XOAUTH2 diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/CheckIsGoogleSignIn.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/CheckIsGoogleSignIn.kt new file mode 100644 index 0000000..7bc325e --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/CheckIsGoogleSignIn.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.oauth.domain.usecase + +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract.UseCase + +internal class CheckIsGoogleSignIn : UseCase.CheckIsGoogleSignIn { + override fun execute(hostname: String): Boolean { + for (domain in domainList) { + if (hostname.lowercase().endsWith(domain)) { + return true + } + } + + return false + } + + private companion object { + val domainList = listOf( + ".gmail.com", + ".googlemail.com", + ".google.com", + ) + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/FinishOAuthSignIn.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/FinishOAuthSignIn.kt new file mode 100644 index 0000000..6257d10 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/FinishOAuthSignIn.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.oauth.domain.usecase + +import android.content.Intent +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract.UseCase +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult + +class FinishOAuthSignIn( + private val repository: AccountOAuthDomainContract.AuthorizationRepository, +) : UseCase.FinishOAuthSignIn { + override suspend fun execute(intent: Intent): AuthorizationResult { + val response = repository.getAuthorizationResponse(intent) + val exception = repository.getAuthorizationException(intent) + + return if (response != null) { + repository.getExchangeToken(response) + } else if (exception != null) { + AuthorizationResult.Failure(exception) + } else { + AuthorizationResult.Canceled + } + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/GetOAuthRequestIntent.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/GetOAuthRequestIntent.kt new file mode 100644 index 0000000..f1b454a --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/domain/usecase/GetOAuthRequestIntent.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.oauth.domain.usecase + +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract.UseCase.GetOAuthRequestIntent +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import net.thunderbird.core.common.oauth.OAuthConfigurationProvider + +internal class GetOAuthRequestIntent( + private val repository: AccountOAuthDomainContract.AuthorizationRepository, + private val configurationProvider: OAuthConfigurationProvider, +) : GetOAuthRequestIntent { + override fun execute(hostname: String, emailAddress: String): AuthorizationIntentResult { + val configuration = configurationProvider.getConfiguration(hostname) + ?: return AuthorizationIntentResult.NotSupported + + return repository.getAuthorizationRequestIntent(configuration, emailAddress) + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContent.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContent.kt new file mode 100644 index 0000000..a64e621 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContent.kt @@ -0,0 +1,59 @@ +package app.k9mail.feature.account.oauth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.oauth.R +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State +import app.k9mail.feature.account.oauth.ui.view.GoogleSignInSupportText +import app.k9mail.feature.account.oauth.ui.view.SignInView +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +internal fun AccountOAuthContent( + state: State, + onEvent: (Event) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + val resources = LocalContext.current.resources + + Column( + modifier = Modifier + .testTagAsResourceId("AccountOAuthContent") + .then(modifier), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double, Alignment.CenterVertically), + ) { + if (state.isLoading) { + LoadingView( + message = stringResource(id = R.string.account_oauth_loading_message), + ) + } else if (state.error != null) { + Column { + ErrorView( + title = stringResource(id = R.string.account_oauth_loading_error), + message = state.error.toResourceString(resources), + onRetry = { onEvent(Event.OnRetryClicked) }, + ) + + if (state.isGoogleSignIn) { + GoogleSignInSupportText() + } + } + } else { + SignInView( + onSignInClick = { onEvent(Event.SignInClicked) }, + isGoogleSignIn = state.isGoogleSignIn, + isEnabled = isEnabled, + ) + } + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContract.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContract.kt new file mode 100644 index 0000000..ca80775 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthContract.kt @@ -0,0 +1,54 @@ +package app.k9mail.feature.account.oauth.ui + +import android.content.Intent +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.ui.WizardNavigationBarState + +interface AccountOAuthContract { + + interface ViewModel : UnidirectionalViewModel { + fun initState(state: State) + } + + data class State( + val hostname: String = "", + val emailAddress: String = "", + val wizardNavigationBarState: WizardNavigationBarState = WizardNavigationBarState( + isNextEnabled = false, + ), + val isGoogleSignIn: Boolean = false, + val error: Error? = null, + val isLoading: Boolean = false, + ) + + sealed interface Event { + data class OnOAuthResult( + val resultCode: Int, + val data: Intent?, + ) : Event + + object SignInClicked : Event + object OnBackClicked : Event + object OnRetryClicked : Event + } + + sealed interface Effect { + data class LaunchOAuth( + val intent: Intent, + ) : Effect + + data class NavigateNext( + val state: AuthorizationState, + ) : Effect + object NavigateBack : Effect + } + + sealed interface Error { + object NotSupported : Error + object Canceled : Error + + object BrowserNotAvailable : Error + data class Unknown(val error: Exception) : Error + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthStringMapper.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthStringMapper.kt new file mode 100644 index 0000000..df9c732 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthStringMapper.kt @@ -0,0 +1,14 @@ +package app.k9mail.feature.account.oauth.ui + +import android.content.res.Resources +import app.k9mail.feature.account.oauth.R +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error + +internal fun Error.toResourceString(resources: Resources): String { + return when (this) { + Error.BrowserNotAvailable -> resources.getString(R.string.account_oauth_error_browser_not_available) + Error.Canceled -> resources.getString(R.string.account_oauth_error_canceled) + Error.NotSupported -> resources.getString(R.string.account_oauth_error_not_supported) + is Error.Unknown -> resources.getString(R.string.account_oauth_error_failed, error.message) + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthView.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthView.kt new file mode 100644 index 0000000..ac2f743 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthView.kt @@ -0,0 +1,40 @@ +package app.k9mail.feature.account.oauth.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.feature.account.oauth.domain.entity.OAuthResult +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.ViewModel + +@Composable +fun AccountOAuthView( + onOAuthResult: (OAuthResult) -> Unit, + viewModel: ViewModel, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + val oAuthLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { + viewModel.event(Event.OnOAuthResult(it.resultCode, it.data)) + } + + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateNext -> onOAuthResult(OAuthResult.Success(effect.state)) + is Effect.NavigateBack -> onOAuthResult(OAuthResult.Failure) + is Effect.LaunchOAuth -> oAuthLauncher.launch(effect.intent) + } + } + + AccountOAuthContent( + state = state.value, + onEvent = { dispatch(it) }, + modifier = modifier, + isEnabled = isEnabled, + ) +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewModel.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewModel.kt new file mode 100644 index 0000000..8ea109d --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewModel.kt @@ -0,0 +1,120 @@ +package app.k9mail.feature.account.oauth.ui + +import android.app.Activity +import android.content.Intent +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract.UseCase +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.ViewModel +import kotlinx.coroutines.launch + +class AccountOAuthViewModel( + initialState: State = State(), + private val getOAuthRequestIntent: UseCase.GetOAuthRequestIntent, + private val finishOAuthSignIn: UseCase.FinishOAuthSignIn, + private val checkIsGoogleSignIn: UseCase.CheckIsGoogleSignIn, +) : BaseViewModel(initialState), ViewModel { + + override fun initState(state: State) { + val isGoogleSignIn = checkIsGoogleSignIn.execute(state.hostname) + + updateState { + state.copy( + isGoogleSignIn = isGoogleSignIn, + ) + } + } + + override fun event(event: Event) { + when (event) { + is Event.OnOAuthResult -> onOAuthResult(event.resultCode, event.data) + + Event.SignInClicked -> onSignIn() + + Event.OnBackClicked -> navigateBack() + + Event.OnRetryClicked -> onRetry() + } + } + + private fun onSignIn() { + val result = getOAuthRequestIntent.execute( + hostname = state.value.hostname, + emailAddress = state.value.emailAddress, + ) + + when (result) { + AuthorizationIntentResult.NotSupported -> { + updateState { state -> + state.copy( + error = Error.NotSupported, + ) + } + } + + is AuthorizationIntentResult.Success -> { + emitEffect(Effect.LaunchOAuth(result.intent)) + } + } + } + + private fun onRetry() { + updateState { state -> + state.copy( + error = null, + ) + } + onSignIn() + } + + private fun onOAuthResult(resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK && data != null) { + finishSignIn(data) + } else { + updateState { state -> + state.copy(error = Error.Canceled) + } + } + } + + private fun finishSignIn(data: Intent) { + updateState { state -> + state.copy( + isLoading = true, + ) + } + viewModelScope.launch { + when (val result = finishOAuthSignIn.execute(data)) { + AuthorizationResult.BrowserNotAvailable -> updateErrorState(Error.BrowserNotAvailable) + AuthorizationResult.Canceled -> updateErrorState(Error.Canceled) + is AuthorizationResult.Failure -> updateErrorState(Error.Unknown(result.error)) + is AuthorizationResult.Success -> { + updateState { state -> + state.copy(isLoading = false) + } + navigateNext(authorizationState = result.state) + } + } + } + } + + private fun updateErrorState(error: Error) = updateState { state -> + state.copy( + error = error, + isLoading = false, + ) + } + + private fun navigateBack() = emitEffect(Effect.NavigateBack) + + private fun navigateNext(authorizationState: AuthorizationState) { + emitEffect(Effect.NavigateNext(authorizationState)) + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/GoogleSignInSupportText.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/GoogleSignInSupportText.kt new file mode 100644 index 0000000..5311e8f --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/GoogleSignInSupportText.kt @@ -0,0 +1,41 @@ +package app.k9mail.feature.account.oauth.ui.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import app.k9mail.core.ui.compose.common.resources.annotatedStringResource +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.oauth.R + +private const val GOOGLE_OAUTH_SUPPORT_PAGE = "https://support.thunderbird.net/kb/gmail-thunderbird-android" + +@Composable +internal fun GoogleSignInSupportText() { + val extraText = annotatedStringResource( + id = R.string.account_oauth_google_sign_in_support_text, + argument = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MainTheme.colors.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + withLink(LinkAnnotation.Url(GOOGLE_OAUTH_SUPPORT_PAGE)) { + append(stringResource(R.string.account_oauth_google_sign_in_support_text_link_text)) + } + } + }, + ) + + TextBodySmall( + text = extraText, + textAlign = TextAlign.Center, + ) +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInView.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInView.kt new file mode 100644 index 0000000..fb271c9 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInView.kt @@ -0,0 +1,47 @@ +package app.k9mail.feature.account.oauth.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.oauth.R + +@Composable +internal fun SignInView( + onSignInClick: () -> Unit, + isGoogleSignIn: Boolean, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + modifier = modifier, + ) { + TextBodySmall( + text = stringResource(id = R.string.account_oauth_sign_in_description), + textAlign = TextAlign.Center, + ) + + if (isGoogleSignIn) { + SignInWithGoogleButton( + onClick = onSignInClick, + enabled = isEnabled, + ) + + GoogleSignInSupportText() + } else { + ButtonFilled( + text = stringResource(id = R.string.account_oauth_sign_in_button), + onClick = onSignInClick, + enabled = isEnabled, + ) + } + } +} diff --git a/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInWithGoogleButton.kt b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInWithGoogleButton.kt new file mode 100644 index 0000000..74c7862 --- /dev/null +++ b/feature/account/oauth/src/main/kotlin/app/k9mail/feature/account/oauth/ui/view/SignInWithGoogleButton.kt @@ -0,0 +1,123 @@ +package app.k9mail.feature.account.oauth.ui.view + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.k9mail.feature.account.oauth.R +import androidx.compose.material3.Button as Material3Button + +/** + * A sign in with Google button, following the Google Branding Guidelines. + * + * @see [Google Branding Guidelines](https://developers.google.com/identity/branding-guidelines) + */ +@Suppress("LongMethod") +@Composable +fun SignInWithGoogleButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isDark: Boolean = isSystemInDarkTheme(), +) { + Material3Button( + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.buttonColors( + contentColor = getTextColor(isDark), + containerColor = getSurfaceColor(isDark), + ), + border = BorderStroke( + width = 1.dp, + color = getBorderColor(isDark), + ), + contentPadding = PaddingValues(all = 0.dp), + enabled = enabled, + ) { + Row( + modifier = Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = 300, + easing = LinearOutSlowInEasing, + ), + ) + .padding( + end = 8.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + color = Color.White, + ) { + Icon( + modifier = Modifier + .padding(8.dp), + painter = painterResource( + id = R.drawable.account_oauth_ic_google_logo, + ), + contentDescription = "Google logo", + tint = Color.Unspecified, + ) + } + Spacer(modifier = Modifier.requiredWidth(8.dp)) + Text( + text = stringResource( + id = R.string.account_oauth_sign_in_with_google_button, + ), + style = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + letterSpacing = 1.25.sp, + ), + ) + } + } +} + +@Suppress("MagicNumber") +private fun getBorderColor(isDark: Boolean): Color { + return if (isDark) { + Color(0xFF4285F4) + } else { + Color(0x87000000) + } +} + +@Suppress("MagicNumber") +private fun getSurfaceColor(isDark: Boolean): Color { + return if (isDark) { + Color(0xFF4285F4) + } else { + Color(0xFFFFFFFF) + } +} + +@Suppress("MagicNumber") +private fun getTextColor(isDark: Boolean): Color { + return if (isDark) { + Color(0xFFFFFFFF) + } else { + Color(0x87000000) + } +} diff --git a/feature/account/oauth/src/main/res/drawable/account_oauth_ic_google_logo.xml b/feature/account/oauth/src/main/res/drawable/account_oauth_ic_google_logo.xml new file mode 100644 index 0000000..ff2ea96 --- /dev/null +++ b/feature/account/oauth/src/main/res/drawable/account_oauth_ic_google_logo.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/feature/account/oauth/src/main/res/values-am/strings.xml b/feature/account/oauth/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ar/strings.xml b/feature/account/oauth/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..a4ecb1b --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ar/strings.xml @@ -0,0 +1,14 @@ + + + تسجيل الدخول + تسجيل الدخول باستخدام جوجل + سيتم إعادة توجيهك إلى مزود البريد الإلكتروني الخاص بك لتقوم بتسجيل الدخول. تحتاج إلى منح التطبيق إمكانية الوصول إلى حساب بريدك الإلكتروني. + يتم الآن تسجيل الدخول باستخدام OAuth + فشل تسجيل الدخول باستخدام OAuth + لم يتمكن التطبيق من العثور على متصفح لاستخدامه لمنح الوصول إلى حسابك. + تم إلغاء التفويض + استخدام OAuth 2.0 مع هذا المزود غير مدعوم حاليًا. + فشل التفويض مع الخطأ التالي: %s + إذا واجهت مشاكل عند تسجيل الدخول باستخدام جوجل، فيُرجى الرجوع إلى {placeHolder}. + مقالة الدعم + diff --git a/feature/account/oauth/src/main/res/values-ast/strings.xml b/feature/account/oauth/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-az/strings.xml b/feature/account/oauth/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-be/strings.xml b/feature/account/oauth/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..ed46eed --- /dev/null +++ b/feature/account/oauth/src/main/res/values-be/strings.xml @@ -0,0 +1,5 @@ + + + Увайсці + Мы перанакіруем вас на старонку вашага пастаўшчыка паслуг электроннай пошты для ўваходу. Вам трэба даць праграме доступ да вашага ўліковага запісу электроннай пошты. + diff --git a/feature/account/oauth/src/main/res/values-bg/strings.xml b/feature/account/oauth/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..8ad373b --- /dev/null +++ b/feature/account/oauth/src/main/res/values-bg/strings.xml @@ -0,0 +1,14 @@ + + + Вписване чрез Google + Вписването чрез OAuth се провали + Разрешаването се провали и даде следната грешка: %s + Приложението не успя да намери браузър, който да използва за разрешаване на достъп до акаунта Ви. + OAuth 2.0 с този доставчик не се поддържа в момента. + Разрешението е отменено + Вписване чрез OAuth + Пренасочваме ви към вашият доставчик на имейл услуги. Трябва да дадете на приложението достъп до профила си. + Вписване + статия за поддръжка + Ако имате проблеми при влизането с Google, моля, консултирайте се с нашия {placeHolder}. + diff --git a/feature/account/oauth/src/main/res/values-bn/strings.xml b/feature/account/oauth/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-bn/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-br/strings.xml b/feature/account/oauth/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..436613e --- /dev/null +++ b/feature/account/oauth/src/main/res/values-br/strings.xml @@ -0,0 +1,14 @@ + + + M\'ho peus kudennoù o kennaskañ gant Google, lennit hor {placeHolder}. + Adheñchañ a raimp ac\'hanoc\'h etrezek ho pourchaser posteloù evit kennaskañ. Dav vo deoc\'h aotren an arload da haeziñ ho kont posteloù. + Kennsakañ + Kennaskañ gant Google + Kennaskañ gant OAuth + Fazi en ur gennaskañ ouzh OAuth + Nullet eo bet an aotre + C\'hwitet war an aotre gant ar fazi da-heul: %s + OAuth n\'eo ket skoret gant ar pourchaser-mañ. + An arload-mañ n\'hall ket kavout ar merdeer evit aotren an haeziñ war ho kont. + pennad skoazell + diff --git a/feature/account/oauth/src/main/res/values-bs/strings.xml b/feature/account/oauth/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..d289d52 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-bs/strings.xml @@ -0,0 +1,12 @@ + + + Prijavite se + Prijavite se pomoću Google-a + OAuth 2.0 trenutno nije podržan od strane ovog provajdera. + Ova aplikacija nije mogla pronaći pretraživač kojim bi dali pristup vašem računu. + Prijavite se pomoću OAuth protokola + OAuth prijavljivanje neuspjelo + Autorizacija otkazana + Preusmjerit ćemo vas na vašeg provajdera e-pošte da se prijavite. Morate odobriti aplikaciji pristup vašem računu e-pošte. + Autorizacija neuspjela iz sljedećeg razloga: %s + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ca/strings.xml b/feature/account/oauth/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..d5f4a4c --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ca/strings.xml @@ -0,0 +1,14 @@ + + + Iniciar sessió amb Google + Ha fallat l\'inici de sessió amb OAuth + L\'autorització ha fallat amb l\'error següent: %s + L\'aplicació no ha pogut trobar un navegador per a concedir accés al vostre compte. + Actualment, OAuth 2.0 no és compatible amb aquest proveïdor. + Autorització cancel·lada + Iniciar sessió utilitzant OAuth + Us redigirem al seu proveïdor de correu per a iniciar la sessió. Heu de concedir accés a l\'aplicació per a poder accedir a les dades. + Iniciar sessió + Si experimentes problemes iniciant sessió amb Google, consulta el nostre {placeHolder}. + article de suport + diff --git a/feature/account/oauth/src/main/res/values-co/strings.xml b/feature/account/oauth/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..4ad0a84 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-co/strings.xml @@ -0,0 +1,14 @@ + + + Fiascu di l’autorizazione cù quellu sbagliu : %s + Avemu da ridiregevi versu u vostru furnidore di messaghjeria per cunnettesi. Ci vole à cuncede à l’appiecazione l’accessu à u vostru contu di messaghjeria. + Cunnettesi + Cunnettesi cù Google + Cunnessione impieghendu OAuth + Fiascu di a cunnessione OAuth + Autorizazione abbandunata + Attualmente, OAuth 2.0 ùn hè micca accettatu cù stu furnidore. + L’appiecazione ùn pò micca truvà un navigatore à impiegà per cuncede l’accessu à u vostru contu. + S’è vo scuntrate prublemi durante a cunnessione à Google, fighjate puru u nostru {placeHolder}. + articulu d’assistenza + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-cs/strings.xml b/feature/account/oauth/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..3f88e1a --- /dev/null +++ b/feature/account/oauth/src/main/res/values-cs/strings.xml @@ -0,0 +1,14 @@ + + + Přihlásit se prostřednictvím Google + Přihlášení pomocí OAuth se nezdařilo + Ověření se nezdařilo s následující chybou: %s + Nepodařilo se najít webový prohlížeč pro udělení přístupu k vašemu účtu. + OAuth 2.0 není v současnosti tímto poskytovatelem podporováno. + Ověření zrušeno + Přihlašování pomocí OAuth + Pro přihlášení budete přesměrování k poskytovateli vaší e-mailové schránky. Budete muset aplikaci udělit oprávnění pro přístup ke svému e-mailovému účtu. + Přihlásit se + článek podpory + Pokud máte problémy s přihlášením přes Google, podívejte se na náš {placeHolder}. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-cy/strings.xml b/feature/account/oauth/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-cy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-da/strings.xml b/feature/account/oauth/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..7426f1e --- /dev/null +++ b/feature/account/oauth/src/main/res/values-da/strings.xml @@ -0,0 +1,7 @@ + + + Log ind med Google + OAuth login mislykkedes + Logger ind med OAuth + Log ind + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-de/strings.xml b/feature/account/oauth/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..48ec047 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-de/strings.xml @@ -0,0 +1,14 @@ + + + Mit Google anmelden + Anmelden mit OAuth fehlgeschlagen + Autorisierung mit folgendem Fehler fehlgeschlagen: %s + Die App konnte keinen Browser finden, der für den Zugriff auf dein Konto verwendet werden kann. + OAuth 2.0 wird von diesem Anbieter derzeit nicht unterstützt. + Autorisierung aufgehoben + Mit OAuth anmelden + Wir leiten dich zu deinem E-Mail-Anbieter weiter, damit du dich anmelden kannst. Du musst der App Zugriff auf dein E-Mail-Konto gewähren. + Anmelden + Artikel zur Unterstützung + Bei Schwierigkeiten beim Anmelden mit Google wenden Sie sich bitte an {placeHolder}. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-el/strings.xml b/feature/account/oauth/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..4bc7601 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-el/strings.xml @@ -0,0 +1,14 @@ + + + Σύνδεση με Google + Η σύνδεση με OAuth απέτυχε + Η εξουσιοδότηση απέτυχε με το ακόλουθο σφάλμα: %s + Η εφαρμογή δεν μπόρεσε να εντοπίσει κάποιο πρόγραμμα περιήγησης για την παραχώρηση πρόσβασης στον λογαριασμό σας. + Το OAuth 2.0 δεν υποστηρίζεται προς το παρόν από αυτόν τον πάροχο. + Η εξουσιοδότηση ακυρώθηκε + Σύνδεση με χρήση OAuth + Θα σας ανακατευθύνουμε στον πάροχο ηλεκτρονικού ταχυδρομείου σας για να κάνετε σύνδεση. Θα πρέπει να παραχωρήσετε στην εφαρμογή πρόσβαση στον λογαριασμό email σας. + Σύνδεση + Αν αντιμετωπίζετε προβλήματα κατά τη σύνδεση με την Google, συμβουλευτείτε το {placeHolder}. + άρθρο υποστήριξης + diff --git a/feature/account/oauth/src/main/res/values-en-rGB/strings.xml b/feature/account/oauth/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..78f7f74 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,14 @@ + + + Sign in with Google + OAuth sign in failed + Authorisation failed with the following error: %s + The app couldn\'t find a browser to use for granting access to your account. + OAuth 2.0 is currently not supported with this provider. + Authorisation cancelled + Signing in using OAuth + We\'ll redirect you to your email provider to sign in. You need to grant the app access to your email account. + Sign in + If you\'re experiencing problems when signing in with Google, please consult our {placeHolder}. + support article + diff --git a/feature/account/oauth/src/main/res/values-enm/strings.xml b/feature/account/oauth/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-eo/strings.xml b/feature/account/oauth/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..0ef1d90 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-eo/strings.xml @@ -0,0 +1,14 @@ + + + Saluti + Saluti per Google + Saluti per OAuth + Malsukcesis saluti per OAuth + Saluto malsukcesis pro la jena eraro: %s + OAuth 2.0 estas nun ne subtenata kun tiu provizanto. + Saluto nuligita + Se vi spertas problemoj dum ensalutado per Google, bonvolu konsulti nia {placeHolder}. + helpoartikolon + Ni alidirektos vin al via retpoŝtlivero por ensaluti. Vi bezonas permesi al la apo alireblon al via retpoŝtkonto. + La apo ne povis trovi retumilon por permesi alireblon al via konto. + diff --git a/feature/account/oauth/src/main/res/values-es/strings.xml b/feature/account/oauth/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..6743973 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-es/strings.xml @@ -0,0 +1,14 @@ + + + Iniciar sesión con Google + El inicio de sesión mediante OAuth ha fallado + La autorización ha fallado con el siguiente error: %s + La aplicación no ha encontrado ningún navegador con el que abrir una interfaz para dar acceso a tu cuenta. + Este servicio no parece que sea compatible con OAuth 2.0 para el inicio de sesión. + Se ha abandonado la autorización + Iniciar sesión mediante OAuth + Te redirigiremos a la web de tu proveedor de correo para que escribas tus credenciales. Tienes que dar acceso a la aplicación para poder acceder a tus datos. + Iniciar sesión + artículo de soporte + Si estás experimentando problemas iniciando sesión con Google, consulta nuestro {placeHolder}. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-et/strings.xml b/feature/account/oauth/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..143eee6 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-et/strings.xml @@ -0,0 +1,14 @@ + + + Logi sisse kasutades Google\'i teenuseid + OAuth\'i põhine sisselogimine ei õnnestunud + Autentimine ei õnnestunud ja tekkis järgnev viga: %s + Rakendus ei suutnud tuvastada veebibrauserit, mida saaks sinu kontole ligipääsu lubamiseks kasutada. + See teenusepakkuja ei võimalda OAuth 2.0 kasutamist. + Autentimine on katkestatud + Logi sisse kasutades OAuth\'i + Me suuname nüüd sind sisselogimisele sinu e-postiteenusepakkuja juurde. Seal pead sa lubama meie rakendusele ligipääsu sinu e-posti kontole. + Logi sisse + Kui sul tekib vigu Google\'i kontoga sisselogimisel, siis lisateavet {placeHolder}. + leiad meie kasutajatoe artiklist + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-eu/strings.xml b/feature/account/oauth/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..61d330c --- /dev/null +++ b/feature/account/oauth/src/main/res/values-eu/strings.xml @@ -0,0 +1,13 @@ + + + Googlerekin saioa hasi + Akatsa OAuth bidez saioa hasterakoan + Baimentzerakoan akatsa honako errorearekin: %s + Aplikazioak ezin izan du aurkitu zure kontura sartzeko erabili beharreko nabigatzailerik. + OAuth 2.0 ez du onartzen hornitzaile honek. + Baimentzea ezeztatuta + Saioa hasi OAuth erabiliaz + Posta elektronikoaren hornitzailearenera bidaliko zaitugu saioa hasteko. Aplikazioari sarbidea baimendu behar diozu zure e-mail kontuan. + Saioa hasi + laguntza artikulua + diff --git a/feature/account/oauth/src/main/res/values-fa/strings.xml b/feature/account/oauth/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..bde6cc8 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-fa/strings.xml @@ -0,0 +1,14 @@ + + + ورود با حساب گوگل + خطا در ورود با OAuth + مجوزدهی به علت این خطا ناتمام ماند: %s + مرورگری یافت نشد. + OAuth 2.0 توسط این ارایه دهنده پشتیبانی نمی‌شود. + مجوزدهی لغو شد + ورود با استفاده از OAuth + برای ورود به‌فراهم‌کنندهٔ رایانامه‌تان هدایتتان می‌کنیم. باید دسترسی حساب رایانامه‌تان را به برنامه بدهید. + ثبت نام + اگر هنگام ورود به سیستم با Google با مشکل مواجه شدید، لطفاً با {placeHolder} ما مشورت کنید. + بند پشتیبانی + diff --git a/feature/account/oauth/src/main/res/values-fi/strings.xml b/feature/account/oauth/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..1a95712 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-fi/strings.xml @@ -0,0 +1,14 @@ + + + Kirjaudu Googlella + OAuth-kirjautuminen epäonnistui + Valtuutus epäonnistui seuraavalla virheellä: %s + Sovellus ei löytänyt selainta käytettäväksi pääsyn antamiseksi tilille. + OAuth 2.0 ei ole tällä hetkellä tuettu tämän palveluntarjoajan kanssa. + Valtuutus peruttiin + Kirjaudu OAuthilla + Ohjaamme sinut sähköpostin palveluntarjoajasi puoleen kirjautumista varten. Valtuuta sovelluksen pääsy sähköpostitilillesi. + Kirjaudu sisään + Jos sinulla on ongelmia Google-tilillä sisäänkirjautumisessa, tutustu {placeHolder}:imme. + tukiartikkeli + diff --git a/feature/account/oauth/src/main/res/values-fr/strings.xml b/feature/account/oauth/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..2745a23 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-fr/strings.xml @@ -0,0 +1,14 @@ + + + Me connecter avec Google + Échec de connexion OAuth + Échec d’autorisation avec l’erreur suivante : %s + L’appli n’a pas trouvé de navigateur pour accorder l’accès à votre compte. + OAuth n\'est actuellement pas pris en charge par ce fournisseur. + L\'autorisation a été annulée + Me connecter avec OAuth + Nous vous redirigerons vers votre fournisseur de courriel pour vous connecter. Vous devez accorder à l’appli l’accès à votre compte de courriel. + Me connecter + article d’assistance + Si vous rencontrez des problèmes lors de la connexion avec Google, consultez notre {placeHolder}. + diff --git a/feature/account/oauth/src/main/res/values-fy/strings.xml b/feature/account/oauth/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..870d1e2 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-fy/strings.xml @@ -0,0 +1,14 @@ + + + Oanmelde mei Google + Oanmelde mei OAuth mislearre + Autorisaasje is mislearre mei de folgjende flatermelding: %s + De app kin gjin browser fine. In browser is nedich om tagong te krijen ta jo account. + OAuth 2.0 wurdt op dit stuit net stipe troch dizze provider. + Autorisaasje is annulearre + Oanmelde mei OAuth + Wy stjoere jo troch nei jo e-mailprovider om oan te melden. Jo moatte de app tagong jaan ta jo e-mailaccount. + Oanmelde + As jo problemen hawwe mei oanmelden by Google, lês dan ús {placeHolder}. + stipe-artikel + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ga/strings.xml b/feature/account/oauth/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..4b008ca --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ga/strings.xml @@ -0,0 +1,14 @@ + + + Déanfaimid tú a atreorú chuig do sholáthraí ríomhphoist chun síniú isteach. Ní mór duit rochtain a thabhairt don aip ar do chuntas ríomhphoist. + Cealaíodh an t-údarú + Níorbh fhéidir leis an aip brabhsálaí a aimsiú le húsáid chun rochtain a dheonú ar do chuntas. + Ag síniú isteach le OAuth + Sínigh isteach + Sínigh isteach le Google + Theip ar shíniú isteach OAuth + Theip ar údarú leis an earráid seo a leanas: %s + Ní thacaítear le OAuth 2.0 leis an soláthraí seo faoi láthair. + alt tacaíochta + Má tá fadhbanna agat agus tú ag síniú isteach le Google, téigh i gcomhairle lenár {placeHolder}. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-gd/strings.xml b/feature/account/oauth/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..cb421b2 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-gd/strings.xml @@ -0,0 +1,14 @@ + + + Cha d’fhuair an aplacaid lorg air brabhsair sam bith a chleachadh e airson cead-inntrigidh dhan chunntas agad a thoirt seachad. + Chan eil an solaraiche seo a’ cur taic ri OAuth 2.0 aig an àm seo. + Ma tha duilgheadasan agad le bhith a’ clàradh a-steach le Google, thoir sùil air an {placeHolder} againn. + artaigeal taice + Ath-stiùirichidh sinn gu solaraiche a’ phuist-d agad airson clàradh a-steach. Feumaidh tu cead-inntrigidh do chunntas a’ phuist-d agad a thoirt dhan aplacaid. + Clàraich a-steach + Clàraich a-steach le Google + Gad chlàradh a-steach le OAuth + Dh’fhàillig an clàradh a-steach le OAuth + Sguireadh dhen ùghdarrachadh + Dh’fhàillig an t-ùghdarrachadh leis a’ mhearachd a leanas: %s + diff --git a/feature/account/oauth/src/main/res/values-gl/strings.xml b/feature/account/oauth/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-gl/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-gu/strings.xml b/feature/account/oauth/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-hi/strings.xml b/feature/account/oauth/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..d2237a2 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-hi/strings.xml @@ -0,0 +1,12 @@ + + + गूगल से साइनइन करें + ओऑथ साइनइन फेल + ऑथराइज़ेशन इस गड़बड़ के वजह से फेल हुआ: %s + आपके अकाउंट का ऐक्सेस लेने के लिए ऐप को ब्राउज़र नहीं मिला। + ये प्रोवाइडर अभी ओऑथ 2.0 सपोर्ट नहीं करता। + ऑथराइज़ेशन कैंसिल किया गया + ओऑथ से साइनइन कर रहे + हम आपको साइनइन करने के लिए आपके ईमेल प्रोवाइडर पे रीडायरेक्ट करेंगे। आपको ऐप को अपने ईमेल अकाउंट का ऐक्सेस देना होगा। + साइनइन करें + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-hr/strings.xml b/feature/account/oauth/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..724d6ba --- /dev/null +++ b/feature/account/oauth/src/main/res/values-hr/strings.xml @@ -0,0 +1,12 @@ + + + Preusmjerit ćemo Vas vašem davatelju usluga e-pošte da se prijavite. Morate aplikaciji odobriti pristup vašem računu e-pošte. + Prijavite se + Prijavite se s Googleom + Prijavite se koristeći OAuth + OAuth prijava nije uspjela + Autorizacija poništena + OAuth 2.0 trenutačno nije podržan kod ovog pružatelja usluga e-pošte. + Aplikacija nije mogla pronaći preglednik za davanje pristupa vašem računu. + Autorizacija nije uspjela sa sljedećom pogreškom: %s + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-hu/strings.xml b/feature/account/oauth/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..3ac546d --- /dev/null +++ b/feature/account/oauth/src/main/res/values-hu/strings.xml @@ -0,0 +1,14 @@ + + + Bejelentkezés Google használatával + Az OAuth bejelentkezés sikertelen volt. + A hitelesítés sikertelen volt a következő hiba miatt: %s + Az alkalmazás nem talált böngészőt, amellyel hozzáférést biztosíthatna fiókhoz. + Az OAuth 2.0 jelenleg nem támogatott ennél a szolgáltatónál. + A hitelesítés megszakításra került + Bejelentkezés OAuth használatával + A bejelentkezéshez átirányítjuk az e-mail-szolgáltatóhoz. Meg kell adni az alkalmazásnak a hozzáférést az e-mail-fiókhoz. + Bejelentkezés + Ha problémákat tapasztal a Google-lel történő bejelentkezéssel, tekintse meg a {placeHolder}. + támogatási leírásunkat + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-hy/strings.xml b/feature/account/oauth/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-in/strings.xml b/feature/account/oauth/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..6bbf059 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-in/strings.xml @@ -0,0 +1,14 @@ + + + Kami akan mengarahkan Anda ke penyedia surel untuk masuk ke akun Anda. Namun, izinkan akses aplikasi ke akun surel Anda terlebih dahulu. + Masuk + Masuk dengan Google + Masuk dengan OAuth + Gagal masuk dengan OAuth + Otorisasi dibatalkan + Otorisasi gagal akibat galat berikut: %s + OAuth 2.0 saat ini tidak didukung oleh penyedia ini. + Aplikasi ini tidak menemukan peramban yang bisa digunakan untuk memberikan akses ke akun Anda. + bantuan + Jika Anda mengalami masalah saat masuk dengan Google, silakan baca {placeHolder}. + diff --git a/feature/account/oauth/src/main/res/values-is/strings.xml b/feature/account/oauth/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..26384e7 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-is/strings.xml @@ -0,0 +1,14 @@ + + + Skrá inn með Google + Innskráning með OAuth mistókst + Auðkenning mistókst með eftirfarandi villuboðum: %s + Forritið fann engan vafra til að nota til að fá aðgang að notandaaðgangnum þínum. + OAuth 2.0 er ekki stutt í augnablikinu hjá þessari þjónustuveitu. + Hætt við auðkenningu + Skrái inn með OAuth + Við munum endurbeina þér til póstþjónustunnar þinnar til að skrá þig inn. Þú þarft að veita forritinu aðgang að tölvupóstreikningnum þínum. + Skrá inn + Ef þú átt í vandræðum með að skrá inn með Google ættirðu að skoða {placeHolder} hjá okkur. + leiðbeiningarnar + diff --git a/feature/account/oauth/src/main/res/values-it/strings.xml b/feature/account/oauth/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..69191ea --- /dev/null +++ b/feature/account/oauth/src/main/res/values-it/strings.xml @@ -0,0 +1,14 @@ + + + Accedi con Google + Accesso OAuth non riuscito + Autorizzazione non riuscita con il seguente errore: %s + L\'app non è riuscita a trovare un browser per concedere l\'accesso al tuo account + OAuth 2.0 non è supportato con questo provider + Autorizzazione annullata + Accedi tramite OAuth + Sarai reindirizzato al tuo provider di posta elettronica per accedere. Autorizza l\'app ad accedere al tuo account di posta elettronica. + Accedi + Articolo di supporto + Se riscontri problemi durante l\'accesso con Google, consulta il nostro {placeHolder}. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-iw/strings.xml b/feature/account/oauth/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..470fd0d --- /dev/null +++ b/feature/account/oauth/src/main/res/values-iw/strings.xml @@ -0,0 +1,14 @@ + + + אנו נפנה אותך אל ספק הדוא\"ל שלך כדי להיכנס. עליך להעניק לאפליקציה גישה לחשבון הדוא\"ל שלך. + התחבר + התחבר עם Google + כניסה באמצעות OAuth + כניסה באמצעות OAuth נכשלה + OAuth 2.0 כרגע לא נתמך עם הספק הזה. + היישומון לא הצליח למצוא דפדפן להשתמש בו כדי לתת גישה לחשבון שלך. + הרשאה בוטלה + הרשאה נכשלה עם השגיאה שלהלן: %s + מאמר תמיכה + אם אתה נתקל בבעיות בעת הכניסה ל-Google, פנה אל {placeHolder} שלנו. + diff --git a/feature/account/oauth/src/main/res/values-ja/strings.xml b/feature/account/oauth/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..7e6d0e8 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ja/strings.xml @@ -0,0 +1,14 @@ + + + Google でログイン + OAuth ログインに失敗しました + 以下のエラーにより認証に失敗しました: %s + アカウントへのアクセス権付与に必要なブラウザーアプリが見つかりませんでした。 + 現在、このプロバイダーは OAuth 2.0 に対応していません。 + 認証がキャンセルされました + OAuth でログイン中 + ログインのため、あなたのメールプロバイダーにリダイレクトします。アプリにメールアカウントへのアクセス権を付与してください。 + ログイン + サポート記事 + Google でログインに問題がある場合は、{placeHolder}を確認してください。 + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ka/strings.xml b/feature/account/oauth/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-kab/strings.xml b/feature/account/oauth/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-kab/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-kk/strings.xml b/feature/account/oauth/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..5bc0dfe --- /dev/null +++ b/feature/account/oauth/src/main/res/values-kk/strings.xml @@ -0,0 +1,6 @@ + + + Кіру + Google арқылы кіру + OAuth арқылы кіруде + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ko/strings.xml b/feature/account/oauth/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..268f129 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ko/strings.xml @@ -0,0 +1,12 @@ + + + 로그인 + 구글로 로그인하기 + OAuth로 로그인 + OAuth 로그인 실패 + 인증 취소됨 + 다음 오류로 인증에 실패했습니다: %s + 계정에 대한 액세스 권한을 부여하는 데 사용할 브라우저를 찾을 수 없습니다. + 현재 이 제공자에서는 OAuth 2.0이 지원되지 않습니다. + 로그인을 위해 이메일 제공업체로 리디렉션됩니다. 이메일 계정에 대한 접근 권한이 필요합니다. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-lt/strings.xml b/feature/account/oauth/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..c7f7e70 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-lt/strings.xml @@ -0,0 +1,12 @@ + + + Kad prisijungtumėte, jus nukreipsime pas jūsų el. pašto paslaugos teikėją. Šiai programai turėsite suteikti prieigą prie savo el. pašto paskyros. + Prisijungti + Prisijungti per „Google“ + Prisijungti naudojant „OAuth“ + Prisijungti naudojant „OAuth“ nepavyko + Tapatybės patvirtinimas nutrauktas + Tapatybės patikrinimas nepavyko dėl klaidos: %s + „OAuth 2.0“ šiuo metu su šiuo tiekėju nepalaikoma. + Programai nepavyko aptikti naršyklės, kurią galėtų panaudoti prieigai prie jūsų paskyros gauti. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-lv/strings.xml b/feature/account/oauth/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ml/strings.xml b/feature/account/oauth/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-nb-rNO/strings.xml b/feature/account/oauth/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..5770ec2 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,14 @@ + + + Logg inn med Google + Innlogging med OAuth mislyktes + Autorisering mislyktes med følgende feil: %s + Appen klarte ikke å finne noen nettleser å bruke for å innvilge tilgang til kontoen din. + OAuth 2.0 støttes ikke med denne tilbyderen. + Autorisasjon avbrutt + Logger inn med OAuth + Du vil bli sendt til din e-posttilbyder for innlogging. Du må innvilge programmet tilgang til e-postkontoen din. + Logg inn + Hvis du opplever problemer når du logger inn med Google, oppsøk vår {placeHolder}. + hjelpeartikkel + diff --git a/feature/account/oauth/src/main/res/values-nl/strings.xml b/feature/account/oauth/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..3d44ac0 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-nl/strings.xml @@ -0,0 +1,14 @@ + + + Aanmelden met Google + Aanmelden met OAuth mislukt + Autorisatie is mislukt met de volgende foutmelding: %s + De app kan geen browser vinden. Een browser is nodig om toegang tot uw account te krijgen. + OAuth 2.0 wordt nu niet ondersteund door deze provider. + Autorisatie is geannuleerd + Aanmelden met OAuth + We sturen u door naar uw e-mailprovider om aan te melden. U moet de app toegang geven tot uw e-mailaccount. + Aanmelden + ondersteuningsartikel + Als u problemen hebt met aanmelden bij Google, lees dan onze {placeHolder}. + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-nn/strings.xml b/feature/account/oauth/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..c7834c7 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-nn/strings.xml @@ -0,0 +1,14 @@ + + + Logg inn + Logg in med Google + Du vil bli sendt til e-posttilbydaren din for innlogging. Du må gje programmet tilgang til e-postkontoen din. + Loggar inn med OAuth + Mislykka innlogging med OAuth + Godkjenning avbroten + Mislykka godkjenning med følgjande feil: %s + OAuth 2.0 er ikkje støtta med denne tilbydaren. + Appen klarte ikkje å finne nokon nettlesar å bruke for å innvilge tilgang til kontoen din. + Om du opplever problemer med å logge inn med Google, konsulter {placeHolder}. + artikkelen i brukarstønaden vår + diff --git a/feature/account/oauth/src/main/res/values-pl/strings.xml b/feature/account/oauth/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..4b08b82 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-pl/strings.xml @@ -0,0 +1,14 @@ + + + Zaloguj się przez Google + Logowanie OAuth nie powiodło się + Autoryzacja nie powiodła się z powodu następującego błędu: %s + Aplikacja nie mogła znaleźć przeglądarki, za pomocą której można uzyskać dostęp do Twojego konta. + Ten dostawca nie obsługuje obecnie protokołu OAuth 2.0. + Autoryzacja anulowana + Logowanie przy użyciu OAuth + Przekierujemy Cię do Twojego dostawcy poczty e-mail, żebyś się zalogował. Musisz przyznać aplikacji dostęp do swojego konta e-mail. + Zaloguj się + artykułem pomocy + Jeśli masz problemy z logowaniem się za pomocą konta Google, zapoznaj się z naszym {placeHolder}. + diff --git a/feature/account/oauth/src/main/res/values-pt-rBR/strings.xml b/feature/account/oauth/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..4571023 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,14 @@ + + + Entrar com Google + Acesso com OAuth falhou + Autorização falhou com o erro: %s + O aplicativo não conseguiu encontrar um navegador para conceder acesso à sua conta. + No momento não há suporte para OAuth 2.0 neste provedor. + Autorização cancelada + Entrar com OAuth + Iremos lhe redirecionar ao provedor de email para entrar na sua conta. Você precisa conceder ao aplicativo acesso à sua conta de email. + Entrar + Se estiver com problemas ao entrar com Google, consulte nosso {placeHolder}. + artigo de suporte + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-pt-rPT/strings.xml b/feature/account/oauth/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..c072d75 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,14 @@ + + + Iniciar sessão com Google + Falha ao iniciar sessão via OAuth + A autorização falhou com o erro:%s + A aplicação não encontrou um navegador que possa utilizar para permitir o acesso à conta. + OAuth 2.0 não é, atualmente, suportado por este serviço. + Autorização cancelada + Iniciar sessão via OAuth + Iremos encaminhar para o seu fornecedor de e-mail para iniciar sessão. Tem de permitir à aplicação o acesso à sua conta. + Iniciar sessão + artigo de apoio + Se estiver com problemas ao entrar com Google, consulte o nosso {placeHolder}. + diff --git a/feature/account/oauth/src/main/res/values-pt/strings.xml b/feature/account/oauth/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/oauth/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ro/strings.xml b/feature/account/oauth/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..1507ea6 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ro/strings.xml @@ -0,0 +1,14 @@ + + + Conectează-te cu Google + Conectarea OAuth a eșuat + Autorizarea a eșuat cu următoarea eroare: %s + Aplicația nu a putut găsi un browser pe care să îl folosească pentru a acorda acces la cont. + OAuth 2.0 nu este acceptat în prezent cu acest furnizor. + Autorizație anulată + Conectare folosind OAuth + Te vom redirecționa către furnizorul de e-mail pentru a te conecta. Trebuie să acorzi aplicației acces la contul de e-mail. + Conectează-te + Dacă întâmpini probleme când te conectezi cu Google, consultă {placeHolder}. + articol de suport + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ru/strings.xml b/feature/account/oauth/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..45f7e17 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ru/strings.xml @@ -0,0 +1,14 @@ + + + В настоящее время OAuth 2.0 данным провайдером не поддерживается. + Не найден браузер для предоставления доступа к вашей учётной записи. + Войти + Войти через Google + Войти через OAuth + Не удалось выполнить вход по протоколу OAuth + Авторизация отменена + Авторизация не выполнена из-за ошибки: %s + Сейчас вы будете перенаправлены к вашему провайдеру электронной почты для входа в систему. Вам будет необходимо предоставить приложению доступ к вашему электронному адресу. + статье поддержки + Если у вас возникли проблемы при входе в учётную запись Google, обратитесь к {placeHolder}. + diff --git a/feature/account/oauth/src/main/res/values-sk/strings.xml b/feature/account/oauth/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..8b7cba4 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-sk/strings.xml @@ -0,0 +1,12 @@ + + + Prihlásiť sa cez Google + Na prihlásenie vás presmerujeme na stránku vášho e-mailu. Budete musieť aplikácii povoliť prístup k vášmu e-mailu. + Prihlásiť sa + Aplikácia nenašla prehliadač použiteľný na udelenie prístupu k vášmu účtu. + OAuth 2.0 nie je zatiaľ týmto poskytovateľom podporované. + Prihlasovanie pomocou OAuth + Prihlásenie pomocou OAuth sa nepodarilo + Overenie zrušené + Overenie sa nepodarilo s nasledujúcou chybou: %s + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-sl/strings.xml b/feature/account/oauth/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..ae50cba --- /dev/null +++ b/feature/account/oauth/src/main/res/values-sl/strings.xml @@ -0,0 +1,14 @@ + + + Za vpis je zahtevana preusmeritev k ponudniku elektronske pošte. Programu je treba odobriti dostop do računa. + Prijavi se z Googlovim računom + Vpis z OAuth je spodletel + Overitev je preklicana + Prijava z uporabo OAuth + Prijavi se + Avtorizacija ni uspela z naslednjo napako: %s + Ta ponudnik trenutno ne podpira OAuth 2.0. + Aplikacija ni našla brskalnika, ki bi ga uporabila za odobritev dostopa do tvojega računa. + Če pri vpisovanju z Googlom naletite na težave, si preberite {placeHolder}. + članek podpore + diff --git a/feature/account/oauth/src/main/res/values-sq/strings.xml b/feature/account/oauth/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..0aa3368 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-sq/strings.xml @@ -0,0 +1,14 @@ + + + Hyni me Google + Hyrja me OAuth dështoi + Autorizimi dështoi me gabimin vijues: %s + Aplikacioni s’gjeti dot një shfletues për ta përdorur për akordim hyrjeje te llogaria juaj. + OAuth 2.0 aktualisht s’mbulohet me këtë shërbim. + Autorizimi u anulua + Po hyhet duke përdorur OAuth + Do t’ju ridrejtojmë te shërbimi juaj email, që të bëni hyrjen. Lypset t’i akordoni aplikacionit hyrje në llogarinë tuaj email. + Hyni + artikullin e asistencës + Nëse hasni probleme, kur bëni hyrje me Google, ju lutemi, shihni {placeHolder} tonë. + diff --git a/feature/account/oauth/src/main/res/values-sr/strings.xml b/feature/account/oauth/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..85250e4 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-sr/strings.xml @@ -0,0 +1,14 @@ + + + Преусмерићемо вас на вашег пружаоца имејла да бисте се пријавили. Потребно је да апликацији дате приступ за ваш имејл налог. + Пријава + Пријава преко Google-а + Пријављивање преко OAuth-а + Пријављивање преко OAuth-а није успело + Овлашћење је отказано + Неуспешно овлашћење, са следећом грешком: %s + OAuth 2.0 тренутно није подржан са овим пружаоцем. + Апликација није могла да пронађе прегледач за коришћење у давању приступа вашем налогу. + Ако имате проблема при пријављивању преко Google-а, консултујте наш {placeHolder}. + чланак подршке + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-sv/strings.xml b/feature/account/oauth/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..1007d96 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-sv/strings.xml @@ -0,0 +1,14 @@ + + + Vi kopplar vidare till din e-postoperatör för inloggning. Du behöver godkänna appåtkomst till ditt e-postkonto. + Logga in med Google + Appen kunde inte hitta en webbläsare att använda för att ge åtkomst till ditt konto. + OAuth 2.0 stöds för närvarande inte av denna leverantör. + Logga in + OAuth-inloggning misslyckades + Auktoriseringen misslyckades med följande fel: %s + Auktoriseringen avbröts + Logga in med OAuth + Om du upplever problem när du loggar in med Google, vänligen uppsök vår {placeHolder}. + support artikel + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-sw/strings.xml b/feature/account/oauth/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-ta/strings.xml b/feature/account/oauth/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..3aa53ff --- /dev/null +++ b/feature/account/oauth/src/main/res/values-ta/strings.xml @@ -0,0 +1,14 @@ + + + பின்வரும் பிழையுடன் ஏற்பு தோல்வியடைந்தது: %s + உள்நுழைய உங்கள் மின்னஞ்சல் வழங்குநருக்கு நாங்கள் உங்களை திருப்பிவிடுவோம். உங்கள் மின்னஞ்சல் கணக்கிற்கான பயன்பாட்டு அணுகலை நீங்கள் வழங்க வேண்டும். + OAuth ஐப் பயன்படுத்துவதில் கையொப்பமிடுதல் + ஏற்பு ரத்து செய்யப்பட்டது + OAUTH 2.0 தற்போது இந்த வழங்குநருடன் ஆதரிக்கப்படவில்லை. + உங்கள் கணக்கிற்கு அணுகலை வழங்க பயன்படுத்த உலாவியை பயன்பாட்டால் கண்டுபிடிக்க முடியவில்லை. + Google உடன் உள்நுழையும்போது நீங்கள் சிக்கல்களைச் சந்திக்கிறீர்கள் என்றால், தயவுசெய்து எங்கள் {ஒதுக்கிடத்தை அணுகவும். + உதவி கட்டுரை + விடுபதிகை + Google உடன் உள்நுழைக + தோல்வியுற்ற OAUTH உள்நுழைவு + diff --git a/feature/account/oauth/src/main/res/values-th/strings.xml b/feature/account/oauth/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-th/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-tr/strings.xml b/feature/account/oauth/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..a6e392c --- /dev/null +++ b/feature/account/oauth/src/main/res/values-tr/strings.xml @@ -0,0 +1,14 @@ + + + Google ile oturum aç + OAuth ile oturum açılamadı + Kimlik doğrulama şu hatayla başarısız oldu: %s + Uygulama, hesabınıza erişim izni vermek için kullanılacak bir tarayıcı bulamadı. + Bu sağlayıcıda şu anda OAuth 2.0 desteklenmiyor. + Kimlik doğrulama iptal edildi + OAuth ile oturum aç + Oturum açmanız için sizi e-posta sağlayıcınıza yönlendireceğiz. Uygulamanın e-posta hesabınıza erişmesine izin vermelisiniz. + Oturum aç + Google ile oturum açarken sorun yaşıyorsanız lütfen {placeHolder} bakın. + destek makalemize + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-uk/strings.xml b/feature/account/oauth/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..2f940c4 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-uk/strings.xml @@ -0,0 +1,14 @@ + + + Ми переадресуємо вас до вашого постачальника послуг електронної пошти для входу. Вам потрібно надати застосунку доступ до вашого облікового запису електронної пошти. + Увійти + Увійти за допомогою Google + Увійти використовуючи OAuth + Не вдалося ввійти за допомогою OAuth + Авторизація скасована + Не вдалося авторизувати через цю помилку: %s + Цей постачальник наразі не підтримує OAuth 2.0. + Застосунок не може знайти браузер для надання доступу до вашого облікового запису. + Якщо у вас виникли проблеми під час входу в Google, зверніться до нашої {placeHolder}. + довідкової статті + diff --git a/feature/account/oauth/src/main/res/values-vi/strings.xml b/feature/account/oauth/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..39966cb --- /dev/null +++ b/feature/account/oauth/src/main/res/values-vi/strings.xml @@ -0,0 +1,14 @@ + + + Đăng nhập bằng Google + Đăng nhập OAuth không thành công + Việc ủy quyền không thành công với lỗi sau: %s + Ứng dụng không thể tìm thấy trình duyệt dùng để cấp quyền truy cập vào tài khoản của bạn. + OAuth 2.0 hiện không được nhà cung cấp này hỗ trợ. + Đã hủy ủy quyền + Đăng nhập bằng OAuth + Chúng tôi sẽ chuyển hướng bạn đến nhà cung cấp email của bạn để đăng nhập. Bạn cần cấp cho ứng dụng quyền truy cập vào tài khoản email của mình. + Đăng nhập + Nếu bạn đang gặp vấn đề khi đăng nhập với Google, hãy xem qua {placeHolder} của chúng tôi. + bài viết hỗ trợ + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-zh-rCN/strings.xml b/feature/account/oauth/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..e69d1f7 --- /dev/null +++ b/feature/account/oauth/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,14 @@ + + + 使用 Google 登录 + OAuth 登录失败 + 授权失败,错误如下:%s + 此应用找不到用于授权访问您账号的浏览器。 + 此提供者目前不支持 OAuth 2.0。 + 授权已取消 + 使用 OAuth 登录 + 我们会将您重定向到您的邮件服务提供者网站进行登录。您需要授权此应用访问您的电子邮件账号。 + 登录 + 支持文章 + 如果登录 Google 时遇到问题,请咨询我们的 {placeHolder}。 + \ No newline at end of file diff --git a/feature/account/oauth/src/main/res/values-zh-rTW/strings.xml b/feature/account/oauth/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..f053f8f --- /dev/null +++ b/feature/account/oauth/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,14 @@ + + + 使用 Google 登入 + OAuth 登入失敗 + 授權失敗,出現以下錯誤:%s + 此應用程式找不到用於授予帳號存取權限的瀏覽器。 + 此提供者目前不支援 OAuth 2.0。 + 授權被取消 + 使用 OAuth 登入 + 我們會將您重新導向至您的電子郵件提供者頁面進行登入。您需要授予此應用程式存取您的電子郵件帳號的權限。 + 登入 + 如果您在使用 Google 登入時遇到問題,請諮詢我們的 {placeHolder}。 + 支援指南 + diff --git a/feature/account/oauth/src/main/res/values/strings.xml b/feature/account/oauth/src/main/res/values/strings.xml new file mode 100644 index 0000000..b097b0c --- /dev/null +++ b/feature/account/oauth/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + We\'ll redirect you to your email provider to sign in. You need to grant the app access to your email account. + Sign in + Sign in with Google + Signing in using OAuth + OAuth sign in failed + + Authorization canceled + Authorization failed with the following error: %s + OAuth 2.0 is currently not supported with this provider. + The app couldn\'t find a browser to use for granting access to your account. + + + "If you're experiencing problems when signing in with Google, please consult our {placeHolder}." + + support article + diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationRepositoryTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationRepositoryTest.kt new file mode 100644 index 0000000..3d957f2 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationRepositoryTest.kt @@ -0,0 +1,260 @@ +package app.k9mail.feature.account.oauth.data + +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEmpty +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.prop +import kotlinx.coroutines.test.runTest +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationService.TokenResponseCallback +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.GrantTypeValues +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenRequest +import net.openid.appauth.TokenResponse +import net.thunderbird.core.common.oauth.OAuthConfiguration +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AuthorizationRepositoryTest { + + private val service: AuthorizationService = mock() + + @Test + fun `getAuthorizationRequestIntent should return Success with intent when hostname has oauth configuration`() = + runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val emailAddress = "emailAddress" + val intent = Intent() + val authRequestCapture = argumentCaptor().apply { + service.stub { on { getAuthorizationRequestIntent(capture()) }.thenReturn(intent) } + } + + // When + val result = testSubject.getAuthorizationRequestIntent(oAuthConfiguration, emailAddress) + + // Then + assertThat(result).isEqualTo( + AuthorizationIntentResult.Success( + intent = intent, + ), + ) + assertThat(authRequestCapture.firstValue).all { + prop(AuthorizationRequest::configuration).all { + prop(AuthorizationServiceConfiguration::authorizationEndpoint).isEqualTo( + oAuthConfiguration.authorizationEndpoint.toUri(), + ) + prop(AuthorizationServiceConfiguration::tokenEndpoint).isEqualTo( + oAuthConfiguration.tokenEndpoint.toUri(), + ) + } + prop(AuthorizationRequest::clientId).isEqualTo(oAuthConfiguration.clientId) + prop(AuthorizationRequest::responseType).isEqualTo(ResponseTypeValues.CODE) + prop(AuthorizationRequest::redirectUri).isEqualTo(oAuthConfiguration.redirectUri.toUri()) + prop(AuthorizationRequest::scope).isEqualTo("scope scope2") + prop(AuthorizationRequest::loginHint).isEqualTo(emailAddress) + prop(AuthorizationRequest::codeVerifier).isNotNull() + prop(AuthorizationRequest::codeVerifierChallengeMethod).isEqualTo("S256") + prop(AuthorizationRequest::codeVerifierChallenge).isNotNull().isNotEmpty() + } + } + + @Test + fun `getAuthorizationResponse should return AuthorizationResponse when intent invalid`() = runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val intent = Intent().apply { + putExtra(AuthorizationResponse.EXTRA_RESPONSE, authorizationResponse.jsonSerializeString()) + } + + // When + val result = testSubject.getAuthorizationResponse(intent) + + // Then + assertThat(result).isNotNull().all { + prop(AuthorizationResponse::request).all { + prop(AuthorizationRequest::configuration).all { + prop(AuthorizationServiceConfiguration::authorizationEndpoint).isEqualTo( + authorizationConfiguration.authorizationEndpoint, + ) + prop(AuthorizationServiceConfiguration::tokenEndpoint).isEqualTo( + authorizationConfiguration.tokenEndpoint, + ) + } + prop(AuthorizationRequest::clientId).isEqualTo(authorizationRequest.clientId) + prop(AuthorizationRequest::responseType).isEqualTo(authorizationRequest.responseType) + prop(AuthorizationRequest::redirectUri).isEqualTo(authorizationRequest.redirectUri) + } + } + } + + @Test + fun `getAuthorizationResponse should return null when intent is invalid`() = runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val intent = Intent() + + // When + val result = testSubject.getAuthorizationResponse(intent) + + // Then + assertThat(result).isNull() + } + + @Test + fun `getAuthorizationException should return AuthorizationException when intent is valid`() = runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val authorizationException = AuthorizationException( + AuthorizationException.TYPE_OAUTH_AUTHORIZATION_ERROR, + 1, + "error", + "errorDescription", + Uri.parse("https://example.com/errorUri"), + null, + ) + val intent = Intent().apply { + putExtra(AuthorizationException.EXTRA_EXCEPTION, authorizationException.toJsonString()) + } + + // When + val result = testSubject.getAuthorizationException(intent) + + // Then + assertThat(result).isEqualTo(authorizationException) + } + + @Test + fun `getAuthorizationException should return null when intent is invalid`() = runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val intent = Intent() + + // When + val result = testSubject.getAuthorizationException(intent) + + // Then + assertThat(result).isNull() + } + + @Test + fun `getExchangeToken should return success when tokenRequest successful`() = runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val tokenRequest = TokenRequest.Builder( + authorizationConfiguration, + authorizationRequest.clientId, + ).setGrantType(GrantTypeValues.AUTHORIZATION_CODE) + .setAuthorizationCode("authorizationCode") + .setRedirectUri(authorizationRequest.redirectUri) + .build() + val tokenResponse = TokenResponse.Builder(tokenRequest) + .build() + service.stub { + on { performTokenRequest(any(), any()) } doAnswer { + val callback = it.getArgument(1, TokenResponseCallback::class.java) + callback.onTokenRequestCompleted(tokenResponse, null) + } + } + + val result = testSubject.getExchangeToken(authorizationResponse) + + val expectedAuthState = AuthState(authorizationResponse, tokenResponse, null) + val successAuthorizationState = expectedAuthState.toAuthorizationState() + + assertThat(result).isEqualTo(AuthorizationResult.Success(successAuthorizationState)) + } + + fun `getExchangeToken should return failure when tokenRequest failure`() = runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val authorizationException = AuthorizationException( + AuthorizationException.TYPE_OAUTH_AUTHORIZATION_ERROR, + 1, + "error", + "errorDescription", + Uri.parse("https://example.com/errorUri"), + null, + ) + service.stub { + on { performTokenRequest(any(), any()) } doAnswer { + val callback = it.getArgument(1, TokenResponseCallback::class.java) + callback.onTokenRequestCompleted(null, authorizationException) + } + } + + val result = testSubject.getExchangeToken(authorizationResponse) + + assertThat(result).isEqualTo(AuthorizationResult.Failure(authorizationException)) + } + + fun `getExchangeToken should return unknown failure when tokenRequest null for response and exception`() = runTest { + val testSubject = AuthorizationRepository( + service = service, + ) + val exception = Exception("Unknown error") + service.stub { + on { performTokenRequest(any(), any()) } doAnswer { + val callback = it.getArgument(1, TokenResponseCallback::class.java) + callback.onTokenRequestCompleted(null, null) + } + } + + val result = testSubject.getExchangeToken(authorizationResponse) + + assertThat(result).isEqualTo(AuthorizationResult.Failure(exception)) + } + + private companion object { + val oAuthConfiguration = OAuthConfiguration( + clientId = "clientId", + scopes = listOf("scope", "scope2"), + authorizationEndpoint = "auth.example.com", + tokenEndpoint = "token.example.com", + redirectUri = "redirect.example.com", + ) + + val authorizationConfiguration = AuthorizationServiceConfiguration( + Uri.parse("https://example.com/authorize"), + Uri.parse("https://example.com/token"), + ) + + val authorizationRequest = AuthorizationRequest.Builder( + authorizationConfiguration, + "clientId", + "responseType", + Uri.parse("https://example.com/redirectUri"), + ).build() + + val authorizationResponse = AuthorizationResponse.Builder(authorizationRequest) + .setAuthorizationCode("authorizationCode") + .build() + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationStateRepositoryTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationStateRepositoryTest.kt new file mode 100644 index 0000000..1811403 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/data/AuthorizationStateRepositoryTest.kt @@ -0,0 +1,54 @@ +package app.k9mail.feature.account.oauth.data + +import android.net.Uri +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlinx.coroutines.test.runTest +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.TokenRequest +import net.openid.appauth.TokenResponse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AuthorizationStateRepositoryTest { + + @Test + fun `should return false with unauthorized auth state`() = runTest { + val authState = AuthState() + val authorizationState = authState.toAuthorizationState() + val testSubject = AuthorizationStateRepository() + + val result = testSubject.isAuthorized(authorizationState) + + assertThat(result).isFalse() + } + + @Test + fun `should return true with authorized auth state`() = runTest { + val authState = AuthState() + val clientId = "clientId" + val configuration = AuthorizationServiceConfiguration( + Uri.parse("https://example.com"), + Uri.parse("https://example.com"), + ) + val tokenRequest = TokenRequest.Builder(configuration, clientId) + .setGrantType(TokenRequest.GRANT_TYPE_PASSWORD) + .build() + val tokenResponse = TokenResponse.Builder(tokenRequest) + .setAccessToken("accessToken") + .setIdToken("idToken") + .build() + authState.update(tokenResponse, null) + val authorizationState = authState.toAuthorizationState() + + val testSubject = AuthorizationStateRepository() + + val result = testSubject.isAuthorized(authorizationState) + + assertThat(result).isTrue() + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/FakeAuthorizationRepository.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/FakeAuthorizationRepository.kt new file mode 100644 index 0000000..df48ad6 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/FakeAuthorizationRepository.kt @@ -0,0 +1,50 @@ +package app.k9mail.feature.account.oauth.domain + +import android.content.Intent +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import net.thunderbird.core.common.oauth.OAuthConfiguration + +class FakeAuthorizationRepository( + private val answerGetAuthorizationRequestIntent: AuthorizationIntentResult = AuthorizationIntentResult.NotSupported, + private val answerGetAuthorizationResponse: AuthorizationResponse? = null, + private val answerGetAuthorizationException: AuthorizationException? = null, + private val answerGetExchangeToken: AuthorizationResult = AuthorizationResult.Canceled, +) : AccountOAuthDomainContract.AuthorizationRepository { + + var recordedGetAuthorizationRequestIntentConfiguration: OAuthConfiguration? = null + var recordedGetAuthorizationRequestIntentEmailAddress: String? = null + override fun getAuthorizationRequestIntent( + configuration: OAuthConfiguration, + emailAddress: String, + ): AuthorizationIntentResult { + recordedGetAuthorizationRequestIntentConfiguration = configuration + recordedGetAuthorizationRequestIntentEmailAddress = emailAddress + return answerGetAuthorizationRequestIntent + } + + var recordedGetAuthorizationResponseIntent: Intent? = null + + override suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse? { + recordedGetAuthorizationResponseIntent = intent + return answerGetAuthorizationResponse + } + + var recordedGetAuthorizationExceptionIntent: Intent? = null + + override suspend fun getAuthorizationException(intent: Intent): AuthorizationException? { + recordedGetAuthorizationExceptionIntent = intent + return answerGetAuthorizationException + } + + var recordedGetExchangeTokenResponse: AuthorizationResponse? = null + + override suspend fun getExchangeToken( + response: AuthorizationResponse, + ): AuthorizationResult { + recordedGetExchangeTokenResponse = response + return answerGetExchangeToken + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/CheckIsGoogleSignInTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/CheckIsGoogleSignInTest.kt new file mode 100644 index 0000000..e6605d7 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/CheckIsGoogleSignInTest.kt @@ -0,0 +1,37 @@ +package app.k9mail.feature.account.oauth.domain.usecase + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.Test + +class CheckIsGoogleSignInTest { + + private val testSubject = CheckIsGoogleSignIn() + + @Test + fun `should return true when hostname ends with a google domain`() { + val hostnames = listOf( + "mail.gmail.com", + "mail.googlemail.com", + "mail.google.com", + ) + + for (hostname in hostnames) { + assertThat(testSubject.execute(hostname)).isTrue() + } + } + + @Test + fun `should return false when hostname does not end with a google domain`() { + val hostnames = listOf( + "mail.example.com", + "mail.example.org", + "mail.example.net", + ) + + for (hostname in hostnames) { + assertThat(testSubject.execute(hostname)).isFalse() + } + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/FinishOAuthSignInTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/FinishOAuthSignInTest.kt new file mode 100644 index 0000000..e64e134 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/FinishOAuthSignInTest.kt @@ -0,0 +1,83 @@ +package app.k9mail.feature.account.oauth.domain.usecase + +import android.content.Intent +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.oauth.domain.FakeAuthorizationRepository +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FinishOAuthSignInTest { + + @Test + fun `should return failure when intent has encoded exception`() = runTest { + val intent = Intent() + val exception = AuthorizationException( + AuthorizationException.TYPE_GENERAL_ERROR, + 1, + "error", + "error_description", + null, + null, + ) + val repository = FakeAuthorizationRepository( + answerGetAuthorizationException = exception, + ) + val testSubject = FinishOAuthSignIn( + repository = repository, + ) + + val result = testSubject.execute(intent) + + assertThat(result).isEqualTo(AuthorizationResult.Failure(exception)) + assertThat(repository.recordedGetAuthorizationExceptionIntent).isEqualTo(intent) + } + + @Test + fun `should return canceled when intent has no response and no exception`() = runTest { + val intent = Intent() + val repository = FakeAuthorizationRepository( + answerGetAuthorizationResponse = null, + answerGetAuthorizationException = null, + ) + val testSubject = FinishOAuthSignIn( + repository = repository, + ) + + val result = testSubject.execute(intent) + + assertThat(result).isEqualTo(AuthorizationResult.Canceled) + assertThat(repository.recordedGetAuthorizationResponseIntent).isEqualTo(intent) + assertThat(repository.recordedGetAuthorizationExceptionIntent).isEqualTo(intent) + } + + @Test + fun `should return success when intent has response`() = runTest { + val authorizationState = AuthorizationState() + val intent = Intent() + val response = mock() + val repository = FakeAuthorizationRepository( + answerGetAuthorizationResponse = response, + answerGetAuthorizationException = null, + answerGetExchangeToken = AuthorizationResult.Success(authorizationState), + ) + val testSubject = FinishOAuthSignIn( + repository = repository, + ) + + val result = testSubject.execute(intent) + + assertThat(result).isEqualTo(AuthorizationResult.Success(authorizationState)) + assertThat(repository.recordedGetAuthorizationResponseIntent).isEqualTo(intent) + assertThat(repository.recordedGetAuthorizationExceptionIntent).isEqualTo(intent) + assertThat(repository.recordedGetExchangeTokenResponse).isEqualTo(response) + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/GetOAuthRequestIntentTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/GetOAuthRequestIntentTest.kt new file mode 100644 index 0000000..f267316 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/domain/usecase/GetOAuthRequestIntentTest.kt @@ -0,0 +1,61 @@ +package app.k9mail.feature.account.oauth.domain.usecase + +import android.content.Intent +import app.k9mail.feature.account.oauth.domain.FakeAuthorizationRepository +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.oauth.OAuthConfiguration +import org.junit.Test + +class GetOAuthRequestIntentTest { + + @Test + fun `should return NotSupported when hostname has no oauth configuration`() = runTest { + val testSubject = GetOAuthRequestIntent( + repository = FakeAuthorizationRepository(), + configurationProvider = { null }, + ) + val hostname = "hostname" + val emailAddress = "emailAddress" + + val result = testSubject.execute(hostname, emailAddress) + + assertThat(result).isEqualTo(AuthorizationIntentResult.NotSupported) + } + + @Test + fun `should return Success when repository has intent`() = runTest { + val intent = Intent() + val repository = FakeAuthorizationRepository( + answerGetAuthorizationRequestIntent = AuthorizationIntentResult.Success(intent), + ) + val testSubject = GetOAuthRequestIntent( + repository = repository, + configurationProvider = { oAuthConfiguration }, + ) + val hostname = "hostname" + val emailAddress = "emailAddress" + + val result = testSubject.execute(hostname, emailAddress) + + assertThat(result).isEqualTo( + AuthorizationIntentResult.Success( + intent = intent, + ), + ) + assertThat(repository.recordedGetAuthorizationRequestIntentConfiguration).isEqualTo(oAuthConfiguration) + assertThat(repository.recordedGetAuthorizationRequestIntentEmailAddress).isEqualTo(emailAddress) + } + + private companion object { + val oAuthConfiguration = OAuthConfiguration( + clientId = "clientId", + scopes = listOf("scope", "scope2"), + authorizationEndpoint = "auth.example.com", + tokenEndpoint = "token.example.com", + redirectUri = "redirect.example.com", + ) + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthStateTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthStateTest.kt new file mode 100644 index 0000000..c68afbf --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthStateTest.kt @@ -0,0 +1,30 @@ +package app.k9mail.feature.account.oauth.ui + +import app.k9mail.feature.account.common.ui.WizardNavigationBarState +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.prop +import org.junit.Test + +class AccountOAuthStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).all { + prop(State::hostname).isEqualTo("") + prop(State::emailAddress).isEqualTo("") + prop(State::wizardNavigationBarState).isEqualTo( + WizardNavigationBarState( + isNextEnabled = false, + ), + ) + prop(State::isGoogleSignIn).isEqualTo(false) + prop(State::error).isEqualTo(null) + prop(State::isLoading).isEqualTo(false) + } + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewKtTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewKtTest.kt new file mode 100644 index 0000000..ce311a1 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewKtTest.kt @@ -0,0 +1,41 @@ +package app.k9mail.feature.account.oauth.ui + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.oauth.domain.entity.OAuthResult +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AccountOAuthViewKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeAccountOAuthViewModel(initialState) + var oAuthResult: OAuthResult? = null + val authorizationState = AuthorizationState() + + setContentWithTheme { + AccountOAuthView( + onOAuthResult = { oAuthResult = it }, + viewModel = viewModel, + ) + } + + assertThat(oAuthResult).isNull() + + viewModel.effect(Effect.NavigateNext(authorizationState)) + + assertThat(oAuthResult).isEqualTo(OAuthResult.Success(authorizationState)) + + viewModel.effect(Effect.NavigateBack) + + assertThat(oAuthResult).isEqualTo(OAuthResult.Failure) + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewModelTest.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewModelTest.kt new file mode 100644 index 0000000..074131f --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/AccountOAuthViewModelTest.kt @@ -0,0 +1,235 @@ +package app.k9mail.feature.account.oauth.ui + +import android.app.Activity +import android.content.Intent +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult +import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.delay +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AccountOAuthViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should change state when google hostname found on initState`() = runMviTest { + val testSubject = createTestSubject( + isGoogleSignIn = true, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.initState(defaultState) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + defaultState.copy(isGoogleSignIn = true), + ) + } + + @Test + fun `should not change state when no google hostname found on initState`() = runMviTest { + val testSubject = createTestSubject( + isGoogleSignIn = false, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.initState(defaultState) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + defaultState.copy(isGoogleSignIn = false), + ) + } + + @Test + fun `should launch OAuth when SignInClicked event received`() = runMviTest { + val initialState = defaultState + val testSubject = createTestSubject(initialState = initialState) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.SignInClicked) + + assertThat(turbines.effectTurbine.awaitItem()).isEqualTo( + Effect.LaunchOAuth(intent), + ) + } + + @Test + fun `should show error when SignInClicked event received and OAuth is not supported`() = runMviTest { + val initialState = defaultState + val testSubject = createTestSubject( + authorizationIntentResult = AuthorizationIntentResult.NotSupported, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.SignInClicked) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + initialState.copy(error = Error.NotSupported), + ) + } + + @Test + fun `should remove error and launch OAuth when OnRetryClicked event received`() = runMviTest { + val initialState = defaultState.copy( + error = Error.NotSupported, + ) + val testSubject = createTestSubject(initialState = initialState) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnRetryClicked) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + initialState.copy(error = null), + ) + + assertThat(turbines.effectTurbine.awaitItem()).isEqualTo( + Effect.LaunchOAuth(intent), + ) + } + + @Test + fun `should finish OAuth sign in when onOAuthResult received with success`() = runMviTest { + val initialState = defaultState + val authorizationState = AuthorizationState(value = "state") + val testSubject = createTestSubject( + authorizationResult = AuthorizationResult.Success(authorizationState), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent)) + + val loadingState = initialState.copy(isLoading = true) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val successState = loadingState.copy( + isLoading = false, + ) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(successState) + assertThat(turbines.effectTurbine.awaitItem()).isEqualTo( + Effect.NavigateNext(authorizationState), + ) + } + + @Test + fun `should set error state when onOAuthResult received with canceled`() = runMviTest { + val initialState = defaultState + val testSubject = createTestSubject( + authorizationResult = AuthorizationResult.Canceled, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_CANCELED, data = intent)) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + initialState.copy( + error = Error.Canceled, + ), + ) + } + + @Test + fun `should finish OAuth sign in when onOAuthResult received with success but authorization result is cancelled`() = + runMviTest { + val initialState = defaultState + val testSubject = createTestSubject( + authorizationResult = AuthorizationResult.Canceled, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent)) + + val loadingState = initialState.copy(isLoading = true) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val failureState = loadingState.copy( + isLoading = false, + error = Error.Canceled, + ) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(failureState) + } + + @Test + fun `should finish OAuth sign in when onOAuthResult received with success but authorization result is failure`() = + runMviTest { + val initialState = defaultState + val failure = Exception("failure") + val testSubject = createTestSubject( + authorizationResult = AuthorizationResult.Failure(failure), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent)) + + val loadingState = initialState.copy(isLoading = true) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val failureState = loadingState.copy( + isLoading = false, + error = Error.Unknown(failure), + ) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(failureState) + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runMviTest { + val viewModel = createTestSubject() + val turbines = turbinesWithInitialStateCheck(viewModel, State()) + + viewModel.event(Event.OnBackClicked) + + assertThat(turbines.effectTurbine.awaitItem()).isEqualTo(Effect.NavigateBack) + } + + private companion object { + val defaultState = State( + hostname = "example.com", + emailAddress = "test@example.com", + ) + + val intent = Intent() + + fun createTestSubject( + authorizationIntentResult: AuthorizationIntentResult = AuthorizationIntentResult.Success(intent = intent), + authorizationResult: AuthorizationResult = AuthorizationResult.Success(AuthorizationState()), + isGoogleSignIn: Boolean = false, + initialState: State = State(), + ) = AccountOAuthViewModel( + getOAuthRequestIntent = { _, _ -> + authorizationIntentResult + }, + finishOAuthSignIn = { _ -> + delay(50) + authorizationResult + }, + checkIsGoogleSignIn = { _ -> + isGoogleSignIn + }, + initialState = initialState, + ) + } +} diff --git a/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/FakeAccountOAuthViewModel.kt b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/FakeAccountOAuthViewModel.kt new file mode 100644 index 0000000..36a06a2 --- /dev/null +++ b/feature/account/oauth/src/test/kotlin/app/k9mail/feature/account/oauth/ui/FakeAccountOAuthViewModel.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.oauth.ui + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.ViewModel + +class FakeAccountOAuthViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun initState(state: State) { + updateState { state } + } + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/server/certificate/build.gradle.kts b/feature/account/server/certificate/build.gradle.kts new file mode 100644 index 0000000..6be68a5 --- /dev/null +++ b/feature/account/server/certificate/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.account.server.certificate" + resourcePrefix = "account_server_certificate_" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.common) + implementation(projects.feature.account.common) + + implementation(projects.mail.common) + implementation(libs.okio) + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContentPreview.kt b/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContentPreview.kt new file mode 100644 index 0000000..a67a99c --- /dev/null +++ b/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContentPreview.kt @@ -0,0 +1,49 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.common.koin.koinPreview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties +import okio.ByteString.Companion.decodeHex + +@Composable +@Preview(showBackground = true) +internal fun ServerCertificateErrorContentPreview() { + val state = ServerCertificateErrorContract.State( + isShowServerCertificate = true, + certificateError = FormattedServerCertificateError( + hostname = "mail.domain.example", + serverCertificateProperties = ServerCertificateProperties( + subjectAlternativeNames = listOf("*.domain.example", "domain.example"), + notValidBefore = "January 1, 2023, 12:00 AM", + notValidAfter = "December 31, 2023, 11:59 PM", + subject = "CN=*.domain.example", + issuer = "CN=test, O=MZLA", + fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d".decodeHex(), + fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f".decodeHex(), + fingerprintSha512 = ( + "81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa6" + + "7988b2ada669ab4837fcd3d4ea6e2b8db2b9da9197d5112fb369fd006da545de" + ).decodeHex(), + ), + ), + ) + + koinPreview { + factory { DefaultServerNameFormatter() } + factory { DefaultFingerprintFormatter() } + } WithContent { + PreviewWithTheme { + ServerCertificateErrorContent( + innerPadding = PaddingValues(all = 0.dp), + state = state, + scrollState = rememberScrollState(), + ) + } + } +} diff --git a/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorScreenPreview.kt b/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorScreenPreview.kt new file mode 100644 index 0000000..440c231 --- /dev/null +++ b/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorScreenPreview.kt @@ -0,0 +1,74 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.common.koin.koinPreview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError +import app.k9mail.feature.account.server.certificate.domain.usecase.FormatServerCertificateError +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +@Composable +@PreviewDevices +internal fun ServerCertificateErrorScreenPreview() { + val inputStream = """ + -----BEGIN CERTIFICATE----- + MIIE8jCCA9qgAwIBAgISA3bsPKY1eoe/RiBO2t8fUvh1MA0GCSqGSIb3DQEBCwUA + MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD + EwJSMzAeFw0yMzA3MjEyMDU1MTJaFw0yMzEwMTkyMDU1MTFaMBcxFTATBgNVBAMM + DCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJgw + o/dYmPaujmm7sqIuZCe5/kyMwDYKo/pWeeXSvQxRXhxiVvd2Xu9PG0ZXW2R0xOSr + BpaRWm6MXxEnNqNr+n22j9US6M62zJpcuU4tQ0J8xRyIGL6rM53z59rEnCdkF9HQ + +7y7PBlVXCm0jrw51h3Bg5qryvTFyimIbqGw0UJhM7m/NaVJWZyBRwHp7emXxRJC + kC7pdX462c+m/7rQ06iohqUt6mf0DkUH1QjpaVbZm8CBs/GSiLB3LdMHj1uvrXgH + z8dp0nQ3eVRCjuD1xVcZnFoeEa/W3a9ZdcBj1phr9XOwaqYMeAv64g2w40G6fXMH + 9DpHuFarRtleQusiPAMCAwEAAaOCAhswggIXMA4GA1UdDwEB/wQEAwIFoDAdBgNV + HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4E + FgQU1M4J2vX/9DWJnsAtofmT+94js/YwHwYDVR0jBBgwFoAUFC6zF7dYVsuuUAlA + 5h+vnYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vcjMu + by5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9yMy5pLmxlbmNyLm9yZy8w + IwYDVR0RBBwwGoIMKi5iYWRzc2wuY29tggpiYWRzc2wuY29tMBMGA1UdIAQMMAow + CAYGZ4EMAQIBMIIBBQYKKwYBBAHWeQIEAgSB9gSB8wDxAHYAtz77JN+cTbp18jnF + ulj0bF38Qs96nzXEnh0JgSXttJkAAAGJenMebAAABAMARzBFAiAH7A3OWC1AKOcO + jsOP39nzkyoIdrwYFHOOW1qKkLrk9gIhAJD0xFn5FwJvag3K6mTXAlW1EvIy9joA + okiPniKVBIztAHcAejKMVNi3LbYg6jjgUh7phBZwMhOFTTvSK8E6V6NS61IAAAGJ + enMehwAABAMASDBGAiEAvRyLnINSJQ0WyfcU8L0PY5z7//Gq8P9i2HJvZJvnfBkC + IQCHslQMJaOg+rn9+2WW4KKgYW/yDrvBbiVABW5CcYWR0DANBgkqhkiG9w0BAQsF + AAOCAQEAB/JpXHqRnGmCFz3f0hx7mJYY/auSNWnOgpdRpc3JXzcOHHUd+569UGtu + TSMAFEGNXYTbXrG52iGBCrdfe1kkRokg7/KtUvFRelkoNt4FN/4/zVjBxINXVIMb + /7toq4OxBF/sz4SU+eXanmwJyOMmNQzM94zqDwrEmMNuNLYshdWn7XyJCXIM4X+6 + 8M/anh/pi2AviLHH9pszkeuH3AjGJR68cPf+QKC4XcFloR08fhx0jKl8LBa4A6Nm + o7IlPgdD9rzZCsbYe+VNBQWY3358u7ifOJG8r2jXzyHKgUC+OBXgz3kjrClzJfl1 + pjcJhNU1UQtIVERwmxI9F5oQqUyxvA== + -----END CERTIFICATE----- + """.trimIndent().byteInputStream() + + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificate = certificateFactory.generateCertificate(inputStream) as X509Certificate + + val serverCertificateError = ServerCertificateError( + hostname = "mail.domain.example", + port = 143, + certificateChain = listOf(certificate), + ) + + koinPreview { + factory { DefaultServerNameFormatter() } + factory { DefaultFingerprintFormatter() } + } WithContent { + PreviewWithTheme { + ServerCertificateErrorScreen( + onCertificateAccepted = {}, + onBack = {}, + viewModel = ServerCertificateErrorViewModel( + addServerCertificateException = { _, _, _ -> }, + certificateErrorRepository = InMemoryServerCertificateErrorRepository(serverCertificateError), + formatServerCertificateError = FormatServerCertificateError(), + initialState = ServerCertificateErrorContract.State(isShowServerCertificate = false), + ), + ) + } + } +} diff --git a/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateViewPreview.kt b/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateViewPreview.kt new file mode 100644 index 0000000..574e179 --- /dev/null +++ b/feature/account/server/certificate/src/debug/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateViewPreview.kt @@ -0,0 +1,41 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.common.koin.koinPreview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties +import okio.ByteString.Companion.decodeHex + +@Composable +@Preview(showBackground = true) +internal fun ServerCertificateViewPreview() { + val serverCertificateProperties = ServerCertificateProperties( + subjectAlternativeNames = listOf( + "*.domain.example", + "domain.example", + "quite.the.long.domain.name.that.hopefully.exceeds.the.available.width.example", + ), + notValidBefore = "January 1, 2023, 12:00 AM", + notValidAfter = "December 31, 2023, 11:59 PM", + subject = "CN=*.domain.example", + issuer = "CN=test, O=MZLA", + fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d".decodeHex(), + fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f".decodeHex(), + fingerprintSha512 = ( + "81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa6" + + "7988b2ada669ab4837fcd3d4ea6e2b8db2b9da9197d5112fb369fd006da545de" + ).decodeHex(), + ) + + koinPreview { + factory { DefaultServerNameFormatter() } + factory { DefaultFingerprintFormatter() } + } WithContent { + PreviewWithTheme { + ServerCertificateView( + serverCertificateProperties = serverCertificateProperties, + ) + } + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ServerCertificateModule.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ServerCertificateModule.kt new file mode 100644 index 0000000..5c5fa65 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ServerCertificateModule.kt @@ -0,0 +1,43 @@ +package app.k9mail.feature.account.server.certificate + +import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.certificate.domain.usecase.AddServerCertificateException +import app.k9mail.feature.account.server.certificate.domain.usecase.FormatServerCertificateError +import app.k9mail.feature.account.server.certificate.ui.DefaultFingerprintFormatter +import app.k9mail.feature.account.server.certificate.ui.DefaultServerNameFormatter +import app.k9mail.feature.account.server.certificate.ui.FingerprintFormatter +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorViewModel +import app.k9mail.feature.account.server.certificate.ui.ServerNameFormatter +import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val featureAccountServerCertificateModule: Module = module { + + single { + InMemoryServerCertificateErrorRepository() + } + + factory { + AddServerCertificateException( + localKeyStore = get(), + ) + } + + factory { + FormatServerCertificateError() + } + + factory { DefaultServerNameFormatter() } + + factory { DefaultFingerprintFormatter() } + + viewModel { + ServerCertificateErrorViewModel( + certificateErrorRepository = get(), + addServerCertificateException = get(), + formatServerCertificateError = get(), + ) + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/data/InMemoryServerCertificateErrorRepository.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/data/InMemoryServerCertificateErrorRepository.kt new file mode 100644 index 0000000..f07bd32 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/data/InMemoryServerCertificateErrorRepository.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature.account.server.certificate.data + +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError + +class InMemoryServerCertificateErrorRepository( + private var serverCertificateError: ServerCertificateError? = null, +) : ServerCertificateDomainContract.ServerCertificateErrorRepository { + + override fun getCertificateError(): ServerCertificateError? { + return serverCertificateError + } + + override fun setCertificateError(serverCertificateError: ServerCertificateError) { + this.serverCertificateError = serverCertificateError + } + + override fun clearCertificateError() { + serverCertificateError = null + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/ServerCertificateDomainContract.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/ServerCertificateDomainContract.kt new file mode 100644 index 0000000..e15eb6b --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/ServerCertificateDomainContract.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.server.certificate.domain + +import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError +import java.security.cert.X509Certificate + +interface ServerCertificateDomainContract { + + interface ServerCertificateErrorRepository { + fun getCertificateError(): ServerCertificateError? + + fun setCertificateError(serverCertificateError: ServerCertificateError) + + fun clearCertificateError() + } + + interface UseCase { + fun interface AddServerCertificateException { + suspend fun addCertificate(hostname: String, port: Int, certificate: X509Certificate?) + } + + fun interface FormatServerCertificateError { + operator fun invoke(serverCertificateError: ServerCertificateError): FormattedServerCertificateError + } + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/FormattedServerCertificateError.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/FormattedServerCertificateError.kt new file mode 100644 index 0000000..a942b8f --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/FormattedServerCertificateError.kt @@ -0,0 +1,6 @@ +package app.k9mail.feature.account.server.certificate.domain.entity + +data class FormattedServerCertificateError( + val hostname: String, + val serverCertificateProperties: ServerCertificateProperties, +) diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/ServerCertificateError.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/ServerCertificateError.kt new file mode 100644 index 0000000..5a558ad --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/ServerCertificateError.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.account.server.certificate.domain.entity + +import java.security.cert.X509Certificate + +data class ServerCertificateError( + val hostname: String, + val port: Int, + val certificateChain: List, +) diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/ServerCertificateProperties.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/ServerCertificateProperties.kt new file mode 100644 index 0000000..d92fc71 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/entity/ServerCertificateProperties.kt @@ -0,0 +1,14 @@ +package app.k9mail.feature.account.server.certificate.domain.entity + +import okio.ByteString + +data class ServerCertificateProperties( + val subjectAlternativeNames: List, + val notValidBefore: String, + val notValidAfter: String, + val subject: String, + val issuer: String, + val fingerprintSha1: ByteString, + val fingerprintSha256: ByteString, + val fingerprintSha512: ByteString, +) diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/AddServerCertificateException.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/AddServerCertificateException.kt new file mode 100644 index 0000000..de65461 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/AddServerCertificateException.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.server.certificate.domain.usecase + +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract.UseCase +import com.fsck.k9.mail.ssl.LocalKeyStore +import java.security.cert.X509Certificate +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class AddServerCertificateException( + private val localKeyStore: LocalKeyStore, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : UseCase.AddServerCertificateException { + override suspend fun addCertificate(hostname: String, port: Int, certificate: X509Certificate?) { + withContext(coroutineDispatcher) { + localKeyStore.addCertificate(hostname, port, certificate) + } + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/FormatServerCertificateError.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/FormatServerCertificateError.kt new file mode 100644 index 0000000..7274dcc --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/FormatServerCertificateError.kt @@ -0,0 +1,75 @@ +package app.k9mail.feature.account.server.certificate.domain.usecase + +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract.UseCase +import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties +import java.security.cert.X509Certificate +import java.text.DateFormat +import java.util.Date +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import okio.ByteString +import okio.HashingSink +import okio.blackholeSink +import okio.buffer + +@OptIn(ExperimentalTime::class) +class FormatServerCertificateError( + private val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT), +) : UseCase.FormatServerCertificateError { + override operator fun invoke(serverCertificateError: ServerCertificateError): FormattedServerCertificateError { + val certificate = serverCertificateError.certificateChain.firstOrNull() + ?: error("Certificate chain must not be empty") + + val notValidBeforeInstant = Instant.fromEpochMilliseconds(certificate.notBefore.time) + val notValidAfterInstant = Instant.fromEpochMilliseconds(certificate.notAfter.time) + + val subjectAlternativeNames = certificate.subjectAlternativeNames.orEmpty().map { it[1].toString() } + + val notValidBefore = dateFormat.format(Date(notValidBeforeInstant.toEpochMilliseconds())) + val notValidAfter = dateFormat.format(Date(notValidAfterInstant.toEpochMilliseconds())) + + // TODO: Parse the name to be able to display the components in a more structured way. + val subject = certificate.subjectX500Principal.toString() + val issuer = certificate.issuerX500Principal.toString() + + val fingerprintSha1 = computeFingerprint(certificate, HashAlgorithm.SHA_1) + val fingerprintSha256 = computeFingerprint(certificate, HashAlgorithm.SHA_256) + val fingerprintSha512 = computeFingerprint(certificate, HashAlgorithm.SHA_512) + + return FormattedServerCertificateError( + hostname = serverCertificateError.hostname, + serverCertificateProperties = ServerCertificateProperties( + subjectAlternativeNames, + notValidBefore, + notValidAfter, + subject, + issuer, + fingerprintSha1, + fingerprintSha256, + fingerprintSha512, + ), + ) + } + + private fun computeFingerprint(certificate: X509Certificate, algorithm: HashAlgorithm): ByteString { + val sink = when (algorithm) { + HashAlgorithm.SHA_1 -> HashingSink.sha1(blackholeSink()) + HashAlgorithm.SHA_256 -> HashingSink.sha256(blackholeSink()) + HashAlgorithm.SHA_512 -> HashingSink.sha512(blackholeSink()) + } + + sink.buffer() + .write(certificate.encoded) + .flush() + + return sink.hash + } +} + +private enum class HashAlgorithm { + SHA_1, + SHA_256, + SHA_512, +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/FingerprintFormatter.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/FingerprintFormatter.kt new file mode 100644 index 0000000..dd6d430 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/FingerprintFormatter.kt @@ -0,0 +1,50 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle +import okio.ByteString + +/** + * Format a certificate fingerprint. + * + * Outputs bytes as hexadecimal number, separated by `:`. Includes zero width space (U+200B) after colons to decrease + * the chance of long lines being displayed with a line break in the middle of a byte. + */ +internal fun interface FingerprintFormatter { + fun format(fingerprint: ByteString, separatorColor: Color): AnnotatedString +} + +internal class DefaultFingerprintFormatter : FingerprintFormatter { + override fun format(fingerprint: ByteString, separatorColor: Color): AnnotatedString { + require(fingerprint.size > 0) + + return buildAnnotatedString { + appendByteAsHexNumber(fingerprint[0]) + + for (i in 1 until fingerprint.size) { + appendSeparator(separatorColor) + appendByteAsHexNumber(fingerprint[i]) + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + private fun AnnotatedString.Builder.appendByteAsHexNumber(byte: Byte) { + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(byte.toHexString(format = HexFormat.UpperCase)) + } + } + + private fun AnnotatedString.Builder.appendSeparator(separatorColor: Color) { + withStyle(style = SpanStyle(color = separatorColor)) { + append(":") + } + + // Zero width space so long lines will be broken here and not in the middle of a byte value + append('\u200B') + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContent.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContent.kt new file mode 100644 index 0000000..8e7c351 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContent.kt @@ -0,0 +1,108 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.common.baseline.withBaseline +import app.k9mail.core.ui.compose.common.resources.annotatedStringResource +import app.k9mail.core.ui.compose.common.text.bold +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.IconsWithBaseline +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.server.certificate.R +import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.State +import org.koin.compose.koinInject + +@Composable +internal fun ServerCertificateErrorContent( + innerPadding: PaddingValues, + state: State, + scrollState: ScrollState, +) { + ResponsiveWidthContainer(modifier = Modifier.padding(innerPadding)) { contentPadding -> + Column( + modifier = Modifier.verticalScroll(scrollState).padding(contentPadding), + ) { + CertificateErrorOverview(state) + + AnimatedContent( + targetState = state.isShowServerCertificate, + label = "ServerCertificateViewVisibility", + ) { isShowServerCertificate -> + if (isShowServerCertificate) { + ServerCertificateView( + serverCertificateProperties = state.certificateError!!.serverCertificateProperties, + ) + } + } + } + } +} + +@Composable +private fun CertificateErrorOverview(state: State) { + Column( + modifier = Modifier.padding(all = MainTheme.spacings.double), + ) { + WarningTitle() + TextTitleMedium(stringResource(R.string.account_server_certificate_unknown_error_subtitle)) + + Spacer(modifier = Modifier.height(MainTheme.spacings.quadruple)) + + state.certificateError?.let { certificateError -> + CertificateErrorDescription(certificateError) + } + } +} + +@Composable +private fun WarningTitle() { + Row { + val warningIcon = IconsWithBaseline.Filled.warning + val iconSize = MainTheme.sizes.medium + val iconScalingFactor = iconSize / warningIcon.image.defaultHeight + val iconBaseline = warningIcon.baseline * iconScalingFactor + + Icon( + imageVector = warningIcon.image, + tint = MainTheme.colors.warning, + modifier = Modifier + .padding(end = MainTheme.spacings.default) + .requiredSize(iconSize) + .withBaseline(iconBaseline) + .alignByBaseline(), + ) + TextHeadlineMedium( + text = stringResource(R.string.account_server_certificate_warning_title), + modifier = Modifier.alignByBaseline(), + ) + } +} + +@Composable +private fun CertificateErrorDescription( + certificateError: FormattedServerCertificateError, + serverNameFormatter: ServerNameFormatter = koinInject(), +) { + TextBodyLarge( + text = annotatedStringResource( + id = R.string.account_server_certificate_unknown_error_description_format, + serverNameFormatter.format(certificateError.hostname).bold(), + ), + ) +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContract.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContract.kt new file mode 100644 index 0000000..16b16c7 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorContract.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.server.certificate.ui + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError + +interface ServerCertificateErrorContract { + + interface ViewModel : UnidirectionalViewModel + + data class State( + val isShowServerCertificate: Boolean = false, + val certificateError: FormattedServerCertificateError? = null, + ) + + sealed interface Event { + data object OnShowAdvancedClicked : Event + data object OnCertificateAcceptedClicked : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + data object NavigateCertificateAccepted : Effect + data object NavigateBack : Effect + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorScreen.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorScreen.kt new file mode 100644 index 0000000..b45bfcc --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorScreen.kt @@ -0,0 +1,120 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonOutlined +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.server.certificate.R +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Effect +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Event +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.State +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.ViewModel +import org.koin.androidx.compose.koinViewModel + +@Composable +fun ServerCertificateErrorScreen( + onCertificateAccepted: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ViewModel = koinViewModel(), +) { + val scrollState = rememberScrollState() + + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateCertificateAccepted -> onCertificateAccepted() + is Effect.NavigateBack -> onBack() + } + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + bottomBar = { + ButtonBar( + state = state.value, + dispatch = dispatch, + scrollState = scrollState, + ) + }, + modifier = modifier, + ) { innerPadding -> + ServerCertificateErrorContent( + innerPadding = innerPadding, + state = state.value, + scrollState = scrollState, + ) + } +} + +@Composable +private fun ButtonBar( + state: State, + dispatch: (Event) -> Unit, + scrollState: ScrollState, +) { + val elevation by animateDpAsState( + targetValue = if (scrollState.canScrollForward) 8.dp else 0.dp, + label = "BottomBarElevation", + ) + + Surface( + tonalElevation = elevation, + ) { + ResponsiveWidthContainer( + modifier = Modifier + .padding( + start = MainTheme.spacings.double, + end = MainTheme.spacings.double, + top = MainTheme.spacings.half, + bottom = MainTheme.spacings.half, + ), + ) { contentPadding -> + Column(modifier = Modifier.animateContentSize().padding(contentPadding)) { + ButtonFilled( + text = stringResource(R.string.account_server_certificate_button_back), + onClick = { dispatch(Event.OnBackClicked) }, + modifier = Modifier.fillMaxWidth(), + ) + + Crossfade( + targetState = state.isShowServerCertificate, + label = "ContinueButton", + ) { isShowServerCertificate -> + if (isShowServerCertificate) { + ButtonOutlined( + text = stringResource(R.string.account_server_certificate_button_continue), + onClick = { dispatch(Event.OnCertificateAcceptedClicked) }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + ButtonOutlined( + text = stringResource(R.string.account_server_certificate_button_advanced), + onClick = { dispatch(Event.OnShowAdvancedClicked) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorViewModel.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorViewModel.kt new file mode 100644 index 0000000..b273817 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateErrorViewModel.kt @@ -0,0 +1,67 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract.UseCase +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Effect +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Event +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.State +import kotlinx.coroutines.launch + +class ServerCertificateErrorViewModel( + private val certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository, + private val addServerCertificateException: UseCase.AddServerCertificateException, + private val formatServerCertificateError: UseCase.FormatServerCertificateError, + initialState: State = State(), +) : BaseViewModel(initialState), ServerCertificateErrorContract.ViewModel { + private val serverCertificateError: ServerCertificateError? = certificateErrorRepository.getCertificateError() + + init { + serverCertificateError?.let { serverCertificateError -> + updateState { + it.copy( + certificateError = formatServerCertificateError(serverCertificateError), + ) + } + } + } + + override fun event(event: Event) { + when (event) { + Event.OnShowAdvancedClicked -> showAdvanced() + Event.OnCertificateAcceptedClicked -> acceptCertificate() + Event.OnBackClicked -> navigateBack() + } + } + + private fun showAdvanced() { + updateState { + it.copy(isShowServerCertificate = true) + } + } + + private fun acceptCertificate() { + val certificateError = requireNotNull(serverCertificateError) + + viewModelScope.launch { + addServerCertificateException.addCertificate( + hostname = certificateError.hostname, + port = certificateError.port, + certificate = certificateError.certificateChain.first(), + ) + + certificateErrorRepository.clearCertificateError() + navigateCertificateAccepted() + } + } + + private fun navigateBack() { + emitEffect(Effect.NavigateBack) + } + + private fun navigateCertificateAccepted() { + emitEffect(Effect.NavigateCertificateAccepted) + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateView.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateView.kt new file mode 100644 index 0000000..6bae4d7 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerCertificateView.kt @@ -0,0 +1,103 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelSmall +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.server.certificate.R +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties +import okio.ByteString +import org.koin.compose.koinInject + +@Composable +internal fun ServerCertificateView( + serverCertificateProperties: ServerCertificateProperties, + modifier: Modifier = Modifier, + serverNameFormatter: ServerNameFormatter = koinInject(), + fingerprintFormatter: FingerprintFormatter = koinInject(), +) { + Column( + modifier = modifier.padding( + start = MainTheme.spacings.double, + end = MainTheme.spacings.double, + top = MainTheme.spacings.double, + ), + ) { + TextTitleLarge(stringResource(R.string.account_server_certificate_section_title)) + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + + if (serverCertificateProperties.subjectAlternativeNames.isNotEmpty()) { + TextTitleSmall(stringResource(R.string.account_server_certificate_subject_alternative_names)) + for (subjectAlternativeName in serverCertificateProperties.subjectAlternativeNames) { + BulletedListItem(serverNameFormatter.format(subjectAlternativeName)) + } + + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + } + + TextTitleSmall(stringResource(R.string.account_server_certificate_not_valid_before)) + TextBodyLarge(text = serverCertificateProperties.notValidBefore) + + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + + TextTitleSmall(stringResource(R.string.account_server_certificate_not_valid_after)) + TextBodyLarge(text = serverCertificateProperties.notValidAfter) + + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + + TextTitleSmall(stringResource(R.string.account_server_certificate_subject)) + TextBodyLarge(text = serverCertificateProperties.subject) + + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + + TextTitleSmall(stringResource(R.string.account_server_certificate_issuer)) + TextBodyLarge(text = serverCertificateProperties.issuer) + + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + + TextLabelSmall(text = stringResource(R.string.account_server_certificate_fingerprints_section)) + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + + Fingerprint("SHA-1", serverCertificateProperties.fingerprintSha1, fingerprintFormatter) + Fingerprint("SHA-256", serverCertificateProperties.fingerprintSha256, fingerprintFormatter) + Fingerprint("SHA-512", serverCertificateProperties.fingerprintSha512, fingerprintFormatter) + } +} + +@Composable +private fun Fingerprint( + title: String, + fingerprint: ByteString, + fingerprintFormatter: FingerprintFormatter, +) { + val formattedFingerprint = fingerprintFormatter.format( + fingerprint, + separatorColor = MainTheme.colors.onSurfaceVariant, + ) + + Column { + TextTitleSmall(text = title) + TextBodyLarge(text = formattedFingerprint) + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + } +} + +@Composable +private fun BulletedListItem(text: String) { + Row { + TextBodyLarge( + text = "\u2022", + modifier = Modifier.padding(horizontal = MainTheme.spacings.half), + ) + TextBodyLarge(text = text) + } +} diff --git a/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerNameFormatter.kt b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerNameFormatter.kt new file mode 100644 index 0000000..42734c0 --- /dev/null +++ b/feature/account/server/certificate/src/main/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerNameFormatter.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.account.server.certificate.ui + +import net.thunderbird.core.common.net.HostNameUtils + +/** + * Format a hostname or IP address for display. + * + * Inserts zero width space (U+200B) after components separators to decrease the chance of long lines being displayed + * with a line break in the middle of a component (DNS label or number component of an IP address). + */ +internal fun interface ServerNameFormatter { + fun format(hostname: String): String +} + +internal class DefaultServerNameFormatter : ServerNameFormatter { + override fun format(hostname: String): String { + val address = HostNameUtils.isLegalIPv6Address(hostname) + return if (address != null) { + formatIPv6Address(address) + } else { + formatDotName(hostname) + } + } + + private fun formatIPv6Address(address: String): String { + return address.replace(":", ":\u200B") + } + + private fun formatDotName(hostname: String): String { + return hostname.replace(".", ".\u200B") + } +} diff --git a/feature/account/server/certificate/src/main/res/values-am/strings.xml b/feature/account/server/certificate/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ar/strings.xml b/feature/account/server/certificate/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..8a17fdb --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ar/strings.xml @@ -0,0 +1,16 @@ + + + تحذير + خطأ في الشهادة + العودة إلى الوراء (يستحسن) + متقدم + قبول المخاطرة والمتابعة + شهادة الخادم + ليست صالحة قبل + ليست صالحة بعد + اسم الموضوع + اسم المُصدر + الأسماء البديلة للموضوع (Subject Alternative Names) + اكتشف التطبيق تهديدًا أمنيًا محتملاً وأوقف الاتصال بـ 1%s.\nإذا قمت بمتابعة الاتصال، فقد يحاول المهاجمون سرقة معلومات مثل كلمة المرور أو رسائل البريد الإلكتروني الخاصة بك. + بصمات رقمية + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ast/strings.xml b/feature/account/server/certificate/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-az/strings.xml b/feature/account/server/certificate/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-be/strings.xml b/feature/account/server/certificate/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..f894834 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-be/strings.xml @@ -0,0 +1,5 @@ + + + Папярэджанне + Памылка сертыфіката + diff --git a/feature/account/server/certificate/src/main/res/values-bg/strings.xml b/feature/account/server/certificate/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..dce6bdb --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-bg/strings.xml @@ -0,0 +1,16 @@ + + + Внимание + Грешка със сертификатите + Приложението откри потенциална заплаха за сигурността и не продължи свързването с %sf:g>.\nАко продължите, хакерите могат да се опитат да откраднат информация като паролата ви или пощата ви. + Назад (препоръчително) + Разширени + Приемане на риска и продължаване + Сертификат на сървъра + Subject alternative names + Невалиден преди + невалиден след + Тема + Издател + отпечатък + diff --git a/feature/account/server/certificate/src/main/res/values-bn/strings.xml b/feature/account/server/certificate/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-bn/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-br/strings.xml b/feature/account/server/certificate/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..6c6c3d3 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-br/strings.xml @@ -0,0 +1,5 @@ + + + Diwallit + Fazi testeni + diff --git a/feature/account/server/certificate/src/main/res/values-bs/strings.xml b/feature/account/server/certificate/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..89390d4 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-bs/strings.xml @@ -0,0 +1,17 @@ + + + Upozorenje + Greška sertifikata + Idite nazad (preporučeno) + Napredna podešavanja + Prihvatite rizik i nastavite + Sertifikat servera + Alternativna imena subjekta (SAN - Subject alternative names) + Nevažeći poslije + Subjekat iz sertifikata servera + Sertifikaciono tijelo + Fingerprint-i + Aplikacija je detektovala potencijalnu sigurnosnu prijetnju i neće se povezati sa %s. +\nAko nastavite, hakeri bi mogli ukrasti vaše informacije kao što su lozinka ili mejlovi. + Nevažeći prije + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ca/strings.xml b/feature/account/server/certificate/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..1a4d3bb --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ca/strings.xml @@ -0,0 +1,17 @@ + + + Subjecte + Error de certificat + L\'aplicació ha detectat una possible amenaça de seguretat i no ha continuat amb la connexió a %s. +\nSi continues, els atacants podrien intentar robar-te informació com ara la contrasenya o els teus correus. + Certificat del servidor + Avís + No vàlid després + Emissor + Empremtes + Torna enrere (recomanat) + Avançat + Accepta el risc i continua + Noms alternatius del subjecte + Abans no era vàlid + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-co/strings.xml b/feature/account/server/certificate/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..5cbddd8 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-co/strings.xml @@ -0,0 +1,17 @@ + + + Avertimentu + Sbagliu di certificatu + Ritornu (ricumandatu) + Accettà u risicu è cuntinuà + Espertu + Certificatu di u servitore + Inaccettevule nanzu à + Inaccettevule dopu à + Sughjettu + Emettore + Impronte + L’appiecazione hà scupertu una minaccia pussibule di sicurità è ùn hà micca cuntinuatu à cunnettesi à %s. +\nS’è vo cuntinuate, assaltadori puderianu pruvà d’arrubavvi infurmazioni cum’è parolla d’intesa o messaghji elettronichi. + Nomi alternativi di u sughjettu + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-cs/strings.xml b/feature/account/server/certificate/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..146332a --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-cs/strings.xml @@ -0,0 +1,17 @@ + + + Varování + Jít zpět (doporučeno) + Pokročilé + Přijmout riziko a pokračovat + Platné do + Vydavatel + Otisky certifikátu + Chyba certifikátu + Aplikace zjistila potenciální bezpečnostní hrozbu a přerušila připojování k %s. +\nPokud budete pokračovat, útočníci by se mohli pokusit získat informace jako hesla a e-maily. + Certifikát serveru + Alternativní názvy + Platné od + Předmět + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-cy/strings.xml b/feature/account/server/certificate/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-cy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-da/strings.xml b/feature/account/server/certificate/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-da/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-de/strings.xml b/feature/account/server/certificate/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..07d6bdf --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-de/strings.xml @@ -0,0 +1,17 @@ + + + Warnung + Zurück (empfohlen) + Risiko akzeptieren und fortfahren + Serverzertifikat + Alternative Namen + Gültig ab + Gültig bis + Gegenstand + Aussteller + Fingerabdrücke + Zertifikatsfehler + Die App hat eine potenzielle Sicherheitsbedrohung erkannt und hat daher keine Verbindung zu %s hergestellt. +\nWenn du fortfahren möchtest, könnten Angreifer versuchen, Informationen wie dein Passwort oder E-Mails zu stehlen. + Erweitert + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-el/strings.xml b/feature/account/server/certificate/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..9dd8bfb --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-el/strings.xml @@ -0,0 +1,16 @@ + + + Προειδοποίηση + Σφάλμα πιστοποιητικού + Επιστροφή (προτείνεται) + Σύνθετα + Αποδοχή κινδύνου και συνέχεια + Πιστοποιητικό διακομιστή + Εκδότης + Αποτυπώματα + Η εφαρμογή εντόπισε μια πιθανή απειλή ασφαλείας και δεν προχώρησε στη σύνδεση με το %s.\nΕάν συνεχίσετε, οι επιτιθέμενοι ενδέχεται να αποπειραθούν να υποκλέψουν πληροφορίες, όπως τον κωδικό πρόσβασης ή τα email σας. + Μη έγκυρο πριν από + Μη έγκυρο μετά από + Θέμα + Subject alternative names (SAN) + diff --git a/feature/account/server/certificate/src/main/res/values-en-rGB/strings.xml b/feature/account/server/certificate/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..b3ba1ef --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,6 @@ + + + Warning + Certificate error + The app detected a potential security threat and did not continue to connect to %s.\nIf you continue, attackers could try to steal information like your password or emails. + diff --git a/feature/account/server/certificate/src/main/res/values-enm/strings.xml b/feature/account/server/certificate/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-eo/strings.xml b/feature/account/server/certificate/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..3bcdb39 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-eo/strings.xml @@ -0,0 +1,13 @@ + + + Averto + Atestila eraro + Fingrospuroj + Servila atestilo + Reen (rekomendate) + Spertula + Akcepti la riskon kaj daŭrigi + Ne valida antaŭ + Ne valida post + La apo detektis eblan sekurecan minacon kaj ne daŭrigis kontekti al %s.\nSe vi daŭrigas, atakantoj eble provos ŝteli informojn, interalie pasvortojn aŭ retpoŝtmesaĝojn. + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-es/strings.xml b/feature/account/server/certificate/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..0a0ba86 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-es/strings.xml @@ -0,0 +1,17 @@ + + + Error en el certificado + Retroceder (recomendado) + No válido antes de + No es válido después de + Asunto + Emisor + Huella digital + Advertencia + La aplicación ha detectado un posible problema de seguridad y ha cortado la conexión con %s. +\nSi ignoras este aviso y sigues adelante los atacantes podrían conseguir robar información privada como tu contraseña o tus correos electrónicos. + Avanzado + Aceptar el riesgo y continuar + Certificado del servidor + Nombres alternativos del sujeto + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-et/strings.xml b/feature/account/server/certificate/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..ce9d252 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-et/strings.xml @@ -0,0 +1,17 @@ + + + Hoiatus + Sertifikaadiviga + Sisu + Rakendus tuvastas võimaliku turvariski ning loobus ühendusest serveriga %s. +\nKui sa jätkad, siis võimalikud ründajad võivad varastada sinu andmeid, sh. kasutajanime ja salasõna. + Mine tagasi (soovitatav) + Lisateave + Nõustu riskiga ja jätka + Serveri sertifikaat + Serveri SANs nimed + Pole kehtiv varem kui + Pole kehtiv hiljem kui + Väljaandja + Sõrmejäljed + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-eu/strings.xml b/feature/account/server/certificate/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..2a09852 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-eu/strings.xml @@ -0,0 +1,17 @@ + + + Abisua + Ziurtagiriaren errorea + Aplikazioak balizko segurtasun-mehatxu bat detektatu du eta ez du %s-ra konektatzen jarraitu. +\nJarraitzen baduzu, erasotzaileak zure pasahitza edo mezu elektronikoak bezalako informazioa lapurtzen saia litezke. + Atzera itzuli (gomendatua) + Aurreratua + Arriskua onartu eta jarraitu + Zerbitzariaren ziurtagiria + Subjektuaren ordezko izenak + Data hau baino lehen ez du balio + Data honen ondoren ez du balio + Subjektua + Jaulkitzailea + Hatz-markak + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-fa/strings.xml b/feature/account/server/certificate/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..ba754e1 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-fa/strings.xml @@ -0,0 +1,16 @@ + + + بازگشت (توصیه می‌شود) + پیشرفته + پذیرفتن ریسک و ادامه دادن + معتبر نیست تا قبل از + معتبر نیست تا بعد از + موضوع + ایجاد کننده + اثرانگشت‌ها + اخطار + خطای گواهی + برنامه مشکل امنیتی بالقوه‌ای تشخیص داد و به %s وصل نشد. \nدر صورت ادامه خطر سرقت اطلاعاتی چون گذرواژه یا رایانامه‌ها وجود دارد. + گواهی کارساز + نام‌های حایگزین موضوع + diff --git a/feature/account/server/certificate/src/main/res/values-fi/strings.xml b/feature/account/server/certificate/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..1e07d1f --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-fi/strings.xml @@ -0,0 +1,14 @@ + + + Lisäasetukset + Varoitus + Varmennevirhe + Palaa takaisin (suositeltu) + Hyväksy riski ja jatka + Palvelimen varmenne + Vaihtoehtoiset nimet + Ei kelvollinen ennen + Ei kelvollinen jälkeen + Myöntäjä + Sormenjäljet + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-fr/strings.xml b/feature/account/server/certificate/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..4a8f2c9 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-fr/strings.xml @@ -0,0 +1,16 @@ + + + Avertissement + Erreur de certificat + Revenir en arrière (recommandé) + Avancé + Accepter le risque et poursuivre + Certificat du serveur + Autres noms du sujet + Non valide avant le + Non valide après le + Sujet + Émetteur + Empreintes + L’appli a détecté une menace de sécurité potentielle et a cessé de se connecter à %s.\nSi vous poursuivez, des assaillants pourraient tenter de voler des renseignements tels que votre mot de passe ou vos courriels. + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-fy/strings.xml b/feature/account/server/certificate/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..186b84a --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-fy/strings.xml @@ -0,0 +1,16 @@ + + + Warskôging + Sertifikaatflater + De app hat in potinsjeel befeiligingsrisiko detektearre en is net troch gien mei ferbinen nei %s.\nAs jo trochgean kinne oanfallers probearje ynformaasje lykas jo wachtwurd of e-mailberjochten te stellen. + Tebek (oanrekommandearre) + Avansearre + Risiko akseptearje en trochgean + Serversertifikaat + Multi-domeinsertifikaten + Pas jildich mei yngong fan + Unjildich nei + Eigener + Utjouwer + Fingerôfdrukken + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ga/strings.xml b/feature/account/server/certificate/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..6c6848c --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ga/strings.xml @@ -0,0 +1,16 @@ + + + Rabhadh + Téigh ar ais (molta) + Earráid deimhnithe + Bhraith an aip go bhféadfadh bagairt shlándála a bheith ann agus níor leanadh de nascadh le %s.\nMá leanann tú ar aghaidh, d’fhéadfadh ionsaitheoirí iarracht faisnéis a ghoid amhail do phasfhocal nó ríomhphoist. + Roghanna Ard + Glac le riosca agus lean ar aghaidh + Méarloirg + Deimhniú freastalaí + Ainmneacha malartacha ábhair + Gan bailí roimhe seo + Gan bailí tar éis + Ábhar + Eisitheoir + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-gd/strings.xml b/feature/account/server/certificate/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..ad506e0 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-gd/strings.xml @@ -0,0 +1,16 @@ + + + Adhartach + Mhothaich an aplacaid do rud a dh’fhaodadh a bhith na cunnart tèarainteachd is cha do choilean e an ceangal ri %s.\nMa leanas tu air adhart, dh’fhaoidte gum b’ urrainn do luchd-ionnsaigh fiosrachadh a ghoid ort, mar fhaclan-faire no post-d. + Rabhadh + Mearachd an teisteanais + Air ais (mholamaid seo) + Tha mi a’ tuigsinn a’ chunnairt ach air adhart leam + Teisteanas an fhrithealaiche + Subject Alternative Names + Cha bhi seo dligheach ron + Cha bhi seo dligheach an dèidh + An cuspair + Lorgan-meòir + Am foillsichear + diff --git a/feature/account/server/certificate/src/main/res/values-gl/strings.xml b/feature/account/server/certificate/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-gl/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-gu/strings.xml b/feature/account/server/certificate/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-hi/strings.xml b/feature/account/server/certificate/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..42622bc --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-hi/strings.xml @@ -0,0 +1,5 @@ + + + सूचना + प्रमाणपत्र की त्रुटि + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-hr/strings.xml b/feature/account/server/certificate/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..9ef1a1f --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-hr/strings.xml @@ -0,0 +1,13 @@ + + + Upozorenje + Greška certifikata + Aplikacija je otkrila potencijalnu sigurnosnu prijetnju i nije nastavila s povezivanjem na %s.\nAko nastavite, napadači bi mogli ukrasti vaše osobne podatke poput lozinke ili e-mailove. + Vrati se (preporučeno) + Potvrdi rizik i nastavi + Certifikat servera + Nije valjano prije + Nije valjano nakon + Napredno + Izdavatelj + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-hu/strings.xml b/feature/account/server/certificate/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..4e22124 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-hu/strings.xml @@ -0,0 +1,17 @@ + + + Az alkalmazás lehetséges biztonsági fenyegetést észlelt, és nem folytatta a kapcsolódást a(z) %s felé. +\nHa folytatja, akkor támadók olyan információkat próbálhatnak ellopni, mint a jelszava vagy az e-mailjei. + Ujjlenyomatok + Figyelmeztetés + Tanúsítványhiba + Ugrás vissza (ajánlott) + Speciális + Kockázat elfogadása és folytatás + Kiszolgálótanúsítvány + Tárgy alternatív nevei + Eddig nem érvényes + Ezután nem érvényes + Tárgy + Kibocsátó + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-hy/strings.xml b/feature/account/server/certificate/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-in/strings.xml b/feature/account/server/certificate/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..6b0751a --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-in/strings.xml @@ -0,0 +1,16 @@ + + + Aplikasi ini mendeteksi adanya potensi ancaman keamanan dan tidak melanjutkan koneksi ke %s. \nJika tetap Anda lanjutkan, informasi diri Anda seperti kata sandi dan surel dapat dicuri oleh penyerang. + Subjek + Penerbit + Peringatan + Galat sertifikat + Kembali (disarankan) + Lanjutan + Terima risikonya dan lanjutkan + Sertifikat peladen + Nama alternatif subjek + Tidak valid sebelum + Tidak valid sesudah + Sidik jari + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-is/strings.xml b/feature/account/server/certificate/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..dbbd382 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-is/strings.xml @@ -0,0 +1,17 @@ + + + Útgefandi + Fingraför + Aðvörun + Villa í skilríki + Forritið rakst á mögulega öryggisógn og hélt ekki áfram að tengjast við %s. +\nEf þú heldur áfram, gætu óprúttnir aðilar reynt að stela upplýsingum á borð við lykilorðin þín eða tölvupósta. + Fara til baka (ráðlagt) + Ítarlegt + Taka áhættu og halda áfram + Skilríki þjóns + Önnur heiti viðfangs + Ekki gilt fyrir + Ekki gilt eftir + Viðfang + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-it/strings.xml b/feature/account/server/certificate/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..3cb4f5e --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-it/strings.xml @@ -0,0 +1,17 @@ + + + Accetta il rischio e continua + Certificato del server + Avviso + Errore di certificato + L\'applicazione ha rilevato una potenziale minaccia alla sicurezza ed ha impedito la connessione a %s. +\nSe prosegui, gli aggressori potrebbero tentare di rubare informazioni come la password o i tuoi indirizzi email. + Torna indietro (raccomandato) + Avanzato + Soggetto + Subject alternative names (SAN) + Non valido prima del + Non valido dopo il + Emittente + Impronta + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-iw/strings.xml b/feature/account/server/certificate/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..4a2ef80 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-iw/strings.xml @@ -0,0 +1,17 @@ + + + שגיאת תעודה + לחזור אחורה (מומלץ) + מתקדם + לקבל את הסיכון ולהמשיך + תעודת שרת + Subject alternative names + לא תקף לפני + לא תקף אחרי + Subject + מנפיק + אזהרה + היישומון הבחין באיום אבטחה אפשרי ולא המשיך להתחבר אל %s. +\nאם תמשיך, תוקפים יוכלו לנסות לגנוב מידע כגון הסיסמה שלך או הודעות דוא\"ל. + טביעות אצבע + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ja/strings.xml b/feature/account/server/certificate/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..2b05ec2 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ja/strings.xml @@ -0,0 +1,17 @@ + + + サブジェクトの代替名 + 警告 + 証明書エラー + 潜在的なセキュリティ上の脅威がアプリによって検出されたため、%s への接続しませんでした。 +\n続行すると、パスワードやメールなどを攻撃者が奪取できるようになります。 + 戻る (推奨) + 詳細 + リスクを承知した上で続行 + サーバー証明書 + 効力の開始 + 有効期限 + サブジェクト + 発行元 + フィンガープリント + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ka/strings.xml b/feature/account/server/certificate/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-kab/strings.xml b/feature/account/server/certificate/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-kab/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-kk/strings.xml b/feature/account/server/certificate/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..44ad578 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-kk/strings.xml @@ -0,0 +1,8 @@ + + + Ескерту + Артқа (ұсынылады) + Сертификат қатесі + Тәуекелді қабылдап, жалғастыру + Сервер сертификаты + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ko/strings.xml b/feature/account/server/certificate/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..0344bfb --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ko/strings.xml @@ -0,0 +1,17 @@ + + + 뒤로 가기 (권장) + 고급 + 서버 인증서 + SAN + 시작 날짜 + 만료 날짜 + 서버 + 발급자 + 경고 + 잠재적인 보안 위협을 감지하여 %s에 연결하지 않았습니다. +\n계속 진행할 경우, 공격자가 비밀번호나 이메일과 같은 정보를 탈취하려 할 수 있습니다. + 위험을 감수하고 계속 진행 + 인증서 오류 + 지문 + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-lt/strings.xml b/feature/account/server/certificate/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..8bfec83 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-lt/strings.xml @@ -0,0 +1,17 @@ + + + Programa aptiko galima saugos grėsmę, todėl nutraukė bandymą jungtis prie %s. +\nJei tęsite, piktavaliai gali kėsintis pavogti jūsų duomenis, pavyzdžiui slaptažodį ar el. laiškus. + Įspėjimas + Liudijimo klaida + Išsamiau + Grįžti (rekomenduotina) + Priimti riziką ir tęsti + Serverio liudijimas + Subjekto alternatyvieji vardai + Negalioja iki + Negalioja po + Subjektas + Išdavėjas + Kontroliniai kodai + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-lv/strings.xml b/feature/account/server/certificate/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ml/strings.xml b/feature/account/server/certificate/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-nb-rNO/strings.xml b/feature/account/server/certificate/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..57d24bc --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,16 @@ + + + Godta risiko og fortsett + Tjenersertifikat + Ikke gyldig før + Ikke gyldig etter + Fingeravtrykk + Gå tilbake (anbefalt) + Alternative emnenavn + Advarsel + Sertifikatfeil + Avansert + Emne + Utsteder + Appen oppdaget en potensiell sikkerhetsrisiko og gikk ikke viderer til å koble til %s. \nHvis du fortsetter kan angripere prøve å stjele info som f.eks. dine passord eller e-poster. + diff --git a/feature/account/server/certificate/src/main/res/values-nl/strings.xml b/feature/account/server/certificate/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..0ad528f --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-nl/strings.xml @@ -0,0 +1,16 @@ + + + Waarschuwing + Certificaatfout + De app heeft een potentieel beveiligingsrisico gedetecteerd en is niet verder gegaan met verbinden naar %s.\nAls u doorgaat kunnen aanvallers proberen informatie zoals uw wachtwoord of e-mailberichten te stelen. + Terug (aanbevolen) + Geavanceerd + Servercertificaat + Pas geldig vanaf + Ongeldig na + Eigenaar + Uitgever + Vingerafdrukken + Risico accepteren en doorgaan + Multi-domeincertificaten + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-nn/strings.xml b/feature/account/server/certificate/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..921f476 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-nn/strings.xml @@ -0,0 +1,16 @@ + + + Åtvaring + Sertifikatsfeil + Gå tilbake (tilrådd) + Tenersertifikat + Alternative emnenamn + utferdar + Fingeravtrykk + Emne + Appen oppdaga ein potensiell sikkerheitsrisiko og gjekk ikkje vidare til å kople til %s. \nViss du held fram kan angriparar prøve å stele info som t.d. passord eller e-postar. + Avansert + Godta risiko og hald fram + Ikkje gyldig etter + Ikkje gyldig før + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-pl/strings.xml b/feature/account/server/certificate/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..c053a1a --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-pl/strings.xml @@ -0,0 +1,16 @@ + + + Wróć (zalecane) + Zaawansowane + Zaakceptuj ryzyko i kontynuuj + Certyfikat serwera + Alternatywne nazwy podmiotu + Nieważny przed + Nieważny po + Wystawca + Odciski palców + Ostrzeżenie + Błąd certyfikatu + Podmiot + Aplikacja wykryła potencjalne zagrożenie bezpieczeństwa i nie nawiązała dalszego połączenia z %s. \nJeśli będziesz kontynuować, osoby atakujące mogą spróbować ukraść informacje, takie jak hasło lub adresy e-mail. + diff --git a/feature/account/server/certificate/src/main/res/values-pt-rBR/strings.xml b/feature/account/server/certificate/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..8145b45 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,16 @@ + + + Aviso + Erro de certificado + O aplicativo detectou um potencial risco de segurança e não conectou com %s. \nSe você continuar, invasores podem tentar roubar informações como sua senha ou emails. + Voltar (recomendado) + Avançado + Aceitar o risco e continuar + Certificado do servidor + Nomes alternativos do sujeito + Inválido antes de + Inválido após + Sujeito + Emissor + Impressão digital + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-pt-rPT/strings.xml b/feature/account/server/certificate/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..d5f5323 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,17 @@ + + + Aviso + Erro de certificado + Regressar (recomendado) + Avançado + Aceitar risco e continuar + Certificado do servidor + Nomes alternativos + Não válido antes de + Não válido depois de + Emissor + Sujeito + Impressão digital + A aplicação detetou um potencial risco de segurança e não continuou a ligar-se a %s. +\nSe continuar, os atacantes podem tentar roubar informações como a sua palavra-passe ou e-mails. + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-pt/strings.xml b/feature/account/server/certificate/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ro/strings.xml b/feature/account/server/certificate/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..8ca7e47 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ro/strings.xml @@ -0,0 +1,17 @@ + + + Eroare de certificare + Avertisment + Întoarcere (recomandat) + Avansat + Acceptă riscurile și continuă + Certificatul serverului + Nume alternative ale subiectului + Nu este valabil înainte de + Nu este valabil după data de + Subiect + Emitent + Amprente digitale + Aplicația a detectat o potențială amenințare la adresa securității și nu a continuat să se conecteze la %s. +\nDacă continui, atacatorii ar putea încerca să fure informații precum parola sau corespondența electronică. + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ru/strings.xml b/feature/account/server/certificate/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..a46e170 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ru/strings.xml @@ -0,0 +1,17 @@ + + + Внимание + Ошибка сертификата + Вернуться (рекомендуется) + Дополнительно + Принять риск и продолжить + Сертификат сервера + Альтернативные названия субъектов + Не действителен до + Не действителен после + Субъект + Эмитент + Приложение обнаружило потенциальную угрозу безопасности и прекратило подключение к %s. +\nЕсли вы продолжите подключение, злоумышленники могут попытаться украсть информацию, например ваш пароль или электронную почту. + Цифровой отпечаток + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-sk/strings.xml b/feature/account/server/certificate/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..68ded40 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-sk/strings.xml @@ -0,0 +1,17 @@ + + + Varovanie + Chyba certifikátu + Aplikácia zistila potenciálnu bezpečnostnú hrozbu a prerušila pripojovanie k %s. +\nAk budete pokračovať, útočníci by sa mohli pokúsiť získať informácie ako heslá alebo emaily. + Platné do + Vydavateľ + Vrátiť sa späť (odporúčané) + Pokročilé + Prijať riziko a pokračovať + Certifikát servera + Alternatívne názvy + Platné od + Predmet + Otlačky certifikátu + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-sl/strings.xml b/feature/account/server/certificate/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..e19f745 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-sl/strings.xml @@ -0,0 +1,16 @@ + + + Pojdi nazaj (priporočljivo) + Opozorilo + Napaka potrdila + Program je zaznal morebitno varnostno grožnjo in se ni povezal na %s.\nČe nadaljujete, bodo napadalci lahko poizkusili ukrasti podatke, kot je vaše geslo ali e-pošto. + Napredno + Sprejmi tveganje in nadaljuj + Potrdilo strežnika + Ni veljavno pred + Ni veljavno po + Izdajatelj + Prstni odtisi + Nadomestna imena subjekta + Subjekt + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-sq/strings.xml b/feature/account/server/certificate/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..29c2242 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-sq/strings.xml @@ -0,0 +1,17 @@ + + + Aplikacioni pikasi një kërcënim potencial sigurie dhe s’vazhdoi të lidhet me %s. +\nNëse vazhdoni, agresorë mund të përpiqen të vjedhin informacione, bie fjala, fjalëkalim, ose email-e. + Lëshues + Shenja gishtash + Kujdes + Gabim dëshmie + Shko mbrapsht (e rekomanduar) + Të mëtejshme + Pranojeni rrezikun dhe vazhdoni + Dëshmi shërbyesi + Emra alternativë të subjektit + Jo e vlefshme para + Jo e vlefshme pas + Subjekt + diff --git a/feature/account/server/certificate/src/main/res/values-sr/strings.xml b/feature/account/server/certificate/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..f6c08f5 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-sr/strings.xml @@ -0,0 +1,17 @@ + + + Напредно + Није важеће пре + Прихвати ризик и настави + Сертификат сервера + Алтернативни називи субјекта + Упозорење + Грешка сертификата + Апликација је открила потенцијалну безбедносну претњу и није наставила са повезивањем на %s. +\nАко наставите, нападачи би могли покушати да украду информације као што су ваша лозинка или имејлови. + Врати се назад (препоручено) + Није важеће после + Субјект + Издавалац + Отисци прстију + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-sv/strings.xml b/feature/account/server/certificate/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..7e975c5 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-sv/strings.xml @@ -0,0 +1,17 @@ + + + Varning + Certifikatfel + Gå tillbaka (rekommenderas) + Avancerat + Acceptera risken och fortsätt + Servercertifikat + Ämnesalternativa namn + Inte giltigt före + Inte giltigt efter + Ämne + Appen upptäckte ett potentiellt säkerhetshot och fortsatte inte att ansluta till %s. +\nOm du fortsätter kan angripare försöka stjäla information som ditt lösenord eller e-postmeddelanden. + Utfärdare + Fingeravtryck + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-sw/strings.xml b/feature/account/server/certificate/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-ta/strings.xml b/feature/account/server/certificate/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..e4ad710 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-ta/strings.xml @@ -0,0 +1,16 @@ + + + சான்றிதழ் பிழை + பயன்பாடு சாத்தியமான பாதுகாப்பு அச்சுறுத்தலைக் கண்டறிந்தது மற்றும் %s உடன் தொடர்ந்து இணைக்கப்படவில்லை.\n நீங்கள் தொடர்ந்தால், தாக்குதல் நடத்தியவர்கள் உங்கள் கடவுச்சொல் அல்லது மின்னஞ்சல்கள் போன்ற தகவல்களைத் திருட முயற்சி செய்யலாம். + திரும்பிச் செல்லுங்கள் (பரிந்துரைக்கப்படுகிறது) + ஆபத்தை ஏற்றுக்கொண்டு தொடரவும் + சேவையக சான்றிதழ் + பிறகு செல்லுபடியாகாது + பொருள் + மேம்பட்ட + வழங்குபவர் + கைரேகைகள் + எச்சரிக்கை + மாற்று பெயர்கள் + இதற்கு முன் செல்லுபடியாகாது + diff --git a/feature/account/server/certificate/src/main/res/values-th/strings.xml b/feature/account/server/certificate/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-th/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-tr/strings.xml b/feature/account/server/certificate/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..c80d744 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-tr/strings.xml @@ -0,0 +1,16 @@ + + + Sertifika hatası + Gelişmiş + Riski kabul ederek devam et + Sunucu sertifikası + Özne alternatif adları + Geçerlilik başlangıcı + Geçerlilik bitişi + Özne + Düzenleyen + Parmak izleri + Uyarı + Uygulama olası bir güvenlik tehdidi algıladı ve %s sunucusuna bağlanmaya devam etmedi.\nDevam ederseniz saldırganlar parolanız veya e-postalarınız gibi bilgileri çalmaya çalışabilir. + Geri dön (önerilir) + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-uk/strings.xml b/feature/account/server/certificate/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..011afe3 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-uk/strings.xml @@ -0,0 +1,16 @@ + + + Попередження + Помилка сертифіката + Додатково + Погодитися на ризик і продовжити + Альтернативні імена суб\'єктів + Недійсний до + Недійсний після + Суб\'єкт + Цифрові відбитки + Застосунок виявив потенційну загрозу безпеці та припинив з\'єднання з %s.\nЯкщо ви продовжите, зловмисники можуть спробувати викрасти вашу інформацію, наприклад, пароль або електронну пошту. + Повернутися (рекомендовано) + Сертифікат сервера + Видавець + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-vi/strings.xml b/feature/account/server/certificate/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..bd48bc8 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-vi/strings.xml @@ -0,0 +1,17 @@ + + + Ứng dụng phát hiện một lỗ hổng bảo mật tiềm năng và không kết nối đến %s. +\nNếu bạn tiếp tục, kẻ xấu có thể lấy được thông tin cá nhân như mật khẩu và emails. + Cảnh báo + Lỗi chứng chỉ + Quay lại (khuyến nghị) + Nâng cao + Chấp nhận rủi ro và tiếp tục + Chứng chỉ máy chủ + Tên miền phụ + Không hợp lệ trước + Không hợp lệ sau + Chủ đề + Bên cấp + Dấu vân tay + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-zh-rCN/strings.xml b/feature/account/server/certificate/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..76d1d37 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,17 @@ + + + 警告 + 证书错误 + 此应用检测到潜在的安全威胁,没有继续连接到 %s。 +\n如果您继续,攻击者可能会试图窃取您的密码或电子邮件等信息。 + 接受风险并继续 + 返回(推荐) + 高级 + 服务器证书 + 主题备用名称 + 此前无效 + 此后无效 + 主题 + 颁发者 + 指纹 + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values-zh-rTW/strings.xml b/feature/account/server/certificate/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..9ac0075 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,17 @@ + + + 警告 + 憑證錯誤 + 進階 + 應用程式偵測到潛在的安全威脅,並未繼續連接到 %s。 +\n如果您繼續,攻擊者可能會試圖竊取您的密碼或電子郵件等資訊。 + 返回(推薦) + 接受風險並繼續 + 伺服器憑證 + 有效期開始時間 + 有效期結束時間 + 指紋 + 發行者 + 主體替代名稱 + 主體 + \ No newline at end of file diff --git a/feature/account/server/certificate/src/main/res/values/strings.xml b/feature/account/server/certificate/src/main/res/values/strings.xml new file mode 100644 index 0000000..46b12b5 --- /dev/null +++ b/feature/account/server/certificate/src/main/res/values/strings.xml @@ -0,0 +1,41 @@ + + + + Warning + + + Certificate error + + + The app detected a potential security threat and did not continue to connect to %s.\nIf you continue, attackers could try to steal information like your password or emails. + + + Go back (recommended) + + + Advanced + + + Accept risk and continue + + + Server certificate + + + Subject alternative names + + + Not valid before + + + Not valid after + + + Subject + + + Issuer + + + Fingerprints + diff --git a/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ServerCertificateModuleKtTest.kt b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ServerCertificateModuleKtTest.kt new file mode 100644 index 0000000..e84550d --- /dev/null +++ b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ServerCertificateModuleKtTest.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.server.certificate + +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract +import org.junit.Test +import org.koin.test.KoinTest +import org.koin.test.verify.verify + +class ServerCertificateModuleKtTest : KoinTest { + + @Test + fun `should have a valid di module`() { + featureAccountServerCertificateModule.verify( + extraTypes = listOf( + ServerCertificateErrorContract.State::class, + ), + ) + } +} diff --git a/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/FormatServerCertificateErrorTest.kt b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/FormatServerCertificateErrorTest.kt new file mode 100644 index 0000000..8771ab1 --- /dev/null +++ b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/domain/usecase/FormatServerCertificateErrorTest.kt @@ -0,0 +1,137 @@ +package app.k9mail.feature.account.server.certificate.domain.usecase + +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.text.DateFormat +import java.util.Locale +import java.util.TimeZone +import kotlin.test.BeforeTest +import kotlin.test.Test +import okio.ByteString.Companion.decodeHex + +class FormatServerCertificateErrorTest { + @BeforeTest + fun setTimeZone() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @Test + fun `format expired certificate`() { + val formatCertificateError = FormatServerCertificateError( + dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, Locale.ROOT), + ) + val serverCertificateError = ServerCertificateError( + hostname = "expired.badssl.com", + port = 443, + certificateChain = listOf(readCertificate(EXPIRED_CERTIFICATE)), + ) + + val result = formatCertificateError(serverCertificateError) + + assertThat(result.hostname).isEqualTo("expired.badssl.com") + assertThat(result.serverCertificateProperties).isEqualTo( + ServerCertificateProperties( + subjectAlternativeNames = listOf("*.badssl.com", "badssl.com"), + notValidBefore = "2015 Apr 9 00:00", + notValidAfter = "2015 Apr 12 23:59", + subject = "CN=*.badssl.com, OU=PositiveSSL Wildcard, OU=Domain Control Validated", + issuer = "CN=COMODO RSA Domain Validation Secure Server CA, O=COMODO CA Limited, L=Salford, " + + "ST=Greater Manchester, C=GB", + fingerprintSha1 = "404bbd2f1f4cc2fdeef13aabdd523ef61f1c71f3".decodeHex(), + fingerprintSha256 = "ba105ce02bac76888ecee47cd4eb7941653e9ac993b61b2eb3dcc82014d21b4f".decodeHex(), + fingerprintSha512 = ( + "851d7249d64f85d1242090b06224b6da67d442ae38cea5d8a78ae1d7d8c3e2f8" + + "f4ad44c7cf239ba5abb05170e0910fd72e6ea5e5c2604888f6c59e5f57c3db27" + ).decodeHex(), + ), + ) + } + + @Test + fun `format certificate without subject alternative names`() { + val formatCertificateError = FormatServerCertificateError( + dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, Locale.ROOT), + ) + val serverCertificateError = ServerCertificateError( + hostname = "10.0.0.1", + port = 993, + certificateChain = listOf(readCertificate(CERTIFICATE_WITHOUT_SAN)), + ) + + val result = formatCertificateError(serverCertificateError) + + assertThat(result.serverCertificateProperties.subjectAlternativeNames).isEmpty() + } + + private fun readCertificate(asciiArmoredCertificate: String): X509Certificate { + val inputStream = asciiArmoredCertificate.byteInputStream() + + val certificateFactory = CertificateFactory.getInstance("X.509") + return certificateFactory.generateCertificate(inputStream) as X509Certificate + } + + companion object { + val EXPIRED_CERTIFICATE = """ + -----BEGIN CERTIFICATE----- + MIIFSzCCBDOgAwIBAgIQSueVSfqavj8QDxekeOFpCTANBgkqhkiG9w0BAQsFADCB + kDELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G + A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxNjA0BgNV + BAMTLUNPTU9ETyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD + QTAeFw0xNTA0MDkwMDAwMDBaFw0xNTA0MTIyMzU5NTlaMFkxITAfBgNVBAsTGERv + bWFpbiBDb250cm9sIFZhbGlkYXRlZDEdMBsGA1UECxMUUG9zaXRpdmVTU0wgV2ls + ZGNhcmQxFTATBgNVBAMUDCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD + ggEPADCCAQoCggEBAMIE7PiM7gTCs9hQ1XBYzJMY61yoaEmwIrX5lZ6xKyx2PmzA + S2BMTOqytMAPgLaw+XLJhgL5XEFdEyt/ccRLvOmULlA3pmccYYz2QULFRtMWhyef + dOsKnRFSJiFzbIRMeVXk0WvoBj1IFVKtsyjbqv9u/2CVSndrOfEk0TG23U3AxPxT + uW1CrbV8/q71FdIzSOciccfCFHpsKOo3St/qbLVytH5aohbcabFXRNsKEqveww9H + dFxBIuGa+RuT5q0iBikusbpJHAwnnqP7i/dAcgCskgjZjFeEU4EFy+b+a1SYQCeF + xxC7c3DvaRhBB0VVfPlkPz0sw6l865MaTIbRyoUCAwEAAaOCAdUwggHRMB8GA1Ud + IwQYMBaAFJCvajqUWgvYkOoSVnPfQ7Q6KNrnMB0GA1UdDgQWBBSd7sF7gQs6R2lx + GH0RN5O8pRs/+zAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNVHSUE + FjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwTwYDVR0gBEgwRjA6BgsrBgEEAbIxAQIC + BzArMCkGCCsGAQUFBwIBFh1odHRwczovL3NlY3VyZS5jb21vZG8uY29tL0NQUzAI + BgZngQwBAgEwVAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5jb21vZG9jYS5j + b20vQ09NT0RPUlNBRG9tYWluVmFsaWRhdGlvblNlY3VyZVNlcnZlckNBLmNybDCB + hQYIKwYBBQUHAQEEeTB3ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LmNvbW9kb2Nh + LmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0EuY3J0 + MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wIwYDVR0RBBww + GoIMKi5iYWRzc2wuY29tggpiYWRzc2wuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBq + evHa/wMHcnjFZqFPRkMOXxQhjHUa6zbgH6QQFezaMyV8O7UKxwE4PSf9WNnM6i1p + OXy+l+8L1gtY54x/v7NMHfO3kICmNnwUW+wHLQI+G1tjWxWrAPofOxkt3+IjEBEH + fnJ/4r+3ABuYLyw/zoWaJ4wQIghBK4o+gk783SHGVnRwpDTysUCeK1iiWQ8dSO/r + ET7BSp68ZVVtxqPv1dSWzfGuJ/ekVxQ8lEEFeouhN0fX9X3c+s5vMaKwjOrMEpsi + 8TRwz311SotoKQwe6Zaoz7ASH1wq7mcvf71z81oBIgxw+s1F73hczg36TuHvzmWf + RwxPuzZEaFZcVlmtqoq8 + -----END CERTIFICATE----- + """.trimIndent() + + val CERTIFICATE_WITHOUT_SAN = """ + -----BEGIN CERTIFICATE----- + MIIDfDCCAmSgAwIBAgIJAJB2iRjpM5OgMA0GCSqGSIb3DQEBCwUAME4xMTAvBgNV + BAsMKE5vIFNOSSBwcm92aWRlZDsgcGxlYXNlIGZpeCB5b3VyIGNsaWVudC4xGTAX + BgNVBAMTEGludmFsaWQyLmludmFsaWQwHhcNMTUwMTAxMDAwMDAwWhcNMzAwMTAx + MDAwMDAwWjBOMTEwLwYDVQQLDChObyBTTkkgcHJvdmlkZWQ7IHBsZWFzZSBmaXgg + eW91ciBjbGllbnQuMRkwFwYDVQQDExBpbnZhbGlkMi5pbnZhbGlkMIIBIjANBgkq + hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzWJP5cMThJgMBeTvRKKl7N6ZcZAbKDVA + tNBNnRhIgSitXxCzKtt9rp2RHkLn76oZjdNO25EPp+QgMiWU/rkkB00Y18Oahw5f + i8s+K9dRv6i+gSOiv2jlIeW/S0hOswUUDH0JXFkEPKILzpl5ML7wdp5kt93vHxa7 + HswOtAxEz2WtxMdezm/3CgO3sls20wl3W03iI+kCt7HyvhGy2aRPLhJfeABpQr0U + ku3q6mtomy2cgFawekN/X/aH8KknX799MPcuWutM2q88mtUEBsuZmy2nsjK9J7/y + hhCRDzOV/yY8c5+l/u/rWuwwkZ2lgzGp4xBBfhXdr6+m9kmwWCUm9QIDAQABo10w + WzAOBgNVHQ8BAf8EBAMCAqQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC + MA8GA1UdEwEB/wQFMAMBAf8wGQYDVR0OBBIEELsPOJZvPr5PK0bQQWrUrLUwDQYJ + KoZIhvcNAQELBQADggEBALnZ4lRc9WHtafO4Y+0DWp4qgSdaGygzS/wtcRP+S2V+ + HFOCeYDmeZ9qs0WpNlrtyeBKzBH8hOt9y8aUbZBw2M1F2Mi23Q+dhAEUfQCOKbIT + tunBuVfDTTbAHUuNl/eyr78v8Egi133z7zVgydVG1KA0AOSCB+B65glbpx+xMCpg + ZLux9THydwg3tPo/LfYbRCof+Mb8I3ZCY9O6FfZGjuxJn+0ux3SDora3NX/FmJ+i + kTCTsMtIFWhH3hoyYAamOOuITpPZHD7yP0lfbuncGDEqAQu2YWbYxRixfq2VSxgv + gWbFcmkgBLYpE8iDWT3Kdluo1+6PHaDaLg2SacOY6Go= + -----END CERTIFICATE----- + """.trimIndent() + } +} diff --git a/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ui/FingerprintFormatterTest.kt b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ui/FingerprintFormatterTest.kt new file mode 100644 index 0000000..134c3be --- /dev/null +++ b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ui/FingerprintFormatterTest.kt @@ -0,0 +1,47 @@ +package app.k9mail.feature.account.server.certificate.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import okio.ByteString.Companion.decodeHex + +class FingerprintFormatterTest { + private val formatter = DefaultFingerprintFormatter() + + @Test + fun `simple fingerprint`() { + val fingerprint = "0088FF".decodeHex() + val separatorColor = Color.Cyan + + val result = formatter.format(fingerprint, separatorColor) + + assertThat(result).isEqualTo( + buildAnnotatedString { + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append("00") + } + withStyle(SpanStyle(color = separatorColor)) { + append(":") + } + append('\u200B') + + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append("88") + } + withStyle(SpanStyle(color = separatorColor)) { + append(":") + } + append('\u200B') + + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append("FF") + } + }, + ) + } +} diff --git a/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerNameFormatterTest.kt b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerNameFormatterTest.kt new file mode 100644 index 0000000..56e1e00 --- /dev/null +++ b/feature/account/server/certificate/src/test/kotlin/app/k9mail/feature/account/server/certificate/ui/ServerNameFormatterTest.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.server.certificate.ui + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test + +class ServerNameFormatterTest { + private val formatter = DefaultServerNameFormatter() + + @Test + fun hostname() { + assertThat(formatter.format("domain.example")).isEqualTo("domain.\u200Bexample") + } + + @Test + fun `IPv4 address`() { + assertThat(formatter.format("127.0.0.1")).isEqualTo("127.\u200B0.\u200B0.\u200B1") + } + + @Test + fun `IPv6 address`() { + assertThat(formatter.format("2001:db8::1")) + .isEqualTo("2001:\u200B0db8:\u200B0000:\u200B0000:\u200B0000:\u200B0000:\u200B0000:\u200B0001") + } +} diff --git a/feature/account/server/settings/build.gradle.kts b/feature/account/server/settings/build.gradle.kts new file mode 100644 index 0000000..c4cb69f --- /dev/null +++ b/feature/account/server/settings/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.account.server.settings" + resourcePrefix = "account_server_settings_" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.common) + + implementation(projects.mail.common) + implementation(projects.mail.protocols.imap) + + implementation(projects.feature.account.common) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.biometric) + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInputPreview.kt b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInputPreview.kt new file mode 100644 index 0000000..90cb16c --- /dev/null +++ b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInputPreview.kt @@ -0,0 +1,28 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.feature.account.common.domain.entity.InteractionMode + +@Composable +@Preview(showBackground = true) +internal fun ServerSettingsPasswordInputCreatePreview() { + PreviewWithThemes { + ServerSettingsPasswordInput( + mode = InteractionMode.Create, + onPasswordChange = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ServerSettingsPasswordInputEditPreview() { + PreviewWithThemes { + ServerSettingsPasswordInput( + mode = InteractionMode.Edit, + onPasswordChange = {}, + ) + } +} diff --git a/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContentPreview.kt b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContentPreview.kt new file mode 100644 index 0000000..211f651 --- /dev/null +++ b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContentPreview.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.domain.entity.InteractionMode + +@Composable +@PreviewDevices +internal fun IncomingServerSettingsContentPreview() { + PreviewWithTheme { + IncomingServerSettingsContent( + mode = InteractionMode.Create, + onEvent = { }, + state = IncomingServerSettingsContract.State(), + contentPadding = PaddingValues(), + ) + } +} diff --git a/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreenPreview.kt b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreenPreview.kt new file mode 100644 index 0000000..cba1701 --- /dev/null +++ b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreenPreview.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.ui.fake.FakeAccountStateRepository + +@Composable +@PreviewDevices +internal fun IncomingServerSettingsScreenPreview() { + PreviewWithTheme { + IncomingServerSettingsScreen( + onNext = {}, + onBack = {}, + viewModel = IncomingServerSettingsViewModel( + mode = InteractionMode.Create, + validator = IncomingServerSettingsValidator(), + accountStateRepository = FakeAccountStateRepository(), + ), + ) + } +} diff --git a/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/IncomingServerSettingsScreenPreview.kt b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/IncomingServerSettingsScreenPreview.kt new file mode 100644 index 0000000..dbba3e8 --- /dev/null +++ b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/IncomingServerSettingsScreenPreview.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.ui.fake.FakeAccountStateRepository + +@Composable +@PreviewDevices +internal fun OutgoingServerSettingsScreenPreview() { + PreviewWithTheme { + OutgoingServerSettingsScreen( + onNext = {}, + onBack = {}, + viewModel = OutgoingServerSettingsViewModel( + mode = InteractionMode.Create, + validator = OutgoingServerSettingsValidator(), + accountStateRepository = FakeAccountStateRepository(), + ), + ) + } +} diff --git a/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContentPreview.kt b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContentPreview.kt new file mode 100644 index 0000000..1cadc1d --- /dev/null +++ b/feature/account/server/settings/src/debug/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContentPreview.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.domain.entity.InteractionMode + +@Composable +@PreviewDevices +internal fun OutgoingServerSettingsContentPreview() { + PreviewWithTheme { + OutgoingServerSettingsContent( + mode = InteractionMode.Create, + state = OutgoingServerSettingsContract.State(), + onEvent = { }, + contentPadding = PaddingValues(), + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ServerSettingsModule.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ServerSettingsModule.kt new file mode 100644 index 0000000..f7ae572 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ServerSettingsModule.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.account.server.settings + +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsValidator +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsViewModel +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsValidator +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsViewModel +import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val featureAccountServerSettingsModule: Module = module { + factory { IncomingServerSettingsValidator() } + factory { OutgoingServerSettingsValidator() } + + viewModel { + IncomingServerSettingsViewModel( + mode = InteractionMode.Create, + validator = get(), + accountStateRepository = get(), + ) + } + + viewModel { + OutgoingServerSettingsViewModel( + mode = InteractionMode.Create, + validator = get(), + accountStateRepository = get(), + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/ServerSettingsDomainContract.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/ServerSettingsDomainContract.kt new file mode 100644 index 0000000..1de4ae7 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/ServerSettingsDomainContract.kt @@ -0,0 +1,29 @@ +package app.k9mail.feature.account.server.settings.domain + +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +interface ServerSettingsDomainContract { + + interface UseCase { + + fun interface ValidatePassword { + fun execute(password: String): ValidationResult + } + + fun interface ValidateServer { + fun execute(server: String): ValidationResult + } + + fun interface ValidatePort { + fun execute(port: Long?): ValidationResult + } + + fun interface ValidateUsername { + fun execute(username: String): ValidationResult + } + + fun interface ValidateImapPrefix { + fun execute(imapPrefix: String): ValidationResult + } + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateImapPrefix.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateImapPrefix.kt new file mode 100644 index 0000000..68d8ffa --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateImapPrefix.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import app.k9mail.feature.account.server.settings.domain.ServerSettingsDomainContract.UseCase +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class ValidateImapPrefix : UseCase.ValidateImapPrefix { + + override fun execute(imapPrefix: String): ValidationResult { + return when { + imapPrefix.isEmpty() -> ValidationResult.Success + imapPrefix.isBlank() -> ValidationResult.Failure(ValidateImapPrefixError.BlankImapPrefix) + + else -> ValidationResult.Success + } + } + + sealed interface ValidateImapPrefixError : ValidationError { + data object BlankImapPrefix : ValidateImapPrefixError + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePassword.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePassword.kt new file mode 100644 index 0000000..8096739 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePassword.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import app.k9mail.feature.account.server.settings.domain.ServerSettingsDomainContract.UseCase +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class ValidatePassword : UseCase.ValidatePassword { + + // TODO change behavior to allow empty password when no password is required based on auth type + override fun execute(password: String): ValidationResult { + return when { + password.isBlank() -> ValidationResult.Failure(ValidatePasswordError.EmptyPassword) + + else -> ValidationResult.Success + } + } + + sealed interface ValidatePasswordError : ValidationError { + data object EmptyPassword : ValidatePasswordError + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePort.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePort.kt new file mode 100644 index 0000000..c0475ab --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePort.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import app.k9mail.feature.account.server.settings.domain.ServerSettingsDomainContract.UseCase +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class ValidatePort : UseCase.ValidatePort { + + override fun execute(port: Long?): ValidationResult { + return when (port) { + null -> ValidationResult.Failure(ValidatePortError.EmptyPort) + in MIN_PORT_NUMBER..MAX_PORT_NUMBER -> ValidationResult.Success + else -> ValidationResult.Failure(ValidatePortError.InvalidPort) + } + } + + sealed interface ValidatePortError : ValidationError { + data object EmptyPort : ValidatePortError + data object InvalidPort : ValidatePortError + } + + companion object { + const val MAX_PORT_NUMBER = 65535 + const val MIN_PORT_NUMBER = 1 + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateServer.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateServer.kt new file mode 100644 index 0000000..8c16c7f --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateServer.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import app.k9mail.feature.account.server.settings.domain.ServerSettingsDomainContract.UseCase +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.common.net.HostNameUtils + +internal class ValidateServer : UseCase.ValidateServer { + + override fun execute(server: String): ValidationResult { + if (server.isBlank()) { + return ValidationResult.Failure(ValidateServerError.EmptyServer) + } + + return validateHostnameOrIpAddress(server) + } + + private fun validateHostnameOrIpAddress(server: String): ValidationResult { + val isLegalHostNameOrIP = HostNameUtils.isLegalHostNameOrIP(server) != null + + return if (isLegalHostNameOrIP) { + ValidationResult.Success + } else { + ValidationResult.Failure(ValidateServerError.InvalidHostnameOrIpAddress) + } + } + + sealed interface ValidateServerError : ValidationError { + data object EmptyServer : ValidateServerError + data object InvalidHostnameOrIpAddress : ValidateServerError + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateUsername.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateUsername.kt new file mode 100644 index 0000000..794de80 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateUsername.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import app.k9mail.feature.account.server.settings.domain.ServerSettingsDomainContract.UseCase +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class ValidateUsername : UseCase.ValidateUsername { + + override fun execute(username: String): ValidationResult { + return when { + username.isBlank() -> ValidationResult.Failure(ValidateUsernameError.EmptyUsername) + + else -> ValidationResult.Success + } + } + + sealed interface ValidateUsernameError : ValidationError { + data object EmptyUsername : ValidateUsernameError + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt new file mode 100644 index 0000000..7c56ebe --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/BiometricPasswordInput.kt @@ -0,0 +1,72 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import app.k9mail.core.ui.compose.designsystem.molecule.input.InputLayout +import app.k9mail.core.ui.compose.designsystem.molecule.input.PasswordInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.inputContentPadding +import app.k9mail.feature.account.server.settings.R +import kotlinx.coroutines.delay +import app.k9mail.core.ui.compose.designsystem.R as RDesign + +private const val SHOW_WARNING_DURATION = 5000L + +/** + * Variant of [PasswordInput] that only allows the password to be unmasked after the user has authenticated using + * [BiometricPrompt]. + * + * Note: Due to limitations of [BiometricPrompt] this composable can only be used inside a [FragmentActivity]. + */ +@Composable +fun BiometricPasswordInput( + onPasswordChange: (String) -> Unit, + modifier: Modifier = Modifier, + password: String = "", + isRequired: Boolean = false, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + var biometricWarning by remember { mutableStateOf(value = null) } + + LaunchedEffect(key1 = biometricWarning) { + if (biometricWarning != null) { + delay(SHOW_WARNING_DURATION) + biometricWarning = null + } + } + + InputLayout( + modifier = modifier, + contentPadding = contentPadding, + errorMessage = errorMessage, + warningMessage = biometricWarning, + ) { + val title = stringResource(R.string.account_server_settings_password_authentication_title) + val subtitle = stringResource(R.string.account_server_settings_password_authentication_subtitle) + val needScreenLockMessage = + stringResource(R.string.account_server_settings_password_authentication_screen_lock_required) + + TextFieldOutlinedPasswordBiometric( + value = password, + onValueChange = onPasswordChange, + authenticationTitle = title, + authenticationSubtitle = subtitle, + needScreenLockMessage = needScreenLockMessage, + onWarningChange = { biometricWarning = it?.toString() }, + label = stringResource(id = RDesign.string.designsystem_molecule_password_input_label), + isRequired = isRequired, + hasError = errorMessage != null, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ClientCertificateInput.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ClientCertificateInput.kt new file mode 100644 index 0000000..ee10a9c --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ClientCertificateInput.kt @@ -0,0 +1,41 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import android.app.Activity +import android.security.KeyChain +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedFakeSelect +import app.k9mail.core.ui.compose.designsystem.molecule.input.inputContentPadding +import app.k9mail.feature.account.server.settings.R + +@Composable +fun ClientCertificateInput( + alias: String?, + onValueChange: (String?) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth() + .then(modifier), + ) { + val activity = LocalActivity.current as Activity + TextFieldOutlinedFakeSelect( + text = alias ?: stringResource(R.string.account_server_settings_client_certificate_none_selected), + onClick = { + KeyChain.choosePrivateKeyAlias(activity, onValueChange, null, null, null, -1, alias) + }, + modifier = Modifier.fillMaxWidth(), + label = label, + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/EmailExtensions.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/EmailExtensions.kt new file mode 100644 index 0000000..485045c --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/EmailExtensions.kt @@ -0,0 +1,3 @@ +package app.k9mail.feature.account.server.settings.ui.common + +fun String.toInvalidEmailDomain() = ".${this.substringAfter("@")}" diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt new file mode 100644 index 0000000..f2c100f --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/ServerSettingsPasswordInput.kt @@ -0,0 +1,39 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.molecule.input.PasswordInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.inputContentPadding +import app.k9mail.feature.account.common.domain.entity.InteractionMode + +@Composable +fun ServerSettingsPasswordInput( + mode: InteractionMode, + onPasswordChange: (String) -> Unit, + modifier: Modifier = Modifier, + password: String = "", + isRequired: Boolean = false, + errorMessage: String? = null, + contentPadding: PaddingValues = inputContentPadding(), +) { + if (mode == InteractionMode.Create) { + PasswordInput( + onPasswordChange = onPasswordChange, + modifier = modifier, + password = password, + isRequired = isRequired, + errorMessage = errorMessage, + contentPadding = contentPadding, + ) + } else { + BiometricPasswordInput( + onPasswordChange = onPasswordChange, + modifier = modifier, + password = password, + isRequired = isRequired, + errorMessage = errorMessage, + contentPadding = contentPadding, + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt new file mode 100644 index 0000000..6e98079 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/TextFieldOutlinedPasswordBiometric.kt @@ -0,0 +1,131 @@ +package app.k9mail.feature.account.server.settings.ui.common + +import android.view.WindowManager +import androidx.activity.compose.LocalActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.fragment.app.FragmentActivity +import app.k9mail.core.ui.compose.designsystem.atom.textfield.TextFieldOutlinedPassword + +/** + * Variant of [TextFieldOutlinedPassword] that only allows the password to be unmasked after the user has authenticated + * using [BiometricPrompt]. + * + * Note: Due to limitations of [BiometricPrompt] this composable can only be used inside a [FragmentActivity]. + */ +@Suppress("LongParameterList") +@Composable +fun TextFieldOutlinedPasswordBiometric( + value: String, + onValueChange: (String) -> Unit, + authenticationTitle: String, + authenticationSubtitle: String, + needScreenLockMessage: String, + onWarningChange: (CharSequence?) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + isEnabled: Boolean = true, + isReadOnly: Boolean = false, + isRequired: Boolean = false, + hasError: Boolean = false, +) { + var isPasswordVisible by rememberSaveable { mutableStateOf(false) } + var isAuthenticated by rememberSaveable { mutableStateOf(false) } + var isAuthenticationRequired by rememberSaveable { mutableStateOf(true) } + + // If the entire password was removed, we allow the user to unmask the text field without requiring authentication. + if (value.isEmpty()) { + isAuthenticationRequired = false + } + + val activity = LocalActivity.current as FragmentActivity + + TextFieldOutlinedPassword( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = label, + isEnabled = isEnabled, + isReadOnly = isReadOnly, + isRequired = isRequired, + hasError = hasError, + isPasswordVisible = isPasswordVisible, + onPasswordVisibilityToggleClicked = { + if (!isAuthenticationRequired || isAuthenticated) { + isPasswordVisible = !isPasswordVisible + activity.setSecure(isPasswordVisible) + } else { + showBiometricPrompt( + activity, + authenticationTitle, + authenticationSubtitle, + needScreenLockMessage, + onAuthSuccess = { + isAuthenticated = true + isPasswordVisible = true + onWarningChange(null) + activity.setSecure(true) + }, + onAuthError = onWarningChange, + ) + } + }, + ) + + DisposableEffect(key1 = "secureWindow") { + activity.setSecure(isPasswordVisible) + + onDispose { + activity.setSecure(false) + } + } +} + +private fun showBiometricPrompt( + activity: FragmentActivity, + title: String, + subtitle: String, + needScreenLockMessage: String, + onAuthSuccess: () -> Unit, + onAuthError: (CharSequence) -> Unit, +) { + val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onAuthSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (errorCode == BiometricPrompt.ERROR_HW_NOT_PRESENT || + errorCode == BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL || + errorCode == BiometricPrompt.ERROR_NO_BIOMETRICS + ) { + onAuthError(needScreenLockMessage) + } else if (errString.isNotEmpty()) { + onAuthError(errString) + } + } + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + .setTitle(title) + .setSubtitle(subtitle) + .build() + + BiometricPrompt(activity, authenticationCallback).authenticate(promptInfo) +} + +private fun FragmentActivity.setSecure(secure: Boolean) { + window.setFlags(if (secure) WindowManager.LayoutParams.FLAG_SECURE else 0, WindowManager.LayoutParams.FLAG_SECURE) +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/AuthenticationTypeStringMapper.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/AuthenticationTypeStringMapper.kt new file mode 100644 index 0000000..a66246c --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/AuthenticationTypeStringMapper.kt @@ -0,0 +1,29 @@ +package app.k9mail.feature.account.server.settings.ui.common.mapper + +import android.content.res.Resources +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.server.settings.R + +internal fun AuthenticationType.toResourceString(resources: Resources): String { + return when (this) { + AuthenticationType.None -> { + resources.getString(R.string.account_server_settings_authentication_none) + } + + AuthenticationType.PasswordCleartext -> { + resources.getString(R.string.account_server_settings_authentication_password_cleartext) + } + + AuthenticationType.PasswordEncrypted -> { + resources.getString(R.string.account_server_settings_authentication_password_encrypted) + } + + AuthenticationType.ClientCertificate -> { + resources.getString(R.string.account_server_settings_authentication_client_certificate) + } + + AuthenticationType.OAuth2 -> { + resources.getString(R.string.account_server_settings_authentication_client_oauth) + } + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/ConnectionSecurityStringMapper.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/ConnectionSecurityStringMapper.kt new file mode 100644 index 0000000..7d27104 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/ConnectionSecurityStringMapper.kt @@ -0,0 +1,15 @@ +package app.k9mail.feature.account.server.settings.ui.common.mapper + +import android.content.res.Resources +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.server.settings.R + +internal fun ConnectionSecurity.toResourceString(resources: Resources): String { + return when (this) { + ConnectionSecurity.None -> resources.getString(R.string.account_server_settings_connection_security_none) + ConnectionSecurity.StartTLS -> resources.getString( + R.string.account_server_settings_connection_security_start_tls, + ) + ConnectionSecurity.TLS -> resources.getString(R.string.account_server_settings_connection_security_ssl) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/ValidationErrorStringMapper.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/ValidationErrorStringMapper.kt new file mode 100644 index 0000000..af70f26 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/common/mapper/ValidationErrorStringMapper.kt @@ -0,0 +1,69 @@ +package app.k9mail.feature.account.server.settings.ui.common.mapper + +import android.content.res.Resources +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateImapPrefix.ValidateImapPrefixError +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePassword.ValidatePasswordError +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePort.ValidatePortError +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateServer.ValidateServerError +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateUsername.ValidateUsernameError +import net.thunderbird.core.common.domain.usecase.validation.ValidationError + +fun ValidationError.toResourceString(resources: Resources): String { + return when (this) { + is ValidateServerError -> toServerErrorString(resources) + is ValidatePortError -> toPortErrorString(resources) + is ValidateUsernameError -> toUsernameErrorString(resources) + is ValidatePasswordError -> toPasswordErrorString(resources) + is ValidateImapPrefixError -> toImapPrefixErrorString(resources) + else -> throw IllegalArgumentException("Unknown error: $this") + } +} + +private fun ValidateServerError.toServerErrorString(resources: Resources): String { + return when (this) { + ValidateServerError.EmptyServer -> resources.getString( + R.string.account_server_settings_validation_error_server_required, + ) + + ValidateServerError.InvalidHostnameOrIpAddress -> resources.getString( + R.string.account_server_settings_validation_error_server_invalid_ip_or_hostname, + ) + } +} + +private fun ValidatePortError.toPortErrorString(resources: Resources): String { + return when (this) { + is ValidatePortError.EmptyPort -> resources.getString( + R.string.account_server_settings_validation_error_port_required, + ) + + is ValidatePortError.InvalidPort -> resources.getString( + R.string.account_server_settings_validation_error_port_invalid, + ) + } +} + +private fun ValidateUsernameError.toUsernameErrorString(resources: Resources): String { + return when (this) { + ValidateUsernameError.EmptyUsername -> resources.getString( + R.string.account_server_settings_validation_error_username_required, + ) + } +} + +private fun ValidatePasswordError.toPasswordErrorString(resources: Resources): String { + return when (this) { + ValidatePasswordError.EmptyPassword -> resources.getString( + R.string.account_server_settings_validation_error_password_required, + ) + } +} + +private fun ValidateImapPrefixError.toImapPrefixErrorString(resources: Resources): String { + return when (this) { + ValidateImapPrefixError.BlankImapPrefix -> resources.getString( + R.string.account_server_settings_validation_error_imap_prefix_blank, + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContent.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContent.kt new file mode 100644 index 0000000..393bc7f --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContent.kt @@ -0,0 +1,55 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.incoming.content.incomingFormItems +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +internal fun IncomingServerSettingsContent( + mode: InteractionMode, + state: State, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + ResponsiveWidthContainer( + modifier = Modifier + .testTagAsResourceId("IncomingServerSettingsContent") + .padding(contentPadding) + .fillMaxWidth() + .then(modifier), + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = contentPadding, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + incomingFormItems( + mode = mode, + state = state, + onEvent = onEvent, + resources = resources, + ) + } + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContract.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContract.kt new file mode 100644 index 0000000..c731c2e --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsContract.kt @@ -0,0 +1,66 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.entity.toDefaultPort +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.common.ui.WithInteractionMode +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +interface IncomingServerSettingsContract { + + interface ViewModel : UnidirectionalViewModel, WithInteractionMode + + data class State( + val protocolType: IncomingProtocolType = IncomingProtocolType.DEFAULT, + val server: StringInputField = StringInputField(), + val security: ConnectionSecurity = IncomingProtocolType.DEFAULT.defaultConnectionSecurity, + val port: NumberInputField = NumberInputField( + IncomingProtocolType.DEFAULT.toDefaultPort(IncomingProtocolType.DEFAULT.defaultConnectionSecurity), + ), + val authenticationType: AuthenticationType = AuthenticationType.PasswordCleartext, + val username: StringInputField = StringInputField(), + val password: StringInputField = StringInputField(), + val clientCertificateAlias: String? = null, + val imapAutodetectNamespaceEnabled: Boolean = true, + val imapPrefix: StringInputField = StringInputField(), + val imapUseCompression: Boolean = true, + val imapSendClientInfo: Boolean = true, + ) + + sealed interface Event { + data class ProtocolTypeChanged(val protocolType: IncomingProtocolType) : Event + data class ServerChanged(val server: String) : Event + data class SecurityChanged(val security: ConnectionSecurity) : Event + data class PortChanged(val port: Long?) : Event + data class AuthenticationTypeChanged(val authenticationType: AuthenticationType) : Event + data class UsernameChanged(val username: String) : Event + data class PasswordChanged(val password: String) : Event + data class ClientCertificateChanged(val clientCertificateAlias: String?) : Event + data class ImapAutoDetectNamespaceChanged(val enabled: Boolean) : Event + data class ImapPrefixChanged(val imapPrefix: String) : Event + data class ImapUseCompressionChanged(val useCompression: Boolean) : Event + data class ImapSendClientInfoChanged(val sendClientInfo: Boolean) : Event + + data object LoadAccountState : Event + + data object OnNextClicked : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + data object NavigateNext : Effect + data object NavigateBack : Effect + } + + interface Validator { + fun validateServer(server: String): ValidationResult + fun validatePort(port: Long?): ValidationResult + fun validateUsername(username: String): ValidationResult + fun validatePassword(password: String): ValidationResult + fun validateImapPrefix(imapPrefix: String): ValidationResult + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreen.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreen.kt new file mode 100644 index 0000000..d3a693b --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreen.kt @@ -0,0 +1,69 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBarWithBackButton +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.ui.AccountTopAppBar +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.ViewModel + +@Composable +fun IncomingServerSettingsScreen( + onNext: (IncomingServerSettingsContract.State) -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateNext -> onNext(viewModel.state.value) + is Effect.NavigateBack -> onBack() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.LoadAccountState) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + topBar = { + if (viewModel.mode == InteractionMode.Edit) { + TopAppBarWithBackButton( + title = stringResource(id = R.string.account_server_settings_incoming_top_bar_title), + onBackClick = { dispatch(Event.OnBackClicked) }, + ) + } else { + AccountTopAppBar( + title = stringResource(id = R.string.account_server_settings_incoming_top_bar_title), + ) + } + }, + bottomBar = { + WizardNavigationBar( + onNextClick = { dispatch(Event.OnNextClicked) }, + onBackClick = { dispatch(Event.OnBackClicked) }, + ) + }, + modifier = modifier, + ) { innerPadding -> + IncomingServerSettingsContent( + mode = viewModel.mode, + onEvent = { dispatch(it) }, + state = state.value, + contentPadding = innerPadding, + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateExtensions.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateExtensions.kt new file mode 100644 index 0000000..8b32668 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateExtensions.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal val IncomingServerSettingsContract.State.isPasswordFieldVisible: Boolean + get() = authenticationType.isPasswordRequired + +internal val IncomingServerSettingsContract.State.allowedAuthenticationTypes: ImmutableList + get() = protocolType.allowedAuthenticationTypes.toImmutableList() + +internal val IncomingProtocolType.allowedAuthenticationTypes: List + get() = when (this) { + IncomingProtocolType.IMAP -> { + listOf( + AuthenticationType.PasswordCleartext, + AuthenticationType.PasswordEncrypted, + AuthenticationType.ClientCertificate, + AuthenticationType.OAuth2, + ) + } + + IncomingProtocolType.POP3 -> { + listOf( + AuthenticationType.PasswordCleartext, + AuthenticationType.PasswordEncrypted, + AuthenticationType.ClientCertificate, + ) + } + } diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateMapper.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateMapper.kt new file mode 100644 index 0000000..a5979fc --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateMapper.kt @@ -0,0 +1,68 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.entity.toAuthType +import app.k9mail.feature.account.common.domain.entity.toAuthenticationType +import app.k9mail.feature.account.common.domain.entity.toConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.toMailConnectionSecurity +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.common.toInvalidEmailDomain +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace +import com.fsck.k9.mail.store.imap.ImapStoreSettings.isSendClientInfo +import com.fsck.k9.mail.store.imap.ImapStoreSettings.isUseCompression +import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix + +fun AccountState.toIncomingServerSettingsState() = incomingServerSettings?.toIncomingServerSettingsState() + ?: State( + username = StringInputField(value = emailAddress ?: ""), + server = StringInputField(value = emailAddress?.toInvalidEmailDomain() ?: ""), + ) + +private fun ServerSettings.toIncomingServerSettingsState(): State { + return State( + protocolType = IncomingProtocolType.fromName(type), + server = StringInputField(value = host ?: ""), + security = connectionSecurity.toConnectionSecurity(), + port = NumberInputField(value = port.toLong()), + authenticationType = authenticationType.toAuthenticationType(), + username = StringInputField(value = username), + password = StringInputField(value = password ?: ""), + clientCertificateAlias = clientCertificateAlias, + imapAutodetectNamespaceEnabled = autoDetectNamespace, + imapPrefix = StringInputField(value = pathPrefix ?: ""), + imapUseCompression = isUseCompression, + imapSendClientInfo = isSendClientInfo, + ) +} + +internal fun State.toServerSettings(): ServerSettings { + return ServerSettings( + type = protocolType.defaultName, + host = server.value.trim(), + port = port.value!!.toInt(), + connectionSecurity = security.toMailConnectionSecurity(), + authenticationType = authenticationType.toAuthType(), + username = username.value.trim(), + password = if (authenticationType.isPasswordRequired) password.value.trim() else null, + clientCertificateAlias = clientCertificateAlias, + extra = createExtras(), + ) +} + +private fun State.createExtras(): Map { + return if (protocolType == IncomingProtocolType.IMAP) { + ImapStoreSettings.createExtra( + autoDetectNamespace = imapAutodetectNamespaceEnabled, + pathPrefix = if (imapAutodetectNamespaceEnabled) null else imapPrefix.value.trim(), + useCompression = imapUseCompression, + sendClientInfo = imapSendClientInfo, + ) + } else { + emptyMap() + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsValidator.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsValidator.kt new file mode 100644 index 0000000..66e8ab3 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsValidator.kt @@ -0,0 +1,37 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.feature.account.server.settings.domain.ServerSettingsDomainContract.UseCase +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateImapPrefix +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePassword +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePort +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateServer +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateUsername +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class IncomingServerSettingsValidator( + private val serverValidator: UseCase.ValidateServer = ValidateServer(), + private val portValidator: UseCase.ValidatePort = ValidatePort(), + private val usernameValidator: UseCase.ValidateUsername = ValidateUsername(), + private val passwordValidator: UseCase.ValidatePassword = ValidatePassword(), + private val imapPrefixValidator: UseCase.ValidateImapPrefix = ValidateImapPrefix(), +) : IncomingServerSettingsContract.Validator { + override fun validateServer(server: String): ValidationResult { + return serverValidator.execute(server) + } + + override fun validatePort(port: Long?): ValidationResult { + return portValidator.execute(port) + } + + override fun validateUsername(username: String): ValidationResult { + return usernameValidator.execute(username) + } + + override fun validatePassword(password: String): ValidationResult { + return passwordValidator.execute(password) + } + + override fun validateImapPrefix(imapPrefix: String): ValidationResult { + return imapPrefixValidator.execute(imapPrefix) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsViewModel.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsViewModel.kt new file mode 100644 index 0000000..1d04928 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsViewModel.kt @@ -0,0 +1,131 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.domain.entity.toDefaultPort +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Validator +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.ViewModel +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +open class IncomingServerSettingsViewModel( + initialState: State = State(), + override val mode: InteractionMode, + private val validator: Validator, + private val accountStateRepository: AccountDomainContract.AccountStateRepository, +) : BaseViewModel(initialState = initialState), ViewModel { + + @Suppress("CyclomaticComplexMethod") + override fun event(event: Event) { + when (event) { + Event.LoadAccountState -> handleOneTimeEvent(event, ::loadAccountState) + + is Event.ProtocolTypeChanged -> updateProtocolType(event.protocolType) + is Event.ServerChanged -> updateState { it.copy(server = it.server.updateValue(event.server)) } + is Event.SecurityChanged -> updateSecurity(event.security) + is Event.PortChanged -> updateState { it.copy(port = it.port.updateValue(event.port)) } + is Event.AuthenticationTypeChanged -> updateState { it.copy(authenticationType = event.authenticationType) } + is Event.UsernameChanged -> updateState { it.copy(username = it.username.updateValue(event.username)) } + is Event.PasswordChanged -> updateState { it.copy(password = it.password.updateValue(event.password)) } + is Event.ClientCertificateChanged -> updateState { + it.copy(clientCertificateAlias = event.clientCertificateAlias) + } + + is Event.ImapAutoDetectNamespaceChanged -> updateState { + it.copy(imapAutodetectNamespaceEnabled = event.enabled) + } + + is Event.ImapPrefixChanged -> updateState { + it.copy(imapPrefix = it.imapPrefix.updateValue(event.imapPrefix)) + } + + is Event.ImapUseCompressionChanged -> updateState { it.copy(imapUseCompression = event.useCompression) } + is Event.ImapSendClientInfoChanged -> updateState { it.copy(imapSendClientInfo = event.sendClientInfo) } + + Event.OnNextClicked -> onNext() + Event.OnBackClicked -> onBack() + } + } + + protected open fun loadAccountState() { + updateState { + accountStateRepository.getState().toIncomingServerSettingsState() + } + } + + private fun onNext() { + submitConfig() + } + + private fun updateProtocolType(protocolType: IncomingProtocolType) { + updateState { + val allowedAuthenticationTypesForNewProtocol = protocolType.allowedAuthenticationTypes + val newAuthenticationType = if (it.authenticationType in allowedAuthenticationTypesForNewProtocol) { + it.authenticationType + } else { + allowedAuthenticationTypesForNewProtocol.first() + } + + it.copy( + protocolType = protocolType, + security = protocolType.defaultConnectionSecurity, + port = it.port.updateValue( + protocolType.toDefaultPort(protocolType.defaultConnectionSecurity), + ), + authenticationType = newAuthenticationType, + ) + } + } + + private fun updateSecurity(security: ConnectionSecurity) { + updateState { + it.copy( + security = security, + port = it.port.updateValue(it.protocolType.toDefaultPort(security)), + ) + } + } + + private fun submitConfig() = with(state.value) { + val serverResult = validator.validateServer(server.value) + val portResult = validator.validatePort(port.value) + val usernameResult = validator.validateUsername(username.value) + val passwordResult = if (authenticationType.isPasswordRequired) { + validator.validatePassword(password.value) + } else { + ValidationResult.Success + } + val imapPrefixResult = validator.validateImapPrefix(imapPrefix.value) + + val hasError = listOf(serverResult, portResult, usernameResult, passwordResult, imapPrefixResult) + .any { it is ValidationResult.Failure } + + updateState { + it.copy( + server = it.server.updateFromValidationResult(serverResult), + port = it.port.updateFromValidationResult(portResult), + username = it.username.updateFromValidationResult(usernameResult), + password = it.password.updateFromValidationResult(passwordResult), + imapPrefix = it.imapPrefix.updateFromValidationResult(imapPrefixResult), + ) + } + + if (!hasError) { + accountStateRepository.setIncomingServerSettings(state.value.toServerSettings()) + navigateNext() + } + } + + private fun onBack() { + navigateBack() + } + + private fun navigateBack() = emitEffect(Effect.NavigateBack) + + private fun navigateNext() = emitEffect(Effect.NavigateNext) +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/content/ImapFormItems.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/content/ImapFormItems.kt new file mode 100644 index 0000000..14a6dfe --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/content/ImapFormItems.kt @@ -0,0 +1,64 @@ +package app.k9mail.feature.account.server.settings.ui.incoming.content + +import android.content.res.Resources +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.input.CheckboxInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput +import app.k9mail.feature.account.common.ui.item.defaultItemPadding +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.ui.common.mapper.toResourceString +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State + +internal fun LazyListScope.imapFormItems( + state: State, + onEvent: (Event) -> Unit, + resources: Resources, +) { + item { + CheckboxInput( + text = stringResource(id = R.string.account_server_settings_incoming_imap_namespace_label), + checked = state.imapAutodetectNamespaceEnabled, + onCheckedChange = { onEvent(Event.ImapAutoDetectNamespaceChanged(it)) }, + contentPadding = defaultItemPadding(), + ) + } + + item { + if (state.imapAutodetectNamespaceEnabled) { + TextInput( + onTextChange = {}, + label = stringResource(id = R.string.account_server_settings_incoming_imap_prefix_label), + contentPadding = defaultItemPadding(), + isEnabled = false, + ) + } else { + TextInput( + text = state.imapPrefix.value, + errorMessage = state.imapPrefix.error?.toResourceString(resources), + onTextChange = { onEvent(Event.ImapPrefixChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_incoming_imap_prefix_label), + contentPadding = defaultItemPadding(), + ) + } + } + + item { + CheckboxInput( + text = stringResource(id = R.string.account_server_settings_incoming_imap_compression_label), + checked = state.imapUseCompression, + onCheckedChange = { onEvent(Event.ImapUseCompressionChanged(it)) }, + contentPadding = defaultItemPadding(), + ) + } + + item { + CheckboxInput( + text = stringResource(R.string.account_server_settings_incoming_imap_send_client_info_label), + checked = state.imapSendClientInfo, + onCheckedChange = { onEvent(Event.ImapSendClientInfoChanged(it)) }, + contentPadding = defaultItemPadding(), + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/content/IncomingFormItems.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/content/IncomingFormItems.kt new file mode 100644 index 0000000..856a2fd --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/content/IncomingFormItems.kt @@ -0,0 +1,130 @@ +package app.k9mail.feature.account.server.settings.ui.incoming.content + +import android.content.res.Resources +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.input.NumberInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.SelectInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.ui.item.defaultItemPadding +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.ui.common.ClientCertificateInput +import app.k9mail.feature.account.server.settings.ui.common.ServerSettingsPasswordInput +import app.k9mail.feature.account.server.settings.ui.common.mapper.toResourceString +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.incoming.allowedAuthenticationTypes +import app.k9mail.feature.account.server.settings.ui.incoming.isPasswordFieldVisible + +@Suppress("LongMethod") +internal fun LazyListScope.incomingFormItems( + mode: InteractionMode, + state: State, + onEvent: (Event) -> Unit, + resources: Resources, +) { + item { + Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller)) + } + + if (mode == InteractionMode.Create) { + item { + SelectInput( + options = IncomingProtocolType.all(), + selectedOption = state.protocolType, + onOptionChange = { onEvent(Event.ProtocolTypeChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_protocol_type_label), + contentPadding = defaultItemPadding(), + ) + } + } + + item { + TextInput( + text = state.server.value, + errorMessage = state.server.error?.toResourceString(resources), + onTextChange = { onEvent(Event.ServerChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_server_label), + contentPadding = defaultItemPadding(), + keyboardOptions = KeyboardOptions(autoCorrect = false), + ) + } + + item { + SelectInput( + options = ConnectionSecurity.all(), + optionToStringTransformation = { it.toResourceString(resources) }, + selectedOption = state.security, + onOptionChange = { onEvent(Event.SecurityChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_security_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + NumberInput( + value = state.port.value, + errorMessage = state.port.error?.toResourceString(resources), + onValueChange = { onEvent(Event.PortChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_port_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = state.allowedAuthenticationTypes, + optionToStringTransformation = { it.toResourceString(resources) }, + selectedOption = state.authenticationType, + onOptionChange = { onEvent(Event.AuthenticationTypeChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_authentication_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + TextInput( + text = state.username.value, + errorMessage = state.username.error?.toResourceString(resources), + onTextChange = { onEvent(Event.UsernameChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_username_label), + contentPadding = defaultItemPadding(), + keyboardOptions = KeyboardOptions(autoCorrect = false), + contentType = ContentType.Username + ContentType.EmailAddress, + ) + } + + if (state.isPasswordFieldVisible) { + item { + ServerSettingsPasswordInput( + mode = mode, + password = state.password.value, + errorMessage = state.password.error?.toResourceString(resources), + onPasswordChange = { onEvent(Event.PasswordChanged(it)) }, + contentPadding = defaultItemPadding(), + ) + } + } + + item { + ClientCertificateInput( + alias = state.clientCertificateAlias, + onValueChange = { onEvent(Event.ClientCertificateChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_client_certificate_label), + contentPadding = defaultItemPadding(), + ) + } + + if (state.protocolType == IncomingProtocolType.IMAP) { + imapFormItems(state, onEvent, resources) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContent.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContent.kt new file mode 100644 index 0000000..cf58ef9 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContent.kt @@ -0,0 +1,55 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.outgoing.content.outgoingFormItems +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +internal fun OutgoingServerSettingsContent( + mode: InteractionMode, + state: State, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + ResponsiveWidthContainer( + modifier = Modifier + .testTagAsResourceId("OutgoingServerSettingsContent") + .padding(contentPadding) + .fillMaxWidth() + .then(modifier), + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = contentPadding, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + outgoingFormItems( + mode = mode, + state = state, + onEvent = onEvent, + resources = resources, + ) + } + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContract.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContract.kt new file mode 100644 index 0000000..aef9517 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsContract.kt @@ -0,0 +1,52 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.toSmtpDefaultPort +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.common.ui.WithInteractionMode +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +interface OutgoingServerSettingsContract { + + interface ViewModel : UnidirectionalViewModel, WithInteractionMode + + data class State( + val server: StringInputField = StringInputField(), + val security: ConnectionSecurity = ConnectionSecurity.DEFAULT, + val port: NumberInputField = NumberInputField(ConnectionSecurity.DEFAULT.toSmtpDefaultPort()), + val authenticationType: AuthenticationType = AuthenticationType.PasswordCleartext, + val username: StringInputField = StringInputField(), + val password: StringInputField = StringInputField(), + val clientCertificateAlias: String? = null, + ) + + sealed interface Event { + data class ServerChanged(val server: String) : Event + data class SecurityChanged(val security: ConnectionSecurity) : Event + data class PortChanged(val port: Long?) : Event + data class AuthenticationTypeChanged(val authenticationType: AuthenticationType) : Event + data class UsernameChanged(val username: String) : Event + data class PasswordChanged(val password: String) : Event + data class ClientCertificateChanged(val clientCertificateAlias: String?) : Event + + data object LoadAccountState : Event + + data object OnNextClicked : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + data object NavigateNext : Effect + data object NavigateBack : Effect + } + + interface Validator { + fun validateServer(server: String): ValidationResult + fun validatePort(port: Long?): ValidationResult + fun validateUsername(username: String): ValidationResult + fun validatePassword(password: String): ValidationResult + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsScreen.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsScreen.kt new file mode 100644 index 0000000..10172dd --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsScreen.kt @@ -0,0 +1,69 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBarWithBackButton +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.ui.AccountTopAppBar +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.ViewModel + +@Composable +fun OutgoingServerSettingsScreen( + onNext: () -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.NavigateBack -> onBack() + Effect.NavigateNext -> onNext() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.LoadAccountState) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + topBar = { + if (viewModel.mode == InteractionMode.Edit) { + TopAppBarWithBackButton( + title = stringResource(id = R.string.account_server_settings_outgoing_top_bar_title), + onBackClick = { dispatch(Event.OnBackClicked) }, + ) + } else { + AccountTopAppBar( + title = stringResource(id = R.string.account_server_settings_outgoing_top_bar_title), + ) + } + }, + bottomBar = { + WizardNavigationBar( + onNextClick = { dispatch(Event.OnNextClicked) }, + onBackClick = { dispatch(Event.OnBackClicked) }, + ) + }, + modifier = modifier, + ) { innerPadding -> + OutgoingServerSettingsContent( + mode = viewModel.mode, + state = state.value, + onEvent = { dispatch(it) }, + contentPadding = innerPadding, + ) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateExtensions.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateExtensions.kt new file mode 100644 index 0000000..5179c7c --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateExtensions.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +internal val OutgoingServerSettingsContract.State.isUsernameFieldVisible: Boolean + get() = authenticationType.isUsernameRequired + +internal val OutgoingServerSettingsContract.State.isPasswordFieldVisible: Boolean + get() = authenticationType.isPasswordRequired diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateMapper.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateMapper.kt new file mode 100644 index 0000000..67313ba --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateMapper.kt @@ -0,0 +1,56 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.toAuthType +import app.k9mail.feature.account.common.domain.entity.toAuthenticationType +import app.k9mail.feature.account.common.domain.entity.toConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.toMailConnectionSecurity +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.common.toInvalidEmailDomain +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import com.fsck.k9.mail.ServerSettings + +fun AccountState.toOutgoingServerSettingsState(): State { + val password = getOutgoingServerPassword() + + return outgoingServerSettings?.toOutgoingServerSettingsState(password) + ?: State( + username = StringInputField(value = emailAddress ?: ""), + password = StringInputField(value = password), + server = StringInputField(value = emailAddress?.toInvalidEmailDomain() ?: ""), + ) +} + +private fun AccountState.getOutgoingServerPassword(): String { + return if (outgoingServerSettings?.authenticationType?.toAuthenticationType()?.isPasswordRequired == false) { + "" + } else { + outgoingServerSettings?.password ?: incomingServerSettings?.password ?: "" + } +} + +private fun ServerSettings.toOutgoingServerSettingsState(password: String): State { + return State( + server = StringInputField(value = host ?: ""), + security = connectionSecurity.toConnectionSecurity(), + port = NumberInputField(value = port.toLong()), + authenticationType = authenticationType.toAuthenticationType(), + username = StringInputField(value = username), + password = StringInputField(value = password), + clientCertificateAlias = clientCertificateAlias, + ) +} + +internal fun State.toServerSettings(): ServerSettings { + return ServerSettings( + type = "smtp", + host = server.value.trim(), + port = port.value!!.toInt(), + connectionSecurity = security.toMailConnectionSecurity(), + authenticationType = authenticationType.toAuthType(), + username = if (authenticationType.isUsernameRequired) username.value.trim() else "", + password = if (authenticationType.isPasswordRequired) password.value.trim() else null, + clientCertificateAlias = clientCertificateAlias, + ) +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsValidator.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsValidator.kt new file mode 100644 index 0000000..00781f0 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsValidator.kt @@ -0,0 +1,30 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePassword +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePort +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateServer +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateUsername +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class OutgoingServerSettingsValidator( + private val serverValidator: ValidateServer = ValidateServer(), + private val portValidator: ValidatePort = ValidatePort(), + private val usernameValidator: ValidateUsername = ValidateUsername(), + private val passwordValidator: ValidatePassword = ValidatePassword(), +) : OutgoingServerSettingsContract.Validator { + override fun validateServer(server: String): ValidationResult { + return serverValidator.execute(server) + } + + override fun validatePort(port: Long?): ValidationResult { + return portValidator.execute(port) + } + + override fun validateUsername(username: String): ValidationResult { + return usernameValidator.execute(username) + } + + override fun validatePassword(password: String): ValidationResult { + return passwordValidator.execute(password) + } +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsViewModel.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsViewModel.kt new file mode 100644 index 0000000..4bba315 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsViewModel.kt @@ -0,0 +1,95 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.domain.entity.toSmtpDefaultPort +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Validator +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.ViewModel +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +open class OutgoingServerSettingsViewModel( + initialState: State = State(), + override val mode: InteractionMode, + private val validator: Validator, + private val accountStateRepository: AccountDomainContract.AccountStateRepository, +) : BaseViewModel(initialState = initialState), ViewModel { + + override fun event(event: Event) { + when (event) { + Event.LoadAccountState -> handleOneTimeEvent(event, ::loadAccountState) + + is Event.ServerChanged -> updateState { it.copy(server = it.server.updateValue(event.server)) } + is Event.SecurityChanged -> updateSecurity(event.security) + is Event.PortChanged -> updateState { it.copy(port = it.port.updateValue(event.port)) } + is Event.AuthenticationTypeChanged -> updateState { it.copy(authenticationType = event.authenticationType) } + is Event.UsernameChanged -> updateState { it.copy(username = it.username.updateValue(event.username)) } + is Event.PasswordChanged -> updateState { it.copy(password = it.password.updateValue(event.password)) } + is Event.ClientCertificateChanged -> updateState { + it.copy(clientCertificateAlias = event.clientCertificateAlias) + } + + Event.OnNextClicked -> onNext() + Event.OnBackClicked -> onBack() + } + } + + protected open fun loadAccountState() { + updateState { + accountStateRepository.getState().toOutgoingServerSettingsState() + } + } + + private fun onNext() { + submitConfig() + } + + private fun updateSecurity(security: ConnectionSecurity) { + updateState { + it.copy( + security = security, + port = it.port.updateValue(security.toSmtpDefaultPort()), + ) + } + } + + private fun submitConfig() = with(state.value) { + val serverResult = validator.validateServer(server.value) + val portResult = validator.validatePort(port.value) + val usernameResult = validator.validateUsername(username.value) + val passwordResult = if (authenticationType.isPasswordRequired) { + validator.validatePassword(password.value) + } else { + ValidationResult.Success + } + + val hasError = listOf(serverResult, portResult, usernameResult, passwordResult) + .any { it is ValidationResult.Failure } + + updateState { + it.copy( + server = it.server.updateFromValidationResult(serverResult), + port = it.port.updateFromValidationResult(portResult), + username = it.username.updateFromValidationResult(usernameResult), + password = it.password.updateFromValidationResult(passwordResult), + ) + } + + if (!hasError) { + accountStateRepository.setOutgoingServerSettings(state.value.toServerSettings()) + navigateNext() + } + } + + private fun onBack() { + navigateBack() + } + + private fun navigateBack() = emitEffect(Effect.NavigateBack) + + private fun navigateNext() = emitEffect(Effect.NavigateNext) +} diff --git a/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/content/OutgoingFormItems.kt b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/content/OutgoingFormItems.kt new file mode 100644 index 0000000..8f8f250 --- /dev/null +++ b/feature/account/server/settings/src/main/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/content/OutgoingFormItems.kt @@ -0,0 +1,120 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing.content + +import android.content.res.Resources +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.input.NumberInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.SelectInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.ui.item.defaultItemPadding +import app.k9mail.feature.account.server.settings.R +import app.k9mail.feature.account.server.settings.ui.common.ClientCertificateInput +import app.k9mail.feature.account.server.settings.ui.common.ServerSettingsPasswordInput +import app.k9mail.feature.account.server.settings.ui.common.mapper.toResourceString +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.outgoing.isPasswordFieldVisible +import app.k9mail.feature.account.server.settings.ui.outgoing.isUsernameFieldVisible + +@Suppress("LongMethod") +internal fun LazyListScope.outgoingFormItems( + mode: InteractionMode, + state: State, + onEvent: (Event) -> Unit, + resources: Resources, +) { + item { + Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller)) + } + + item { + TextInput( + text = state.server.value, + errorMessage = state.server.error?.toResourceString(resources), + onTextChange = { onEvent(Event.ServerChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_server_label), + isRequired = true, + contentPadding = defaultItemPadding(), + keyboardOptions = KeyboardOptions(autoCorrect = false), + ) + } + + item { + SelectInput( + options = ConnectionSecurity.all(), + optionToStringTransformation = { it.toResourceString(resources) }, + selectedOption = state.security, + onOptionChange = { onEvent(Event.SecurityChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_security_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + NumberInput( + value = state.port.value, + errorMessage = state.port.error?.toResourceString(resources), + onValueChange = { onEvent(Event.PortChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_port_label), + isRequired = true, + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = AuthenticationType.outgoing(), + optionToStringTransformation = { it.toResourceString(resources) }, + selectedOption = state.authenticationType, + onOptionChange = { onEvent(Event.AuthenticationTypeChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_authentication_label), + contentPadding = defaultItemPadding(), + ) + } + + if (state.isUsernameFieldVisible) { + item { + TextInput( + text = state.username.value, + errorMessage = state.username.error?.toResourceString(resources), + onTextChange = { onEvent(Event.UsernameChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_username_label), + isRequired = true, + contentPadding = defaultItemPadding(), + keyboardOptions = KeyboardOptions(autoCorrect = false), + contentType = ContentType.Username + ContentType.EmailAddress, + ) + } + } + + if (state.isPasswordFieldVisible) { + item { + ServerSettingsPasswordInput( + mode = mode, + password = state.password.value, + errorMessage = state.password.error?.toResourceString(resources), + onPasswordChange = { onEvent(Event.PasswordChanged(it)) }, + isRequired = true, + contentPadding = defaultItemPadding(), + ) + } + } + + item { + ClientCertificateInput( + alias = state.clientCertificateAlias, + onValueChange = { onEvent(Event.ClientCertificateChanged(it)) }, + label = stringResource(id = R.string.account_server_settings_client_certificate_label), + contentPadding = defaultItemPadding(), + ) + } +} diff --git a/feature/account/server/settings/src/main/res/values-am/strings.xml b/feature/account/server/settings/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ar/strings.xml b/feature/account/server/settings/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..034e9e9 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ar/strings.xml @@ -0,0 +1,35 @@ + + + لا يمكن أن تكون بادئة IMAP فارغة. + البروتوكول + الخادم + الحماية + المنفذ + المصادقة + StartTLS + SSL/TLS + كلمة المرور العادية + كلمة المرور مشفرة + شهادة العميل + OAuth 2.0 + شهادة العميل + التعرف التلقائي على مساحة اسم IMAP + بادئة مسار IMAP + استخدام تقنية الضغط + اسم المستخدم + بدون + بدون + إعدادات خادم البريد الوارد + بدون + يُرجى ادخال اسم المستخدم. + يُرجى إدخال كلمة المرور. + إعدادات خادم البريد الصادر + يُرجى ادخال اسم الخادم. + يُرجى ادخال المنفذ. + المنفذ غير صالح (يجب أن يكون من 1 إلى 65535). + إثبات هويتك + افتح القفل لعرض كلمة المرور الخاصة بك + لعرض كلمة المرور الخاصة بك هنا، قم بتفعيل قفل الشاشة على هذا الجهاز. + إرسال معلومات العميل + عنوان الـ IP أو اسم المضيف غير صالح + diff --git a/feature/account/server/settings/src/main/res/values-ast/strings.xml b/feature/account/server/settings/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-az/strings.xml b/feature/account/server/settings/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-be/strings.xml b/feature/account/server/settings/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..402b38e --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-be/strings.xml @@ -0,0 +1,5 @@ + + + Пратакол + Сервер + diff --git a/feature/account/server/settings/src/main/res/values-bg/strings.xml b/feature/account/server/settings/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..cfb82aa --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-bg/strings.xml @@ -0,0 +1,35 @@ + + + Името на сървъра е задължително поле. + Потребителското име е задължително. + Настройки на изходящ сървър + Паролата е задължителна. + OAuth 2.0 + Никаква + Шифрована парола + Автоматично намиране на IMAP пространство + Настройки на входящ сървър + Обикновена парола + Удостоверяване + Няма + Порт + Използвай компресия + Порт е задължително поле. + Клиентски сертификат + IMAP префикс на пътя + SSL/TLS + Протокол + Отключете за да видите паролата си + Потребителско име + Номерът на порта е невалиден (трябва да е в интервала 1-65535). + StartTLS + Клиентски сертификат + IMAP префиксът не може да е празен. + Не е избран + Сървър + Защита + Потвърдете самоличността си + За да видите паролата си тук, включете заключването на екрана на това устройство. + Изпращане на информация до клиента + Невалиден IP адрес или име на хост + diff --git a/feature/account/server/settings/src/main/res/values-bn/strings.xml b/feature/account/server/settings/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-bn/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-br/strings.xml b/feature/account/server/settings/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..4543a7c --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-br/strings.xml @@ -0,0 +1,5 @@ + + + Fazi gant ar c\'homenad + Dafariad + diff --git a/feature/account/server/settings/src/main/res/values-bs/strings.xml b/feature/account/server/settings/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..9127a21 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-bs/strings.xml @@ -0,0 +1,34 @@ + + + Protokol + Server + Port + Autentikacija + Nema + StartTLS + Sertifikat klijenta + OAuth 2.0 + SSL/TLS + Nema + Uobičajena lozinka + Enkriptovana lozinka + Nema + Sertifikat klijenta + Postavke dolazećeg servera + Automatsko otkrivanje IMAP imenskog prostora + Prefiks IMAP putanje + Koristi kompresiju podataka + Postavke odlazećeg servera + Nevažeća IP adresa ili ime hosta + Port je neophodan. + Port nije važeći (mora biti od 1 do 65535). + Korisničko ime je neophodno. + Lozinka je neophodna. + IMAP prefiks ne može biti prazan. + Verifikujte svoj identitet + Otključajte da bi lozinka bila prikazana + Sigurnost + Korisničko ime + Ime servera je neophodno. + Da bi vidjeli lozinku ovde, omogućite zaključavanje ekrana na ovom uređaju. + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ca/strings.xml b/feature/account/server/settings/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..2507ad5 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ca/strings.xml @@ -0,0 +1,35 @@ + + + El nom del servidor és obligatori. + Cal el nom d\'usuari. + Configuració del servidor de sortida + Cal la contrasenya. + OAuth 2.0 + Cap + Contrasenya encriptada + Detecció automàtica de l\'espai de noms IMAP + Configuració del servidor d\'entrada + Contrasenya normal + Autenticació + Cap + Port + Utilitza compressió + El port és obligatori. + Certificat del client + Prefix del camí IMAP + SSL/TLS + Protocol + Nom d\'usuari + El port no és vàlid (ha de ser 1–65535). + StartTLS + Certificat del client + El prefix IMAP no pot estar en blanc. + Cap + Servidor + Seguretat + Verifiqueu la vostra identitat + Desbloqueu-ho per veure la contrasenya + Per veure la contrasenya aquí, activeu el bloqueig de pantalla en aquest dispositiu. + IP o nom d\'amfitrió no vàlids + Envia informació del client + diff --git a/feature/account/server/settings/src/main/res/values-co/strings.xml b/feature/account/server/settings/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..724e104 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-co/strings.xml @@ -0,0 +1,35 @@ + + + Sicurità + Parolle d’intesa cifrata + Certificatu di u cliente + Nisunu + Scuperta autumatica di u spaziu di nome IMAP + Impiegà a cumpressione + U nome d’utilizatore hè richiestu. + A parolla d’intesa hè richiesta. + Verificà a vostra identità + Certificatu di u cliente + Parametri di u servitore d’entrata + Autenticazione + Nome d’utilizatore + Protocollu + Servitore + Portu + Nisuna + StartTLS + SSL/TLS + Nisuna + Parolla d’intesa nurmale + OAuth 2.0 + Prefissu di u chjassu IMAP + Mandà l’infurmazione di u cliente + Parametri di u servitore d’esciuta + Indirizzu IP o nome d’ospite inaccettevule + U portu hè richiestu. + U nome di u servitore hè richiestu. + U portu hè inaccettevule (deve esse trà 1 è 65535). + U prefissu IMAP ùn pò micca esse viotu. + Spalancate per affissà a vostra parolla d’intesa + Per affissà quì a vostra parolla d’intesa, attivate l’ammarchjunata di u screnu nant’à st’apparechju. + diff --git a/feature/account/server/settings/src/main/res/values-cs/strings.xml b/feature/account/server/settings/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..b9f3d03 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-cs/strings.xml @@ -0,0 +1,35 @@ + + + Název serveru je vyžadován. + Uživatelské jméno je vyžadováno. + Nastavení serveru odchozí pošty + Heslo je vyžadováno. + OAuth 2.0 + Žádný + Šifrované heslo + Zjistit jmenný prostor IMAP automaticky + Nastavení serveru příchozí pošty + Normální heslo + Ověřte svou identitu + Autentifikace + Žádné + Port + Komprimovat + Port je vyžadován. + Klientský certifikát + Předpona cesty IMAP + SSL/TLS + Protokol + Pokud chcete vidět své heslo, odemkněte + Uživatelské jméno + Port je neplatný (musí být 1–65535). + StartTLS + Klientský certifikát + Předpona IMAP nemůže být prázdná. + Žádný + Pokud si zde chcete zobrazit své heslo, zapněte zamykání obrazovky tohoto zařízení. + Server + Zabezpečení + Neplatná IP adresa nebo název hostitele + Odeslat informace o klientovi + diff --git a/feature/account/server/settings/src/main/res/values-cy/strings.xml b/feature/account/server/settings/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-cy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-da/strings.xml b/feature/account/server/settings/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..060b74a --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-da/strings.xml @@ -0,0 +1,26 @@ + + + Server navn er påkrævet. + Brugernavn er påkrævet. + Udgående serverindstillinger + Adgangskode er påkrævet. + OAuth 2.0 + Ingen + Krypteret adgangskode + Autodektér IMAP navneområde + Indgående serverindstillinger + Normal adgangskode + Ingen + Port + Port er påkrævet. + Klient certifikat + SSL/TLS + Protokol + Brugernavn + Port er ugyldig (skal være mellem 1-65535). + StartTLS + Klient certifikat + Ingen + Server + Sikkerhed + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-de/strings.xml b/feature/account/server/settings/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..a08c235 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-de/strings.xml @@ -0,0 +1,35 @@ + + + Servername ist erforderlich. + Benutzername ist erforderlich. + Einstellungen des Postausgangsservers + Passwort ist erforderlich. + OAuth 2.0 + Keine + Verschlüsseltes Passwort + IMAP-Namensraum automatisch ermitteln + Einstellungen des Posteingangsservers + Normales Passwort + Authentifizierung + Keine + Port + Komprimierung verwenden + Port ist erforderlich. + Client-Zertifikat + Präfix für IMAP-Pfad + SSL/TLS + Protokoll + Benutzername + Der Port ist ungültig (muss 1–65535 sein). + StartTLS + Client-Zertifikat + Präfix für IMAP darf nicht leer sein. + Kein + Server + Sicherheit + Deine Identität bestätigen + Entsperren, um dein Passwort anzuzeigen + Um dein Passwort hier anzuzeigen, aktiviere die Displaysperre auf diesem Gerät. + Ungültige IP oder ungültiger Hostname + Client-Informationen senden + diff --git a/feature/account/server/settings/src/main/res/values-el/strings.xml b/feature/account/server/settings/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..67ce541 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-el/strings.xml @@ -0,0 +1,35 @@ + + + Καμία + Κρυπτογραφημένος κωδικός πρόσβασης + Αυτόματος εντοπισμός χώρου ονομάτων IMAP + Κανονικός κωδικός πρόσβασης + Ταυτοποίηση + Καμία + Θύρα + Πιστοποητικό πελάτη + Πρωτόκολλο + Όνομα χρήστη + Πιστοποιητικό πελάτη + Κανένα + Διακομιστής + Ασφάλεια + Αποστολή πληροφοριών πελάτη + StartTLS + SSL/TLS + OAuth 2.0 + Το όνομα χρήστη είναι υποχρεωτικό. + Ο κωδικός πρόσβασης είναι υποχρεωτικός. + Το πρόθεμα IMAP δεν μπορεί να είναι κενό. + Ρυθμίσεις διακομιστή εισερχομένων + Πρόθεμα διαδρομής IMAP + Χρήση συμπίεσης + Ρυθμίσεις διακομιστή εξερχομένων + Το όνομα διακομιστή είναι υποχρεωτικό. + Η θύρα είναι υποχρεωτική. + Η θύρα δεν είναι έγκυρη (πρέπει να είναι 1-65535). + Επαληθεύστε την ταυτότητά σας + Ξεκλειδώστε για να δείτε τον κωδικό πρόσβασής σας + Μη έγκυρη IP ή όνομα κεντρικού υπολογιστή + Για να δείτε τον κωδικό πρόσβασής σας εδώ, ενεργοποιήστε το κλείδωμα οθόνης της συσκευής. + diff --git a/feature/account/server/settings/src/main/res/values-en-rGB/strings.xml b/feature/account/server/settings/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..1a8fca2 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,35 @@ + + + Server name is required. + Username is required. + Outgoing server settings + Password is required. + OAuth 2.0 + None + Encrypted password + Auto-detect IMAP namespace + Incoming server settings + Normal password + Verify your identity + Authentication + None + Port + Use compression + Port is required. + Client certificate + IMAP path prefix + SSL/TLS + Protocol + Unlock to view your password + Username + Port is invalid (must be 1–65535). + StartTLS + Client certificate + IMAP prefix can\'t be blank. + None + To view your password here, enable screen lock on this device. + Server + Security + Invalid IP or hostname + Send client information + diff --git a/feature/account/server/settings/src/main/res/values-enm/strings.xml b/feature/account/server/settings/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-eo/strings.xml b/feature/account/server/settings/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..9a03a19 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-eo/strings.xml @@ -0,0 +1,34 @@ + + + StartTLS + SSL/TLS + Nenio + Ordinara pasvorto + Ĉifrita pasvorto + Konfirmu vian identecon + Por vidi vian pasvorton tie, aktivigu ekranan ŝlocecon en tiu aparato. + Malŝlosu por vidi vian pasvorton + Sendi klientajn informojn + Protokolo + Servilo + Sekureco + Pordo + Aŭtentokontrolo + Uzanta nomo + Nenia + Klienta atestilo + OAuth 2.0 + Neniu + Klienta atestilo + Aŭtomate detekti IMAP nomkampo + IMAP vojprefikso + Uzi densigo + Pasvorto nepras. + IMAP prefikso ne povas esti malplena. + Servila nomo nepras. + Pordo estas nevalida (endas esti 1–65535). + Pordo nepras. + Uzanta nomo nepras. + Eniran servilon agordoj + Eliran servilon agordoj + diff --git a/feature/account/server/settings/src/main/res/values-es/strings.xml b/feature/account/server/settings/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..cc4f712 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-es/strings.xml @@ -0,0 +1,35 @@ + + + Es necesario poner el nombre del servidor. + Es necesario poner el nombre de usuario. + Ajustes del servidor saliente + Es necesario poner una contraseña. + OAuth 2.0 + Ninguna + Contraseña cifrada + Detectar el espacio de nombres IMAP + Ajustes del servidor entrante + Contraseña normal + Verifica tu identidad + Autenticación + Ninguna + Puerto + Con compresión + Es necesario poner el puerto. + Certificado de usuario + Prefijo de ruta IMAP + SSL/TLS + Protocolo + Para ver tu contraseña tienes que verificar tu identidad + Usuario + El número de puerto no parece correcto (van de 1 a 65535). + StartTLS + Certificado de usuario + El prefijo IMAP no puede estar en blanco. + Ninguna + Para poder ver tu contraseña primero tienes que activar el bloqueo de pantalla en tu dispositivo. + Servidor + Seguridad + IP o nombre de host no válidos + Enviar información del cliente + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-et/strings.xml b/feature/account/server/settings/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..0b11d80 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-et/strings.xml @@ -0,0 +1,35 @@ + + + Serveri nimi on vajalik. + Kasutajanimi on vajalik. + Väljuva e-posti serveriseadistused + Salasõna on vajalik. + OAuth 2.0 + Puudub + Krüptitud salasõna + Tuvasta IMAP nimeruum automaatselt + Saabuva e-posti serveriseadistused + Tavaline salasõna + Tuvasta oma isik + Autentimine + Puudub + Port + Kasuta pakkimist + Port on vajalik. + Kliendi sertifikaat + IMAP tee eesliide + SSL/TLS + Protokoll + Oma salasõna vaatamiseks eemalda lukustus + Kasutajanimi + Port on vigane (peab olema vahemikus 1–65535). + StartTLS + Kliendi sertifikaat + IMAP\'i eesliide ei saa olla tühi. + Puudub + Kui soovid siin näha salasõna, siis lülita oma nutiseadmes sisse lukustuskuva kasutamine. + Server + Turvalisus + Vigane ip-aadress või serveri nimi + Saada kliendi teave + diff --git a/feature/account/server/settings/src/main/res/values-eu/strings.xml b/feature/account/server/settings/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..9929549 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-eu/strings.xml @@ -0,0 +1,35 @@ + + + Zerbitzari izena beharrezkoa da. + Erabiltzaile izena beharrezkoa da. + Irteera zerbitzariaren ezarpenak + Pasahitza beharrezkoa da. + OAuth 2.0 + Bat ere ez + Pasahitz zifratua + Auto-detektatu IMAP izen-lekua + Sarrerako zerbitzariaren ezarpenak + Pasahitz arrunta + Egiaztatu zure identitatea + Autentifikazioa + Bat ere ez + Portua + Konpresioa erabili + Portua beharrezkoa da. + Bezero ziurtagiria + IMAP bidearen aurrizkia + SSL/TLS + Protokoloa + Desblokeatu zure pasahitza ikusteko + Erabiltzaile izena + Portua ez da zuzena (1–65535 artekoa izan behar du). + StartTLS + Bezero ziurtagiria + IMAP aurrizkiak ezin du hutsa izan. + Bat ere ez + Pasahitza hemen ikusteko, gaitu pantailaren blokeoa gailu honetan. + Zerbitzaria + Segurtasuna + IP edo ostalari-izen baliogabea + Bidali bezeroaren informazioa + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-fa/strings.xml b/feature/account/server/settings/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..0b62475 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-fa/strings.xml @@ -0,0 +1,35 @@ + + + نام سرور لازم است. + نام کاربری الزامی است. + تنظیمات سرور خروجی + رمز عبور الزامی است. + OAuth 2.0 + هیچ کدام + IP یا hostname معتبر نیست + رمز عبور رمزنگاری شده + تشخیص خودکار فضای نام IMAP + تنظیمات سرور ورودی + رمز عبور عادی + هویت‌ خود را تایید کنید + احراز هویت + هیچ‌کدام + پورت + استفاده از فشرده‌سازی + وارد کردن پورت الزامی است. + گواهینامهٔ کارخواه + پیشوند مسیر IMAP + SSL/TLS + پروتکل + برای دیدن رمز عبور، قفل را باز کنید + نام کاربری + پورت معتبر نیست (باید بین 1 تا 6535 باشد). + StartTLS + گواهینامهٔ کارخواه + پیشوند IMAP نمی تواند خالی باشد. + هیچ‌کدام + برای دیدن رمز عبور در این قسمت، قفل صفحه در این دستگاه را فعال کنید. + سرور + امنیت + ارسال اطلاعات کارخواه + diff --git a/feature/account/server/settings/src/main/res/values-fi/strings.xml b/feature/account/server/settings/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..4384110 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-fi/strings.xml @@ -0,0 +1,35 @@ + + + Palvelimen nimi vaaditaan. + Käyttäjätunnus vaaditaan. + Lähtevän postin palvelimen asetukset + Salasana vaaditaan. + OAuth 2.0 + Ei mitään + Salattu salasana + Havaitse automaattisesti IMAP-nimiavaruus + Saapuvan postin palvelimen asetukset + Normaali salasana + Vahvista henkilöytesi + Todennus + Ei mitään + Portti + Käytä pakkausta + Portti vaaditaan. + Asiakasvarmenne + IMAP-polun etuliite + SSL/TLS + Yhteyskäytäntö + Avaa lukitus nähdäksesi salasanasi + Käyttäjätunnus + Portti on virheellinen (pitää olla välillä 1–65535). + StartTLS + Asiakasvarmenne + IMAP-etuliite ei voi olla tyhjä. + Ei mitään + Nähdäksesi salasanasi tässä, ota näytön lukitus käyttöön tällä laitteella. + Palvelin + Suojaus + Virheellinen IP-osoite tai konenimi + Lähetä asiakastiedot + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-fr/strings.xml b/feature/account/server/settings/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..fda694c --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-fr/strings.xml @@ -0,0 +1,35 @@ + + + Le nom du serveur est requis. + Le nom d\'utilisateur est requis. + Paramètres du serveur sortant + Le mot de passe est requis. + OAuth 2.0 + Aucun + Mot de passe chiffré + Détection automatique de l\'espace de nommage IMAP + Paramètres du serveur entrant + Mot de passe normal + Confirmer votre identité + Authentification + Aucune + Port + Utiliser la compression + Le port est requis. + Certificat client + Préfixe du chemin IMAP + SSL/TLS + Protocole + Déverrouiller pour afficher votre mot de passe + Nom d’utilisateur + Le port est invalide (entre 1 et 65535). + StartTLS + Certificat client + Le préfixe IMAP ne peut pas être vide. + Aucun + Pour afficher votre mot de passe ici, activez le verrouillage de l\'écran de votre appareil. + Serveur + Sécurité + Adresse IP ou nom d’hôte invalide + Envoyer les renseignements du client + diff --git a/feature/account/server/settings/src/main/res/values-fy/strings.xml b/feature/account/server/settings/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..b7011d3 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-fy/strings.xml @@ -0,0 +1,35 @@ + + + Servernamme is fereaske. + Brûkersnamme is fereaske. + Ynstellingen útgeande server + Wachtwurd is fereaske. + OAuth 2.0 + Gjin + Fersifere wachtwurd + IMAP-namespace automatysk detektearje + Ynstellingen ynkommende server + Normaal wachtwurd + Ferifiearje jo identiteit + Autentikaasje + Gjin + Poarte + Kompresje brûke + Poarte is fereaske. + Clientsertifikaat + Prefix IMAP-paad + SSL/TLS + Protokol + Untskoattelje om jo wachtwurd te toanen + Brûkersnamme + Poarte is net jildich (moat lizze tusken 1–65535). + StartTLS + Clientsertifikaat + IMAP-prefix mei net leech wêze. + Gjin + Skeakelje skermbeskoatteling op dit apparaat yn, om jo wachtwurd hjir te sjen. + Server + Befeiliging + Ferkeard IP-adres of hostnamme + Clientynformaasje ferstjoere + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ga/strings.xml b/feature/account/server/settings/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..5222438 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ga/strings.xml @@ -0,0 +1,35 @@ + + + Prótacal + Freastalaí + Dada + StartTLS + Dada + Deimhniú cliant + Úsáid comhbhrúite + Seol faisnéis chliaint + Socruithe freastalaí amach + Díghlasáil chun féachaint ar do phasfhocal + Port + Socruithe freastalaí isteach + Ainm úsáideora + Gnáth-fhocal faire + OAuth 2.0 + Slándáil + Deimhniú cliant + Fíordheimhniú + SSL/TLS + Dada + Pasfhocal criptithe + Auto-bhrath ainmspás IMAP + Réimír cosán IMAP + Tá pasfhocal ag teastáil. + Tá ainm an fhreastalaí ag teastáil. + Port ag teastáil. + Tá ainm úsáideora ag teastáil. + Ní féidir le réimír IMAP a bheith bán. + IP nó óstainm neamhbhailí + Tá an port neamhbhailí (caithfidh sé a bheith 1–65535). + Fíoraigh d\'aitheantas + Chun féachaint ar do phasfhocal anseo, cumasaigh glas scáileáin ar an ngléas seo. + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-gd/strings.xml b/feature/account/server/settings/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..a5028ef --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-gd/strings.xml @@ -0,0 +1,35 @@ + + + Dearbh cò thusa + Thoir a’ ghlas dheth airson am facal-faire fhaicinn + Tha an IP no ainm an òstair mì-dhligheach + Airson am facal-faire agad fhaicinn an-seo, cuir glas na sgrìn an comas air an uidheam seo. + Cuir fiosrachadh mun chliant + Pròtacal + Frithealaiche + Tèarainteachd + Port + Dearbhadh + Ainm-cleachdaiche + Chan eil gin + StartTLS + SSL/TLS + Chan eil gin + Facal-faire àbhaisteach + Facal-faire crioptaichte + Teisteanas a’ chliant + OAuth 2.0 + Chan eil gin + Teisteanas a’ chliant + Roghainnean an fhrithealaiche a-steach + Mothaich dha ainm-spàs IMAP gu fèin-obrachail + Ro-leasachan slighe IMAP + Cleachd dùmhlachadh + Roghainnean an fhrithealaiche a-mach + Tha feum air facal-faire. + Chan fhaod ro-leasachan IMAP a bhith bàn. + Tha feum air port. + Tha feum air ainm an fhrithealaiche. + Tha feum air port (feumaidh e a bhith eadar 1 is 65535). + Tha feum air ainm-cleachdaiche. + diff --git a/feature/account/server/settings/src/main/res/values-gl/strings.xml b/feature/account/server/settings/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-gl/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-gu/strings.xml b/feature/account/server/settings/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-hi/strings.xml b/feature/account/server/settings/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..cd46fc3 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-hi/strings.xml @@ -0,0 +1,33 @@ + + + सर्वर नाम ज़रूरी है। + यूज़रनेम ज़रूरी है। + आउटगोइंग सर्वर सेटिंग + पासवर्ड ज़रूरी है। + ओऑथ 2.0 + कुछ नहीं + एंक्रिप्टेड पासवर्ड + आईमैप नेमस्पेस अपनेआप डिटेक्ट करें + इनकमिंग सर्वर सेटिंग + नॉर्मल पासवर्ड + अपनी आईडी वेरिफाइ करें + ऑथेंटिकेशन + कुछ नहीं + पोर्ट + कम्प्रैशन इस्तेमाल करें + पोर्ट ज़रूरी है। + क्लाइंट सर्टिफिकेट + आइमैप पाथ प्रीफिक्स + एसएसएल/टीएलएस + प्रोटोकॉल + अपना पासवर्ड देखने के लिए खोलें + यूज़रनेम + पोर्ट गलत है (सिर्फ 1-65535 हो सकता है)। + टीएलएस शुरू करें + क्लाइंट सर्टिफिकेट + आईमैप प्रीफिक्स खाली नहीं हो सकता। + कुछ नहीं + यहां अपना पासवर्ड देखने के लिए इस डिवाइस पे स्क्रीन लॉक लगाएं। + सर्वर + सेक्योरिटी + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-hr/strings.xml b/feature/account/server/settings/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..cde53c6 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-hr/strings.xml @@ -0,0 +1,35 @@ + + + Potvrdite svoj identitet + Za prikaz lozinke ovdje, omogućite zaključavanje zaslona na ovom uređaju. + Pošalji informacije o klijentu + Protokol + Poslužitelj + Provjera autentičnosti + Sigurnost + Priključak + Korisničko ime + Ništa + StartTLS + SSL/TLS + Ništa + Obična lozinka + Šifrirana lozinka + IMAP prefiks ne može biti prazan. + Klijentski certifikat + OAuth 2.0 + Ništa + Klijentski certifikat + Postavke dolaznog poslužitelja + Automatsko otkrivanje IMAP imenskog prostora + IMAP putni prefiks + Koristi kompresiju + Postavke odlaznog poslužitelja + Naziv poslužitelja je obavezan. + Priključak je obavezan. + Neispravan priključak (mora biti između 1–65535). + Lozinka je obavezna. + Neispravna IP adresa ili naziv hosta + Korisničko ime je obavezno. + Otključajte za prikaz lozinke + diff --git a/feature/account/server/settings/src/main/res/values-hu/strings.xml b/feature/account/server/settings/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..864a5e6 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-hu/strings.xml @@ -0,0 +1,35 @@ + + + A kiszolgálónév megadása szükséges. + A felhasználónév megadása szükséges. + Kimenő kiszolgáló beállítások + A jelszó megadása szükséges. + OAuth 2.0 + Nincs + Titkosított jelszó + IMAP névtér automatikus felismerése + Bejövő kiszolgáló beállításai + Normál jelszó + Hitelesítés + Nincs + Port + Tömörítés használata + A port megadása szükséges. + Ügyféltanúsítvány + IMAP útvonalelőtag + SSL/TLS + Protokoll + Felhasználónév + A port érvénytelen (legyen 1–65535 között). + StartTLS + Ügyféltanúsítvány + Az IMAP előtag nem lehet üres. + Nincs + Kiszolgáló + Biztonság + Személyazonosság igazolása + Feloldás a jelszó megtekintéséhez + A jelszó megtekintéséhez itt engedélyezzük a képernyőzárat ezen az eszközön. + Érvénytelen IP-cím vagy kiszolgálónév + Kliensinformációk küldése + diff --git a/feature/account/server/settings/src/main/res/values-hy/strings.xml b/feature/account/server/settings/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-in/strings.xml b/feature/account/server/settings/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..c98505f --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-in/strings.xml @@ -0,0 +1,35 @@ + + + IP atau nama hos takvalid + Nama pengguna diperlukan. + Kata sandi diperlukan. + Prefiks IMAP tidak boleh kosong. + Verifikasikan identitas Anda + Buka untuk melihat kata sandi Anda + Untuk melihat kata sandi Anda di sini, aktifkanlah layar kunci pada perangkat ini. + Kirim informasi klien + Protokol + Peladen + Keamanan + Porta + Autentikasi + Nama pengguna + Tidak ada + StartTLS + SSL/TLS + Tidak ada + Kata sandi normal + Kata sandi terenkripsi + Sertifikat klien + OAuth 2.0 + Tidak ada + Sertifikat klien + Pengaturan peladen yang masuk + Deteksi otomatis namespace IMAP + Prefiks lokasi IMAP + Porta takvalid (harus bernilai 1—65535). + Gunakan kompresi + Pengaturan peladen yang keluar + Nama peladen diperlukan. + Porta diperlukan. + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-is/strings.xml b/feature/account/server/settings/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..d809843 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-is/strings.xml @@ -0,0 +1,35 @@ + + + Nafn póstþjóns er nauðsynlegt. + Notandanafn er nauðsynlegt. + Stillingar sendinga-póstþjóns + Lykilorð er nauðsynlegt. + OAuth 2.0 + Ekkert + Dulritað lykilorð + Skynja sjálfvirkt IMAP-nafnarými + Stillingar inn-póstþjóns + Venjulegt lykilorð + Auðkenning + Ekkert + Gátt + Nota þjöppun + Gátt er nauðsynleg. + Skilríki biðlara + Forskeyti IMAP-slóðar + SSL/TLS + Samskiptamáti + Notandanafn + Ógild gátt (verður að vera 1–65535). + StartTLS + Skilríki biðlara + Forskeyti IMAP-slóðar má ekki vera tómt. + Ekkert + Póstþjónn + Öryggi + Sannreyndu auðkennin þín + Aflæstu til að sjá lykilorðið þitt + Til að sjá lykilorðið þitt hér, skaltu virkja skjálæsingu á þessu tæki. + Ógilt IP-vistfang eða vélarheiti + Senda upplýsingar um biðlaraforrit + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-it/strings.xml b/feature/account/server/settings/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..6889f14 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-it/strings.xml @@ -0,0 +1,35 @@ + + + Nome server obbligatorio + Nome utente obbligatorio + Impostazioni server in uscita + Password obbligatoria + OAuth 2.0 + Nessuno + Password criptata + Rileva automaticamente namespace IMAP + Impostazioni server in arrivo + Password + Autenticazione + Nessuno + Porta + Usa la compressione + Porta obbligatoria + Certificato del client + Prefisso del percorso IMAP + SSL/TLS + Protocollo + Nome utente + Porta non valida (compresa tra 1–65535) + StartTLS + Certificato del client + Il prefisso IMAP non può essere vuoto + Nessuno + Server + Sicurezza + Verifica la tua identità + Sblocca per visualizzare la password + Per visualizzare la password qui, attiva il blocco schermo su questo dispositivo + IP o hostname invalido + Invia informazioni sul client + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-iw/strings.xml b/feature/account/server/settings/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..5daff4f --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-iw/strings.xml @@ -0,0 +1,35 @@ + + + אמת את זהותך + בטל את הנעילה כדי לראות את הסיסמה שלך + כדי להציג את הסיסמה שלך כאן, הפעל נעילת מסך במכשיר זה. + פרוטוקול + שרת + אבטחה + פורט + אימות + שם משתמש + כלום + StartTLS + SSL/TLS + כלום + סיסמה רגילה + סיסמה מוצפנת + תעודת לקוח + קידומת IMAP לא יכולה להיות ריקה. + IP או שם מארח לא חוקיים + OAuth 2.0 + כלום + תעודת לקוח + הגדרות שרת דואר נכנס + זהה אוטומטית מרחב שמות של IMAP + קידומת נתיב IMAP + השתמש בדחיסה + הפורט לא חוקי (חייב להיות בין 1–65535). + נדרש שם משתמש. + הגדרות שרת דואר יוצא + נדרש שם שרת. + נדרש פורט. + דרושה סיסמה. + שלח מידע לקוח + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ja/strings.xml b/feature/account/server/settings/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..aaaeb6e --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ja/strings.xml @@ -0,0 +1,35 @@ + + + サーバー名は必須です。 + ユーザー名は必須です。 + 送信サーバー設定 + パスワードは必須です。 + OAuth 2.0 + なし + 暗号化されたパスワード認証 + IMAP 名前空間を自動検出する + 受信サーバー設定 + 通常のパスワード認証 + 認証方式 + なし + ポート番号 + 圧縮を使用する + ポート番号は必須です。 + クライアント証明書認証 + IMAP ルートフォルダーパス + SSL/TLS + プロトコル + ユーザー名 + ポート番号が無効です (1-65535 の範囲内)。 + StartTLS + クライアント証明書 + IMAP プレフィックスは空欄にはできません。 + なし + サーバー + セキュリティ + 本人確認 + ロック解除してパスワードを表示 + ここでパスワードを表示するには、端末に画面ロックを設定してください。 + 無効な IP アドレスまたはホスト名です + クライアントの情報を送信する + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ka/strings.xml b/feature/account/server/settings/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-kab/strings.xml b/feature/account/server/settings/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-kab/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-kk/strings.xml b/feature/account/server/settings/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..0998c63 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-kk/strings.xml @@ -0,0 +1,10 @@ + + + Протокол + Қауіпсіздік + Сервер + Порт + Аутентификация + Пайдаланушы аты + Ешнәрсе + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ko/strings.xml b/feature/account/server/settings/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..933fc76 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ko/strings.xml @@ -0,0 +1,35 @@ + + + 사용자명이 필요합니다. + 암호화된 비밀번호 + IMAP 네임스페이스 자동 탐지 + 수신 서버 설정 + 인증 + 포트 + 클라이언트 인증서 + 프로토콜 + 사용자명 + 포트가 유효하지 않음 (1-65535 사이). + 클라이언트 인증서 + 서버 + 보안 + IMAP 접두사는 비워 둘 수 없습니다. + 잘못된 IP 또는 호스트 이름 + 비밀번호를 보려면 잠금 해제하세요 + 여기서 비밀번호를 보려면, 기기에 잠금 화면을 설정하세요. + 비밀번호가 필요합니다. + IMAP 경로 접두사 + 압축 사용 + 발신 서버 설정 + 없음 + 포트가 필요합니다. + 없음 + StartTLS + SSL/TLS + 없음 + OAuth 2.0 + 서버 이름이 필요합니다. + 본인 확인 + 클라이언트 정보 전송 + 평문 비밀번호 + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-lt/strings.xml b/feature/account/server/settings/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..97f2dc9 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-lt/strings.xml @@ -0,0 +1,34 @@ + + + Slaptažodį nurodyti būtina. + Atrakinkite slaptažodžiui pamatyti + Protokolas + StartTLS + SSL/TLS + IMAP kelio prefiksas + Neleistinas IP adresas ar serverio vardas + Prievadas neleistinas (leistinos reikšmės – 1–65535). + Naudotojo vardą nurodyti būtina. + Patvirtinkite savo tapatybę + Kad čia matytumėte slaptažodį, šiame įrenginyje įgalinkite ekrano užraktą. + IMAP prefiksas negali būti tuščias. + Serveris + Šifravimas + Prievadas + Tapatumo nustatymas + Naudotojo vardas + Jokio + Jokio + Paprastas slaptažodis + Šifruotas slaptažodis + Kliento liudijimas + OAuth 2.0 + Jokio + Kliento liudijimas + Gaunamų laiškų serverio parametrai + Automatiškai aptikti IMAP vardų erdvę + Naudoti glaudinimą + Siunčiamų laiškų serverio parametrai + Serverio vardą nurodyti būtina. + Prievadą nurodyti būtina. + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-lv/strings.xml b/feature/account/server/settings/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ml/strings.xml b/feature/account/server/settings/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-nb-rNO/strings.xml b/feature/account/server/settings/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..9f6b6e2 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,35 @@ + + + Tjenernavn kreves. + Brukernavn kreves. + Innstillinger for utgående tjener + Passord kreves. + OAuth 2.0 + Ingen + Kryptert passord + Oppdag IMAP-navnerom automatisk + Innkommende tjenerinnstillinger + Normalt passord + Autentisering + Ingen + Port + Bruk sammenpakking + Port kreves. + Klientsertifikat + IMAP-stiforstavelse + SSL/TLS + Protokoll + Brukernavn + Ugyldig port (må være 1–65535). + StartTLS + Klientsertifikat + IMAP-forstavelse må fylles ut. + Ingen + Server + Sikkerhet + Bekreft din identitet + Lås opp for å vise passordet ditt + Skru på skjermlås for enheten for å vise passordet ditt her. + Ugyldig IP eller vertsnavn + Send klientinformasjon + diff --git a/feature/account/server/settings/src/main/res/values-nl/strings.xml b/feature/account/server/settings/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..d91aa68 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-nl/strings.xml @@ -0,0 +1,35 @@ + + + Servernaam is vereist. + Gebruikersnaam is vereist. + Uitgaande-serverinstellingen + Wachtwoord is vereist. + OAuth 2.0 + Geen + Versleuteld wachtwoord + IMAP-namespace automatisch detecteren + Inkomende-serverinstellingen + Normaal wachtwoord + Uw identiteit verifiëren + Authenticatie + Geen + Poort + Compressie gebruiken + Poort is vereist. + Clientcertificaat + Prefix IMAP-pad + SSL/TLS + Protocol + Ontgrendel om uw wachtwoord te zien + Gebruikersnaam + Poort is ongeldig (moet liggen tussen 1-65535). + StartTLS + Clientcertificaat + IMAP-prefix mag niet leeg zijn. + Geen + Schakel schermbeveiliging op dit apparaat in, om hier uw wachtwoord te zien. + Server + Beveiliging + Verkeerd IP-adres of hostnaam + Clientinformatie versturen + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-nn/strings.xml b/feature/account/server/settings/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..8325939 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-nn/strings.xml @@ -0,0 +1,35 @@ + + + Ugyldig port (må vere 1–65535). + Passord påkravd. + Lås opp for å vise passordet ditt + Protokoll + Tenar + Tryggleik + Brukarnamn + Ingen + StartTLS + SSL/TLS + Ingen + Klientsertifikat + OAuth 2.0 + Ingen + Klientsertifikat + Innkomande serverinnstillingar + Bruk samanpakking + Utgåande serverinnstillingar + Servernamn er påkravd. + Port er npåkravd. + Port + Oppdag IMAP-navnerom automatisk + Normalt passord + Kryptert passord + Send klientinformasjon + Ugyldig IP eller vertsnamn + Brukarnamn påkravd. + For å sjå passordet ditt her, aktiver skjermlås på denne eininga. + Verifiser din identitet + IMAP prefiks kan ikkje vere tom. + Autentisering + IMAP-sti prefiks + diff --git a/feature/account/server/settings/src/main/res/values-pl/strings.xml b/feature/account/server/settings/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..6f42003 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-pl/strings.xml @@ -0,0 +1,35 @@ + + + Nazwa serwera jest wymagana. + Nazwa użytkownika jest wymagana. + Ustawienia serwera poczty wychodzącej + Hasło jest wymagane. + OAuth 2.0 + Brak + Zaszyfrowane hasło + Automatycznie wykrywaj przestrzeń nazw IMAP + Ustawienia serwera poczty przychodzącej + Zwykłe hasło + Uwierzytelnianie + Brak + Port + Użyj kompresji + Port jest wymagany. + Certyfikat klienta + Prefiks ścieżki IMAP + SSL/TLS + Protokół + Nazwa użytkownika + Port jest nieprawidłowy (musi mieć wartość 1-65535). + StartTLS + Certyfikat klienta + Prefiks IMAP nie może być pusty. + Brak + Serwer + Zabezpieczenia + Zweryfikuj swoją tożsamość + Odblokuj, aby zobaczyć swoje hasło + Aby zobaczyć tutaj swoje hasło, ustaw blokadę ekranu na tym urządzeniu. + Nieprawidłowy adres IP lub nazwa hosta + Wyślij informacje o kliencie + diff --git a/feature/account/server/settings/src/main/res/values-pt-rBR/strings.xml b/feature/account/server/settings/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..c4c9b3a --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,35 @@ + + + Nome do servidor é obrigatório. + Nome de usuário é obrigatório. + Configuração do servidor de saída + Senha é obrigatória. + OAuth 2.0 + Nenhum + Senha criptografada + Detectar namespace IMAP automaticamente + Configuração do servidor de entrada + Senha normal + Verifique sua identidade + Autenticação + Nenhum + Porta + Usar compressão + Porta é obrigatória. + Certificado de usuário + Prefixo do caminho de IMAP + SSL/TLS + Protocolo + Desbloqueie para ver sua senha + Nome de usuário + Porta inválida (deve ser de 1 a 65535). + StartTLS + Certificado de cliente + Prefixo de IMAP não pode estar vazio. + Nenhum + Para ver sua senha aqui, ative bloqueio de tela neste dispositivo. + Servidor + Segurança + Endereço IP ou nome de servidor inválido + Enviar informações do cliente + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-pt-rPT/strings.xml b/feature/account/server/settings/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..7c3f90b --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,35 @@ + + + Nome do servidor é necessário. + Nome de utilizador é necessário. + Configurações do servidor de saída + Palavra-chave é necessária. + OAuth 2.0 + Nenhum + IP ou nome do servidor inválido + Palavra-chave encriptada + Detectar automaticamente o namespace do IMAP + Configurações do servidor de entrada + Palavra-chave normal + Verificar a sua identidade + Autenticação + Nenhum + Porta + Usar compressão + Porta é necessária. + Certificado do cliente + Prefixo do caminho de IMAP + SSL/TLS + Protocolo + Desbloquear para visualizar a sua password + Nome de utilizador + Porta é inválida (tem que ser entre 1-65535). + StartTLS + Certificado do cliente + O prefixo de IMAP não pode estar em branco. + Nenum + Para visualizar a sua palavra-chave aqui, ative o bloqueio de ecrã neste dispositivo. + Servidor + Segurança + Enviar informações do cliente + diff --git a/feature/account/server/settings/src/main/res/values-pt/strings.xml b/feature/account/server/settings/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ro/strings.xml b/feature/account/server/settings/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..cfefb08 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ro/strings.xml @@ -0,0 +1,35 @@ + + + Numele serverului este necesar. + Numele de utilizator este necesar. + Setări server de expediere + Parola este necesară. + OAuth 2.0 + Nimic + Parolă criptată + Auto-detectare IMAP namespace + Setări server de primire + Parolă normală + Autentificare + Nimic + Port + Utilizează comprimarea + Portul este necesar. + Certificat client + Prefix cale IMAP + SSL/TLS + Protocol + Nume de utilizator + Portul este greșit (trebuie să fie 1–65535). + StartTLS + Certificat client + Prefixul IMAP nu poate fi gol. + Nimic + Server + Securitate + Verifică identitatea ta + Deblochează ca sa vezi parola + Pentru a-ți vedea parola aici, activează ecranul de blocare pe acest dispozitiv. + IP sau hostname invalid + Trimite informații despre client + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ru/strings.xml b/feature/account/server/settings/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..ab33284 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ru/strings.xml @@ -0,0 +1,35 @@ + + + Автоматическое определение пространства имён IMAP + Настройки исходящего сервера + Недопустимый IP-адрес или имя хоста + Порт неверный (должен быть 1-65535). + Необходимо указать пароль. + Подтвердите вашу личность + Разблокировка для просмотра пароля + Префикс IMAP не может быть пустым. + Сервер + Протокол + Безопасность + Порт + Аутентификация + StartTLS + SSL/TLS + Нет + Обычный пароль + Зашифрованный пароль + Сертификат клиента + OAuth 2.0 + Ничего + Сертификат клиента + Настройки входящего сервера + Префикс пути IMAP + Использовать сжатие + Ничего + Имя пользователя + Необходимо указать порт. + Необходимо указать имя пользователя. + Необходимо указать имя сервера. + Чтобы увидеть ваш пароль, включите блокировку экрана на этом устройстве. + Отправлять информацию о клиенте + diff --git a/feature/account/server/settings/src/main/res/values-sk/strings.xml b/feature/account/server/settings/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..9b121e9 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-sk/strings.xml @@ -0,0 +1,35 @@ + + + Protokol + Server + Zabezpečenie + Žiadne + Normálne heslo + Žiadne + SSL/TLS + StartTLS + Neplatná IP adresa alebo názov hostiteľa + Ak si tu chcete zobraziť svoje heslo, povoľte zámku obrazovky tohto zariadenia. + Overte svoju identitu + Ak chcete vidieť svoje heslo, odomknite + Port + Overenie + Užívateľské meno + Zašifrované heslo + Klientský certifikát + OAuth 2.0 + Žiadny + Klientský certifikát + Nastavenie servera prichádzajúcej pošty + Automaticky zistiť menný priestor IMAPu + Predpona cesty IMAP + Komprimovať + Port je neplatný (musí byť 1-65535). + Užívateľské meno je vyžadované. + Predpona IMAP nemôže byť prázdna. + Nastavenie servera odchádzajúcej pošty + Názov servera je vyžadovaný. + Port je vyžadovaný. + Heslo je vyžadované. + Odoslať klientské informácie + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-sl/strings.xml b/feature/account/server/settings/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..8bc07be --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-sl/strings.xml @@ -0,0 +1,35 @@ + + + Neveljaven IP ali ime gostitelja + Preveri istovetnost + Pošlji podatke o odjemalcu + Vrata + Overitev + StartTLS + SSL/TLS + Brez + Običajno geslo + Šifrirano geslo + Potrdilo odjemalca + OAuth 2.0 + Brez + Potrdilo odjemalca + Nastavitve dohodnega strežnika + Samodejno zaznaj imenski prostor IMAP + Predpona poti IMAP + Uporabi stiskanje + Predpona IMAP ne sme biti prazna. + Nastavitve odhodnega strežnika + Vrata so neveljavna (morajo biti od 1 do 65535). + Geslo je zahtevan podatek. + Imenski prostor strežnika je zahtevan podatek. + Vrata so zahtevan podatek. + Uporabniško ime je zahtevan podatek. + Protokol + Brez + Strežnik + Varnost + Uporabniško ime + Odklenite, če želite prikazati svoje geslo. + Če želite tukaj prikazati svoje geslo, na tej napravi omogočite zaklepanje zaslona. + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-sq/strings.xml b/feature/account/server/settings/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..d35c8ab --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-sq/strings.xml @@ -0,0 +1,35 @@ + + + Emri i shërbyesit është i domosdoshëm. + Emri i përdoruesit është i domosdoshëm. + Rregullime shërbyesi dërgimi mesazhesh + Fjalëkalimi është i domosdoshëm. + OAuth 2.0 + Asnjë + Fjalëkalim të fshehtëzuar + Vetëpikas emërhapësirë IMAP + Rregullime shërbyesi marrjeje mesazhesh + Fjalëkalim normal + Mirëfilltësim + Asnjë + Portë + Përdor ngjeshje + Porta është e domosdoshme. + Dëshmi klienti + Parashtesë shtegu IMAP + SSL/TLS + Protokoll + Emër përdoruesi + Porta është e pavlefshme (duhet të jetë 1–65535). + StartTLS + Dëshmi klienti + Parashtesa për IMAP s’mund të jetë e zbrazët. + Asnjë + Shërbyes + Siguri + Verifikoni identitetin tuaj + Që të shihni fjalëkalimin tuaj, shkyçeni + Që të shihni këtu fjalëkalimin tuaj, aktivizoni në këtë pajisje kyçje ekrani. + IP ose strehëemër i pavlefshëm + Dërgo hollësi klienti + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-sr/strings.xml b/feature/account/server/settings/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..295c168 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-sr/strings.xml @@ -0,0 +1,35 @@ + + + Нема + StartTLS + SSL/TLS + Подешавања долазног сервера + Подешавања одлазног сервера + Порт је неважећи (мора бити 1–65535). + Ниједно + Нормална лозинка + Шифрована лозинка + Аутоматско откривање простора назива IMAP-а + Путања префикса IMAP-а + Користи компресију + Префикс IMAP-а не може бити празан. + Назив сервера је обавезан. + Порт је обавезан. + Корисничко име је обавезно. + Лозинка је обавезна. + Потврда идентитета + Неважећа IP адреса или назив хоста + Откључајте да бисте видели своју лозинку + Да бисте овде видели своју лозинку, омогућите закључавање екрана на овом уређају. + Пошаљи информације о клијенту + Протокол + Сервер + Безбедност + Порт + Аутентификација + Корисничко име + Сертификат клијента + OAuth 2.0 + Нема + Сертификат клијента + diff --git a/feature/account/server/settings/src/main/res/values-sv/strings.xml b/feature/account/server/settings/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..2e22c0f --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-sv/strings.xml @@ -0,0 +1,35 @@ + + + Servernamn krävs. + Användarnamn krävs. + Utgående serverinställningar + Lösenord krävs. + OAuth 2.0 + Ingen + Krypterat lösenord + Upptäck automatiskt IMAP-namnutrymme + Inkommande serverinställningar + Normalt lösenord + Verifiera din identitet + Använd kompression + Port krävs. + Klientcertifikat + IMAP-sökvägsprefix + SSL/TLS + Protokoll + Lås upp för att visa ditt lösenord + Port är ogiltig (måste vara 1-65535). + StartTLS + Klientcertifikat + IMAP-prefixet får inte vara tomt. + Ingen + För att se ditt lösenord här, aktivera skärmlås på den här enheten. + Server + Säkerhet + Autentisering + Ingen + Port + Användarnamn + Ogiltig IP eller värdnamn + Skicka klientinformation + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-sw/strings.xml b/feature/account/server/settings/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-ta/strings.xml b/feature/account/server/settings/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..513c7e4 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-ta/strings.xml @@ -0,0 +1,35 @@ + + + பயனர்பெயர் + எதுவுமில்லை + Startls + SSL/TLS + எதுவுமில்லை + சாதாரண கடவுச்சொல் + மறைகுறியாக்கப்பட்ட கடவுச்சொல் + வாடிக்கையாளர் சான்றிதழ் + OAUTH 2.0 + எதுவுமில்லை + வாடிக்கையாளர் சான்றிதழ் + உள்வரும் சேவையக அமைப்புகள் + தானாக கண்டறியப்பட்ட IMAP பெயர்வெளி + IMAP பாதை முன்னொட்டு + சுருக்கத்தைப் பயன்படுத்துங்கள் + கிளையன்ட் தகவல்களை அனுப்பவும் + வெளிச்செல்லும் சேவையக அமைப்புகள் + சேவையக பெயர் தேவை. + துறைமுகம் தேவை. + துறைமுகம் தவறானது (1–65535 ஆக இருக்க வேண்டும்). + பயனர்பெயர் தேவை. + கடவுச்சொல் தேவை. + உங்கள் அடையாளத்தை சரிபார்க்கவும் + உங்கள் கடவுச்சொல்லைக் காண திறக்கவும் + உங்கள் கடவுச்சொல்லை இங்கே காண, இந்த சாதனத்தில் திரை பூட்டை இயக்கவும். + தவறான ஐபி அல்லது ஓச்ட்பெயர் + IMAP முன்னொட்டு காலியாக இருக்க முடியாது. + நெறிமுறை + சேவையகம் + பாதுகாப்பு + துறைமுகம் + ஏற்பு + diff --git a/feature/account/server/settings/src/main/res/values-th/strings.xml b/feature/account/server/settings/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..c817829 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-th/strings.xml @@ -0,0 +1,27 @@ + + + โปรโตคอล + เซิฟเวอร์ + ความปลอดภัย + พอร์ต + ชื่อผู้ใช้ + StartTLS + SSL/TLS + รหัสผ่านปกติ + เข้ารหัส รหัสผ่าน + การรับรองความถูกต้อง + OAuth 2.0 + ตั้งค่าเซิร์ฟเวอร์ขาเข้า + ใช้การบีบอัด + ตั้งค่าเซิร์ฟเวอร์ขาออก + จำเป็นต้องระบุชื่อเซิร์ฟเวอร์ + IP หรือชื่อโฮสต์ไม่ถูกต้อง + พอร์ตไม่ถูกต้อง (ต้องเป็น 1–65535) + จำเป็นต้องมีชื่อผู้ใช้ + จำเป็นต้องระบุรหัสผ่าน + คำนำหน้า IMAP ไม่สามารถว่างเปล่าได้ + ยืนยันตัวตนของคุณ + ปลดล็อคเพื่อดูรหัสผ่านของคุณ + หากต้องการดูรหัสผ่านของคุณที่นี่ โปรดเปิดใช้งานการล็อกหน้าจอบนอุปกรณ์นี้ + ไม่มีอะไร + diff --git a/feature/account/server/settings/src/main/res/values-tr/strings.xml b/feature/account/server/settings/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..e4e3409 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-tr/strings.xml @@ -0,0 +1,35 @@ + + + Sunucu adı zorunludur. + Kullanıcı adı zorunludur. + Giden sunucusu ayarları + Parola zorunludur. + OAuth 2.0 + Yok + Şifreli parola + IMAP isim uzayını otomatik algıla + Gelen sunucusu ayarları + Normal parola + Kimlik doğrulama + Yok + Port + Sıkıştırma kullan + Port zorunludur. + İstemci sertifikası + IMAP yolu ön eki + SSL/TLS + Protokol + Kullanıcı adı + Port geçersiz (1 ile 65535 arasında olmalıdır). + StartTLS + İstemci sertifikası + IMAP ön eki boş olamaz. + Yok + Sunucu + Güvenlik + Kimliğinizi doğrulayın + Parolanızı görmek için kilidi açın + Parolanızı burada görmek için cihazınızda ekran kilidini etkinleştirin. + Geçersiz IP veya ana makine adı + İstemci bilgilerini gönder + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-uk/strings.xml b/feature/account/server/settings/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..fb538fa --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-uk/strings.xml @@ -0,0 +1,35 @@ + + + Надіслати інформацію про клієнта + Протокол + Сервер + Безпека + Порт + Автентифікація + Ім\'я користувача + Немає + Жоден + Звичайний пароль + Зашифрований пароль + Сертифікат клієнта + Немає + Сертифікат клієнта + Налаштування вхідного сервера + Автовизначення області назв IMAP + Префікс шляху IMAP + Використовувати стиснення + Налаштування вихідного сервера + Недійсний IP або ім\'я хосту + StartTLS + SSL/TLS + OAuth 2.0 + Порт недійсний (має бути 1-65535). + Ім\'я користувача обов\'язкове. + Ім\'я сервера обов\'язкове. + Порт обов\'язковий. + Пароль обов\'язковий. + Префікс IMAP не може бути порожнім. + Щоб переглянути тут свій пароль, увімкніть блокування екрана на цьому пристрої. + Підтвердьте свою особистість + Розблокуйте, щоб переглянути пароль + diff --git a/feature/account/server/settings/src/main/res/values-vi/strings.xml b/feature/account/server/settings/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..c6921b6 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-vi/strings.xml @@ -0,0 +1,35 @@ + + + Tên máy chủ là bắt buộc. + Tên người dùng là bắt buộc. + Thiết đặt máy chủ thư đi + Mật khẩu là bắt buộc. + OAuth 2.0 + Không + Mật khẩu được mã hóa + Tự động phát hiện không gian tên IMAP + Thiết đặt máy chủ thư đến + Mật khẩu thông thường + Xác minh danh tính của bạn + Xác thực + Không + Cổng + Sử dụng tính năng nén + Cổng là bắt buộc. + Chứng chỉ máy khách + Tiền tố đường dẫn IMAP + SSL/TLS + Giao thức + Mở khóa để xem mật khẩu của bạn + Tên tài khoản + Cổng không hợp lệ (phải là 1–65535). + StartTLS + Chứng chỉ máy khách + Tiền tố IMAP không được để trống. + Không + Để xem mật khẩu của bạn tại đây, hãy bật khóa màn hình trên thiết bị này. + Máy chủ + Bảo vệ + Gửi thông tin máy khách + IP hoặc tên máy chủ không hợp lệ + \ No newline at end of file diff --git a/feature/account/server/settings/src/main/res/values-zh-rCN/strings.xml b/feature/account/server/settings/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..01a1ccc --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,35 @@ + + + 服务器名是必需的。 + 用户名是必需的。 + 发件服务器设置 + 密码是必需的。 + OAuth 2.0 + + 加密的密码 + 自动检测 IMAP 命名空间 + 收件服务器设置 + 普通密码 + 身份验证 + + 端口 + 使用压缩 + 端口是必需的。 + 客户端证书 + IMAP 路径前缀 + SSL/TLS + 协议 + 用户名 + 端口无效(必须是 1–65535 之间的整数)。 + StartTLS + 客户端证书 + IMAP 前缀不能为空。 + + 服务器 + 安全性 + 验证您的身份 + 解锁以查看您的密码 + 要在此查看您的密码,请启用此设备的屏幕锁。 + 无效 IP 或主机名 + 发送客户端信息 + diff --git a/feature/account/server/settings/src/main/res/values-zh-rTW/strings.xml b/feature/account/server/settings/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..50a1999 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,35 @@ + + + 需要輸入伺服器名稱。 + 需要輸入使用者名稱。 + 寄件伺服器設定 + 需要輸入密碼。 + OAuth 2.0 + + 已加密的密碼 + 自動偵測 IMAP 命名空間 + 收件伺服器設定 + 常規密碼 + 驗證您的身分 + 身份認證 + + 端口 + 使用壓縮 + 需要輸入通訊埠。 + 用戶端憑證 + IMAP 路徑前綴 + SSL/TLS + 協議 + 解鎖以檢視您的密碼 + 用戶名 + 通訊埠無效(必須為 1–65535 之間的正整數)。 + StartTLS + 用戶端憑證 + IMAP 前綴不能為空白。 + + 如要在此檢視您的密碼,請先啟用此裝置的螢幕鎖定。 + 服務器 + 安全 + IP或主機名無效 + 發送客戶資訊 + diff --git a/feature/account/server/settings/src/main/res/values/strings.xml b/feature/account/server/settings/src/main/res/values/strings.xml new file mode 100644 index 0000000..82292d1 --- /dev/null +++ b/feature/account/server/settings/src/main/res/values/strings.xml @@ -0,0 +1,44 @@ + + + Protocol + Server + Security + Port + Authentication + Username + + None + StartTLS + SSL/TLS + + None + Normal password + Encrypted password + Client certificate + OAuth 2.0 + + None + Client certificate + + Incoming server settings + + Auto-detect IMAP namespace + IMAP path prefix + Use compression + Send client information + + Outgoing server settings + + Server name is required. + Invalid IP or hostname + Port is required. + Port is invalid (must be 1–65535). + Username is required. + Password is required. + IMAP prefix can\'t be blank. + + Verify your identity + Unlock to view your password + + To view your password here, enable screen lock on this device. + diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateImapPrefixTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateImapPrefixTest.kt new file mode 100644 index 0000000..982dccd --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateImapPrefixTest.kt @@ -0,0 +1,35 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidateImapPrefixTest { + + private val testSubject = ValidateImapPrefix() + + @Test + fun `should success when imap prefix is set`() { + val result = testSubject.execute("imap") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should succeed when imap prefix is empty`() { + val result = testSubject.execute("") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when imap prefix is blank`() { + val result = testSubject.execute(" ") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePasswordTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePasswordTest.kt new file mode 100644 index 0000000..4e640db --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePasswordTest.kt @@ -0,0 +1,37 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidatePasswordTest { + + private val testSubject = ValidatePassword() + + @Test + fun `should succeed when password is set`() { + val result = testSubject.execute("password") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when password is empty`() { + val result = testSubject.execute("") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when password is blank`() { + val result = testSubject.execute(" ") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePortTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePortTest.kt new file mode 100644 index 0000000..49d1299 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidatePortTest.kt @@ -0,0 +1,56 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePort.ValidatePortError +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidatePortTest { + + private val testSubject = ValidatePort() + + @Test + fun `should succeed when port is set`() { + val result = testSubject.execute(123L) + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when port is negative`() { + val result = testSubject.execute(-1L) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when port is zero`() { + val result = testSubject.execute(0) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when port exceeds maximum`() { + val result = testSubject.execute(65536L) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when port is null`() { + val result = testSubject.execute(null) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateServerTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateServerTest.kt new file mode 100644 index 0000000..233b093 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateServerTest.kt @@ -0,0 +1,123 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import app.k9mail.feature.account.server.settings.domain.usecase.ValidateServer.ValidateServerError +import assertk.Assert +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +/** + * Test data copied from Thunderbird Desktop `mailnews/base/test/unit/test_hostnameUtils.js` + */ +class ValidateServerTest { + + private val testSubject = ValidateServer() + + @Test + fun `should fail when server is empty or blank`() { + assertThat(validate("")).isFailureEmptyServer() + assertThat(validate(" ")).isFailureEmptyServer() + } + + @Test + fun `should succeed when server is valid IPv4 address`() { + assertThat(validate("1.2.3.4")).isSuccess() + assertThat(validate("123.245.111.222")).isSuccess() + assertThat(validate("255.255.255.255")).isSuccess() + assertThat(validate("1.2.0.4")).isSuccess() + assertThat(validate("1.2.3.4")).isSuccess() + assertThat(validate("127.1.2.3")).isSuccess() + assertThat(validate("10.1.2.3")).isSuccess() + assertThat(validate("192.168.2.3")).isSuccess() + } + + @Test + fun `should succeed when server is valid IPv6 address`() { + assertThat(validate("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).isSuccess() + assertThat(validate("2001:db8:85a3:0:0:8a2e:370:7334")).isSuccess() + assertThat(validate("2001:db8:85a3::8a2e:370:7334")).isSuccess() + assertThat(validate("2001:0db8:85a3:0000:0000:8a2e:0370:")).isSuccess() + assertThat(validate("::ffff:c000:0280")).isSuccess() + assertThat(validate("::ffff:192.0.2.128")).isSuccess() + assertThat(validate("2001:db8::1")).isSuccess() + assertThat(validate("2001:DB8::1")).isSuccess() + assertThat(validate("1:2:3:4:5:6:7:8")).isSuccess() + assertThat(validate("::1")).isSuccess() + assertThat(validate("::0000:0000:1")).isSuccess() + } + + @Test + fun `should succeed when server is valid domain`() { + assertThat(validate("localhost")).isSuccess() + assertThat(validate("some-server")).isSuccess() + assertThat(validate("server.company.other")).isSuccess() + assertThat(validate("server.comp-any.other")).isSuccess() + assertThat(validate("server.123.other")).isSuccess() + assertThat(validate("1server.123.other")).isSuccess() + assertThat(validate("1.2.3.4.5")).isSuccess() + assertThat(validate("very.log.sub.domain.name.other")).isSuccess() + assertThat(validate("1234567890")).isSuccess() + assertThat(validate("1234567890.")).isSuccess() + assertThat(validate("server.company.other.")).isSuccess() + } + + @Test + fun `should fail when server is invalid IPv4 address`() { + assertThat(validate(".1.2.3")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("1.2..123")).isFailureInvalidDomainOrIpAddress() + } + + @Test + fun `should fail when server is invalid IPv6 address`() { + assertThat(validate("::")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000:8a2e:0370:73346")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000:8a2e:0370:7334:1")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000:8a2e:0370:7334x")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000:8a2e:03707334")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000x8a2e:0370:7334")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000:::1")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000:0000:some:junk")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("2001:0db8:85a3:0000:0000:0000::192.0.2.359")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("some::junk")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("some_junk")).isFailureInvalidDomainOrIpAddress() + } + + @Test + fun `should fail when server is invalid domain`() { + assertThat(validate("server.badcompany!.invalid")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("server._badcompany.invalid")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("server.bad_company.invalid")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("server.badcompany-.invalid")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("server.bad company.invalid")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("server.b…dcompany.invalid")).isFailureInvalidDomainOrIpAddress() + assertThat(validate(".server.badcompany.invalid")).isFailureInvalidDomainOrIpAddress() + assertThat(validate("make-this-a-long-host-name-component-that-is-over-63-characters-long.invalid")) + .isFailureInvalidDomainOrIpAddress() + + val domain = + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." + + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." + + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." + + "append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid" + + assertThat(validate(domain)).isFailureInvalidDomainOrIpAddress() + } + + private fun validate(input: String): ValidationResult { + return testSubject.execute(input) + } + + private fun Assert.isSuccess() = isInstanceOf() + + private fun Assert.isFailureEmptyServer() = + isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf(ValidateServerError.EmptyServer::class) + + private fun Assert.isFailureInvalidDomainOrIpAddress() = + isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf(ValidateServerError.InvalidHostnameOrIpAddress::class) +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateUsernameTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateUsernameTest.kt new file mode 100644 index 0000000..65d2852 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/domain/usecase/ValidateUsernameTest.kt @@ -0,0 +1,37 @@ +package app.k9mail.feature.account.server.settings.domain.usecase + +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidateUsernameTest { + + private val testSubject = ValidateUsername() + + @Test + fun `should succeed when username is set`() { + val result = testSubject.execute("username") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when username is empty`() { + val result = testSubject.execute("") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when username is blank`() { + val result = testSubject.execute(" ") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/FakeIncomingServerSettingsValidator.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/FakeIncomingServerSettingsValidator.kt new file mode 100644 index 0000000..bbaebd3 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/FakeIncomingServerSettingsValidator.kt @@ -0,0 +1,17 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class FakeIncomingServerSettingsValidator( + private val serverAnswer: ValidationResult = ValidationResult.Success, + private val portAnswer: ValidationResult = ValidationResult.Success, + private val usernameAnswer: ValidationResult = ValidationResult.Success, + private val passwordAnswer: ValidationResult = ValidationResult.Success, + private val imapPrefixAnswer: ValidationResult = ValidationResult.Success, +) : IncomingServerSettingsContract.Validator { + override fun validateServer(server: String): ValidationResult = serverAnswer + override fun validatePort(port: Long?): ValidationResult = portAnswer + override fun validateUsername(username: String): ValidationResult = usernameAnswer + override fun validatePassword(password: String): ValidationResult = passwordAnswer + override fun validateImapPrefix(imapPrefix: String): ValidationResult = imapPrefixAnswer +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/FakeIncomingServerSettingsViewModel.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/FakeIncomingServerSettingsViewModel.kt new file mode 100644 index 0000000..c736954 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/FakeIncomingServerSettingsViewModel.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.ViewModel + +class FakeIncomingServerSettingsViewModel( + override val mode: InteractionMode = InteractionMode.Create, + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreenKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreenKtTest.kt new file mode 100644 index 0000000..4a226cf --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsScreenKtTest.kt @@ -0,0 +1,42 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class IncomingServerSettingsScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeIncomingServerSettingsViewModel(initialState = initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + IncomingServerSettingsScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateMapperKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateMapperKtTest.kt new file mode 100644 index 0000000..125f93d --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateMapperKtTest.kt @@ -0,0 +1,144 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.common.toInvalidEmailDomain +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings +import org.junit.Test + +class IncomingServerSettingsStateMapperKtTest { + + @Suppress("MaxLineLength") + @Test + fun `should map to state with email as username and emailDomain With dot prefix as server name when server settings are null`() { + val email = "test@example.com" + val accountState = AccountState( + emailAddress = email, + incomingServerSettings = null, + ) + + val result = accountState.toIncomingServerSettingsState() + + assertThat(result).isEqualTo( + State( + username = StringInputField(value = email), + server = StringInputField(value = email.toInvalidEmailDomain()), + ), + ) + } + + @Test + fun `should map from IMAP server settings to state`() { + val serverSettings = AccountState( + incomingServerSettings = IMAP_SERVER_SETTINGS, + ) + + val result = serverSettings.toIncomingServerSettingsState() + + assertThat(result).isEqualTo(INCOMING_IMAP_STATE) + } + + @Test + fun `should map from state to IMAP server settings and trim`() { + val incomingState = INCOMING_IMAP_STATE.copy( + server = StringInputField(value = " imap.example.org "), + username = StringInputField(value = " user "), + password = StringInputField(value = " password "), + imapPrefix = StringInputField(value = " prefix "), + ) + + val result = incomingState.toServerSettings() + + assertThat(result).isEqualTo(IMAP_SERVER_SETTINGS) + } + + @Test + fun `should map from POP3 server settings to state`() { + val serverSettings = AccountState( + incomingServerSettings = POP3_SERVER_SETTINGS, + ) + + val result = serverSettings.toIncomingServerSettingsState() + + assertThat(result).isEqualTo(INCOMING_POP3_STATE) + } + + @Test + fun `should map from state to POP3 server settings and trim`() { + val incomingState = INCOMING_POP3_STATE.copy( + server = StringInputField(value = " pop3.example.org "), + username = StringInputField(value = " user "), + password = StringInputField(value = " password "), + ) + + val result = incomingState.toServerSettings() + + assertThat(result).isEqualTo(POP3_SERVER_SETTINGS) + } + + private companion object { + private val INCOMING_IMAP_STATE = State( + protocolType = IncomingProtocolType.IMAP, + server = StringInputField(value = "imap.example.org"), + port = NumberInputField(value = 993), + security = ConnectionSecurity.TLS, + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "user"), + password = StringInputField(value = "password"), + clientCertificateAlias = null, + imapAutodetectNamespaceEnabled = false, + imapPrefix = StringInputField(value = "prefix"), + imapUseCompression = true, + imapSendClientInfo = true, + ) + + private val IMAP_SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.org", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = false, + pathPrefix = "prefix", + useCompression = true, + sendClientInfo = true, + ), + ) + + private val INCOMING_POP3_STATE = State( + protocolType = IncomingProtocolType.POP3, + server = StringInputField(value = "pop3.example.org"), + port = NumberInputField(value = 995), + security = ConnectionSecurity.TLS, + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "user"), + password = StringInputField(value = "password"), + clientCertificateAlias = null, + ) + + private val POP3_SERVER_SETTINGS = ServerSettings( + type = "pop3", + host = "pop3.example.org", + port = 995, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateTest.kt new file mode 100644 index 0000000..a6cb9a2 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsStateTest.kt @@ -0,0 +1,36 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.entity.toImapDefaultPort +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class IncomingServerSettingsStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + protocolType = IncomingProtocolType.IMAP, + server = StringInputField(), + security = ConnectionSecurity.DEFAULT, + port = NumberInputField(value = ConnectionSecurity.DEFAULT.toImapDefaultPort()), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(), + password = StringInputField(), + clientCertificateAlias = null, + imapAutodetectNamespaceEnabled = true, + imapUseCompression = true, + imapSendClientInfo = true, + ), + ) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsViewModelTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsViewModelTest.kt new file mode 100644 index 0000000..76d03de --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/incoming/IncomingServerSettingsViewModelTest.kt @@ -0,0 +1,406 @@ +package app.k9mail.feature.account.server.settings.ui.incoming + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndMviTurbinesConsumed +import app.k9mail.core.ui.compose.testing.mvi.eventStateTest +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.toImapDefaultPort +import app.k9mail.feature.account.common.domain.entity.toPop3DefaultPort +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class IncomingServerSettingsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should load account setup state when LoadAccountState event is received`() = runMviTest { + val accountState = AccountState( + emailAddress = "test@example.com", + incomingServerSettings = ServerSettings( + "imap", + "imap.example.com", + 123, + MailConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = true, + pathPrefix = null, + useCompression = true, + sendClientInfo = true, + ), + ), + ) + val repository = InMemoryAccountStateRepository( + state = AccountState(), + ) + val testSubject = createTestSubject( + repository = repository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + repository.setState(accountState) + + testSubject.event(Event.LoadAccountState) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitStateItem(), + turbines = turbines, + ) { + isEqualTo( + State( + protocolType = IncomingProtocolType.IMAP, + server = StringInputField(value = "imap.example.com"), + security = ConnectionSecurity.TLS, + port = NumberInputField(value = 123L), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "username"), + password = StringInputField(value = "password"), + imapAutodetectNamespaceEnabled = true, + imapPrefix = StringInputField(value = ""), + imapUseCompression = true, + imapSendClientInfo = true, + ), + ) + } + } + + @Test + fun `should change protocol, security and port when ProtocolTypeChanged event is received`() = runMviTest { + val initialState = State( + security = ConnectionSecurity.StartTLS, + port = NumberInputField(value = ConnectionSecurity.StartTLS.toImapDefaultPort()), + ) + val testSubject = createTestSubject(initialState) + + eventStateTest( + viewModel = testSubject, + initialState = initialState, + event = Event.ProtocolTypeChanged(IncomingProtocolType.POP3), + expectedState = State( + protocolType = IncomingProtocolType.POP3, + security = ConnectionSecurity.TLS, + port = NumberInputField(value = ConnectionSecurity.TLS.toPop3DefaultPort()), + ), + ) + } + + @Test + fun `should change state when ServerChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.ServerChanged("server"), + expectedState = State(server = StringInputField(value = "server")), + ) + } + + @Test + fun `should change security and port when SecurityChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.SecurityChanged(ConnectionSecurity.StartTLS), + expectedState = State( + security = ConnectionSecurity.StartTLS, + port = NumberInputField(value = ConnectionSecurity.StartTLS.toImapDefaultPort()), + ), + ) + } + + @Test + fun `should change state when PortChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.PortChanged(456L), + expectedState = State(port = NumberInputField(value = 456L)), + ) + } + + @Test + fun `should change authentication type when AuthenticationTypeChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.AuthenticationTypeChanged(AuthenticationType.PasswordEncrypted), + expectedState = State(authenticationType = AuthenticationType.PasswordEncrypted), + ) + } + + @Test + fun `should change state when UsernameChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.UsernameChanged("username"), + expectedState = State(username = StringInputField(value = "username")), + ) + } + + @Test + fun `should change state when PasswordChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.PasswordChanged("password"), + expectedState = State(password = StringInputField(value = "password")), + ) + } + + @Test + fun `should change state when ClientCertificateChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.ClientCertificateChanged("clientCertificate"), + expectedState = State(clientCertificateAlias = "clientCertificate"), + ) + } + + @Test + fun `should change state when ImapAutoDetectNamespaceChanged event is received`() = runMviTest { + val initialState = State(imapAutodetectNamespaceEnabled = true) + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = State(imapAutodetectNamespaceEnabled = true), + event = Event.ImapAutoDetectNamespaceChanged(false), + expectedState = State(imapAutodetectNamespaceEnabled = false), + ) + } + + @Test + fun `should change state when ImapPrefixChanged event is received`() = runMviTest { + val initialState = State() + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.ImapPrefixChanged("imapPrefix"), + expectedState = State(imapPrefix = StringInputField(value = "imapPrefix")), + ) + } + + @Test + fun `should change state when ImapUseCompressionChanged event is received`() = runMviTest { + val initialState = State(imapUseCompression = true) + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.ImapUseCompressionChanged(false), + expectedState = State(imapUseCompression = false), + ) + } + + @Test + fun `should change state when ImapSendClientInfoChanged event is received`() = runMviTest { + val initialState = State(imapSendClientInfo = true) + eventStateTest( + viewModel = createTestSubject(initialState), + initialState = initialState, + event = Event.ImapSendClientInfoChanged(false), + expectedState = State(imapSendClientInfo = false), + ) + } + + @Test + fun `should save state emit effect NavigateNext when OnNextClicked is received and input valid`() = runMviTest { + val initialState = State() + val repository = InMemoryAccountStateRepository() + val testSubject = createTestSubject( + initialState = initialState, + repository = repository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + assertThat(turbines.awaitStateItem()).isEqualTo( + State( + protocolType = IncomingProtocolType.IMAP, + server = StringInputField(value = "", isValid = true), + port = NumberInputField(value = 993L, isValid = true), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "", isValid = true), + password = StringInputField(value = "", isValid = true), + imapPrefix = StringInputField(value = "", isValid = true), + ), + ) + + assertThat(repository.getState()).isEqualTo( + AccountState( + incomingServerSettings = ServerSettings( + type = "imap", + host = "", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "", + password = "", + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = true, + pathPrefix = null, + useCompression = true, + sendClientInfo = true, + ), + ), + ), + ) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitEffectItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should save state and emit effect NavigateNext when OnNextClicked is received and input valid with OAuth`() = + runMviTest { + val initialState = State( + authenticationType = AuthenticationType.OAuth2, + ) + val repository = InMemoryAccountStateRepository() + val testSubject = createTestSubject( + initialState = initialState, + repository = repository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + assertThat(turbines.awaitStateItem()).isEqualTo( + State( + protocolType = IncomingProtocolType.IMAP, + server = StringInputField(value = "", isValid = true), + port = NumberInputField(value = 993L, isValid = true), + authenticationType = AuthenticationType.OAuth2, + username = StringInputField(value = "", isValid = true), + password = StringInputField(value = "", isValid = true), + imapPrefix = StringInputField(value = "", isValid = true), + ), + ) + + assertThat(repository.getState()).isEqualTo( + AccountState( + emailAddress = null, + incomingServerSettings = ServerSettings( + type = "imap", + host = "", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.XOAUTH2, + username = "", + password = null, + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = true, + pathPrefix = null, + useCompression = true, + sendClientInfo = true, + ), + ), + ), + ) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitEffectItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should change state and not emit NavigateNext effect when OnNextClicked event received and input invalid`() = + runMviTest { + val testSubject = IncomingServerSettingsViewModel( + mode = InteractionMode.Create, + validator = FakeIncomingServerSettingsValidator( + serverAnswer = ValidationResult.Failure(TestError), + ), + accountStateRepository = InMemoryAccountStateRepository(), + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.OnNextClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitStateItem(), + turbines = turbines, + ) { + isEqualTo( + State( + server = StringInputField(value = "", error = TestError, isValid = false), + port = NumberInputField(value = 993L, isValid = true), + username = StringInputField(value = "", isValid = true), + password = StringInputField(value = "", isValid = true), + imapPrefix = StringInputField(value = "", isValid = true), + ), + ) + } + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runMviTest { + val testSubject = createTestSubject(State()) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.OnBackClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitEffectItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateBack) + } + } + + private object TestError : ValidationError + + private companion object { + fun createTestSubject( + initialState: State = State(), + validator: IncomingServerSettingsContract.Validator = FakeIncomingServerSettingsValidator(), + repository: AccountDomainContract.AccountStateRepository = InMemoryAccountStateRepository(), + ) = IncomingServerSettingsViewModel( + mode = InteractionMode.Create, + validator = validator, + accountStateRepository = repository, + initialState = initialState, + ) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/FakeOutgoingServerSettingsValidator.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/FakeOutgoingServerSettingsValidator.kt new file mode 100644 index 0000000..00a5817 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/FakeOutgoingServerSettingsValidator.kt @@ -0,0 +1,15 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class FakeOutgoingServerSettingsValidator( + private val serverAnswer: ValidationResult = ValidationResult.Success, + private val portAnswer: ValidationResult = ValidationResult.Success, + private val usernameAnswer: ValidationResult = ValidationResult.Success, + private val passwordAnswer: ValidationResult = ValidationResult.Success, +) : OutgoingServerSettingsContract.Validator { + override fun validateServer(server: String): ValidationResult = serverAnswer + override fun validatePort(port: Long?): ValidationResult = portAnswer + override fun validateUsername(username: String): ValidationResult = usernameAnswer + override fun validatePassword(password: String): ValidationResult = passwordAnswer +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/FakeOutgoingServerSettingsViewModel.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/FakeOutgoingServerSettingsViewModel.kt new file mode 100644 index 0000000..5c088ec --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/FakeOutgoingServerSettingsViewModel.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.ViewModel + +class FakeOutgoingServerSettingsViewModel( + override val mode: InteractionMode = InteractionMode.Create, + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsScreenKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsScreenKtTest.kt new file mode 100644 index 0000000..036c080 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsScreenKtTest.kt @@ -0,0 +1,42 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class OutgoingServerSettingsScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeOutgoingServerSettingsViewModel(initialState = initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + OutgoingServerSettingsScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateMapperKtTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateMapperKtTest.kt new file mode 100644 index 0000000..bd4fab8 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateMapperKtTest.kt @@ -0,0 +1,148 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.common.toInvalidEmailDomain +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import org.junit.Test + +class OutgoingServerSettingsStateMapperKtTest { + + @Suppress("MaxLineLength") + @Test + fun `should map to state with email as username and emailDomain With dot prefix as server name when server settings are null`() { + val email = "test@example.com" + val accountState = AccountState( + emailAddress = email, + outgoingServerSettings = null, + ) + + val result = accountState.toOutgoingServerSettingsState() + + assertThat(result).isEqualTo( + State( + username = StringInputField(value = email), + server = StringInputField(value = email.toInvalidEmailDomain()), + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `should map to state with password from incomingServerSettings and emailDomain With dot prefix as server name when outgoingServerSettings is null`() { + val email = "test@domain.example" + val accountState = AccountState( + emailAddress = email, + incomingServerSettings = IMAP_SERVER_SETTINGS, + outgoingServerSettings = null, + ) + + val result = accountState.toOutgoingServerSettingsState() + + assertThat(result).isEqualTo( + State( + username = StringInputField(value = email), + password = StringInputField(value = INCOMING_SERVER_PASSWORD), + server = StringInputField(value = email.toInvalidEmailDomain()), + ), + ) + } + + @Test + fun `should map from SMTP server settings to state`() { + val accountState = AccountState( + outgoingServerSettings = SMTP_SERVER_SETTINGS, + ) + + val result = accountState.toOutgoingServerSettingsState() + + assertThat(result).isEqualTo(OUTGOING_STATE) + } + + @Test + fun `should use password from incomingServerSettings when outgoingServerSettings is missing a password`() { + val accountState = AccountState( + incomingServerSettings = IMAP_SERVER_SETTINGS, + outgoingServerSettings = SMTP_SERVER_SETTINGS.copy(password = null), + ) + + val result = accountState.toOutgoingServerSettingsState() + + assertThat(result).isEqualTo( + OUTGOING_STATE.copy( + password = StringInputField(value = INCOMING_SERVER_PASSWORD), + ), + ) + } + + @Test + fun `should use empty password if neither incomingServerSettings nor outgoingServerSettings contain passwords`() { + val accountState = AccountState( + outgoingServerSettings = SMTP_SERVER_SETTINGS.copy(password = null), + ) + + val result = accountState.toOutgoingServerSettingsState() + + assertThat(result).isEqualTo( + OUTGOING_STATE.copy( + password = StringInputField(value = ""), + ), + ) + } + + @Test + fun `should map state to server settings and trim input`() { + val outgoingState = OUTGOING_STATE.copy( + server = StringInputField(value = " smtp.example.org "), + username = StringInputField(value = " user "), + password = StringInputField(value = " password "), + ) + + val result = outgoingState.toServerSettings() + + assertThat(result).isEqualTo(SMTP_SERVER_SETTINGS) + } + + private companion object { + private val OUTGOING_STATE = State( + server = StringInputField(value = "smtp.example.org"), + port = NumberInputField(value = 587), + security = ConnectionSecurity.TLS, + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "user"), + password = StringInputField(value = "password"), + clientCertificateAlias = null, + ) + + private val SMTP_SERVER_SETTINGS = ServerSettings( + type = "smtp", + host = "smtp.example.org", + port = 587, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + private const val INCOMING_SERVER_PASSWORD = "incoming-password" + private val IMAP_SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.domain.example", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = INCOMING_SERVER_PASSWORD, + clientCertificateAlias = null, + ) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateTest.kt new file mode 100644 index 0000000..ade28a9 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsStateTest.kt @@ -0,0 +1,31 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.toSmtpDefaultPort +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class OutgoingServerSettingsStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + server = StringInputField(), + security = ConnectionSecurity.DEFAULT, + port = NumberInputField(value = ConnectionSecurity.DEFAULT.toSmtpDefaultPort()), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(), + password = StringInputField(), + clientCertificateAlias = null, + ), + ) + } +} diff --git a/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsViewModelTest.kt b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsViewModelTest.kt new file mode 100644 index 0000000..3a9c3e7 --- /dev/null +++ b/feature/account/server/settings/src/test/kotlin/app/k9mail/feature/account/server/settings/ui/outgoing/OutgoingServerSettingsViewModelTest.kt @@ -0,0 +1,307 @@ +package app.k9mail.feature.account.server.settings.ui.outgoing + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndMviTurbinesConsumed +import app.k9mail.core.ui.compose.testing.mvi.eventStateTest +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.toSmtpDefaultPort +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Effect +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.Event +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class OutgoingServerSettingsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should load account setup state when LoadAccountState event is received`() = runMviTest { + val accountState = AccountState( + emailAddress = "test@example.com", + outgoingServerSettings = ServerSettings( + "smtp", + "smtp.example.com", + 123, + MailConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + clientCertificateAlias = null, + extra = emptyMap(), + ), + ) + val repository = InMemoryAccountStateRepository( + state = AccountState(), + ) + val testSubject = createTestSubject( + repository = repository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + repository.setState(accountState) + + testSubject.event(Event.LoadAccountState) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitStateItem(), + turbines = turbines, + ) { + isEqualTo( + State( + server = StringInputField(value = "smtp.example.com"), + security = ConnectionSecurity.TLS, + port = NumberInputField(value = 123L), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "username"), + password = StringInputField(value = "password"), + ), + ) + } + } + + @Test + fun `should change state when ServerChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(State()), + initialState = State(), + event = Event.ServerChanged("server"), + expectedState = State(server = StringInputField(value = "server")), + ) + } + + @Test + fun `should change security and port when SecurityChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(State()), + initialState = State(), + event = Event.SecurityChanged(ConnectionSecurity.StartTLS), + expectedState = State( + security = ConnectionSecurity.StartTLS, + port = NumberInputField(value = ConnectionSecurity.StartTLS.toSmtpDefaultPort()), + ), + ) + } + + @Test + fun `should change state when PortChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(State()), + initialState = State(), + event = Event.PortChanged(456L), + expectedState = State(port = NumberInputField(value = 456L)), + ) + } + + @Test + fun `should change state when AuthenticationTypeChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(State()), + initialState = State(), + event = Event.AuthenticationTypeChanged(AuthenticationType.PasswordEncrypted), + expectedState = State(authenticationType = AuthenticationType.PasswordEncrypted), + ) + } + + @Test + fun `should change state when UsernameChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(State()), + initialState = State(), + event = Event.UsernameChanged("username"), + expectedState = State(username = StringInputField(value = "username")), + ) + } + + @Test + fun `should change state when PasswordChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(State()), + initialState = State(), + event = Event.PasswordChanged("password"), + expectedState = State(password = StringInputField(value = "password")), + ) + } + + @Test + fun `should change state when ClientCertificateChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(State()), + initialState = State(), + event = Event.ClientCertificateChanged("clientCertificate"), + expectedState = State(clientCertificateAlias = "clientCertificate"), + ) + } + + @Test + fun `should save state and emit effect NavigateNext when OnNextClicked is received and input valid`() = runMviTest { + val initialState = State() + val repository = InMemoryAccountStateRepository() + val testSubject = createTestSubject( + initialState = initialState, + repository = repository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + assertThat(turbines.awaitStateItem()).isEqualTo( + State( + server = StringInputField(value = "", isValid = true), + port = NumberInputField(value = 465L, isValid = true), + authenticationType = AuthenticationType.PasswordCleartext, + username = StringInputField(value = "", isValid = true), + password = StringInputField(value = "", isValid = true), + ), + ) + + assertThat(repository.getState()).isEqualTo( + AccountState( + outgoingServerSettings = ServerSettings( + type = "smtp", + host = "", + port = 465, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "", + password = "", + clientCertificateAlias = null, + extra = emptyMap(), + ), + ), + ) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitEffectItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should save state and emit effect NavigateNext when OnNextClicked is received and input valid with OAuth`() = + runMviTest { + val initialState = State( + authenticationType = AuthenticationType.OAuth2, + ) + val repository = InMemoryAccountStateRepository() + val testSubject = createTestSubject( + initialState = initialState, + repository = repository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + assertThat(turbines.awaitStateItem()).isEqualTo( + State( + server = StringInputField(value = "", isValid = true), + port = NumberInputField(value = 465L, isValid = true), + authenticationType = AuthenticationType.OAuth2, + username = StringInputField(value = "", isValid = true), + password = StringInputField(value = "", isValid = true), + ), + ) + + assertThat(repository.getState()).isEqualTo( + AccountState( + outgoingServerSettings = ServerSettings( + type = "smtp", + host = "", + port = 465, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.XOAUTH2, + username = "", + password = null, + clientCertificateAlias = null, + extra = emptyMap(), + ), + ), + ) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitEffectItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should change state and not emit NavigateNext effect when OnNextClicked event received and input invalid`() = + runMviTest { + val initialState = State() + val testSubject = createTestSubject( + validator = FakeOutgoingServerSettingsValidator( + serverAnswer = ValidationResult.Failure(TestError), + ), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitStateItem(), + turbines = turbines, + ) { + isEqualTo( + State( + server = StringInputField(value = "", error = TestError, isValid = false), + port = NumberInputField(value = 465L, isValid = true), + username = StringInputField(value = "", isValid = true), + password = StringInputField(value = "", isValid = true), + ), + ) + } + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runMviTest { + val initialState = State() + val testSubject = createTestSubject(initialState) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnBackClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.awaitEffectItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateBack) + } + } + + private object TestError : ValidationError + + private companion object { + fun createTestSubject( + initialState: State = State(), + validator: OutgoingServerSettingsContract.Validator = FakeOutgoingServerSettingsValidator(), + repository: AccountDomainContract.AccountStateRepository = InMemoryAccountStateRepository(), + ) = OutgoingServerSettingsViewModel( + mode = InteractionMode.Create, + validator = validator, + accountStateRepository = repository, + initialState = initialState, + ) + } +} diff --git a/feature/account/server/validation/build.gradle.kts b/feature/account/server/validation/build.gradle.kts new file mode 100644 index 0000000..8cfb84a --- /dev/null +++ b/feature/account/server/validation/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.account.server.validation" + resourcePrefix = "account_server_validation_" +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.common) + + implementation(projects.mail.common) + implementation(projects.mail.protocols.imap) + implementation(projects.mail.protocols.pop3) + implementation(projects.mail.protocols.smtp) + + implementation(projects.feature.account.common) + implementation(projects.feature.account.oauth) + implementation(projects.feature.account.server.certificate) + + testImplementation(projects.core.ui.compose.testing) +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContentPreview.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContentPreview.kt new file mode 100644 index 0000000..ff4401a --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContentPreview.kt @@ -0,0 +1,35 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel + +@Composable +@Preview(showBackground = true) +internal fun IncomingServerValidationContentPreview() { + PreviewWithTheme { + ServerValidationContent( + onEvent = { }, + state = ServerValidationContract.State(), + isIncomingValidation = true, + oAuthViewModel = FakeAccountOAuthViewModel(), + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun OutgoingServerValidationContentPreview() { + PreviewWithTheme { + ServerValidationContent( + onEvent = { }, + state = ServerValidationContract.State(), + isIncomingValidation = false, + oAuthViewModel = FakeAccountOAuthViewModel(), + contentPadding = PaddingValues(), + ) + } +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationMainScreenPreview.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationMainScreenPreview.kt new file mode 100644 index 0000000..8ed54cc --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationMainScreenPreview.kt @@ -0,0 +1,35 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.server.validation.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.validation.ui.fake.FakeBrandNameProvider +import app.k9mail.feature.account.server.validation.ui.fake.FakeIncomingServerValidationViewModel +import app.k9mail.feature.account.server.validation.ui.fake.FakeOutgoingServerValidationViewModel + +@Composable +@PreviewDevices +internal fun IncomingServerValidationMainScreenPreview() { + PreviewWithTheme { + ServerValidationMainScreen( + viewModel = FakeIncomingServerValidationViewModel( + oAuthViewModel = FakeAccountOAuthViewModel(), + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} + +@Composable +@PreviewDevices +internal fun OutgoingServerValidationMainScreenPreview() { + PreviewWithTheme { + ServerValidationMainScreen( + viewModel = FakeOutgoingServerValidationViewModel( + oAuthViewModel = FakeAccountOAuthViewModel(), + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreenPreview.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreenPreview.kt new file mode 100644 index 0000000..2edc5a8 --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreenPreview.kt @@ -0,0 +1,48 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.ui.fake.FakeAccountStateRepository +import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository +import app.k9mail.feature.account.server.validation.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.validation.ui.fake.FakeBrandNameProvider +import com.fsck.k9.mail.server.ServerSettingsValidationResult + +@Composable +@PreviewDevices +internal fun IncomingServerValidationScreenPreview() { + PreviewWithTheme { + ServerValidationScreen( + onNext = { }, + onBack = { }, + viewModel = IncomingServerValidationViewModel( + accountStateRepository = FakeAccountStateRepository(), + validateServerSettings = { ServerSettingsValidationResult.Success }, + authorizationStateRepository = { true }, + certificateErrorRepository = InMemoryServerCertificateErrorRepository(), + oAuthViewModel = FakeAccountOAuthViewModel(), + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} + +@Composable +@PreviewDevices +internal fun OutgoingServerValidationScreenPreview() { + PreviewWithTheme { + ServerValidationScreen( + onNext = { }, + onBack = { }, + viewModel = OutgoingServerValidationViewModel( + accountStateRepository = FakeAccountStateRepository(), + validateServerSettings = { ServerSettingsValidationResult.Success }, + authorizationStateRepository = { true }, + certificateErrorRepository = InMemoryServerCertificateErrorRepository(), + oAuthViewModel = FakeAccountOAuthViewModel(), + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationToolbarScreenPreview.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationToolbarScreenPreview.kt new file mode 100644 index 0000000..170136b --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationToolbarScreenPreview.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.server.validation.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.validation.ui.fake.FakeIncomingServerValidationViewModel + +@Composable +@PreviewDevices +internal fun IncomingServerValidationToolbarScreenPreview() { + PreviewWithTheme { + ServerValidationToolbarScreen( + title = "Incoming server settings", + viewModel = FakeIncomingServerValidationViewModel( + oAuthViewModel = FakeAccountOAuthViewModel(), + ), + ) + } +} + +@Composable +@PreviewDevices +internal fun OutgoingServerValidationToolbarScreenPreview() { + PreviewWithTheme { + ServerValidationToolbarScreen( + title = "Incoming server settings", + viewModel = FakeIncomingServerValidationViewModel( + oAuthViewModel = FakeAccountOAuthViewModel(), + ), + ) + } +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeAccountOAuthViewModel.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeAccountOAuthViewModel.kt new file mode 100644 index 0000000..b938641 --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeAccountOAuthViewModel.kt @@ -0,0 +1,17 @@ +package app.k9mail.feature.account.server.validation.ui.fake + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract + +/** + * Only for previewing the UI. + */ +class FakeAccountOAuthViewModel : + BaseViewModel( + AccountOAuthContract.State(), + ), + AccountOAuthContract.ViewModel { + + override fun initState(state: AccountOAuthContract.State) = Unit + override fun event(event: AccountOAuthContract.Event) = Unit +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeBrandNameProvider.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeBrandNameProvider.kt new file mode 100644 index 0000000..0c23f90 --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeBrandNameProvider.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.account.server.validation.ui.fake + +import net.thunderbird.core.common.provider.BrandNameProvider + +internal object FakeBrandNameProvider : BrandNameProvider { + override val brandName: String = "Fake Brand Name" +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeIncomingServerValidationViewModel.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeIncomingServerValidationViewModel.kt new file mode 100644 index 0000000..38b7fbe --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeIncomingServerValidationViewModel.kt @@ -0,0 +1,27 @@ +package app.k9mail.feature.account.server.validation.ui.fake + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Effect +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.ViewModel + +class FakeIncomingServerValidationViewModel( + override val oAuthViewModel: AccountOAuthContract.ViewModel = FakeAccountOAuthViewModel(), + override val isIncomingValidation: Boolean = true, + initialState: State = State(), +) : BaseViewModel(initialState), ServerValidationContract.IncomingViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeOutgoingServerValidationViewModel.kt b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeOutgoingServerValidationViewModel.kt new file mode 100644 index 0000000..3b82517 --- /dev/null +++ b/feature/account/server/validation/src/debug/kotlin/app/k9mail/feature/account/server/validation/ui/fake/FakeOutgoingServerValidationViewModel.kt @@ -0,0 +1,27 @@ +package app.k9mail.feature.account.server.validation.ui.fake + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Effect +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.ViewModel + +class FakeOutgoingServerValidationViewModel( + override val oAuthViewModel: AccountOAuthContract.ViewModel = FakeAccountOAuthViewModel(), + override val isIncomingValidation: Boolean = true, + initialState: State = State(), +) : BaseViewModel(initialState), ServerValidationContract.OutgoingViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ServerValidationModule.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ServerValidationModule.kt new file mode 100644 index 0000000..59a34d1 --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ServerValidationModule.kt @@ -0,0 +1,64 @@ +package app.k9mail.feature.account.server.validation + +import app.k9mail.feature.account.common.featureAccountCommonModule +import app.k9mail.feature.account.oauth.featureAccountOAuthModule +import app.k9mail.feature.account.server.certificate.featureAccountServerCertificateModule +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract +import app.k9mail.feature.account.server.validation.domain.usecase.ValidateServerSettings +import app.k9mail.feature.account.server.validation.ui.IncomingServerValidationViewModel +import app.k9mail.feature.account.server.validation.ui.OutgoingServerValidationViewModel +import com.fsck.k9.mail.store.imap.ImapServerSettingsValidator +import com.fsck.k9.mail.store.pop3.Pop3ServerSettingsValidator +import com.fsck.k9.mail.transport.smtp.SmtpServerSettingsValidator +import net.thunderbird.core.common.coreCommonModule +import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val featureAccountServerValidationModule = module { + includes( + coreCommonModule, + featureAccountCommonModule, + featureAccountServerCertificateModule, + featureAccountOAuthModule, + ) + + factory { + ValidateServerSettings( + authStateStorage = get(), + imapValidator = ImapServerSettingsValidator( + trustedSocketFactory = get(), + oAuth2TokenProviderFactory = get(), + clientInfoAppName = get(named("ClientInfoAppName")), + clientInfoAppVersion = get(named("ClientInfoAppVersion")), + ), + pop3Validator = Pop3ServerSettingsValidator( + trustedSocketFactory = get(), + ), + smtpValidator = SmtpServerSettingsValidator( + trustedSocketFactory = get(), + oAuth2TokenProviderFactory = get(), + ), + ) + } + + viewModel { + IncomingServerValidationViewModel( + validateServerSettings = get(), + accountStateRepository = get(), + authorizationStateRepository = get(), + certificateErrorRepository = get(), + oAuthViewModel = get(), + ) + } + + viewModel { + OutgoingServerValidationViewModel( + validateServerSettings = get(), + accountStateRepository = get(), + authorizationStateRepository = get(), + certificateErrorRepository = get(), + oAuthViewModel = get(), + ) + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/domain/ServerValidationDomainContract.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/domain/ServerValidationDomainContract.kt new file mode 100644 index 0000000..7ee70de --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/domain/ServerValidationDomainContract.kt @@ -0,0 +1,14 @@ +package app.k9mail.feature.account.server.validation.domain + +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.server.ServerSettingsValidationResult + +interface ServerValidationDomainContract { + + interface UseCase { + + fun interface ValidateServerSettings { + suspend fun execute(settings: ServerSettings): ServerSettingsValidationResult + } + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/ValidateServerSettings.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/ValidateServerSettings.kt new file mode 100644 index 0000000..41b22e9 --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/ValidateServerSettings.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.account.server.validation.domain.usecase + +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.AuthStateStorage +import com.fsck.k9.mail.server.ServerSettingsValidationResult +import com.fsck.k9.mail.server.ServerSettingsValidator +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ValidateServerSettings( + private val authStateStorage: AuthStateStorage, + private val imapValidator: ServerSettingsValidator, + private val pop3Validator: ServerSettingsValidator, + private val smtpValidator: ServerSettingsValidator, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ServerValidationDomainContract.UseCase.ValidateServerSettings { + override suspend fun execute(settings: ServerSettings): ServerSettingsValidationResult { + return withContext(coroutineDispatcher) { + when (settings.type) { + "imap" -> imapValidator.checkServerSettings(settings, authStateStorage) + "pop3" -> pop3Validator.checkServerSettings(settings, authStateStorage) + "smtp" -> smtpValidator.checkServerSettings(settings, authStateStorage) + "demo" -> ServerSettingsValidationResult.Success + else -> { + throw IllegalArgumentException("Unsupported server type: ${settings.type}") + } + } + } + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/BaseServerValidationViewModel.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/BaseServerValidationViewModel.kt new file mode 100644 index 0000000..48038a2 --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/BaseServerValidationViewModel.kt @@ -0,0 +1,223 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.ui.WizardConstants +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.domain.entity.OAuthResult +import app.k9mail.feature.account.oauth.domain.entity.isOAuth +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Effect +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Error +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import com.fsck.k9.mail.server.ServerSettingsValidationResult +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Suppress("TooManyFunctions") +abstract class BaseServerValidationViewModel( + private val accountStateRepository: AccountDomainContract.AccountStateRepository, + private val validateServerSettings: ServerValidationDomainContract.UseCase.ValidateServerSettings, + private val authorizationStateRepository: AccountOAuthDomainContract.AuthorizationStateRepository, + private val certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository, + override val oAuthViewModel: AccountOAuthContract.ViewModel, + override val isIncomingValidation: Boolean = true, + initialState: State? = null, +) : BaseViewModel( + initialState = initialState ?: accountStateRepository.getState().toServerValidationState(isIncomingValidation), +), + ServerValidationContract.ViewModel { + + override fun event(event: Event) { + when (event) { + Event.LoadAccountStateAndValidate -> handleOneTimeEvent(event, ::loadAccountStateAndValidate) + is Event.OnOAuthResult -> onOAuthResult(event.result) + Event.ValidateServerSettings -> onValidateConfig() + Event.OnNextClicked -> navigateNext() + Event.OnBackClicked -> onBack() + Event.OnRetryClicked -> onRetry() + Event.OnCertificateAccepted -> onRetry() + } + } + + private fun loadAccountStateAndValidate() { + updateState { + accountStateRepository.getState().toServerValidationState(isIncomingValidation) + } + onValidateConfig() + } + + private fun onValidateConfig() { + if (state.value.isSuccess) { + navigateNext() + } else if (state.value.serverSettings.isOAuth()) { + checkOAuthState() + } else { + validateServerSettings() + } + } + + private fun checkOAuthState() { + val authorizationState = accountStateRepository.getState().authorizationState + if (authorizationState != null) { + if (authorizationStateRepository.isAuthorized(authorizationState)) { + validateServerSettings() + } else { + startOAuthSignIn() + } + } else { + startOAuthSignIn() + } + } + + private fun startOAuthSignIn() { + val hostname = state.value.serverSettings?.host + val emailAddress = state.value.emailAddress + + if (hostname == null || emailAddress == null) { + updateError(Error.UnknownError("Hostname or email address not set")) + return + } else { + updateState { state -> + state.copy( + needsAuthorization = true, + ) + } + + oAuthViewModel.initState( + AccountOAuthContract.State( + hostname = hostname, + emailAddress = emailAddress, + ), + ) + } + } + + private fun onOAuthResult(result: OAuthResult) { + if (result is OAuthResult.Success) { + accountStateRepository.setAuthorizationState(result.authorizationState) + updateState { + it.copy( + needsAuthorization = false, + ) + } + + validateServerSettings() + } + } + + private fun validateServerSettings() { + viewModelScope.launch { + val serverSettings = state.value.serverSettings + if (serverSettings == null) { + updateError(Error.UnknownError("Server settings not set")) + return@launch + } + + updateState { + it.copy(isLoading = true) + } + + when (val result = validateServerSettings.execute(serverSettings)) { + ServerSettingsValidationResult.Success -> updateSuccess() + + is ServerSettingsValidationResult.AuthenticationError -> updateError( + Error.AuthenticationError(result.serverMessage), + ) + + is ServerSettingsValidationResult.CertificateError -> updateError( + Error.CertificateError(result.certificateChain), + ) + + ServerSettingsValidationResult.ClientCertificateError.ClientCertificateExpired -> updateError( + Error.ClientCertificateExpired, + ) + + ServerSettingsValidationResult.ClientCertificateError.ClientCertificateRetrievalFailure -> updateError( + Error.ClientCertificateRetrievalFailure, + ) + + is ServerSettingsValidationResult.NetworkError -> updateError( + Error.NetworkError(result.exception), + ) + + is ServerSettingsValidationResult.ServerError -> updateError( + Error.ServerError(result.serverMessage), + ) + + is ServerSettingsValidationResult.MissingServerCapabilityError -> updateError( + Error.MissingServerCapabilityError(result.capabilityName), + ) + + is ServerSettingsValidationResult.UnknownError -> updateError( + Error.UnknownError(result.exception.message ?: "Unknown error"), + ) + } + } + } + + private fun updateSuccess() { + updateState { + it.copy( + isLoading = false, + isSuccess = true, + ) + } + + viewModelScope.launch { + delay(WizardConstants.CONTINUE_NEXT_DELAY) + navigateNext() + } + } + + private fun updateError(error: Error) { + if (error is Error.CertificateError) { + val serverSettings = checkNotNull(state.value.serverSettings) + + certificateErrorRepository.setCertificateError( + ServerCertificateError( + hostname = serverSettings.host!!, + port = serverSettings.port, + certificateChain = error.certificateChain, + ), + ) + } + + updateState { + it.copy( + error = error, + isLoading = false, + isSuccess = false, + ) + } + } + + private fun onBack() { + navigateBack() + } + + private fun onRetry() { + updateState { + it.copy( + error = null, + ) + } + onValidateConfig() + } + + private fun navigateBack() { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateBack) + } + + private fun navigateNext() { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateNext) + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/IncomingServerValidationViewModel.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/IncomingServerValidationViewModel.kt new file mode 100644 index 0000000..dcef4fc --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/IncomingServerValidationViewModel.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract.UseCase + +class IncomingServerValidationViewModel( + accountStateRepository: AccountDomainContract.AccountStateRepository, + validateServerSettings: UseCase.ValidateServerSettings, + authorizationStateRepository: AccountOAuthDomainContract.AuthorizationStateRepository, + certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository, + oAuthViewModel: AccountOAuthContract.ViewModel, + initialState: ServerValidationContract.State? = null, +) : BaseServerValidationViewModel( + accountStateRepository = accountStateRepository, + validateServerSettings = validateServerSettings, + authorizationStateRepository = authorizationStateRepository, + certificateErrorRepository = certificateErrorRepository, + oAuthViewModel = oAuthViewModel, + initialState = initialState, + isIncomingValidation = true, +), + ServerValidationContract.IncomingViewModel diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/OutgoingServerValidationViewModel.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/OutgoingServerValidationViewModel.kt new file mode 100644 index 0000000..da1d13d --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/OutgoingServerValidationViewModel.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract.UseCase + +class OutgoingServerValidationViewModel( + accountStateRepository: AccountDomainContract.AccountStateRepository, + validateServerSettings: UseCase.ValidateServerSettings, + authorizationStateRepository: AccountOAuthDomainContract.AuthorizationStateRepository, + certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository, + oAuthViewModel: AccountOAuthContract.ViewModel, + initialState: ServerValidationContract.State? = null, +) : BaseServerValidationViewModel( + accountStateRepository = accountStateRepository, + validateServerSettings = validateServerSettings, + authorizationStateRepository = authorizationStateRepository, + certificateErrorRepository = certificateErrorRepository, + oAuthViewModel = oAuthViewModel, + initialState = initialState, + isIncomingValidation = false, +), + ServerValidationContract.OutgoingViewModel diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContent.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContent.kt new file mode 100644 index 0000000..4414434 --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContent.kt @@ -0,0 +1,120 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.ui.item.ErrorItem +import app.k9mail.feature.account.common.ui.item.ListItem +import app.k9mail.feature.account.common.ui.item.LoadingItem +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthView +import app.k9mail.feature.account.server.validation.R +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Suppress("LongMethod", "ViewModelForwarding") +@Composable +internal fun ServerValidationContent( + state: State, + isIncomingValidation: Boolean, + oAuthViewModel: AccountOAuthContract.ViewModel, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + ResponsiveWidthContainer( + modifier = Modifier + .testTagAsResourceId("AccountValidationContent") + .padding(contentPadding) + .fillMaxWidth() + .then(modifier), + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = contentPadding, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double, Alignment.CenterVertically), + ) { + if (state.error != null) { + item(key = "error") { + // TODO add raw error message + ErrorItem( + title = stringResource( + id = if (isIncomingValidation) { + R.string.account_server_validation_incoming_loading_error + } else { + R.string.account_server_validation_outgoing_loading_error + }, + ), + message = state.error.toResourceString(resources), + onRetry = { onEvent(Event.OnRetryClicked) }, + ) + } + } else if (state.isSuccess) { + item(key = "success") { + LoadingItem( + message = stringResource( + id = if (isIncomingValidation) { + R.string.account_server_validation_incoming_success + } else { + R.string.account_server_validation_outgoing_success + }, + ), + ) + } + } else if (state.needsAuthorization) { + item(key = "oauth") { + ListItem { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TextTitleMedium( + text = stringResource( + id = R.string.account_server_validation_sign_in, + ), + ) + Spacer(modifier = Modifier.padding(MainTheme.spacings.default)) + AccountOAuthView( + onOAuthResult = { result -> onEvent(Event.OnOAuthResult(result)) }, + viewModel = oAuthViewModel, + ) + } + } + } + } else { + item(key = "loading") { + LoadingItem( + message = stringResource( + id = if (isIncomingValidation) { + R.string.account_server_validation_incoming_loading_message + } else { + R.string.account_server_validation_outgoing_loading_message + }, + ), + ) + } + } + } + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContract.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContract.kt new file mode 100644 index 0000000..c028dfe --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationContract.kt @@ -0,0 +1,56 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.oauth.domain.entity.OAuthResult +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import com.fsck.k9.mail.ServerSettings +import java.io.IOException +import java.security.cert.X509Certificate + +interface ServerValidationContract { + + interface ViewModel : UnidirectionalViewModel { + val isIncomingValidation: Boolean + val oAuthViewModel: AccountOAuthContract.ViewModel + } + + interface OutgoingViewModel : ViewModel + interface IncomingViewModel : ViewModel + + data class State( + val emailAddress: String? = null, + val serverSettings: ServerSettings? = null, + val needsAuthorization: Boolean = false, + val isSuccess: Boolean = false, + val error: Error? = null, + val isLoading: Boolean = false, + ) + + sealed interface Event { + object LoadAccountStateAndValidate : Event + + data class OnOAuthResult(val result: OAuthResult) : Event + + object ValidateServerSettings : Event + object OnNextClicked : Event + object OnBackClicked : Event + object OnRetryClicked : Event + object OnCertificateAccepted : Event + } + + sealed interface Effect { + object NavigateNext : Effect + object NavigateBack : Effect + } + + sealed interface Error { + data class NetworkError(val exception: IOException) : Error + data class CertificateError(val certificateChain: List) : Error + data object ClientCertificateRetrievalFailure : Error + data object ClientCertificateExpired : Error + data class AuthenticationError(val serverMessage: String?) : Error + data class ServerError(val serverMessage: String?) : Error + data class MissingServerCapabilityError(val capabilityName: String) : Error + data class UnknownError(val message: String) : Error + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationMainScreen.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationMainScreen.kt new file mode 100644 index 0000000..137b3b8 --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationMainScreen.kt @@ -0,0 +1,47 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observeWithoutEffect +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.ui.AppTitleTopHeader +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.common.ui.WizardNavigationBarState +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.ViewModel +import net.thunderbird.core.common.provider.BrandNameProvider + +@Composable +internal fun ServerValidationMainScreen( + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observeWithoutEffect() + + Scaffold( + topBar = { + AppTitleTopHeader( + title = brandNameProvider.brandName, + ) + }, + bottomBar = { + WizardNavigationBar( + onNextClick = {}, + onBackClick = { dispatch(Event.OnBackClicked) }, + state = WizardNavigationBarState( + showNext = false, + ), + ) + }, + modifier = modifier, + ) { innerPadding -> + ServerValidationContent( + onEvent = { dispatch(it) }, + state = state.value, + isIncomingValidation = viewModel.isIncomingValidation, + oAuthViewModel = viewModel.oAuthViewModel, + contentPadding = innerPadding, + ) + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreen.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreen.kt new file mode 100644 index 0000000..d8d248f --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreen.kt @@ -0,0 +1,60 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorScreen +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Effect +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.ViewModel +import net.thunderbird.core.common.provider.BrandNameProvider + +@Suppress("ViewModelForwarding") +@Composable +fun ServerValidationScreen( + onNext: () -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, + title: String? = null, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateNext -> onNext() + is Effect.NavigateBack -> onBack() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.LoadAccountStateAndValidate) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + if (state.value.error is ServerValidationContract.Error.CertificateError) { + ServerCertificateErrorScreen( + onCertificateAccepted = { dispatch(Event.OnCertificateAccepted) }, + onBack = { dispatch(Event.OnBackClicked) }, + modifier = modifier, + ) + } else { + if (title != null) { + ServerValidationToolbarScreen( + title = title, + viewModel = viewModel, + modifier = modifier, + ) + } else { + ServerValidationMainScreen( + viewModel = viewModel, + brandNameProvider = brandNameProvider, + modifier = modifier, + ) + } + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStateMapper.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStateMapper.kt new file mode 100644 index 0000000..b140ae2 --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStateMapper.kt @@ -0,0 +1,13 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.feature.account.common.domain.entity.AccountState + +internal fun AccountState.toServerValidationState(isIncomingValidation: Boolean): ServerValidationContract.State { + return ServerValidationContract.State( + emailAddress = emailAddress, + serverSettings = if (isIncomingValidation) incomingServerSettings else outgoingServerSettings, + isLoading = false, + isSuccess = false, + error = null, + ) +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStringMapper.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStringMapper.kt new file mode 100644 index 0000000..407969e --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStringMapper.kt @@ -0,0 +1,75 @@ +package app.k9mail.feature.account.server.validation.ui + +import android.content.res.Resources +import androidx.annotation.StringRes +import app.k9mail.feature.account.server.validation.R +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Error +import app.k9mail.feature.account.common.R as CommonR + +internal fun Error.toResourceString(resources: Resources): String { + return when (this) { + is Error.CertificateError -> error("Handle CertificateError using ServerCertificateErrorScreen") + + is Error.AuthenticationError -> { + resources.buildErrorString( + titleResId = R.string.account_server_validation_error_authentication, + detailsResId = CommonR.string.account_common_error_server_message, + detailsMessage = serverMessage, + ) + } + + is Error.NetworkError -> { + resources.buildErrorString( + titleResId = R.string.account_server_validation_error_network, + detailsResId = R.string.account_server_validation_error_details, + detailsMessage = exception.message, + ) + } + + is Error.ServerError -> { + resources.buildErrorString( + titleResId = R.string.account_server_validation_error_server, + detailsResId = CommonR.string.account_common_error_server_message, + detailsMessage = serverMessage, + ) + } + + is Error.UnknownError -> { + resources.buildErrorString( + titleResId = R.string.account_server_validation_error_unknown, + detailsResId = R.string.account_server_validation_error_details, + detailsMessage = message, + ) + } + + Error.ClientCertificateExpired -> { + resources.getString(R.string.account_server_validation_error_client_certificate_expired) + } + + Error.ClientCertificateRetrievalFailure -> { + resources.getString(R.string.account_server_validation_error_client_certificate_retrieval_failure) + } + + is Error.MissingServerCapabilityError -> { + resources.buildErrorString( + titleResId = R.string.account_server_validation_error_missing_server_capability, + detailsResId = R.string.account_server_validation_error_missing_server_capability_details, + detailsMessage = capabilityName, + ) + } + } +} + +private fun Resources.buildErrorString( + @StringRes titleResId: Int, + @StringRes detailsResId: Int, + detailsMessage: String?, +): String { + val title = getString(titleResId) + return if (detailsMessage != null) { + val details = getString(detailsResId, detailsMessage) + "$title\n\n$details" + } else { + title + } +} diff --git a/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationToolbarScreen.kt b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationToolbarScreen.kt new file mode 100644 index 0000000..ff3f6ca --- /dev/null +++ b/feature/account/server/validation/src/main/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationToolbarScreen.kt @@ -0,0 +1,47 @@ +package app.k9mail.feature.account.server.validation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observeWithoutEffect +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBarWithBackButton +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.common.ui.WizardNavigationBarState +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.ViewModel + +@Composable +internal fun ServerValidationToolbarScreen( + title: String, + viewModel: ViewModel, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observeWithoutEffect() + + Scaffold( + topBar = { + TopAppBarWithBackButton( + title = title, + onBackClick = { dispatch(Event.OnBackClicked) }, + ) + }, + bottomBar = { + WizardNavigationBar( + onNextClick = {}, + onBackClick = { dispatch(Event.OnBackClicked) }, + state = WizardNavigationBarState( + showNext = false, + ), + ) + }, + modifier = modifier, + ) { innerPadding -> + ServerValidationContent( + onEvent = { dispatch(it) }, + state = state.value, + isIncomingValidation = viewModel.isIncomingValidation, + oAuthViewModel = viewModel.oAuthViewModel, + contentPadding = innerPadding, + ) + } +} diff --git a/feature/account/server/validation/src/main/res/values-am/strings.xml b/feature/account/server/validation/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ar/strings.xml b/feature/account/server/validation/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..0791f38 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ar/strings.xml @@ -0,0 +1,19 @@ + + + تعذر الوصول إلى شهادة العميل + شهادة العميل لم تعد صالحة + التفاصيل:\n%s + هناك إمكانية مفقودة في الخادم + يفتقد الخادم هذه الإمكانية:\n%s + التحقق من إعدادات خادم البريد الوارد… + تم التحقق من إعدادات خادم البريد الوارد + التحقق من إعدادات خادم البريد الصادر… + يُرجى القيام بتسجيل الدخول + فشل التحقق من إعدادات خادم البريد الوارد + فشل التحقق من إعدادات خادم البريد الصادر + خطأ في المصادقة + خطأ في الشبكة + خطأ في الخادم + خطأ غير معروف + تم التحقق من إعدادات خادم البريد الصادر + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ast/strings.xml b/feature/account/server/validation/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-az/strings.xml b/feature/account/server/validation/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-be/strings.xml b/feature/account/server/validation/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..41e5f9b --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-be/strings.xml @@ -0,0 +1,5 @@ + + + Памылка сеткі + Памылка аўтэнтыфікацыі + diff --git a/feature/account/server/validation/src/main/res/values-bg/strings.xml b/feature/account/server/validation/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..ee395ff --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-bg/strings.xml @@ -0,0 +1,19 @@ + + + Проверява настройките на сървър за входяща поща… + Грешка при удостоверяване + Проверява настройките на сървър за изходяща поща… + Мрежова грешка + Настройките на сървър за изходяща поща са потвърдени + Непозната грешка + Моля впишете се + Проверката на настройките на сървър за изходяща поща се провали + Настройките на сървър за входяща поща са потвърдени + Проверката на настройките на сървъра за входяща поща се провали + Грешка на сървъра + Клиентският сертификат вече не е валиден + Клиентският сертификат не може да бъде достъпен + Липсващи възможности на сървъра + Подробности:\n%s + Сървърът не поддържа тази операция:\n%s + diff --git a/feature/account/server/validation/src/main/res/values-bn/strings.xml b/feature/account/server/validation/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-bn/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-br/strings.xml b/feature/account/server/validation/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..a68a4d8 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-br/strings.xml @@ -0,0 +1,6 @@ + + + Dilesadur fazi + Fazi rouedad + Fazi dafariat + diff --git a/feature/account/server/validation/src/main/res/values-bs/strings.xml b/feature/account/server/validation/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..6ecc650 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-bs/strings.xml @@ -0,0 +1,21 @@ + + + Greška pri autentikaciji + Mrežna greška + Greška servera + Nepoznata greška + Sertifikat klijenta više nije važeći + Ne može se pristupiti sertifikatu klijenta + Detalji: +\n%s + Server ne posjeduje ovu sposobnost: +\n%s + Server nema ovu sposobnost + Proveravanje postavki dolazećeg servera… + Proveravanje postavki dolazećeg servera neuspješno + Postavke dolazećeg servera verifikovane + Proveravanje postavki odlazećeg servera… + Proveravanje postavki odlazećeg servera neuspješno + Postavke odlazećeg servera verifikovane + Molimo prijavite se + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ca/strings.xml b/feature/account/server/validation/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..f5d5aaf --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ca/strings.xml @@ -0,0 +1,21 @@ + + + S\'està comprovant la configuració del servidor d\'entrada… + Error d\'autenticació + S\'està comprovant la configuració del servidor de sortida… + Error de la xarxa + La configuració del servidor de sortida s\'ha verificat + Error desconegut + Si us plau, inicieu la sessió + Ha fallat la comprovació de la configuració del servidor de sortida + La configuració del servidor d\'entrada s\'ha verificat + Ha fallat la comprovació de la configuració del servidor d\'entrada + Error del servidor + Falta capacitat al servidor + No s\'ha pogut accedir al certificat de client + Al servidor li falta aquesta capacitat: +\n%s + El certificat de client ja no és vàlid + Detalls: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-co/strings.xml b/feature/account/server/validation/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..30c5549 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-co/strings.xml @@ -0,0 +1,21 @@ + + + Sbagliu di u servitore + Funzione assente nant’à u servitore + Cuntrollu di i parametri di u servitore d’entrata… + I parametri di u servitore d’entrata sò stati verificati + Cuntrollu di i parametri di u servitore d’esciuta… + I parametri di u servitore d’esciuta sò stati verificati + Cunnittitevi + Sbagliu d’autenticazione + Sbagliu di a reta + Sbagliu scunnisciutu + U certificatu di u cliente ùn hè più accettevule + U certificatu di u cliente ùn pò micca esse acciditu + U servitore ùn pussede micca sta funzione : +\n%s + Detaglii : +\n%s + Fiascu di u cuntrollu di i parametri di u servitore d’entrata + Fiascu di u cuntrollu di i parametri di u servitore d’esciuta + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-cs/strings.xml b/feature/account/server/validation/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..aec9871 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-cs/strings.xml @@ -0,0 +1,21 @@ + + + Kontrola nastavení serveru příchozí pošty… + Chyba autentizace + Kontrola nastavení serveru odchozí pošty… + Chyba sítě + Nastavení serveru odchozí pošty je ověřené + Neznámá chyba + Přihlaste se + Kontrola nastavení serveru odchozí pošty se nezdařila + Nastavení serveru příchozí pošty je ověřené + Kontrola nastavení serveru příchozí pošty se nezdařila + Chyba serveru + Klientský certifikát je již neplatný + Klientský certifikát není přístupný + Chybějící funkcionalita + Serveru chybí tato funkcionalita: +\n%s + Detaily: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-cy/strings.xml b/feature/account/server/validation/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-cy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-da/strings.xml b/feature/account/server/validation/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-da/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-de/strings.xml b/feature/account/server/validation/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..447bf01 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-de/strings.xml @@ -0,0 +1,19 @@ + + + Einstellungen des Posteingangsservers werden überprüft… + Authentifizierungsfehler + Einstellungen des Postausgangsservers werden überprüft… + Netzwerkfehler + Einstellungen des Postausgangsservers sind verifiziert + Unbekannter Fehler + Bitte anmelden + Prüfung der Einstellungen des Postausgangsservers fehlgeschlagen + Einstellungen des Posteingangsservers sind verifiziert + Prüfung der Einstellungen des Posteingangservers fehlgeschlagen + Serverfehler + Fehlende Serverfähigkeit + Auf das Client-Zertifikat konnte nicht zugegriffen werden + Der Server unterstützt diese Fähigkeit nicht:\n%s + Das Client-Zertifikat ist nicht mehr gültig + Details:\n%s + diff --git a/feature/account/server/validation/src/main/res/values-el/strings.xml b/feature/account/server/validation/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..66d4793 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-el/strings.xml @@ -0,0 +1,19 @@ + + + Σφάλμα ελέγχου ταυτότητας + Σφάλμα δικτύου + Άγνωστο σφάλμα + Σφάλμα διακομιστή + Το πιστοποιητικό πελάτη δεν είναι πλέον έγκυρο + Δεν ήταν δυνατή η πρόσβαση στο πιστοποιητικό πελάτη + Αποτυχία ελέγχου των ρυθμίσεων του διακομιστή εξερχομένων + Λεπτομέρειες:\n%s + Ο διακομιστής στερείται αυτής της δυνατότητας:\n%s + Έλεγχος ρυθμίσεων διακομιστή εισερχομένων… + Αποτυχία ελέγχου των ρυθμίσεων του διακομιστή εισερχομένων + Έλεγχος ρυθμίσεων διακομιστή εξερχομένων… + Πραγματοποιήστε σύνδεση + Οι ρυθμίσεις του διακομιστή εισερχομένων επαληθεύτηκαν + Οι ρυθμίσεις του διακομιστή εξερχομένων επαληθεύτηκαν + Έλλειψη δυνατότητας διακομιστή + diff --git a/feature/account/server/validation/src/main/res/values-en-rGB/strings.xml b/feature/account/server/validation/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..bbbe71e --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,14 @@ + + + Checking incoming server settings… + Authentication error + Checking outgoing server settings… + Network error + Outgoing server settings are valid + Unknown error + Please sign in + Checking outgoing server settings failed + Incoming server settings are valid + Checking incoming server settings failed + Server error + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-enm/strings.xml b/feature/account/server/validation/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-eo/strings.xml b/feature/account/server/validation/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..49755da --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-eo/strings.xml @@ -0,0 +1,20 @@ + + + Detaloj: +\n%s + Bonvolu saluti + Servila eraro + Reta eraro + Nekonata eraro + Aŭtentokontrola eraro + Mankata servila kapablo + La klienta atestilo ne plu estas valida + La klienta atestilo ne povis esti atingata + La servilo ne havas tiun kapablon:\n%s + Kontrolo de la agordoj eniran servilon fiaskis + Kontrolas agordojn de la enira servilo… + Kontrolas agordojn de la elira servilo… + Kontrolo de la agordoj eliran servilon fiaskis + Eniran servilon agordoj kontrolitaj + Eliran servilon agordoj kontrolitaj + diff --git a/feature/account/server/validation/src/main/res/values-es/strings.xml b/feature/account/server/validation/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..8a32a8e --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-es/strings.xml @@ -0,0 +1,21 @@ + + + Comprobando los ajustes del servidor entrante… + Error de autenticación + Comprobando los ajustes del servidor saliente… + Error de red + Configuración del servidor saliente verificada + Error desconocido + Seguir con el inicio de sesión + No se han podido comprobar los ajustes del servidor saliente + Configuración del servidor entrante verificada + No se han podido comprobar los ajustes del servidor entrante + Error del servidor + Falta capacidad en el servidor + No se ha podido acceder al certificado del cliente + Al servidor le falta esta capacidad: +\n%s + El certificado del cliente ya no es válido + Detalles: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-et/strings.xml b/feature/account/server/validation/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..db10adc --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-et/strings.xml @@ -0,0 +1,21 @@ + + + Kontrollime saabuva e-posti serveriseadistusi… + Viga autentimisel + Kontrollime väljuva e-posti serveriseadistusi… + Võrguühenduse viga + Väljuva e-posti serveriseadistused on üle kontrollitud + Teadmata viga + Palun logi sisse + Väljuva e-posti serveriseadistuste kontrollimine ei õnnestunud + Saabuva e-posti serveriseadistused on üle kontrollitud + Saabuva e-posti serveriseadistuste kontrollimine ei õnnestunud + Serveri viga + Serveril puudub vajalik võimekus + Kliendisertifikaati ei õnnestunud laadida + Serveril puudub vajalik võimekus: +\n%s + Kliendisertifikaadi kehtivus on lõppenud + Üksikasjalik teave: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-eu/strings.xml b/feature/account/server/validation/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..5d24b6c --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-eu/strings.xml @@ -0,0 +1,21 @@ + + + Sarrerako zerbitzariaren ezarpenak egiaztatzen… + Autentifikazio errorea + Irteerako zerbitzariaren ezarpenak egiaztatzen… + Sarearen errorea + Irteerako zerbitzariaren ezarpenak egiaztatu dira + Errore ezezaguna + Mesedez hasi saioa + Akatsa irteerako zerbitzariaren ezarpenak egiaztatzerakoan + Sarrerako zerbitzariaren ezarpenak egiaztatu dira + Akatsa sarrerako zerbitzariaren ezarpenak egiaztatzean + Zerbitzariaren errorea + Zerbitzariari gaitasun hau falta zaio: +\n%s + Bezeroaren ziurtagiriak dagoeneko ez du balio + Ezin izan da bezeroaren ziurtagiria atzitu + Zerbitzariaren gaitasuna falta da + Xehetasunak: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-fa/strings.xml b/feature/account/server/validation/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..5129815 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-fa/strings.xml @@ -0,0 +1,21 @@ + + + بررسی تنظیمات سرور ورودی … + قابلیت‌های سرور پیدا نشد + خطای احراز هویت + بررسی تنظیمات سرور خروجی … + خطای شبکه + تنظیمات سرور خروجی اعتبارسنجی شد + خطای ناشناخته + لطفا وارد شوید + بررسی تنظیمات سرور خروجی با خطا مواجه شد + تنظیمات سرور ورودی اعتبارسنجی شد + گواهینامهٔ کارخواه در دسترس نیست + سرور این قابلیت را ندارد: +\n%s + بررسی تنظیمات سرور ورودی با خطا مواجه شد + خطای سرور + گواهینامهٔ کارخواه دیگر معتبر نیست + جزییات: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-fi/strings.xml b/feature/account/server/validation/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..a9cffef --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-fi/strings.xml @@ -0,0 +1,18 @@ + + + Tarkistetaan saapuvan postin palvelimen asetuksia… + Todennusvirhe + Tarkistetaan lähtevän postin palvelimen asetuksia… + Verkkovirhe + Lähtevän postin palvelimen asetukset on vahvistettu + Tuntematon virhe + Kirjaudu sisään + Lähtevän postin palvelimen asetusten tarkistaminen epäonnistui + Saapuvan postin palvelimen asetukset on vahvistettu + Saapuvan postin palvelimen asetusten tarkistaminen epäonnistui + Palvelinvirhe + Lisätiedot: +\n%s + Asiakkaan varmenne ei ole enää kelvollinen + Ei pääsyä asiakkaan varmenteeseen + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-fr/strings.xml b/feature/account/server/validation/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..6eaa8be --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-fr/strings.xml @@ -0,0 +1,19 @@ + + + Vérification des paramètres du serveur entrant… + Erreur d\'authentification + Vérification des paramètres du serveur sortant… + Erreur réseau + Les paramètres du serveur sortant sont confirmés + Erreur inconnue + Connectez-vous + Échec de vérification des paramètres du serveur sortant + Les paramètres du serveur entrant sont confirmés + Échec de vérification des paramètres du serveur entrant + Erreur du serveur + Le serveur ne dispose pas d’une fonction + Impossible d’accéder au certificat client + Le serveur ne dispose pas de cette fonction :\n%s + Le certificat client n’est plus valide + Détails :\n%s + diff --git a/feature/account/server/validation/src/main/res/values-fy/strings.xml b/feature/account/server/validation/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..7f1919a --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-fy/strings.xml @@ -0,0 +1,19 @@ + + + Ynstellingen foar ynkommende server kontrolearje… + Autentikaasjeflater + Ynstellingen foar útgeande server kontrolearje… + Netwurkflater + Ynstellingen foar útgeande server ferifiearre + Unbekende flater + Meld jo oan + Ynstellingen foar útgeande server kontrolearje mislearre + Ynstellingen foar ynkommende server ferifiearre + Ynstellingen foar ynkommende server kontrolearje mislearre + Serverflater + Untbrekkende serverfunksjonaliteit + It clientsertifikaat koe net rieplachte wurde + De server mist dizze funksjonaliteit:\n%s + It clientsertifikaat is net langer jildich + Details:\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ga/strings.xml b/feature/account/server/validation/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..a582c08 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ga/strings.xml @@ -0,0 +1,19 @@ + + + Níl teastas an chliaint bailí a thuilleadh + Cumas freastalaí in easnamh + Sonraí:\n%s + Tá an cumas seo in easnamh ar an bhfreastalaí:\n%s + Socruithe freastalaí amach á seiceáil… + Earráid fhíordheimhnithe + Earráid líonra + Earráid freastalaí + Theip ar shocruithe an fhreastalaí isteach a sheiceáil + Earráid anaithnid + Níorbh fhéidir teastas an chliaint a rochtain + Fíoraíodh socruithe freastalaí isteach + Ag seiceáil socruithe freastalaí isteach… + Theip ar shocruithe an fhreastalaí amach a sheiceáil + Fíoraíodh socruithe an fhreastalaí amach + Sínigh isteach le do thoil + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-gd/strings.xml b/feature/account/server/validation/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..4ad7654 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-gd/strings.xml @@ -0,0 +1,19 @@ + + + A’ sgrùdadh roghainnean an fhrithealaiche a-steach… + Cha d’fhuair sinn greim air teisteanas a’ chliant + Tha comas a dhìth air an fhrithealaiche + Clàraich a-steach + Dh’fhàillig sgrùdadh roghainnean an fhrithealaiche a-mach + Tha an comas a leanas a dhìth air an fhrithealaiche:\n%s + Dh’fhàillig sgrùdadh roghainnean an fhrithealaiche a-steach + Chaidh roghainnean an fhrithealaiche a-mach a dhearbhadh + A’ sgrùdadh roghainnean an fhrithealaiche a-mach… + Chaidh roghainnean an fhrithealaiche a-steach a dhearbhadh + Mion-fhiosrachadh:\n%s + Chan eil teisteanas a’ chliant dligheach tuilleadh + Mearachd dearbhaidh + Mearachd lìonraidh + Mearachd frithealaiche + Mearachd neo-aithnichte + diff --git a/feature/account/server/validation/src/main/res/values-gl/strings.xml b/feature/account/server/validation/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-gl/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-gu/strings.xml b/feature/account/server/validation/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-hi/strings.xml b/feature/account/server/validation/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..5bc4199 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-hi/strings.xml @@ -0,0 +1,14 @@ + + + इनकमिंग सर्वर सेटिंग चेक कर रहे… + ऑथेंटिकेशन गड़बड़ + आउटगोइंग सर्वर सेटिंग चेक कर रहे… + नेटवर्क गड़बड़ + आउटगोइंग सर्वर सेटिंग सही है! + अनजान गड़बड़ + साइनइन करें + आउटगोइंग सर्वर सेटिंग चेक करने में फेल! + इनकमिंग सर्वर सेटिंग सही है! + इनकमिंग सर्वर सेटिंग चेक करने में फेल! + सर्वर गड़बड़ + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-hr/strings.xml b/feature/account/server/validation/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..d6d8da4 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-hr/strings.xml @@ -0,0 +1,4 @@ + + + Pogreška provjere autentičnosti + diff --git a/feature/account/server/validation/src/main/res/values-hu/strings.xml b/feature/account/server/validation/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..68dee01 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-hu/strings.xml @@ -0,0 +1,21 @@ + + + A bejövő kiszolgáló beállítások ellenőrzése… + Hitelesítési hiba + A kimenő kiszolgáló beállítások ellenőrzése… + Hálózati hiba + A kimenő kiszolgáló beállításai érvényesek + Ismeretlen hiba + Jelentkezzen be + A kimenő kiszolgáló beállítások ellenőrzése sikertelen volt + A bejövő kiszolgáló beállításai érvényesek + A bejövő kiszolgáló beállítások ellenőrzése sikertelen volt + Kiszolgálóhiba + A klienstanúsítvány már nem érvényes + A klienstanúsítvány nem érhető el + Hiányzó kiszolgálófunkció + Részletek: +\n%s + A kiszolgálóból hiányzik ez a funkció: +\n%s + diff --git a/feature/account/server/validation/src/main/res/values-hy/strings.xml b/feature/account/server/validation/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-in/strings.xml b/feature/account/server/validation/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..7bf734c --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-in/strings.xml @@ -0,0 +1,20 @@ + + + Galat autentikasi + Galat jaringan + Memeriksa pengaturan peladen yang keluar… + Sertifikat klien sudah tidak valid + Sertifikat klien ini tidak dapat diakses + Gagal memeriksa pengaturan peladen yang masuk + Pengaturan peladen yang masuk diverifikasi + Kehilangan kemampuan peladen + Rincian: +\n%s + Memeriksa pengaturan peladen yang masuk… + Peladen kehilangan kemampuan berikut ini:\n%s + Pengaturan peladen yang keluar diverifikasi + Silakan masuk + Gagal memeriksa pengaturan peladen yang keluar + Galat peladen + Galat tak diketahui + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-is/strings.xml b/feature/account/server/validation/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..bdfed68 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-is/strings.xml @@ -0,0 +1,21 @@ + + + Athuga stillingar inn-póstþjónsins… + Auðkenningarvilla + Athuga stillingar sendinga-póstþjónsins… + Villa í netkerfi + Stillingar sendinga-póstþjóns sannreyndar + Óþekkt villa + Skráðu þig inn + Athugun á stillingum sendinga-póstþjónsins mistókst + Stillingar inn-póstþjóns sannreyndar + Athugun á stillingum inn-póstþjónsins mistókst + Villa á þjóni + Vantar upplýsingar um getu póstþjóns + Skilríki biðlara var ekki aðgengilegt + Póstþjóninn vantar þennan eiginleika: +\n%s + Skilríki biðlara er ekki lengur gilt + Nánar: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-it/strings.xml b/feature/account/server/validation/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..4a29217 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-it/strings.xml @@ -0,0 +1,21 @@ + + + Controllo impostazioni server posta in arrivo… + Errore di autenticazione + Controllo impostazioni server in uscita… + Errore di rete + Impostazioni server in uscita verificate + Errore sconosciuto + Accedi + Controllo impostazioni server in uscita non riuscito + Impostazioni server in arrivo verificate + Controllo impostazioni server in arrivo non riuscito + Errore del server + Funzionalità del server mancante + Impossibile accedere al certificato client + Al server manca questa funzionalità: +\n%s + Il certificato client non è più valido + Dettagli: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-iw/strings.xml b/feature/account/server/validation/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..d144a75 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-iw/strings.xml @@ -0,0 +1,21 @@ + + + תעודת הלקוח כבר לא בתוקף + לא ניתן לגשת לתעודת הלקוח + יכולת שרת חסרה + פרטים: +\n%s + הגדרות שרת דואר נכנס אומתו + בדיקת הגדרות שרת דואר יוצא נכשלה + בדיקת הגדרות שרת דואר נכנס נכשלה + הגדרות שרת דואר יוצא בבדיקה… + הגדרות שרת דואר נכנס בבדיקה… + הגדרות שרת דואר יוצא אומתו + אנא התחבר + שגיאת אימות + שגיאת רשת + שגיאת שרת + שגיאה לא ידועה + היכולת הבאה חסרה לשרת: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ja/strings.xml b/feature/account/server/validation/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..f917b6f --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ja/strings.xml @@ -0,0 +1,21 @@ + + + 受信サーバーの設定を確認中… + 認証エラー + 送信サーバーの設定を確認中… + ネットワークエラー + 送信サーバーの設定を確認できました + 不明なエラー + ログインしてください + 送信サーバーの設定の確認に失敗しました + 受信サーバーの設定を確認できました + 受信サーバーの設定の確認に失敗しました + サーバーエラー + サーバーの機能が不足しています + クライアント証明書にアクセスできませんでした + この機能がサーバーにありません: +\n%s + このクライアント証明書の有効期限は満了しました + 詳細: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ka/strings.xml b/feature/account/server/validation/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-kab/strings.xml b/feature/account/server/validation/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-kab/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-kk/strings.xml b/feature/account/server/validation/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..3aba79b --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-kk/strings.xml @@ -0,0 +1,8 @@ + + + Аутентификация қатесі + Желі қатесі + Сервер қатесі + Белгісіз қате + Сервер мүмкіндігі жоқ + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ko/strings.xml b/feature/account/server/validation/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..e60a641 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ko/strings.xml @@ -0,0 +1,21 @@ + + + 만료된 클라이언트 인증서 + 클라이언트 인증서에 접근할 수 없습니다 + 서버 용량 부족 + 자세히: +\n%s + 서버 오류 + 알수 없는 오류 + 인증 오류 + 네트워크 오류 + 로그인이 필요합니다 + 발신 서버 설정을 확인하는데 실패했습니다 + 수신 서버 설정을 확인하는데 실패했습니다 + 서버에 누락된 기능이 있습니다: +\n%s + 발신 서버 설정이 확인되었습니다 + 수신 서버 설정이 확인되었습니다 + 수신 서버 설정을 확인하는 중… + 발신 서버 설정을 확인하는 중… + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-lt/strings.xml b/feature/account/server/validation/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..774a0f2 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-lt/strings.xml @@ -0,0 +1,21 @@ + + + Kliento liudijimas nebegalioja + Serveryje trūksta galimybės + Kliento liudijimo nepavykto pasiekti + Išsamiau: +\n%s + Tikrinami gaunamų laiškų serverio parametrai… + Prisijunkite + Siunčiamų laiškų serverio parametrai patvirtinti + Serveryje trūksta šios galimybės: +\n%s + Nepavyko patikrinti gaunamų laiškų serverio parametrų + Gaunamų laiškų serverio parametrai patvirtinti + Tikrinami siunčiamų laiškų serverio parametrai… + Nepavyko patikrinti siunčiamų laiškų serverio parametrų + Klaida nustatant tapatybę + Tinklo klaida + Serverio klaida + Nežinoma klaida + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-lv/strings.xml b/feature/account/server/validation/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ml/strings.xml b/feature/account/server/validation/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-nb-rNO/strings.xml b/feature/account/server/validation/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..ae9bc0e --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,21 @@ + + + Sjekker innstillinger for innkommende tjener … + Identitetsbekreftelsesfeil + Sjekker innstillinger for utgående tjener … + Nettverksfeil + Innstillinger for utgående tjener er verifisert + Ukjent feil + Logg inn + Kunne ikke sjekke innstillinger for utgående tjener + Innstillinger for innkommende tjener er verifisert + Kunne ikke sjekke innstillinger for innkommende tjener + Tjenerfeil + Manglende serverkapasitet + Klientsertifikatet er ikke lenger gyldig + Fikk ikke tak i klientsertifikatet + Detaljer: +\n%s + Tjeneren mangler denne egenskapen: +\n%s + diff --git a/feature/account/server/validation/src/main/res/values-nl/strings.xml b/feature/account/server/validation/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..b5a55da --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-nl/strings.xml @@ -0,0 +1,19 @@ + + + Instellingen voor inkomende server controleren… + Authenticatiefout + Instellingen voor uitgaande server controleren… + Netwerkfout + Instellingen voor uitgaande server geverifieerd + Onbekende fout + Meld u aan + Instellingen voor uitgaande server controleren mislukt + Instellingen voor inkomende server geverifieerd + Instellingen voor inkomende server controleren mislukt + Serverfout + Ontbrekende serverfunctionaliteit + Het clientcertificaat kon niet worden geraadpleegd + De server mist deze functionaliteit:\n%s + Het clientcertificaat is niet langer geldig + Details:\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-nn/strings.xml b/feature/account/server/validation/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..b8545e7 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-nn/strings.xml @@ -0,0 +1,19 @@ + + + Klientsertifikatet er ikkje lenger gyldig + Klarte ikkje å få tilgang til klientsertifikatet + Manglande serverkapasitet + Serveren manglar denne eigenskapen:\n%s + Logg inn + Ukjend feil + Nettverksfeil + Tenarfeil + Detaljar:\n%s + Autentiseringsfeil + Sjekker innkommande tenar-innstillingar… + Sjekking av innkommande tenar-innstillingar feila + Sjekking av utgåande tenar-innstillingar feila + Utgåande serverinnstillingar stadfesta + Innkomande serverinnstillingar stadfesta + Sjekkar utgåande tenar-innstillingar… + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-pl/strings.xml b/feature/account/server/validation/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..034989b --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-pl/strings.xml @@ -0,0 +1,21 @@ + + + Sprawdzanie ustawień serwera poczty przychodzącej… + Błąd autoryzacji + Sprawdzanie ustawień serwera poczty wychodzącej… + Błąd sieci + Zweryfikowano ustawienia serwera poczty wychodzącej + Nieznany błąd + Zaloguj się + Sprawdzanie ustawień serwera poczty wychodzącej nie powiodło się + Zweryfikowano ustawienia serwera poczty przychodzącej + Sprawdzanie ustawień serwera poczty przychodzącej nie powiodło się + Błąd serwera + Brak możliwości serwera + Nie można uzyskać dostępu do certyfikatu klienta + Serwerowi brakuje tej możliwości: +\n%s + Certyfikat klienta nie jest już ważny + Szczegóły: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-pt-rBR/strings.xml b/feature/account/server/validation/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..0a58175 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,20 @@ + + + Verificando configurações do servidor de recebimento… + Erro de autenticação + Verificando configurações do servidor de envio… + Erro de rede + Configurações do servidor de envio verificadas + Erro desconhecido + Entre na sua conta + Falha ao verificar configurações do servidor de envio + Configurações do servidor de recebimento verificadas + Falha ao verificar configurações do servidor de recebimento + Erro do servidor + O certificado do cliente não é mais válido + O certificado do cliente não pode ser acessado + Falta capacidade no servidor + Detalhes: +\n%s + O servidor não tem esta capacidade: \n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-pt-rPT/strings.xml b/feature/account/server/validation/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..baa4cd5 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,21 @@ + + + A verificar as configurações do servidor de entrada… + Funcionalidade do servidor em falta + Erro de autenticação + A verificar as configurações do servidor de saída… + Erro de rede + As configurações do servidor de saída verificadas + Erro desconhecido + Por favor efectue log in + Falhou a verificação das configurações do servidor de saída + Configurações do servidor de entrada verificadas + O certificado do cliente não pode ser acedido + O servidor não tem esta funcionalidade: +\n%s + Falhou a verificação das configurações do servidor de entrada + Erro de servidor + O certificado do cliente já não é válido + Detalhes: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-pt/strings.xml b/feature/account/server/validation/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ro/strings.xml b/feature/account/server/validation/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..e08b99c --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ro/strings.xml @@ -0,0 +1,21 @@ + + + Verificarea setărilor serverului de intrare… + Eroare de autentificare + Verificarea setărilor serverului de expediere… + Eroare de rețea + Setările serverului de ieșire sunt verificate + Eroare necunoscută + Conectează-te + Verificarea setărilor serverului de ieșire a eșuat + Setările serverului de intrare sunt verificate + Verificarea setărilor serverului de intrare a eșuat + Eroare de server + Lipsește capacitatea serverului + Certificatul clientului nu a putut fi accesat + Serverului îi lipsește această capacitate: +\n%s + Certificatul de client nu mai este valabil + Detalii: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ru/strings.xml b/feature/account/server/validation/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..41beeaa --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ru/strings.xml @@ -0,0 +1,21 @@ + + + Сертификат клиента больше не действителен + Не удалось получить доступ к сертификату клиента + Отсутствующие возможности сервера + Проверка настроек исходящего сервера… + Пожалуйста, войдите + На сервере отсутствует эта возможность: +\n%s + Настройки сервера входящих сообщений проверены + Проверка параметров входящего сервера не удалась + Проверка настроек исходящего сервера не удалась + Настройки исходящего сервера проверены + Ошибка аутентификации + Ошибка сети + Проверка настроек входящего сервера… + Ошибка сервера + Неизвестная ошибка + Подробности: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-sk/strings.xml b/feature/account/server/validation/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..3563107 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-sk/strings.xml @@ -0,0 +1,19 @@ + + + Chyba siete + Kontrolujú sa nastavenia servera odchádzajúcej pošty… + Nastavenia servera odchádzajúcej pošty sú platné + Kontrola nastavení servera odchádzajúcej pošty zlyhala + Nastavenia servera prichádzajúcej pošty sú platné + Chyba overovania nastavenia servera prichádzajúcej pošty + Chyba servera + Neznáma chyba + Klientský certifikát už nie je platný + Klientský certifikát nie je prístupný + Chýbajúca funkčnosť servera + Chýbajúca funkčnosť servera:\n%s + Podrobnosti:\n%s + Overujem nastavenia servera prichádzajúcej pošty… + Prosím prihláste sa + Chyba autentifikácie + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-sl/strings.xml b/feature/account/server/validation/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..321fc8e --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-sl/strings.xml @@ -0,0 +1,19 @@ + + + Potrdilo odjemalca ni več veljavno. + Dostop do potrdila odjemalca ni bil mogoč. + Manjka zmogljivost strežnika. + Preverjanje nastavitev dohodnega strežnika … + Nastavitve dohodnega strežnika so bile preverjene. + Preverjanje nastavitev odhodnega strežnika … + Podrobnosti:\n%s + Strežniku manjka naslednja zmogljivost:\n%s + Pred nadaljevanjem se je treba vpisati. + Preverjanje nastavitev odhodnega strežnika je spodletelo. + Nastavitve odhodnega strežnika so bile preverjene. + Napaka overitve + Napaka omrežja + Napaka strežnika + Neznana napaka + Preverjanje nastavitev dohodnega strežnika je spodletelo. + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-sq/strings.xml b/feature/account/server/validation/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..ca91c86 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-sq/strings.xml @@ -0,0 +1,19 @@ + + + Po kontrollohen rregullime shërbyesi marrjeje mesazhesh… + Gabim mirëfilltësimi + Po kontrollohen rregullime shërbyesi dërgimi mesazhesh… + Gabim rrjeti + Rregullimet për shërbyes dërgimi mesazhesh janë verifikuar + Gabim i panjohur + Ju lutemi, bëni hyrjen + Kontrolli i rregullimeve të shërbyesit të dërgimit të mesazheve dështoi + Rregullimet për shërbyes marrjeje mesazhesh janë verifikuar + Kontrolli i rregullimeve të shërbyesit të marrjes së mesazheve dështoi + Gabim shërbyesi + S’u përdor dot dëshmia e klientit + Shërbyesit i mungon kjo aftësi: \n%s + Dëshmia e klientit s’është më e vlefshme + Hollësi: \n%s + Aftësi që i mungon shërbyesit + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-sr/strings.xml b/feature/account/server/validation/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..c712d87 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-sr/strings.xml @@ -0,0 +1,21 @@ + + + Сертификат клијента више није важећи + Детаљи: +\n%s + Провера подешавања долазног сервера… + Није могуће приступити сертификату клијента + Провера подешавања одлазног сервера… + Провера подешавања одлазног сервера није успела + Грешка при аутентификацији + Провера подешавања долазног сервера није успела + Подешавања долазног сервера су верификована + Пријавите се + Серверу недостаје ова могућност: +\n%s + Недостаје могућност сервера + Подешавања одлазног сервера су верификована + Мрежна грешка + Грешка сервера + Непозната грешка + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-sv/strings.xml b/feature/account/server/validation/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..4216ee6 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-sv/strings.xml @@ -0,0 +1,20 @@ + + + Kontrollerar inkommande serverinställningar… + Okänt fel + Kontrollera inkommande serverinställningar misslyckades + Serverfel + Autentiseringsfel + Kontrollerar utgående serverinställningar… + Nätverksfel + Utgående serverinställningar verifierade + Var god logga in + Misslyckades att kontrollera inställningarna för utgående server + Inkommande serverinställningar verifierade + Serverkapacitet saknas + Klientcertifikatet kunde inte nås + Servern saknar denna kapacitet:\n%s + Klientcertifikatet är inte längre giltigt + Detaljer: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-sw/strings.xml b/feature/account/server/validation/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-ta/strings.xml b/feature/account/server/validation/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..c0379d5 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-ta/strings.xml @@ -0,0 +1,19 @@ + + + கிளையன்ட் சான்றிதழ் இனி செல்லுபடியாகாது + விவரங்கள்:\n %s + சேவையகம் இந்த திறனைக் காணவில்லை:\n %s + உள்வரும் சேவையக அமைப்புகளை சரிபார்க்கிறது… + உள்வரும் சேவையக அமைப்புகளை சரிபார்க்கிறது + உள்வரும் சேவையக அமைப்புகள் சரிபார்க்கப்பட்டன + வெளிச்செல்லும் சேவையக அமைப்புகளைச் சரிபார்க்கிறது… + வெளிச்செல்லும் சேவையக அமைப்புகளைப் பார்ப்பது தோல்வியடைந்தது + கிளையன்ட் சான்றிதழை அணுக முடியவில்லை + சேவையக திறனைக் காணவில்லை + வெளிச்செல்லும் சேவையக அமைப்புகள் சரிபார்க்கப்பட்டன + தயவுசெய்து உள்நுழைக + அங்கீகார பிழை + பிணைய பிழை + சேவையக பிழை + தெரியாத பிழை + diff --git a/feature/account/server/validation/src/main/res/values-th/strings.xml b/feature/account/server/validation/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..9877f0e --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-th/strings.xml @@ -0,0 +1,18 @@ + + + ผิดพลาดในการรับรองความถูกต้อง + เกิดข้อผิดพลาดของเครือข่าย + เกิดข้อผิดพลาดของเซิร์ฟเวอร์ + เกิดข้อผิดพลาดที่ไม่ทราบ + ไม่สามารถเข้าถึงใบรับรองไคลเอนต์ได้ + รายละเอียด:\n%s + กำลังตรวจสอบตั้งค่าเซิร์ฟเวอร์ขาเข้า… + ตรวจสอบการตั้งค่าเซิร์ฟเวอร์ขาเข้าล้มเหลว + ตรวจสอบการตั้งค่าเซิร์ฟเวอร์ขาเข้าแล้ว + กำลังตรวจสอบการตั้งค่าเซิร์ฟเวอร์ขาออก… + ใบรับรองไคลเอนต์ไม่ถูกต้องอีกต่อไป + ตรวจสอบตั้งค่าเซิร์ฟเวอร์ขาออกล้มเหลว + ตรวจสอบการตั้งค่าเซิร์ฟเวอร์ขาออกแล้ว + กรุณาลงชื่อเข้าใช้ + เซิร์ฟเวอร์ขาดความสามารถนี้:\n%s + diff --git a/feature/account/server/validation/src/main/res/values-tr/strings.xml b/feature/account/server/validation/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..e805440 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-tr/strings.xml @@ -0,0 +1,19 @@ + + + Gelen sunucusu ayarları denetleniyor… + Kimlik doğrulama hatası + Giden sunucusu ayarları denetleniyor… + Ağ hatası + Giden sunucusu ayarları doğrulandı + Bilinmeyen hata + Lütfen oturum açın + Giden sunucusu ayarlarının denetlenmesi başarısız oldu + Gelen sunucusu ayarları doğrulandı + Gelen sunucusu ayarlarının denetlenmesi başarısız oldu + Sunucu hatası + Eksik sunucu özelliği + İstemci sertifikasına erişilemedi + Sunucuda aşağıdaki özellik eksik: \n%s + İstemci sertifikası artık geçerli değil + Ayrıntılar: \n%s + diff --git a/feature/account/server/validation/src/main/res/values-uk/strings.xml b/feature/account/server/validation/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..9b3ff78 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-uk/strings.xml @@ -0,0 +1,19 @@ + + + Сертифікат клієнта більше недійсний + Подробиці:\n%s + Перевірка налаштувань вхідного сервера… + Не вдалося перевірити вхідні налаштування сервера + Налаштування вхідного сервера підтверджено + Перевірка налаштувань вихідного сервера… + Налаштування вихідного сервера підтверджено + Будь ласка, увійдіть + Не вдалося перевірити налаштування вихідного сервера + Помилка автентифікації + Помилка мережі + Помилка сервера + Невідома помилка + Відсутня можливість сервера + Не вдалося отримати доступ до сертифіката клієнта + На сервері відсутня ця можливість:\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-vi/strings.xml b/feature/account/server/validation/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..78aa053 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-vi/strings.xml @@ -0,0 +1,21 @@ + + + Đang kiểm tra thiết đặt máy chủ thư đến… + Lỗi xác thực + Đang kiểm tra thiết đặt máy chủ thư đi… + Lỗi mạng + Thiết đặt máy chủ gửi đi đã được xác thực + Lỗi không rõ + Xin hãy đăng nhập + Kiểm tra thiết đặt máy chủ thư đi không thành công + Thiết đặt máy chủ gửi đến đã được xác thực + Kiểm tra thiết đặt máy chủ thư đến không thành công + Lỗi máy chủ + Chứng chỉ máy khánh không còn hợp lệ + Chứng chỉ máy khách không thể truy cập được + Chi tiết: +\n%s + Máy chủ không có khả năng này: +\n%s + Máy chủ không có khả năng + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-zh-rCN/strings.xml b/feature/account/server/validation/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..8b6c33d --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,21 @@ + + + 正在检查收件服务器设置… + 身份验证错误 + 正在检查发件服务器设置… + 网络错误 + 发件服务器设置已验证 + 未知错误 + 请登录 + 检查发件服务器设置失败 + 收件服务器设置已验证 + 检查收件服务器设置失败 + 服务器错误 + 缺少服务器功能 + 无法访问客户端证书 + 服务器缺少此功能: +\n%s + 客户端证书不再有效 + 详情: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values-zh-rTW/strings.xml b/feature/account/server/validation/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..6494439 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,21 @@ + + + 正在檢查收件伺服器設定… + 身分認證錯誤 + 正在檢查寄件伺服器設定… + 網路錯誤 + 傳出伺服器設定已驗證 + 未知錯誤 + 請登入 + 檢查寄件伺服器設定失敗 + 傳入伺服器設定已驗證 + 檢查收件伺服器設定失敗 + 伺服器錯誤 + 缺少伺服器功能 + 無法存取客戶端證書 + 伺服器缺少此功能: +\n%s + 用戶端憑證不再有效 + 詳情: +\n%s + \ No newline at end of file diff --git a/feature/account/server/validation/src/main/res/values/strings.xml b/feature/account/server/validation/src/main/res/values/strings.xml new file mode 100644 index 0000000..c6b3027 --- /dev/null +++ b/feature/account/server/validation/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + Authentication error + Network error + Server error + Unknown error + The client certificate is no longer valid + "The client certificate couldn't be accessed" + Missing server capability + Details:\n%s + The server is missing this capability:\n%s + Checking incoming server settings… + Checking incoming server settings failed + Incoming server settings verified + Checking outgoing server settings… + Checking outgoing server settings failed + Outgoing server settings verified + Please sign in + diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ServerValidationModuleKtTest.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ServerValidationModuleKtTest.kt new file mode 100644 index 0000000..d99ca2d --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ServerValidationModuleKtTest.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.account.server.validation + +import android.content.Context +import app.k9mail.feature.account.common.AccountCommonExternalContract +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract +import org.junit.Test +import org.koin.test.KoinTest +import org.koin.test.verify.verify + +class ServerValidationModuleKtTest : KoinTest { + + @Test + fun `should have a valid di module`() { + featureAccountServerValidationModule.verify( + extraTypes = listOf( + ServerValidationContract.State::class, + AccountDomainContract.AccountStateRepository::class, + AccountCommonExternalContract.AccountStateLoader::class, + ServerCertificateDomainContract.ServerCertificateErrorRepository::class, + ServerCertificateErrorContract.State::class, + AccountState::class, + Context::class, + Boolean::class, + Class.forName("net.openid.appauth.AppAuthConfiguration").kotlin, + ), + ) + } +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/FakeAuthStateStorage.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/FakeAuthStateStorage.kt new file mode 100644 index 0000000..7158d7a --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/FakeAuthStateStorage.kt @@ -0,0 +1,13 @@ +package app.k9mail.feature.account.server.validation.domain.usecase + +import com.fsck.k9.mail.oauth.AuthStateStorage + +class FakeAuthStateStorage( + private var authorizationState: String? = null, +) : AuthStateStorage { + override fun getAuthorizationState(): String? = authorizationState + + override fun updateAuthorizationState(authorizationState: String?) { + this.authorizationState = authorizationState + } +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/ValidateServerSettingsTest.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/ValidateServerSettingsTest.kt new file mode 100644 index 0000000..bb651f3 --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/domain/usecase/ValidateServerSettingsTest.kt @@ -0,0 +1,163 @@ +package app.k9mail.feature.account.server.validation.domain.usecase + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.server.ServerSettingsValidationResult +import java.io.IOException +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ValidateServerSettingsTest { + private val authStateStorage = FakeAuthStateStorage() + + @Test + fun `should check with imap validator when protocol is imap`() = runTest { + val testSubject = ValidateServerSettings( + authStateStorage = authStateStorage, + imapValidator = { _, _ -> ServerSettingsValidationResult.Success }, + pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) }, + smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) }, + ) + + val result = testSubject.execute(IMAP_SERVER_SETTINGS) + + assertThat(result).isEqualTo(ServerSettingsValidationResult.Success) + } + + @Test + fun `should check with imap validator when protocol is imap and return failure`() = runTest { + val failure = ServerSettingsValidationResult.ServerError("Failed") + val testSubject = ValidateServerSettings( + authStateStorage = authStateStorage, + imapValidator = { _, _ -> failure }, + pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) }, + smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) }, + ) + + val result = testSubject.execute(IMAP_SERVER_SETTINGS) + + assertThat(result).isEqualTo(failure) + } + + @Test + fun `should check with pop3 validator when protocol is pop3`() = runTest { + val testSubject = ValidateServerSettings( + authStateStorage = authStateStorage, + imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) }, + pop3Validator = { _, _ -> ServerSettingsValidationResult.Success }, + smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) }, + ) + + val result = testSubject.execute(POP3_SERVER_SETTINGS) + + assertThat(result).isEqualTo(ServerSettingsValidationResult.Success) + } + + @Test + fun `should check with pop3 validator when protocol is pop3 and return failure`() = runTest { + val failure = ServerSettingsValidationResult.ServerError("Failed POP3") + val testSubject = ValidateServerSettings( + authStateStorage = authStateStorage, + imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) }, + pop3Validator = { _, _ -> failure }, + smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) }, + ) + + val result = testSubject.execute(POP3_SERVER_SETTINGS) + + assertThat(result).isEqualTo(failure) + } + + @Test + fun `should check with smtp validator when protocol is smtp`() = runTest { + val testSubject = ValidateServerSettings( + authStateStorage = authStateStorage, + imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) }, + pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) }, + smtpValidator = { _, _ -> ServerSettingsValidationResult.Success }, + ) + + val result = testSubject.execute(SMTP_SERVER_SETTINGS) + + assertThat(result).isEqualTo(ServerSettingsValidationResult.Success) + } + + @Test + fun `should check with smtp validator when protocol is smtp and return failure`() = runTest { + val failure = ServerSettingsValidationResult.ServerError("Failed SMTP") + val testSubject = ValidateServerSettings( + authStateStorage = authStateStorage, + imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) }, + pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) }, + smtpValidator = { _, _ -> failure }, + ) + + val result = testSubject.execute(SMTP_SERVER_SETTINGS) + + assertThat(result).isEqualTo(failure) + } + + @Test + fun `should validate successfully for demo settings`() = runTest { + val testSubject = ValidateServerSettings( + authStateStorage = authStateStorage, + imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) }, + pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) }, + smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) }, + ) + + val result = testSubject.execute( + ServerSettings( + type = "demo", + host = "demo.example.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ), + ) + + assertThat(result).isEqualTo(ServerSettingsValidationResult.Success) + } + + private companion object { + + val IMAP_SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val POP3_SERVER_SETTINGS = ServerSettings( + type = "pop3", + host = "pop3.example.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val SMTP_SERVER_SETTINGS = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + } +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/BaseServerValidationViewModelTest.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/BaseServerValidationViewModelTest.kt new file mode 100644 index 0000000..3eec417 --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/BaseServerValidationViewModelTest.kt @@ -0,0 +1,269 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndMviTurbinesConsumed +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Effect +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Error +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.server.ServerSettingsValidationResult +import kotlinx.coroutines.delay +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +abstract class BaseServerValidationViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should update state when LoadAccountStateAndValidate event received and validate`() = runMviTest { + val accountState = if (isIncomingValidation) { + AccountState( + incomingServerSettings = SERVER_SETTINGS, + ) + } else { + AccountState( + outgoingServerSettings = SERVER_SETTINGS, + ) + } + val initialState = State( + serverSettings = null, + isLoading = true, + error = Error.ServerError("server error"), + isSuccess = true, + ) + val testSubject = createTestSubject( + accountState = accountState, + initialState = initialState, + ) + + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + val expectedState = State( + serverSettings = SERVER_SETTINGS, + isLoading = false, + error = null, + isSuccess = false, + ) + + testSubject.event(Event.LoadAccountStateAndValidate) + + assertThat(turbines.awaitStateItem()).isEqualTo(expectedState) + + val loadingState = expectedState.copy(isLoading = true) + + assertThat(turbines.awaitStateItem()).isEqualTo(loadingState) + + val successState = loadingState.copy(isLoading = false, isSuccess = true) + + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(successState) + } + } + + @Test + fun `should fail when ValidateServerSettings event received and server settings null`() = runMviTest { + val initialState = State() + val testSubject = createTestSubject() + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.ValidateServerSettings) + + val errorState = initialState.copy( + error = Error.UnknownError("Server settings not set"), + ) + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(errorState) + } + } + + @Test + fun `should validate server settings when ValidateServerSettings event received`() = runMviTest { + val initialState = State( + serverSettings = SERVER_SETTINGS, + ) + val testSubject = createTestSubject( + serverSettingsValidationResult = ServerSettingsValidationResult.Success, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.ValidateServerSettings) + + val loadingState = initialState.copy(isLoading = true) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val successState = loadingState.copy( + isLoading = false, + isSuccess = true, + ) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(successState) + + assertThatAndMviTurbinesConsumed( + actual = turbines.effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should set error state when ValidateServerSettings received and check settings failed`() = runMviTest { + val initialState = State( + serverSettings = SERVER_SETTINGS, + ) + val testSubject = createTestSubject( + serverSettingsValidationResult = ServerSettingsValidationResult.ServerError("server error"), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.ValidateServerSettings) + + val loadingState = initialState.copy(isLoading = true) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val failureState = loadingState.copy( + isLoading = false, + error = Error.ServerError("server error"), + ) + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(failureState) + } + } + + @Test + fun `should emit effect NavigateNext when ValidateConfig is successful`() = runMviTest { + val initialState = State( + serverSettings = SERVER_SETTINGS, + isSuccess = true, + ) + val testSubject = createTestSubject( + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.ValidateServerSettings) + + assertThatAndMviTurbinesConsumed( + actual = turbines.effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runMviTest { + val testSubject = createTestSubject() + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.OnBackClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateBack) + } + } + + @Test + fun `should clear error and trigger check settings when OnRetryClicked event received`() = runMviTest { + val initialState = State( + serverSettings = SERVER_SETTINGS, + error = Error.ServerError("server error"), + ) + var checkSettingsCalled = false + + val testSubject = createTestSubject( + validateServerSettings = { + delay(50) + checkSettingsCalled = true + ServerSettingsValidationResult.Success + }, + accountStateRepository = InMemoryAccountStateRepository(), + authorizationStateRepository = { true }, + certificateErrorRepository = InMemoryServerCertificateErrorRepository(), + oAuthViewModel = FakeAccountOAuthViewModel(), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnRetryClicked) + + val stateWithoutError = initialState.copy(error = null) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(stateWithoutError) + + val loadingState = stateWithoutError.copy(isLoading = true) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val successState = loadingState.copy(isLoading = false, isSuccess = true) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(successState) + assertThat(checkSettingsCalled).isTrue() + + assertThatAndMviTurbinesConsumed( + actual = turbines.effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + abstract fun createTestSubject( + serverSettingsValidationResult: ServerSettingsValidationResult = ServerSettingsValidationResult.Success, + accountState: AccountState = AccountState(), + initialState: State = State(), + ): T + + abstract fun createTestSubject( + accountStateRepository: AccountDomainContract.AccountStateRepository, + validateServerSettings: ServerValidationDomainContract.UseCase.ValidateServerSettings, + authorizationStateRepository: AccountOAuthDomainContract.AuthorizationStateRepository, + certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository, + oAuthViewModel: AccountOAuthContract.ViewModel, + initialState: State, + ): T + + abstract val isIncomingValidation: Boolean + + protected companion object { + val SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 465, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "username", + password = "password", + clientCertificateAlias = null, + ) + } +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/FakeServerValidationViewModel.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/FakeServerValidationViewModel.kt new file mode 100644 index 0000000..3a56a82 --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/FakeServerValidationViewModel.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Effect +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Event +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.ViewModel + +class FakeServerValidationViewModel( + override val oAuthViewModel: AccountOAuthContract.ViewModel = FakeAccountOAuthViewModel(), + override val isIncomingValidation: Boolean = true, + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/IncomingServerValidationViewModelTest.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/IncomingServerValidationViewModelTest.kt new file mode 100644 index 0000000..82ae20d --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/IncomingServerValidationViewModelTest.kt @@ -0,0 +1,77 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Error +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import assertk.assertThat +import assertk.assertions.isTrue +import com.fsck.k9.mail.server.ServerSettingsValidationResult +import kotlinx.coroutines.delay +import org.junit.Test + +class IncomingServerValidationViewModelTest : BaseServerValidationViewModelTest() { + + @Test + fun `should set isIncoming to true`() { + val testSubject = createTestSubject( + serverSettingsValidationResult = ServerSettingsValidationResult.Success, + accountState = AccountState( + incomingServerSettings = SERVER_SETTINGS, + ), + initialState = State( + serverSettings = null, + isLoading = true, + error = Error.ServerError("server error"), + isSuccess = true, + ), + ) + + assertThat(testSubject.isIncomingValidation).isTrue() + } + + override fun createTestSubject( + serverSettingsValidationResult: ServerSettingsValidationResult, + accountState: AccountState, + initialState: State, + ): IncomingServerValidationViewModel { + return IncomingServerValidationViewModel( + validateServerSettings = { + delay(50) + serverSettingsValidationResult + }, + accountStateRepository = InMemoryAccountStateRepository( + state = accountState, + ), + authorizationStateRepository = { true }, + certificateErrorRepository = InMemoryServerCertificateErrorRepository(), + oAuthViewModel = FakeAccountOAuthViewModel(), + initialState = initialState, + ) + } + + override fun createTestSubject( + accountStateRepository: AccountDomainContract.AccountStateRepository, + validateServerSettings: ServerValidationDomainContract.UseCase.ValidateServerSettings, + authorizationStateRepository: AccountOAuthDomainContract.AuthorizationStateRepository, + certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository, + oAuthViewModel: AccountOAuthContract.ViewModel, + initialState: State, + ) = IncomingServerValidationViewModel( + accountStateRepository = accountStateRepository, + validateServerSettings = validateServerSettings, + authorizationStateRepository = authorizationStateRepository, + certificateErrorRepository = certificateErrorRepository, + oAuthViewModel = oAuthViewModel, + initialState = initialState, + ) + + override val isIncomingValidation: Boolean = true +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/OutgoingServerValidationViewModelTest.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/OutgoingServerValidationViewModelTest.kt new file mode 100644 index 0000000..408a95c --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/OutgoingServerValidationViewModelTest.kt @@ -0,0 +1,77 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.oauth.domain.AccountOAuthDomainContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository +import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract +import app.k9mail.feature.account.server.validation.domain.ServerValidationDomainContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Error +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import assertk.assertThat +import assertk.assertions.isFalse +import com.fsck.k9.mail.server.ServerSettingsValidationResult +import kotlinx.coroutines.delay +import org.junit.Test + +class OutgoingServerValidationViewModelTest : BaseServerValidationViewModelTest() { + + @Test + fun `should set isIncoming to false`() { + val testSubject = createTestSubject( + serverSettingsValidationResult = ServerSettingsValidationResult.Success, + accountState = AccountState( + outgoingServerSettings = SERVER_SETTINGS, + ), + initialState = State( + serverSettings = null, + isLoading = true, + error = Error.ServerError("server error"), + isSuccess = true, + ), + ) + + assertThat(testSubject.isIncomingValidation).isFalse() + } + + override fun createTestSubject( + serverSettingsValidationResult: ServerSettingsValidationResult, + accountState: AccountState, + initialState: State, + ): OutgoingServerValidationViewModel { + return OutgoingServerValidationViewModel( + validateServerSettings = { + delay(50) + serverSettingsValidationResult + }, + accountStateRepository = InMemoryAccountStateRepository( + state = accountState, + ), + authorizationStateRepository = { true }, + certificateErrorRepository = InMemoryServerCertificateErrorRepository(), + oAuthViewModel = FakeAccountOAuthViewModel(), + initialState = initialState, + ) + } + + override fun createTestSubject( + accountStateRepository: AccountDomainContract.AccountStateRepository, + validateServerSettings: ServerValidationDomainContract.UseCase.ValidateServerSettings, + authorizationStateRepository: AccountOAuthDomainContract.AuthorizationStateRepository, + certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository, + oAuthViewModel: AccountOAuthContract.ViewModel, + initialState: State, + ) = OutgoingServerValidationViewModel( + accountStateRepository = accountStateRepository, + validateServerSettings = validateServerSettings, + authorizationStateRepository = authorizationStateRepository, + certificateErrorRepository = certificateErrorRepository, + oAuthViewModel = oAuthViewModel, + initialState = initialState, + ) + + override val isIncomingValidation: Boolean = false +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreenKtTest.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreenKtTest.kt new file mode 100644 index 0000000..e6775e6 --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationScreenKtTest.kt @@ -0,0 +1,48 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.Effect +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.provider.BrandNameProvider +import org.junit.Test + +class ServerValidationScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeServerValidationViewModel(initialState = initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + ServerValidationScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + brandNameProvider = FakeBrandNameProvider, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } + + private object FakeBrandNameProvider : BrandNameProvider { + override val brandName: String = "K-9 Mail" + } +} diff --git a/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStateTest.kt b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStateTest.kt new file mode 100644 index 0000000..c7d7c4b --- /dev/null +++ b/feature/account/server/validation/src/test/kotlin/app/k9mail/feature/account/server/validation/ui/ServerValidationStateTest.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.server.validation.ui + +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class ServerValidationStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + serverSettings = null, + isSuccess = false, + error = null, + isLoading = false, + ), + ) + } +} diff --git a/feature/account/settings/api/build.gradle.kts b/feature/account/settings/api/build.gradle.kts new file mode 100644 index 0000000..bb79c59 --- /dev/null +++ b/feature/account/settings/api/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "net.thunderbird.feature.account.settings.api" + resourcePrefix = "account_settings_api_" +} + +dependencies { + implementation(projects.core.ui.compose.navigation) +} diff --git a/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsNavigation.kt b/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsNavigation.kt new file mode 100644 index 0000000..38c0135 --- /dev/null +++ b/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsNavigation.kt @@ -0,0 +1,5 @@ +package net.thunderbird.feature.account.settings.api + +import app.k9mail.core.ui.compose.navigation.Navigation + +interface AccountSettingsNavigation : Navigation diff --git a/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsRoute.kt b/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsRoute.kt new file mode 100644 index 0000000..978a159 --- /dev/null +++ b/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsRoute.kt @@ -0,0 +1,22 @@ +package net.thunderbird.feature.account.settings.api + +import app.k9mail.core.ui.compose.navigation.Route +import kotlinx.serialization.Serializable + +sealed interface AccountSettingsRoute : Route { + + @Serializable + data class GeneralSettings(val accountId: String) : AccountSettingsRoute { + override val basePath: String = BASE_PATH + + override fun route(): String = "$basePath/$accountId" + + companion object { + const val BASE_PATH = "$ACCOUNT_SETTINGS_BASE_PATH/general" + } + } + + companion object { + const val ACCOUNT_SETTINGS_BASE_PATH = "app://account/settings" + } +} diff --git a/feature/account/settings/impl/build.gradle.kts b/feature/account/settings/impl/build.gradle.kts new file mode 100644 index 0000000..cf5c51d --- /dev/null +++ b/feature/account/settings/impl/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "net.thunderbird.feature.account.settings" + resourcePrefix = "account_settings_" +} + +dependencies { + api(projects.feature.account.settings.api) + implementation(projects.feature.account.core) + implementation(projects.feature.account.avatar.impl) + + implementation(projects.core.outcome) + + implementation(projects.core.logging.implLegacy) + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.ui.compose.navigation) + implementation(projects.core.ui.compose.preference) + implementation(projects.core.ui.legacy.theme2.common) + + testImplementation(projects.core.logging.testing) + testImplementation(projects.core.ui.compose.testing) +} diff --git a/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/fake/FakePreferenceData.kt b/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/fake/FakePreferenceData.kt new file mode 100644 index 0000000..6072b90 --- /dev/null +++ b/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/fake/FakePreferenceData.kt @@ -0,0 +1,73 @@ +package net.thunderbird.feature.account.settings.impl.ui.fake + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import app.k9mail.core.ui.compose.designsystem.atom.card.CardElevated +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +object FakePreferenceData { + + val textPreference = PreferenceSetting.Text( + id = "text", + icon = { Icons.Outlined.Info }, + title = { "Title" }, + description = { "Description" }, + value = "Value", + ) + + val colorPreference = PreferenceSetting.Color( + id = "color", + icon = { Icons.Outlined.Info }, + title = { "Title" }, + description = { "Description" }, + value = 0xFFFF0000.toInt(), + colors = persistentListOf( + 0xFFFF0000.toInt(), + 0xFF00FF00.toInt(), + 0xFF0000FF.toInt(), + ), + ) + + val customPreference = PreferenceDisplay.Custom( + id = "custom", + customUi = { modifier -> + CardElevated( + modifier = modifier + .fillMaxWidth() + .padding(MainTheme.spacings.double), + ) { + TextBodyLarge( + text = "Custom UI", + modifier = Modifier + .padding(MainTheme.spacings.default) + .fillMaxWidth(), + ) + } + }, + ) + + val sectionDivider = PreferenceDisplay.SectionDivider( + id = "section_divider", + ) + + val sectionHeader = PreferenceDisplay.SectionHeader( + id = "section_header", + title = { "Section Title" }, + color = { Color.Black }, + ) + + val preferences = persistentListOf( + textPreference, + colorPreference, + customPreference, + sectionHeader, + sectionDivider, + ) +} diff --git a/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContentPreview.kt b/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContentPreview.kt new file mode 100644 index 0000000..aa2380a --- /dev/null +++ b/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContentPreview.kt @@ -0,0 +1,20 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import net.thunderbird.feature.account.settings.impl.ui.fake.FakePreferenceData + +@Composable +@Preview(showBackground = true) +internal fun GeneralSettingsContentPreview() { + PreviewWithTheme { + GeneralSettingsContent( + state = GeneralSettingsContract.State( + subtitle = "Subtitle", + preferences = FakePreferenceData.preferences, + ), + onEvent = {}, + ) + } +} diff --git a/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/components/GeneralSettingsProfileViewPreview.kt b/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/components/GeneralSettingsProfileViewPreview.kt new file mode 100644 index 0000000..ec5b4c1 --- /dev/null +++ b/feature/account/settings/impl/src/debug/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/components/GeneralSettingsProfileViewPreview.kt @@ -0,0 +1,32 @@ +package net.thunderbird.feature.account.settings.impl.ui.general.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun GeneralSettingsProfileViewPreview() { + PreviewWithThemes { + GeneralSettingsProfileView( + name = "Name", + email = "demo@example.com", + color = Color.Green, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun GeneralSettingsProfileViewWithLongTextPreview() { + PreviewWithThemes { + GeneralSettingsProfileView( + name = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut " + + "labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris " + + "nisi ut aliquip ex ea commodo consequat.", + email = "verylongemailaddress@exampledomainwithaverylongname.com", + color = Color.Green, + ) + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/AccountSettingsModule.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/AccountSettingsModule.kt new file mode 100644 index 0000000..62bc149 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/AccountSettingsModule.kt @@ -0,0 +1,52 @@ +package net.thunderbird.feature.account.settings + +import net.thunderbird.feature.account.settings.api.AccountSettingsNavigation +import net.thunderbird.feature.account.settings.impl.DefaultAccountSettingsNavigation +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase +import net.thunderbird.feature.account.settings.impl.domain.usecase.GetAccountName +import net.thunderbird.feature.account.settings.impl.domain.usecase.GetGeneralPreferences +import net.thunderbird.feature.account.settings.impl.domain.usecase.UpdateGeneralPreferences +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralResourceProvider +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsViewModel +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val featureAccountSettingsModule = module { + single { DefaultAccountSettingsNavigation() } + + factory { + GeneralResourceProvider( + context = androidContext(), + ) + } + + factory { + GetAccountName( + repository = get(), + ) + } + + factory { + GetGeneralPreferences( + repository = get(), + resourceProvider = get(), + ) + } + + factory { + UpdateGeneralPreferences( + repository = get(), + ) + } + + viewModel { params -> + GeneralSettingsViewModel( + accountId = params.get(), + getAccountName = get(), + getGeneralPreferences = get(), + updateGeneralPreferences = get(), + ) + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/DefaultAccountSettingsNavigation.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/DefaultAccountSettingsNavigation.kt new file mode 100644 index 0000000..cdbae3a --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/DefaultAccountSettingsNavigation.kt @@ -0,0 +1,32 @@ +package net.thunderbird.feature.account.settings.impl + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import app.k9mail.core.ui.compose.navigation.deepLinkComposable +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.settings.api.AccountSettingsNavigation +import net.thunderbird.feature.account.settings.api.AccountSettingsRoute +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsScreen + +internal class DefaultAccountSettingsNavigation : AccountSettingsNavigation { + + override fun registerRoutes( + navGraphBuilder: NavGraphBuilder, + onBack: () -> Unit, + onFinish: (AccountSettingsRoute) -> Unit, + ) { + with(navGraphBuilder) { + deepLinkComposable( + basePath = AccountSettingsRoute.GeneralSettings.Companion.BASE_PATH, + ) { backStackEntry -> + val generalSettingsRoute = backStackEntry.toRoute() + val accountId = AccountIdFactory.of(generalSettingsRoute.accountId) + + GeneralSettingsScreen( + accountId = accountId, + onBack = onBack, + ) + } + } + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/AccountSettingsDomainContract.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/AccountSettingsDomainContract.kt new file mode 100644 index 0000000..afc4ab0 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/AccountSettingsDomainContract.kt @@ -0,0 +1,60 @@ +package net.thunderbird.feature.account.settings.impl.domain + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.Flow +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError + +internal typealias AccountNameOutcome = Outcome +internal typealias AccountSettingsOutcome = Outcome, SettingsError> + +internal interface AccountSettingsDomainContract { + + interface UseCase { + + fun interface GetAccountName { + operator fun invoke(accountId: AccountId): Flow + } + + fun interface GetGeneralPreferences { + operator fun invoke(accountId: AccountId): Flow + } + + fun interface UpdateGeneralPreferences { + suspend operator fun invoke( + accountId: AccountId, + preference: PreferenceSetting<*>, + ): Outcome + } + } + + interface ResourceProvider { + interface GeneralResourceProvider { + fun profileUi( + name: String, + color: Int, + ): @Composable (Modifier) -> Unit + + val nameTitle: () -> String + val nameDescription: () -> String? + val nameIcon: () -> ImageVector? + + val colorTitle: () -> String + val colorDescription: () -> String? + val colorIcon: () -> ImageVector? + val colors: ImmutableList + } + } + + sealed interface SettingsError { + data class NotFound( + val message: String, + ) : SettingsError + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/entity/GeneralPreference.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/entity/GeneralPreference.kt new file mode 100644 index 0000000..5db77c7 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/entity/GeneralPreference.kt @@ -0,0 +1,13 @@ +package net.thunderbird.feature.account.settings.impl.domain.entity + +import net.thunderbird.feature.account.AccountId + +internal enum class GeneralPreference { + PROFILE, + NAME, + COLOR, +} + +internal fun GeneralPreference.generateId(accountId: AccountId): String { + return "${accountId.asRaw()}-general-${this.name.lowercase()}" +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetAccountName.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetAccountName.kt new file mode 100644 index 0000000..54025e2 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetAccountName.kt @@ -0,0 +1,29 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.profile.AccountProfileRepository +import net.thunderbird.feature.account.settings.impl.domain.AccountNameOutcome +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase + +internal class GetAccountName( + private val repository: AccountProfileRepository, +) : UseCase.GetAccountName { + + override fun invoke(accountId: AccountId): Flow { + return repository.getById(accountId).map { profile -> + if (profile != null) { + Outcome.success(profile.name) + } else { + Outcome.failure( + AccountSettingsDomainContract.SettingsError.NotFound( + message = "Account profile not found for accountId: ${accountId.asRaw()}", + ), + ) + } + } + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetGeneralPreferences.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetGeneralPreferences.kt new file mode 100644 index 0000000..d280b65 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetGeneralPreferences.kt @@ -0,0 +1,65 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.profile.AccountProfileRepository +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsOutcome +import net.thunderbird.feature.account.settings.impl.domain.entity.GeneralPreference +import net.thunderbird.feature.account.settings.impl.domain.entity.generateId + +internal class GetGeneralPreferences( + private val repository: AccountProfileRepository, + private val resourceProvider: ResourceProvider.GeneralResourceProvider, +) : UseCase.GetGeneralPreferences { + override fun invoke(accountId: AccountId): Flow { + return repository.getById(accountId).map { profile -> + if (profile != null) { + Outcome.success(generatePreferences(accountId, profile)) + } else { + Outcome.failure( + SettingsError.NotFound( + message = "Account profile not found for accountId: ${accountId.asRaw()}", + ), + ) + } + } + } + + private fun generatePreferences(accountId: AccountId, profile: AccountProfile): ImmutableList { + return persistentListOf( + PreferenceDisplay.Custom( + id = GeneralPreference.PROFILE.generateId(accountId), + customUi = resourceProvider.profileUi( + name = profile.name, + color = profile.color, + ), + ), + PreferenceSetting.Text( + id = GeneralPreference.NAME.generateId(accountId), + title = resourceProvider.nameTitle, + description = resourceProvider.nameDescription, + icon = resourceProvider.nameIcon, + value = profile.name, + ), + PreferenceSetting.Color( + id = GeneralPreference.COLOR.generateId(accountId), + title = resourceProvider.colorTitle, + description = resourceProvider.colorDescription, + icon = resourceProvider.colorIcon, + value = profile.color, + colors = resourceProvider.colors, + ), + ) + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateGeneralPreferences.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateGeneralPreferences.kt new file mode 100644 index 0000000..9fbfcb1 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateGeneralPreferences.kt @@ -0,0 +1,58 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import kotlinx.coroutines.flow.firstOrNull +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.profile.AccountProfileRepository +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase +import net.thunderbird.feature.account.settings.impl.domain.entity.GeneralPreference +import net.thunderbird.feature.account.settings.impl.domain.entity.generateId + +internal class UpdateGeneralPreferences( + private val repository: AccountProfileRepository, +) : UseCase.UpdateGeneralPreferences { + override suspend fun invoke( + accountId: AccountId, + preference: PreferenceSetting<*>, + ): Outcome { + return when (preference.id) { + GeneralPreference.NAME.generateId(accountId) -> { + updateAccountProfile(accountId) { + copy(name = preference.value as String) + } + } + + GeneralPreference.COLOR.generateId(accountId) -> { + updateAccountProfile(accountId) { + copy(color = preference.value as Int) + } + } + + else -> Outcome.failure( + SettingsError.NotFound( + message = "Unknown preference id: ${preference.id}", + ), + ) + } + } + + private suspend fun updateAccountProfile( + accountId: AccountId, + update: AccountProfile.() -> AccountProfile, + ): Outcome { + val accountProfile = repository.getById(accountId).firstOrNull() + ?: return Outcome.failure( + SettingsError.NotFound( + message = "Account profile not found for accountId: $accountId", + ), + ) + val updatedAccountProfile = update(accountProfile) + + repository.update(updatedAccountProfile) + + return Outcome.success(Unit) + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralResourceProvider.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralResourceProvider.kt new file mode 100644 index 0000000..cd81eb2 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralResourceProvider.kt @@ -0,0 +1,48 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import net.thunderbird.feature.account.settings.R +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider +import net.thunderbird.feature.account.settings.impl.ui.general.components.GeneralSettingsProfileView +import app.k9mail.core.ui.legacy.theme2.common.R as ThunderbirdCommonR + +internal class GeneralResourceProvider( + private val context: Context, +) : ResourceProvider.GeneralResourceProvider { + + override fun profileUi( + name: String, + color: Int, + ): @Composable ((Modifier) -> Unit) = { modifier -> + GeneralSettingsProfileView( + name = name, + email = null, + color = Color(color), + modifier = modifier, + ) + } + + override val nameTitle: () -> String = { + context.getString(R.string.account_settings_general_name_title) + } + override val nameDescription: () -> String? = { + context.getString(R.string.account_settings_general_name_description) + } + override val nameIcon: () -> ImageVector? = { null } + + override val colorTitle: () -> String = { + context.getString(R.string.account_settings_general_color_title) + } + override val colorDescription: () -> String? = { + context.getString(R.string.account_settings_general_color_description) + } + override val colorIcon: () -> ImageVector? = { null } + override val colors: ImmutableList = context.resources.getIntArray(ThunderbirdCommonR.array.account_colors) + .toList().toImmutableList() +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContent.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContent.kt new file mode 100644 index 0000000..e75200a --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContent.kt @@ -0,0 +1,27 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.thunderbird.core.ui.compose.preference.ui.PreferenceView +import net.thunderbird.feature.account.settings.R +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Event +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State + +@Composable +internal fun GeneralSettingsContent( + state: State, + onEvent: (Event) -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceView( + title = stringResource(R.string.account_settings_general_title), + subtitle = state.subtitle, + preferences = state.preferences, + onPreferenceChange = { preference -> + onEvent(Event.OnPreferenceSettingChange(preference)) + }, + onBack = { onEvent(Event.OnBackPressed) }, + modifier = modifier, + ) +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContract.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContract.kt new file mode 100644 index 0000000..a7c2b33 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsContract.kt @@ -0,0 +1,31 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import androidx.compose.runtime.Stable +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +internal interface GeneralSettingsContract { + + interface ViewModel : UnidirectionalViewModel + + @Stable + data class State( + val subtitle: String? = null, + val preferences: ImmutableList = persistentListOf(), + ) + + sealed interface Event { + data class OnPreferenceSettingChange( + val preference: PreferenceSetting<*>, + ) : Event + + data object OnBackPressed : Event + } + + sealed interface Effect { + object NavigateBack : Effect + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsScreen.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsScreen.kt new file mode 100644 index 0000000..188b184 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsScreen.kt @@ -0,0 +1,31 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.mvi.observe +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +internal fun GeneralSettingsScreen( + accountId: AccountId, + onBack: () -> Unit, + viewModel: GeneralSettingsContract.ViewModel = koinViewModel { + parametersOf(accountId) + }, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateBack -> onBack() + } + } + + BackHandler(onBack = onBack) + + GeneralSettingsContent( + state = state.value, + onEvent = { dispatch(it) }, + ) +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsViewModel.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsViewModel.kt new file mode 100644 index 0000000..68f8960 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsViewModel.kt @@ -0,0 +1,74 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import kotlinx.coroutines.launch +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.outcome.handle +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Event +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State + +internal class GeneralSettingsViewModel( + private val accountId: AccountId, + private val getAccountName: UseCase.GetAccountName, + private val getGeneralPreferences: UseCase.GetGeneralPreferences, + private val updateGeneralPreferences: UseCase.UpdateGeneralPreferences, + initialState: State = State(), +) : BaseViewModel(initialState), GeneralSettingsContract.ViewModel { + + init { + viewModelScope.launch { + getAccountName(accountId).collect { outcome -> + outcome.handle( + onSuccess = { accountName -> + updateState { state -> + state.copy( + subtitle = accountName, + ) + } + }, + onFailure = { handleError(it) }, + ) + } + } + + viewModelScope.launch { + getGeneralPreferences(accountId).collect { outcome -> + outcome.handle( + onSuccess = { preferences -> + updateState { state -> + state.copy( + preferences = preferences, + ) + } + }, + onFailure = { handleError(it) }, + ) + } + } + } + + override fun event(event: Event) { + when (event) { + is Event.OnPreferenceSettingChange -> updatePreference(event.preference) + is Event.OnBackPressed -> emitEffect(Effect.NavigateBack) + } + } + + private fun updatePreference(preference: PreferenceSetting<*>) { + viewModelScope.launch { + updateGeneralPreferences(accountId, preference) + } + } + + private fun handleError(error: SettingsError) { + when (error) { + is SettingsError.NotFound -> Log.w(error.message) + } + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/components/GeneralSettingsProfileView.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/components/GeneralSettingsProfileView.kt new file mode 100644 index 0000000..dffb8d7 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/components/GeneralSettingsProfileView.kt @@ -0,0 +1,87 @@ +package net.thunderbird.feature.account.settings.impl.ui.general.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import app.k9mail.core.ui.compose.designsystem.atom.card.CardElevated +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.feature.account.avatar.ui.AvatarOutlined +import net.thunderbird.feature.account.avatar.ui.AvatarSize + +@Composable +internal fun GeneralSettingsProfileView( + name: String, + email: String?, + color: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .padding(MainTheme.spacings.double), + contentAlignment = Alignment.TopCenter, + ) { + ProfileCard( + name = name, + email = email, + modifier = Modifier + .padding(top = MainTheme.spacings.quadruple) + .fillMaxWidth(), + ) + AvatarOutlined( + color = color, + name = name, + size = AvatarSize.LARGE, + ) + } +} + +@Composable +private fun ProfileCard( + name: String, + email: String?, + modifier: Modifier = Modifier, +) { + CardElevated( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MainTheme.spacings.oneHalf, + vertical = MainTheme.spacings.triple, + ), + ) { + Spacer(modifier = Modifier.height(MainTheme.spacings.triple)) + TextHeadlineSmall( + text = name, + color = MainTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + email?.let { + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + TextBodyLarge( + text = it, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } +} diff --git a/feature/account/settings/impl/src/main/res/values/strings.xml b/feature/account/settings/impl/src/main/res/values/strings.xml new file mode 100644 index 0000000..fc5cc8d --- /dev/null +++ b/feature/account/settings/impl/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + + + General Settings + + Account name + The name associated with your account. + + Account color + The accent color of this account used in folders and account lists. + + diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/AccountSettingsModuleKtTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/AccountSettingsModuleKtTest.kt new file mode 100644 index 0000000..e1eb583 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/AccountSettingsModuleKtTest.kt @@ -0,0 +1,22 @@ +package net.thunderbird.feature.account.settings.impl + +import kotlin.test.Test +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.featureAccountSettingsModule +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.test.verify.verify + +internal class AccountSettingsModuleKtTest { + + @OptIn(KoinExperimentalAPI::class) + @Test + fun `should hava a valid di module`() { + featureAccountSettingsModule.verify( + extraTypes = listOf( + AccountId::class, + GeneralSettingsContract.State::class, + ), + ) + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/FakeAccountProfileRepository.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/FakeAccountProfileRepository.kt new file mode 100644 index 0000000..245c122 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/FakeAccountProfileRepository.kt @@ -0,0 +1,27 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.profile.AccountProfileRepository + +internal class FakeAccountProfileRepository( + initialAccountProfile: AccountProfile? = null, +) : AccountProfileRepository { + + private val accountProfileState = MutableStateFlow(initialAccountProfile) + private val accountProfile: StateFlow = accountProfileState + + override fun getById(accountId: AccountId): Flow { + return accountProfile + } + + override suspend fun update(accountProfile: AccountProfile) { + accountProfileState.update { + accountProfile + } + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/FakeGeneralResourceProvider.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/FakeGeneralResourceProvider.kt new file mode 100644 index 0000000..82b0389 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/FakeGeneralResourceProvider.kt @@ -0,0 +1,24 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider + +internal class FakeGeneralResourceProvider : ResourceProvider.GeneralResourceProvider { + override fun profileUi( + name: String, + color: Int, + ): @Composable ((Modifier) -> Unit) = { } + + override val nameTitle: () -> String = { "Name" } + override val nameDescription: () -> String? = { null } + override val nameIcon: () -> ImageVector? = { null } + + override val colorTitle: () -> String = { "Color" } + override val colorDescription: () -> String? = { null } + override val colorIcon: () -> ImageVector? = { null } + override val colors: ImmutableList = persistentListOf(0xFF0000, 0x00FF00, 0x0000FF) +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetAccountNameTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetAccountNameTest.kt new file mode 100644 index 0000000..02f2209 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetAccountNameTest.kt @@ -0,0 +1,63 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase + +class GetAccountNameTest { + + @Test + fun `should emit account name when account profile present`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val accountProfile = AccountProfile( + id = accountId, + name = "Test Account", + color = 0xFF0000, + avatar = AccountAvatar.Icon(name = "star"), + ) + val testSubject = createTestSubject(accountProfile) + + // Act & Assert + testSubject(accountId).test { + val outcome = awaitItem() + assertThat(outcome).isInstanceOf(Outcome.Success::class) + + val success = outcome as Outcome.Success + assertThat(success.data).isEqualTo(accountProfile.name) + } + } + + @Test + fun `should emit NotFound when account profile not present`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val testSubject = createTestSubject() + + // Act & Assert + testSubject(accountId).test { + val outcome = awaitItem() + assertThat(outcome).isInstanceOf(Outcome.Failure::class) + + val failure = outcome as Outcome.Failure + assertThat(failure.error).isInstanceOf(SettingsError.NotFound::class) + } + } + + private fun createTestSubject( + accountProfile: AccountProfile? = null, + ): UseCase.GetAccountName { + return GetAccountName( + repository = FakeAccountProfileRepository(accountProfile), + ) + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetGeneralPreferencesTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetGeneralPreferencesTest.kt new file mode 100644 index 0000000..001425f --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/GetGeneralPreferencesTest.kt @@ -0,0 +1,97 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import kotlin.test.Test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase + +internal class GetGeneralPreferencesTest { + + @Test + fun `should emit preferences when account profile present`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val accountProfile = AccountProfile( + id = accountId, + name = "Test Account", + color = 0xFF0000, + avatar = AccountAvatar.Icon(name = "star"), + ) + val resourceProvider = FakeGeneralResourceProvider() + val testSubject = createTestSubject(accountProfile) + + // Act & Assert + testSubject(accountId).test { + val outcome = awaitItem() + assertThat(outcome).isInstanceOf(Outcome.Success::class) + + val success = outcome as Outcome.Success + assertThat(success.data).isEqualTo( + persistentListOf( + PreferenceDisplay.Custom( + id = "${accountId.asRaw()}-general-profile", + customUi = resourceProvider.profileUi( + name = accountProfile.name, + color = accountProfile.color, + ), + ), + PreferenceSetting.Text( + id = "${accountId.asRaw()}-general-name", + title = resourceProvider.nameTitle, + description = resourceProvider.nameDescription, + icon = resourceProvider.nameIcon, + value = accountProfile.name, + ), + PreferenceSetting.Color( + id = "${accountId.asRaw()}-general-color", + title = resourceProvider.colorTitle, + description = resourceProvider.colorDescription, + icon = resourceProvider.colorIcon, + value = accountProfile.color, + colors = resourceProvider.colors, + ), + ), + ) + } + } + + @Test + fun `should emit NotFound when account profile not found`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val testSubject = createTestSubject() + + // Act & Assert + testSubject(accountId).test { + assertThat(awaitItem()).isEqualTo( + Outcome.failure( + SettingsError.NotFound( + message = "Account profile not found for accountId: ${accountId.asRaw()}", + ), + ), + ) + } + } + + private fun createTestSubject( + accountProfile: AccountProfile? = null, + resourceProvider: ResourceProvider.GeneralResourceProvider = FakeGeneralResourceProvider(), + ): UseCase.GetGeneralPreferences { + return GetGeneralPreferences( + repository = FakeAccountProfileRepository(accountProfile), + resourceProvider = resourceProvider, + ) + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateGeneralPreferencesTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateGeneralPreferencesTest.kt new file mode 100644 index 0000000..8e094e9 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateGeneralPreferencesTest.kt @@ -0,0 +1,123 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import kotlin.test.Test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError +import net.thunderbird.feature.account.settings.impl.domain.entity.GeneralPreference +import net.thunderbird.feature.account.settings.impl.domain.entity.generateId + +class UpdateGeneralPreferencesTest { + + @Test + fun `should update account profile`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val accountProfile = AccountProfile( + id = accountId, + name = "Test Account", + color = 0xFF0000, + avatar = AccountAvatar.Icon(name = "star"), + ) + val newName = "Updated Account Name" + val preference = PreferenceSetting.Text( + id = GeneralPreference.NAME.generateId(accountId), + title = { "Name" }, + description = { "Account name" }, + icon = { null }, + value = newName, + ) + val repository = FakeAccountProfileRepository( + initialAccountProfile = accountProfile, + ) + val testSubject = UpdateGeneralPreferences(repository) + + // Act + val result = testSubject(accountId, preference) + + // Assert + assertThat(result).isInstanceOf(Outcome.Success::class) + assertThat(repository.getById(accountId).firstOrNull()).isEqualTo( + accountProfile.copy(name = newName), + ) + } + + @Test + fun `should update account profile for all general settings`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val accountProfile = AccountProfile( + id = accountId, + name = "Test Account", + color = 0xFF0000, + avatar = AccountAvatar.Icon(name = "star"), + ) + val newName = "Updated Account Name" + val newColor = 0x00FF00 + val preferences = listOf( + PreferenceSetting.Text( + id = GeneralPreference.NAME.generateId(accountId), + title = { "Name" }, + description = { "Account name" }, + icon = { null }, + value = newName, + ), + PreferenceSetting.Color( + id = GeneralPreference.COLOR.generateId(accountId), + title = { "Color" }, + description = { "Account color" }, + icon = { null }, + value = newColor, + colors = persistentListOf(0xFF0000, 0x00FF00, 0x0000FF), + ), + ) + val repository = FakeAccountProfileRepository( + initialAccountProfile = accountProfile, + ) + val testSubject = UpdateGeneralPreferences(repository) + + // Act + preferences.forEach { preference -> + testSubject(accountId, preference) + } + + // Assert + assertThat(repository.getById(accountId).firstOrNull()).isEqualTo( + accountProfile.copy( + name = newName, + color = newColor, + ), + ) + } + + @Test + fun `should emit NotFound when account profile not found`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val preference = PreferenceSetting.Text( + id = GeneralPreference.NAME.generateId(accountId), + title = { "Name" }, + description = { "Account name" }, + icon = { null }, + value = "Updated Account Name", + ) + val repository = FakeAccountProfileRepository() + val testSubject = UpdateGeneralPreferences(repository) + + // Act + val result = testSubject(accountId, preference) + + // Assert + assertThat(result).isInstanceOf(Outcome.Failure::class) + assertThat((result as Outcome.Failure).error).isInstanceOf(SettingsError.NotFound::class) + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/FakeData.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/FakeData.kt new file mode 100644 index 0000000..7a34c04 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/FakeData.kt @@ -0,0 +1,19 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting + +internal object FakeData { + + val preferences: ImmutableList = persistentListOf( + PreferenceSetting.Text( + id = "test_id", + title = { "Title" }, + description = { "Description" }, + icon = { null }, + value = "Test", + ), + ) +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/FakeGeneralSettingsViewModel.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/FakeGeneralSettingsViewModel.kt new file mode 100644 index 0000000..ec63b48 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/FakeGeneralSettingsViewModel.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import app.k9mail.core.ui.compose.testing.BaseFakeViewModel +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Event +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.ViewModel + +internal class FakeGeneralSettingsViewModel( + initialState: State = State(), +) : BaseFakeViewModel(initialState), ViewModel diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsScreenKtTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsScreenKtTest.kt new file mode 100644 index 0000000..ffd8bf1 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsScreenKtTest.kt @@ -0,0 +1,58 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.pressBack +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State + +internal class GeneralSettingsScreenKtTest : ComposeTest() { + + @Test + fun `should call onBack when back button is pressed`() { + val initialState = State() + val accountId = AccountIdFactory.create() + val viewModel = FakeGeneralSettingsViewModel(initialState) + var onBackCounter = 0 + + setContentWithTheme { + GeneralSettingsScreen( + accountId = accountId, + onBack = { onBackCounter++ }, + viewModel = viewModel, + ) + } + + assertThat(onBackCounter).isEqualTo(0) + + pressBack() + + assertThat(onBackCounter).isEqualTo(1) + } + + @Test + fun `should call onBack when navigate back effect received`() { + val initialState = State() + val accountId = AccountIdFactory.create() + val viewModel = FakeGeneralSettingsViewModel(initialState) + var onBackCounter = 0 + + setContentWithTheme { + GeneralSettingsScreen( + accountId = accountId, + onBack = { onBackCounter++ }, + viewModel = viewModel, + ) + } + + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsStateTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsStateTest.kt new file mode 100644 index 0000000..b926add --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsStateTest.kt @@ -0,0 +1,24 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State + +internal class GeneralSettingsStateTest { + + @Test + fun `should set default values`() { + // Arrange + val state = State() + + // Assert + assertThat(state).isEqualTo( + State( + subtitle = null, + preferences = persistentListOf(), + ), + ) + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsViewModelTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsViewModelTest.kt new file mode 100644 index 0000000..9bfa7fd --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/general/GeneralSettingsViewModelTest.kt @@ -0,0 +1,199 @@ +package net.thunderbird.feature.account.settings.impl.ui.general + +import app.k9mail.core.ui.compose.testing.mvi.MviContext +import app.k9mail.core.ui.compose.testing.mvi.MviTurbines +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.StandardTestDispatcher +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import net.thunderbird.core.ui.compose.preference.api.Preference +import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect +import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State +import org.junit.Before +import org.junit.Rule + +class GeneralSettingsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher()) + + @Before + fun setUp() { + Log.logger = TestLogger() + } + + @Test + fun `should load account name`() = runMviTest { + val accountId = AccountIdFactory.create() + val initialState = State( + subtitle = null, + preferences = persistentListOf(), + ) + + generalSettingsRobot(accountId, initialState, persistentListOf()) { + verifyAccountNameLoaded() + } + } + + @Test + fun `should load general settings`() = runMviTest { + val accountId = AccountIdFactory.create() + val initialState = State( + subtitle = "Subtitle", + preferences = persistentListOf(), + ) + val preferences = FakeData.preferences + + generalSettingsRobot(accountId, initialState, preferences) { + verifyGeneralSettingsLoaded(preferences) + } + } + + @Test + fun `should navigate back when back is pressed`() = runMviTest { + val accountId = AccountIdFactory.create() + val initialState = State( + subtitle = "Subtitle", + preferences = persistentListOf(), + ) + val preferences = FakeData.preferences + + generalSettingsRobot(accountId, initialState, preferences) { + verifyGeneralSettingsLoaded(preferences) + pressBack() + verifyBackNavigation() + } + } + + @Test + fun `should update preference when changed`() = runMviTest { + val accountId = AccountIdFactory.create() + val initialState = State( + subtitle = "Subtitle", + preferences = persistentListOf(), + ) + val preferences = FakeData.preferences + + generalSettingsRobot(accountId, initialState, preferences) { + verifyGeneralSettingsLoaded(preferences) + val updatedPreference = (preferences.first() as PreferenceSetting.Text).copy( + title = { "Updated Title" }, + description = { "Updated Description" }, + ) + updatePreference(updatedPreference) + + verifyPreferenceUpdated(updatedPreference) + } + } +} + +private suspend fun MviContext.generalSettingsRobot( + accountId: AccountId, + initialState: State, + preferences: ImmutableList, + interaction: suspend GeneralSettingsRobot.() -> Unit, +) = GeneralSettingsRobot(this, accountId, initialState, preferences).apply { + initialize() + interaction() +} + +private class GeneralSettingsRobot( + private val mviContext: MviContext, + private val accountId: AccountId, + private val initialState: State = State(), + private val preferences: ImmutableList, +) { + private lateinit var preferencesState: MutableStateFlow> + private lateinit var turbines: MviTurbines + + private val viewModel: GeneralSettingsContract.ViewModel by lazy { + GeneralSettingsViewModel( + accountId = accountId, + getAccountName = { + flowOf(Outcome.success("Subtitle")) + }, + getGeneralPreferences = { + preferencesState.map { + println("Loading preferences: $it") + Outcome.success(it) + } + }, + updateGeneralPreferences = { _, preference -> + preferencesState.value = preferencesState.value.map { existingPreference -> + if (existingPreference is PreferenceSetting<*> && existingPreference.id == preference.id) { + println("Updating preference: ${preference.id}") + println("Old preference: $existingPreference") + println("New preference: $preference") + preference + } else { + existingPreference + } + }.toImmutableList() + Outcome.success(Unit) + }, + initialState = initialState, + ) + } + + suspend fun initialize() { + preferencesState = MutableStateFlow(preferences) + + turbines = mviContext.turbinesWithInitialStateCheck( + initialState = initialState, + viewModel = viewModel, + ) + } + + suspend fun verifyAccountNameLoaded() { + assertThat(turbines.awaitStateItem()).isEqualTo( + initialState.copy( + subtitle = "Subtitle", + ), + ) + } + + suspend fun verifyGeneralSettingsLoaded(preferences: ImmutableList) { + assertThat(turbines.awaitStateItem()).isEqualTo( + initialState.copy( + preferences = preferences, + ), + ) + } + + fun pressBack() { + viewModel.event(GeneralSettingsContract.Event.OnBackPressed) + } + + suspend fun verifyBackNavigation() { + assertThat(turbines.awaitEffectItem()).isEqualTo( + Effect.NavigateBack, + ) + } + + fun updatePreference(preference: PreferenceSetting<*>) { + viewModel.event(GeneralSettingsContract.Event.OnPreferenceSettingChange(preference)) + } + + suspend fun verifyPreferenceUpdated(preference: PreferenceSetting<*>) { + val updatedPreference = turbines.awaitStateItem().preferences + .filterIsInstance>() + .find { it.id == preference.id } + + assertThat(updatedPreference).isEqualTo(preference) + } +} diff --git a/feature/account/setup/build.gradle.kts b/feature/account/setup/build.gradle.kts new file mode 100644 index 0000000..34377b6 --- /dev/null +++ b/feature/account/setup/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.account.setup" + resourcePrefix = "account_setup_" +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.ui.compose.navigation) + + implementation(projects.mail.common) + implementation(projects.mail.protocols.imap) + implementation(projects.mail.protocols.pop3) + implementation(projects.mail.protocols.smtp) + + implementation(projects.feature.autodiscovery.service) + implementation(projects.feature.autodiscovery.demo) + + api(projects.feature.account.common) + implementation(projects.feature.account.oauth) + implementation(projects.feature.account.server.settings) + implementation(projects.feature.account.server.certificate) + api(projects.feature.account.server.validation) + + testImplementation(projects.core.logging.testing) + testImplementation(projects.core.ui.compose.testing) + + testImplementation(platform(libs.forkhandles.bom)) + testImplementation(libs.forkhandles.fabrikate4k) +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContentPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContentPreview.kt new file mode 100644 index 0000000..5983df1 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContentPreview.kt @@ -0,0 +1,103 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.validation.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.setup.ui.autodiscovery.fake.fakeAutoDiscoveryResultSettings + +@Composable +@Preview(showBackground = true) +internal fun AccountAutoDiscoveryContentPreview() { + PreviewWithTheme { + AccountAutoDiscoveryContent( + state = AccountAutoDiscoveryContract.State(), + onEvent = {}, + oAuthViewModel = FakeAccountOAuthViewModel(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AccountAutoDiscoveryContentEmailPreview() { + PreviewWithTheme { + AccountAutoDiscoveryContent( + state = AccountAutoDiscoveryContract.State( + emailAddress = StringInputField(value = "test@example.com"), + ), + onEvent = {}, + oAuthViewModel = FakeAccountOAuthViewModel(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AccountAutoDiscoveryContentPasswordPreview() { + PreviewWithTheme { + AccountAutoDiscoveryContent( + state = AccountAutoDiscoveryContract.State( + configStep = AccountAutoDiscoveryContract.ConfigStep.PASSWORD, + emailAddress = StringInputField(value = "test@example.com"), + autoDiscoverySettings = fakeAutoDiscoveryResultSettings(isTrusted = true), + ), + onEvent = {}, + oAuthViewModel = FakeAccountOAuthViewModel(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AccountAutoDiscoveryContentPasswordUntrustedSettingsPreview() { + PreviewWithTheme { + AccountAutoDiscoveryContent( + state = AccountAutoDiscoveryContract.State( + configStep = AccountAutoDiscoveryContract.ConfigStep.PASSWORD, + emailAddress = StringInputField(value = "test@example.com"), + autoDiscoverySettings = fakeAutoDiscoveryResultSettings(isTrusted = false), + ), + onEvent = {}, + oAuthViewModel = FakeAccountOAuthViewModel(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AccountAutoDiscoveryContentPasswordNoSettingsPreview() { + PreviewWithTheme { + AccountAutoDiscoveryContent( + state = AccountAutoDiscoveryContract.State( + configStep = AccountAutoDiscoveryContract.ConfigStep.PASSWORD, + emailAddress = StringInputField(value = "test@example.com"), + ), + onEvent = {}, + oAuthViewModel = FakeAccountOAuthViewModel(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AccountAutoDiscoveryContentOAuthPreview() { + PreviewWithTheme { + AccountAutoDiscoveryContent( + state = AccountAutoDiscoveryContract.State( + configStep = AccountAutoDiscoveryContract.ConfigStep.OAUTH, + emailAddress = StringInputField(value = "test@example.com"), + autoDiscoverySettings = fakeAutoDiscoveryResultSettings(isTrusted = true), + ), + onEvent = {}, + oAuthViewModel = FakeAccountOAuthViewModel(), + brandName = "BrandName", + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreenPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreenPreview.kt new file mode 100644 index 0000000..79512a4 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreenPreview.kt @@ -0,0 +1,27 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import androidx.compose.runtime.Composable +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.core.ui.compose.common.annotation.PreviewDevicesWithBackground +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.ui.fake.FakeAccountStateRepository +import app.k9mail.feature.account.server.validation.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.setup.ui.fake.FakeBrandNameProvider + +@Composable +@PreviewDevicesWithBackground +internal fun AccountAutoDiscoveryScreenPreview() { + PreviewWithTheme { + AccountAutoDiscoveryScreen( + onNext = {}, + onBack = {}, + viewModel = AccountAutoDiscoveryViewModel( + validator = AccountAutoDiscoveryValidator(), + getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound }, + accountStateRepository = FakeAccountStateRepository(), + oAuthViewModel = FakeAccountOAuthViewModel(), + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/fake/FakeAutoDiscoveryResult.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/fake/FakeAutoDiscoveryResult.kt new file mode 100644 index 0000000..d42f3c8 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/fake/FakeAutoDiscoveryResult.kt @@ -0,0 +1,29 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.fake + +import app.k9mail.autodiscovery.api.AuthenticationType +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.ConnectionSecurity +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort + +internal fun fakeAutoDiscoveryResultSettings(isTrusted: Boolean) = + AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = "imap.example.com".toHostname(), + port = 993.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordEncrypted), + username = "", + ), + outgoingServerSettings = SmtpServerSettings( + hostname = "smtp.example.com".toHostname(), + port = 465.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordEncrypted), + username = "", + ), + isTrusted = isTrusted, + source = "preview", + ) diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultApprovalViewPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultApprovalViewPreview.kt new file mode 100644 index 0000000..325d1ad --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultApprovalViewPreview.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.feature.account.common.domain.input.BooleanInputField + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultApprovalViewPreview() { + PreviewWithThemes { + AutoDiscoveryResultApprovalView( + approvalState = BooleanInputField( + value = true, + isValid = true, + ), + onApprovalChange = {}, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultBodyViewPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultBodyViewPreview.kt new file mode 100644 index 0000000..f85570d --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultBodyViewPreview.kt @@ -0,0 +1,17 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.feature.account.setup.ui.autodiscovery.fake.fakeAutoDiscoveryResultSettings + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultBodyViewPreview() { + PreviewWithThemes { + AutoDiscoveryResultBodyView( + settings = fakeAutoDiscoveryResultSettings(isTrusted = true), + onEditConfigurationClick = {}, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderViewPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderViewPreview.kt new file mode 100644 index 0000000..ea2d770 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderViewPreview.kt @@ -0,0 +1,60 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultHeaderViewTrustedCollapsedPreview() { + PreviewWithThemes { + AutoDiscoveryResultHeaderView( + state = AutoDiscoveryResultHeaderState.Trusted, + isExpanded = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultHeaderViewTrustedExpandedPreview() { + PreviewWithThemes { + AutoDiscoveryResultHeaderView( + state = AutoDiscoveryResultHeaderState.Trusted, + isExpanded = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultHeaderViewUntrustedCollapsedPreview() { + PreviewWithThemes { + AutoDiscoveryResultHeaderView( + state = AutoDiscoveryResultHeaderState.Untrusted, + isExpanded = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultHeaderViewUntrustedExpandedPreview() { + PreviewWithThemes { + AutoDiscoveryResultHeaderView( + state = AutoDiscoveryResultHeaderState.Untrusted, + isExpanded = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultHeaderNoSettingsPreview() { + PreviewWithThemes { + AutoDiscoveryResultHeaderView( + state = AutoDiscoveryResultHeaderState.NoSettings, + isExpanded = false, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultViewPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultViewPreview.kt new file mode 100644 index 0000000..4540d88 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultViewPreview.kt @@ -0,0 +1,28 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import app.k9mail.feature.account.setup.ui.autodiscovery.fake.fakeAutoDiscoveryResultSettings + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultViewTrustedPreview() { + PreviewWithThemes { + AutoDiscoveryResultView( + settings = fakeAutoDiscoveryResultSettings(isTrusted = true), + onEditConfigurationClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryResultViewUntrustedPreview() { + PreviewWithThemes { + AutoDiscoveryResultView( + settings = fakeAutoDiscoveryResultSettings(isTrusted = false), + onEditConfigurationClick = {}, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryServerSettingsViewPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryServerSettingsViewPreview.kt new file mode 100644 index 0000000..e4d9ed2 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryServerSettingsViewPreview.kt @@ -0,0 +1,62 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.autodiscovery.api.ConnectionSecurity +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import net.thunderbird.core.common.net.toHostname + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryServerSettingsViewPreview() { + PreviewWithThemes { + AutoDiscoveryServerSettingsView( + protocolName = "IMAP", + serverHostname = "imap.example.com".toHostname(), + serverPort = 993, + connectionSecurity = ConnectionSecurity.TLS, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryServerSettingsViewOutgoingPreview() { + PreviewWithThemes { + AutoDiscoveryServerSettingsView( + protocolName = "IMAP", + serverHostname = "imap.example.com".toHostname(), + serverPort = 993, + connectionSecurity = ConnectionSecurity.TLS, + isIncoming = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryServerSettingsViewWithUserPreview() { + PreviewWithThemes { + AutoDiscoveryServerSettingsView( + protocolName = "IMAP", + serverHostname = "imap.example.com".toHostname(), + serverPort = 993, + connectionSecurity = ConnectionSecurity.TLS, + username = "username", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun AutoDiscoveryServerSettingsViewWithIpAddressPreview() { + PreviewWithThemes { + AutoDiscoveryServerSettingsView( + protocolName = "IMAP", + serverHostname = "127.0.0.1".toHostname(), + serverPort = 993, + connectionSecurity = ConnectionSecurity.TLS, + username = "username", + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContentPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContentPreview.kt new file mode 100644 index 0000000..1b096fe --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContentPreview.kt @@ -0,0 +1,49 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult + +@Composable +@Preview(showBackground = true) +internal fun CreateAccountContentSuccessPreview() { + PreviewWithTheme { + CreateAccountContent( + state = CreateAccountContract.State( + isLoading = false, + error = null, + ), + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun CreateAccountContentLoadingPreview() { + PreviewWithTheme { + CreateAccountContent( + state = CreateAccountContract.State( + isLoading = true, + error = null, + ), + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun CreateAccountContentErrorPreview() { + PreviewWithTheme { + CreateAccountContent( + state = CreateAccountContract.State( + isLoading = false, + error = AccountCreatorResult.Error("Error message"), + ), + contentPadding = PaddingValues(), + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreenPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreenPreview.kt new file mode 100644 index 0000000..c3a3eaa --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreenPreview.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import app.k9mail.feature.account.setup.ui.fake.FakeBrandNameProvider + +@Composable +@PreviewDevices +internal fun AccountOptionsScreenK9Preview() { + PreviewWithTheme { + CreateAccountScreen( + onNext = {}, + onBack = {}, + viewModel = CreateAccountViewModel( + createAccount = { AccountCreatorResult.Success("irrelevant") }, + accountStateRepository = InMemoryAccountStateRepository(), + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/fake/FakeBrandNameProvider.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/fake/FakeBrandNameProvider.kt new file mode 100644 index 0000000..fd0cf18 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/fake/FakeBrandNameProvider.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.account.setup.ui.fake + +import net.thunderbird.core.common.provider.BrandNameProvider + +internal object FakeBrandNameProvider : BrandNameProvider { + override val brandName: String = "Fake Brand Name" +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContentPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContentPreview.kt new file mode 100644 index 0000000..1f331e3 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContentPreview.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@Preview(showBackground = true) +internal fun DisplayOptionsContentPreview() { + PreviewWithTheme { + DisplayOptionsContent( + state = DisplayOptionsContract.State(), + onEvent = {}, + contentPadding = PaddingValues(), + brandName = "BrandName", + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreenPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreenPreview.kt new file mode 100644 index 0000000..0199c7d --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreenPreview.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.ui.fake.FakeAccountStateRepository +import app.k9mail.feature.account.setup.ui.fake.FakeBrandNameProvider + +@Composable +@Preview(showBackground = true) +internal fun DisplayOptionsScreenPreview() { + PreviewWithTheme { + DisplayOptionsScreen( + onNext = {}, + onBack = {}, + viewModel = DisplayOptionsViewModel( + validator = DisplayOptionsValidator(), + accountStateRepository = FakeAccountStateRepository(), + accountOwnerNameProvider = { null }, + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContentPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContentPreview.kt new file mode 100644 index 0000000..5d0df81 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContentPreview.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@Preview(showBackground = true) +internal fun SyncOptionsContentPreview() { + PreviewWithTheme { + SyncOptionsContent( + state = SyncOptionsContract.State(), + onEvent = {}, + contentPadding = PaddingValues(), + brandName = "BrandName", + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreenPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreenPreview.kt new file mode 100644 index 0000000..6e9b822 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreenPreview.kt @@ -0,0 +1,22 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.common.ui.fake.FakeAccountStateRepository +import app.k9mail.feature.account.setup.ui.fake.FakeBrandNameProvider + +@Composable +@Preview(showBackground = true) +internal fun SyncOptionsScreenPreview() { + PreviewWithTheme { + SyncOptionsScreen( + onNext = {}, + onBack = {}, + viewModel = SyncOptionsViewModel( + accountStateRepository = FakeAccountStateRepository(), + ), + brandNameProvider = FakeBrandNameProvider, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContentPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContentPreview.kt new file mode 100644 index 0000000..46722ae --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContentPreview.kt @@ -0,0 +1,68 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@Preview(showBackground = true) +internal fun SpecialFoldersContentLoadingPreview() { + PreviewWithTheme { + SpecialFoldersContent( + state = SpecialFoldersContract.State( + isLoading = true, + ), + onEvent = {}, + contentPadding = PaddingValues(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SpecialFoldersContentFormPreview() { + PreviewWithTheme { + SpecialFoldersContent( + state = SpecialFoldersContract.State( + isLoading = false, + ), + onEvent = {}, + contentPadding = PaddingValues(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SpecialFoldersContentSuccessPreview() { + PreviewWithTheme { + SpecialFoldersContent( + state = SpecialFoldersContract.State( + isLoading = false, + isSuccess = true, + ), + onEvent = {}, + contentPadding = PaddingValues(), + brandName = "BrandName", + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun SpecialFoldersContentErrorPreview() { + PreviewWithTheme { + SpecialFoldersContent( + state = SpecialFoldersContract.State( + isLoading = false, + error = SpecialFoldersContract.Failure.LoadFoldersFailed("Error"), + ), + onEvent = {}, + contentPadding = PaddingValues(), + brandName = "BrandName", + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormContentPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormContentPreview.kt new file mode 100644 index 0000000..1f4b4a5 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormContentPreview.kt @@ -0,0 +1,16 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@Preview(showBackground = true) +internal fun SpecialFoldersFormContentPreview() { + PreviewWithTheme { + SpecialFoldersFormContent( + state = SpecialFoldersContract.FormState(), + onEvent = {}, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreenPreview.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreenPreview.kt new file mode 100644 index 0000000..2c490cb --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreenPreview.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevices +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.account.setup.ui.fake.FakeBrandNameProvider +import app.k9mail.feature.account.setup.ui.specialfolders.fake.FakeSpecialFoldersViewModel + +@Composable +@PreviewDevices +internal fun SpecialFoldersScreenPreview() { + PreviewWithTheme { + SpecialFoldersScreen( + onNext = {}, + onBack = {}, + viewModel = FakeSpecialFoldersViewModel(), + brandNameProvider = FakeBrandNameProvider, + ) + } +} diff --git a/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/fake/FakeSpecialFoldersViewModel.kt b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/fake/FakeSpecialFoldersViewModel.kt new file mode 100644 index 0000000..904dea2 --- /dev/null +++ b/feature/account/setup/src/debug/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/fake/FakeSpecialFoldersViewModel.kt @@ -0,0 +1,22 @@ +package app.k9mail.feature.account.setup.ui.specialfolders.fake + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Effect +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Event +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.State +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.ViewModel + +class FakeSpecialFoldersViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupExternalContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupExternalContract.kt new file mode 100644 index 0000000..554f418 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupExternalContract.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.setup + +import app.k9mail.feature.account.common.domain.entity.Account + +interface AccountSetupExternalContract { + + fun interface AccountCreator { + suspend fun createAccount(account: Account): AccountCreatorResult + + sealed interface AccountCreatorResult { + data class Success(val accountUuid: String) : AccountCreatorResult + data class Error(val message: String) : AccountCreatorResult + } + } + + fun interface AccountOwnerNameProvider { + suspend fun getOwnerName(): String? + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt new file mode 100644 index 0000000..731caa3 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt @@ -0,0 +1,146 @@ +package app.k9mail.feature.account.setup + +import app.k9mail.autodiscovery.api.AutoDiscovery +import app.k9mail.autodiscovery.api.AutoDiscoveryRegistry +import app.k9mail.autodiscovery.api.AutoDiscoveryService +import app.k9mail.autodiscovery.service.RealAutoDiscoveryRegistry +import app.k9mail.autodiscovery.service.RealAutoDiscoveryService +import app.k9mail.feature.account.common.featureAccountCommonModule +import app.k9mail.feature.account.oauth.featureAccountOAuthModule +import app.k9mail.feature.account.server.settings.featureAccountServerSettingsModule +import app.k9mail.feature.account.server.validation.featureAccountServerValidationModule +import app.k9mail.feature.account.setup.domain.DomainContract +import app.k9mail.feature.account.setup.domain.usecase.CreateAccount +import app.k9mail.feature.account.setup.domain.usecase.GetAutoDiscovery +import app.k9mail.feature.account.setup.domain.usecase.GetSpecialFolderOptions +import app.k9mail.feature.account.setup.domain.usecase.ValidateSpecialFolderOptions +import app.k9mail.feature.account.setup.navigation.AccountSetupNavigation +import app.k9mail.feature.account.setup.navigation.DefaultAccountSetupNavigation +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryValidator +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryViewModel +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountViewModel +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsValidator +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsViewModel +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsViewModel +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersFormUiModel +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersViewModel +import com.fsck.k9.mail.folders.FolderFetcher +import com.fsck.k9.mail.store.imap.ImapFolderFetcher +import okhttp3.OkHttpClient +import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val featureAccountSetupModule: Module = module { + includes( + featureAccountCommonModule, + featureAccountOAuthModule, + featureAccountServerValidationModule, + featureAccountServerSettingsModule, + ) + + single { DefaultAccountSetupNavigation() } + + single { + OkHttpClient() + } + + single { + val extraAutoDiscoveries = get>(named("extraAutoDiscoveries")) + RealAutoDiscoveryRegistry( + autoDiscoveries = RealAutoDiscoveryRegistry.createDefaultAutoDiscoveries( + okHttpClient = get(), + ) + extraAutoDiscoveries, + ) + } + + single { + RealAutoDiscoveryService( + autoDiscoveryRegistry = get(), + ) + } + + single { + GetAutoDiscovery( + service = get(), + oauthProvider = get(), + ) + } + + factory { + CreateAccount( + accountCreator = get(), + ) + } + + factory { AccountAutoDiscoveryValidator() } + factory { DisplayOptionsValidator() } + + viewModel { + AccountAutoDiscoveryViewModel( + validator = get(), + getAutoDiscovery = get(), + accountStateRepository = get(), + oAuthViewModel = get(), + ) + } + + factory { + ImapFolderFetcher( + trustedSocketFactory = get(), + oAuth2TokenProviderFactory = get(), + clientInfoAppName = get(named("ClientInfoAppName")), + clientInfoAppVersion = get(named("ClientInfoAppVersion")), + ) + } + + factory { + GetSpecialFolderOptions( + folderFetcher = get(), + accountStateRepository = get(), + authStateStorage = get(), + ) + } + + factory { + ValidateSpecialFolderOptions() + } + + factory { + SpecialFoldersFormUiModel() + } + + viewModel { + SpecialFoldersViewModel( + formUiModel = get(), + getSpecialFolderOptions = get(), + validateSpecialFolderOptions = get(), + accountStateRepository = get(), + ) + } + + viewModel { + DisplayOptionsViewModel( + validator = get(), + accountStateRepository = get(), + accountOwnerNameProvider = get(), + ) + } + + viewModel { + SyncOptionsViewModel( + accountStateRepository = get(), + ) + } + + viewModel { + CreateAccountViewModel( + createAccount = get(), + accountStateRepository = get(), + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/AutoDiscoveryMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/AutoDiscoveryMapper.kt new file mode 100644 index 0000000..4c18faf --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/AutoDiscoveryMapper.kt @@ -0,0 +1,69 @@ +package app.k9mail.feature.account.setup.domain + +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.autodiscovery.api.OutgoingServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import app.k9mail.autodiscovery.demo.DemoServerSettings +import app.k9mail.feature.account.common.domain.entity.toAuthType +import app.k9mail.feature.account.common.domain.entity.toMailConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.toAuthenticationType +import app.k9mail.feature.account.setup.domain.entity.toConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings + +internal fun IncomingServerSettings.toServerSettings(password: String?): ServerSettings { + return when (this) { + is ImapServerSettings -> this.toImapServerSettings(password) + is DemoServerSettings -> this.serverSettings + + else -> throw IllegalArgumentException("Unknown server settings type: $this") + } +} + +private fun ImapServerSettings.toImapServerSettings(password: String?): ServerSettings { + return ServerSettings( + type = "imap", + host = hostname.value, + port = port.value, + connectionSecurity = connectionSecurity.toConnectionSecurity().toMailConnectionSecurity(), + authenticationType = authenticationTypes.first().toAuthenticationType().toAuthType(), + username = username, + password = password, + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = true, + pathPrefix = null, + useCompression = true, + sendClientInfo = true, + ), + ) +} + +/** + * Convert [OutgoingServerSettings] to [ServerSettings]. + * + * @throws IllegalArgumentException if the server settings type is unknown. + */ +internal fun OutgoingServerSettings.toServerSettings(password: String?): ServerSettings { + return when (this) { + is SmtpServerSettings -> this.toSmtpServerSettings(password) + is DemoServerSettings -> this.serverSettings + + else -> throw IllegalArgumentException("Unknown server settings type: $this") + } +} + +private fun SmtpServerSettings.toSmtpServerSettings(password: String?): ServerSettings { + return ServerSettings( + type = "smtp", + host = hostname.value, + port = port.value, + connectionSecurity = connectionSecurity.toConnectionSecurity().toMailConnectionSecurity(), + authenticationType = authenticationTypes.first().toAuthenticationType().toAuthType(), + username = username, + password = password, + clientCertificateAlias = null, + extra = emptyMap(), + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/DomainContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/DomainContract.kt new file mode 100644 index 0000000..e2f932d --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/DomainContract.kt @@ -0,0 +1,53 @@ +package app.k9mail.feature.account.setup.domain + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +interface DomainContract { + + interface UseCase { + fun interface GetAutoDiscovery { + suspend fun execute(emailAddress: String): AutoDiscoveryResult + } + + fun interface CreateAccount { + suspend fun execute(accountState: AccountState): AccountCreatorResult + } + + fun interface ValidateEmailAddress { + fun execute(emailAddress: String): ValidationResult + } + + fun interface ValidateConfigurationApproval { + fun execute(isApproved: Boolean?, isAutoDiscoveryTrusted: Boolean?): ValidationResult + } + + fun interface ValidateAccountName { + fun execute(accountName: String): ValidationResult + } + + fun interface ValidateDisplayName { + fun execute(displayName: String): ValidationResult + } + + fun interface ValidateEmailSignature { + fun execute(emailSignature: String): ValidationResult + } + + fun interface GetSpecialFolderOptions { + suspend operator fun invoke(): SpecialFolderOptions + } + + fun interface ValidateSpecialFolderOptions { + operator fun invoke(specialFolderOptions: SpecialFolderOptions): ValidationResult + + sealed interface Failure : ValidationError { + data object MissingDefaultSpecialFolderOption : Failure + } + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AccountUuid.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AccountUuid.kt new file mode 100644 index 0000000..a71091e --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AccountUuid.kt @@ -0,0 +1,4 @@ +package app.k9mail.feature.account.setup.domain.entity + +@JvmInline +value class AccountUuid(val value: String) diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryAuthenticationType.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryAuthenticationType.kt new file mode 100644 index 0000000..ae50f98 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryAuthenticationType.kt @@ -0,0 +1,13 @@ +package app.k9mail.feature.account.setup.domain.entity + +import app.k9mail.feature.account.common.domain.entity.AuthenticationType + +typealias AutoDiscoveryAuthenticationType = app.k9mail.autodiscovery.api.AuthenticationType + +internal fun AutoDiscoveryAuthenticationType.toAuthenticationType(): AuthenticationType { + return when (this) { + AutoDiscoveryAuthenticationType.PasswordCleartext -> AuthenticationType.PasswordCleartext + AutoDiscoveryAuthenticationType.PasswordEncrypted -> AuthenticationType.PasswordEncrypted + AutoDiscoveryAuthenticationType.OAuth2 -> AuthenticationType.OAuth2 + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryConnectionSecurity.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryConnectionSecurity.kt new file mode 100644 index 0000000..a5b480e --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryConnectionSecurity.kt @@ -0,0 +1,12 @@ +package app.k9mail.feature.account.setup.domain.entity + +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity + +internal typealias AutoDiscoveryConnectionSecurity = app.k9mail.autodiscovery.api.ConnectionSecurity + +internal fun AutoDiscoveryConnectionSecurity.toConnectionSecurity(): ConnectionSecurity { + return when (this) { + AutoDiscoveryConnectionSecurity.StartTLS -> ConnectionSecurity.StartTLS + AutoDiscoveryConnectionSecurity.TLS -> ConnectionSecurity.TLS + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/EmailCheckFrequency.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/EmailCheckFrequency.kt new file mode 100644 index 0000000..6b75292 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/EmailCheckFrequency.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.account.setup.domain.entity + +import kotlinx.collections.immutable.toImmutableList + +@Suppress("MagicNumber") +enum class EmailCheckFrequency( + val minutes: Int, +) { + MANUAL(-1), + EVERY_15_MINUTES(15), + EVERY_30_MINUTES(30), + EVERY_HOUR(1.fromHour()), + EVERY_2_HOURS(2.fromHour()), + EVERY_3_HOURS(3.fromHour()), + EVERY_6_HOURS(6.fromHour()), + EVERY_12_HOURS(12.fromHour()), + EVERY_24_HOURS(24.fromHour()), + ; + + companion object { + val DEFAULT = EVERY_HOUR + fun all() = entries.toImmutableList() + + fun fromMinutes(minutes: Int): EmailCheckFrequency { + return all().find { it.minutes == minutes } ?: DEFAULT + } + } +} + +@Suppress("MagicNumber") +private fun Int.fromHour(): Int { + return 60 * this +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/EmailDisplayCount.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/EmailDisplayCount.kt new file mode 100644 index 0000000..20ac66f --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/EmailDisplayCount.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.setup.domain.entity + +import kotlinx.collections.immutable.toImmutableList + +@Suppress("MagicNumber") +enum class EmailDisplayCount( + val count: Int, +) { + MESSAGES_10(10), + MESSAGES_25(25), + MESSAGES_50(50), + MESSAGES_100(100), + MESSAGES_250(250), + MESSAGES_500(500), + MESSAGES_1000(1000), + ; + + companion object { + val DEFAULT = MESSAGES_100 + fun all() = entries.toImmutableList() + + fun fromCount(count: Int): EmailDisplayCount { + return all().find { it.count == count } ?: DEFAULT + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingServerSettingsExtension.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingServerSettingsExtension.kt new file mode 100644 index 0000000..646b71d --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingServerSettingsExtension.kt @@ -0,0 +1,12 @@ +package app.k9mail.feature.account.setup.domain.entity + +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType + +internal fun IncomingServerSettings.toIncomingProtocolType(): IncomingProtocolType { + when (this) { + is ImapServerSettings -> return IncomingProtocolType.IMAP + else -> throw IllegalArgumentException("Unsupported incoming server settings type: $this") + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/CreateAccount.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/CreateAccount.kt new file mode 100644 index 0000000..ce37312 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/CreateAccount.kt @@ -0,0 +1,44 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.common.domain.entity.Account +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import java.util.UUID + +class CreateAccount( + private val accountCreator: AccountCreator, + private val uuidGenerator: () -> String = { UUID.randomUUID().toString() }, +) : UseCase.CreateAccount { + override suspend fun execute(accountState: AccountState): AccountCreatorResult { + val account = Account( + uuid = uuidGenerator(), + emailAddress = accountState.emailAddress!!, + incomingServerSettings = accountState.incomingServerSettings!!.copy(), + outgoingServerSettings = accountState.outgoingServerSettings!!.copy(), + authorizationState = accountState.authorizationState?.value, + specialFolderSettings = accountState.specialFolderSettings, + options = mapOptions(accountState.displayOptions!!, accountState.syncOptions!!), + ) + + return accountCreator.createAccount(account) + } + + private fun mapOptions( + displayOptions: AccountDisplayOptions, + syncOptions: AccountSyncOptions, + ): AccountOptions { + return AccountOptions( + accountName = displayOptions.accountName, + displayName = displayOptions.displayName, + emailSignature = displayOptions.emailSignature, + checkFrequencyInMinutes = syncOptions.checkFrequencyInMinutes, + messageDisplayCount = syncOptions.messageDisplayCount, + showNotification = syncOptions.showNotification, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscovery.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscovery.kt new file mode 100644 index 0000000..66188cb --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscovery.kt @@ -0,0 +1,81 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.autodiscovery.api.AuthenticationType +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryService +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import app.k9mail.autodiscovery.demo.DemoServerSettings +import app.k9mail.feature.account.setup.domain.DomainContract +import net.thunderbird.core.common.mail.toUserEmailAddress +import net.thunderbird.core.common.oauth.OAuthConfigurationProvider + +internal class GetAutoDiscovery( + private val service: AutoDiscoveryService, + private val oauthProvider: OAuthConfigurationProvider, +) : DomainContract.UseCase.GetAutoDiscovery { + override suspend fun execute(emailAddress: String): AutoDiscoveryResult { + val email = emailAddress.toUserEmailAddress() + + val result = service.discover(email) + + return if (result is AutoDiscoveryResult.Settings) { + if (result.incomingServerSettings is DemoServerSettings) { + return result + } else { + validateOAuthSupport(result) + } + } else { + result + } + } + + private fun validateOAuthSupport(settings: AutoDiscoveryResult.Settings): AutoDiscoveryResult { + if (settings.incomingServerSettings !is ImapServerSettings || + settings.outgoingServerSettings !is SmtpServerSettings + ) { + return AutoDiscoveryResult.NoUsableSettingsFound + } + + val incomingServerSettings = settings.incomingServerSettings as ImapServerSettings + val outgoingServerSettings = settings.outgoingServerSettings as SmtpServerSettings + + val incomingAuthenticationTypes = cleanAuthenticationTypes( + authenticationTypes = incomingServerSettings.authenticationTypes, + hostname = incomingServerSettings.hostname.value, + ) + val outgoingAuthenticationTypes = cleanAuthenticationTypes( + authenticationTypes = outgoingServerSettings.authenticationTypes, + hostname = outgoingServerSettings.hostname.value, + ) + + return if (incomingAuthenticationTypes.isNotEmpty() && outgoingAuthenticationTypes.isNotEmpty()) { + settings.copy( + incomingServerSettings = incomingServerSettings.copy( + authenticationTypes = incomingAuthenticationTypes, + ), + outgoingServerSettings = outgoingServerSettings.copy( + authenticationTypes = outgoingAuthenticationTypes, + ), + ) + } else { + AutoDiscoveryResult.NoUsableSettingsFound + } + } + + private fun cleanAuthenticationTypes( + authenticationTypes: List, + hostname: String, + ): List { + return if (AuthenticationType.OAuth2 in authenticationTypes && !isOAuthSupportedFor(hostname)) { + // OAuth2 is not supported for this hostname; remove it from the list of supported authentication types + authenticationTypes.filter { it != AuthenticationType.OAuth2 } + } else { + authenticationTypes + } + } + + private fun isOAuthSupportedFor(hostname: String): Boolean { + return oauthProvider.getConfiguration(hostname) != null + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetSpecialFolderOptions.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetSpecialFolderOptions.kt new file mode 100644 index 0000000..42654b4 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetSpecialFolderOptions.kt @@ -0,0 +1,84 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.folders.FolderFetcher +import com.fsck.k9.mail.folders.RemoteFolder +import com.fsck.k9.mail.oauth.AuthStateStorage +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class GetSpecialFolderOptions( + private val folderFetcher: FolderFetcher, + private val accountStateRepository: AccountDomainContract.AccountStateRepository, + private val authStateStorage: AuthStateStorage, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : UseCase.GetSpecialFolderOptions { + override suspend fun invoke(): SpecialFolderOptions { + return withContext(coroutineDispatcher) { + val serverSettings = accountStateRepository.getState().incomingServerSettings + ?: error("No incoming server settings available") + + val remoteFolders = folderFetcher.getFolders(serverSettings, authStateStorage) + .sortedWith( + compareByDescending { it.type == FolderType.INBOX } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }, + ) + + SpecialFolderOptions( + archiveSpecialFolderOptions = mapByFolderType(FolderType.ARCHIVE, remoteFolders), + draftsSpecialFolderOptions = mapByFolderType(FolderType.DRAFTS, remoteFolders), + sentSpecialFolderOptions = mapByFolderType(FolderType.SENT, remoteFolders), + spamSpecialFolderOptions = mapByFolderType(FolderType.SPAM, remoteFolders), + trashSpecialFolderOptions = mapByFolderType(FolderType.TRASH, remoteFolders), + ) + } + } + + private fun mapByFolderType( + folderType: FolderType, + remoteFolders: List, + ): List { + val automaticFolder = selectAutomaticFolderByType(folderType, remoteFolders) + val folders = remoteFolders.map { remoteFolder -> + getFolderByType(remoteFolder) + } + + return (listOf(automaticFolder, SpecialFolderOption.None()) + folders) + } + + // This uses the same implementation as the SpecialFolderSelectionStrategy. In case the implementation of the + // SpecialFolderSelectionStrategy changes, this use case should be updated accordingly. + private fun selectAutomaticFolderByType( + folderType: FolderType, + remoteFolders: List, + ): SpecialFolderOption = remoteFolders.firstOrNull { folder -> folder.type == folderType } + ?.let { + getFolderByType( + remoteFolder = it, + isAutomatic = true, + ) + } ?: SpecialFolderOption.None(isAutomatic = true) + + private fun getFolderByType( + remoteFolder: RemoteFolder, + isAutomatic: Boolean = false, + ): SpecialFolderOption { + return when (remoteFolder.type) { + FolderType.INBOX, + FolderType.OUTBOX, + FolderType.ARCHIVE, + FolderType.DRAFTS, + FolderType.SENT, + FolderType.SPAM, + FolderType.TRASH, + -> SpecialFolderOption.Special(isAutomatic, remoteFolder) + + FolderType.REGULAR -> SpecialFolderOption.Regular(remoteFolder) + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateAccountName.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateAccountName.kt new file mode 100644 index 0000000..3dcbbae --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateAccountName.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.DomainContract +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class ValidateAccountName : DomainContract.UseCase.ValidateAccountName { + override fun execute(accountName: String): ValidationResult { + return when { + accountName.isEmpty() -> ValidationResult.Success + accountName.isBlank() -> ValidationResult.Failure(ValidateAccountNameError.BlankAccountName) + else -> ValidationResult.Success + } + } + + sealed interface ValidateAccountNameError : ValidationError { + data object BlankAccountName : ValidateAccountNameError + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateConfigurationApproval.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateConfigurationApproval.kt new file mode 100644 index 0000000..04940e3 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateConfigurationApproval.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class ValidateConfigurationApproval : UseCase.ValidateConfigurationApproval { + override fun execute(isApproved: Boolean?, isAutoDiscoveryTrusted: Boolean?): ValidationResult { + return if (isApproved == null && isAutoDiscoveryTrusted == null) { + ValidationResult.Success + } else if (isAutoDiscoveryTrusted == true) { + ValidationResult.Success + } else if (isApproved == true) { + ValidationResult.Success + } else { + ValidationResult.Failure(ValidateConfigurationApprovalError.ApprovalRequired) + } + } + + sealed interface ValidateConfigurationApprovalError : ValidationError { + data object ApprovalRequired : ValidateConfigurationApprovalError + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateDisplayName.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateDisplayName.kt new file mode 100644 index 0000000..986c42f --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateDisplayName.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.DomainContract +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class ValidateDisplayName : DomainContract.UseCase.ValidateDisplayName { + + override fun execute(displayName: String): ValidationResult { + return when { + displayName.isBlank() -> ValidationResult.Failure(ValidateDisplayNameError.EmptyDisplayName) + else -> ValidationResult.Success + } + } + + sealed interface ValidateDisplayNameError : ValidationError { + data object EmptyDisplayName : ValidateDisplayNameError + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailAddress.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailAddress.kt new file mode 100644 index 0000000..462976c --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailAddress.kt @@ -0,0 +1,73 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.common.mail.EmailAddressParserError +import net.thunderbird.core.common.mail.EmailAddressParserException +import net.thunderbird.core.common.mail.toEmailAddressOrNull +import net.thunderbird.core.common.mail.toUserEmailAddress +import net.thunderbird.core.logging.legacy.Log + +/** + * Validate an email address that the user wants to add to an account. + * + * This only allows a subset of all valid email addresses. We currently don't support international email addresses + * and don't allow quoted local parts, or email addresses exceeding length restrictions. + * + * Note: Do NOT use this to validate recipients in incoming or outgoing messages. Use [String.toEmailAddressOrNull] + * instead. + */ +class ValidateEmailAddress : UseCase.ValidateEmailAddress { + + override fun execute(emailAddress: String): ValidationResult { + if (emailAddress.isBlank()) { + return ValidationResult.Failure(ValidateEmailAddressError.EmptyEmailAddress) + } + + return try { + val parsedEmailAddress = emailAddress.toUserEmailAddress() + + if (parsedEmailAddress.warnings.isEmpty()) { + ValidationResult.Success + } else { + ValidationResult.Failure(ValidateEmailAddressError.NotAllowed) + } + } catch (e: EmailAddressParserException) { + Log.v(e, "Error parsing email address: %s", emailAddress) + + val validationError = when (e.error) { + EmailAddressParserError.AddressLiteralsNotSupported, + EmailAddressParserError.LocalPartLengthExceeded, + EmailAddressParserError.DnsLabelLengthExceeded, + EmailAddressParserError.DomainLengthExceeded, + EmailAddressParserError.TotalLengthExceeded, + EmailAddressParserError.QuotedStringInLocalPart, + EmailAddressParserError.LocalPartRequiresQuotedString, + EmailAddressParserError.EmptyLocalPart, + -> { + ValidateEmailAddressError.NotAllowed + } + + else -> { + if ('@' in emailAddress) { + // We currently don't support or recognize international email addresses. So if the string + // contains an "@" character, we assume it's a valid email address that we don't support. + ValidateEmailAddressError.InvalidOrNotSupported + } else { + ValidateEmailAddressError.InvalidEmailAddress + } + } + } + + ValidationResult.Failure(validationError) + } + } + + sealed interface ValidateEmailAddressError : ValidationError { + data object EmptyEmailAddress : ValidateEmailAddressError + data object NotAllowed : ValidateEmailAddressError + data object InvalidOrNotSupported : ValidateEmailAddressError + data object InvalidEmailAddress : ValidateEmailAddressError + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailSignature.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailSignature.kt new file mode 100644 index 0000000..1708034 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailSignature.kt @@ -0,0 +1,22 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.DomainContract +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature.ValidateEmailSignatureError.BlankEmailSignature +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +// TODO check signature for input validity +internal class ValidateEmailSignature : DomainContract.UseCase.ValidateEmailSignature { + + override fun execute(emailSignature: String): ValidationResult { + return when { + emailSignature.isEmpty() -> ValidationResult.Success + emailSignature.isBlank() -> ValidationResult.Failure(error = BlankEmailSignature) + else -> ValidationResult.Success + } + } + + sealed interface ValidateEmailSignatureError : ValidationError { + data object BlankEmailSignature : ValidateEmailSignatureError + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateSpecialFolderOptions.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateSpecialFolderOptions.kt new file mode 100644 index 0000000..bba4a41 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateSpecialFolderOptions.kt @@ -0,0 +1,29 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.ValidateSpecialFolderOptions.Failure +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class ValidateSpecialFolderOptions : UseCase.ValidateSpecialFolderOptions { + override fun invoke(specialFolderOptions: SpecialFolderOptions): ValidationResult { + return if (specialFolderOptions.hasMissingDefaultOption()) { + ValidationResult.Failure(error = Failure.MissingDefaultSpecialFolderOption) + } else { + ValidationResult.Success + } + } + + private fun SpecialFolderOptions.hasMissingDefaultOption(): Boolean { + return archiveSpecialFolderOptions.hasMissingDefaultFolder() || + draftsSpecialFolderOptions.hasMissingDefaultFolder() || + sentSpecialFolderOptions.hasMissingDefaultFolder() || + spamSpecialFolderOptions.hasMissingDefaultFolder() || + trashSpecialFolderOptions.hasMissingDefaultFolder() + } + + private fun List.hasMissingDefaultFolder(): Boolean { + return first() is SpecialFolderOption.None + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupNavHost.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupNavHost.kt new file mode 100644 index 0000000..a8ab676 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupNavHost.kt @@ -0,0 +1,186 @@ +package app.k9mail.feature.account.setup.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsScreen +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsViewModel +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsScreen +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsViewModel +import app.k9mail.feature.account.server.validation.ui.IncomingServerValidationViewModel +import app.k9mail.feature.account.server.validation.ui.OutgoingServerValidationViewModel +import app.k9mail.feature.account.server.validation.ui.ServerValidationScreen +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryScreen +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryViewModel +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountScreen +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountViewModel +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsScreen +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsViewModel +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsScreen +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsViewModel +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersScreen +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject + +private const val NESTED_NAVIGATION_AUTO_CONFIG = "autoconfig" +private const val NESTED_NAVIGATION_INCOMING_SERVER_CONFIG = "incoming-server/config" +private const val NESTED_NAVIGATION_INCOMING_SERVER_VALIDATION = "incoming-server/validation" +private const val NESTED_NAVIGATION_OUTGOING_SERVER_CONFIG = "outgoing-server/config" +private const val NESTED_NAVIGATION_OUTGOING_SERVER_VALIDATION = "outgoing-server/validation" +private const val NESTED_NAVIGATION_SPECIAL_FOLDERS = "special-folders" +private const val NESTED_NAVIGATION_DISPLAY_OPTIONS = "display-options" +private const val NESTED_NAVIGATION_SYNC_OPTIONS = "sync-options" +private const val NESTED_NAVIGATION_CREATE_ACCOUNT = "create-account" + +@Suppress("LongMethod") +@Composable +fun AccountSetupNavHost( + onBack: () -> Unit, + onFinish: (AccountSetupRoute) -> Unit, +) { + val navController = rememberNavController() + var isAutomaticConfig by rememberSaveable { mutableStateOf(false) } + var hasSpecialFolders by rememberSaveable { mutableStateOf(false) } + + NavHost( + navController = navController, + startDestination = NESTED_NAVIGATION_AUTO_CONFIG, + ) { + composable(route = NESTED_NAVIGATION_AUTO_CONFIG) { + AccountAutoDiscoveryScreen( + onNext = { result -> + isAutomaticConfig = result.isAutomaticConfig + if (isAutomaticConfig) { + hasSpecialFolders = checkSpecialFoldersSupport(result.incomingProtocolType) + navController.navigate(NESTED_NAVIGATION_INCOMING_SERVER_VALIDATION) + } else { + navController.navigate(NESTED_NAVIGATION_INCOMING_SERVER_CONFIG) + } + }, + onBack = onBack, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } + + composable(route = NESTED_NAVIGATION_INCOMING_SERVER_CONFIG) { + IncomingServerSettingsScreen( + onNext = { state -> + hasSpecialFolders = checkSpecialFoldersSupport(state.protocolType) + navController.navigate(NESTED_NAVIGATION_INCOMING_SERVER_VALIDATION) + }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + ) + } + + composable(route = NESTED_NAVIGATION_INCOMING_SERVER_VALIDATION) { + ServerValidationScreen( + onNext = { + if (isAutomaticConfig) { + navController.navigate(NESTED_NAVIGATION_OUTGOING_SERVER_VALIDATION) { + popUpTo(NESTED_NAVIGATION_AUTO_CONFIG) + } + } else { + navController.navigate(NESTED_NAVIGATION_OUTGOING_SERVER_CONFIG) { + popUpTo(NESTED_NAVIGATION_INCOMING_SERVER_CONFIG) + } + } + }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } + + composable(route = NESTED_NAVIGATION_OUTGOING_SERVER_CONFIG) { + OutgoingServerSettingsScreen( + onNext = { navController.navigate(NESTED_NAVIGATION_OUTGOING_SERVER_VALIDATION) }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + ) + } + + composable(route = NESTED_NAVIGATION_OUTGOING_SERVER_VALIDATION) { + ServerValidationScreen( + onNext = { + navController.navigate( + if (hasSpecialFolders) { + NESTED_NAVIGATION_SPECIAL_FOLDERS + } else { + NESTED_NAVIGATION_DISPLAY_OPTIONS + }, + ) { + if (isAutomaticConfig) { + popUpTo(NESTED_NAVIGATION_AUTO_CONFIG) + } else { + popUpTo(NESTED_NAVIGATION_OUTGOING_SERVER_CONFIG) + } + } + }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } + + composable(route = NESTED_NAVIGATION_SPECIAL_FOLDERS) { + SpecialFoldersScreen( + onNext = { isManualSetup -> + navController.navigate(NESTED_NAVIGATION_DISPLAY_OPTIONS) { + if (isManualSetup) { + popUpTo(NESTED_NAVIGATION_SPECIAL_FOLDERS) + } else { + if (isAutomaticConfig) { + popUpTo(NESTED_NAVIGATION_AUTO_CONFIG) + } else { + popUpTo(NESTED_NAVIGATION_OUTGOING_SERVER_CONFIG) + } + } + } + }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } + + composable(route = NESTED_NAVIGATION_DISPLAY_OPTIONS) { + DisplayOptionsScreen( + onNext = { navController.navigate(NESTED_NAVIGATION_SYNC_OPTIONS) }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } + + composable(route = NESTED_NAVIGATION_SYNC_OPTIONS) { + SyncOptionsScreen( + onNext = { navController.navigate(NESTED_NAVIGATION_CREATE_ACCOUNT) }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } + + composable(route = NESTED_NAVIGATION_CREATE_ACCOUNT) { + CreateAccountScreen( + onNext = { accountUuid -> onFinish(AccountSetupRoute.AccountSetup(accountUuid.value)) }, + onBack = { navController.popBackStack() }, + viewModel = koinViewModel(), + brandNameProvider = koinInject(), + ) + } + } +} + +internal fun checkSpecialFoldersSupport(protocolType: IncomingProtocolType?): Boolean { + return protocolType == IncomingProtocolType.IMAP +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupNavigation.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupNavigation.kt new file mode 100644 index 0000000..149ce7d --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupNavigation.kt @@ -0,0 +1,5 @@ +package app.k9mail.feature.account.setup.navigation + +import app.k9mail.core.ui.compose.navigation.Navigation + +interface AccountSetupNavigation : Navigation diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupRoute.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupRoute.kt new file mode 100644 index 0000000..6a7e27b --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/AccountSetupRoute.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.setup.navigation + +import app.k9mail.core.ui.compose.navigation.Route +import kotlinx.serialization.Serializable + +sealed interface AccountSetupRoute : Route { + + @Serializable + data class AccountSetup( + val accountId: String? = null, + ) : AccountSetupRoute { + override val basePath: String = BASE_PATH + + override fun route(): String = basePath + + companion object { + const val BASE_PATH = ACCOUNT_SETUP_BASE_PATH + } + } + + companion object { + const val ACCOUNT_SETUP_BASE_PATH = "app://account/setup" + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/DefaultAccountSetupNavigation.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/DefaultAccountSetupNavigation.kt new file mode 100644 index 0000000..26ff2bd --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/navigation/DefaultAccountSetupNavigation.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.setup.navigation + +import androidx.navigation.NavGraphBuilder +import app.k9mail.core.ui.compose.navigation.deepLinkComposable +import app.k9mail.feature.account.setup.navigation.AccountSetupRoute.AccountSetup + +class DefaultAccountSetupNavigation : AccountSetupNavigation { + + override fun registerRoutes( + navGraphBuilder: NavGraphBuilder, + onBack: () -> Unit, + onFinish: (AccountSetupRoute) -> Unit, + ) { + with(navGraphBuilder) { + deepLinkComposable( + basePath = AccountSetup.BASE_PATH, + ) { + AccountSetupNavHost( + onBack = onBack, + onFinish = onFinish, + ) + } + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContent.kt new file mode 100644 index 0000000..04f443d --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContent.kt @@ -0,0 +1,179 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import android.content.res.Resources +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.ContentLoadingErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView +import app.k9mail.core.ui.compose.designsystem.molecule.input.EmailAddressInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.PasswordInput +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.ui.AppTitleTopHeader +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.common.ui.WizardNavigationBarState +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.AccountOAuthView +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State +import app.k9mail.feature.account.setup.ui.autodiscovery.view.AutoDiscoveryResultApprovalView +import app.k9mail.feature.account.setup.ui.autodiscovery.view.AutoDiscoveryResultView +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +internal fun AccountAutoDiscoveryContent( + state: State, + onEvent: (Event) -> Unit, + oAuthViewModel: AccountOAuthContract.ViewModel, + brandName: String, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + ResponsiveWidthContainer( + modifier = Modifier + .fillMaxSize() + .testTagAsResourceId("AccountAutoDiscoveryContent") + .then(modifier), + ) { paddingValues -> + Column( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(scrollState) + .padding(paddingValues) + .imePadding(), + ) { + AppTitleTopHeader( + title = brandName, + ) + Spacer(modifier = Modifier.weight(1f)) + AutoDiscoveryContent( + state = state, + onEvent = onEvent, + oAuthViewModel = oAuthViewModel, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + WizardNavigationBar( + onNextClick = { onEvent(Event.OnNextClicked) }, + onBackClick = { onEvent(Event.OnBackClicked) }, + state = WizardNavigationBarState(showNext = state.isNextButtonVisible), + ) + } + } +} + +@Composable +internal fun AutoDiscoveryContent( + state: State, + onEvent: (Event) -> Unit, + oAuthViewModel: AccountOAuthContract.ViewModel, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + ContentLoadingErrorView( + state = state, + loading = { + LoadingView( + message = stringResource(id = R.string.account_setup_auto_discovery_loading_message), + modifier = Modifier.fillMaxSize(), + ) + }, + error = { error -> + ErrorView( + title = stringResource(id = R.string.account_setup_auto_discovery_loading_error), + message = error.toAutoDiscoveryErrorString(resources), + onRetry = { onEvent(Event.OnRetryClicked) }, + modifier = Modifier.fillMaxSize(), + ) + }, + content = { contentState -> + @Suppress("ViewModelForwarding") + ContentView( + state = contentState, + onEvent = onEvent, + oAuthViewModel = oAuthViewModel, + resources = resources, + ) + }, + modifier = Modifier + .fillMaxSize() + .then(modifier), + ) +} + +@Composable +internal fun ContentView( + state: State, + onEvent: (Event) -> Unit, + oAuthViewModel: AccountOAuthContract.ViewModel, + resources: Resources, + modifier: Modifier = Modifier, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(MainTheme.spacings.quadruple) + .then(modifier), + ) { + if (state.configStep != AccountAutoDiscoveryContract.ConfigStep.EMAIL_ADDRESS) { + AutoDiscoveryResultView( + settings = state.autoDiscoverySettings, + onEditConfigurationClick = { onEvent(Event.OnEditConfigurationClicked) }, + ) + if (state.autoDiscoverySettings != null && state.autoDiscoverySettings.isTrusted.not()) { + AutoDiscoveryResultApprovalView( + approvalState = state.configurationApproved, + onApprovalChange = { onEvent(Event.ResultApprovalChanged(it)) }, + ) + } + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + } + + EmailAddressInput( + emailAddress = state.emailAddress.value, + errorMessage = state.emailAddress.error?.toAutoDiscoveryValidationErrorString(resources), + onEmailAddressChange = { onEvent(Event.EmailAddressChanged(it)) }, + contentPadding = PaddingValues(), + modifier = Modifier.testTagAsResourceId("account_setup_email_address_input"), + ) + + if (state.configStep == AccountAutoDiscoveryContract.ConfigStep.PASSWORD) { + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + PasswordInput( + password = state.password.value, + errorMessage = state.password.error?.toAutoDiscoveryValidationErrorString(resources), + onPasswordChange = { onEvent(Event.PasswordChanged(it)) }, + contentPadding = PaddingValues(), + modifier = Modifier.testTagAsResourceId("account_setup_password_input"), + ) + } else if (state.configStep == AccountAutoDiscoveryContract.ConfigStep.OAUTH) { + val isAutoDiscoverySettingsTrusted = state.autoDiscoverySettings?.isTrusted ?: false + val isConfigurationApproved = state.configurationApproved.value ?: false + Spacer(modifier = Modifier.height(MainTheme.spacings.double)) + AccountOAuthView( + onOAuthResult = { result -> onEvent(Event.OnOAuthResult(result)) }, + viewModel = oAuthViewModel, + isEnabled = isAutoDiscoverySettingsTrusted || isConfigurationApproved, + ) + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContract.kt new file mode 100644 index 0000000..7dbdc6f --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryContract.kt @@ -0,0 +1,79 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingErrorState +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.input.BooleanInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.oauth.domain.entity.OAuthResult +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +interface AccountAutoDiscoveryContract { + + enum class ConfigStep { + EMAIL_ADDRESS, + OAUTH, + PASSWORD, + MANUAL_SETUP, + } + + interface ViewModel : UnidirectionalViewModel { + val oAuthViewModel: AccountOAuthContract.ViewModel + + fun initState(state: State) + } + + data class State( + val configStep: ConfigStep = ConfigStep.EMAIL_ADDRESS, + val emailAddress: StringInputField = StringInputField(), + val password: StringInputField = StringInputField(), + val autoDiscoverySettings: AutoDiscoveryResult.Settings? = null, + val configurationApproved: BooleanInputField = BooleanInputField(), + val authorizationState: AuthorizationState? = null, + + val isSuccess: Boolean = false, + override val error: Error? = null, + override val isLoading: Boolean = false, + + val isNextButtonVisible: Boolean = true, + ) : LoadingErrorState + + sealed interface Event { + data class EmailAddressChanged(val emailAddress: String) : Event + data class PasswordChanged(val password: String) : Event + data class ResultApprovalChanged(val confirmed: Boolean) : Event + data class OnOAuthResult(val result: OAuthResult) : Event + + data object OnNextClicked : Event + data object OnBackClicked : Event + data object OnRetryClicked : Event + data object OnEditConfigurationClicked : Event + } + + sealed class Effect { + data class NavigateNext( + val result: AutoDiscoveryUiResult, + ) : Effect() + + data object NavigateBack : Effect() + } + + interface Validator { + fun validateEmailAddress(emailAddress: String): ValidationResult + fun validatePassword(password: String): ValidationResult + fun validateConfigurationApproval(isApproved: Boolean?, isAutoDiscoveryTrusted: Boolean?): ValidationResult + } + + sealed interface Error { + data object NetworkError : Error + data object UnknownError : Error + } + + data class AutoDiscoveryUiResult( + val isAutomaticConfig: Boolean, + val incomingProtocolType: IncomingProtocolType?, + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreen.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreen.kt new file mode 100644 index 0000000..9e1aa4d --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreen.kt @@ -0,0 +1,39 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.AutoDiscoveryUiResult +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ViewModel +import net.thunderbird.core.common.provider.BrandNameProvider + +@Composable +internal fun AccountAutoDiscoveryScreen( + onNext: (AutoDiscoveryUiResult) -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.NavigateBack -> onBack() + is Effect.NavigateNext -> onNext(effect.result) + } + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + AccountAutoDiscoveryContent( + state = state.value, + onEvent = { dispatch(it) }, + oAuthViewModel = viewModel.oAuthViewModel, + brandName = brandNameProvider.brandName, + modifier = modifier, + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateMapper.kt new file mode 100644 index 0000000..53c4435 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateMapper.kt @@ -0,0 +1,74 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract +import app.k9mail.feature.account.setup.domain.entity.toAuthenticationType +import app.k9mail.feature.account.setup.domain.entity.toConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.toIncomingProtocolType +import app.k9mail.feature.account.setup.domain.toServerSettings +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract + +internal fun AccountAutoDiscoveryContract.State.toAccountState(): AccountState { + return AccountState( + emailAddress = emailAddress.value, + incomingServerSettings = autoDiscoverySettings?.incomingServerSettings?.toServerSettings(password.value), + outgoingServerSettings = autoDiscoverySettings?.outgoingServerSettings?.toServerSettings(password.value), + authorizationState = authorizationState, + displayOptions = null, + syncOptions = null, + ) +} + +internal fun AccountAutoDiscoveryContract.State.toIncomingConfigState(): IncomingServerSettingsContract.State { + val incomingSettings = autoDiscoverySettings?.incomingServerSettings as? ImapServerSettings? + return if (incomingSettings == null) { + IncomingServerSettingsContract.State( + username = StringInputField(value = emailAddress.value), + password = StringInputField(value = password.value), + ) + } else { + IncomingServerSettingsContract.State( + protocolType = incomingSettings.toIncomingProtocolType(), + server = StringInputField(value = incomingSettings.hostname.value), + security = incomingSettings.connectionSecurity.toConnectionSecurity(), + port = NumberInputField(value = incomingSettings.port.value.toLong()), + authenticationType = incomingSettings.authenticationTypes.first().toAuthenticationType(), + username = StringInputField(value = incomingSettings.username), + password = StringInputField(value = password.value), + imapAutodetectNamespaceEnabled = true, + imapPrefix = StringInputField(value = ""), + imapUseCompression = true, + imapSendClientInfo = true, + ) + } +} + +internal fun AccountAutoDiscoveryContract.State.toOutgoingConfigState(): OutgoingServerSettingsContract.State { + val outgoingSettings = autoDiscoverySettings?.outgoingServerSettings as? SmtpServerSettings? + return if (outgoingSettings == null) { + OutgoingServerSettingsContract.State( + username = StringInputField(value = emailAddress.value), + password = StringInputField(value = password.value), + ) + } else { + OutgoingServerSettingsContract.State( + server = StringInputField(value = outgoingSettings.hostname.value), + security = outgoingSettings.connectionSecurity.toConnectionSecurity(), + port = NumberInputField(value = outgoingSettings.port.value.toLong()), + authenticationType = outgoingSettings.authenticationTypes.first().toAuthenticationType(), + username = StringInputField(value = outgoingSettings.username), + password = StringInputField(value = password.value), + ) + } +} + +internal fun AccountAutoDiscoveryContract.State.toOptionsState(): DisplayOptionsContract.State { + return DisplayOptionsContract.State( + accountName = StringInputField(value = emailAddress.value), + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryValidator.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryValidator.kt new file mode 100644 index 0000000..afa6cf2 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryValidator.kt @@ -0,0 +1,30 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePassword +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import app.k9mail.feature.account.setup.domain.usecase.ValidateConfigurationApproval +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailAddress +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import app.k9mail.feature.account.server.settings.domain.ServerSettingsDomainContract.UseCase as ServerSettingsUseCase + +internal class AccountAutoDiscoveryValidator( + private val emailAddressValidator: UseCase.ValidateEmailAddress = ValidateEmailAddress(), + private val passwordValidator: ServerSettingsUseCase.ValidatePassword = ValidatePassword(), + private val configurationApprovalValidator: UseCase.ValidateConfigurationApproval = ValidateConfigurationApproval(), +) : AccountAutoDiscoveryContract.Validator { + + override fun validateEmailAddress(emailAddress: String): ValidationResult { + return emailAddressValidator.execute(emailAddress) + } + + override fun validatePassword(password: String): ValidationResult { + return passwordValidator.execute(password) + } + + override fun validateConfigurationApproval( + isApproved: Boolean?, + isAutoDiscoveryTrusted: Boolean?, + ): ValidationResult { + return configurationApprovalValidator.execute(isApproved, isAutoDiscoveryTrusted) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryViewModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryViewModel.kt new file mode 100644 index 0000000..3ae0196 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryViewModel.kt @@ -0,0 +1,298 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import androidx.lifecycle.viewModelScope +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.autodiscovery.demo.DemoServerSettings +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.oauth.domain.entity.OAuthResult +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import app.k9mail.feature.account.setup.domain.entity.AutoDiscoveryAuthenticationType +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.AutoDiscoveryUiResult +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ConfigStep +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Error +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Validator +import kotlinx.coroutines.launch +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +@Suppress("TooManyFunctions") +internal class AccountAutoDiscoveryViewModel( + initialState: State = State(), + private val validator: Validator, + private val getAutoDiscovery: UseCase.GetAutoDiscovery, + private val accountStateRepository: AccountDomainContract.AccountStateRepository, + override val oAuthViewModel: AccountOAuthContract.ViewModel, +) : BaseViewModel(initialState), AccountAutoDiscoveryContract.ViewModel { + + override fun initState(state: State) { + updateState { + state.copy() + } + } + + override fun event(event: Event) { + when (event) { + is Event.EmailAddressChanged -> changeEmailAddress(event.emailAddress) + is Event.PasswordChanged -> changePassword(event.password) + is Event.ResultApprovalChanged -> changeConfigurationApproval(event.confirmed) + is Event.OnOAuthResult -> onOAuthResult(event.result) + + Event.OnNextClicked -> onNext() + Event.OnBackClicked -> onBack() + Event.OnRetryClicked -> onRetry() + Event.OnEditConfigurationClicked -> { + navigateNext(isAutomaticConfig = false) + } + } + } + + private fun changeEmailAddress(emailAddress: String) { + accountStateRepository.clear() + updateState { + State( + emailAddress = StringInputField(value = emailAddress), + isNextButtonVisible = true, + ) + } + } + + private fun changePassword(password: String) { + updateState { + it.copy( + password = it.password.updateValue(password), + ) + } + } + + private fun changeConfigurationApproval(approved: Boolean) { + updateState { + it.copy( + configurationApproved = it.configurationApproved.updateValue(approved), + ) + } + } + + private fun onNext() { + when (state.value.configStep) { + ConfigStep.EMAIL_ADDRESS -> + if (state.value.error != null) { + updateState { + it.copy( + error = null, + configStep = ConfigStep.PASSWORD, + ) + } + } else { + submitEmail() + } + + ConfigStep.PASSWORD -> submitPassword() + ConfigStep.OAUTH -> Unit + ConfigStep.MANUAL_SETUP -> navigateNext(isAutomaticConfig = false) + } + } + + private fun onRetry() { + updateState { + it.copy(error = null) + } + loadAutoDiscovery() + } + + private fun submitEmail() { + with(state.value) { + val emailValidationResult = validator.validateEmailAddress(emailAddress.value) + val hasError = emailValidationResult is ValidationResult.Failure + + updateState { + it.copy( + emailAddress = it.emailAddress.updateFromValidationResult(emailValidationResult), + ) + } + + if (!hasError) { + loadAutoDiscovery() + } + } + } + + private fun loadAutoDiscovery() { + viewModelScope.launch { + updateState { + it.copy( + isLoading = true, + ) + } + + val result = getAutoDiscovery.execute(state.value.emailAddress.value) + when (result) { + AutoDiscoveryResult.NoUsableSettingsFound -> updateNoSettingsFound() + is AutoDiscoveryResult.Settings -> updateAutoDiscoverySettings(result) + is AutoDiscoveryResult.NetworkError -> updateError(Error.NetworkError) + is AutoDiscoveryResult.UnexpectedException -> updateError(Error.UnknownError) + } + } + } + + private fun updateNoSettingsFound() { + updateState { + it.copy( + isLoading = false, + autoDiscoverySettings = null, + configStep = ConfigStep.MANUAL_SETUP, + ) + } + } + + private fun updateAutoDiscoverySettings(settings: AutoDiscoveryResult.Settings) { + if (settings.incomingServerSettings is DemoServerSettings) { + updateState { + it.copy( + isLoading = false, + autoDiscoverySettings = settings, + configStep = ConfigStep.PASSWORD, + isNextButtonVisible = true, + ) + } + return + } + + val imapServerSettings = settings.incomingServerSettings as ImapServerSettings + val isOAuth = imapServerSettings.authenticationTypes.first() == AutoDiscoveryAuthenticationType.OAuth2 + + if (isOAuth) { + oAuthViewModel.initState( + AccountOAuthContract.State( + hostname = imapServerSettings.hostname.value, + emailAddress = state.value.emailAddress.value, + ), + ) + } + + updateState { + it.copy( + isLoading = false, + autoDiscoverySettings = settings, + configStep = if (isOAuth) ConfigStep.OAUTH else ConfigStep.PASSWORD, + isNextButtonVisible = !isOAuth, + ) + } + } + + private fun updateError(error: Error) { + updateState { + it.copy( + isLoading = false, + error = error, + ) + } + } + + private fun submitPassword() { + with(state.value) { + val emailValidationResult = validator.validateEmailAddress(emailAddress.value) + val passwordValidationResult = validator.validatePassword(password.value) + val configurationApprovalValidationResult = validator.validateConfigurationApproval( + isApproved = configurationApproved.value, + isAutoDiscoveryTrusted = autoDiscoverySettings?.isTrusted, + ) + val hasError = listOf( + emailValidationResult, + passwordValidationResult, + configurationApprovalValidationResult, + ).any { it is ValidationResult.Failure } + + updateState { + it.copy( + emailAddress = it.emailAddress.updateFromValidationResult(emailValidationResult), + password = it.password.updateFromValidationResult(passwordValidationResult), + configurationApproved = it.configurationApproved.updateFromValidationResult( + configurationApprovalValidationResult, + ), + ) + } + + if (!hasError) { + navigateNext(state.value.autoDiscoverySettings != null) + } + } + } + + private fun onBack() { + when (state.value.configStep) { + ConfigStep.EMAIL_ADDRESS -> { + if (state.value.error != null) { + updateState { + it.copy(error = null) + } + } else { + navigateBack() + } + } + + ConfigStep.OAUTH, + ConfigStep.PASSWORD, + ConfigStep.MANUAL_SETUP, + -> updateState { + it.copy( + configStep = ConfigStep.EMAIL_ADDRESS, + password = StringInputField(), + isNextButtonVisible = true, + ) + } + } + } + + private fun onOAuthResult(result: OAuthResult) { + if (result is OAuthResult.Success) { + updateState { + it.copy(authorizationState = result.authorizationState) + } + + navigateNext(isAutomaticConfig = true) + } else { + updateState { + it.copy(authorizationState = null) + } + } + } + + private fun navigateBack() = emitEffect(Effect.NavigateBack) + + private fun navigateNext(isAutomaticConfig: Boolean) { + accountStateRepository.setState(state.value.toAccountState()) + + emitEffect( + Effect.NavigateNext( + result = mapToAutoDiscoveryResult( + isAutomaticConfig = isAutomaticConfig, + incomingServerSettings = state.value.autoDiscoverySettings?.incomingServerSettings, + ), + ), + ) + } + + private fun mapToAutoDiscoveryResult( + isAutomaticConfig: Boolean, + incomingServerSettings: IncomingServerSettings?, + ): AutoDiscoveryUiResult { + val incomingProtocolType = if (incomingServerSettings is ImapServerSettings) { + IncomingProtocolType.IMAP + } else { + null + } + + return AutoDiscoveryUiResult( + isAutomaticConfig = isAutomaticConfig, + incomingProtocolType = incomingProtocolType, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AutoDiscoveryStringMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AutoDiscoveryStringMapper.kt new file mode 100644 index 0000000..238fb8e --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AutoDiscoveryStringMapper.kt @@ -0,0 +1,79 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import android.content.res.Resources +import app.k9mail.feature.account.server.settings.domain.usecase.ValidatePassword +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.domain.entity.AutoDiscoveryConnectionSecurity +import app.k9mail.feature.account.setup.domain.usecase.ValidateConfigurationApproval +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailAddress +import net.thunderbird.core.common.domain.usecase.validation.ValidationError + +internal fun AutoDiscoveryConnectionSecurity.toAutoDiscoveryConnectionSecurityString(resources: Resources): String { + return when (this) { + AutoDiscoveryConnectionSecurity.StartTLS -> resources.getString( + R.string.account_setup_auto_discovery_connection_security_start_tls, + ) + + AutoDiscoveryConnectionSecurity.TLS -> resources.getString( + R.string.account_setup_auto_discovery_connection_security_ssl, + ) + } +} + +internal fun AccountAutoDiscoveryContract.Error.toAutoDiscoveryErrorString(resources: Resources): String { + return when (this) { + AccountAutoDiscoveryContract.Error.NetworkError -> resources.getString(R.string.account_setup_error_network) + AccountAutoDiscoveryContract.Error.UnknownError -> resources.getString(R.string.account_setup_error_unknown) + } +} + +internal fun ValidationError.toAutoDiscoveryValidationErrorString(resources: Resources): String { + return when (this) { + is ValidateEmailAddress.ValidateEmailAddressError -> toEmailAddressErrorString(resources) + is ValidatePassword.ValidatePasswordError -> toPasswordErrorString(resources) + + is ValidateConfigurationApproval.ValidateConfigurationApprovalError -> toConfigurationApprovalErrorString( + resources, + ) + + else -> throw IllegalArgumentException("Unknown error: $this") + } +} + +private fun ValidateEmailAddress.ValidateEmailAddressError.toEmailAddressErrorString(resources: Resources): String { + return when (this) { + ValidateEmailAddress.ValidateEmailAddressError.EmptyEmailAddress -> { + resources.getString(R.string.account_setup_auto_discovery_validation_error_email_address_required) + } + + ValidateEmailAddress.ValidateEmailAddressError.NotAllowed -> { + resources.getString(R.string.account_setup_auto_discovery_validation_error_email_address_not_allowed) + } + + ValidateEmailAddress.ValidateEmailAddressError.InvalidOrNotSupported -> { + resources.getString(R.string.account_setup_auto_discovery_validation_error_email_address_not_supported) + } + + ValidateEmailAddress.ValidateEmailAddressError.InvalidEmailAddress -> { + resources.getString(R.string.account_setup_auto_discovery_validation_error_email_address_invalid) + } + } +} + +private fun ValidatePassword.ValidatePasswordError.toPasswordErrorString(resources: Resources): String { + return when (this) { + ValidatePassword.ValidatePasswordError.EmptyPassword -> resources.getString( + R.string.account_setup_auto_discovery_validation_error_password_required, + ) + } +} + +private fun ValidateConfigurationApproval.ValidateConfigurationApprovalError.toConfigurationApprovalErrorString( + resources: Resources, +): String { + return when (this) { + ValidateConfigurationApproval.ValidateConfigurationApprovalError.ApprovalRequired -> resources.getString( + R.string.account_setup_auto_discovery_result_approval_error_approval_required, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultApprovalView.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultApprovalView.kt new file mode 100644 index 0000000..87d5e31 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultApprovalView.kt @@ -0,0 +1,34 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.input.CheckboxInput +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.domain.input.BooleanInputField +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.ui.autodiscovery.toAutoDiscoveryValidationErrorString + +@Composable +internal fun AutoDiscoveryResultApprovalView( + approvalState: BooleanInputField, + onApprovalChange: (Boolean) -> Unit, +) { + val resources = LocalContext.current.resources + + Spacer(modifier = Modifier.height(MainTheme.spacings.default)) + + CheckboxInput( + text = stringResource( + id = R.string.account_setup_auto_discovery_result_approval_checkbox_label, + ), + checked = approvalState.value ?: false, + onCheckedChange = onApprovalChange, + errorMessage = approvalState.error?.toAutoDiscoveryValidationErrorString(resources), + contentPadding = PaddingValues(), + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultBodyView.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultBodyView.kt new file mode 100644 index 0000000..855670e --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultBodyView.kt @@ -0,0 +1,95 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.setup.R + +@Composable +internal fun AutoDiscoveryResultBodyView( + settings: AutoDiscoveryResult.Settings, + onEditConfigurationClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MainTheme.spacings.default) + .then(modifier), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + if (settings.isTrusted.not()) { + Spacer(modifier = Modifier.height(MainTheme.sizes.smaller)) + TextBodyMedium( + text = stringResource( + id = R.string.account_setup_auto_discovery_result_disclaimer_untrusted_configuration, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + val incomingServerSettings = settings.incomingServerSettings + if (incomingServerSettings is ImapServerSettings) { + Spacer(modifier = Modifier.height(MainTheme.sizes.smaller)) + AutoDiscoveryServerSettingsView( + protocolName = "IMAP", + serverHostname = incomingServerSettings.hostname, + serverPort = incomingServerSettings.port.value, + connectionSecurity = incomingServerSettings.connectionSecurity, + username = incomingServerSettings.username, + isIncoming = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + val outgoingServerSettings = settings.outgoingServerSettings + if (outgoingServerSettings is SmtpServerSettings) { + Spacer(modifier = Modifier.height(MainTheme.sizes.smaller)) + AutoDiscoveryServerSettingsView( + protocolName = "SMTP", + serverHostname = outgoingServerSettings.hostname, + serverPort = outgoingServerSettings.port.value, + connectionSecurity = outgoingServerSettings.connectionSecurity, + username = outgoingServerSettings.username, + isIncoming = false, + modifier = Modifier.fillMaxWidth(), + ) + } + + EditConfigurationButton( + onEditConfigurationClick = onEditConfigurationClick, + ) + } +} + +@Composable +internal fun EditConfigurationButton( + modifier: Modifier = Modifier, + onEditConfigurationClick: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .then(modifier), + ) { + ButtonText( + text = stringResource(id = R.string.account_setup_auto_discovery_result_edit_configuration_button_label), + onClick = onEditConfigurationClick, + color = MainTheme.colors.warning, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderState.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderState.kt new file mode 100644 index 0000000..b35ef4f --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderState.kt @@ -0,0 +1,35 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.feature.account.setup.R + +@Suppress("detekt.UnnecessaryAnnotationUseSiteTarget") // https://github.com/detekt/detekt/issues/8212 +enum class AutoDiscoveryResultHeaderState( + val icon: ImageVector, + @param:StringRes val titleResourceId: Int, + @param:StringRes val subtitleResourceId: Int, + val isExpandable: Boolean, +) { + NoSettings( + icon = Icons.Outlined.Info, + titleResourceId = R.string.account_setup_auto_discovery_result_header_title_configuration_not_found, + subtitleResourceId = R.string.account_setup_auto_discovery_result_header_subtitle_configuration_not_found, + isExpandable = false, + ), + + Trusted( + icon = Icons.Outlined.Check, + titleResourceId = R.string.account_setup_auto_discovery_status_header_title_configuration_found, + subtitleResourceId = R.string.account_setup_auto_discovery_result_header_subtitle_configuration_trusted, + isExpandable = true, + ), + + Untrusted( + icon = Icons.Outlined.Info, + titleResourceId = R.string.account_setup_auto_discovery_status_header_title_configuration_found, + subtitleResourceId = R.string.account_setup_auto_discovery_result_header_subtitle_configuration_untrusted, + isExpandable = true, + ), +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderView.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderView.kt new file mode 100644 index 0000000..5e24073 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultHeaderView.kt @@ -0,0 +1,71 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Suppress("LongMethod") +@Composable +internal fun AutoDiscoveryResultHeaderView( + state: AutoDiscoveryResultHeaderState, + isExpanded: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = state.icon, + tint = selectColor(state), + modifier = Modifier + .padding(MainTheme.spacings.default) + .requiredSize(MainTheme.sizes.medium), + ) + Column( + modifier = Modifier + .weight(1f) + .padding( + start = MainTheme.spacings.default, + top = MainTheme.spacings.half, + bottom = MainTheme.spacings.half, + ), + ) { + TextTitleLarge( + text = stringResource(state.titleResourceId), + ) + TextBodyMedium( + text = stringResource(state.subtitleResourceId), + ) + } + if (state.isExpandable) { + Icon( + imageVector = if (isExpanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + modifier = Modifier.padding(MainTheme.spacings.default), + ) + } + } +} + +@Composable +private fun selectColor(state: AutoDiscoveryResultHeaderState): Color { + return when (state) { + AutoDiscoveryResultHeaderState.NoSettings -> MainTheme.colors.primary + AutoDiscoveryResultHeaderState.Trusted -> MainTheme.colors.success + AutoDiscoveryResultHeaderState.Untrusted -> MainTheme.colors.warning + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultView.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultView.kt new file mode 100644 index 0000000..5b7b76e --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryResultView.kt @@ -0,0 +1,75 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +internal fun AutoDiscoveryResultView( + settings: AutoDiscoveryResult.Settings?, + onEditConfigurationClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val expanded = rememberSaveable { + mutableStateOf(settings?.isTrusted?.not() ?: false) + } + + val discoveryResultHeaderState = if (settings == null) { + AutoDiscoveryResultHeaderState.NoSettings + } else if (settings.isTrusted) { + AutoDiscoveryResultHeaderState.Trusted + } else { + AutoDiscoveryResultHeaderState.Untrusted + } + + Column( + modifier = modifier, + ) { + Surface( + shape = MainTheme.shapes.small, + modifier = Modifier + .border( + width = 1.dp, + color = Color.Gray.copy(alpha = 0.5f), + shape = MainTheme.shapes.small, + ).let { + if (discoveryResultHeaderState.isExpandable) { + it.clickable(enabled = true) { expanded.value = !expanded.value } + } else if (discoveryResultHeaderState == AutoDiscoveryResultHeaderState.NoSettings) { + it.clickable(enabled = true) { onEditConfigurationClick() } + } else { + it.clickable(enabled = false) {} + } + }, + ) { + Column( + modifier = Modifier.padding(MainTheme.spacings.default), + ) { + AutoDiscoveryResultHeaderView( + state = discoveryResultHeaderState, + isExpanded = expanded.value, + ) + + if (settings != null) { + AnimatedVisibility(visible = expanded.value) { + AutoDiscoveryResultBodyView( + settings = settings, + onEditConfigurationClick = onEditConfigurationClick, + ) + } + } + } + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryServerSettingsView.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryServerSettingsView.kt new file mode 100644 index 0000000..42f6054 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/view/AutoDiscoveryServerSettingsView.kt @@ -0,0 +1,115 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import app.k9mail.autodiscovery.api.ConnectionSecurity +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.setup.ui.autodiscovery.toAutoDiscoveryConnectionSecurityString +import net.thunderbird.core.common.net.Hostname +import net.thunderbird.core.common.net.isIpAddress + +@Composable +internal fun AutoDiscoveryServerSettingsView( + protocolName: String, + serverHostname: Hostname, + serverPort: Int, + connectionSecurity: ConnectionSecurity, + modifier: Modifier = Modifier, + username: String = "", + isIncoming: Boolean = true, +) { + val resources = LocalContext.current.resources + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + modifier = modifier, + ) { + TextBodyLarge( + text = buildAnnotatedString { + append(if (isIncoming) "Incoming" else "Outgoing") + append(" ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(protocolName.uppercase()) + } + append(" ") + append("configuration") + }, + ) + + ServerSettingRow( + icon = if (isIncoming) Icons.Outlined.Inbox else Icons.Outlined.Outbox, + text = buildAnnotatedString { + append("Server") + append(": ") + if (serverHostname.isIpAddress()) { + append(serverHostname.value) + } else { + append(serverHostname.value.substringBefore(".") + ".") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(serverHostname.value.substringAfter(".")) + } + } + append(":$serverPort") + }, + ) + + ServerSettingRow( + icon = Icons.Outlined.Security, + text = buildAnnotatedString { + append("Security: ") + append(connectionSecurity.toAutoDiscoveryConnectionSecurityString(resources)) + }, + ) + + if (username.isNotEmpty()) { + ServerSettingRow( + icon = Icons.Outlined.AccountCircle, + text = buildAnnotatedString { + append("Username: ") + append(username) + }, + ) + } + } +} + +@Composable +private fun ServerSettingRow( + icon: ImageVector, + text: AnnotatedString, + modifier: Modifier = Modifier, + showIcon: Boolean = false, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showIcon) { + Icon( + imageVector = icon, + modifier = Modifier.padding(end = MainTheme.spacings.default), + ) + } + TextBodyMedium( + text = text, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContent.kt new file mode 100644 index 0000000..4e6deb1 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContent.kt @@ -0,0 +1,48 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.ContentLoadingErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.feature.account.setup.R +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +internal fun CreateAccountContent( + state: CreateAccountContract.State, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + ResponsiveWidthContainer( + modifier = Modifier + .padding(contentPadding) + .testTagAsResourceId("CreateAccountContent") + .then(modifier), + ) { contentPadding -> + ContentLoadingErrorView( + state = state, + loading = { + LoadingView( + message = stringResource(R.string.account_setup_create_account_creating), + ) + }, + error = { + ErrorView( + title = stringResource(R.string.account_setup_create_account_error), + ) + }, + content = { + LoadingView( + message = stringResource(R.string.account_setup_create_account_created), + ) + }, + modifier = Modifier.fillMaxSize().padding(contentPadding), + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContract.kt new file mode 100644 index 0000000..8200b4f --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountContract.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingErrorState +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult.Error +import app.k9mail.feature.account.setup.domain.entity.AccountUuid + +interface CreateAccountContract { + + interface ViewModel : UnidirectionalViewModel + + data class State( + override val isLoading: Boolean = true, + override val error: Error? = null, + ) : LoadingErrorState + + sealed interface Event { + data object CreateAccount : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + data class NavigateNext(val accountUuid: AccountUuid) : Effect + data object NavigateBack : Effect + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreen.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreen.kt new file mode 100644 index 0000000..21287d0 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreen.kt @@ -0,0 +1,66 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.ui.AppTitleTopHeader +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.common.ui.WizardNavigationBarState +import app.k9mail.feature.account.setup.domain.entity.AccountUuid +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.ViewModel +import net.thunderbird.core.common.provider.BrandNameProvider + +@Composable +internal fun CreateAccountScreen( + onNext: (AccountUuid) -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.NavigateBack -> onBack() + is Effect.NavigateNext -> onNext(effect.accountUuid) + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.CreateAccount) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + topBar = { + AppTitleTopHeader( + title = brandNameProvider.brandName, + ) + }, + bottomBar = { + WizardNavigationBar( + onNextClick = {}, + onBackClick = { + dispatch(Event.OnBackClicked) + }, + state = WizardNavigationBarState( + showNext = false, + isBackEnabled = state.value.error != null, + ), + ) + }, + modifier = modifier, + ) { innerPadding -> + CreateAccountContent( + state = state.value, + contentPadding = innerPadding, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountViewModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountViewModel.kt new file mode 100644 index 0000000..a156666 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountViewModel.kt @@ -0,0 +1,80 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract.AccountStateRepository +import app.k9mail.feature.account.common.ui.WizardConstants +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.CreateAccount +import app.k9mail.feature.account.setup.domain.entity.AccountUuid +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class CreateAccountViewModel( + private val createAccount: CreateAccount, + private val accountStateRepository: AccountStateRepository, + initialState: State = State(), +) : BaseViewModel(initialState), + CreateAccountContract.ViewModel { + + override fun event(event: Event) { + when (event) { + Event.CreateAccount -> handleOneTimeEvent(event, ::createAccount) + Event.OnBackClicked -> maybeNavigateBack() + } + } + + private fun createAccount() { + val accountState = accountStateRepository.getState() + + viewModelScope.launch { + when (val result = createAccount.execute(accountState)) { + is AccountCreatorResult.Success -> showSuccess(AccountUuid(result.accountUuid)) + is AccountCreatorResult.Error -> showError(result) + } + } + } + + private fun showSuccess(accountUuid: AccountUuid) { + updateState { + it.copy( + isLoading = false, + error = null, + ) + } + + viewModelScope.launch { + delay(WizardConstants.CONTINUE_NEXT_DELAY) + navigateNext(accountUuid) + } + } + + private fun showError(error: AccountCreatorResult.Error) { + updateState { + it.copy( + isLoading = false, + error = error, + ) + } + } + + private fun maybeNavigateBack() { + if (!state.value.isLoading) { + navigateBack() + } + } + + private fun navigateBack() { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateBack) + } + + private fun navigateNext(accountUuid: AccountUuid) { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateNext(accountUuid)) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContent.kt new file mode 100644 index 0000000..1e5e888 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContent.kt @@ -0,0 +1,111 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelSmall +import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.ui.AppTitleTopHeader +import app.k9mail.feature.account.common.ui.item.defaultHeadlineItemPadding +import app.k9mail.feature.account.common.ui.item.defaultItemPadding +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Suppress("LongMethod") +@Composable +internal fun DisplayOptionsContent( + state: State, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + brandName: String, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + ResponsiveWidthContainer( + modifier = Modifier + .testTagAsResourceId("DisplayOptionsContent") + .consumeWindowInsets(contentPadding) + .padding(contentPadding) + .then(modifier), + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = contentPadding, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + item { + AppTitleTopHeader( + title = brandName, + ) + } + + item { + TextLabelSmall( + text = stringResource(id = R.string.account_setup_options_section_display_options), + modifier = Modifier + .fillMaxWidth() + .padding(defaultHeadlineItemPadding()), + ) + } + + item { + TextInput( + text = state.accountName.value, + errorMessage = state.accountName.error?.toResourceString(resources), + onTextChange = { onEvent(Event.OnAccountNameChanged(it)) }, + label = stringResource(id = R.string.account_setup_options_account_name_label), + contentPadding = defaultItemPadding(), + modifier = Modifier.testTagAsResourceId("account_setup_display_options_account_name_input"), + ) + } + + item { + TextInput( + text = state.displayName.value, + errorMessage = state.displayName.error?.toResourceString(resources), + onTextChange = { onEvent(Event.OnDisplayNameChanged(it)) }, + label = stringResource(id = R.string.account_setup_options_display_name_label), + contentPadding = defaultItemPadding(), + isRequired = true, + modifier = Modifier.testTagAsResourceId("account_setup_display_options_display_name_input"), + ) + } + + item { + TextInput( + text = state.emailSignature.value, + errorMessage = state.emailSignature.error?.toResourceString(resources), + onTextChange = { onEvent(Event.OnEmailSignatureChanged(it)) }, + label = stringResource(id = R.string.account_setup_options_email_signature_label), + contentPadding = defaultItemPadding(), + isSingleLine = false, + modifier = Modifier.testTagAsResourceId("account_setup_display_options_signature_input"), + ) + } + + item { + Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller)) + } + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContract.kt new file mode 100644 index 0000000..8b2df67 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsContract.kt @@ -0,0 +1,38 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.common.domain.input.StringInputField +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +interface DisplayOptionsContract { + + interface ViewModel : UnidirectionalViewModel + + data class State( + val accountName: StringInputField = StringInputField(), + val displayName: StringInputField = StringInputField(), + val emailSignature: StringInputField = StringInputField(), + ) + + sealed interface Event { + data class OnAccountNameChanged(val accountName: String) : Event + data class OnDisplayNameChanged(val displayName: String) : Event + data class OnEmailSignatureChanged(val emailSignature: String) : Event + + data object LoadAccountState : Event + + data object OnNextClicked : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + data object NavigateNext : Effect + data object NavigateBack : Effect + } + + interface Validator { + fun validateAccountName(accountName: String): ValidationResult + fun validateDisplayName(displayName: String): ValidationResult + fun validateEmailSignature(emailSignature: String): ValidationResult + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreen.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreen.kt new file mode 100644 index 0000000..3e91bd1 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreen.kt @@ -0,0 +1,54 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.ViewModel +import net.thunderbird.core.common.provider.BrandNameProvider + +@Composable +internal fun DisplayOptionsScreen( + onNext: () -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.NavigateBack -> onBack() + Effect.NavigateNext -> onNext() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.LoadAccountState) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + bottomBar = { + WizardNavigationBar( + onNextClick = { dispatch(Event.OnNextClicked) }, + onBackClick = { dispatch(Event.OnBackClicked) }, + ) + }, + modifier = modifier, + ) { innerPadding -> + DisplayOptionsContent( + state = state.value, + onEvent = { dispatch(it) }, + contentPadding = innerPadding, + brandName = brandNameProvider.brandName, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateMapper.kt new file mode 100644 index 0000000..46011da --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateMapper.kt @@ -0,0 +1,31 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State + +internal fun AccountState.toDisplayOptionsState(): State { + val options = displayOptions + return if (options == null) { + State( + accountName = StringInputField(emailAddress ?: ""), + // displayName = StringInputField(""), + // TODO: get display name from: preferences.defaultAccount?.senderName ?: "" + ) + } else { + State( + accountName = StringInputField(options.accountName), + displayName = StringInputField(options.displayName), + emailSignature = StringInputField(options.emailSignature ?: ""), + ) + } +} + +internal fun State.toAccountDisplayOptions(): AccountDisplayOptions { + return AccountDisplayOptions( + accountName = accountName.value, + displayName = displayName.value, + emailSignature = emailSignature.value.takeIf { it.isNotEmpty() }, + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStringMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStringMapper.kt new file mode 100644 index 0000000..0aad30f --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStringMapper.kt @@ -0,0 +1,38 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import android.content.res.Resources +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.domain.usecase.ValidateAccountName.ValidateAccountNameError +import app.k9mail.feature.account.setup.domain.usecase.ValidateAccountName.ValidateAccountNameError.BlankAccountName +import app.k9mail.feature.account.setup.domain.usecase.ValidateDisplayName.ValidateDisplayNameError +import app.k9mail.feature.account.setup.domain.usecase.ValidateDisplayName.ValidateDisplayNameError.EmptyDisplayName +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature.ValidateEmailSignatureError +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature.ValidateEmailSignatureError.BlankEmailSignature +import net.thunderbird.core.common.domain.usecase.validation.ValidationError + +internal fun ValidationError.toResourceString(resources: Resources): String { + return when (this) { + is ValidateAccountNameError -> toAccountNameErrorString(resources) + is ValidateDisplayNameError -> toDisplayNameErrorString(resources) + is ValidateEmailSignatureError -> toEmailSignatureErrorString(resources) + else -> throw IllegalArgumentException("Unknown error: $this") + } +} + +private fun ValidateAccountNameError.toAccountNameErrorString(resources: Resources): String { + return when (this) { + is BlankAccountName -> resources.getString(R.string.account_setup_options_account_name_error_blank) + } +} + +private fun ValidateDisplayNameError.toDisplayNameErrorString(resources: Resources): String { + return when (this) { + is EmptyDisplayName -> resources.getString(R.string.account_setup_options_display_name_error_required) + } +} + +private fun ValidateEmailSignatureError.toEmailSignatureErrorString(resources: Resources): String { + return when (this) { + is BlankEmailSignature -> resources.getString(R.string.account_setup_options_email_signature_error_blank) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsValidator.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsValidator.kt new file mode 100644 index 0000000..c762523 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsValidator.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.feature.account.setup.domain.usecase.ValidateAccountName +import app.k9mail.feature.account.setup.domain.usecase.ValidateDisplayName +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Validator +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class DisplayOptionsValidator( + private val accountNameValidator: ValidateAccountName = ValidateAccountName(), + private val displayNameValidator: ValidateDisplayName = ValidateDisplayName(), + private val emailSignatureValidator: ValidateEmailSignature = ValidateEmailSignature(), +) : Validator { + override fun validateAccountName(accountName: String): ValidationResult { + return accountNameValidator.execute(accountName) + } + + override fun validateDisplayName(displayName: String): ValidationResult { + return displayNameValidator.execute(displayName) + } + + override fun validateEmailSignature(emailSignature: String): ValidationResult { + return emailSignatureValidator.execute(emailSignature) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsViewModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsViewModel.kt new file mode 100644 index 0000000..79457e7 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsViewModel.kt @@ -0,0 +1,98 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.setup.AccountSetupExternalContract +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Validator +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.ViewModel +import kotlinx.coroutines.launch +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class DisplayOptionsViewModel( + private val validator: Validator, + private val accountStateRepository: AccountDomainContract.AccountStateRepository, + private val accountOwnerNameProvider: AccountSetupExternalContract.AccountOwnerNameProvider, + initialState: State? = null, +) : BaseViewModel( + initialState = initialState ?: accountStateRepository.getState().toDisplayOptionsState(), +), + ViewModel { + + override fun event(event: Event) { + when (event) { + Event.LoadAccountState -> handleOneTimeEvent(event, ::loadAccountState) + + is Event.OnAccountNameChanged -> updateState { state -> + state.copy( + accountName = state.accountName.updateValue(event.accountName), + ) + } + + is Event.OnDisplayNameChanged -> updateState { + it.copy( + displayName = it.displayName.updateValue(event.displayName), + ) + } + + is Event.OnEmailSignatureChanged -> updateState { + it.copy( + emailSignature = it.emailSignature.updateValue(event.emailSignature), + ) + } + + Event.OnNextClicked -> submit() + Event.OnBackClicked -> navigateBack() + } + } + + private fun loadAccountState() { + viewModelScope.launch { + val ownerName = accountOwnerNameProvider.getOwnerName().orEmpty() + + updateState { + val displayOptionsState = accountStateRepository.getState().toDisplayOptionsState() + if (displayOptionsState.displayName.value.isEmpty()) { + displayOptionsState.copy( + displayName = StringInputField(value = ownerName), + ) + } else { + displayOptionsState + } + } + } + } + + private fun submit() = with(state.value) { + val accountNameResult = validator.validateAccountName(accountName.value) + val displayNameResult = validator.validateDisplayName(displayName.value) + val emailSignatureResult = validator.validateEmailSignature(emailSignature.value) + + val hasError = listOf( + accountNameResult, + displayNameResult, + emailSignatureResult, + ).any { it is ValidationResult.Failure } + + updateState { + it.copy( + accountName = it.accountName.updateFromValidationResult(accountNameResult), + displayName = it.displayName.updateFromValidationResult(displayNameResult), + emailSignature = it.emailSignature.updateFromValidationResult(emailSignatureResult), + ) + } + + if (!hasError) { + accountStateRepository.setDisplayOptions(state.value.toAccountDisplayOptions()) + navigateNext() + } + } + + private fun navigateBack() = emitEffect(Effect.NavigateBack) + + private fun navigateNext() = emitEffect(Effect.NavigateNext) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContent.kt new file mode 100644 index 0000000..3bf2366 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContent.kt @@ -0,0 +1,110 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelSmall +import app.k9mail.core.ui.compose.designsystem.molecule.input.SelectInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.SwitchInput +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.ui.AppTitleTopHeader +import app.k9mail.feature.account.common.ui.item.defaultHeadlineItemPadding +import app.k9mail.feature.account.common.ui.item.defaultItemPadding +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency +import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.State +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Suppress("LongMethod") +@Composable +internal fun SyncOptionsContent( + state: State, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + brandName: String, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + ResponsiveWidthContainer( + modifier = Modifier + .testTagAsResourceId("SyncOptionsContent") + .consumeWindowInsets(contentPadding) + .padding(contentPadding) + .then(modifier), + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentPadding = contentPadding, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + item { + AppTitleTopHeader( + title = brandName, + ) + } + + item { + TextLabelSmall( + text = stringResource(id = R.string.account_setup_options_section_sync_options), + modifier = Modifier + .fillMaxWidth() + .padding(defaultHeadlineItemPadding()), + ) + } + + item { + SelectInput( + options = EmailCheckFrequency.all(), + optionToStringTransformation = { it.toResourceString(resources) }, + selectedOption = state.checkFrequency, + onOptionChange = { onEvent(Event.OnCheckFrequencyChanged(it)) }, + label = stringResource(id = R.string.account_setup_options_account_check_frequency_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = EmailDisplayCount.all(), + optionToStringTransformation = { it.toResourceString(resources) }, + selectedOption = state.messageDisplayCount, + onOptionChange = { onEvent(Event.OnMessageDisplayCountChanged(it)) }, + label = stringResource(id = R.string.account_setup_options_email_display_count_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SwitchInput( + text = stringResource(id = R.string.account_setup_options_show_notifications_label), + checked = state.showNotification, + onCheckedChange = { onEvent(Event.OnShowNotificationChanged(it)) }, + contentPadding = defaultItemPadding(), + ) + } + + item { + Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller)) + } + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContract.kt new file mode 100644 index 0000000..a10fab1 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsContract.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency +import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount + +interface SyncOptionsContract { + + interface ViewModel : UnidirectionalViewModel + + data class State( + val checkFrequency: EmailCheckFrequency = EmailCheckFrequency.DEFAULT, + val messageDisplayCount: EmailDisplayCount = EmailDisplayCount.DEFAULT, + val showNotification: Boolean = true, + ) + + sealed interface Event { + data class OnCheckFrequencyChanged(val checkFrequency: EmailCheckFrequency) : Event + data class OnMessageDisplayCountChanged(val messageDisplayCount: EmailDisplayCount) : Event + data class OnShowNotificationChanged(val showNotification: Boolean) : Event + + data object LoadAccountState : Event + + data object OnNextClicked : Event + data object OnBackClicked : Event + } + + sealed interface Effect { + object NavigateNext : Effect + object NavigateBack : Effect + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreen.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreen.kt new file mode 100644 index 0000000..ff01a65 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreen.kt @@ -0,0 +1,54 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.ViewModel +import net.thunderbird.core.common.provider.BrandNameProvider + +@Composable +internal fun SyncOptionsScreen( + onNext: () -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + Effect.NavigateBack -> onBack() + Effect.NavigateNext -> onNext() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.LoadAccountState) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + bottomBar = { + WizardNavigationBar( + onNextClick = { dispatch(Event.OnNextClicked) }, + onBackClick = { dispatch(Event.OnBackClicked) }, + ) + }, + modifier = modifier, + ) { innerPadding -> + SyncOptionsContent( + state = state.value, + onEvent = { dispatch(it) }, + contentPadding = innerPadding, + brandName = brandNameProvider.brandName, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateMapper.kt new file mode 100644 index 0000000..c76ec45 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateMapper.kt @@ -0,0 +1,28 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency +import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.State + +internal fun AccountState.toSyncOptionsState(): State { + val options = syncOptions + return if (options == null) { + State() + } else { + State( + checkFrequency = EmailCheckFrequency.fromMinutes(options.checkFrequencyInMinutes), + messageDisplayCount = EmailDisplayCount.fromCount(options.messageDisplayCount), + showNotification = options.showNotification, + ) + } +} + +internal fun State.toAccountSyncOptions(): AccountSyncOptions { + return AccountSyncOptions( + checkFrequencyInMinutes = checkFrequency.minutes, + messageDisplayCount = messageDisplayCount.count, + showNotification = showNotification, + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStringMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStringMapper.kt new file mode 100644 index 0000000..e7d4206 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStringMapper.kt @@ -0,0 +1,31 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import android.content.res.Resources +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency +import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount + +internal fun EmailDisplayCount.toResourceString(resources: Resources) = resources.getQuantityString( + R.plurals.account_setup_options_email_display_count_messages, + count, + count, +) + +@Suppress("MagicNumber") +internal fun EmailCheckFrequency.toResourceString(resources: Resources): String { + return when (minutes) { + -1 -> resources.getString(R.string.account_setup_options_email_check_frequency_never) + + in 1..59 -> resources.getQuantityString( + R.plurals.account_setup_options_email_check_frequency_minutes, + minutes, + minutes, + ) + + else -> resources.getQuantityString( + R.plurals.account_setup_options_email_check_frequency_hours, + (minutes / 60), + (minutes / 60), + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsViewModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsViewModel.kt new file mode 100644 index 0000000..109e403 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsViewModel.kt @@ -0,0 +1,59 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.State +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.ViewModel + +internal class SyncOptionsViewModel( + private val accountStateRepository: AccountDomainContract.AccountStateRepository, + initialState: State? = null, +) : BaseViewModel( + initialState = initialState ?: accountStateRepository.getState().toSyncOptionsState(), +), + ViewModel { + + override fun event(event: Event) { + when (event) { + Event.LoadAccountState -> handleOneTimeEvent(event, ::loadAccountState) + + is Event.OnCheckFrequencyChanged -> updateState { + it.copy( + checkFrequency = event.checkFrequency, + ) + } + + is Event.OnMessageDisplayCountChanged -> updateState { state -> + state.copy( + messageDisplayCount = event.messageDisplayCount, + ) + } + + is Event.OnShowNotificationChanged -> updateState { state -> + state.copy( + showNotification = event.showNotification, + ) + } + + Event.OnNextClicked -> submit() + Event.OnBackClicked -> navigateBack() + } + } + + private fun loadAccountState() { + updateState { + accountStateRepository.getState().toSyncOptionsState() + } + } + + private fun submit() { + accountStateRepository.setSyncOptions(state.value.toAccountSyncOptions()) + navigateNext() + } + + private fun navigateBack() = emitEffect(Effect.NavigateBack) + + private fun navigateNext() = emitEffect(Effect.NavigateNext) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContent.kt new file mode 100644 index 0000000..dd8d55b --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContent.kt @@ -0,0 +1,96 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.molecule.ContentLoadingErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.ui.AppTitleTopHeader +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Event +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.State +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId +import app.k9mail.feature.account.common.R as CommonR + +@Composable +fun SpecialFoldersContent( + state: State, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + brandName: String, + modifier: Modifier = Modifier, +) { + ResponsiveWidthContainer( + modifier = Modifier + .testTagAsResourceId("SpecialFoldersContent") + .padding(contentPadding) + .then(modifier), + ) { contentPadding -> + Column(Modifier.padding(contentPadding)) { + AppTitleTopHeader( + title = brandName, + ) + + ContentLoadingErrorView( + state = state, + loading = { + LoadingView( + message = stringResource(id = R.string.account_setup_special_folders_loading_message), + modifier = Modifier.fillMaxWidth(), + ) + }, + error = { error -> + SpecialFoldersErrorView( + failure = error, + onRetry = { onEvent(Event.OnRetryClicked) }, + ) + }, + modifier = Modifier.fillMaxSize(), + ) { state -> + if (state.isSuccess) { + LoadingView( + message = stringResource(id = R.string.account_setup_special_folders_success_message), + modifier = Modifier.padding(horizontal = MainTheme.spacings.double), + ) + } else { + SpecialFoldersFormContent( + state = state.formState, + onEvent = onEvent, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} + +@Composable +private fun SpecialFoldersErrorView( + failure: SpecialFoldersContract.Failure, + onRetry: () -> Unit, +) { + val message = when (failure) { + is SpecialFoldersContract.Failure.LoadFoldersFailed -> { + failure.messageFromServer?.let { messageFromServer -> + stringResource(id = CommonR.string.account_common_error_server_message, messageFromServer) + } + } + } + + ErrorView( + title = stringResource(id = R.string.account_setup_special_folders_error_message), + message = message, + onRetry = onRetry, + modifier = Modifier + .fillMaxWidth() + .padding(MainTheme.spacings.double), + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContract.kt new file mode 100644 index 0000000..9fb437d --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersContract.kt @@ -0,0 +1,64 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingErrorState +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption + +interface SpecialFoldersContract { + + interface ViewModel : UnidirectionalViewModel + + interface FormUiModel { + fun event(event: FormEvent, formState: FormState): FormState + } + + data class State( + val formState: FormState = FormState(), + + val isManualSetup: Boolean = false, + val isSuccess: Boolean = false, + override val error: Failure? = null, + override val isLoading: Boolean = true, + ) : LoadingErrorState + + data class FormState( + val archiveSpecialFolderOptions: List = emptyList(), + val draftsSpecialFolderOptions: List = emptyList(), + val sentSpecialFolderOptions: List = emptyList(), + val spamSpecialFolderOptions: List = emptyList(), + val trashSpecialFolderOptions: List = emptyList(), + + val selectedArchiveSpecialFolderOption: SpecialFolderOption = SpecialFolderOption.None(true), + val selectedDraftsSpecialFolderOption: SpecialFolderOption = SpecialFolderOption.None(true), + val selectedSentSpecialFolderOption: SpecialFolderOption = SpecialFolderOption.None(true), + val selectedSpamSpecialFolderOption: SpecialFolderOption = SpecialFolderOption.None(true), + val selectedTrashSpecialFolderOption: SpecialFolderOption = SpecialFolderOption.None(true), + ) + + sealed interface Event { + data object LoadSpecialFolderOptions : Event + data object OnRetryClicked : Event + data object OnNextClicked : Event + data object OnBackClicked : Event + } + + sealed interface FormEvent : Event { + data class ArchiveFolderChanged(val specialFolderOption: SpecialFolderOption) : FormEvent + data class DraftsFolderChanged(val specialFolderOption: SpecialFolderOption) : FormEvent + data class SentFolderChanged(val specialFolderOption: SpecialFolderOption) : FormEvent + data class SpamFolderChanged(val specialFolderOption: SpecialFolderOption) : FormEvent + data class TrashFolderChanged(val specialFolderOption: SpecialFolderOption) : FormEvent + } + + sealed interface Effect { + data class NavigateNext( + val isManualSetup: Boolean, + ) : Effect + + data object NavigateBack : Effect + } + + sealed interface Failure { + data class LoadFoldersFailed(val messageFromServer: String?) : Failure + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormContent.kt new file mode 100644 index 0000000..23d084d --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormContent.kt @@ -0,0 +1,113 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.designsystem.molecule.input.SelectInput +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.account.common.ui.item.defaultItemPadding +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormEvent +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormState +import kotlinx.collections.immutable.toImmutableList + +@Suppress("LongMethod") +@Composable +fun SpecialFoldersFormContent( + state: FormState, + onEvent: (FormEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + LazyColumn( + modifier = Modifier + .imePadding() + .then(modifier), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + ) { + item { + Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller)) + } + + item { + TextBodyLarge( + text = stringResource(id = R.string.account_setup_special_folders_form_description), + modifier = Modifier.padding(defaultItemPadding()), + ) + } + + item { + SelectInput( + options = state.archiveSpecialFolderOptions.toImmutableList(), + selectedOption = state.selectedArchiveSpecialFolderOption, + onOptionChange = { onEvent(FormEvent.ArchiveFolderChanged(it)) }, + optionToStringTransformation = { it.toResourceString(resources) }, + label = stringResource(R.string.account_setup_special_folders_archive_folder_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = state.draftsSpecialFolderOptions.toImmutableList(), + selectedOption = state.selectedDraftsSpecialFolderOption, + onOptionChange = { onEvent(FormEvent.DraftsFolderChanged(it)) }, + optionToStringTransformation = { it.toResourceString(resources) }, + label = stringResource(id = R.string.account_setup_special_folders_drafts_folder_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = state.sentSpecialFolderOptions.toImmutableList(), + selectedOption = state.selectedSentSpecialFolderOption, + onOptionChange = { onEvent(FormEvent.SentFolderChanged(it)) }, + optionToStringTransformation = { it.toResourceString(resources) }, + label = stringResource(id = R.string.account_setup_special_folders_sent_folder_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = state.spamSpecialFolderOptions.toImmutableList(), + selectedOption = state.selectedSpamSpecialFolderOption, + onOptionChange = { onEvent(FormEvent.SpamFolderChanged(it)) }, + optionToStringTransformation = { it.toResourceString(resources) }, + label = stringResource(id = R.string.account_setup_special_folders_spam_folder_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = state.trashSpecialFolderOptions.toImmutableList(), + selectedOption = state.selectedTrashSpecialFolderOption, + onOptionChange = { onEvent(FormEvent.TrashFolderChanged(it)) }, + optionToStringTransformation = { it.toResourceString(resources) }, + label = stringResource(id = R.string.account_setup_special_folders_trash_folder_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + TextBodySmall( + text = stringResource(id = R.string.account_setup_special_folders_form_description_automatic), + modifier = Modifier.padding(defaultItemPadding()), + ) + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormStateMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormStateMapper.kt new file mode 100644 index 0000000..1d2fbcb --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormStateMapper.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormState + +fun SpecialFolderOptions.toFormState(): FormState { + return FormState( + archiveSpecialFolderOptions = archiveSpecialFolderOptions, + draftsSpecialFolderOptions = draftsSpecialFolderOptions, + sentSpecialFolderOptions = sentSpecialFolderOptions, + spamSpecialFolderOptions = spamSpecialFolderOptions, + trashSpecialFolderOptions = trashSpecialFolderOptions, + + selectedArchiveSpecialFolderOption = archiveSpecialFolderOptions.first(), + selectedDraftsSpecialFolderOption = draftsSpecialFolderOptions.first(), + selectedSentSpecialFolderOption = sentSpecialFolderOptions.first(), + selectedSpamSpecialFolderOption = spamSpecialFolderOptions.first(), + selectedTrashSpecialFolderOption = trashSpecialFolderOptions.first(), + ) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormUiModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormUiModel.kt new file mode 100644 index 0000000..b67299e --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormUiModel.kt @@ -0,0 +1,49 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormEvent +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormState +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormUiModel + +class SpecialFoldersFormUiModel : FormUiModel { + + override fun event(event: FormEvent, formState: FormState): FormState { + return when (event) { + is FormEvent.ArchiveFolderChanged -> onArchiveFolderChanged(formState, event.specialFolderOption) + is FormEvent.DraftsFolderChanged -> onDraftsFolderChanged(formState, event.specialFolderOption) + is FormEvent.SentFolderChanged -> onSentFolderChanged(formState, event.specialFolderOption) + is FormEvent.SpamFolderChanged -> onSpamFolderChanged(formState, event.specialFolderOption) + is FormEvent.TrashFolderChanged -> onTrashFolderChanged(formState, event.specialFolderOption) + } + } + + private fun onArchiveFolderChanged(formState: FormState, specialFolderOption: SpecialFolderOption): FormState { + return formState.copy( + selectedArchiveSpecialFolderOption = specialFolderOption, + ) + } + + private fun onDraftsFolderChanged(formState: FormState, specialFolderOption: SpecialFolderOption): FormState { + return formState.copy( + selectedDraftsSpecialFolderOption = specialFolderOption, + ) + } + + private fun onSentFolderChanged(formState: FormState, specialFolderOption: SpecialFolderOption): FormState { + return formState.copy( + selectedSentSpecialFolderOption = specialFolderOption, + ) + } + + private fun onSpamFolderChanged(formState: FormState, specialFolderOption: SpecialFolderOption): FormState { + return formState.copy( + selectedSpamSpecialFolderOption = specialFolderOption, + ) + } + + private fun onTrashFolderChanged(formState: FormState, specialFolderOption: SpecialFolderOption): FormState { + return formState.copy( + selectedTrashSpecialFolderOption = specialFolderOption, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreen.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreen.kt new file mode 100644 index 0000000..6f936dc --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreen.kt @@ -0,0 +1,58 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.account.common.ui.WizardNavigationBar +import app.k9mail.feature.account.common.ui.WizardNavigationBarState +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Effect +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Event +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.ViewModel +import net.thunderbird.core.common.provider.BrandNameProvider + +@Composable +fun SpecialFoldersScreen( + onNext: (isManualSetup: Boolean) -> Unit, + onBack: () -> Unit, + viewModel: ViewModel, + brandNameProvider: BrandNameProvider, + modifier: Modifier = Modifier, +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateNext -> onNext(effect.isManualSetup) + Effect.NavigateBack -> onBack() + } + } + + LaunchedEffect(key1 = Unit) { + dispatch(Event.LoadSpecialFolderOptions) + } + + BackHandler { + dispatch(Event.OnBackClicked) + } + + Scaffold( + bottomBar = { + WizardNavigationBar( + onNextClick = { dispatch(Event.OnNextClicked) }, + onBackClick = { dispatch(Event.OnBackClicked) }, + state = WizardNavigationBarState( + showNext = state.value.isManualSetup && state.value.isLoading.not(), + ), + ) + }, + modifier = modifier, + ) { innerPadding -> + SpecialFoldersContent( + state = state.value, + onEvent = { dispatch(it) }, + contentPadding = innerPadding, + brandName = brandNameProvider.brandName, + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersStringMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersStringMapper.kt new file mode 100644 index 0000000..11a3f43 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersStringMapper.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import android.content.res.Resources +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.setup.R + +internal fun SpecialFolderOption.toResourceString(resources: Resources) = when (this) { + is SpecialFolderOption.None -> { + val noneString = resources.getString(R.string.account_setup_special_folders_folder_none) + if (isAutomatic) { + resources.getString(R.string.account_setup_special_folders_folder_automatic, noneString) + } else { + noneString + } + } + is SpecialFolderOption.Regular -> remoteFolder.displayName + is SpecialFolderOption.Special -> { + if (isAutomatic) { + resources.getString(R.string.account_setup_special_folders_folder_automatic, remoteFolder.displayName) + } else { + remoteFolder.displayName + } + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersViewModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersViewModel.kt new file mode 100644 index 0000000..5220579 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersViewModel.kt @@ -0,0 +1,149 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import app.k9mail.feature.account.common.ui.WizardConstants +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Effect +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Event +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormEvent +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.State +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.ViewModel +import com.fsck.k9.mail.folders.FolderFetcherException +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.logging.legacy.Log + +class SpecialFoldersViewModel( + private val formUiModel: SpecialFoldersContract.FormUiModel, + private val getSpecialFolderOptions: UseCase.GetSpecialFolderOptions, + private val validateSpecialFolderOptions: UseCase.ValidateSpecialFolderOptions, + private val accountStateRepository: AccountDomainContract.AccountStateRepository, + initialState: State = State(), +) : BaseViewModel(initialState), + ViewModel { + + override fun event(event: Event) { + when (event) { + Event.LoadSpecialFolderOptions -> handleOneTimeEvent(event, ::onLoadSpecialFolderOptions) + + is FormEvent -> onFormEvent(event) + + Event.OnNextClicked -> onNextClicked() + Event.OnBackClicked -> onBackClicked() + Event.OnRetryClicked -> onRetryClicked() + } + } + + private fun onFormEvent(event: FormEvent) { + updateState { + it.copy( + formState = formUiModel.event(event, it.formState), + ) + } + } + + private fun onLoadSpecialFolderOptions() { + viewModelScope.launch { + val specialFolderOptions = loadSpecialFolderOptions() ?: return@launch + + updateState { state -> + state.copy( + formState = specialFolderOptions.toFormState(), + ) + } + + val result = validateSpecialFolderOptions(specialFolderOptions) + when (result) { + is ValidationResult.Failure -> { + updateState { + it.copy( + isManualSetup = true, + isSuccess = false, + isLoading = false, + ) + } + } + + ValidationResult.Success -> { + updateState { + it.copy( + isSuccess = true, + ) + } + + saveSpecialFolderSettings() + + delay(WizardConstants.CONTINUE_NEXT_DELAY) + navigateNext() + } + } + } + } + + private suspend fun loadSpecialFolderOptions(): SpecialFolderOptions? { + return try { + getSpecialFolderOptions() + } catch (exception: FolderFetcherException) { + Log.e(exception, "Error while loading special folders") + updateState { state -> + state.copy( + isLoading = false, + isSuccess = false, + error = SpecialFoldersContract.Failure.LoadFoldersFailed(exception.messageFromServer), + ) + } + null + } + } + + private fun saveSpecialFolderSettings() { + val formState = state.value.formState + + accountStateRepository.setSpecialFolderSettings( + SpecialFolderSettings( + archiveSpecialFolderOption = formState.selectedArchiveSpecialFolderOption, + draftsSpecialFolderOption = formState.selectedDraftsSpecialFolderOption, + sentSpecialFolderOption = formState.selectedSentSpecialFolderOption, + spamSpecialFolderOption = formState.selectedSpamSpecialFolderOption, + trashSpecialFolderOption = formState.selectedTrashSpecialFolderOption, + ), + ) + updateState { state -> + state.copy( + isLoading = false, + ) + } + } + + private fun onNextClicked() { + saveSpecialFolderSettings() + navigateNext() + } + + private fun navigateNext() { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateNext(isManualSetup = state.value.isManualSetup)) + } + + private fun onBackClicked() { + viewModelScope.coroutineContext.cancelChildren() + emitEffect(Effect.NavigateBack) + } + + private fun onRetryClicked() { + viewModelScope.coroutineContext.cancelChildren() + updateState { + it.copy( + isLoading = true, + error = null, + ) + } + onLoadSpecialFolderOptions() + } +} diff --git a/feature/account/setup/src/main/res/values-am/strings.xml b/feature/account/setup/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ar/strings.xml b/feature/account/setup/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..860a842 --- /dev/null +++ b/feature/account/setup/src/main/res/values-ar/strings.xml @@ -0,0 +1,74 @@ + + + خطأ غير معروف + يتم الآن الحصول على قائمة المجلدات… + مجلد المسودات + الاسم + جارٍ إنشاء الحساب… + تم إنشاء الحساب بنجاح + حدث خطأ ما أثناء محاولة إنشاء الحساب + تم العثور على الإعدادات + لم يتم العثور على الإعدادات + جارٍ البحث عن الإعدادات… + يُرجى إدخال كلمة المرور. + مجلد الرسائل الغير مرغوب فيها + مجلد المهملات + معدل تكرار التحقق + مطلقًا + عدد الرسائل المُراد عرضها + لا يمكن أن يكون اسم الحساب فارغًا. + خيارات المزامنة + خطأ في الشبكة. يرجى التحقق من حالة الاتصال والمحاولة مرة أخرى. + لم يتم التعرف على عنوان بريدك الإلكتروني. + StartTLS + تشفير SSL/TLS + عنوان البريد الإلكتروني هذا غير مسموح به. + عنوان البريد الإلكتروني هذا غير مدعوم. + هذه الإعدادات غير موثوق بها + تعذر تحميل إعدادات البريد الإلكتروني + تغيير الإعدادات + مجلد الأرشيف + خيارات العرض + اسم الحساب + لا يمكن أن يكون اسم توقيع البريد الإلكتروني فارغًا. + + كل 0 دقيقة + كل دقيقة + كل دقيقتين + كل %d دقائق + كل %d دقيقة + كل %d دقيقة + + + كل 0 ساعة + كل ساعة + كل ساعتين + كل %d ساعات + كل %d ساعة + كل %d ساعة + + + 0 رسالة + رسالة واحدة + رسالتين + %d رسائل + %d رسالة + %d رسالة + + لقد تلقينا الإعدادات الخاصة بخادم البريد الإلكتروني الخاص بك عبر اتصال ليس بمستوى الأمان الذي نريده. هذا يعني أن هناك فرصة ضئيلة أن يكون شخص ما قد قام بتعديلها. هل يمكنك إعادة فحص الإعدادات المزودة للتأكد من أنها كما ينبغي أن تكون؟ + عند اختيار \"تلقائي\" سوف يتم اتباع التغييرات التي يجريها الخادم تلقائيًا بشكل مستمر. يتم عرض قيمة الخادم الحالية بين قوسين. + الإعداد تلقائيًا + الإعداد يدويًا + أثق في هذه الإعدادات + يتطلب ذلك الموافقة على هذه الإعدادات. + يُرجى تحديد المجلدات الخاصة بحسابك. + فشل الحصول على قائمة المجلدات من الخادم + لقد تم إعداد جميع المجلدات الخاصة تلقائيًا بواسطة الخادم. + مجلد البريد المرسَل + بدون + تلقائي (%s) + توقيع البريد الإلكتروني + عرض الإشعارات + يُرجى إدخال عنوان البريد الإلكتروني. + يُرجى ادخال الاسم. + diff --git a/feature/account/setup/src/main/res/values-ast/strings.xml b/feature/account/setup/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-az/strings.xml b/feature/account/setup/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-be/strings.xml b/feature/account/setup/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..56671a5 --- /dev/null +++ b/feature/account/setup/src/main/res/values-be/strings.xml @@ -0,0 +1,5 @@ + + + Памылка сеткі. Праверце стан падключэння і паспрабуйце яшчэ раз. + Невядомая памылка + diff --git a/feature/account/setup/src/main/res/values-bg/strings.xml b/feature/account/setup/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..8be378d --- /dev/null +++ b/feature/account/setup/src/main/res/values-bg/strings.xml @@ -0,0 +1,62 @@ + + + Настрой автоматично + Тези настройки не са проверени + Ръчно настройване + Нужно е да одобрите настройките. + StartTLS + Получихме настройките за Вашия имейл чрез интернет връзка, която не е достатъчно защитена. Това означава, че има малка вероятност някой да ги е променил. Може ли да проверите отново заредените настройки, за да се уверите че са правилни? + Промяна на настройки + Непозната грешка + SSL/TLS + Не бяха намерени настройки + Намерени са настройки + Имейл адресът е задължителен. + Неуспешно зареждане на имейл настройки + Одобрявам тези настройки + Мрежова грешка. Моля, проверете състоянието на връзката си и опитайте отново. + Имейл адресът не беше разпознат като валиден. + Намиране на конфифурацията… + Името на акаунта не може да бъде празно. + Този имейл адрес не е разрешен. + Интервал на проверка + Акаунтът беше създаден успешно + Подпис + Брой съобщения, които да бъдат показани + Името на подписа не може да бъде празно. + Този имейл адрес не се поддържа. + Създаване на акаунт… + Име на акаунта + Вашето име + Име е задължително. + Възникна грешка при създаването на акаунта + Никога + Настройки за синхронизация + Показване на известия + Настройки за визуализация + + Всяка минута + Всяка %d минута + + + Всеки час + Всеки %d час + + + 1 съобшение + %d съобщения + + Паролата е задължителна. + Моля, посочете специалните папки за вашия профил. + Въведеното „Автоматично“ ще следва автоматично промените, направени от сървъра. Текущата стойност на сървъра се показва в скоби. + Извличане на списъка с папки… + Неуспешно извличане на списъка с папки от сървъра + Всички специални папки са конфигурирани автоматично от сървъра. + папка \"Архив\" + Папка \"Чернови\" + папка \"Изпратени\" + Папка \"Нежелана поща\" + Папка \"Кошче\" + Никаква + Автоматично (%s) + diff --git a/feature/account/setup/src/main/res/values-bn/strings.xml b/feature/account/setup/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-bn/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-br/strings.xml b/feature/account/setup/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..6c61660 --- /dev/null +++ b/feature/account/setup/src/main/res/values-br/strings.xml @@ -0,0 +1,6 @@ + + + Fazi rouedad. Mar plij, gwiriañ stad hor c\'hennask ha klaskit en-dro. + Fazi dianav + Chomlec\'h postel rekis. + diff --git a/feature/account/setup/src/main/res/values-bs/strings.xml b/feature/account/setup/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-bs/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ca/strings.xml b/feature/account/setup/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..c917004 --- /dev/null +++ b/feature/account/setup/src/main/res/values-ca/strings.xml @@ -0,0 +1,65 @@ + + + El nom del compte no pot estar en blanc. + Freqüència de comprovació + Configura automàticament + Aquesta configuració no és fiable + Signatura de correu electrònic + Nombre de missatges a mostrar + Configureu manualment + El nom de la signatura de correu electrònic no pot estar en blanc. + És necessari aprovar la configuració. + StartTLS + Hem rebut la configuració del vostre servidor de correu electrònic a través d\'una connexió que no és tan segura com ens agradaria. Això significa que hi ha una petita possibilitat que algú la pugui haver modificat. Podríeu comprovar la configuració proporcionada per assegurar-vos que és com hauria de ser\? + Edita la configuració + Error desconegut + SSL/TLS + No s\'ha trobat cap configuració + Nom del compte + S\'ha trobat una configuració + Cal l\'adreça de correu electrònic. + El teu nom + Ha fallat la càrrega de la configuració de correu electrònic + Confio en aquesta configuració + Es requereix el teu nom. + Error de xarxa. Comproveu l\'estat de la vostra connexió i torneu-ho a provar. + L\'adreça de correu electrònic no es reconeix com a vàlida. + Mai + Cercant configuració… + Opcions de sincronització + Mostra les notificacions + Opcions de visualització + Aquesta direcció de correu electrònic no està permesa. + Compte creat amb èxit + Aquesta direcció de correu electrònic no és compatible. + S\'està creant el compte… + S\'ha produït un error en intentar crear el compte + Totes les carpetes especials han estat configurades automàticament pel servidor. + Automàtic (%s) + Carpeta d\'esborranys + No s\'ha pogut obtenir la llista de carpetes del servidor + S\'està obtenint la llista de carpetes… + Especifiqueu les carpetes especials per al vostre compte. + Carpeta d\'enviats + Cap + Carpeta paperera + Carpeta d\'arxiu + Carpeta brossa + L\'entrada \"Automàtica\" seguirà els canvis fets pel servidor automàticament. El valor actual del servidor es mostra entre parèntesis. + + Cada minut + Cada %d minuts + Cada %d minuts + + Es requereix contrasenya. + + Cada hora + Cada %d hores + Cada %d hores + + + 1 missatge + %d missatges + %d missatges + + diff --git a/feature/account/setup/src/main/res/values-co/strings.xml b/feature/account/setup/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..2791d35 --- /dev/null +++ b/feature/account/setup/src/main/res/values-co/strings.xml @@ -0,0 +1,62 @@ + + + L’indirizzu elettronicu hè richiestu. + St’indirizzu elettronicu ùn hè micca permessu. + St’indirizzu elettronicu ùn hè micca ricunnisciutu cum’è accettevule. + StartTLS + SSL/TLS + Ricerca di a cunfigurazione… + Fiascu di u caricamentu di a cunfigurazione di u contu di messaghjeria + A cunfigurazione hè stata trova + A cunfigurazione ùn hè micca degna di cunfidenza + Facciu cunfidenza à quella cunfigurazione + Hè richiestu d’appruvà a cunfigurazione. + Riguarera di a lista di i cartulari… + Fiascu di a riguarera di a lista di i cartulari da u servitore + Tutti i cartulari speziali sò stati cunfigurati autumaticamente da u servitore. + Cartulare di l’archivii + Cartulare di i messaghji mandati + Nisunu + Autumaticu (%s) + Ozzioni d’affissera + U nome di u contu ùn pò micca esse viotu. + U vostru nome hè richiestu. + Segnatura di u messaghju elettronicu + Frequenza di cuntrollu + Mai + + Ogni minutu + Tutti i %d minuti + + + Ogni ora + Tutte e %d ore + + Numeru di messaghji à affissà + Affissà e nutificazioni + Creazione di contu… + Un sbagliu hè accadutu durante a creazione di u contu + U contu hè statu creatu currettamente + Sbagliu di a reta. Ci vole à verificà a vostra cunnessione è pruvà torna. + Sbagliu scunnisciutu + St’indirizzu elettronicu ùn hè micca accettatu. + A parolla d’intesa hè richiesta. + A cunfigurazione ùn si trova micca + Cunfigurà autumaticamente + Selezziunate i cartulari speziali per u vostru contu. + Avemu ricevutu a cunfigurazione per u vostru servitore di messaghjeria via una cunnessione chì ùn hè micca tantu sicura chì no a vuleriamu. Vole si dì chì ci hè una pussibilità chjuca chjuca chì qualchissia l’abbia alterata. Puderiate verificà torna a cunfigurazione pruvista per assicurassi ch’ella sia degna di cunfidenza ? + Cunfigurà manualmente + Mudificà a cunfigurazione + L’ozzione « Autumaticu » seguiterà autumaticamente i cambiamenti fatti da u servitore. U valore attuale di u servitore hè affissatu trà parentesi. + Cartulare di e bruttacopie + Cartulare di i merzaghji (dispiacevule) + Cartulare di a curbella + Nome di u contu + U vostru nome + Ozzioni di sincrunizazione + A segnatura di u messaghju elettronicu ùn pò micca esse viota. + + 1 messaghju + %d messaghji + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-cs/strings.xml b/feature/account/setup/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..9b11e8b --- /dev/null +++ b/feature/account/setup/src/main/res/values-cs/strings.xml @@ -0,0 +1,68 @@ + + + Název účtu nemůže být prázdný. + Četnost kontroly + Nastavit automaticky + Toto nastavení není důvěryhodné + Podpis e-mailů + Počet zobrazovaných zpráv + Nastavit ručně + Název podpisu e-mailu nemůže být prázdný. + Nastavení je potřeba schválit. + StartTLS + Nastavení pro váš e-mailový server bylo získáno prostřednictvím spojení, které není tak zabezpečené, jak bychom si přáli. Existuje malá pravděpodobnost, že ho někdo změnil. Zkontrolujte, že je nabízené nastavení v pořádku. + Upravit nastavení + Neznámá chyba + SSL/TLS + Nastavení nenalezeno + Název účtu + Nastavení nalezeno + E-mailová adresa je vyžadována. + Vaše jméno + Nastavení e-mailu se nepodařilo nahrát + Tomuto nastavení důvěřuji + Vaše jméno je vyžadováno. + Chyba sítě. Zkontrolujte prosím stav svého připojení a zkuste to znovu. + Toto není rozeznáno jako platná e-mailová adresa. + Nikdy + Zjišťování konfigurace… + Nastavení synchronizace + Zobrazovat oznámení + Nastavení zobrazení + Tato e-mailová adresa není dovolena. + Účet úspěšně vytvořen + Tato e-mailová adresa není podporována. + Vytváření účtu… + Při vytváření účtu nastala chyba + Zvolte prosím speciální složky pro svůj účet. + Volba \"Automaticky\" sleduje změny provedené na serveru. Aktuální hodnota je zobrazena v závorkách. + Získávání seznamu složek… + Nepodařilo se získat seznam složek ze serveru + Všechny speciální složky byly nastaveny automaticky na serveru. + Archiv + Koncepty + Odeslané + Spam + Koš + Žádný + Automaticky (%s) + Heslo je vyžadováno. + + Každou minutu + Každé %d minuty + Každých %d minut + Každých %d minut + + + Každou hodinu + Každé %d hodiny + Každých %d hodin + Každých %d hodin + + + 1 zpráva + %d zprávy + %d zpráv + %d zpráv + + diff --git a/feature/account/setup/src/main/res/values-cy/strings.xml b/feature/account/setup/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-cy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-da/strings.xml b/feature/account/setup/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..be614df --- /dev/null +++ b/feature/account/setup/src/main/res/values-da/strings.xml @@ -0,0 +1,22 @@ + + + Konfigurér automatisk + Email signatur + Konfigurér manuelt + StartTLS + Redigér konfiguration + Ukendt fejl + SSL/TLS + Konfiguration ikke fundet + Konto navn + Konfiguration fundet + Email adresse er påkrævet. + Vist navn + Vist navn er påkrævet. + Netværk + Email adresse er ugyldig. + Aldrig + Synkroniserings indstillinger + Vis notifikationer + Visningsindstillinger + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-de/strings.xml b/feature/account/setup/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..0168ba3 --- /dev/null +++ b/feature/account/setup/src/main/res/values-de/strings.xml @@ -0,0 +1,62 @@ + + + Kontoname darf nicht leer sein. + Prüfintervall + Automatisch konfigurieren + Diese Konfiguration ist nicht vertrauenswürdig + E-Mail-Signatur + Anzahl der anzuzeigenden Nachrichten + Manuell konfigurieren + Name der E-Mail-Signatur darf nicht leer sein. + Es ist erforderlich, die Konfiguration zu bestätigen. + StartTLS + Wir haben die Konfiguration für deinen E-Mail-Server über eine Verbindung erhalten, die nicht so sicher ist, wie es wünschenswert wäre. Das bedeutet, dass eine geringe Möglichkeit besteht, dass jemand sie verändert haben könnte. Könntest du bitte die bereitgestellte Konfiguration noch einmal überprüfen, um sicherzustellen, dass sie so ist, wie sie sein sollte\? + Konfiguration bearbeiten + Unbekannter Fehler + SSL/TLS + Konfiguration nicht gefunden + Kontoname + Konfiguration gefunden + E-Mail-Adresse ist erforderlich. + Dein Name + E-Mail-Konfiguration konnte nicht geladen werden + Ich vertraue dieser Konfiguration + Dein Name ist erforderlich. + Netzwerkfehler. Bitte überprüfe deinen Verbindungsstatus und versuche es erneut. + Diese Adresse wird nicht als gültige E-Mail-Adresse erkannt. + Niemals + E-Mail-Konfiguration suchen… + Synchronisierungsoptionen + Benachrichtigungen anzeigen + Anzeigeoptionen + Diese E-Mail-Adresse ist nicht erlaubt. + Diese E-Mail-Adresse wird nicht unterstützt. + Konto erfolgreich erstellt + Konto wird erstellt… + Beim Versuch, das Konto zu erstellen, ist ein Fehler aufgetreten + Alle besonderen Ordner wurden vom Server automatisch konfiguriert. + Automatisch (%s) + Bitte wähle die besonderen Ordner für dein Konto aus. + Archiv + Der Eintrag \"Automatisch\" übernimmt die vom Server vorgenommenen Änderungen. Der aktuelle Serverwert wird in Klammern angezeigt. + Liste der Ordner wird abgerufen… + Liste der Ordner konnte nicht vom Server abgerufen werden + Gesendet + Entwürfe + Papierkorb + Keine + Spam + Passwort ist erforderlich. + + Jede Minute + Alle %d Minuten + + + Jede Stunde + Alle %d Stunden + + + 1 Nachricht + %d Nachrichten + + diff --git a/feature/account/setup/src/main/res/values-el/strings.xml b/feature/account/setup/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..0fc3738 --- /dev/null +++ b/feature/account/setup/src/main/res/values-el/strings.xml @@ -0,0 +1,62 @@ + + + Το όνομα του λογαριασμού δεν μπορεί να είναι κενό. + Αυτή η διεύθυνση email δεν επιτρέπεται. + Συχνότητα ελέγχου + Αυτόματη διαμόρφωση + Αυτή η διαμόρφωση δεν είναι αξιόπιστη + Επιτυχής δημιουργία λογαριασμού + Υπογραφή email + Αριθμός μηνυμάτων προς εμφάνιση + Χειροκίνητη διαμόρφωση + Απαιτείται η έγκριση της διαμόρφωσης. + StartTLS + Λάβαμε τις ρυθμίσεις για τον διακομιστή ηλεκτρονικού ταχυδρομείου σας μέσω μιας σύνδεσης που δεν είναι τόσο ασφαλής όσο θα θέλαμε. Αυτό σημαίνει ότι υπάρχει μια μικρή πιθανότητα κάποιος να την έχει τροποποιήσει. Μπορείτε να ελέγξετε ξανά τις ρυθμίσεις που μας δώσατε για να βεβαιωθείτε ότι είναι όπως πρέπει; + Επεξεργασία διαμόρφωσης + Αυτή η διεύθυνση email δεν υποστηρίζεται. + Άγνωστο σφάλμα + SSL/TLS + Η διαμόρφωση δεν βρέθηκε + Δημιουργία λογαριασμού… + Όνομα λογαριασμού + Η διαμόρφωση βρέθηκε + Απαιτείται διεύθυνση email. + Το όνομά σας + Η φόρτωση της διαμόρφωσης email απέτυχε + Εμπιστεύομαι αυτήν τη διαμόρφωση + Το όνομά σας είναι απαραίτητο. + Προέκυψε σφάλμα κατά την προσπάθεια δημιουργίας του λογαριασμού + Σφάλμα δικτύου. Ελέγξτε την κατάσταση της σύνδεσής σας και προσπαθήστε ξανά. + Η διεύθυνση email δεν αναγνωρίζεται ως έγκυρη. + Ποτέ + Αναζήτηση διαμόρφωσης… + Επιλογές συγχρονισμού + Εμφάνιση ειδοποιήσεων + Επιλογές εμφάνισης + Απαιτείται κωδικός πρόσβασης. + Καθορίστε τους ειδικούς φακέλους για τον λογαριασμό σας. + Φάκελος «Απεσταλμένα» + Φάκελος «Ανεπιθύμητα» + Όλοι οι ειδικοί φάκελοι έχουν ρυθμιστεί αυτόματα από τον διακομιστή. + Αυτόματα (%s) + Φάκελος «Απορρίμματα» + Κανένας + Φάκελος «Προσχέδια» + Φάκελος «Αρχειοθήκη» + Φόρτωση λίστας φακέλων… + Αποτυχία φόρτωσης της λίστας των φακέλων από τον διακομιστή + Το όνομα της υπογραφής email δεν μπορεί να είναι κενό. + + Κάθε ώρα + Κάθε %d ώρες + + + 1 μήνυμα + %d μηνύματα + + Η καταχώριση «Αυτόματη» θα ακολουθεί αυτόματα τις αλλαγές που πραγματοποιούνται από το διακομιστή. Η τρέχουσα τιμή του διακομιστή εμφανίζεται σε παρένθεση. + + Κάθε λεπτό + Κάθε %d λεπτά + + diff --git a/feature/account/setup/src/main/res/values-en-rGB/strings.xml b/feature/account/setup/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..7ae7462 --- /dev/null +++ b/feature/account/setup/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,37 @@ + + + Account name can\'t be blank. + This email address is not allowed. + Check frequency + Configure automatically + This configuration is not trusted + Account successfully created + Email signature + Number of messages to display + Configure manually + Email signature name can\'t be blank. + It is required to approve the configuration. + StartTLS + We received the configuration for your email server over a connection that isn\'t as secure as we\'d like. This means that there is a tiny chance that someone could have altered it. Could you please double-check the provided configuration to make sure it\'s as it should be? + Edit configuration + This email address is not supported. + Unknown error + SSL/TLS + Configuration not found + Creating account… + Account name + Configuration found + Email address is required. + Display name + Failed to load email configuration + I trust this configuration + Display name is required. + An error occurred while trying to create the account + Network error. Please check your connection status and try again. + This is not recognised as a valid email address. + Never + Finding email details + Sync options + Show notifications + Display options + diff --git a/feature/account/setup/src/main/res/values-enm/strings.xml b/feature/account/setup/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-eo/strings.xml b/feature/account/setup/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..8a84cf0 --- /dev/null +++ b/feature/account/setup/src/main/res/values-eo/strings.xml @@ -0,0 +1,47 @@ + + + Pasvorto necesas. + Retpoŝta adreso necesas. + StartTLS + Reta eraro. Bonvolu kontroli vian konektan staton kaj provi denove. + Nekonata eraro + SSL/TLS + Senditujo + Trudmesaĝujo + Malnetujo + Aŭtomate (%s) + Nomo de konto + Via nomo + Kreante konton… + Opcioj pri sinkronigado + + Ĉiuminute + Po unu fojo en %d minutoj + + + Ĉiuhore + Po unu fojo en %d horoj + + + 1 mesaĝo + %d mesaĝoj + + Montri sciigojn + Arĥivujo + Rubujo + Ofto de kontrolado + Neniam + Nenio + Tiu retpoŝtadreso estas ne subtenata. + Tio ne estas rekonata kiel valida retpoŝtadreso. + Tiu retpoŝtadreso estas ne permesata. + Nombro da vidigendaj mesaĝoj + Vidigan opcioj + Eraro okazis dum provo krei la konton + La nomo konton ne povas esti malplena. + Via nomo necesas. + Retletera subskribo + La retletera subskribo ne povas esti malplena. + Konto sukcese kreita + Bonvolu specifi la specialajn dosierujojn por via konto. + diff --git a/feature/account/setup/src/main/res/values-es/strings.xml b/feature/account/setup/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..af57d67 --- /dev/null +++ b/feature/account/setup/src/main/res/values-es/strings.xml @@ -0,0 +1,65 @@ + + + El nombre de cuenta no puede quedar en blanco. + Frecuencia de comprobación + Configurar de forma automática + Estos ajustes no han sido validados + Firma del correo + Cantidad de correos a mostrar + Configurar a mano + La firma del correo no puede quedar en blanco. + Es necesario que primero apruebes los ajustes recibidos. + StartTLS + Hemos recibido la configuración de tu servidor de correo electrónico a través de una conexión que no es tan segura como nos gustaría. Esto significa que existe una pequeña posibilidad de que alguien la haya alterado. Por favor, ¿podrías volver a comprobar la configuración proporcionada para asegurarte de que es como debería ser? + Editar los ajustes + Error desconocido + SSL/TLS + Configuración no encontrada + Nombre de cuenta + Configuración detectada + Es necesario proporcionar al menos una dirección de correo. + Su nombre + No se han podido encontrar ajustes para este servicio + Me fío de los ajustes + Su nombre es requerido. + Error en la red. Comprueba el estado de tu conexión e inténtalo de nuevo. + No se reconoce como una dirección de correo electrónico válida. + Nunca + Buscando la configuración… + Ajustes de sincronización + Mostrar notificaciones + Ajustes de pantalla + Esta dirección de correo electrónico no está permitida. + Esta dirección de correo electrónico no es compatible. + Cuenta creada correctamente + Creando la cuenta… + Se ha producido un error al intentar crear la cuenta + Todas las carpetas especiales han sido configuradas automáticamente por el servidor. + Automático (%s) + Carpeta de borradores + Error al recuperar la lista de carpetas del servidor + Obtención de la lista de carpetas… + Por favor, especifique las carpetas especiales de su cuenta. + Carpeta enviada + Ninguno + Papelera + Carpeta de archivo + Carpeta de spam + La entrada \"Automática\" seguirá automáticamente los cambios realizados por el servidor. El valor actual del servidor se muestra entre paréntesis. + La contraseña es obligatoria. + + Cada minuto + Cada %d minutos + Cada %d minutos + + + Cada hora + Cada %d horas + Cada %d horas + + + 1 mensaje + %d mensajes + %d mensajes + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-et/strings.xml b/feature/account/setup/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..3585b8c --- /dev/null +++ b/feature/account/setup/src/main/res/values-et/strings.xml @@ -0,0 +1,62 @@ + + + Seadista automaatselt + See seadistus ei ole usaldusväärne + Seadista käsitsi + StartTLS + Me laadisime sinu e-postikonto seadistused üle võrguühenduse, mis pole meie arvates piisavalt turvaline. See tähendab, et on pisikene võimalus, et väline osapool on neid muutnud. Palun topeltkontrolli, et näidatud seadistused on sellised, nagu nad peaksid olema, eksole\? + Muuda seadistusi + Tundmatu viga + SSL/TLS + Seadistusi ei õnnestunud leida + Seadistused on tuvastatud + E-posti aadressi sisestamine on kohustuslik. + E-posti konto seadistuste tuvastamine ei õnnestunud + Ma usaldan neid seadistusi + Võrguühenduse viga. Palun kontrolli sinu nutiseadme võrguühenduse toimivust ja proovi siis uuesti. + See e-posti aadress ei tundu olema korrektne. + Tuvastame e-posti konto seadistusi… + Kasutajakonto nimi ei saa olla tühi. + Kirjade kontrollimise sagedus + E-kirja allkiri + Kuvatavate kirjade arv + E-kirja allkiri ei saa olla tühi. + On nõutav, et saa nõustud nende seadistustega. + Kasutajakonto nimi + Sinu nimi + Sinu nimi peab olema sisestatud. + Mitte kunagi + Sünkroniseerimisvalikud + Näita teavitusi + Kuvatavad valikud + Sellise e-posti aadressi kasutamine pole lubatud. + Sellise e-posti aadressi kasutamine pole toetatud. + Kasutajakonto loomine õnnestus + Kasutajakonto on loomisel… + Kasutajakonto loomisel tekkis viga + Kõik määratud kaustad on nimetatud serveri poolt. + Määratud automaatselt (%s) + Mustandite kaust + Kaustade loendi laadimine ei õnnestunud + Laadime kaustade loendit… + Palun seadista oma kasutajakonto jaoks määratud kaustad. + Saadetud kirjade kaust + Määratlemata + Prügikasti kaust + Arhiivi kaust + Spämmi kaust + Märge „Automaatne“ näitab serveri poolt tehtud muudatusi. Hetkel serveri poolt määratud väärtus on kuvatud sulgudes. + Salasõna on vajalik. + + Kord tunnis + Iga %d tunni järel + + + 1 sõnum + %d sõnumit + + + Kord minutis + Iga %d minuti järel + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-eu/strings.xml b/feature/account/setup/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..99d54c5 --- /dev/null +++ b/feature/account/setup/src/main/res/values-eu/strings.xml @@ -0,0 +1,62 @@ + + + Kontu izenak ezin du hutsa egon. + Egiaztatze maiztasuna + Konfiguratu automatikoki + Konfigurazio hori ez da fidagarria + E-mail sinadura + Bistaratuko den mezu kopurua + Eskuz konfiguratu + E-mail sinadura izenak ezin du hutsa izan. + Konfigurazioa onartu egin behar da. + StartTLS + Erabat segurua ez den konexio baten bidez zure e-mail zerbitzariko ezarpenak jaso ditugu. Horrek esan nahi du aukera txiki bat eduki duela norbaitek ibilbidean zehar aldatzeko. Ziurta zenezake emandako konfigurazioa zuzena dela\? + Editatu konfigurazioa + Errore ezezaguna + SSL/TLS + Ez da ezarpenik aurkitu + Kontu izena + Ezarpenak aurkitu dira + E-mail helbidea nahitaezkoa da. + Zure izena + Akatsa e-mail konfigurazioa kargatzerakoan + Konfigurazio honetan konfiantza dut + Zure izena beharrezkoa da. + Sareko errorea. Mesedez, egiaztatu zure konexioaren egoera eta saiatu berriro. + Helbide hau ez da helbide elektroniko zuzentzat jotzen. + Inoiz ere ez + Konfigurazioa bilatzen… + Sinkronizazio ezarpenak + Erakutsi jakinarazpenak + Pantaila ezarpenak + Helbide elektroniko hau ez dago onartuta. + Kontua sortu da + Helbide elektroniko hau ez da bateragarria. + Kontua sortzen… + Errore bat gertatu da kontua sortzerakoan + Zehaztu zure kontuko karpeta bereziak. + Karpeten zerrenda lortzen… + Ezin izan da zerbitzaritik karpeten zerrenda eskuratu + \"Automatiko\" sarrerak zerbitzariak egindako aldaketei jarraituko die automatikoki. Uneko zerbitzariaren balioa parentesi artean bistaratzen da. + Zerbitzariak karpeta berezi guztiak automatikoki konfiguratu ditu. + Artxiboen karpeta + Zirriborroen karpeta + Bidalitakoen karpeta + Spam karpeta + Zaborrontziaren karpeta + Bat ere ez + Automatikoa (%s) + Pasahitza beharrezkoa da. + + Minuturo + %d minuturo + + + Orduro + %d orduro + + + Mezu bat + %d mezu + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-fa/strings.xml b/feature/account/setup/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..bad57b8 --- /dev/null +++ b/feature/account/setup/src/main/res/values-fa/strings.xml @@ -0,0 +1,62 @@ + + + نام حساب نمی تواند خالی باشد. + تمام پوشه‌های ویژه به طور خودکار توسط سرور پیکربندی شده‌اند. + این نشانی رایانامه قابل قبول نیست. + بررسی میزان تکرار + پیکربندی خودکار + خودکار (%s) + پوشه پیش‌نویس‌‌ها + این پیکربندی قابل اعتماد نیست + حساب کاربری با موفقیت ایجاد شد + واکشی لیست پوشه‌ها از سرور با خطا مواجه شد + امضای رایانامه + تعداد پیام‌ها برای نمایش + واکشی لیست پوشه‌ها … + لطفا پوشه‌های خاص را برای حساب خود مشخص کنید. + پیکربندی دستی + نام امضای رایانامه نمی‌تواند خالی باشد. + لازم است که پیکربندی را تایید کنید. + پوشه ارسالی + StartTLS + پیکربندی سرور رایانامه شما روی ارتباطی که به اندازه کافی امن نیست دریافت شد، لطفا یکبار دیگر تنظیمات را با دقت بررسی کرده و از صحت آن اطمینان حاصل نمایید؟ + هیچ‌کدام + ویرایش پیکربندی + این نشانی رایانامه پشتیبانی نمی‌شود. + پوشه زباله‌دان + خطای ناشناخته + SSL/TLS + پیکربندی پیدا نشد + ایجاد حساب … + نام حساب + پیکربندی پیدا شد + پوشه بایگانی + نشانی رایانامه اجباری است. + نام شما + شکست در بار کردن پیکربندی رایانامه + من به این پیکربندی اعتماد دارم + نام نمایشی شما اجباری است. + در هنگام تلاش برای ایجاد حساب خطا رخ داده است + خطای شبکه. لطفا وضعیت اتّصال را بررسی کرده و دوباره تلاش کنید. + این نشانی رایانامه معتبر شناخته نشد. + پوشه هرزنامه + هرگز + گشتن به دنبال پیکربندی… + گزینه‌های همگام‌سازی + نمایش اعلان‌ها + گزینه‌های نمایش + تنظیم \"خودکار\" تغییراتی که توسط سرور به طور خودکار انجام می شود را اعمال می‌کند. مقدار فعلی در داخل پرانتز نمایش داده می‌شود. + گذرواژه لازم است. + + هر %d دقیقه + هر %d دقیقه + + + هر ساعت + هر %d ساعت + + + ۱ پیام + %d پیام + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-fi/strings.xml b/feature/account/setup/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..60ec5ad --- /dev/null +++ b/feature/account/setup/src/main/res/values-fi/strings.xml @@ -0,0 +1,61 @@ + + + Tilin nimi ei voi olla tyhjä. + Tarkistusväli + Määritä automaattisesti + Tähän määritykseen ei luoteta + Sähköpostin allekirjoitus + Näytettävien viestien määrä + Määritä manuaalisesti + Sähköpostin allekirjoituksen nimi ei voi olla tyhjä. + Vaaditaan tämän määrityksen hyväksymiseksi. + StartTLS + Muokkaa määritystä + Tuntematon virhe + SSL/TLS + Määritystä ei löytynyt + Tilin nimi + Määritys löytyi + Sähköpostiosoite vaaditaan. + Nimi + Sähköpostin määrityksen lataaminen epäonnistui + Luotan tähän määritykseen + Nimi vaaditaan. + Verkkovirhe. Tarkista verkkoyhteyden tila ja yritä uudelleen. + Tätä ei tunnistettu kelvolliseksi sähköpostiosoitteeksi. + Ei koskaan + Etsitään määritystä… + Synkronointiasetukset + Näytä ilmoitukset + Näkymäasetukset + Tämä sähköpostiosoite ei ole sallittu. + Tili luotu onnistuneesti + Tämä sähköpostiosoite ei ole tuettu. + Luodaan tiliä… + Tiliä luotaessa tapahtui virhe + Palvelin määritti kaikki erityiset kansiot automaattisesti. + Automaattinen(%s) + Luonnoskansio + Listan kansoista hakeminen palvelimelta epäonnistui + Haetaan listaa kansioista… + Määrittele tilisi erityiset kansiot + Lähetetyt-kansio + Vastaanotimme sähköpostipalvelimesi asetukset yhteydellä, joka ei ollut aivan niin turvallinen kuin haluaisimme. Tämä tarkoittaa, että on pieni mahdollisuus, että joku on muuttanut niitä. Voisitko vielä tarkistaa ne, jotta ne ovat varmasti oikein? + Ei mitään + Roskakorikansio + Arkistokansio + Roskapostikansio + Salasana on pakollinen. + + 1 message + %d messages + + + Minuutin välein + %d minuutin välein + + + Tunnin välein + %d tunnin välein + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-fr/strings.xml b/feature/account/setup/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..5808308 --- /dev/null +++ b/feature/account/setup/src/main/res/values-fr/strings.xml @@ -0,0 +1,65 @@ + + + Le nom du compte ne peut pas être vide. + Fréquence de relève + Configurer automatiquement + Cette configuration n’est pas fiable + Signature du courriel + Nombre de courriels à afficher + Configurer manuellement + La signature du courriel ne peut pas être vide. + Il est obligatoire d’approuver la configuration. + StartTLS + Nous avons reçu la configuration de votre serveur de courriel par une connexion qui n’est pas aussi sécurisée que nous le souhaitions. Cela signifie qu’il y a une infime chance que quelqu’un ait pu la modifier. Pourriez-vous revérifier la configuration fournie pour vous assurer qu’elle est conforme à ce qu’elle devrait être ? + Modifier la configuration + Erreur inconnue + SSL/TLS + La configuration n’a pas été trouvée + Nom du compte + La configuration a été trouvée + L’adresse courriel est requise. + Votre nom + Échec de chargement de la configuration du compte de courriel + Je fais confiance à cette configuration + Votre nom est requis. + Erreur réseau. Vérifiez l’état de votre connexion et réessayez. + Cette adresse courriel n’est pas reconnue comme valide. + Jamais + Découverte de la configuration… + Options de synchronisation + Afficher les notifications + Options d’affichage + Cette adresse courriel n’est pas permise. + Cette adresse courriel n’est pas prise en charge. + Le compte a été créé + Création du compte… + Une erreur est survenue lors de la création du compte + Sélectionnez les dossiers spéciaux de votre compte. + Tous les dossiers spéciaux ont été configurés automatiquement par le serveur. + Automatique (%s) + Dossier des brouillons + Échec d’obtention de la liste des dossiers du serveur + Obtention de la liste des dossiers… + Dossier des courriels envoyés + Aucun + Dossier de la corbeille + Dossier des archives + Dossier du pourriel (indésirables) + L’option « Automatique » suivra automatiquement les changements effectués par le serveur. La valeur actuelle du serveur est affichée entre parenthèses. + Le mot de passe est requis. + + Toutes les minutes + Toutes les %d de minutes + Toutes les %d minutes + + + Toutes les heures + Toutes les %d d’heures + Toutes les %d heures + + + 1 courriel + %d de courriels + %d courriels + + diff --git a/feature/account/setup/src/main/res/values-fy/strings.xml b/feature/account/setup/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..e87c5df --- /dev/null +++ b/feature/account/setup/src/main/res/values-fy/strings.xml @@ -0,0 +1,62 @@ + + + Accountnamme mei net leech wêze. + Kontrôlefrekwinsje + Automatysk konfigurearje + Dizze konfiguraasje is net fertroud + E-mailhantekening + Oantal te toanen berjochten + Hânmjittich konfigurearje + E-mailhantekeningsnamme mei net leech wêze. + Goedkarring fan de konfiguraasje is fereaske. + StartTLS + Wy hawwe de konfiguraasje foar jo e-mailserver fia in ferbining ûntfongen dy’t net sa feilich is as wy graach wolle. Dit betsjut dat der in lytse kâns is dat ien it oanpast hat. Wille jo nochris de opjûne konfiguraasje kontrolearje om hjir wis fan te wêzen? + Konfiguraasje bewurkje + Unbekende flater + SSL/TLS + Konfiguraasje net fûn + Accountnamme + Konfiguraasje fûn + E-mailadres is fereaske. + Jo namme + Laden e-mailkonfiguraasje mislearre + Ik fertrou dizze konfiguraasje + Jo namme is fereaske. + Netwerkflater. Kontrolearje jo netwurkferbining en probearje it opnij. + Dit is net as in jildich e-mailadres werkend. + Nea + Konfiguraasje sykje… + Syngronisaasjeopsjes + Meldingen toane + Werjefteopsjes + Dit e-mailadres is net tastien. + Account mei sukses oanmakke + Dit e-mailadres wurdt net stipe. + Account oanmeitsje… + Der is in flater bard wylst it oanmeitsjen fan in account + Alle spesjale mappen binne automatysk troch de server konfigurearre. + Automatysk (%s) + Konsepten + Opheljen mappelist fan de server is mislearre + Mappelist ophelje… + Spesifisearje de spesjale mappen foar jo account. + Ferstjoerd + Gjin + Jiskefet + Argyf + Net-winske + De ynfier ‘Automatysk’ sil de wizigingen makke troch de server automatysk folgje. De aktuele serverwearde wurdt tusken heakjes toand. + Wachtwurd is fereaske. + + Elke oere + Elke %d oeren + + + 1 berjocht + %d berjochten + + + Elke minút + Elke %d minuten + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ga/strings.xml b/feature/account/setup/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..119ebe5 --- /dev/null +++ b/feature/account/setup/src/main/res/values-ga/strings.xml @@ -0,0 +1,71 @@ + + + SSL/TLS + Ní chuirtear muinín sa chumraíocht seo + Cumraigh de láimh + Cuir cumraíocht in eagar + Liosta fillteán á fháil… + Dréachtaí fillteán + Fillteán bruscar + Dada + Uathoibríoch (%s) + Roghanna taispeána + Do ainm + Tá d’ainm ag teastáil. + + Gach nóiméad + Gach %d nóiméad + Gach %d nóiméad + Gach %d nóiméad + Gach %d nóiméad + + Líon na dteachtaireachtaí le taispeáint + Cumraíocht á lorg… + StartTLS + Ní thacaítear leis an seoladh ríomhphoist seo. + Tá pasfhocal ag teastáil. + Earráid líonra. Seiceáil stádas do cheangail agus bain triail eile as. + Earráid anaithnid + Tá seoladh ríomhphoist ag teastáil. + Ní cheadaítear an seoladh ríomhphoist seo. + Ní aithnítear é seo mar sheoladh ríomhphoist bailí. + Níor aimsíodh an chumraíocht + Theip ar chumraíocht ríomhphoist a lódáil + Aimsíodh an chumraíocht + Cumraigh go huathoibríoch + Tá muinín agam as an gcumraíocht seo + Sonraigh na fillteáin speisialta le do chuntas. + Fillteán seolta + Tá sé riachtanach an chumraíocht a cheadú. + Fillteán spam + Fuaireamar cumraíocht do fhreastalaí ríomhphoist thar nasc nach bhfuil chomh slán agus ba mhaith linn. Ciallaíonn sé seo go bhfuil seans beag ann go bhféadfadh duine éigin é a athrú. An bhféadfá seiceáil faoi dhó ar an gcumraíocht a cuireadh ar fáil le cinntiú go bhfuil sé mar ba chóir? + Leanfaidh an iontráil \"Uathoibríoch\" athruithe a rinne an freastalaí go huathoibríoch. Taispeántar luach reatha an fhreastalaí i lúibíní. + Theip ar liosta na bhfillteán a fháil ón bhfreastalaí + Tá gach fillteán speisialta cumraithe go huathoibríoch ag an bhfreastalaí. + Fillteán cartlainne + Ainm an chuntais + Ní féidir ainm an chuntais a bheith bán. + Ní féidir ainm sínithe ríomhphoist a bheith bán. + Síniú ríomhphoist + Roghanna sioncronaithe + Seiceáil minicíocht + Taispeáin fógraí + D\'éirigh leis an gcuntas a chruthú + Riamh + + Gach uair an chloig + Gach %d uair an chloig + Gach %d uair an chloig + Gach %d uair an chloig + Gach %d uair an chloig + + + 1 teachtaireacht + %d teachtaireacht + %d teachtaireacht + %d teachtaireacht + %d teachtaireacht + + Cuntas á chruthú… + Tharla earráid agus an cuntas a chruthú + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-gd/strings.xml b/feature/account/setup/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..e6e6a3f --- /dev/null +++ b/feature/account/setup/src/main/res/values-gd/strings.xml @@ -0,0 +1,68 @@ + + + Tha feum air facal-faire. + Cha b’ urrainn dhuinn rèiteachadh a’ phuist a luchdadh + Chan eil an seòladh puist-d seo ceadaichte. + Chan eil taic ri seòladh puist-d d’ a leithid. + SSL/TLS + Chan e seòladh puist-d dligheach a tha seo nar beachd. + Rèitich gu fèin-obrachail + Mearachd lìonraidh. Thoir sùil air a’ cheangal agad ris an eadar-lìon is feuch ris a-rithist. + Mearachd neo-aithnichte + Tha feum air seòladh puist-d. + StartTLS + A’ lorg rèiteachadh… + Lorg sinn rèiteachadh + Cha do lorg sinn rèiteachadh + Pasgan nan dreachdan + Pasgan a’ phuist chuirte + Chan fhaod ainm a’ chunntais a bhith bàn. + Chaidh an cunntas a chruthachadh + Chan eil earbsa san rèiteachadh seo + Feumar seo mus gabh an rèiteachadh aontachadh. + Eàrr-sgrìobhadh a’ phuist-d + Sònraich na pasganan sònraichte sa chunntas agad. + A’ faighinn liosta nam pasganan… + D’ ainm + Co mheud teachdaireachd a thèid a shealltainn + A’ cruthachadh a’ chunntais… + Ainm a’ chunntais + Tha earbsa agam san rèiteachadh seo + Chaidh gach pasgan sònraichte a rèiteachadh gu fèin-obrachail leis an fhrithealaiche. + Pasgan an sgudail + + Gach %d mhionaid + Gach %d mhionaid + Gach %d mionaidean + Gach %d mionaid + + + %d teachdaireachd + %d theachdaireachd + %d teachdaireachdan + %d teachdaireachd + + Dh’èirich mearachd fhad ’s a bha sinn a’ cruthachadh a’ chunntais + Pasgan na tasg-lainn + Roghainnean siocronachaidh + Chan eil gin + Chan ann idir + Pasgan an spama + Roghainnean taisbeanaidh + Fèin-obrachail (%s) + Tha feum air d’ ainm. + Chan fhaod eàrr-sgrìobhadh a’ phuist-d a bhith bàn. + Dè cho tric ’s a bheirear sùil + Seall na brathan + Rèitich de làimh + Deasaich an rèiteachadh + Cha b’ urrainn dhuinn liosta nam pasganan fhaighinn on fhrithealaiche + + Gach %d uair a thìde + Gach %d uair a thìde + Gach %d uairean a thìde + Gach %d uair a thìde + + Gabhaidh an t-inneart “Fèin-obrachail” ri atharraichean a rinn am frithealaiche gu fèin-obrachail. Tha luach làithreach an fhrithealaiche ann an eadar-ràdhan. + Fhuair sinn rèiteachadh frithealaiche a’ phuist-d agad air ceangal nach eil buileach tèarainte. ’S ciall dha sin gu bheil cunnart beag ann gu bheil cuideigin air beantainn ris. Thoir sùil eile air an rèiteachadh is dèan cinnteach gu bheil gach rud mar bu chòir. + diff --git a/feature/account/setup/src/main/res/values-gl/strings.xml b/feature/account/setup/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..84654cf --- /dev/null +++ b/feature/account/setup/src/main/res/values-gl/strings.xml @@ -0,0 +1,46 @@ + + + Non se recoñece como unha dirección de correo electrónico válida. + Esta dirección de correo electrónico non é compatible. + Configuración atopada + Configurar automaticamente + Editar configuración + Confío nesta configuración + Obtención da lista de cartafoles… + Arquivar cartafol + Cartafol de borradores + Cartafol de enviados + O teu nome + O nome da firma de correo electrónico non pode estar en branco. + Automático (%s) + Conta creada correctamente + Esta dirección de correo electrónico non está permitida. + Esta configuración non é de confianza + Recibimos a configuración do teu servidor de correo electrónico a través dunha conexión que non é tan segura como nos gustaría. Isto significa que existe unha pequena posibilidade de que alguén a alterou. Por favor, poderías volver comprobar a configuración proporcionada para asegurarche de que é como debería ser? + A entrada «Automático» seguirá automaticamente os cambios realizados polo servidor. O valor actual do servidor móstrase entre paréntese. + Ningún + O nome da conta non pode estar en branco. + Comprobar frecuencia + Requírese contrasinal. + StartTLS + SSL/TLS + Buscando configuración… + Erro ao cargar a configuración de correo electrónico + Configuración non atopada + É necesario para aprobar a configuración. + Especifique os cartafoles especiais da súa conta. + Erro de rede. Comprobe o estado da súa conexión e ténteo de novo. + Erro descoñecido + A dirección de correo electrónico é obrigatoria. + Configurar manualmente + Erro ao recuperar a lista de cartafoles do servidor + Todos os cartafoles especiais foron configuradas automaticamente polo servidor. + Cartafol de correo non desexado + Cartafol da papeleira + Mostrar opcións + Nome da conta + O teu nome é obrigatorio. + Firma de correo electrónico + Opcións de sincronización + Nunca + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-gu/strings.xml b/feature/account/setup/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-hi/strings.xml b/feature/account/setup/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..77bf754 --- /dev/null +++ b/feature/account/setup/src/main/res/values-hi/strings.xml @@ -0,0 +1,33 @@ + + + अकाउंट का नाम खाली नहीं हो सकता। + चेक करने की फ्रीक्वेंसी + अपनेआप सेट करें + इस सेटिंग पे भरोसा नहीं है + ईमेल दस्तखत + मैसेज डिस्प्ले करें + खुद सेट करें + ईमेल दस्तखत नाम खाली नहीं हो सकता। + सेटिंग को मंज़ूरी देने के लिए ज़रूरी। + टीएलएस शुरू करें + हमें आपके ईमेल सर्वर के लिए सेटिंग एक ऐसे कनेक्शन पे मिली जो ज़्यादा सेफ नहीं थी। इसका ये मतलब है कि शायद किसी ने उसके साथ छेड़छाड़ की हो। आप दिए गए सेटिंग को वापस चेक कर लें ताकि ये पक्का कर सके कि सब कुछ सही है। + सेटिंग बदलें + अनजान गड़बड़ + एसएसएल/टीएलएस + सेटिंग नहीं मिली + अकाउंट का नाम + सेटिंग मिल गई + ईमेल पता ज़रूरी है। + डिस्प्ले नाम + ईमेल की सेटिंग लोड नहीं कर सके + हम इस सेटिंग पे भरोसा करते है + डिस्प्ले नाम ज़रूरी है। + नेटवर्क + ईमेल पता गलत है। + कभी नहीं + ईमेल की जानकारी खोज रहे + सिंक ऑप्शन + नोटिफिकेशन दिखाएं + डिस्प्ले ऑप्शन + ये ईमेल पता गलत है। + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-hr/strings.xml b/feature/account/setup/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..a5c1e8d --- /dev/null +++ b/feature/account/setup/src/main/res/values-hr/strings.xml @@ -0,0 +1,4 @@ + + + Mrežna pogreška. Provjerite status veze i pokušajte ponovno. + diff --git a/feature/account/setup/src/main/res/values-hu/strings.xml b/feature/account/setup/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..2271fbb --- /dev/null +++ b/feature/account/setup/src/main/res/values-hu/strings.xml @@ -0,0 +1,62 @@ + + + A fióknév nem lehet üres. + Ellenőrzési gyakoriság + Automatikus konfigurálás + Ez a konfiguráció nem megbízható. + E-mail-aláírás + Megjelenítendő üzenetek száma + Kézi beállítás + Az e-mail-aláírásnév nem lehet üres. + A konfiguráció jóváhagyása szükséges. + StartTLS + Az e-mail-kiszolgáló konfigurációját olyan kapcsolaton keresztül érkezett, amely nem tekinthető biztonságosnak. Ez azt jelenti, hogy előfordulhat, hogy valaki megváltoztatta. Ellenőrizze még egyszer a megadott konfigurációt, hogy megbizonyosodjon arról, hogy olyan, amilyennek lennie kell. + Konfiguráció szerkesztése + Ismeretlen hiba + SSL/TLS + Nem található konfiguráció + Fióknév + Konfiguráció megtalálva + Az e-mail-cím megadása kötelező. + Saját név + Nem sikerült betölteni az e-mail-konfigurációt. + Megbízom a konfigurációban + A saját név kötelező. + Hálózati hiba. Ellenőrizze a kapcsolat állapotát, és próbálja meg újra. + Az e-mail-cím nem tekinthető érvényes e-mail-címnek. + Soha + Konfiguráció keresése… + Szinkronizálási beállítások + Értesítések megjelenítése + Megjelenítési beállítások + Ez az e-mail-cím nem engedélyezett. + Ez az e-mail-cím nem támogatott. + A jelszó megadása kötelező. + Archívum mappa + Piszkozatok mappa + Mappalista lekérése… + A mappalista lekérése a kiszolgálótól sikertelen + Levélszemét mappa + A fiók sikeresen létre lett hozva + Hiba történt a fiók létrehozása során + Kuka mappa + Az „Automatikus” lehetőség automatikusan követi a kiszolgáló módosításait. A jelenlegi kiszolgálóérték zárójelben látható. + Az összes speciális mappát automatikusan állította be a kiszolgáló. + Nincs + Elküldött mappa + Automatikus (%s) + Adja meg a fiókja speciális mappáit. + Fiók létrehozása… + + Percenként + %d percenként + + + Óránként + %d óránként + + + 1 üzenet + %d üzenet + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-hy/strings.xml b/feature/account/setup/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-in/strings.xml b/feature/account/setup/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..3e15352 --- /dev/null +++ b/feature/account/setup/src/main/res/values-in/strings.xml @@ -0,0 +1,59 @@ + + + Entri \"Otomatis\" akan mengikuti perubahan yang dilakukan oleh peladen secara otomatis. Nilai peladen saat ini ditampilkan dalam tanda kurung. + Mengambil daftar folder… + Silakan tentukan folder khusus untuk akun Anda. + Gagal mengambil daftar folder dari peladen + Semua folder khusus telah dikonfigurasi secara otomatis oleh peladen. + Folder arsip + Folder spam + Folder sampah + Folder terkirim + Tampilkan notifikasi + Tidak pernah + Frekuensi pengecekan + Jumlah pesan yang akan ditampilkan + Kata sandi diperlukan. + Kami menerima kknnfigurasi peladen surel Anda melalui koneksi jaringan yang tidak seaman yang kami hendaki. Hal ini memberikan celah kecil bagi orang lain untuk dapat mengubah peladen surel Anda. Dapatkah Anda periksa ulang konfigurasi tersebut untuk memastikan bahwa konfigurasi tersebut sudah benar? + Sunting konfigurasi + Saya memercayai konfigurasi ini + Persetujuan Anda akan konfigurasi ini diperlukan. + Folder draf + Tidak ada + Otomatis (%s) + Nama akun + Pilihan tampilan + Nama akun tidak boleh kosong. + Nama Anda + Nama Anda diperlukan. + Tanda tangan surel + Nama tanda tangan surel tidak boleh kosong. + Pilihan penyinkronan + Membuat akun… + Terdapat galat saat mencoba untuk membuat akun + Akun berhasil dibuat + Terdapat galat jaringan. Harap periksa status koneksi Anda dan coba lagi. + Galat tak diketahui + Alamat surel diperlukan. + Alamat surel ini tidak diperbolehkan. + Alamat surel ini tidak didukung. + Yang Anda masukkan tidak diakui sebagai alamat surel yang valid. + StartTLS + Konfigurasikan secara manual + + %d pesan + + SSL/TLS + Mencari konfigurasi… + Gagal memuat konfigurasi surel + Konfigurasi ditemukan + Konfigurasi tidak ditemukan + Konfigurasikan secara otomatis + Konfigurasi ini tidak dipercaya + + Setiap %d menit + + + Setiap %d jam + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-is/strings.xml b/feature/account/setup/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..ff55d9c --- /dev/null +++ b/feature/account/setup/src/main/res/values-is/strings.xml @@ -0,0 +1,62 @@ + + + Villa í netkerfi. Athugaðu tenginguna þína og prófaðu aftur. + Heiti notandaaðgangs má ekki vera tómt. + Tíðni athugana + Stilla sjálfvirkt + Þessari uppsetningu er ekki treyst + Undirskrift tölvupósts + Fjöldi skilaboða sem á að birta + Stilla handvirkt + Undirritun tölvupósts má ekki vera tóm. + Þetta er nauðsynlegt til að samþykkja uppsetninguna. + StartTLS + Breyta uppsetningu + Óþekkt villa + SSL/TLS + Uppsetning fannst ekki + Heiti notandaaðgangs + Uppsetning fannst + Tölvupóstfang er nauðsynlegt. + Nafnið þitt + Mistókst að hlaða inn tölvupóststillingum + Ég treysti þessari uppsetningu + Nafnið þitt er nauðsynlegt. + Þetta telst ekki vera gilt tölvupóstfang. + Aldrei + Skoða grunnstillingar… + Valkostir samstillinga + Birta tilkynningar + Valkostir birtingar + Við höfum tekið við uppsetningunni fyrir póstþjóninn um tengingu sem ekki er eins örugg og við myndum vilja. Þetta þýðir að það eru einhverjar líkur á að einhver hafi komist í upplýsingarnar og breytt þeim. Geturðu yfirfarið þessar upplýsingar og gengið úr skugga um að allt sé eins og það á að vera\? + Þetta tölvupóstfang er ekki leyfilegt. + Tókst að útbúa aðganginn + Þetta tölvupóstfang er ekki stutt. + Útbý aðgang… + Villa kom upp við að útbúa aðganginn + Sjálfvirkt (%s) + Drög-mappa + Mistókst að sækja lista yfir möppur af póstþjóninum + Sæki lista yfir möppur… + Skilgreindu sérmöppurnar fyrir aðganginn þinn. + Sent-mappa + Ekkert + Rusl-mappa + Safnmappa + Ruslpóstmappa + \"Sjálfvirkt\" færslan mun fylgja sjálfkrafa þeim breytingum sem póstþjónninn gerir. Fyrirliggjandi gildi þjónsins eru birt innan sviga. + Lykilorð er nauðsynlegt. + Allar sérmöppur hafa verið stilltar sjálfkrafa af póstþjóninum. + + Á mínútu fresti + Á %d mínútna fresti + + + Á klukkustundar fresti + Á %d klukkustunda fresti + + + 1 skilaboð + %d skilaboð + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-it/strings.xml b/feature/account/setup/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..c306a7f --- /dev/null +++ b/feature/account/setup/src/main/res/values-it/strings.xml @@ -0,0 +1,65 @@ + + + Nome account non può essere vuoto + Frequenza controllo + Configura automaticamente + Questa configurazione non è attendibile + Firma email + Numero di messaggi da visualizzare + Configura manualmente + Il nome della firma email non può essere vuoto + È necessario approvare la configurazione + StartTLS + Abbiamo ricevuto la configurazione del server di posta elettronica tramite una connessione non sicura come vorremmo. C\'è dunque una possibilità che qualcuno possa averla modificata. Potresti ricontrollare la configurazione per assicurarti che sia affidabile\? + Modifica configurazione + Errore sconosciuto + SSL/TLS + Configurazione non trovata + Nome account + Configurazione trovata + Indirizzo email obbligatorio + Il tuo nome + Impossibile caricare configurazione email + Mi fido di questa configurazione + Il tuo nome è obbligatorio. + Errore di rete. Controlla lo stato della tua connessione e riprova. + Non è riconosciuto come indirizzo email valido + Mai + Ricerca della configurazione… + Opzioni di sincronizzazione + Mostra notifiche + Opzioni di visualizzazione + Questo indirizzo email non è consentito + Creazione account completata + Questo indirizzo email non è supportato + Creazione account… + Errore nel tentativo di creazione account + Tutte le cartelle speciali sono state configurate automaticamente dal server + Automatico (%s) + Cartella bozze + Impossibile recuperare la lista delle cartelle dal server + Recupero lista cartelle… + Imposta le cartelle speciali per il tuo account + Cartella inviati + Nessuno + Cartella cestino + Cartella archivio + Cartella spam + L\'opzione \"Automatico\" seguirà le modifiche apportate dal server. Il valore corrente del server viene visualizzato tra parentesi. + + 1 messaggio + %d messaggi + %d messaggi + + + Ogni ora + Ogni %d ore + Ogni %d ore + + La password è richiesta. + + Ogni minuto + Ogni %d minuti + Ogni %d minuti + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-iw/strings.xml b/feature/account/setup/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..7bf8426 --- /dev/null +++ b/feature/account/setup/src/main/res/values-iw/strings.xml @@ -0,0 +1,67 @@ + + + תיקיית ארכיון + תיקיית פריטים שנשלחו + תיקיית דואר זבל + תיקיית פריטים שנמחקו + אוטומטי (%s) + כלום + שם חשבון + שם חשבון אינו יכול להיות ריק. + שם תצוגה + דרוש שם תצוגה. + שם חתימת דוא\"ל לא יכול להיות ריק. + חתימת דוא\"ל + תדירות בדיקה + אף פעם + יוצר חשבון… + אירעה שגיאה בעת ניסיון יצירת החשבון + דרושה סיסמה. + תיקיית טיוטות + הצג אפשרויות + אפשרויות סנכרון + כמות הודעות להצגה + שגיאת רשת. בדוק את מצב החיבורים שלך ונסה שנית. + שגיאה לא ידועה + דרושה כתובת דוא\"ל. + כתובת דוא\"ל זו לא מוכרת ככתובת דוא\"ל חוקית. + StartTLS + כתובת הדוא\"ל הזאת לא מותרת. + כתובת הדוא\"ל הזאת לא נתמכת. + טעינת תצורת דוא\"ל נכשלה + SSL/TLS + מחפש תצורה… + + כל שעה + כל שעתיים + כל %d שעות + כל %d שעות + + + כל דקה + כל שתי דקות + כל %d דקות + כל %d דקות + + + הודעה אחת + שתי הודעות + %d הודעות + + הצג התראות + החשבון נוצר בהצלחה + תצורת כל התיקיות המיוחדות נעשתה אוטומטית ע\"י השרת. + מביא רשימת תיקיות… + הבאת רשימת התיקיות מהשרת נכשלה + תצורה נמצאה + לא נמצאה תצורה + תצורה אוטומטית + תצורה זו אינה מהימנה + תצורה ידנית + ערוך תצורה + אני סומך על תצורה זו + נדרש לאשר את התצורה. + אנא ציין את התיקיות המיוחדות עבור החשבון שלך. + הערך \"אוטומטי\" יעקוב אחר שינויים שנעשים ע\"י השרת באופן אוטומטי. ערך השרת הנוכחי מופיע בסוגריים. + קיבלנו את התצורה עבור שרת הדוא\"ל שלך דרך חיבור שאינו מאובטח כפי שהיינו רוצים. משמעות הדבר היא שיש סיכוי זעיר שמישהו יכול היה לשנות אותה. האם תוכל בבקשה לבדוק שוב את התצורה שסופקה כדי לוודא שהיא כפי שהיא צריכה להיות? + diff --git a/feature/account/setup/src/main/res/values-ja/strings.xml b/feature/account/setup/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..5140d57 --- /dev/null +++ b/feature/account/setup/src/main/res/values-ja/strings.xml @@ -0,0 +1,59 @@ + + + アカウント名は空白にはできません。 + 確認頻度 + 自動で設定します + この設定は信頼されていません + メール署名 + 表示するメッセージの数 + 手動設定 + メール署名は空白にはできません。 + 設定を承認する必要があります。 + StartTLS + 理想的に安全とはいえない接続からメールサーバーの設定情報を受信しました。これは、誰かに変更された可能性がごくわずかに存在することを意味します。受信した設定をダブルチェックして、問題ないことを確かめてください。 + 設定を編集 + 不明なエラーが発生しました + SSL/TLS + 設定が見つかりませんでした + アカウント名 + 設定が見つかりました + メールアドレスは必須です。 + あなたのお名前 + メールの設定が見つかりませんでした + この設定を信頼します + あなたのお名前は必須です。 + ネットワークエラーが発生しました。接続状況を確認してもう一度お試しください。 + 有効なメールアドレスとして認識されませんでした。 + 確認しない + メールの設定を確認中… + 同期設定 + 通知を表示する + 表示設定 + このメールアドレスは許可されていません。 + このメールアドレスには対応していません。 + アカウントは正常に作成されました + アカウントを作成中… + アカウントの作成中にエラーが発生しました + すべての特別なフォルダーが、サーバーにより自動的に設定されました。 + 自動 (%s) + 下書きフォルダー + サーバーからフォルダー一覧を取得できませんでした + フォルダー一覧を取得中… + アカウントの特別なフォルダーを指定してください。 + 送信済みトレイフォルダー + なし + ごみ箱フォルダー + アーカイブフォルダー + 迷惑メールフォルダー + 「自動」を設定すると、サーバーで設定が変更されても自動的に追従します。現在、サーバーに設定された値が括弧内に表示されています。 + + %d 分ごと + + パスワードが必要です。 + + %d 時間ごと + + + メッセージ %d 通 + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ka/strings.xml b/feature/account/setup/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-kab/strings.xml b/feature/account/setup/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..5dc3313 --- /dev/null +++ b/feature/account/setup/src/main/res/values-kab/strings.xml @@ -0,0 +1,23 @@ + + + Tuccḍa tarussint + StartTLS + SSL/TLS + Awal n uɛeddi yettwasra. + Ula yiwen + Awurman (%s) + Isem n umiḍan + Isem-ik·im + Azmul n imayl + Werǧin + Sken-d ilɣa + Asnulfu n umiḍan… + Twila s wudem awurman + Twila s ufus + Ẓreg twila + Tixtiṛiyin n ubeqqeḍ + + %d n yizen + %d n yiznan + + diff --git a/feature/account/setup/src/main/res/values-kk/strings.xml b/feature/account/setup/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..e0767c1 --- /dev/null +++ b/feature/account/setup/src/main/res/values-kk/strings.xml @@ -0,0 +1,5 @@ + + + Белгісіз қате + Пароль керек. + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ko/strings.xml b/feature/account/setup/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..2783ca5 --- /dev/null +++ b/feature/account/setup/src/main/res/values-ko/strings.xml @@ -0,0 +1,59 @@ + + + 알 수 없는 오류 + 네트워크 오류가 발생했습니다. 연결 상태를 확인한 후 다시 시도해 주세요. + 보관함 + 서버에서 폴더 목록을 가져오지 못했습니다 + 보낸편지함 + 임시 보관함 + 스팸함 + 휴지통 + 계정 생성중… + 계정 이름은 비워둘 수 없습니다. + 비밀번호가 필요합니다. + SSL/TLS + 구성을 찾는중… + 이메일 주소가 필요합니다. + 해당 이메일 주소는 허용되지 않습니다. + 해당 이메일 주소는 지원되지 않습니다. + 해당 이메일 주소는 유효하지 않습니다. + 화면 설정 + 계정 이름 + 자동 (%s) + 당신의 이름은 필수입니다. + + %d 분마다 + + + 메시지 %d 개 + + 성공적으로 계정이 생성됨 + 폴더 리스트를 가져오는 중… + 모든 특수 폴더는 서버에 의해 자동으로 구성됩니다. + 없음 + 당신의 이름 + 표시할 메시지 수 + 확인 빈도 + 계정을 만들려는 동안 오류가 발생함 + 구성을 승인해야 합니다. + 계정의 특수 폴더를 지정해 주세요. + \"자동\" 항목은 서버가 변경한 내용을 자동으로 따릅니다. 현재 서버 값은 괄호 안에 표시됩니다. + 이메일 서명 + 동기화 옵션 + 이메일 서명은 비워둘 수 없습니다. + + %d 시간마다 + + StartTLS + 설정을 찾음 + 이메일 설정 로딩 실패 + 설정을 찾을 수 없음 + 자동으로 설정하기 + 이 구성을 신뢰할 수 없음 + 보안이 취약한 연결을 통해 이메일 서버 설정을 수신했습니다. 즉, 누군가 설정을 변경했을 가능성이 작지만 있다는 뜻입니다. 제공된 설정이 올바른지 다시 한 번 확인해 주시겠습니까? + 수동으로 구성하기 + 구성 수정 + 이 설정을 신뢰함 + 알림 보기 + 사용 안 함 + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-lt/strings.xml b/feature/account/setup/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..b2b285d --- /dev/null +++ b/feature/account/setup/src/main/res/values-lt/strings.xml @@ -0,0 +1,68 @@ + + + Gaunamas aplankų sąrašas… + Nepavyko gauti aplankų sąrašo iš serverio + Visi specialieji aplankai automatiškai sukonfigūruoti remiantis serverio parametrais. + Archyvo aplankas + Juodraščių aplankas + Brukalo aplankas + Šiukšlinės aplankas + Jokio + Automatinis (%s) + Paskyros pavadinimas negali būti tuščias. + Jūsų vardas + Jūsų vardas būtinas. + Laiškų parašo pavadinimas negali būti tuščias. + Laiškų parašas + Tikrinimo dažnis + Niekada + Rodomų laiškų kiekis + Išsiųstųjų aplankas + + Kas %d minutę + Kas %d minutes + Kas %d minučių + Kas %d minučių + + Rodyti pranešimus + Jūsų el. pašto serverių konfigūraciją gavome ne tokiu saugiu ryšiu, kokiu norėtume. Tai reiškia, jog yra nedidelė galimybė, kad ji buvo pakeista. Prašom patikrinti, jog žemiau nurodyta konfigūracija yra teisinga. + Prašom nurodyti šios paskyros specialiuosius aplankus. + Pasirinkus „Automatinis“, bus automatiškai sekami serveryje atlikti pakeitimai. Esamoji reikšmė nurodyta skliaustuose. + Slaptažodį įvesti privaloma. + Ieškoma konfigūracijos… + Nepavyko įkelti el. pašto konfigūracijos + Konfigūracija aptikta + Konfigūracija neaptikta + Konfigūruoti automatiškai + Šia konfigūracija nepasitikima + Konfigūruoti rankiniu būdu + Taisyti konfigūraciją + Pasitikiu šia konfigūracija + Tai būtina konfigūracijai patvirtinti. + Sinchronizavimo nustatymai + Kuriama paskyra… + Paskyra sėkmingai sukurta + Tinklo klaida. Patikrinkite tinklo nustatymus ir bandykite dar kartą. + Nenumatyta klaida + El. pašto adresą įvesti būtina. + Šis el. pašto adresas neleidžiamas. + Šis el. pašto adresas nepalaikomas. + Įvestas tekstas nepanašus į el. pašto adresą. + StartTLS + SSL/TLS + Rodymo nustatymai + Paskyros pavadinimas + + Kas %d valandą + Kas %d valandas + Kas %d valandų + Kas %d valandų + + + %d laiškas + %d laiškai + %d laiškų + %d laiškų + + Kuriant paskyrą, įvyko klaida + diff --git a/feature/account/setup/src/main/res/values-lv/strings.xml b/feature/account/setup/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ml/strings.xml b/feature/account/setup/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/account/setup/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-nb-rNO/strings.xml b/feature/account/setup/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..6948464 --- /dev/null +++ b/feature/account/setup/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,62 @@ + + + Kontonavn må fylles ut. + Sjekkfrekvens + Sett opp automatisk + Dette oppsettet er ikke tiltrodd + E-postsignatur + Antall meldinger å vise + Sett opp manuelt + E-postsignatur må fylles ut. + Det kreves for å godkjenne oppsettet. + StartTLS + Vi mottok oppsettet for din e-posttjener på en tilkobling som ikke er så sikker som vi hadde ønske. Dette betyr at det er en mikroskopisk sjanse for at noen kan ha endret den. Kan du dobbeltsjekke at det angitte oppsettet er slik det skal være? + Rediger oppsett + Ukjent feil + SSL/TLS + Fant ikke noe oppsett + Kontonavn + Fant et oppsett + E-postadresse kreves. + Ditt navn + Klarte ikke å laste inn e-postoppsett + Jeg stoler på dette oppsettet + Navnet ditt kreves. + Nettverksfeil. Vennligst sjekk tilkoblingen din og prøv på nytt. + Dette gjenkjennes ikke som en gyldig e-postadresse. + Aldri + Finner oppsett … + Synkroniseringsalternativer + Vis merknader + Visningsalternativer + Denne e-postadressen tillates ikke. + Denne e-postadressen støttes ikke. + Angi spesialmapper for din konto. + Kladdmappe + Henter mappeliste … + Klarte ikke å hente mappeliste fra tjeneren + Alle spesialmapper har blitt satt opp automatisk av tjeneren. + Papirkurvsmappe + Ingen + En feil oppstod under opprettelse av kontoen + Kontoen ble vellykket opprettet + Oppretter konto … + «Sendt»-mappe + Søppelpostmappe + Automatisk (%s) + Arkivmappe + «Automatisk»-oppføringen følger endringer gjort av tjeneren automatisk. Nåværende tjenerverdi vises i parenteser. + + Hvert minutt + Hvert %d. minutt + + Passord kreves. + + Hver time + Hver %d. time + + + 1 melding + %d meldinger + + diff --git a/feature/account/setup/src/main/res/values-nl/strings.xml b/feature/account/setup/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..cc5ba60 --- /dev/null +++ b/feature/account/setup/src/main/res/values-nl/strings.xml @@ -0,0 +1,62 @@ + + + Accountnaam mag niet leeg zijn. + Controlefrequentie + Automatisch configureren + Deze configuratie is niet vertrouwd + E-mailhandtekening + Aantal te tonen berichten + Handmatig configureren + E-mailhandtekening mag niet leeg zijn. + Goedkeuring van de configuratie is vereist. + StartTLS + We hebben de configuratie voor uw e-mailserver via een verbinding ontvangen die niet zo veilig is als we graag willen. Dit betekent dat er een kleine kans is dat iemand het heeft aangepast. Wilt u nogmaals de opgegeven configuratie controleren om hier zeker van te zijn? + Configuratie bewerken + Onbekende fout + SSL/TLS + Configuratie niet gevonden + Accountnaam + Configuratie gevonden + E-mailadres is vereist. + Uw naam + Laden e-mailconfiguratie mislukt + Ik vertrouw deze configuratie + Uw naam is vereist. + Netwerkfout. Controleer uw netwerkverbinding en probeer het opnieuw. + Dit is niet als een geldig e-mailadres herkend. + Nooit + Configuratie zoeken… + Synchronisatieopties + Meldingen tonen + Weergaveopties + Dit e-mailadres is niet toegestaan. + Account met succes aangemaakt + Dit e-mailadres wordt niet ondersteund. + Account aanmaken… + Er is een fout opgetreden tijdens het aanmaken van deze account + Alle speciale mappen zijn automatisch door de server geconfigureerd. + Automatisch (%s) + Concepten + Ophalen mappenlijst van de server is mislukt + Mappenlijst ophalen… + Specificeer de speciale mappen voor uw account. + Verzonden + Geen + Prullenbak + Archief + Spam + De invoer ‘Automatisch’ zal de wijzigingen gemaakt door de server automatisch volgen. De actuele serverwaarde wordt tussen haakjes getoond. + Wachtwoord is vereist. + + Elk uur + Elke %d uur + + + 1 bericht + %d berichten + + + Elke minuut + Elke %d minuten + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-nn/strings.xml b/feature/account/setup/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..425add3 --- /dev/null +++ b/feature/account/setup/src/main/res/values-nn/strings.xml @@ -0,0 +1,62 @@ + + + StartTLS + Slår opp konfigurasjon… + Kontonamn + Automatisk konfigurering + Synkroniseringsalternativ + Aldri + Vis merknader + «Sendt»-mappe + Ein feil oppstod under oppretting av kontoen + Opprettar konto… + Konto oppretta + + Kvart minutt + Kvart %d. minutt + + Fann konfigurasjonen + Fann ikkje konfigurasjonen + Passord er påkravd. + Manuell konfigurasjon + Rediger konfigurasjon + Det er påkravd for å godkjenne konfigurasjonen. + Arkivmappe + Kladdemappe + Spam-mappe + Papirkorgmappe + Ingen + Automatisk (%s) + Visingsalternativ + Namnet ditt er påkravd. + Ditt namn + Denne e-postadressa er ikkje støtta. + E-postsignatur må fyllast ut. + Ukjend feil + E-postadresse påkravd. + Denne e-postadressa er ikkje tillaten. + Dette blir ikkje gjenkjent som ei gyldig e-postadresse. + SSL/TLS + Klarte ikkje å laste inn e-postkonfigurasjonen + Denne konfigurasjonen er ikkje tiltrudd + Eg stolar på denne konfigurasjonen + Kontonamn må fyllast ut. + E-postsignatur + Antal meldingar å vise + + Kvar time + Kvar %d. time + + + 1 melding + %d meldingar + + Nettverksfeil. Sjekk nettverkstatus, og prøv igjen. + Spesifiser spesialmapper for kontoen din. + Hentar liste over mapper… + Kunne ikkje hente liste over mapper frå tenaren + Alle spesialmapper har vorte konfigurert automatisk av tenaren. + Sjekk frekvens + \"Automatisk\"-oppføringa vil følgje endringar frå tenaren automatisk. Den gjeldande tenar-verdien er vist i parantes. + Me mottok konfigurasjonen for e-post-tenaren din over ein tilkopling som ikkje var så sikker som med hadde likt. Det betyr at det er ein liten sjanse nokon kan ha tukla med den. Kan du dobbelsjekke at den mottekne konfigurasjonen er rett? + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-pl/strings.xml b/feature/account/setup/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..30d43cb --- /dev/null +++ b/feature/account/setup/src/main/res/values-pl/strings.xml @@ -0,0 +1,68 @@ + + + Nazwa konta nie może być pusta. + Częstotliwość sprawdzania + Skonfiguruj automatycznie + Ta konfiguracja nie jest zaufana + Podpis e-mail + Liczba wiadomości do wyświetlenia + Skonfiguruj ręcznie + Nazwa podpisu e-mail nie może być pusta. + Wymagane jest zatwierdzenie konfiguracji. + StartTLS + Otrzymaliśmy konfigurację Twojego serwera poczty e-mail za pośrednictwem połączenia, które nie jest tak bezpieczne, jak byśmy tego chcieli. Oznacza to, że istnieje niewielka szansa, że ktoś mógł je zmienić. Czy możesz dokładnie sprawdzić podaną konfigurację, aby upewnić się, że jest taka, jak powinna\? + Edytuj konfigurację + Nieznany błąd + SSL/TLS + Nie znaleziono konfiguracji + Nazwa konta + Znaleziono konfigurację + Adres e-mail jest wymagany. + Twoje imię i nazwisko + Nie udało się wczytać konfiguracji poczty e-mail + Ufam tej konfiguracji + Twoje imię i nazwisko jest wymagane. + Błąd sieci. Sprawdź stan połączenia i spróbuj ponownie. + Nie jest rozpoznawany jako prawidłowy adres e-mail. + Nigdy + Sprawdzanie konfiguracji… + Opcje synchronizacji + Pokaż powiadomienia + Opcje wyświetlania + Ten adres e-mail jest niedozwolony. + Ten adres e-mail nie jest obsługiwany. + Konto pomyślnie utworzone + Tworzenie konta… + Wystąpił błąd podczas próby utworzenia konta + Wszystkie foldery specjalne zostały skonfigurowane automatycznie przez serwer. + Automatyczny (%s) + Folder wersji roboczych + Nie udało się pobrać listy folderów z serwera + Pobieranie listy folderów… + Określ foldery specjalne dla swojego konta. + Folder wysłanych + Żaden + Folder kosza + Folder archiwum + Folder spamu + Pozycja „Automatyczny” będzie podążać automatycznie za zmianami wprowadzonymi przez serwer. Bieżąca wartość serwera jest wyświetlana w nawiasach. + Hasło jest wymagane. + + Co minutę + Co %d minuty + Co %d minut + Co %d minut + + + Co godzinę + Co %d godziny + Co %d godzin + Co %d godzin + + + 1 wiadomość + %d wiadomości + %d wiadomości + %d wiadomości + + diff --git a/feature/account/setup/src/main/res/values-pt-rBR/strings.xml b/feature/account/setup/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..ac66c08 --- /dev/null +++ b/feature/account/setup/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,65 @@ + + + Nome da conta não pode estar vazio. + Frequência de verificação + Configurar automaticamente + Essa configuração não é confiável + Assinatura de email + Número de mensagens a exibir + Configurar manualmente + O nome da assinatura de email não pode estar vazio. + É necessário aprovar a configuração. + StartTLS + Recebemos a configuração do servidor de seu email por meio de uma conexão que não é tão segura quanto gostaríamos. Significa que há uma pequena chance de alguém a ter alterado. Você pode verificar novamente a configuração fornecida para ter certeza que está correta? + Editar configuração + Erro desconhecido + SSL/TLS + Configuração não encontrada + Nome da conta + Configuração encontrada + Endereço de email é obrigatório. + Seu nome + Falha ao carregar configuração de email + Eu confio nesta configuração + Seu nome é obrigatório. + Erro de rede. Verifique sua conexão e tente novamente. + Este endereço de email não é reconhecido como válido. + Nunca + Procurando configuração… + Opções de sincronização + Mostrar notificações + Opções de exibição + Este endereço de email não é permitido. + Conta criada com sucesso + Não há suporte para este endereço de email. + Criando conta… + Ocorreu um erro ao tentar criar a conta + O item \"Automático\" seguirá automaticamente mudanças feitas pelo servidor. O valor atual do servidor é exibido entre parênteses. + Automático (%s) + Obtendo lista de pastas… + Todas as pastas especiais foram configuradas automaticamente pelo servidor. + Pasta de spam + Pasta da lixeira + Nenhum + Senha é obrigatória. + Especifique as pastas especiais da sua conta. + Falha ao obter lista de pastas do servidor + Pasta de arquivamento + Pasta de rascunhos + Pasta de enviados + + A cada minuto + A cada %d minutos + A cada %d minutos + + + A cada hora + A cada %d horas + A cada %d horas + + + 1 mensagem + %d mensagens + %d mensagens + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-pt-rPT/strings.xml b/feature/account/setup/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..d391f3c --- /dev/null +++ b/feature/account/setup/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,65 @@ + + + Nome da conta não pode estar em branco. + Este endereço de e-mail não é permitido. + Frequência de verificação + Configurar automaticamente + Esta configuração não é confiável + Conta criada com sucesso + Assinatura de e-mail + Número de mensagens a mostrar + Configurar manualmente + Assinatura de e-mail não pode ser vazia. + É mandatório para aprovar a configuração. + StartTLS + Recebemos a configuração do seu servidor de correio eletrónico através de uma ligação que não é tão segura como gostaríamos. Isto significa que existe uma pequena hipótese de alguém a poder ter alterado. Pode verificar novamente a configuração fornecida para se certificar de que está como deve estar? + Editar configuração + Este endereço de e-mail não é suportado. + Erro desconhecido + SSL/TLS + Configuração não encontrada + A criar conta… + Nome da conta + Configuração encontrada + Endereço de email necessário. + O seu nome + Falha ao carregar configurações de email + Eu confio nesta configuração + O seu nome é obrigatório. + Ocorreu um erro ao tentar criar a conta + Erro de rede. Verifique o estado da sua conexão e tente novamente. + Não reconhecido como um endereço de email válido. + Nunca + A procurar configuração… + Opções de sincronização + Mostrar notificações + Opções de visualização + Por favor indique as pastas especiais para a sua conta. + A entrada \"Automático\" seguirá automaticamente as alterações efetuadas pelo servidor. O valor atual do servidor é apresentado entre parênteses. + A obter a lista de pastas… + Falha ao obter a lista de pastas do servidor + Pasta de arquivo + Pasta de enviados + Todas as pastas especiais foram configuradas automaticamente pelo servidor. + Pasta de rascunhos + Pasta do lixo + Nenhum + Automático (%s) + Password necessária. + Pasta de spam + + A cada minuto + A cada %d minutos + A cada %d minutos + + + A cada hora + A cada %d horas + A cada %d horas + + + 1 mensagem + %d mensagens + %d mensagens + + diff --git a/feature/account/setup/src/main/res/values-pt/strings.xml b/feature/account/setup/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..db1547d --- /dev/null +++ b/feature/account/setup/src/main/res/values-pt/strings.xml @@ -0,0 +1,65 @@ + + + Este não é reconhecido como um endereço de e-mail válido. + Recebemos a configuração do seu servidor de e-mail por uma conexão que não é tão segura quanto gostaríamos. Isso significa que há uma pequena chance de que alguém possa tê-la alterado. Você poderia verificar a configuração fornecida para garantir que está correta? + Erro de rede. Verifique o status da sua conexão e tente novamente. + Configuração encontrada + Nenhum + Seu nome + A assinatura de e-mail não pode estar em branco. + Conta criada com sucesso + Erro desconhecido + Endereço de e-mail é obrigatório. + Esse endereço de e-mail não é permitido. + Esse endereço de e-mail não é suportado. + A senha é obrigatória. + Inicialização de TLS + SSL/TLS + Buscando configuração… + Falha ao carregar configuração de e-mail + Configuração não encontrada + Configure automaticamente + Esta configuração não é confiável + Configure manualmente + Editar configuração + I confio nesta configuração + É necessário aprovar a configuração. + Por favor, especifique a pasta especial para sua conta. + A entrada \'Automática\' seguirá as alterações feitas pelo servidor automaticamente. O valor atual do servidor é exibido entre parênteses. + Buscando lista de pastas… + Falha ao buscar a lista de pastas do servidor + Todas as pastas especiais foram configuradas automaticamente pelo servidor. + Arquivar pasta + Pasta de rascunhos + Pasta de enviados + Pasta de spam + Pasta de lixeira + Automático (%s) + Opções de exibição + Nome da conta + O nome da conta não pode estar em branco. + Seu nome é obrigatório. + Assinatura de e-mail + Opções de sincronização + Frequência de verificação + Nunca + + A cada minuto + A cada %d minutos + A cada %d minutos + + + 1 mensagem + %d mensagens + %d mensagens + + Número de mensagens a exibir + + A cada hora + A cada %d horas + A cada %d horas + + Mostrar notificações + Criando conta… + Ocorreu um erro ao tentar criar a conta + diff --git a/feature/account/setup/src/main/res/values-ro/strings.xml b/feature/account/setup/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..5cea096 --- /dev/null +++ b/feature/account/setup/src/main/res/values-ro/strings.xml @@ -0,0 +1,65 @@ + + + Numele contului nu poate fi necompletat. + Frecvența de verificare + Configurare automată + Această configurație nu este de încredere + Semnătură e-mail + Numărul de mesaje afișate + Configurare manuală + Numele semnăturii de e-mail nu poate fi necompletat. + Este necesar să se aprobe configurația. + StartTLS + Am primit configurația pentru serverul de e-mail printr-o conexiune care nu este atât de sigură pe cât ne-am dori. Aceasta înseamnă că există o mică șansă ca cineva s-o fi modificat. Ai putea verifica din nou configurația furnizată pentru a te asigura că este așa cum ar trebui să fie\? + Editare configurație + Eroare necunoscută + SSL/TLS + Configurația nu a fost găsită + Nume cont + Configurație găsită + Este necesară adresa de e-mail. + Numele dvs. + Nu s-a putut încărca configurația e-mailului + Am încredere în această configurație + Numele este obligatoriu. + Eroare de rețea. Verificați starea conexiunii și încercați din nou. + Aceasta nu este recunoscută ca o adresă de e-mail validă. + Niciodată + Se caută configurația… + Opțiuni de sincronizare + Afișare notificări + Opțiuni de afișare + Această adresă de e-mail nu este permisă. + Cont creat cu succes + Această adresă de e-mail nu este acceptată. + Se creează contul… + A apărut o eroare la încercarea de a crea contul + Toate dosarele speciale au fost configurate automat de către server. + Automat (%s) + Dosarul Ciorne + Nu s-a putut prelua lista dosarelor de pe server + Se preia lista dosarelor… + Specifică dosarele speciale pentru contul tău. + Dosarul trimise + Nimic + Dosarul gunoi + Dosarul de arhivare + Dosarul spam + Intrarea „Automat” va urma automat modificările făcute de server. Valoarea curentă a serverului este afișată în paranteze. + Parola este necesară. + + Fiecare %d minut + Fiecare %d minute + Fiecare %d minute + + + Fiecare %d oră + Fiecare %d ore + Fiecare %d ore + + + %d mesaj + %d mesaje + %d mesaje + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ru/strings.xml b/feature/account/setup/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..ffd88ad --- /dev/null +++ b/feature/account/setup/src/main/res/values-ru/strings.xml @@ -0,0 +1,68 @@ + + + Адрес электронной почты обязателен. + Запись \"Автоматически\" будет автоматически следовать за изменениями, вносимыми сервером. Текущее значение сервера отображается в круглых скобках. + Укажите специальные папки для вашей учётной записи. + Папка \"Архивные\" + Отображаемое имя + Подпись электронной почты + Имя подписи электронной почты не может быть пустым. + Показывать уведомления + При попытке создать учётную запись произошла ошибка + Частота проверки + Никогда + Число отображённых сообщений + Создание учётной записи… + Требуется пароль. + Настроить вручную + Изменить настройки + Я доверяю этим настройкам + Необходимо подтвердить настройки. + Получение списка папок… + Не удалось получить список папок с сервера + Все специальные папки были настроены сервером автоматически. + Папка \"Черновики\" + Название учётной записи + Папка \"Отправленные\" + Папка \"Спам\" + Папка \"Корзина\" + Ничего + Автоматически (%s) + Отобразить параметры + Параметры синхронизации + Имя учётной записи не может быть пустым. + Учётная запись успешно создана + Необходимо указать ваше имя. + Ошибка сети. Проверьте состояние вашего соединения и повторите попытку. + Неизвестная ошибка + SSL/TLS + Не удалось загрузить настройки электронной почты + Поиск настроек… + Настройки найдены + Не удалось найти настройки + Эти настройки не являются доверенными + Этот адрес электронной почты недопустим. + Этот адрес электронной почты не поддерживается. + Этот адрес электронной почты не распознан как действительный. + StartTLS + Настроить автоматически + + Каждую %d минуту + Каждые %d минуты + Каждые %d минут + Каждые %d минут + + + Каждый %d час + Каждые %d часа + Каждые %d часов + Каждые %d часов + + + %d сообщение + %d сообщения + %d сообщений + %d сообщений + + Полученные настройки для вашего сервера электронной почты содержат недостаточно надёжные параметры соединения. Это означает, что есть риск того, что кто-то мог изменить настройки. Не могли бы вы ещё раз проверить полученные настройки и убедиться, что всё в порядке? + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-sk/strings.xml b/feature/account/setup/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..8a54cf8 --- /dev/null +++ b/feature/account/setup/src/main/res/values-sk/strings.xml @@ -0,0 +1,66 @@ + + + Sieťová chyba. Prosím skontrolujte stav pripojenia a skúste znova. + StartTLS + Táto e-mailová adresa nie je podporovaná. + SSL/TLS + Neplatná e-mailová adresa. + Zisťujem podrobnosti o konfigurácií… + Zložka konceptov + Zložka odoslanej pošty + Nastala chyba počas vytvárania konta + Názov konta nemôže byť prázdny. + Konto bolo úspešne vytvorené + Frekvencia kontroly + Konfigurovať ručne + Je nevyhnutné potvrdiť konfiguráciu. + Získavam zoznam zložiek… + Vaše meno + Možnosti synchronizácie + + Každú minútu + Každé %d minúty + Každých %d minút + Každých %d minút + + + Každú hodinu + Každé %d hodiny + Každých %d hodín + Každých %d hodín + + + 1 správa + %d správy + %d správ + %d správ + + Zlyhalo načítanie zoznamu zložiek zo servera + Zložka SPAMu + Názov konta + Vaše meno je povinné. + E-mailový podpis nemôže byť prázdny. + Vytváram konto… + Heslo je povinné. + Konfigurácia sa nenašla + Konfigurovať automaticky + Archívna zložka + Zložka koša + Žiadna + Automaticky (%s) + Neznáma chyba + E-mailová adresa je povinná. + Táto e-mailová adresa nie je povolená. + Táto konfigurácia nie je dôveryhodná + E-mailový podpis + Zlyhalo načítanie e-mailovej konfigurácie + Našla sa konfigurácia + Upraviť konfiguráciu + Dôverujem tejto konfigurácií + Prosím zvoľte špeciálnu zložku pre vaše konto. + Všetky špeciálne zložky boli nakonfigurované automaticky zo servera. + Zobraziť možnosti + Nikdy + Počet správ na zobrazenie + Zobraziť notifikácie + diff --git a/feature/account/setup/src/main/res/values-sl/strings.xml b/feature/account/setup/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..d067ecc --- /dev/null +++ b/feature/account/setup/src/main/res/values-sl/strings.xml @@ -0,0 +1,68 @@ + + + Napaka omrežja. Preverite stanja omrežja in poizkusite znova. + E-poštni naslov je zahtevan podatek. + StartTLS + SSL/TLS + Nastavitve za vaš e-.poštni strežnik smo prejeli preko povezave, ki tako varna, kot bi si želeli. To pomeni, da obstaja zelo majhna možnost, da jo je lahko nekdo spremenil. Preverite dobljene nastavitve, da se prepričate o njihovi ustreznosti. + Pridobivanje seznama map … + + Vsako uro + Vsaki %d uri + Vsake %d ure + Vsakih %d ur + + + %d sporočilo + %d sporočili + %d sporočila + %d sporočil + + Neznana napaka + Ta e-poštni naslov ni dovoljen. + Ta e-poštni naslov ni podprt. + Vneseno ni prepoznano kot veljaven e-poštni naslov. + Geslo je zahtevan podatek. + Iskanje nastavitev … + Nalaganje e-poštnih nastavitev je spodletelo + Nastavitve so bile najdene + Nastavitev ni bilo mogoče najti + Samodejno nastavi + Tem nastavitvam ni mogoče zaupati. + Ročno nastavi + Uredi nastavitve + Tem nastavitvam zaupam + Odobritev teh nastavitev je zahtevana. + Navedite posebne mape za vaš račun. + Vnos \"Samodejno\" bo samodejno sledil spremembam na strežniku. Trenutna vrednost strežnika je prikazana v oklepajih. + Pridobivanje seznama map s strežnika je spodletelo. + Vse posebne mape je strežnik samodejno nastavil. + Mapa za arhivirano pošto + Mapa za osnutke + Mapa za poslano pošto + Mapa za neželeno pošto + Mapa za smeti + Brez + Samodejno (%s) + Možnosti prikaza + Ime računa + Ime računa ne sme biti prazno. + Vaše ime + Vaše ime je zahtevan podatek. + E-poštni podpis + Ime podpisa ne sme biti prazno. + Možnosti uskaljevanja + Pogostost preverjanja + Nikoli + Število prikazanih sporočil + Prikaži obvestila + Ustvarjanje računa … + Med poizkusom ustvarjanja računa je prišlo do napake. + Račun je bil uspešno ustvarjen. + + Vsako minuto + Vsaki %d minuti + Vsake %d minute + Vsakih %d minut + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-sq/strings.xml b/feature/account/setup/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..5da497e --- /dev/null +++ b/feature/account/setup/src/main/res/values-sq/strings.xml @@ -0,0 +1,62 @@ + + + Emri i llogarisë s’mund të jetë i zbrazët. + Shpeshti kontrolli + Formësoje automatikisht + Ky formësim s’është i besuar + Nënshkrim email-i + Numër mesazhesh për shfaqje + Formësojeni dorazi + Emri i nënshkrimit të email-it s’mund të jetë i zbrazët. + Lypset të miratohet formësimi. + StartTLS + E morëm formësimin për shërbyesin tuaj email përmes një lidhjeje që s’është aq e siguruar sa do të donim. Kjo do të thotë se ka një mundësi të vockël që dikur të mund ta ketë ndryshuar. A mund ta rikontrolloni formësimin e dhënë, për t’u siguruar se është siç duhet të jetë? + Përpunoni formësimin + Gabim i panjohur + SSL/TLS + S’u gjet formësim + Emër llogarie + U gjet formësim + Lypset adresë email. + Emri juaj + S’u arrit të ngarkohej formësimi i email-it + E besoj këtë formësim + Emri juaj është i domosdoshëm. + Gabim rrjeti. Ju lutemi, kontrolloni gjendjen e lidhjes tuaj dhe riprovoni. + Kjo s’njihet si adresë email e vlefshme. + Kurrë + Po shihet për formësim… + Mundësi njëkohësimi + Shfaq njoftime + Mundësi shfaqjeje + Kjo adresë email s’lejohet. + Kjo adresë email s’ mbulohet. + Krejt dosjet speciale janë formësuar automatikisht nga shërbyesi. + Automatike (%s) + Dosje skicash + Llogaria u krijua me sukses + S’u arrit të sillej listë dosjesh nga shërbyesi + Po sillet listë dosjesh… + Ju lutemi, përcaktoni dosjet speciale për llogarinë tuaj. + Dosje të dërguarish + Asnjë + Dosje hedhurinash + Po krijohet llogari… + Dosje arkiv + Ndodhi një gabim teksa provohej të krijohej llogaria + Dosje të padëshiruarish + Zëri “Automatik” do të pasojë ndryshime të bëra nga shërbyesi automatikisht. Vlera aktuale e shërbyesit shfaqet në kllapa. + Fjalëkalimi është i domosdoshëm. + + Çdo minutë + Çdo %d minuta + + + Çdo orë + Çdo %d orë + + + 1 mesazh + %d mesazhe + + diff --git a/feature/account/setup/src/main/res/values-sr/strings.xml b/feature/account/setup/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..8d92dcd --- /dev/null +++ b/feature/account/setup/src/main/res/values-sr/strings.xml @@ -0,0 +1,65 @@ + + + Конфигурација пронађена + Аутоматски (%s) + + 1 порука + %d поруке + %d порука + + Број порука за приказ + Наведите посебне фолдере за ваш налог. + Унос „Аутоматски“ ће аутоматски пратити промене које направи сервер. Тренутна вредност сервера је приказана у заградама. + Архивски фолдер + Сви посебни фолдери су аутоматски конфигурисани од стране сервера. + Фолдер нацрта + Фолдер послатих + Фолдер непожељног + Фолдер смећа + Прикажи опције + Име налога + Име налога не може бити празно. + Ваше име + Ваше име је обавезно. + Потпис имејла + Учесталост провере + Никада + Прављење налога… + Дошло је до грешке приликом покушаја прављења налога + Налог је успешно направљен + Ово није препознато као важећа имејл адреса. + Тражење конфигурације… + Примили смо конфигурацију за ваш сервер имејла преко везе која није толико безбедна колико бисмо желели. То значи да постоји мала шанса да је неко могао да је измени. Можете ли двапут проверити пружену конфигурацију како бисте били сигурни да је исправна? + StartTLS + Конфигуриши ручно + Конфигуриши аутоматски + Ова конфигурација није поуздана + Учитавање конфигурације имејла није успело + Ниједан + Конфигурација није пронађена + Прикажи обавештења + Измени конфигурацију + Верујем овој конфигурацији + Лозинка је обавезна. + Прикупљање листе фолдера… + Обавезно је одобрити конфигурацију. + Преузимање листе фолдера са сервера није успело + Мрежна грешка. Проверите статус ваше везе и покушајте поново. + Непозната грешка + Имејл адреса је обавезна. + SSL/TLS + Ова имејл адреса није дозвољена. + Ова имејл адреса није подржана. + + Сваког минута + Свака %d минута + Сваких %d минута + + + Сваког сата + Свака %d сата + Сваких %d сати + + Име потписа имејла не може бити празно. + Опције синхронизације + diff --git a/feature/account/setup/src/main/res/values-sv/strings.xml b/feature/account/setup/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..57efa1a --- /dev/null +++ b/feature/account/setup/src/main/res/values-sv/strings.xml @@ -0,0 +1,62 @@ + + + Kontonamn kan inte vara tomt. + Frekvenskontroll + Konfigurera automatiskt + Denna konfiguration är inte pålitlig + E-postsignatur + Antal meddelanden att visa + Konfigurera manuellt + E-postsignaturens namn får inte vara tomt. + Det krävs för att godkänna konfigurationen. + Vi fick konfigurationen för din e-postserver via en anslutning som inte är så säker som vi skulle vilja. Det betyder att det finns en liten chans att någon kunde ha ändrat den. Kan du dubbelkolla den medföljande konfigurationen för att se till att den är som den ska vara\? + Redigera konfiguration + Okänt fel + Konfiguration hittades inte + Kontonamn + Konfiguration hittades + Ditt namn + Misslyckades att ladda e-postkonfiguration + Jag litar på denna konfiguration + Ditt namn krävs. + Nätverksfel. Vänligen se över din uppkoppling och försök igen. + Aldrig + Söker upp konfiguration… + Synkroniseringsinställningar + Visa notiser + Visningsinställningar + StartTLS + SSL/TLS + E-postadress krävs. + Detta känns inte igen som en giltig e-postadress. + Denna e-postadress är inte tillåten. + Den här e-postadressen stöds inte. + Kontot har skapats framgångsrikt + Skapar konto… + Ett fel uppstod när kontot skulle skapas + Alla speciella mappar har konfigurerats automatiskt av servern. + Automatisk (%s) + Utkast mapp + Misslyckades att hämta lista av mappar från servern + Hämtar lista av mappar… + Vänligen specificera dom speciella mapparna för ditt konto. + Skickat mapp + Ingen + Papperskorgen mapp + Arkiv mapp + Spam mapp + Inmatningen \"Automatisk\" följer automatiskt ändringar som görs av servern. Det aktuella servervärdet visas inom parentes. + Lösenord krävs. + + 1 meddelande + %d meddelanden + + + Varje minut + Var %d minut + + + Varje timme + Var %d timme + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-sw/strings.xml b/feature/account/setup/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/account/setup/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-ta/strings.xml b/feature/account/setup/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..77a70e8 --- /dev/null +++ b/feature/account/setup/src/main/res/values-ta/strings.xml @@ -0,0 +1,62 @@ + + + இந்த மின்னஞ்சல் முகவரி ஆதரிக்கப்படவில்லை. + ச்பேம் கோப்புறை + குப்பை கோப்புறை + காட்சி விருப்பங்கள் + கணக்கு பெயர் + + ஒவ்வொரு மணி நேரமும் + ஒவ்வொரு %d மணிநேரமும் + + உங்கள் கணக்கிற்கான சிறப்பு கோப்புறைகளைக் குறிப்பிடவும். + கோப்புறைகளின் பட்டியலைப் பெறுதல்… + சேவையகத்திலிருந்து கோப்புறைகளின் பட்டியலைப் பெறுவதில் தோல்வி + அனைத்து சிறப்பு கோப்புறைகளும் சேவையகத்தால் தானாக கட்டமைக்கப்பட்டுள்ளன. + காப்பக கோப்புறை + வரைவுகள் கோப்புறை + கோப்புறை அனுப்பப்பட்டது + எதுவுமில்லை + மின்னஞ்சல் கையொப்பம் + மின்னஞ்சல் கையொப்ப பெயர் காலியாக இருக்க முடியாது. + காண்பிக்க செய்திகளின் எண்ணிக்கை + அறிவிப்புகளைக் காட்டு + கடவுச்சொல் தேவை. + உள்ளமைவு காணப்படவில்லை + கைமுறையாக உள்ளமைக்கவும் + \"தானியங்கி\" நுழைவு சேவையகத்தால் செய்யப்பட்ட மாற்றங்களை தானாகவே பின்பற்றும். தற்போதைய சேவையக மதிப்பு அடைப்புக்குறிக்குள் காட்டப்படும். + உங்கள் பெயர் தேவை. + விருப்பங்களை ஒத்திசைக்கவும் + கணக்கை உருவாக்குதல்… + தெரியாத பிழை + மின்னஞ்சல் முகவரி தேவை. + SSL/TLS + உள்ளமைவைப் பார்க்கிறது… + இந்த மின்னஞ்சல் முகவரி அனுமதிக்கப்படவில்லை. + இது சரியான மின்னஞ்சல் முகவரியாக அங்கீகரிக்கப்படவில்லை. + Startls + மின்னஞ்சல் உள்ளமைவை ஏற்றுவதில் தோல்வி + உள்ளமைவு காணப்பட்டது + தானாக உள்ளமைக்கவும் + இந்த உள்ளமைவு நம்பப்படவில்லை + நாங்கள் விரும்பும் அளவுக்கு பாதுகாப்பாக இல்லாத இணைப்பின் மூலம் உங்கள் மின்னஞ்சல் சேவையகத்திற்கான உள்ளமைவைப் பெற்றோம். இதன் பொருள் யாராவது அதை மாற்றியிருக்க ஒரு சிறிய வாய்ப்பு உள்ளது. வழங்கப்பட்ட உள்ளமைவை இருமுறை சரிபார்க்க முடியுமா? + உள்ளமைவுக்கு ஒப்புதல் அளிக்க வேண்டும். + தானியங்கி (%s) + கணக்கு பெயர் காலியாக இருக்க முடியாது. + உங்கள் பெயர் + சோதனை அதிர்வெண் + ஒருபோதும் + + 1 செய்தி + %d செய்திகள் + + கணக்கை உருவாக்க முயற்சிக்கும்போது பிழை ஏற்பட்டது + கணக்கு வெற்றிகரமாக உருவாக்கப்பட்டது + உள்ளமைவைத் திருத்து + இந்த உள்ளமைவை நான் நம்புகிறேன் + பிணைய பிழை. உங்கள் இணைப்பு நிலையை சரிபார்த்து மீண்டும் முயற்சிக்கவும். + + ஒவ்வொரு நிமிடமும் + ஒவ்வொரு %d நிமிடங்களும் + + diff --git a/feature/account/setup/src/main/res/values-th/strings.xml b/feature/account/setup/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..b9c4ba6 --- /dev/null +++ b/feature/account/setup/src/main/res/values-th/strings.xml @@ -0,0 +1,46 @@ + + + เกิดข้อผิดพลาดของเครือข่าย โปรดตรวจสอบสถานะการเชื่อมต่อของคุณแล้วลองอีกครั้ง + ไม่ทราบข้อผิดพลาด + จำเป็นต้องมีที่อยู่อีเมล + ไม่อนุญาตให้ใช้ที่อยู่อีเมลนี้ + ไม่รองรับที่อยู่อีเมลนี้ + นี่ไม่ได้รับการยอมรับว่าเป็นที่อยู่อีเมลที่ถูกต้อง + จำเป็นต้องระบุรหัสผ่าน + StartTLS + SSL/TLS + กำลังมองหาการกำหนดค่า… + ไม่สามารถโหลดการกำหนดค่าอีเมล์ได้ + พบการกำหนดค่าแล้ว + ไม่พบการกำหนดค่า + ตั้งค่าอัตโนมัติ + การตั้งค่านี้ไม่น่าเชื่อถือ + เราได้รับการตั้งค่าสำหรับเซิร์ฟเวอร์อีเมลของคุณผ่านการเชื่อมต่อที่ไม่ปลอดภัยเท่าที่เราต้องการ ซึ่งหมายความว่ามีโอกาสเล็กน้อยที่ใครบางคนอาจเปลี่ยนแปลงการตั้งค่านั้นได้ โปรดตรวจสอบตั้งค่าที่ให้มาอีกครั้งเพื่อให้แน่ใจว่าถูกต้อง + ตั้งค่าด้วยตนเอง + แก้ไขการกำหนดค่า + ฉันเชื่อมั่นในกำหนดค่านี้ + จำเป็นต้องอนุมัติการกำหนดค่า + กรุณาระบุโฟลเดอร์พิเศษสำหรับบัญชีของคุณ + รายการ \"อัตโนมัติ\" จะติดตามการเปลี่ยนแปลงที่ทำโดยเซิร์ฟเวอร์โดยอัตโนมัติ ค่าเซิร์ฟเวอร์ปัจจุบันจะแสดงในวงเล็บ + กำลังดึงรายการโฟลเดอร์… + ไม่สามารถดึงรายการโฟลเดอร์จากเซิร์ฟเวอร์ได้ + โฟลเดอร์พิเศษทั้งหมดได้รับการกำหนดค่าโดยอัตโนมัติโดยเซิร์ฟเวอร์ + โฟลเดอร์เก็บถาวร + โฟลเดอร์ฉบับร่าง + โฟลเดอร์ที่ส่งแล้ว + โฟลเดอร์สแปม + โฟลเดอร์ถังขยะ + อัตโนมัติ (%s) + ตัวเลือกการแสดงผล + ชื่อบัญชี + ชื่อบัญชีไม่สามารถเว้นว่างได้ + ชื่อของคุณ + กรุณาระบุชื่อของคุณ + ลายเซ็นอีเมล์ + ชื่อลายเซ็นอีเมล์ไม่สามารถว่างเปล่าได้ + ตัวเลือกการซิงค์ + ความถี่ตรวจสอบ + จำนวนข้อความที่แสดง + แสดงการแจ้งเตือน + กำลังสร้างบัญชี… + diff --git a/feature/account/setup/src/main/res/values-tr/strings.xml b/feature/account/setup/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..70dc5bd --- /dev/null +++ b/feature/account/setup/src/main/res/values-tr/strings.xml @@ -0,0 +1,62 @@ + + + Hesap adı boş olamaz. + Otomatik olarak yapılandır + Bu yapılandırma güvenilir değil + E-posta imzası + Manuel olarak yapılandır + E-posta imza adı boş olamaz. + Yapılandırmanın onaylanması zorunludur. + StartTLS + E-posta sunucunuzun yapılandırmasını istediğimiz kadar güvenli olmayan bir bağlantı üzerinden aldık. Bu durumda düşük ihtimalle de olsa birisi yapılandırmanıza müdahale etmiş olabilir. Doğruluğundan emin olmak için lütfen sağlanan yapılandırmayı bir kere daha gözden geçirir misiniz? + Yapılandırmayı düzenle + Bilinmeyen hata + SSL/TLS + Yapılandırma bulunamadı + Hesap adı + Yapılandırma bulundu + E-posta adresi zorunludur. + Adınız + E-posta yapılandırması yüklenemedi + Bu yapılandırmaya güveniyorum + Adınız zorunludur. + Ağ hatası. Lütfen bağlantı durumunuzu kontrol edip yeniden deneyin. + Bu geçerli bir e-posta adresi olarak tanınmıyor. + Yapılandırma aranıyor… + Eşitleme seçenekleri + Görüntüleme seçenekleri + Denetleme sıklığı + Görüntülenecek ileti sayısı + Hiçbir zaman + Bildirimleri göster + Bu e-posta adresine izin verilmiyor. + Bu e-posta adresi desteklenmiyor. + Hesap başarıyla oluşturuldu + Hesap oluşturuluyor… + Hesap oluşturulurken bir hata oluştu + Tüm özel klasörler sunucu tarafından otomatik olarak yapılandırıldı. + Otomatik (%s) + Taslaklar klasörü + Klasör listesi sunucudan alınamadı + Klasör listesi alınıyor… + Lütfen hesabınız için özel klasörleri belirtin. + Gönderilmiş klasörü + Yok + Çöp kutusu klasörü + Arşiv klasörü + Spam klasörü + \"Otomatik\" seçeneği sunucu tarafından yapılan değişiklikleri otomatik olarak takip edecektir. Geçerli sunucu değeri parantez içinde gösterilir. + Parola zorunludur. + + Saatte bir + %d saatte bir + + + 1 ileti + %d ileti + + + Dakikada bir + %d dakikada bir + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-uk/strings.xml b/feature/account/setup/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..3160e3a --- /dev/null +++ b/feature/account/setup/src/main/res/values-uk/strings.xml @@ -0,0 +1,68 @@ + + + SSL/TLS + StartTLS + Не вдалося отримати список тек із сервера + Вкажіть спеціальні теки для вашого облікового запису. + Запис «Автоматично» буде автоматично слідувати за змінами, зробленими сервером. Поточне значення сервера показано в круглих дужках. + Отримання списку тек… + Усі спеціальні теки налаштовані сервером автоматично. + Тека «Архів» + Тека «Чернетки» + Тека «Надіслані» + Тека «Кошик» + Тека «Спам» + Автоматично (%s) + Ніколи + Кількість показуваних повідомлень + Показувати сповіщення + Виникла помилка під час спроби створити обліковий запис + Обліковий запис створено + Пароль обов\'язковий. + Це не довірена конфігурація + Ми отримали конфігурацію вашого поштового сервера через з\'єднання, яке недостатньо захищене. Це означає, що існує незначна ймовірність, що хтось міг його видозмінити. Чи могли б ви ще раз перевірити надану конфігурацію, щоб переконатися, що вона правильна? + Налаштувати вручну + Опції показу + Ім\'я облікового запису + Підпис е-пошти + Створення облікового запису… + Ім\'я облікового запису не може бути порожнім. + Ваше ім\'я + Потрібно вказати ім\'я. + Помилка в мережі. Перевірте стан вашого з\'єднання та повторіть спробу. + Невідома помилка + Адреса е-пошти обов\'язкова. + Конфігурація знайдена + Налаштувати автоматично + Ця адреса е-пошти не підтримується. + Конфігурація не знайдена + Я довіряю цій конфігурації + Ця адреса е-пошти не дозволена. + Ця адреса е-пошти не розпізнана дійсною. + Пошук конфігурації… + Не вдалося завантажити конфігурацію е-пошти + Змінити конфігурацію + Підтвердження конфігурації обовʼязкове. + Немає + Підпис е-пошти не може бути порожнім. + + Щохвилини + Що %d хвилини + Що %d хвилин + Що %d хвилин + + Параметри синхронізації + Частота перевірки + + Щогодини + Що %d години + Що %d годин + Що %d годин + + + 1 повідомлення + %d повідомлення + %d повідомлень + %d повідомлень + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-vi/strings.xml b/feature/account/setup/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..12cff8e --- /dev/null +++ b/feature/account/setup/src/main/res/values-vi/strings.xml @@ -0,0 +1,59 @@ + + + Tên tài khoản không được để trống. + Địa chỉ e-mail này không được phép. + Tần suất kiểm tra + Cấu hình tự động + Cấu hình này không đáng tin cậy + Chữ ký email + Số lượng tin nhắn hiển thị + Định cấu hình thủ công + Tên chữ ký email không được để trống. + Cần phải phê duyệt cấu hình. + StartTLS + Chúng tôi đã nhận được cấu hình cho máy chủ email của bạn qua kết nối không an toàn như chúng tôi mong muốn. Điều này có nghĩa là có rất ít khả năng ai đó có thể đã thay đổi nó. Bạn có thể vui lòng kiểm tra kỹ cấu hình được cung cấp để đảm bảo nó như mong muốn không? + Chỉnh sửa cấu hình + Địa chỉ e-mail này không được hỗ trợ. + Lỗi không rõ + SSL/TLS + Không tìm thấy cấu hình + Tên tài khoản + Đã tìm thấy cấu hình + Địa chỉ e-mail là bắt buộc. + Tên của bạn + Không tải được cấu hình email + Tôi tin tưởng cấu hình này + Tên của bạn là bắt buộc. + Lỗi mang. Xin vui lòng kiểm tra lại kết nối mạng của bạn và thử lại. + Đây không được công nhận là địa chỉ email hợp lệ. + Không bao giờ + Đang tìm cấu hình… + Tùy chọn đồng bộ hóa + Hiển thị thông báo + Tùy chọn hiển thị + Tạo tài khoản thành công + Đang tạo tài khoản… + Đã xảy ra lỗi khi cố gắng tạo tài khoản + Đang lấy danh sách thư mục… + Không thể lấy danh sách thư mục từ máy chủ + Mật khẩu là bắt buộc. + Hãy chỉ cụ thể thư mục đặc biệt cho tài khoản của bạn. + Tất cả thư mục đặc biệt đã được cấu hình tự động bởi máy chủ. + Thư mục lưu trữ + Thư mục nháp + Thư mục đã gửi + Thư mục spam + Thư mục rác + Không thiết đặt + Tự động (%s) + Mục \"Tự động\" sẽ tuân theo thay đổi của máy chủ. Giá trị hiện tại của máy chủ đang hiển thị trong ngoặc kép. + + Mỗi %d phút + + + Mỗi %d giờ + + + %d tin nhắn + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values-zh-rCN/strings.xml b/feature/account/setup/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..0fc8de5 --- /dev/null +++ b/feature/account/setup/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,59 @@ + + + 账号名称不能为空。 + 检查频率 + 自动配置 + 此配置不受信任 + 电子邮件签名 + 要显示的邮件数 + 手动配置 + 电子邮件签名名称不能为空。 + 需要批准配置。 + StartTLS + 我们通过连接收到了您的电子邮件服务器的配置,该连接不像我们希望的那样安全。这意味着有很小的可能性有人可能对其进行了更改。能否请您仔细检查所提供的配置,以确保其是正确的? + 编辑配置 + 未知错误 + SSL/TLS + 未找到配置 + 账号名称 + 找到配置 + 电子邮件地址是必需的。 + 您的名称 + 加载电子邮件配置失败 + 我信任此配置 + 名称是必需的。 + 网络错误。请检查您的连接状态,然后重试。 + 这不是有效的电子邮件地址。 + 从不 + 正在查找配置… + 同步选项 + 显示通知 + 显示选项 + 不允许此电子邮件地址。 + 不支持此电子邮件地址。 + 账号已成功创建 + 正在创建账号… + 尝试创建账号时出错 + 服务器已自动配置所有特殊文件夹。 + 自动(%s) + 草稿文件夹 + 从服务器获取文件夹列表失败 + 正在获取文件夹列表… + 请为您的账号指定特殊文件夹。 + 已发送邮件文件夹 + + 已删除邮件文件夹 + 归档文件夹 + 垃圾邮件文件夹 + “自动”条目将自动遵循服务器所做的更改。当前服务器值显示在括号中。 + 密码是必需的。 + + 每 %d 小时 + + + 每 %d 分钟 + + + %d 封邮件 + + diff --git a/feature/account/setup/src/main/res/values-zh-rTW/strings.xml b/feature/account/setup/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..3e34419 --- /dev/null +++ b/feature/account/setup/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,59 @@ + + + 帳號名稱不能為空白。 + 檢查頻率 + 自動配置 + 此配置不受信任 + 電子郵件簽名 + 要顯示的郵件數 + 手動配置 + 電子郵件簽名不能為空白。 + 您需要批准配置。 + StartTLS + 我們透過一個安全性不足的連線收到了你的電子郵件配置,雖然發生這種情況的可能性並不大,但這意味著有人可能已經對配置進行了改動。請您現在再次檢查之前提供的配置是否經過改動? + 編輯配置 + 未知錯誤 + SSL/TLS + 未找到配置 + 帳號名稱 + 找到了配置 + 需要輸入電子郵件地址。 + 您的名字 + 載入電子郵件配置失敗 + 我信任此配置 + 必須填寫您的名字。 + 網路錯誤。請檢查您的連線狀態並重試。 + 輸入值未被識別為有效的電子郵件地址。 + 從不 + 正在查找設定… + 同步選項 + 顯示通知 + 顯示選項 + 此郵箱地址不允許使用。 + 建立帳戶成功 + 不支援此電子郵件地址。 + 建立帳戶中… + 嘗試建立帳戶時發生錯誤 + 所有特殊資料夾都已由伺服器自動配置。 + 自動( %s) + 草稿資料夾 + 從伺服器獲取資料夾列表失敗 + 獲取資料夾列表⋯⋯ + 請為您的帳戶指定特殊資料夾。 + 已傳送資料夾 + + 垃圾資料夾 + 存檔資料夾 + 垃圾郵件資料夾 + 「自動」項目會自動跟隨伺服器所做的變更。目前的伺服器值會顯示在括號內。 + 密碼為必填項。 + + 每 %d 分鐘 + + + 每 %d 小時 + + + %d 封郵件 + + \ No newline at end of file diff --git a/feature/account/setup/src/main/res/values/strings.xml b/feature/account/setup/src/main/res/values/strings.xml new file mode 100644 index 0000000..bdf2cd0 --- /dev/null +++ b/feature/account/setup/src/main/res/values/strings.xml @@ -0,0 +1,68 @@ + + + + Network error. Please check your connection status and try again. + Unknown error + + Email address is required. + This email address is not allowed. + This email address is not supported. + This is not recognized as a valid email address. + Password is required. + + StartTLS + SSL/TLS + Looking up configuration… + Failed to load email configuration + Configuration found + Configuration not found + Configure automatically + This configuration is not trusted + We received the configuration for your email server over a connection that isn\'t as secure as we\'d like. This means that there is a tiny chance that someone could have altered it. Could you please double-check the provided configuration to make sure it\'s as it should be? + Configure manually + Edit configuration + I trust this configuration + It is required to approve the configuration. + + Please specify the special folders for your account. + The \"Automatic\" entry will follow changes made by the server automatically. The current server value is displayed in parentheses. + Fetching list of folders… + Failed to fetch the list of folders from the server + All special folders have been configured automatically by the server. + Archive folder + Drafts folder + Sent folder + Spam folder + Trash folder + None + Automatic (%s) + + Display options + Account name + Account name can\'t be blank. + Your name + Your name is required. + Email signature + Email signature name can\'t be blank. + Sync options + Check frequency + Never + + Every minute + Every %d minutes + + + Every hour + Every %d hours + + + 1 message + %d messages + + Number of messages to display + Show notifications + + Creating account… + An error occurred while trying to create the account + Account successfully created + diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/AccountSetupModuleKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/AccountSetupModuleKtTest.kt new file mode 100644 index 0000000..c3e4981 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/AccountSetupModuleKtTest.kt @@ -0,0 +1,49 @@ +package app.k9mail.feature.account.setup + +import android.content.Context +import app.k9mail.feature.account.common.AccountCommonExternalContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.InteractionMode +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract +import app.k9mail.feature.account.server.validation.ui.ServerValidationContract +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract +import com.fsck.k9.mail.oauth.AuthStateStorage +import org.junit.Test +import org.koin.test.KoinTest +import org.koin.test.verify.verify + +class AccountSetupModuleKtTest : KoinTest { + + @Test + fun `should have a valid di module`() { + featureAccountSetupModule.verify( + extraTypes = listOf( + AccountCommonExternalContract.AccountStateLoader::class, + AccountAutoDiscoveryContract.State::class, + AccountOAuthContract.State::class, + ServerValidationContract.State::class, + IncomingServerSettingsContract.State::class, + OutgoingServerSettingsContract.State::class, + DisplayOptionsContract.State::class, + SyncOptionsContract.State::class, + AccountState::class, + ServerCertificateErrorContract.State::class, + AuthStateStorage::class, + Context::class, + Boolean::class, + Class.forName("net.openid.appauth.AppAuthConfiguration").kotlin, + InteractionMode::class, + SpecialFoldersContract.State::class, + CreateAccountContract.State::class, + AccountSetupExternalContract.AccountOwnerNameProvider::class, + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/AutoDiscoveryMapperKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/AutoDiscoveryMapperKtTest.kt new file mode 100644 index 0000000..b94fd1b --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/AutoDiscoveryMapperKtTest.kt @@ -0,0 +1,100 @@ +package app.k9mail.feature.account.setup.domain + +import app.k9mail.autodiscovery.api.AuthenticationType +import app.k9mail.autodiscovery.api.ConnectionSecurity +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.autodiscovery.api.OutgoingServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings +import kotlin.test.assertFailsWith +import net.thunderbird.core.common.net.Hostname +import net.thunderbird.core.common.net.Port +import org.junit.Test + +class AutoDiscoveryMapperKtTest { + + @Test + fun `should map IncomingServerSettings to ServerSettings`() { + val incomingServerSettings = ImapServerSettings( + hostname = Hostname("imap.example.org"), + port = Port(993), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordCleartext), + username = "user", + ) + val password = "password" + + val serverSettings = incomingServerSettings.toServerSettings(password) + + assertThat(serverSettings).isEqualTo( + ServerSettings( + type = "imap", + host = "imap.example.org", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + extra = ImapStoreSettings.createExtra( + autoDetectNamespace = true, + pathPrefix = null, + useCompression = true, + sendClientInfo = true, + ), + ), + ) + } + + @Test + fun `should throw error when IncomingServerSettings not known`() { + val incomingServerSettings = object : IncomingServerSettings {} + + assertFailsWith { + incomingServerSettings.toServerSettings("password") + } + } + + @Test + fun `should map OutgoingServerSettings to ServerSettings`() { + val outgoingServerSettings = SmtpServerSettings( + hostname = Hostname("smtp.example.org"), + port = Port(587), + connectionSecurity = ConnectionSecurity.StartTLS, + authenticationTypes = listOf(AuthenticationType.PasswordCleartext), + username = "user", + ) + val password = "password" + + val serverSettings = outgoingServerSettings.toServerSettings(password) + + assertThat(serverSettings).isEqualTo( + ServerSettings( + type = "smtp", + host = "smtp.example.org", + port = 587, + connectionSecurity = MailConnectionSecurity.STARTTLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + extra = emptyMap(), + ), + ) + } + + @Test + fun `should throw error when OutgoingServerSettings not known`() { + val outgoingServerSettings = object : OutgoingServerSettings {} + + assertFailsWith { + outgoingServerSettings.toServerSettings("password") + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryAuthenticationTypeKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryAuthenticationTypeKtTest.kt new file mode 100644 index 0000000..53e9d3d --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryAuthenticationTypeKtTest.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.setup.domain.entity + +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class AutoDiscoveryAuthenticationTypeKtTest { + + @Test + fun `should map all AutoDiscoveryAuthenticationTypes`() { + val types = AutoDiscoveryAuthenticationType.entries + + for (type in types) { + val authenticationType = type.toAuthenticationType() + + assertThat(authenticationType).isEqualTo( + when (type) { + AutoDiscoveryAuthenticationType.PasswordCleartext -> AuthenticationType.PasswordCleartext + AutoDiscoveryAuthenticationType.PasswordEncrypted -> AuthenticationType.PasswordEncrypted + AutoDiscoveryAuthenticationType.OAuth2 -> AuthenticationType.OAuth2 + }, + ) + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryConnectionSecurityKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryConnectionSecurityKtTest.kt new file mode 100644 index 0000000..c7290ae --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoveryConnectionSecurityKtTest.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.account.setup.domain.entity + +import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class AutoDiscoveryConnectionSecurityKtTest { + + @Test + fun `should map all AutoDiscoveryConnectionSecurities`() { + val securities = AutoDiscoveryConnectionSecurity.entries + + for (security in securities) { + val connectionSecurity = security.toConnectionSecurity() + + assertThat(connectionSecurity).isEqualTo( + when (security) { + AutoDiscoveryConnectionSecurity.StartTLS -> ConnectionSecurity.StartTLS + AutoDiscoveryConnectionSecurity.TLS -> ConnectionSecurity.TLS + }, + ) + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoverySettingsFixture.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoverySettingsFixture.kt new file mode 100644 index 0000000..19448d0 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/AutoDiscoverySettingsFixture.kt @@ -0,0 +1,31 @@ +package app.k9mail.feature.account.setup.domain.entity + +import app.k9mail.autodiscovery.api.AuthenticationType +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.ConnectionSecurity +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort + +object AutoDiscoverySettingsFixture { + + val settings = AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = "incoming.example.com".toHostname(), + port = 123.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordEncrypted), + username = "incoming_username", + ), + outgoingServerSettings = SmtpServerSettings( + hostname = "outgoing.example.com".toHostname(), + port = 456.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordEncrypted), + username = "outgoing_username", + ), + isTrusted = true, + source = "test", + ) +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingServerSettingsExtensionKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingServerSettingsExtensionKtTest.kt new file mode 100644 index 0000000..82b1d6a --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingServerSettingsExtensionKtTest.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.setup.domain.entity + +import app.k9mail.autodiscovery.api.AuthenticationType +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort +import org.junit.Test + +class IncomingServerSettingsExtensionKtTest { + + @Test + fun `should map all ImapServerSettings to IncomingProtocolType IMAP`() { + val imapServerSettings = ImapServerSettings( + hostname = "example.com".toHostname(), + port = 993.toPort(), + connectionSecurity = AutoDiscoveryConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordCleartext), + username = "username", + ) + + assertThat(imapServerSettings.toIncomingProtocolType()).isEqualTo(IncomingProtocolType.IMAP) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/CreateAccountTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/CreateAccountTest.kt new file mode 100644 index 0000000..1928ca6 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/CreateAccountTest.kt @@ -0,0 +1,128 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.common.domain.entity.Account +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CreateAccountTest { + + @Test + fun `should successfully create account`() = runTest { + var recordedAccount: Account? = null + val createAccount = CreateAccount( + accountCreator = { account -> + recordedAccount = account + AccountCreatorResult.Success(accountUuid = "uuid") + }, + uuidGenerator = { "uuid" }, + ) + + val result = createAccount.execute( + AccountState( + emailAddress = EMAIL_ADDRESS, + incomingServerSettings = INCOMING_SETTINGS, + outgoingServerSettings = OUTGOING_SETTINGS, + authorizationState = AUTHORIZATION_STATE, + specialFolderSettings = SPECIAL_FOLDER_SETTINGS, + displayOptions = DISPLAY_OPTIONS, + syncOptions = SYNC_OPTIONS, + ), + ) + + assertThat(result).isEqualTo(AccountCreatorResult.Success("uuid")) + assertThat(recordedAccount).isEqualTo( + Account( + uuid = "uuid", + emailAddress = EMAIL_ADDRESS, + incomingServerSettings = INCOMING_SETTINGS, + outgoingServerSettings = OUTGOING_SETTINGS, + authorizationState = AUTHORIZATION_STATE.value, + specialFolderSettings = SPECIAL_FOLDER_SETTINGS, + options = OPTIONS, + ), + ) + } + + private companion object { + const val EMAIL_ADDRESS = "user@example.com" + + val INCOMING_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val OUTGOING_SETTINGS = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val AUTHORIZATION_STATE = AuthorizationState("authorization state") + + val SPECIAL_FOLDER_SETTINGS = SpecialFolderSettings( + archiveSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder(FolderServerId("archive"), "archive", FolderType.ARCHIVE), + ), + draftsSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder(FolderServerId("drafts"), "drafts", FolderType.DRAFTS), + ), + sentSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder(FolderServerId("sent"), "sent", FolderType.SENT), + ), + spamSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder(FolderServerId("spam"), "spam", FolderType.SPAM), + ), + trashSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder(FolderServerId("trash"), "trash", FolderType.TRASH), + ), + ) + + val OPTIONS = AccountOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = null, + checkFrequencyInMinutes = 15, + messageDisplayCount = 25, + showNotification = true, + ) + + val DISPLAY_OPTIONS = AccountDisplayOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = null, + ) + + val SYNC_OPTIONS = AccountSyncOptions( + checkFrequencyInMinutes = 15, + messageDisplayCount = 25, + showNotification = true, + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/FakeFolderFetcher.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/FakeFolderFetcher.kt new file mode 100644 index 0000000..0571e42 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/FakeFolderFetcher.kt @@ -0,0 +1,15 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.folders.FolderFetcher +import com.fsck.k9.mail.folders.RemoteFolder +import com.fsck.k9.mail.oauth.AuthStateStorage + +class FakeFolderFetcher( + private val folders: List = emptyList(), +) : FolderFetcher { + override fun getFolders( + serverSettings: ServerSettings, + authStateStorage: AuthStateStorage?, + ): List = folders +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscoveryTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscoveryTest.kt new file mode 100644 index 0000000..781c5a0 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscoveryTest.kt @@ -0,0 +1,217 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.autodiscovery.api.AuthenticationType +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryService +import app.k9mail.autodiscovery.api.ConnectionSecurity +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.autodiscovery.api.OutgoingServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort +import net.thunderbird.core.common.oauth.OAuthConfiguration +import net.thunderbird.core.common.oauth.OAuthConfigurationProvider +import org.junit.Test + +class GetAutoDiscoveryTest { + + @Test + fun `should return a valid result`() = runTest { + val useCase = GetAutoDiscovery( + service = FakeAutoDiscoveryService(SETTINGS_WITH_PASSWORD), + oauthProvider = FakeOAuthConfigurationProvider(OAUTH_CONFIGURATION), + ) + + val result = useCase.execute("user@example.com") + + assertThat(result) + .isInstanceOf() + .isEqualTo(SETTINGS_WITH_PASSWORD) + } + + @Test + fun `should return NoUsableSettingsFound result`() = runTest { + val useCase = GetAutoDiscovery( + service = FakeAutoDiscoveryService(AutoDiscoveryResult.NoUsableSettingsFound), + oauthProvider = FakeOAuthConfigurationProvider(), + ) + + val result = useCase.execute("user@example.com") + + assertThat(result) + .isInstanceOf() + } + + @Test + fun `should return NoUsableSettingsFound result when incoming server settings not supported`() = runTest { + val useCase = GetAutoDiscovery( + service = FakeAutoDiscoveryService(SETTINGS_WITH_UNSUPPORTED_INCOMING_SERVER), + oauthProvider = FakeOAuthConfigurationProvider(), + ) + + val result = useCase.execute("user@example.com") + + assertThat(result) + .isInstanceOf() + } + + @Test + fun `should return NoUsableSettingsFound result when server outgoing settings not supported`() = runTest { + val useCase = GetAutoDiscovery( + service = FakeAutoDiscoveryService(SETTINGS_WITH_UNSUPPORTED_OUTGOING_SERVER), + oauthProvider = FakeOAuthConfigurationProvider(), + ) + + val result = useCase.execute("user@example.com") + + assertThat(result) + .isInstanceOf() + } + + @Test + fun `should return UnexpectedException result`() = runTest { + val autoDiscoveryResult = AutoDiscoveryResult.UnexpectedException(Exception("unexpected exception")) + val useCase = GetAutoDiscovery( + service = FakeAutoDiscoveryService(autoDiscoveryResult), + oauthProvider = FakeOAuthConfigurationProvider(), + ) + + val result = useCase.execute("user@example.com") + + assertThat(result) + .isInstanceOf() + .isEqualTo(autoDiscoveryResult) + } + + @Test + fun `should check for oauth support and return when supported`() = runTest { + val useCase = GetAutoDiscovery( + service = FakeAutoDiscoveryService(SETTINGS_WITH_OAUTH), + oauthProvider = FakeOAuthConfigurationProvider(OAUTH_CONFIGURATION), + ) + + val result = useCase.execute("user@example.com") + + assertThat(result) + .isInstanceOf() + .isEqualTo(SETTINGS_WITH_OAUTH) + } + + @Test + fun `should check for OAuth support and drop OAuth when not supported`() = runTest { + val useCase = GetAutoDiscovery( + service = FakeAutoDiscoveryService(SETTINGS_WITH_OAUTH), + oauthProvider = FakeOAuthConfigurationProvider(), + ) + + val result = useCase.execute("user@example.com") + + assertThat(result) + .isInstanceOf() + .isEqualTo( + SETTINGS_WITH_OAUTH.copy( + incomingServerSettings = (SETTINGS_WITH_OAUTH.incomingServerSettings as ImapServerSettings).copy( + authenticationTypes = listOf(AuthenticationType.PasswordCleartext), + ), + outgoingServerSettings = (SETTINGS_WITH_OAUTH.outgoingServerSettings as SmtpServerSettings).copy( + authenticationTypes = listOf(AuthenticationType.PasswordCleartext), + ), + ), + ) + } + + private class FakeAutoDiscoveryService( + private val answer: AutoDiscoveryResult = AutoDiscoveryResult.NoUsableSettingsFound, + ) : AutoDiscoveryService { + override suspend fun discover(email: EmailAddress): AutoDiscoveryResult = answer + } + + private class FakeOAuthConfigurationProvider( + private val answer: OAuthConfiguration? = null, + ) : OAuthConfigurationProvider { + override fun getConfiguration(hostname: String): OAuthConfiguration? = answer + } + + private class UnsupportedIncomingServerSettings : IncomingServerSettings + private class UnsupportedOutgoingServerSettings : OutgoingServerSettings + + private companion object { + private val SETTINGS_WITH_OAUTH = AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = "imap.example.com".toHostname(), + port = 993.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.OAuth2, AuthenticationType.PasswordCleartext), + username = "user", + ), + outgoingServerSettings = SmtpServerSettings( + hostname = "smtp.example.com".toHostname(), + port = 465.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.OAuth2, AuthenticationType.PasswordCleartext), + username = "user", + ), + isTrusted = true, + source = "source", + ) + + private val SETTINGS_WITH_UNSUPPORTED_INCOMING_SERVER = AutoDiscoveryResult.Settings( + incomingServerSettings = UnsupportedIncomingServerSettings(), + outgoingServerSettings = SmtpServerSettings( + hostname = "smtp.example.com".toHostname(), + port = 465.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.OAuth2), + username = "user", + ), + isTrusted = true, + source = "source", + ) + + private val SETTINGS_WITH_UNSUPPORTED_OUTGOING_SERVER = AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = "imap.example.com".toHostname(), + port = 993.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.OAuth2, AuthenticationType.PasswordCleartext), + username = "user", + ), + outgoingServerSettings = UnsupportedOutgoingServerSettings(), + isTrusted = true, + source = "source", + ) + + private val SETTINGS_WITH_PASSWORD = AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = "imap.example.com".toHostname(), + port = 993.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordCleartext), + username = "user", + ), + outgoingServerSettings = SmtpServerSettings( + hostname = "smtp.example.com".toHostname(), + port = 465.toPort(), + connectionSecurity = ConnectionSecurity.TLS, + authenticationTypes = listOf(AuthenticationType.PasswordCleartext), + username = "user", + ), + isTrusted = true, + source = "source", + ) + + private val OAUTH_CONFIGURATION = OAuthConfiguration( + clientId = "clientId", + scopes = listOf("scopes"), + authorizationEndpoint = "authorizationEndpoint", + tokenEndpoint = "tokenEndpoint", + redirectUri = "redirectUri", + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetSpecialFolderOptionsTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetSpecialFolderOptionsTest.kt new file mode 100644 index 0000000..ac86d27 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetSpecialFolderOptionsTest.kt @@ -0,0 +1,327 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract.AccountStateRepository +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.folders.FolderFetcher +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import com.fsck.k9.mail.oauth.AuthStateStorage +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class GetSpecialFolderOptionsTest { + + @Test + fun `should fail when no incoming server settings found`() = runTest { + val testSubject = createTestSubject( + accountStateRepository = InMemoryAccountStateRepository( + state = AccountState( + incomingServerSettings = null, + ), + ), + ) + + assertFailure { testSubject() } + .isInstanceOf() + .hasMessage("No incoming server settings available") + } + + @Test + fun `should map remote folders to Folders`() = runTest { + val testSubject = createTestSubject( + folderFetcher = FakeFolderFetcher(folders = FOLDERS), + accountStateRepository = InMemoryAccountStateRepository( + state = AccountState( + incomingServerSettings = SERVER_SETTINGS, + ), + ), + ) + + val folders = testSubject() + + assertThat(folders.archiveSpecialFolderOptions).containsExactly( + *getArrayOfFolders( + SpecialFolderOption.Special( + remoteFolder = ARCHIVE_FOLDER_1, + isAutomatic = true, + ), + ), + ) + assertThat(folders.draftsSpecialFolderOptions).containsExactly( + *getArrayOfFolders( + SpecialFolderOption.Special( + remoteFolder = DRAFTS_FOLDER_1, + isAutomatic = true, + ), + ), + ) + assertThat(folders.sentSpecialFolderOptions).containsExactly( + *getArrayOfFolders( + SpecialFolderOption.Special( + remoteFolder = SENT_FOLDER_1, + isAutomatic = true, + ), + ), + ) + assertThat(folders.spamSpecialFolderOptions).containsExactly( + *getArrayOfFolders( + SpecialFolderOption.Special( + remoteFolder = SPAM_FOLDER_1, + isAutomatic = true, + ), + ), + ) + assertThat(folders.trashSpecialFolderOptions).containsExactly( + *getArrayOfFolders( + SpecialFolderOption.Special( + remoteFolder = TRASH_FOLDER_1, + isAutomatic = true, + ), + ), + ) + } + + @Test + fun `should map remote folders to Folders and take first special folder for type`() = runTest { + val testSubject = createTestSubject( + folderFetcher = FakeFolderFetcher( + folders = listOf( + ARCHIVE_FOLDER_2, + ARCHIVE_FOLDER_1, + DRAFTS_FOLDER_2, + DRAFTS_FOLDER_1, + SENT_FOLDER_2, + SENT_FOLDER_1, + SPAM_FOLDER_2, + SPAM_FOLDER_1, + TRASH_FOLDER_2, + TRASH_FOLDER_1, + REGULAR_FOLDER_1, + REGULAR_FOLDER_2, + ), + ), + accountStateRepository = InMemoryAccountStateRepository( + state = AccountState( + incomingServerSettings = SERVER_SETTINGS, + ), + ), + ) + + val folders = testSubject() + + assertThat(folders.archiveSpecialFolderOptions[0]).isEqualTo( + SpecialFolderOption.Special( + remoteFolder = ARCHIVE_FOLDER_1, + isAutomatic = true, + ), + ) + assertThat(folders.draftsSpecialFolderOptions[0]).isEqualTo( + SpecialFolderOption.Special( + remoteFolder = DRAFTS_FOLDER_1, + isAutomatic = true, + ), + ) + assertThat(folders.sentSpecialFolderOptions[0]).isEqualTo( + SpecialFolderOption.Special( + remoteFolder = SENT_FOLDER_1, + isAutomatic = true, + ), + ) + assertThat(folders.spamSpecialFolderOptions[0]).isEqualTo( + SpecialFolderOption.Special( + remoteFolder = SPAM_FOLDER_1, + isAutomatic = true, + ), + ) + assertThat(folders.trashSpecialFolderOptions[0]).isEqualTo( + SpecialFolderOption.Special( + remoteFolder = TRASH_FOLDER_1, + isAutomatic = true, + ), + ) + } + + @Test + fun `should map remote folders to Folders when no special folder present`() = runTest { + val testSubject = createTestSubject( + folderFetcher = FakeFolderFetcher( + folders = listOf( + REGULAR_FOLDER_1, + REGULAR_FOLDER_2, + ), + ), + accountStateRepository = InMemoryAccountStateRepository( + state = AccountState( + incomingServerSettings = SERVER_SETTINGS, + ), + ), + ) + val expectedSpecialFolderOptions = listOf( + SpecialFolderOption.None(isAutomatic = true), + SpecialFolderOption.None(), + SpecialFolderOption.Regular(REGULAR_FOLDER_1), + SpecialFolderOption.Regular(REGULAR_FOLDER_2), + ).toTypedArray() + + val folders = testSubject() + + assertThat(folders.archiveSpecialFolderOptions).containsExactly( + *expectedSpecialFolderOptions, + ) + assertThat(folders.draftsSpecialFolderOptions).containsExactly( + *expectedSpecialFolderOptions, + ) + assertThat(folders.sentSpecialFolderOptions).containsExactly( + *expectedSpecialFolderOptions, + ) + assertThat(folders.spamSpecialFolderOptions).containsExactly( + *expectedSpecialFolderOptions, + ) + assertThat(folders.trashSpecialFolderOptions).containsExactly( + *expectedSpecialFolderOptions, + ) + } + + private companion object { + fun createTestSubject( + folderFetcher: FolderFetcher = FakeFolderFetcher(), + accountStateRepository: AccountStateRepository = InMemoryAccountStateRepository(), + ): UseCase.GetSpecialFolderOptions { + return GetSpecialFolderOptions( + folderFetcher = folderFetcher, + accountStateRepository = accountStateRepository, + authStateStorage = accountStateRepository as AuthStateStorage, + ) + } + + val ARCHIVE_FOLDER_1 = RemoteFolder( + serverId = FolderServerId("Archive"), + displayName = "Archive", + type = FolderType.ARCHIVE, + ) + + val ARCHIVE_FOLDER_2 = RemoteFolder( + serverId = FolderServerId("Archive2"), + displayName = "Archive2", + type = FolderType.ARCHIVE, + ) + + val DRAFTS_FOLDER_1 = RemoteFolder( + serverId = FolderServerId("Drafts"), + displayName = "Drafts", + type = FolderType.DRAFTS, + ) + + val DRAFTS_FOLDER_2 = RemoteFolder( + serverId = FolderServerId("Drafts2"), + displayName = "Drafts2", + type = FolderType.DRAFTS, + ) + + val SENT_FOLDER_1 = RemoteFolder( + serverId = FolderServerId("Sent"), + displayName = "Sent", + type = FolderType.SENT, + ) + + val SENT_FOLDER_2 = RemoteFolder( + serverId = FolderServerId("Sent2"), + displayName = "Sent2", + type = FolderType.SENT, + ) + + val SPAM_FOLDER_1 = RemoteFolder( + serverId = FolderServerId("Spam"), + displayName = "Spam", + type = FolderType.SPAM, + ) + + val SPAM_FOLDER_2 = RemoteFolder( + serverId = FolderServerId("Spam2"), + displayName = "Spam2", + type = FolderType.SPAM, + ) + + val TRASH_FOLDER_1 = RemoteFolder( + serverId = FolderServerId("Trash"), + displayName = "Trash", + type = FolderType.TRASH, + ) + + val TRASH_FOLDER_2 = RemoteFolder( + serverId = FolderServerId("Trash2"), + displayName = "Trash2", + type = FolderType.TRASH, + ) + + val REGULAR_FOLDER_1 = RemoteFolder( + serverId = FolderServerId("Regular1"), + displayName = "Regular1", + type = FolderType.REGULAR, + ) + + val REGULAR_FOLDER_2 = RemoteFolder( + serverId = FolderServerId("Regular2"), + displayName = "Regular2", + type = FolderType.REGULAR, + ) + + val FOLDERS = listOf( + ARCHIVE_FOLDER_1, + DRAFTS_FOLDER_1, + SENT_FOLDER_1, + SPAM_FOLDER_1, + TRASH_FOLDER_1, + REGULAR_FOLDER_1, + REGULAR_FOLDER_2, + ) + + fun getArrayOfFolders(defaultSpecialFolderOption: SpecialFolderOption?): Array { + return listOfNotNull( + defaultSpecialFolderOption, + SpecialFolderOption.None(), + SpecialFolderOption.Special( + remoteFolder = ARCHIVE_FOLDER_1, + ), + SpecialFolderOption.Special( + remoteFolder = DRAFTS_FOLDER_1, + ), + SpecialFolderOption.Regular(REGULAR_FOLDER_1), + SpecialFolderOption.Regular(REGULAR_FOLDER_2), + SpecialFolderOption.Special( + remoteFolder = SENT_FOLDER_1, + ), + SpecialFolderOption.Special( + remoteFolder = SPAM_FOLDER_1, + ), + SpecialFolderOption.Special( + remoteFolder = TRASH_FOLDER_1, + ), + ).toTypedArray() + } + + val SERVER_SETTINGS = ServerSettings( + type = "imap", + host = "imap.example.org", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + username = "example", + password = "password", + clientCertificateAlias = null, + authenticationType = AuthType.PLAIN, + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateAccountNameTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateAccountNameTest.kt new file mode 100644 index 0000000..b575612 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateAccountNameTest.kt @@ -0,0 +1,36 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.usecase.ValidateAccountName.ValidateAccountNameError +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidateAccountNameTest { + + private val testSubject = ValidateAccountName() + + @Test + fun `should succeed when account name is set`() { + val result = testSubject.execute("account name") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should succeed when account name is empty`() { + val result = testSubject.execute("") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when account name is blank`() { + val result = testSubject.execute(" ") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateConfigurationApprovalTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateConfigurationApprovalTest.kt new file mode 100644 index 0000000..3e08180 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateConfigurationApprovalTest.kt @@ -0,0 +1,76 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidateConfigurationApprovalTest { + + private val testSubject = ValidateConfigurationApproval() + + @Test + fun `should succeed when auto discovery is approved and trusted`() { + val result = testSubject.execute(isApproved = true, isAutoDiscoveryTrusted = true) + + assertThat(result).isInstanceOf() + } + + @Test + fun `should succeed when auto discovery not approved but is trusted`() { + val result = testSubject.execute(isApproved = false, isAutoDiscoveryTrusted = true) + + assertThat(result).isInstanceOf() + } + + @Test + fun `should succeed when auto discovery is approved but not trusted`() { + val result = testSubject.execute(isApproved = true, isAutoDiscoveryTrusted = false) + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when auto discovery is not approved and not trusted`() { + val result = testSubject.execute(isApproved = false, isAutoDiscoveryTrusted = false) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should succeed when auto discovery isApproved null and is trusted`() { + val result = testSubject.execute(isApproved = null, isAutoDiscoveryTrusted = true) + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when auto discovery is isApproved null and is not trusted`() { + val result = testSubject.execute(isApproved = null, isAutoDiscoveryTrusted = false) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when auto discovery is approved and trusted is null`() { + val result = testSubject.execute(isApproved = false, isAutoDiscoveryTrusted = null) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when auto discovery is not approved and trusted is null`() { + val result = testSubject.execute(isApproved = false, isAutoDiscoveryTrusted = null) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateDisplayNameTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateDisplayNameTest.kt new file mode 100644 index 0000000..3a07e52 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateDisplayNameTest.kt @@ -0,0 +1,38 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.usecase.ValidateDisplayName.ValidateDisplayNameError +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidateDisplayNameTest { + + private val testSubject = ValidateDisplayName() + + @Test + fun `should succeed when display name is set`() { + val result = testSubject.execute("display name") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when display name is empty`() { + val result = testSubject.execute("") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when display name is blank`() { + val result = testSubject.execute(" ") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailAddressTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailAddressTest.kt new file mode 100644 index 0000000..9eeb5e8 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailAddressTest.kt @@ -0,0 +1,109 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailAddress.ValidateEmailAddressError +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.logging.testing.TestLogger +import org.junit.Before +import org.junit.Test + +class ValidateEmailAddressTest { + + private val testSubject = ValidateEmailAddress() + + @Before + fun setUp() { + Log.logger = TestLogger() + } + + @Test + fun `should succeed when email address is valid`() { + val result = testSubject.execute("test@example.com") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when email address is blank`() { + val result = testSubject.execute(" ") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when email address is using unnecessary quoting in local part`() { + val result = testSubject.execute("\"local-part\"@domain.example") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when email address requires quoted local part`() { + val result = testSubject.execute("\"local part\"@domain.example") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when local part is empty`() { + val result = testSubject.execute("\"\"@domain.example") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when domain part contains IPv4 literal`() { + val result = testSubject.execute("user@[255.0.100.23]") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when domain part contains IPv6 literal`() { + val result = testSubject.execute("user@[IPv6:2001:0db8:0000:0000:0000:ff00:0042:8329]") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when local part contains non-ASCII character`() { + val result = testSubject.execute("töst@domain.example") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when domain contains non-ASCII character`() { + val result = testSubject.execute("test@dömain.example") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `should fail when email address is invalid`() { + val result = testSubject.execute("test") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailSignatureTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailSignatureTest.kt new file mode 100644 index 0000000..357f660 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateEmailSignatureTest.kt @@ -0,0 +1,36 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature.ValidateEmailSignatureError +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidateEmailSignatureTest { + + private val testSubject = ValidateEmailSignature() + + @Test + fun `should succeed when email signature is set`() { + val result = testSubject.execute("email signature") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should succeed when email signature is empty`() { + val result = testSubject.execute("") + + assertThat(result).isInstanceOf() + } + + @Test + fun `should fail when email signature is blank`() { + val result = testSubject.execute(" ") + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateSpecialFolderOptionsTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateSpecialFolderOptionsTest.kt new file mode 100644 index 0000000..6614e42 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/ValidateSpecialFolderOptionsTest.kt @@ -0,0 +1,146 @@ +package app.k9mail.feature.account.setup.domain.usecase + +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import app.k9mail.feature.account.setup.domain.DomainContract +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.ValidateSpecialFolderOptions.Failure +import assertk.assertThat +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import dev.forkhandles.fabrikate.Fabrikate +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import org.junit.Test + +class ValidateSpecialFolderOptionsTest { + + private val testSubject = createTestSubject() + + @Test + fun `validate special folder options should succeed when all default options are present`() { + val result = testSubject(SPECIAL_FOLDER_OPTIONS) + + assertThat(result).isInstanceOf() + } + + @Test + fun `validate special folder options should fail when archive default option is missing`() { + val specialFolderOptions = SPECIAL_FOLDER_OPTIONS.copy( + archiveSpecialFolderOptions = listOf( + SpecialFolderOption.None( + isAutomatic = true, + ), + ), + ) + + val result = testSubject(specialFolderOptions) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `validate special folder options should fail when drafts default option is missing`() { + val specialFolderOptions = SPECIAL_FOLDER_OPTIONS.copy( + draftsSpecialFolderOptions = listOf( + SpecialFolderOption.None( + isAutomatic = true, + ), + ), + ) + + val result = testSubject(specialFolderOptions) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `validate special folder options should fail when sent default option is missing`() { + val specialFolderOptions = SPECIAL_FOLDER_OPTIONS.copy( + sentSpecialFolderOptions = listOf( + SpecialFolderOption.None( + isAutomatic = true, + ), + ), + ) + + val result = testSubject(specialFolderOptions) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `validate special folder options should fail when spam default option is missing`() { + val specialFolderOptions = SPECIAL_FOLDER_OPTIONS.copy( + spamSpecialFolderOptions = listOf( + SpecialFolderOption.None( + isAutomatic = true, + ), + ), + ) + + val result = testSubject(specialFolderOptions) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + @Test + fun `validate special folder options should fail when trash default option is missing`() { + val specialFolderOptions = SPECIAL_FOLDER_OPTIONS.copy( + trashSpecialFolderOptions = listOf( + SpecialFolderOption.None( + isAutomatic = true, + ), + ), + ) + + val result = testSubject(specialFolderOptions) + + assertThat(result).isInstanceOf() + .prop(ValidationResult.Failure::error) + .isInstanceOf() + } + + private companion object { + fun createTestSubject(): DomainContract.UseCase.ValidateSpecialFolderOptions = ValidateSpecialFolderOptions() + + val SPECIAL_FOLDER_OPTIONS = SpecialFolderOptions( + archiveSpecialFolderOptions = listOf( + SpecialFolderOption.Special( + isAutomatic = true, + remoteFolder = Fabrikate().random(), + ), + ), + draftsSpecialFolderOptions = listOf( + SpecialFolderOption.Special( + isAutomatic = true, + remoteFolder = Fabrikate().random(), + ), + ), + sentSpecialFolderOptions = listOf( + SpecialFolderOption.Special( + isAutomatic = true, + remoteFolder = Fabrikate().random(), + ), + ), + spamSpecialFolderOptions = listOf( + SpecialFolderOption.Special( + isAutomatic = true, + remoteFolder = Fabrikate().random(), + ), + ), + trashSpecialFolderOptions = listOf( + SpecialFolderOption.Special( + isAutomatic = true, + remoteFolder = Fabrikate().random(), + ), + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/FakeBrandNameProvider.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/FakeBrandNameProvider.kt new file mode 100644 index 0000000..d6b0772 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/FakeBrandNameProvider.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.account.setup.ui + +import net.thunderbird.core.common.provider.BrandNameProvider + +internal object FakeBrandNameProvider : BrandNameProvider { + override val brandName: String = "Fake Brand Name" +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreenKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreenKtTest.kt new file mode 100644 index 0000000..e4a5bf2 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryScreenKtTest.kt @@ -0,0 +1,52 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.setup.ui.FakeBrandNameProvider +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AccountAutoDiscoveryScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeAccountAutoDiscoveryViewModel(initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + AccountAutoDiscoveryScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + brandNameProvider = FakeBrandNameProvider, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect( + Effect.NavigateNext( + result = AccountAutoDiscoveryContract.AutoDiscoveryUiResult( + isAutomaticConfig = false, + incomingProtocolType = IncomingProtocolType.IMAP, + ), + ), + ) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateMapperKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateMapperKtTest.kt new file mode 100644 index 0000000..05bc327 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateMapperKtTest.kt @@ -0,0 +1,221 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AuthenticationType +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.input.NumberInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.server.settings.ui.incoming.IncomingServerSettingsContract +import app.k9mail.feature.account.server.settings.ui.outgoing.OutgoingServerSettingsContract +import app.k9mail.feature.account.setup.domain.entity.AutoDiscoveryAuthenticationType +import app.k9mail.feature.account.setup.domain.entity.AutoDiscoveryConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.toConnectionSecurity +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort +import org.junit.Test + +class AccountAutoDiscoveryStateMapperKtTest { + + @Test + fun `should map to empty AccountState when empty`() { + val accountState = EMPTY_STATE.toAccountState() + + assertThat(accountState).isEqualTo( + AccountState( + emailAddress = "", + incomingServerSettings = null, + outgoingServerSettings = null, + authorizationState = null, + displayOptions = null, + syncOptions = null, + ), + ) + } + + @Test + fun `should map to default IncomingConfigState when empty`() { + val incomingConfigState = EMPTY_STATE.toIncomingConfigState() + + assertThat(incomingConfigState).isEqualTo(IncomingServerSettingsContract.State()) + } + + @Test + fun `should map to IncomingConfigState when no AutoDiscovery`() { + val incomingConfigState = EMAIL_PASSWORD_STATE.toIncomingConfigState() + + assertThat(incomingConfigState).isEqualTo( + IncomingServerSettingsContract.State( + username = StringInputField(value = EMAIL_ADDRESS), + password = StringInputField(value = PASSWORD), + ), + ) + } + + @Test + fun `should map to IncomingConfigState when AutoDiscovery`() { + val incomingConfigState = AUTO_DISCOVERY_STATE.toIncomingConfigState() + + assertThat(incomingConfigState).isEqualTo( + IncomingServerSettingsContract.State( + protocolType = IncomingProtocolType.IMAP, + server = StringInputField(value = AUTO_DISCOVERY_HOSTNAME.value), + security = AUTO_DISCOVERY_SECURITY.toConnectionSecurity(), + port = NumberInputField(value = AUTO_DISCOVERY_PORT_IMAP.value.toLong()), + authenticationType = AuthenticationType.PasswordEncrypted, + username = StringInputField(value = AUTO_DISCOVERY_USERNAME), + password = StringInputField(value = PASSWORD), + ), + ) + } + + @Test + fun `should map to empty username IncomingConfigState when AutoDiscovery empty username`() { + val incomingConfigState = AUTO_DISCOVERY_STATE_USERNAME_EMPTY.toIncomingConfigState() + + assertThat(incomingConfigState).isEqualTo( + IncomingServerSettingsContract.State( + protocolType = IncomingProtocolType.IMAP, + server = StringInputField(value = AUTO_DISCOVERY_HOSTNAME.value), + security = AUTO_DISCOVERY_SECURITY.toConnectionSecurity(), + port = NumberInputField(value = AUTO_DISCOVERY_PORT_IMAP.value.toLong()), + authenticationType = AuthenticationType.PasswordEncrypted, + username = StringInputField(value = ""), + password = StringInputField(value = PASSWORD), + ), + ) + } + + @Test + fun `should map to OutgoingConfigState when empty`() { + val outgoingConfigState = EMPTY_STATE.toOutgoingConfigState() + + assertThat(outgoingConfigState).isEqualTo(OutgoingServerSettingsContract.State()) + } + + @Test + fun `should map to OutgoingConfigState when no AutoDiscovery`() { + val outgoingConfigState = EMAIL_PASSWORD_STATE.toOutgoingConfigState() + + assertThat(outgoingConfigState).isEqualTo( + OutgoingServerSettingsContract.State( + username = StringInputField(value = EMAIL_ADDRESS), + password = StringInputField(value = PASSWORD), + ), + ) + } + + @Test + fun `should map to OutgoingConfigState when AutoDiscovery`() { + val outgoingConfigState = AUTO_DISCOVERY_STATE.toOutgoingConfigState() + + assertThat(outgoingConfigState).isEqualTo( + OutgoingServerSettingsContract.State( + server = StringInputField(value = AUTO_DISCOVERY_HOSTNAME.value), + security = AUTO_DISCOVERY_SECURITY.toConnectionSecurity(), + port = NumberInputField(value = AUTO_DISCOVERY_PORT_SMTP.value.toLong()), + authenticationType = AuthenticationType.PasswordEncrypted, + username = StringInputField(value = AUTO_DISCOVERY_USERNAME), + password = StringInputField(value = PASSWORD), + ), + ) + } + + @Test + fun `should map to empty username OutgoingConfigState when AutoDiscovery empty username`() { + val outgoingConfigState = AUTO_DISCOVERY_STATE_USERNAME_EMPTY.toOutgoingConfigState() + + assertThat(outgoingConfigState).isEqualTo( + OutgoingServerSettingsContract.State( + server = StringInputField(value = AUTO_DISCOVERY_HOSTNAME.value), + security = AUTO_DISCOVERY_SECURITY.toConnectionSecurity(), + port = NumberInputField(value = AUTO_DISCOVERY_PORT_SMTP.value.toLong()), + authenticationType = AuthenticationType.PasswordEncrypted, + username = StringInputField(value = ""), + password = StringInputField(value = PASSWORD), + ), + ) + } + + @Test + fun `should map to OptionsState when empty`() { + val optionsState = EMPTY_STATE.toOptionsState() + + assertThat(optionsState).isEqualTo(DisplayOptionsContract.State()) + } + + @Test + fun `should map to OptionsState when email and password set`() { + val optionsState = EMAIL_PASSWORD_STATE.toOptionsState() + + assertThat(optionsState).isEqualTo( + DisplayOptionsContract.State( + accountName = StringInputField(value = EMAIL_ADDRESS), + ), + ) + } + + private companion object { + const val EMAIL_ADDRESS = "test@example.com" + const val PASSWORD = "password" + const val SERVER_IMAP = "imap.example.com" + const val SERVER_SMTP = "smtp.example.com" + + val AUTO_DISCOVERY_HOSTNAME = "incoming.example.com".toHostname() + val AUTO_DISCOVERY_PORT_IMAP = 143.toPort() + val AUTO_DISCOVERY_PORT_SMTP = 587.toPort() + val AUTO_DISCOVERY_SECURITY = AutoDiscoveryConnectionSecurity.StartTLS + val AUTO_DISCOVERY_AUTHENTICATION = AutoDiscoveryAuthenticationType.PasswordEncrypted + const val AUTO_DISCOVERY_USERNAME = "username" + + val EMPTY_STATE = AccountAutoDiscoveryContract.State() + + val EMAIL_PASSWORD_STATE = AccountAutoDiscoveryContract.State( + emailAddress = StringInputField(value = EMAIL_ADDRESS), + password = StringInputField(value = PASSWORD), + ) + + val AUTO_DISCOVERY_STATE = EMAIL_PASSWORD_STATE.copy( + autoDiscoverySettings = AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = AUTO_DISCOVERY_HOSTNAME, + port = AUTO_DISCOVERY_PORT_IMAP, + connectionSecurity = AUTO_DISCOVERY_SECURITY, + authenticationTypes = listOf(AUTO_DISCOVERY_AUTHENTICATION), + username = AUTO_DISCOVERY_USERNAME, + ), + outgoingServerSettings = SmtpServerSettings( + hostname = AUTO_DISCOVERY_HOSTNAME, + port = AUTO_DISCOVERY_PORT_SMTP, + connectionSecurity = AUTO_DISCOVERY_SECURITY, + authenticationTypes = listOf(AUTO_DISCOVERY_AUTHENTICATION), + username = AUTO_DISCOVERY_USERNAME, + ), + isTrusted = true, + source = "test", + ), + ) + + val AUTO_DISCOVERY_STATE_USERNAME_EMPTY = AUTO_DISCOVERY_STATE.copy( + autoDiscoverySettings = AUTO_DISCOVERY_STATE.autoDiscoverySettings?.copy( + incomingServerSettings = ( + AUTO_DISCOVERY_STATE.autoDiscoverySettings + ?.incomingServerSettings as ImapServerSettings + ).copy( + username = "", + ), + outgoingServerSettings = ( + AUTO_DISCOVERY_STATE.autoDiscoverySettings + ?.outgoingServerSettings as SmtpServerSettings + ).copy( + username = "", + ), + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateTest.kt new file mode 100644 index 0000000..8fcfff3 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryStateTest.kt @@ -0,0 +1,29 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.feature.account.common.domain.input.BooleanInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ConfigStep +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class AccountAutoDiscoveryStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField(), + password = StringInputField(), + autoDiscoverySettings = null, + configurationApproved = BooleanInputField(), + error = null, + isLoading = false, + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryViewModelTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryViewModelTest.kt new file mode 100644 index 0000000..d557653 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/AccountAutoDiscoveryViewModelTest.kt @@ -0,0 +1,464 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndMviTurbinesConsumed +import app.k9mail.core.ui.compose.testing.mvi.eventStateTest +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.common.domain.input.BooleanInputField +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.setup.domain.entity.AutoDiscoverySettingsFixture +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.AutoDiscoveryUiResult +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ConfigStep +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Error +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.delay +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class AccountAutoDiscoveryViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should reset state when EmailAddressChanged event is received`() = runMviTest { + val initialState = State( + configStep = ConfigStep.PASSWORD, + emailAddress = StringInputField(value = "email"), + password = StringInputField(value = "password"), + ) + val testSubject = createTestSubject(initialState) + + eventStateTest( + viewModel = testSubject, + initialState = initialState, + event = Event.EmailAddressChanged("email"), + expectedState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField(value = "email"), + password = StringInputField(), + ), + ) + } + + @Test + fun `should change state when PasswordChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(), + initialState = State(), + event = Event.PasswordChanged("password"), + expectedState = State( + password = StringInputField(value = "password"), + ), + ) + } + + @Test + fun `should change state when ResultApprovalChanged event is received`() = runMviTest { + eventStateTest( + viewModel = createTestSubject(), + initialState = State(), + event = Event.ResultApprovalChanged(true), + expectedState = State( + configurationApproved = BooleanInputField(value = true), + ), + ) + } + + @Test + fun `should change state to password when OnNextClicked event is received, input valid and discovery loaded`() = + runMviTest { + val autoDiscoverySettings = AutoDiscoverySettingsFixture.settings + val initialState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField(value = "email"), + ) + val testSubject = AccountAutoDiscoveryViewModel( + validator = FakeAccountAutoDiscoveryValidator(), + getAutoDiscovery = { + delay(50) + autoDiscoverySettings + }, + oAuthViewModel = FakeAccountOAuthViewModel(), + accountStateRepository = InMemoryAccountStateRepository(), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + val validatedState = initialState.copy( + emailAddress = StringInputField( + value = "email", + error = null, + isValid = true, + ), + ) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(validatedState) + + val loadingState = validatedState.copy( + isLoading = true, + ) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val successState = validatedState.copy( + autoDiscoverySettings = autoDiscoverySettings, + configStep = ConfigStep.PASSWORD, + isLoading = false, + ) + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(successState) + } + } + + @Test + fun `should not change state when OnNextClicked event is received, input valid but discovery failed`() = + runMviTest { + val initialState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField(value = "email"), + ) + val discoveryError = Exception("discovery error") + val testSubject = AccountAutoDiscoveryViewModel( + validator = FakeAccountAutoDiscoveryValidator(), + getAutoDiscovery = { + delay(50) + AutoDiscoveryResult.UnexpectedException(discoveryError) + }, + oAuthViewModel = FakeAccountOAuthViewModel(), + accountStateRepository = InMemoryAccountStateRepository(), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + val validatedState = initialState.copy( + emailAddress = StringInputField( + value = "email", + error = null, + isValid = true, + ), + ) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(validatedState) + + val loadingState = validatedState.copy( + isLoading = true, + ) + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(loadingState) + + val failureState = validatedState.copy( + isLoading = false, + error = Error.UnknownError, + ) + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(failureState) + } + } + + @Test + fun `should reset error state and change to password step when OnNextClicked event received when having error`() = + runMviTest { + val initialState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField( + value = "email", + isValid = true, + ), + error = Error.UnknownError, + ) + val testSubject = createTestSubject(initialState) + + eventStateTest( + viewModel = testSubject, + initialState = initialState, + event = Event.OnNextClicked, + expectedState = State( + configStep = ConfigStep.PASSWORD, + emailAddress = StringInputField( + value = "email", + isValid = true, + ), + error = null, + ), + ) + } + + @Test + fun `should not change config step to password when OnNextClicked event is received and input invalid`() = + runMviTest { + val initialState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField(value = "invalid email"), + ) + val testSubject = AccountAutoDiscoveryViewModel( + validator = FakeAccountAutoDiscoveryValidator( + emailAddressAnswer = ValidationResult.Failure(TestError), + ), + getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound }, + oAuthViewModel = FakeAccountOAuthViewModel(), + accountStateRepository = InMemoryAccountStateRepository(), + initialState = initialState, + ) + + eventStateTest( + viewModel = testSubject, + initialState = initialState, + event = Event.OnNextClicked, + expectedState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField( + value = "invalid email", + error = TestError, + isValid = false, + ), + ), + ) + } + + @Test + fun `should save state and emit NavigateNext when OnNextClicked received in password step with valid input`() = + runMviTest { + val initialState = State( + configStep = ConfigStep.PASSWORD, + emailAddress = StringInputField(value = "email"), + password = StringInputField(value = "password"), + ) + val repository = InMemoryAccountStateRepository() + val testSubject = createTestSubject( + initialState = initialState, + repository = repository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + State( + configStep = ConfigStep.PASSWORD, + emailAddress = StringInputField( + value = "email", + error = null, + isValid = true, + ), + password = StringInputField( + value = "password", + error = null, + isValid = true, + ), + configurationApproved = BooleanInputField( + value = null, + error = null, + isValid = true, + ), + ), + ) + + assertThatAndMviTurbinesConsumed( + actual = turbines.effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo( + Effect.NavigateNext( + result = AutoDiscoveryUiResult( + isAutomaticConfig = false, + incomingProtocolType = null, + ), + ), + ) + } + + assertThat(repository.getState()).isEqualTo( + AccountState( + emailAddress = "email", + incomingServerSettings = null, + outgoingServerSettings = null, + authorizationState = null, + displayOptions = null, + syncOptions = null, + ), + ) + } + + @Test + fun `should not emit NavigateNext when OnNextClicked received in password step with invalid input`() = + runMviTest { + val initialState = State( + configStep = ConfigStep.PASSWORD, + emailAddress = StringInputField(value = "email"), + password = StringInputField(value = "password"), + ) + val viewModel = AccountAutoDiscoveryViewModel( + validator = FakeAccountAutoDiscoveryValidator( + passwordAnswer = ValidationResult.Failure(TestError), + ), + getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound }, + oAuthViewModel = FakeAccountOAuthViewModel(), + accountStateRepository = InMemoryAccountStateRepository(), + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(viewModel, initialState) + + viewModel.event(Event.OnNextClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo( + State( + configStep = ConfigStep.PASSWORD, + emailAddress = StringInputField( + value = "email", + error = null, + isValid = true, + ), + password = StringInputField( + value = "password", + error = TestError, + isValid = false, + ), + configurationApproved = BooleanInputField( + value = null, + error = null, + isValid = true, + ), + ), + ) + } + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event is received`() = runMviTest { + val testSubject = createTestSubject() + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.OnBackClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateBack) + } + } + + @Test + fun `should change config step to email address when OnBackClicked event is received in password config step`() = + runMviTest { + val initialState = State( + configStep = ConfigStep.PASSWORD, + emailAddress = StringInputField(value = "email"), + password = StringInputField(value = "password"), + ) + val testSubject = createTestSubject(initialState) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnBackClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo( + State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField(value = "email"), + ), + ) + } + } + + @Test + fun `should reset error state when OnBackClicked event received when having error and in email address step`() = + runMviTest { + val initialState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField( + value = "email", + isValid = true, + ), + error = Error.UnknownError, + ) + val testSubject = createTestSubject(initialState) + + eventStateTest( + viewModel = testSubject, + initialState = initialState, + event = Event.OnBackClicked, + expectedState = State( + configStep = ConfigStep.EMAIL_ADDRESS, + emailAddress = StringInputField( + value = "email", + isValid = true, + ), + error = null, + ), + ) + } + + @Test + fun `should emit NavigateNext effect when OnEditConfigurationClicked event is received`() = runMviTest { + val initialState = State( + autoDiscoverySettings = AutoDiscoverySettingsFixture.settings, + ) + val testSubject = createTestSubject() + testSubject.initState(initialState) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnEditConfigurationClicked) + + assertThatAndMviTurbinesConsumed( + actual = turbines.effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo( + Effect.NavigateNext( + result = AutoDiscoveryUiResult( + isAutomaticConfig = false, + incomingProtocolType = IncomingProtocolType.IMAP, + ), + ), + ) + } + } + + private object TestError : ValidationError + + private companion object { + fun createTestSubject( + initialState: State = State(), + repository: AccountDomainContract.AccountStateRepository = InMemoryAccountStateRepository(), + ): AccountAutoDiscoveryViewModel { + return AccountAutoDiscoveryViewModel( + validator = FakeAccountAutoDiscoveryValidator(), + getAutoDiscovery = { + delay(50) + AutoDiscoveryResult.NoUsableSettingsFound + }, + accountStateRepository = repository, + oAuthViewModel = FakeAccountOAuthViewModel(), + initialState = initialState, + ) + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/FakeAccountAutoDiscoveryValidator.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/FakeAccountAutoDiscoveryValidator.kt new file mode 100644 index 0000000..8309639 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/FakeAccountAutoDiscoveryValidator.kt @@ -0,0 +1,16 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +class FakeAccountAutoDiscoveryValidator( + private val emailAddressAnswer: ValidationResult = ValidationResult.Success, + private val passwordAnswer: ValidationResult = ValidationResult.Success, + private val configurationApprovalAnswer: ValidationResult = ValidationResult.Success, +) : AccountAutoDiscoveryContract.Validator { + override fun validateEmailAddress(emailAddress: String): ValidationResult = emailAddressAnswer + override fun validatePassword(password: String): ValidationResult = passwordAnswer + override fun validateConfigurationApproval( + isApproved: Boolean?, + isAutoDiscoveryTrusted: Boolean?, + ): ValidationResult = configurationApprovalAnswer +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/FakeAccountAutoDiscoveryViewModel.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/FakeAccountAutoDiscoveryViewModel.kt new file mode 100644 index 0000000..c4d372a --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/autodiscovery/FakeAccountAutoDiscoveryViewModel.kt @@ -0,0 +1,29 @@ +package app.k9mail.feature.account.setup.ui.autodiscovery + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.oauth.ui.AccountOAuthContract +import app.k9mail.feature.account.oauth.ui.fake.FakeAccountOAuthViewModel +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event +import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State + +class FakeAccountAutoDiscoveryViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), AccountAutoDiscoveryContract.ViewModel { + + val events = mutableListOf() + + override val oAuthViewModel: AccountOAuthContract.ViewModel = FakeAccountOAuthViewModel() + + override fun initState(state: State) { + updateState { state } + } + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreenTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreenTest.kt new file mode 100644 index 0000000..8bac1f6 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountScreenTest.kt @@ -0,0 +1,51 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.setup.domain.entity.AccountUuid +import app.k9mail.feature.account.setup.ui.FakeBrandNameProvider +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class CreateAccountScreenTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val accountUuid = AccountUuid("irrelevant") + val initialState = State( + isLoading = false, + error = null, + ) + val viewModel = FakeCreateAccountViewModel(initialState) + val navigateNextArguments = mutableListOf() + var navigateBackCounter = 0 + + setContentWithTheme { + CreateAccountScreen( + onNext = { accountUuid -> navigateNextArguments.add(accountUuid) }, + onBack = { navigateBackCounter++ }, + viewModel = viewModel, + brandNameProvider = FakeBrandNameProvider, + ) + } + + assertThat(navigateNextArguments).isEmpty() + assertThat(navigateBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext(accountUuid)) + + assertThat(navigateNextArguments).containsExactly(accountUuid) + assertThat(navigateBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(navigateNextArguments).containsExactly(accountUuid) + assertThat(navigateBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountViewModelTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountViewModelTest.kt new file mode 100644 index 0000000..27a9324 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/CreateAccountViewModelTest.kt @@ -0,0 +1,214 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import app.cash.turbine.turbineScope +import app.k9mail.core.ui.compose.testing.mvi.eventStateTest +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.common.domain.entity.AuthorizationState +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import app.k9mail.feature.account.setup.domain.entity.AccountUuid +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule + +class CreateAccountViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val fakeCreateAccount = FakeCreateAccount() + private val accountStateRepository = InMemoryAccountStateRepository().apply { + setState(ACCOUNT_STATE) + } + private val createAccountViewModel = CreateAccountViewModel( + createAccount = fakeCreateAccount, + accountStateRepository = accountStateRepository, + ) + + @Test + fun `initial state should be loading state`() { + assertThat(createAccountViewModel.state.value).isEqualTo(State(isLoading = true, error = null)) + } + + @Test + fun `should change state and emit navigate effect after successfully creating account`() = runMviTest { + val accountUuid = "accountUuid" + fakeCreateAccount.result = AccountCreatorResult.Success(accountUuid) + val turbines = turbinesWithInitialStateCheck(createAccountViewModel, State(isLoading = true, error = null)) + + createAccountViewModel.event(Event.CreateAccount) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo(State(isLoading = false, error = null)) + + assertThat(fakeCreateAccount.recordedInvocations).containsExactly( + AccountState( + emailAddress = EMAIL_ADDRESS, + incomingServerSettings = INCOMING_SERVER_SETTINGS, + outgoingServerSettings = OUTGOING_SERVER_SETTINGS, + authorizationState = AUTHORIZATION_STATE, + specialFolderSettings = SPECIAL_FOLDER_SETTINGS, + displayOptions = ACCOUNT_DISPLAY_OPTIONS, + syncOptions = ACCOUNT_SYNC_OPTIONS, + ), + ) + + assertThat(turbines.effectTurbine.awaitItem()).isEqualTo(Effect.NavigateNext(AccountUuid(accountUuid))) + } + + @Test + fun `should change state when creating account has failed`() = runMviTest { + val errorResult = AccountCreatorResult.Error("something went wrong") + fakeCreateAccount.result = errorResult + + eventStateTest( + viewModel = createAccountViewModel, + initialState = State(isLoading = true, error = null), + event = Event.CreateAccount, + expectedState = State(isLoading = false, error = errorResult), + ) + } + + @Test + fun `should ignore OnBackClicked event when in loading state`() = runTest { + turbineScope { + val effectTurbine = createAccountViewModel.effect.testIn(scope = backgroundScope) + + createAccountViewModel.event(Event.OnBackClicked) + + effectTurbine.ensureAllEventsConsumed() + } + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event was received while in success state`() = runTest { + turbineScope { + fakeCreateAccount.result = AccountCreatorResult.Success("accountUuid") + createAccountViewModel.event(Event.CreateAccount) + val effectTurbine = createAccountViewModel.effect.testIn(backgroundScope) + + createAccountViewModel.event(Event.OnBackClicked) + + assertThat(effectTurbine.awaitItem()).isEqualTo(Effect.NavigateBack) + } + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event was received while in error state`() = runTest { + turbineScope { + fakeCreateAccount.result = AccountCreatorResult.Error("something went wrong") + createAccountViewModel.event(Event.CreateAccount) + val effectTurbine = createAccountViewModel.effect.testIn(backgroundScope) + + createAccountViewModel.event(Event.OnBackClicked) + + assertThat(effectTurbine.awaitItem()).isEqualTo(Effect.NavigateBack) + } + } + + private companion object { + const val EMAIL_ADDRESS = "test@domain.example" + + val INCOMING_SERVER_SETTINGS = ServerSettings( + "imap", + "imap.domain.example", + 993, + ConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + null, + ) + + val OUTGOING_SERVER_SETTINGS = ServerSettings( + "smtp", + "smtp.domain.example", + 465, + ConnectionSecurity.SSL_TLS_REQUIRED, + AuthType.PLAIN, + "username", + "password", + null, + ) + + val AUTHORIZATION_STATE = AuthorizationState("authorization state") + + val SPECIAL_FOLDER_SETTINGS = SpecialFolderSettings( + archiveSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder( + FolderServerId("archive folder"), + "archive folder", + FolderType.ARCHIVE, + ), + ), + draftsSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder( + FolderServerId("drafts folder"), + "drafts folder", + FolderType.DRAFTS, + ), + ), + sentSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder( + FolderServerId("sent folder"), + "sent folder", + FolderType.SENT, + ), + ), + spamSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder( + FolderServerId("spam folder"), + "spam folder", + FolderType.SPAM, + ), + ), + trashSpecialFolderOption = SpecialFolderOption.Special( + remoteFolder = RemoteFolder( + FolderServerId("trash folder"), + "trash folder", + FolderType.TRASH, + ), + ), + ) + + val ACCOUNT_DISPLAY_OPTIONS = AccountDisplayOptions( + accountName = "account name", + displayName = "display name", + emailSignature = null, + ) + + val ACCOUNT_SYNC_OPTIONS = AccountSyncOptions( + checkFrequencyInMinutes = 0, + messageDisplayCount = 50, + showNotification = false, + ) + + val ACCOUNT_STATE = AccountState( + emailAddress = EMAIL_ADDRESS, + incomingServerSettings = INCOMING_SERVER_SETTINGS, + outgoingServerSettings = OUTGOING_SERVER_SETTINGS, + authorizationState = AUTHORIZATION_STATE, + specialFolderSettings = SPECIAL_FOLDER_SETTINGS, + displayOptions = ACCOUNT_DISPLAY_OPTIONS, + syncOptions = ACCOUNT_SYNC_OPTIONS, + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/FakeCreateAccount.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/FakeCreateAccount.kt new file mode 100644 index 0000000..d8c0fef --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/FakeCreateAccount.kt @@ -0,0 +1,19 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult +import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.CreateAccount + +class FakeCreateAccount : CreateAccount { + val recordedInvocations = mutableListOf() + + var result: AccountCreatorResult = AccountCreatorResult.Success("default result") + + override suspend fun execute( + accountState: AccountState, + ): AccountCreatorResult { + recordedInvocations.add(accountState) + + return result + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/FakeCreateAccountViewModel.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/FakeCreateAccountViewModel.kt new file mode 100644 index 0000000..2191d0f --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/createaccount/FakeCreateAccountViewModel.kt @@ -0,0 +1,21 @@ +package app.k9mail.feature.account.setup.ui.createaccount + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State +import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.ViewModel + +class FakeCreateAccountViewModel(initialState: State = State()) : + BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreenKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreenKtTest.kt new file mode 100644 index 0000000..aa514f1 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsScreenKtTest.kt @@ -0,0 +1,44 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.setup.ui.FakeBrandNameProvider +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DisplayOptionsScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeDisplayOptionsViewModel(initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + DisplayOptionsScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + brandNameProvider = FakeBrandNameProvider, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateMapperKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateMapperKtTest.kt new file mode 100644 index 0000000..9941437 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateMapperKtTest.kt @@ -0,0 +1,39 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions +import app.k9mail.feature.account.common.domain.input.StringInputField +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import org.junit.Test + +class DisplayOptionsStateMapperKtTest { + + @Test + fun `should map state to account options`() { + val state = DisplayOptionsContract.State( + accountName = StringInputField("accountName"), + displayName = StringInputField("displayName"), + emailSignature = StringInputField("emailSignature"), + ) + + val result = state.toAccountDisplayOptions() + + assertThat(result).isEqualTo( + AccountDisplayOptions( + accountName = "accountName", + displayName = "displayName", + emailSignature = "emailSignature", + ), + ) + } + + @Test + fun `empty signature should map to null`() { + val state = DisplayOptionsContract.State(emailSignature = StringInputField("")) + + val result = state.toAccountDisplayOptions() + + assertThat(result.emailSignature).isNull() + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateTest.kt new file mode 100644 index 0000000..ed306f2 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsStateTest.kt @@ -0,0 +1,23 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class DisplayOptionsStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + accountName = StringInputField(), + displayName = StringInputField(), + emailSignature = StringInputField(), + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsViewModelTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsViewModelTest.kt new file mode 100644 index 0000000..8417494 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/DisplayOptionsViewModelTest.kt @@ -0,0 +1,127 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.core.ui.compose.testing.mvi.eventStateTest +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.input.StringInputField +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class DisplayOptionsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val accountOwnerNameProvider = FakeAccountOwnerNameProvider() + private val testSubject = DisplayOptionsViewModel( + validator = FakeDisplayOptionsValidator(), + accountStateRepository = InMemoryAccountStateRepository(), + accountOwnerNameProvider = accountOwnerNameProvider, + ) + + @Test + fun `should change state when OnAccountNameChanged event is received`() = runMviTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.OnAccountNameChanged("accountName"), + expectedState = State(accountName = StringInputField(value = "accountName")), + ) + } + + @Test + fun `should change state when OnDisplayNameChanged event is received`() = runMviTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.OnDisplayNameChanged("displayName"), + expectedState = State(displayName = StringInputField(value = "displayName")), + ) + } + + @Test + fun `should change state when OnEmailSignatureChanged event is received`() = runMviTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.OnEmailSignatureChanged("emailSignature"), + expectedState = State(emailSignature = StringInputField(value = "emailSignature")), + ) + } + + @Test + fun `should change state and emit NavigateNext effect when OnNextClicked event received and input valid`() = + runMviTest { + val viewModel = testSubject + val turbines = turbinesWithInitialStateCheck(viewModel, State()) + + viewModel.event(Event.OnNextClicked) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + State( + accountName = StringInputField(value = "", isValid = true), + displayName = StringInputField(value = "", isValid = true), + emailSignature = StringInputField(value = "", isValid = true), + ), + ) + + assertThat(turbines.effectTurbine.awaitItem()).isEqualTo(Effect.NavigateNext) + } + + @Test + fun `should change state and not emit effect when OnNextClicked event received and input invalid`() = + runMviTest { + val viewModel = DisplayOptionsViewModel( + validator = FakeDisplayOptionsValidator( + accountNameAnswer = ValidationResult.Failure(TestError), + ), + accountStateRepository = InMemoryAccountStateRepository(), + accountOwnerNameProvider = accountOwnerNameProvider, + ) + val turbines = turbinesWithInitialStateCheck(viewModel, State()) + + viewModel.event(Event.OnNextClicked) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + State( + accountName = StringInputField(value = "", error = TestError, isValid = false), + displayName = StringInputField(value = "", isValid = true), + emailSignature = StringInputField(value = "", isValid = true), + ), + ) + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runMviTest { + val viewModel = testSubject + val turbines = turbinesWithInitialStateCheck(viewModel, State()) + + viewModel.event(Event.OnBackClicked) + + assertThat(turbines.effectTurbine.awaitItem()).isEqualTo(Effect.NavigateBack) + } + + @Test + fun `should set owner name when LoadAccountState event received`() = runMviTest { + accountOwnerNameProvider.ownerName = "Alice Example" + val viewModel = testSubject + val turbines = turbinesWithInitialStateCheck(viewModel, State()) + + viewModel.event(Event.LoadAccountState) + + assertThat(turbines.stateTurbine.awaitItem()).isEqualTo( + State(displayName = StringInputField("Alice Example")), + ) + } + + private object TestError : ValidationError +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeAccountOwnerNameProvider.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeAccountOwnerNameProvider.kt new file mode 100644 index 0000000..ec1dcb5 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeAccountOwnerNameProvider.kt @@ -0,0 +1,11 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.feature.account.setup.AccountSetupExternalContract + +class FakeAccountOwnerNameProvider : AccountSetupExternalContract.AccountOwnerNameProvider { + var ownerName: String? = null + + override suspend fun getOwnerName(): String? { + return ownerName + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeDisplayOptionsValidator.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeDisplayOptionsValidator.kt new file mode 100644 index 0000000..5aefbf0 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeDisplayOptionsValidator.kt @@ -0,0 +1,14 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Validator +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult + +internal class FakeDisplayOptionsValidator( + private val accountNameAnswer: ValidationResult = ValidationResult.Success, + private val displayNameAnswer: ValidationResult = ValidationResult.Success, + private val emailSignatureAnswer: ValidationResult = ValidationResult.Success, +) : Validator { + override fun validateAccountName(accountName: String): ValidationResult = accountNameAnswer + override fun validateDisplayName(displayName: String): ValidationResult = displayNameAnswer + override fun validateEmailSignature(emailSignature: String): ValidationResult = emailSignatureAnswer +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeDisplayOptionsViewModel.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeDisplayOptionsViewModel.kt new file mode 100644 index 0000000..52d65d6 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/display/FakeDisplayOptionsViewModel.kt @@ -0,0 +1,22 @@ +package app.k9mail.feature.account.setup.ui.options.display + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State +import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.ViewModel + +class FakeDisplayOptionsViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/FakeSyncOptionsViewModel.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/FakeSyncOptionsViewModel.kt new file mode 100644 index 0000000..4ad5395 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/FakeSyncOptionsViewModel.kt @@ -0,0 +1,22 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.State +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.ViewModel + +class FakeSyncOptionsViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreenKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreenKtTest.kt new file mode 100644 index 0000000..ec27714 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsScreenKtTest.kt @@ -0,0 +1,44 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.setup.ui.FakeBrandNameProvider +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SyncOptionsScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeSyncOptionsViewModel(initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + SyncOptionsScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + brandNameProvider = FakeBrandNameProvider, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateMapperKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateMapperKtTest.kt new file mode 100644 index 0000000..eff8cd9 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateMapperKtTest.kt @@ -0,0 +1,30 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency +import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class SyncOptionsStateMapperKtTest { + + @Test + fun `should map state to account options`() { + val state = SyncOptionsContract.State( + checkFrequency = EmailCheckFrequency.EVERY_2_HOURS, + messageDisplayCount = EmailDisplayCount.MESSAGES_100, + showNotification = true, + ) + + val result = state.toAccountSyncOptions() + + assertThat(result).isEqualTo( + AccountSyncOptions( + checkFrequencyInMinutes = 120, + messageDisplayCount = 100, + showNotification = true, + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateTest.kt new file mode 100644 index 0000000..3950767 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsStateTest.kt @@ -0,0 +1,24 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency +import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class SyncOptionsStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + checkFrequency = EmailCheckFrequency.DEFAULT, + messageDisplayCount = EmailDisplayCount.DEFAULT, + showNotification = true, + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsViewModelTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsViewModelTest.kt new file mode 100644 index 0000000..3fa3e4b --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/options/sync/SyncOptionsViewModelTest.kt @@ -0,0 +1,104 @@ +package app.k9mail.feature.account.setup.ui.options.sync + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndEffectTurbineConsumed +import app.k9mail.core.ui.compose.testing.mvi.eventStateTest +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions +import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency +import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Effect +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.Event +import app.k9mail.feature.account.setup.ui.options.sync.SyncOptionsContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class SyncOptionsViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val testSubject = SyncOptionsViewModel( + accountStateRepository = InMemoryAccountStateRepository(), + ) + + @Test + fun `should change state when OnCheckFrequencyChanged event is received`() = runMviTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.OnCheckFrequencyChanged(EmailCheckFrequency.EVERY_12_HOURS), + expectedState = State(checkFrequency = EmailCheckFrequency.EVERY_12_HOURS), + ) + } + + @Test + fun `should change state when OnMessageDisplayCountChanged event is received`() = runMviTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.OnMessageDisplayCountChanged(EmailDisplayCount.MESSAGES_1000), + expectedState = State(messageDisplayCount = EmailDisplayCount.MESSAGES_1000), + ) + } + + @Test + fun `should change state when OnShowNotificationChanged event is received`() = runMviTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.OnShowNotificationChanged(false), + expectedState = State(showNotification = false), + ) + } + + @Test + fun `should store state and emit NavigateNext effect when OnNextClicked event received and input valid`() = + runMviTest { + val accountStateRepository = InMemoryAccountStateRepository() + val initialState = State( + checkFrequency = EmailCheckFrequency.EVERY_HOUR, + messageDisplayCount = EmailDisplayCount.MESSAGES_1000, + showNotification = true, + ) + val viewModel = SyncOptionsViewModel( + accountStateRepository = accountStateRepository, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck( + viewModel = viewModel, + initialState = initialState, + ) + + viewModel.event(Event.OnNextClicked) + + turbines.assertThatAndEffectTurbineConsumed { + isEqualTo(Effect.NavigateNext) + } + + assertThat(accountStateRepository.getState()).isEqualTo( + AccountState( + syncOptions = AccountSyncOptions( + checkFrequencyInMinutes = 60, + messageDisplayCount = 1000, + showNotification = true, + ), + ), + ) + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runMviTest { + val viewModel = testSubject + val turbines = turbinesWithInitialStateCheck(viewModel, State()) + + viewModel.event(Event.OnBackClicked) + + assertThat(turbines.awaitEffectItem()).isEqualTo(Effect.NavigateBack) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/FakeSpecialFoldersFormUiModel.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/FakeSpecialFoldersFormUiModel.kt new file mode 100644 index 0000000..6059afc --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/FakeSpecialFoldersFormUiModel.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormEvent +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormState +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormUiModel + +class FakeSpecialFoldersFormUiModel : FormUiModel { + + val events = mutableListOf() + + override fun event( + event: FormEvent, + formState: FormState, + ): FormState { + events.add(event) + return formState + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormStateMapperKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormStateMapperKtTest.kt new file mode 100644 index 0000000..e915f02 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormStateMapperKtTest.kt @@ -0,0 +1,115 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class SpecialFoldersFormStateMapperKtTest { + + @Test + fun `should map folders to form state and assign selected folders`() = runTest { + val specialOptions = SpecialFolderOptions( + archiveSpecialFolderOptions = createFolderList( + SpecialFolderOption.Special( + remoteFolder = createRemoteFolder("archive1"), + isAutomatic = true, + ), + ), + draftsSpecialFolderOptions = createFolderList( + SpecialFolderOption.Special( + remoteFolder = createRemoteFolder("drafts1"), + isAutomatic = true, + ), + ), + sentSpecialFolderOptions = createFolderList( + SpecialFolderOption.Special( + remoteFolder = createRemoteFolder("sent1"), + isAutomatic = true, + ), + ), + spamSpecialFolderOptions = createFolderList( + SpecialFolderOption.Special( + remoteFolder = createRemoteFolder("spam1"), + isAutomatic = true, + ), + ), + trashSpecialFolderOptions = createFolderList( + SpecialFolderOption.Special( + remoteFolder = createRemoteFolder("trash1"), + isAutomatic = true, + ), + ), + ) + + val result = specialOptions.toFormState() + + assertThat(result).isEqualTo( + SpecialFoldersContract.FormState( + archiveSpecialFolderOptions = specialOptions.archiveSpecialFolderOptions, + draftsSpecialFolderOptions = specialOptions.draftsSpecialFolderOptions, + sentSpecialFolderOptions = specialOptions.sentSpecialFolderOptions, + spamSpecialFolderOptions = specialOptions.spamSpecialFolderOptions, + trashSpecialFolderOptions = specialOptions.trashSpecialFolderOptions, + + selectedArchiveSpecialFolderOption = specialOptions.archiveSpecialFolderOptions.first(), + selectedDraftsSpecialFolderOption = specialOptions.draftsSpecialFolderOptions.first(), + selectedSentSpecialFolderOption = specialOptions.sentSpecialFolderOptions.first(), + selectedSpamSpecialFolderOption = specialOptions.spamSpecialFolderOptions.first(), + selectedTrashSpecialFolderOption = specialOptions.trashSpecialFolderOptions.first(), + ), + ) + } + + @Test + fun `should map folders to form state and not assign selected folders when there is none automatic`() { + val specialFolderOptions = SpecialFolderOptions( + archiveSpecialFolderOptions = createFolderList(SpecialFolderOption.None(isAutomatic = true)), + draftsSpecialFolderOptions = createFolderList(SpecialFolderOption.None(isAutomatic = true)), + sentSpecialFolderOptions = createFolderList(SpecialFolderOption.None(isAutomatic = true)), + spamSpecialFolderOptions = createFolderList(SpecialFolderOption.None(isAutomatic = true)), + trashSpecialFolderOptions = createFolderList(SpecialFolderOption.None(isAutomatic = true)), + ) + + val result = specialFolderOptions.toFormState() + + assertThat(result).isEqualTo( + SpecialFoldersContract.FormState( + archiveSpecialFolderOptions = specialFolderOptions.archiveSpecialFolderOptions, + draftsSpecialFolderOptions = specialFolderOptions.draftsSpecialFolderOptions, + sentSpecialFolderOptions = specialFolderOptions.sentSpecialFolderOptions, + spamSpecialFolderOptions = specialFolderOptions.spamSpecialFolderOptions, + trashSpecialFolderOptions = specialFolderOptions.trashSpecialFolderOptions, + + selectedArchiveSpecialFolderOption = specialFolderOptions.archiveSpecialFolderOptions.first(), + selectedDraftsSpecialFolderOption = specialFolderOptions.draftsSpecialFolderOptions.first(), + selectedSentSpecialFolderOption = specialFolderOptions.sentSpecialFolderOptions.first(), + selectedSpamSpecialFolderOption = specialFolderOptions.spamSpecialFolderOptions.first(), + selectedTrashSpecialFolderOption = specialFolderOptions.trashSpecialFolderOptions.first(), + ), + ) + } + + private companion object { + fun createRemoteFolder(name: String): RemoteFolder { + return RemoteFolder( + serverId = FolderServerId(name), + displayName = name, + type = FolderType.REGULAR, + ) + } + + fun createFolderList(automaticSpecialFolderOption: SpecialFolderOption): List { + return listOf( + automaticSpecialFolderOption, + SpecialFolderOption.None(), + SpecialFolderOption.Regular(createRemoteFolder("regular1")), + ) + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormUiModelTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormUiModelTest.kt new file mode 100644 index 0000000..b758e48 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersFormUiModelTest.kt @@ -0,0 +1,81 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormEvent +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormState +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import org.junit.Test + +class SpecialFoldersFormUiModelTest { + + private val testSubject = SpecialFoldersFormUiModel() + + @Test + fun `should change archive folder on ArchiveFolderChanged`() { + val folder = createFolder("archiveFolder") + + val result = testSubject.event(FormEvent.ArchiveFolderChanged(folder), FORM_STATE) + + assertThat(result).isEqualTo(FORM_STATE.copy(selectedArchiveSpecialFolderOption = folder)) + } + + @Test + fun `should change drafts folder on DraftsFolderChanged`() { + val folder = createFolder("draftsFolder") + + val result = testSubject.event(FormEvent.DraftsFolderChanged(folder), FORM_STATE) + + assertThat(result).isEqualTo(FORM_STATE.copy(selectedDraftsSpecialFolderOption = folder)) + } + + @Test + fun `should change sent folder on SentFolderChanged`() { + val folder = createFolder("sentFolder") + + val result = testSubject.event(FormEvent.SentFolderChanged(folder), FORM_STATE) + + assertThat(result).isEqualTo(FORM_STATE.copy(selectedSentSpecialFolderOption = folder)) + } + + @Test + fun `should change spam folder on SpamFolderChanged`() { + val folder = createFolder("spamFolder") + + val result = testSubject.event(FormEvent.SpamFolderChanged(folder), FORM_STATE) + + assertThat(result).isEqualTo(FORM_STATE.copy(selectedSpamSpecialFolderOption = createFolder("spamFolder"))) + } + + @Test + fun `should change trash folder on TrashFolderChanged`() { + val folder = createFolder("trashFolder") + + val result = testSubject.event(FormEvent.TrashFolderChanged(folder), FORM_STATE) + + assertThat(result).isEqualTo(FORM_STATE.copy(selectedTrashSpecialFolderOption = folder)) + } + + private companion object { + val FORM_STATE = FormState( + archiveSpecialFolderOptions = listOf(createFolder("archiveFolder")), + draftsSpecialFolderOptions = listOf(createFolder("draftsFolder")), + sentSpecialFolderOptions = listOf(createFolder("sentFolder")), + spamSpecialFolderOptions = listOf(createFolder("spamFolder")), + trashSpecialFolderOptions = listOf(createFolder("trashFolder")), + ) + + fun createFolder(folderName: String): SpecialFolderOption { + return SpecialFolderOption.Regular( + RemoteFolder( + serverId = FolderServerId(folderName), + displayName = folderName, + type = FolderType.REGULAR, + ), + ) + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreenKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreenKtTest.kt new file mode 100644 index 0000000..d5d1096 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersScreenKtTest.kt @@ -0,0 +1,45 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.account.setup.ui.FakeBrandNameProvider +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Effect +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.State +import app.k9mail.feature.account.setup.ui.specialfolders.fake.FakeSpecialFoldersViewModel +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SpecialFoldersScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeSpecialFoldersViewModel(initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContentWithTheme { + SpecialFoldersScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + brandNameProvider = FakeBrandNameProvider, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext(true)) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersStateTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersStateTest.kt new file mode 100644 index 0000000..968a109 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersStateTest.kt @@ -0,0 +1,47 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormState +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class SpecialFoldersStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + formState = FormState(), + isManualSetup = false, + isSuccess = false, + error = null, + isLoading = true, + ), + ) + } + + @Test + fun `should set default form values`() { + val formState = FormState() + + assertThat(formState).isEqualTo( + FormState( + archiveSpecialFolderOptions = emptyList(), + draftsSpecialFolderOptions = emptyList(), + sentSpecialFolderOptions = emptyList(), + spamSpecialFolderOptions = emptyList(), + trashSpecialFolderOptions = emptyList(), + + selectedArchiveSpecialFolderOption = SpecialFolderOption.None(true), + selectedDraftsSpecialFolderOption = SpecialFolderOption.None(true), + selectedSentSpecialFolderOption = SpecialFolderOption.None(true), + selectedSpamSpecialFolderOption = SpecialFolderOption.None(true), + selectedTrashSpecialFolderOption = SpecialFolderOption.None(true), + ), + ) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersViewModelTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersViewModelTest.kt new file mode 100644 index 0000000..ac578b8 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/specialfolders/SpecialFoldersViewModelTest.kt @@ -0,0 +1,351 @@ +package app.k9mail.feature.account.setup.ui.specialfolders + +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndEffectTurbineConsumed +import app.k9mail.core.ui.compose.testing.mvi.assertThatAndStateTurbineConsumed +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository +import app.k9mail.feature.account.common.domain.AccountDomainContract +import app.k9mail.feature.account.common.domain.entity.AccountState +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption +import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions +import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Effect +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.Event +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormEvent +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.FormState +import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersContract.State +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.folders.FolderFetcherException +import com.fsck.k9.mail.folders.FolderServerId +import com.fsck.k9.mail.folders.RemoteFolder +import kotlinx.coroutines.delay +import net.thunderbird.core.common.domain.usecase.validation.ValidationError +import net.thunderbird.core.common.domain.usecase.validation.ValidationResult +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule +import org.junit.Test + +class SpecialFoldersViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should load folders, validate and save successfully when LoadSpecialFolders event received and setup valid`() = + runMviTest { + val accountStateRepository = InMemoryAccountStateRepository() + val initialState = State( + isLoading = true, + ) + val testSubject = createTestSubject( + formUiModel = FakeSpecialFoldersFormUiModel(), + validateSpecialFolderOptions = { ValidationResult.Success }, + accountStateRepository = accountStateRepository, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.LoadSpecialFolderOptions) + + val validatedState = initialState.copy( + isLoading = false, + isSuccess = true, + formState = FormState( + archiveSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.archiveSpecialFolderOptions, + draftsSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.draftsSpecialFolderOptions, + sentSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.sentSpecialFolderOptions, + spamSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.spamSpecialFolderOptions, + trashSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.trashSpecialFolderOptions, + + selectedArchiveSpecialFolderOption = SPECIAL_FOLDER_ARCHIVE.copy(isAutomatic = true), + selectedDraftsSpecialFolderOption = SPECIAL_FOLDER_DRAFTS.copy(isAutomatic = true), + selectedSentSpecialFolderOption = SPECIAL_FOLDER_SENT.copy(isAutomatic = true), + selectedSpamSpecialFolderOption = SPECIAL_FOLDER_SPAM.copy(isAutomatic = true), + selectedTrashSpecialFolderOption = SPECIAL_FOLDER_TRASH.copy(isAutomatic = true), + ), + ) + + turbines.assertThatAndStateTurbineConsumed { + isEqualTo(validatedState) + } + + turbines.assertThatAndEffectTurbineConsumed { + isEqualTo(Effect.NavigateNext(false)) + } + + assertThat(accountStateRepository.getState()).isEqualTo( + AccountState( + specialFolderSettings = SpecialFolderSettings( + archiveSpecialFolderOption = SPECIAL_FOLDER_ARCHIVE.copy(isAutomatic = true), + draftsSpecialFolderOption = SPECIAL_FOLDER_DRAFTS.copy(isAutomatic = true), + sentSpecialFolderOption = SPECIAL_FOLDER_SENT.copy(isAutomatic = true), + spamSpecialFolderOption = SPECIAL_FOLDER_SPAM.copy(isAutomatic = true), + trashSpecialFolderOption = SPECIAL_FOLDER_TRASH.copy(isAutomatic = true), + ), + ), + ) + } + + @Test + fun `should load folders and validate unsuccessful when LoadSpecialFolders event received`() = runMviTest { + val accountStateRepository = InMemoryAccountStateRepository() + val initialState = State( + isLoading = true, + ) + val testSubject = createTestSubject( + formUiModel = FakeSpecialFoldersFormUiModel(), + validateSpecialFolderOptions = { ValidationResult.Failure(TestValidationError) }, + accountStateRepository = accountStateRepository, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.LoadSpecialFolderOptions) + + val unvalidatedState = initialState.copy( + isManualSetup = true, + isLoading = false, + isSuccess = false, + formState = FormState( + archiveSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.archiveSpecialFolderOptions, + draftsSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.draftsSpecialFolderOptions, + sentSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.sentSpecialFolderOptions, + spamSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.spamSpecialFolderOptions, + trashSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.trashSpecialFolderOptions, + + selectedArchiveSpecialFolderOption = SPECIAL_FOLDER_ARCHIVE.copy(isAutomatic = true), + selectedDraftsSpecialFolderOption = SPECIAL_FOLDER_DRAFTS.copy(isAutomatic = true), + selectedSentSpecialFolderOption = SPECIAL_FOLDER_SENT.copy(isAutomatic = true), + selectedSpamSpecialFolderOption = SPECIAL_FOLDER_SPAM.copy(isAutomatic = true), + selectedTrashSpecialFolderOption = SPECIAL_FOLDER_TRASH.copy(isAutomatic = true), + ), + ) + + turbines.assertThatAndStateTurbineConsumed { + isEqualTo(unvalidatedState) + } + + turbines.effectTurbine.ensureAllEventsConsumed() + + assertThat(accountStateRepository.getState()).isEqualTo(AccountState()) + } + + @Test + fun `should change to error state when LoadSpecialFolders fails with loading folder failure`() = runMviTest { + val initialState = State( + isLoading = true, + ) + val testSubject = createTestSubject( + formUiModel = FakeSpecialFoldersFormUiModel(), + getSpecialFolderOptions = { + throw FolderFetcherException(IllegalStateException(), messageFromServer = "Failed to load folders") + }, + initialState = initialState, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.LoadSpecialFolderOptions) + + turbines.assertThatAndStateTurbineConsumed { + isEqualTo( + State( + isLoading = false, + isSuccess = false, + error = SpecialFoldersContract.Failure.LoadFoldersFailed( + "Failed to load folders", + ), + ), + ) + } + } + + @Test + fun `should delegate form events to form view model`() = runMviTest { + val formUiModel = FakeSpecialFoldersFormUiModel() + val testSubject = createTestSubject( + formUiModel = formUiModel, + ) + + testSubject.event(FormEvent.ArchiveFolderChanged(SPECIAL_FOLDER_ARCHIVE)) + testSubject.event(FormEvent.DraftsFolderChanged(SPECIAL_FOLDER_DRAFTS)) + testSubject.event(FormEvent.SentFolderChanged(SPECIAL_FOLDER_SENT)) + testSubject.event(FormEvent.SpamFolderChanged(SPECIAL_FOLDER_SPAM)) + testSubject.event(FormEvent.TrashFolderChanged(SPECIAL_FOLDER_TRASH)) + + assertThat(formUiModel.events).containsExactly( + FormEvent.ArchiveFolderChanged(SPECIAL_FOLDER_ARCHIVE), + FormEvent.DraftsFolderChanged(SPECIAL_FOLDER_DRAFTS), + FormEvent.SentFolderChanged(SPECIAL_FOLDER_SENT), + FormEvent.SpamFolderChanged(SPECIAL_FOLDER_SPAM), + FormEvent.TrashFolderChanged(SPECIAL_FOLDER_TRASH), + ) + } + + @Test + fun `should save form data and emit NavigateNext effect when OnNextClicked event received`() = runMviTest { + val initialState = State(isManualSetup = true) + val accountStateRepository = InMemoryAccountStateRepository() + val testSubject = createTestSubject( + initialState = initialState, + accountStateRepository = accountStateRepository, + ) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnNextClicked) + + assertThat(turbines.awaitStateItem()).isEqualTo(initialState.copy(isLoading = false)) + + turbines.assertThatAndEffectTurbineConsumed { + isEqualTo(Effect.NavigateNext(true)) + } + + assertThat(accountStateRepository.getState()).isEqualTo( + AccountState( + specialFolderSettings = SpecialFolderSettings( + archiveSpecialFolderOption = SpecialFolderOption.None(isAutomatic = true), + draftsSpecialFolderOption = SpecialFolderOption.None(isAutomatic = true), + sentSpecialFolderOption = SpecialFolderOption.None(isAutomatic = true), + spamSpecialFolderOption = SpecialFolderOption.None(isAutomatic = true), + trashSpecialFolderOption = SpecialFolderOption.None(isAutomatic = true), + ), + ), + ) + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runMviTest { + val testSubject = createTestSubject() + val turbines = turbinesWithInitialStateCheck(testSubject, State()) + + testSubject.event(Event.OnBackClicked) + + turbines.assertThatAndEffectTurbineConsumed { + isEqualTo(Effect.NavigateBack) + } + } + + @Test + fun `should show form when OnRetryClicked event received`() = runMviTest { + val initialState = State(error = SpecialFoldersContract.Failure.LoadFoldersFailed("irrelevant")) + val testSubject = createTestSubject(initialState = initialState) + val turbines = turbinesWithInitialStateCheck(testSubject, initialState) + + testSubject.event(Event.OnRetryClicked) + + assertThat(turbines.awaitStateItem()).isEqualTo( + initialState.copy( + isLoading = true, + error = null, + ), + ) + + // Turbine misses the intermediate state because we're using UnconfinedTestDispatcher and StateFlow. + // Here we need to make sure the coroutine used to load the special folder options has completed. + mainDispatcherRule.testDispatcher.scheduler.advanceUntilIdle() + + assertThat(turbines.awaitStateItem()).isEqualTo( + State( + isLoading = false, + isSuccess = true, + formState = FormState( + archiveSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.archiveSpecialFolderOptions, + draftsSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.draftsSpecialFolderOptions, + sentSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.sentSpecialFolderOptions, + spamSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.spamSpecialFolderOptions, + trashSpecialFolderOptions = SPECIAL_FOLDER_OPTIONS.trashSpecialFolderOptions, + + selectedArchiveSpecialFolderOption = SPECIAL_FOLDER_ARCHIVE.copy(isAutomatic = true), + selectedDraftsSpecialFolderOption = SPECIAL_FOLDER_DRAFTS.copy(isAutomatic = true), + selectedSentSpecialFolderOption = SPECIAL_FOLDER_SENT.copy(isAutomatic = true), + selectedSpamSpecialFolderOption = SPECIAL_FOLDER_SPAM.copy(isAutomatic = true), + selectedTrashSpecialFolderOption = SPECIAL_FOLDER_TRASH.copy(isAutomatic = true), + ), + ), + ) + + turbines.assertThatAndEffectTurbineConsumed { + isEqualTo(Effect.NavigateNext(false)) + } + } + + private object TestValidationError : ValidationError + + private companion object { + fun createTestSubject( + formUiModel: SpecialFoldersContract.FormUiModel = FakeSpecialFoldersFormUiModel(), + getSpecialFolderOptions: () -> SpecialFolderOptions = { SPECIAL_FOLDER_OPTIONS }, + validateSpecialFolderOptions: (SpecialFolderOptions) -> ValidationResult = { ValidationResult.Success }, + accountStateRepository: AccountDomainContract.AccountStateRepository = InMemoryAccountStateRepository(), + initialState: State = State(), + ) = SpecialFoldersViewModel( + formUiModel = formUiModel, + getSpecialFolderOptions = { + delay(50) + getSpecialFolderOptions() + }, + validateSpecialFolderOptions = validateSpecialFolderOptions, + accountStateRepository = accountStateRepository, + initialState = initialState, + ) + + val REMOTE_FOLDER = RemoteFolder(FolderServerId("archive"), "archive", FolderType.ARCHIVE) + + val SPECIAL_FOLDER_ARCHIVE = SpecialFolderOption.Special( + isAutomatic = false, + remoteFolder = REMOTE_FOLDER.copy(displayName = "Archive"), + ) + val SPECIAL_FOLDER_DRAFTS = SpecialFolderOption.Special( + isAutomatic = false, + remoteFolder = REMOTE_FOLDER.copy(displayName = "Drafts"), + ) + val SPECIAL_FOLDER_SENT = SpecialFolderOption.Special( + isAutomatic = false, + remoteFolder = REMOTE_FOLDER.copy(displayName = "Sent"), + ) + val SPECIAL_FOLDER_SPAM = SpecialFolderOption.Special( + isAutomatic = false, + remoteFolder = REMOTE_FOLDER.copy(displayName = "Spam"), + ) + val SPECIAL_FOLDER_TRASH = SpecialFolderOption.Special( + isAutomatic = false, + remoteFolder = REMOTE_FOLDER.copy(displayName = "Trash"), + ) + + val SPECIAL_FOLDER_OPTIONS = SpecialFolderOptions( + archiveSpecialFolderOptions = listOf( + SPECIAL_FOLDER_ARCHIVE.copy(isAutomatic = true), + SpecialFolderOption.None(), + SPECIAL_FOLDER_ARCHIVE, + SpecialFolderOption.Regular(REMOTE_FOLDER), + ), + draftsSpecialFolderOptions = listOf( + SPECIAL_FOLDER_DRAFTS.copy(isAutomatic = true), + SpecialFolderOption.None(), + SPECIAL_FOLDER_DRAFTS, + SpecialFolderOption.Regular(REMOTE_FOLDER), + ), + sentSpecialFolderOptions = listOf( + SPECIAL_FOLDER_SENT.copy(isAutomatic = true), + SpecialFolderOption.None(), + SPECIAL_FOLDER_SENT, + SpecialFolderOption.Regular(REMOTE_FOLDER), + ), + spamSpecialFolderOptions = listOf( + SPECIAL_FOLDER_SPAM.copy(isAutomatic = true), + SpecialFolderOption.None(), + SPECIAL_FOLDER_SPAM, + SpecialFolderOption.Regular(REMOTE_FOLDER), + ), + trashSpecialFolderOptions = listOf( + SPECIAL_FOLDER_TRASH.copy(isAutomatic = true), + SpecialFolderOption.None(), + SPECIAL_FOLDER_TRASH, + SpecialFolderOption.Regular(REMOTE_FOLDER), + ), + ) + } +} diff --git a/feature/account/storage/api/build.gradle.kts b/feature/account/storage/api/build.gradle.kts new file mode 100644 index 0000000..08cdd6c --- /dev/null +++ b/feature/account/storage/api/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id(ThunderbirdPlugins.Library.kmp) +} + +android { + namespace = "net.thunderbird.feature.account.storage" +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(projects.feature.account.api) + } + } +} diff --git a/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/mapper/AccountAvatarDataMapper.kt b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/mapper/AccountAvatarDataMapper.kt new file mode 100644 index 0000000..3ff21b7 --- /dev/null +++ b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/mapper/AccountAvatarDataMapper.kt @@ -0,0 +1,7 @@ +package net.thunderbird.feature.account.storage.mapper + +import net.thunderbird.core.architecture.data.DataMapper +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.storage.profile.AvatarDto + +interface AccountAvatarDataMapper : DataMapper diff --git a/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/mapper/AccountProfileDataMapper.kt b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/mapper/AccountProfileDataMapper.kt new file mode 100644 index 0000000..a004052 --- /dev/null +++ b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/mapper/AccountProfileDataMapper.kt @@ -0,0 +1,7 @@ +package net.thunderbird.feature.account.storage.mapper + +import net.thunderbird.core.architecture.data.DataMapper +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.storage.profile.ProfileDto + +interface AccountProfileDataMapper : DataMapper diff --git a/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/AvatarDto.kt b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/AvatarDto.kt new file mode 100644 index 0000000..6371450 --- /dev/null +++ b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/AvatarDto.kt @@ -0,0 +1,8 @@ +package net.thunderbird.feature.account.storage.profile + +data class AvatarDto( + val avatarType: AvatarTypeDto, + val avatarMonogram: String?, + val avatarImageUri: String?, + val avatarIconName: String?, +) diff --git a/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/AvatarTypeDto.kt b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/AvatarTypeDto.kt new file mode 100644 index 0000000..d6f1f0f --- /dev/null +++ b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/AvatarTypeDto.kt @@ -0,0 +1,7 @@ +package net.thunderbird.feature.account.storage.profile + +enum class AvatarTypeDto { + MONOGRAM, + IMAGE, + ICON, +} diff --git a/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/ProfileDto.kt b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/ProfileDto.kt new file mode 100644 index 0000000..4dc7a98 --- /dev/null +++ b/feature/account/storage/api/src/commonMain/kotlin/net/thunderbird/feature/account/storage/profile/ProfileDto.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.account.storage.profile + +import net.thunderbird.feature.account.Account +import net.thunderbird.feature.account.AccountId + +data class ProfileDto( + override val id: AccountId, + val name: String, + val color: Int, + val avatar: AvatarDto, +) : Account diff --git a/feature/account/storage/legacy/build.gradle.kts b/feature/account/storage/legacy/build.gradle.kts new file mode 100644 index 0000000..59ce07f --- /dev/null +++ b/feature/account/storage/legacy/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "net.thunderbird.feature.account.storage.legacy" +} + +dependencies { + api(projects.feature.account.storage.api) + + implementation(projects.feature.notification.api) + implementation(projects.feature.mail.account.api) + implementation(projects.feature.mail.folder.api) + + implementation(projects.core.logging.api) + implementation(projects.core.preference.api) + + implementation(projects.mail.common) + + implementation(projects.core.android.account) + + implementation(libs.moshi) + + testImplementation(projects.feature.account.fake) + testImplementation(projects.mail.protocols.imap) +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/AccountKeyGenerator.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/AccountKeyGenerator.kt new file mode 100644 index 0000000..59628cd --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/AccountKeyGenerator.kt @@ -0,0 +1,22 @@ +package net.thunderbird.feature.account.storage.legacy + +import net.thunderbird.feature.account.AccountId + +/** + * Generates keys for account storage. + */ +class AccountKeyGenerator( + private val id: AccountId, +) { + + /** + * Creates a key by combining account ID with the specified key. + * + * @param key The key to combine with the account ID. + * @throws IllegalArgumentException if the key is empty. + */ + fun create(key: String): String { + require(key.isNotEmpty()) { "Key must not be empty" } + return "${id.asRaw()}.$key" + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/AccountStorageLegacyModule.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/AccountStorageLegacyModule.kt new file mode 100644 index 0000000..5a1ed0b --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/AccountStorageLegacyModule.kt @@ -0,0 +1,45 @@ +package net.thunderbird.feature.account.storage.legacy + +import net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountAvatarDataMapper +import net.thunderbird.feature.account.storage.legacy.mapper.DefaultAccountProfileDataMapper +import net.thunderbird.feature.account.storage.legacy.mapper.DefaultLegacyAccountWrapperDataMapper +import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer +import net.thunderbird.feature.account.storage.mapper.AccountAvatarDataMapper +import net.thunderbird.feature.account.storage.mapper.AccountProfileDataMapper +import org.koin.dsl.module + +val featureAccountStorageLegacyModule = module { + factory { + DefaultLegacyAccountWrapperDataMapper() + } + + factory { + DefaultAccountAvatarDataMapper() + } + + factory { + DefaultAccountProfileDataMapper( + avatarMapper = get(), + ) + } + + factory { ServerSettingsDtoSerializer() } + + factory { + LegacyAvatarDtoStorageHandler() + } + + factory { + LegacyProfileDtoStorageHandler( + avatarDtoStorageHandler = get(), + ) + } + + single { + LegacyAccountStorageHandler( + serverSettingsDtoSerializer = get(), + profileDtoStorageHandler = get(), + logger = get(), + ) + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt new file mode 100644 index 0000000..2f69512 --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt @@ -0,0 +1,611 @@ +package net.thunderbird.feature.account.storage.legacy + +import net.thunderbird.core.android.account.AccountDefaultsProvider +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.FolderMode +import net.thunderbird.core.android.account.Identity +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.android.account.MessageFormat +import net.thunderbird.core.android.account.QuoteStyle +import net.thunderbird.core.android.account.ShowPictures +import net.thunderbird.core.android.account.SortType +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor +import net.thunderbird.core.preference.storage.getEnumOrDefault +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer +import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection +import net.thunderbird.feature.notification.NotificationLight +import net.thunderbird.feature.notification.NotificationSettings +import net.thunderbird.feature.notification.NotificationVibration +import net.thunderbird.feature.notification.VibratePattern + +class LegacyAccountStorageHandler( + private val serverSettingsDtoSerializer: ServerSettingsDtoSerializer, + private val profileDtoStorageHandler: ProfileDtoStorageHandler, + private val logger: Logger, +) : AccountDtoStorageHandler { + + @Suppress("LongMethod", "MagicNumber") + @Synchronized + override fun load(data: LegacyAccount, storage: Storage) { + val keyGen = AccountKeyGenerator(data.id) + + profileDtoStorageHandler.load(data, storage) + + with(data) { + incomingServerSettings = serverSettingsDtoSerializer.deserialize( + storage.getStringOrDefault(keyGen.create(INCOMING_SERVER_SETTINGS_KEY), ""), + ) + outgoingServerSettings = serverSettingsDtoSerializer.deserialize( + storage.getStringOrDefault(keyGen.create(OUTGOING_SERVER_SETTINGS_KEY), ""), + ) + oAuthState = storage.getStringOrNull(keyGen.create("oAuthState")) + alwaysBcc = storage.getStringOrNull(keyGen.create("alwaysBcc")) ?: alwaysBcc + automaticCheckIntervalMinutes = storage.getInt( + keyGen.create("automaticCheckIntervalMinutes"), + AccountDefaultsProvider.Companion.DEFAULT_SYNC_INTERVAL, + ) + idleRefreshMinutes = storage.getInt(keyGen.create("idleRefreshMinutes"), 24) + displayCount = storage.getInt( + keyGen.create("displayCount"), + AccountDefaultsProvider.Companion.DEFAULT_VISIBLE_LIMIT, + ) + if (displayCount < 0) { + displayCount = AccountDefaultsProvider.Companion.DEFAULT_VISIBLE_LIMIT + } + isNotifyNewMail = storage.getBoolean(keyGen.create("notifyNewMail"), false) + folderNotifyNewMailMode = getEnumStringPref( + storage, + keyGen.create("folderNotifyNewMailMode"), + FolderMode.ALL, + ) + isNotifySelfNewMail = storage.getBoolean(keyGen.create("notifySelfNewMail"), true) + isNotifyContactsMailOnly = storage.getBoolean(keyGen.create("notifyContactsMailOnly"), false) + isIgnoreChatMessages = storage.getBoolean(keyGen.create("ignoreChatMessages"), false) + isNotifySync = storage.getBoolean(keyGen.create("notifyMailCheck"), false) + messagesNotificationChannelVersion = storage.getInt(keyGen.create("messagesNotificationChannelVersion"), 0) + deletePolicy = DeletePolicy.Companion.fromInt( + storage.getInt( + keyGen.create("deletePolicy"), + DeletePolicy.NEVER.setting, + ), + ) + legacyInboxFolder = storage.getStringOrNull(keyGen.create("inboxFolderName")) + importedDraftsFolder = storage.getStringOrNull(keyGen.create("draftsFolderName")) + importedSentFolder = storage.getStringOrNull(keyGen.create("sentFolderName")) + importedTrashFolder = storage.getStringOrNull(keyGen.create("trashFolderName")) + importedArchiveFolder = storage.getStringOrNull(keyGen.create("archiveFolderName")) + importedSpamFolder = storage.getStringOrNull(keyGen.create("spamFolderName")) + + inboxFolderId = storage.getStringOrNull(keyGen.create("inboxFolderId"))?.toLongOrNull() + + val draftsFolderId = storage.getStringOrNull(keyGen.create("draftsFolderId"))?.toLongOrNull() + val draftsFolderSelection = getEnumStringPref( + storage, + keyGen.create("draftsFolderSelection"), + SpecialFolderSelection.AUTOMATIC, + ) + setDraftsFolderId(draftsFolderId, draftsFolderSelection) + + val sentFolderId = storage.getStringOrNull(keyGen.create("sentFolderId"))?.toLongOrNull() + val sentFolderSelection = getEnumStringPref( + storage, + keyGen.create("sentFolderSelection"), + SpecialFolderSelection.AUTOMATIC, + ) + setSentFolderId(sentFolderId, sentFolderSelection) + + val trashFolderId = storage.getStringOrNull(keyGen.create("trashFolderId"))?.toLongOrNull() + val trashFolderSelection = getEnumStringPref( + storage, + keyGen.create("trashFolderSelection"), + SpecialFolderSelection.AUTOMATIC, + ) + setTrashFolderId(trashFolderId, trashFolderSelection) + + val archiveFolderId = storage.getStringOrNull(keyGen.create("archiveFolderId"))?.toLongOrNull() + val archiveFolderSelection = getEnumStringPref( + storage, + keyGen.create("archiveFolderSelection"), + SpecialFolderSelection.AUTOMATIC, + ) + setArchiveFolderId(archiveFolderId, archiveFolderSelection) + + val spamFolderId = storage.getStringOrNull(keyGen.create("spamFolderId"))?.toLongOrNull() + val spamFolderSelection = getEnumStringPref( + storage, + keyGen.create("spamFolderSelection"), + SpecialFolderSelection.AUTOMATIC, + ) + setSpamFolderId(spamFolderId, spamFolderSelection) + + autoExpandFolderId = storage.getStringOrNull(keyGen.create("autoExpandFolderId"))?.toLongOrNull() + + expungePolicy = getEnumStringPref(storage, keyGen.create("expungePolicy"), Expunge.EXPUNGE_IMMEDIATELY) + isSyncRemoteDeletions = storage.getBoolean(keyGen.create("syncRemoteDeletions"), true) + + maxPushFolders = storage.getInt(keyGen.create("maxPushFolders"), 10) + isSubscribedFoldersOnly = storage.getBoolean(keyGen.create("subscribedFoldersOnly"), false) + maximumPolledMessageAge = storage.getInt(keyGen.create("maximumPolledMessageAge"), -1) + maximumAutoDownloadMessageSize = storage.getInt( + keyGen.create("maximumAutoDownloadMessageSize"), + AccountDefaultsProvider.Companion.DEFAULT_MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE, + ) + messageFormat = getEnumStringPref( + storage, + keyGen.create("messageFormat"), + AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT, + ) + val messageFormatAuto = storage.getBoolean( + keyGen.create("messageFormatAuto"), + AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_FORMAT_AUTO, + ) + if (messageFormatAuto && messageFormat == MessageFormat.TEXT) { + messageFormat = MessageFormat.AUTO + } + isMessageReadReceipt = storage.getBoolean( + keyGen.create("messageReadReceipt"), + AccountDefaultsProvider.Companion.DEFAULT_MESSAGE_READ_RECEIPT, + ) + quoteStyle = getEnumStringPref( + storage, + keyGen.create("quoteStyle"), + AccountDefaultsProvider.Companion.DEFAULT_QUOTE_STYLE, + ) + quotePrefix = storage.getStringOrDefault( + keyGen.create("quotePrefix"), + AccountDefaultsProvider.Companion.DEFAULT_QUOTE_PREFIX, + ) + isDefaultQuotedTextShown = storage.getBoolean( + keyGen.create("defaultQuotedTextShown"), + AccountDefaultsProvider.Companion.DEFAULT_QUOTED_TEXT_SHOWN, + ) + isReplyAfterQuote = storage.getBoolean( + keyGen.create("replyAfterQuote"), + AccountDefaultsProvider.Companion.DEFAULT_REPLY_AFTER_QUOTE, + ) + isStripSignature = storage.getBoolean( + keyGen.create("stripSignature"), + AccountDefaultsProvider.Companion.DEFAULT_STRIP_SIGNATURE, + ) + useCompression = storage.getBoolean(keyGen.create("useCompression"), true) + isSendClientInfoEnabled = storage.getBoolean(keyGen.create("sendClientInfo"), true) + + importedAutoExpandFolder = storage.getStringOrNull(keyGen.create("autoExpandFolderName")) + + accountNumber = storage.getInt( + keyGen.create("accountNumber"), + AccountDefaultsProvider.Companion.UNASSIGNED_ACCOUNT_NUMBER, + ) + + sortType = getEnumStringPref(storage, keyGen.create("sortTypeEnum"), SortType.SORT_DATE) + + setSortAscending(sortType, storage.getBoolean(keyGen.create("sortAscending"), false)) + + showPictures = + getEnumStringPref(storage, keyGen.create("showPicturesEnum"), ShowPictures.NEVER) + + updateNotificationSettings { + NotificationSettings( + isRingEnabled = storage.getBoolean(keyGen.create("ring"), true), + ringtone = storage.getStringOrDefault( + keyGen.create("ringtone"), + AccountDefaultsProvider.Companion.DEFAULT_RINGTONE_URI, + ), + light = getEnumStringPref( + storage, + keyGen.create("notificationLight"), + NotificationLight.Disabled, + ), + vibration = NotificationVibration( + isEnabled = storage.getBoolean(keyGen.create("vibrate"), false), + pattern = VibratePattern.Companion.deserialize( + storage.getInt( + keyGen.create("vibratePattern"), + 0, + ), + ), + repeatCount = storage.getInt(keyGen.create("vibrateTimes"), 5), + ), + ) + } + + folderDisplayMode = + getEnumStringPref(storage, keyGen.create("folderDisplayMode"), FolderMode.NOT_SECOND_CLASS) + + folderSyncMode = + getEnumStringPref(storage, keyGen.create("folderSyncMode"), FolderMode.FIRST_CLASS) + + folderPushMode = getEnumStringPref(storage, keyGen.create("folderPushMode"), FolderMode.NONE) + + isSignatureBeforeQuotedText = storage.getBoolean(keyGen.create("signatureBeforeQuotedText"), false) + replaceIdentities(loadIdentities(data.id, storage)) + + openPgpProvider = storage.getStringOrDefault(keyGen.create("openPgpProvider"), "") + openPgpKey = storage.getLong(keyGen.create("cryptoKey"), AccountDefaultsProvider.Companion.NO_OPENPGP_KEY) + isOpenPgpHideSignOnly = storage.getBoolean(keyGen.create("openPgpHideSignOnly"), true) + isOpenPgpEncryptSubject = storage.getBoolean(keyGen.create("openPgpEncryptSubject"), true) + isOpenPgpEncryptAllDrafts = storage.getBoolean(keyGen.create("openPgpEncryptAllDrafts"), true) + autocryptPreferEncryptMutual = storage.getBoolean(keyGen.create("autocryptMutualMode"), false) + isRemoteSearchFullText = storage.getBoolean(keyGen.create("remoteSearchFullText"), false) + remoteSearchNumResults = + storage.getInt( + keyGen.create("remoteSearchNumResults"), + AccountDefaultsProvider.Companion.DEFAULT_REMOTE_SEARCH_NUM_RESULTS, + ) + isUploadSentMessages = storage.getBoolean(keyGen.create("uploadSentMessages"), true) + + isMarkMessageAsReadOnView = storage.getBoolean(keyGen.create("markMessageAsReadOnView"), true) + isMarkMessageAsReadOnDelete = storage.getBoolean(keyGen.create("markMessageAsReadOnDelete"), true) + isAlwaysShowCcBcc = storage.getBoolean(keyGen.create("alwaysShowCcBcc"), false) + lastSyncTime = storage.getLong(keyGen.create("lastSyncTime"), 0L) + lastFolderListRefreshTime = storage.getLong(keyGen.create("lastFolderListRefreshTime"), 0L) + + shouldMigrateToOAuth = storage.getBoolean(keyGen.create("migrateToOAuth"), false) + folderPathDelimiter = storage.getStringOrDefault( + key = keyGen.create(FOLDER_PATH_DELIMITER_KEY), + defValue = FOLDER_DEFAULT_PATH_DELIMITER, + ) + + val isFinishedSetup = storage.getBoolean(keyGen.create("isFinishedSetup"), true) + if (isFinishedSetup) markSetupFinished() + + resetChangeMarkers() + } + } + + @Synchronized + private fun loadIdentities(accountId: AccountId, storage: Storage): List { + val newIdentities = ArrayList() + var ident = 0 + var gotOne: Boolean + val keyGen = AccountKeyGenerator(accountId) + + do { + gotOne = false + val name = storage.getStringOrNull(keyGen.create("$IDENTITY_NAME_KEY.$ident")) + val email = storage.getStringOrNull(keyGen.create("$IDENTITY_EMAIL_KEY.$ident")) + val signatureUse = storage.getBoolean(keyGen.create("signatureUse.$ident"), false) + val signature = storage.getStringOrNull(keyGen.create("signature.$ident")) + val description = storage.getStringOrNull(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$ident")) + val replyTo = storage.getStringOrNull(keyGen.create("replyTo.$ident")) + if (email != null) { + val identity = Identity( + name = name, + email = email, + signatureUse = signatureUse, + signature = signature, + description = description, + replyTo = replyTo, + ) + newIdentities.add(identity) + gotOne = true + } + ident++ + } while (gotOne) + + if (newIdentities.isEmpty()) { + val name = storage.getStringOrNull(keyGen.create("name")) + val email = storage.getStringOrNull(keyGen.create("email")) + val signatureUse = storage.getBoolean(keyGen.create("signatureUse"), false) + val signature = storage.getStringOrNull(keyGen.create("signature")) + val identity = Identity( + name = name, + email = email, + signatureUse = signatureUse, + signature = signature, + description = email, + ) + newIdentities.add(identity) + } + + return newIdentities + } + + @Suppress("LongMethod") + @Synchronized + override fun save(data: LegacyAccount, storage: Storage, editor: StorageEditor) { + val keyGen = AccountKeyGenerator(data.id) + + profileDtoStorageHandler.save(data, storage, editor) + + if (!storage.getStringOrDefault("accountUuids", "").contains(data.uuid)) { + var accountUuids = storage.getStringOrDefault("accountUuids", "") + accountUuids += (if (accountUuids.isNotEmpty()) "," else "") + data.uuid + editor.putString("accountUuids", accountUuids) + } + + with(data) { + editor.putString( + keyGen.create(INCOMING_SERVER_SETTINGS_KEY), + serverSettingsDtoSerializer.serialize(incomingServerSettings), + ) + editor.putString( + keyGen.create(OUTGOING_SERVER_SETTINGS_KEY), + serverSettingsDtoSerializer.serialize(outgoingServerSettings), + ) + editor.putString(keyGen.create("oAuthState"), oAuthState) + editor.putString(keyGen.create("alwaysBcc"), alwaysBcc) + editor.putInt(keyGen.create("automaticCheckIntervalMinutes"), automaticCheckIntervalMinutes) + editor.putInt(keyGen.create("idleRefreshMinutes"), idleRefreshMinutes) + editor.putInt(keyGen.create("displayCount"), displayCount) + editor.putBoolean(keyGen.create("notifyNewMail"), isNotifyNewMail) + editor.putString(keyGen.create("folderNotifyNewMailMode"), folderNotifyNewMailMode.name) + editor.putBoolean(keyGen.create("notifySelfNewMail"), isNotifySelfNewMail) + editor.putBoolean(keyGen.create("notifyContactsMailOnly"), isNotifyContactsMailOnly) + editor.putBoolean(keyGen.create("ignoreChatMessages"), isIgnoreChatMessages) + editor.putBoolean(keyGen.create("notifyMailCheck"), isNotifySync) + editor.putInt(keyGen.create("messagesNotificationChannelVersion"), messagesNotificationChannelVersion) + editor.putInt(keyGen.create("deletePolicy"), deletePolicy.setting) + editor.putString(keyGen.create("inboxFolderName"), legacyInboxFolder) + editor.putString(keyGen.create("draftsFolderName"), importedDraftsFolder) + editor.putString(keyGen.create("sentFolderName"), importedSentFolder) + editor.putString(keyGen.create("trashFolderName"), importedTrashFolder) + editor.putString(keyGen.create("archiveFolderName"), importedArchiveFolder) + editor.putString(keyGen.create("spamFolderName"), importedSpamFolder) + editor.putString(keyGen.create("inboxFolderId"), inboxFolderId?.toString()) + editor.putString(keyGen.create("draftsFolderId"), draftsFolderId?.toString()) + editor.putString(keyGen.create("sentFolderId"), sentFolderId?.toString()) + editor.putString(keyGen.create("trashFolderId"), trashFolderId?.toString()) + editor.putString(keyGen.create("archiveFolderId"), archiveFolderId?.toString()) + editor.putString(keyGen.create("spamFolderId"), spamFolderId?.toString()) + editor.putString(keyGen.create("archiveFolderSelection"), archiveFolderSelection.name) + editor.putString(keyGen.create("draftsFolderSelection"), draftsFolderSelection.name) + editor.putString(keyGen.create("sentFolderSelection"), sentFolderSelection.name) + editor.putString(keyGen.create("spamFolderSelection"), spamFolderSelection.name) + editor.putString(keyGen.create("trashFolderSelection"), trashFolderSelection.name) + editor.putString(keyGen.create("autoExpandFolderName"), importedAutoExpandFolder) + editor.putString(keyGen.create("autoExpandFolderId"), autoExpandFolderId?.toString()) + editor.putInt(keyGen.create("accountNumber"), accountNumber) + editor.putString(keyGen.create("sortTypeEnum"), sortType.name) + editor.putBoolean(keyGen.create("sortAscending"), isSortAscending(sortType)) + editor.putString(keyGen.create("showPicturesEnum"), showPictures.name) + editor.putString(keyGen.create("folderDisplayMode"), folderDisplayMode.name) + editor.putString(keyGen.create("folderSyncMode"), folderSyncMode.name) + editor.putString(keyGen.create("folderPushMode"), folderPushMode.name) + editor.putBoolean(keyGen.create("signatureBeforeQuotedText"), isSignatureBeforeQuotedText) + editor.putString(keyGen.create("expungePolicy"), expungePolicy.name) + editor.putBoolean(keyGen.create("syncRemoteDeletions"), isSyncRemoteDeletions) + editor.putInt(keyGen.create("maxPushFolders"), maxPushFolders) + editor.putBoolean(keyGen.create("subscribedFoldersOnly"), isSubscribedFoldersOnly) + editor.putInt(keyGen.create("maximumPolledMessageAge"), maximumPolledMessageAge) + editor.putInt(keyGen.create("maximumAutoDownloadMessageSize"), maximumAutoDownloadMessageSize) + val messageFormatAuto = if (MessageFormat.AUTO == messageFormat) { + // saving MessageFormat.AUTO as is to the database will cause downgrades to crash on + // startup, so we save as MessageFormat.TEXT instead with a separate flag for auto. + editor.putString(keyGen.create("messageFormat"), MessageFormat.TEXT.name) + true + } else { + editor.putString(keyGen.create("messageFormat"), messageFormat.name) + false + } + editor.putBoolean(keyGen.create("messageFormatAuto"), messageFormatAuto) + editor.putBoolean(keyGen.create("messageReadReceipt"), isMessageReadReceipt) + editor.putString(keyGen.create("quoteStyle"), quoteStyle.name) + editor.putString(keyGen.create("quotePrefix"), quotePrefix) + editor.putBoolean(keyGen.create("defaultQuotedTextShown"), isDefaultQuotedTextShown) + editor.putBoolean(keyGen.create("replyAfterQuote"), isReplyAfterQuote) + editor.putBoolean(keyGen.create("stripSignature"), isStripSignature) + editor.putLong(keyGen.create("cryptoKey"), openPgpKey) + editor.putBoolean(keyGen.create("openPgpHideSignOnly"), isOpenPgpHideSignOnly) + editor.putBoolean(keyGen.create("openPgpEncryptSubject"), isOpenPgpEncryptSubject) + editor.putBoolean(keyGen.create("openPgpEncryptAllDrafts"), isOpenPgpEncryptAllDrafts) + editor.putString(keyGen.create("openPgpProvider"), openPgpProvider) + editor.putBoolean(keyGen.create("autocryptMutualMode"), autocryptPreferEncryptMutual) + editor.putBoolean(keyGen.create("remoteSearchFullText"), isRemoteSearchFullText) + editor.putInt(keyGen.create("remoteSearchNumResults"), remoteSearchNumResults) + editor.putBoolean(keyGen.create("uploadSentMessages"), isUploadSentMessages) + editor.putBoolean(keyGen.create("markMessageAsReadOnView"), isMarkMessageAsReadOnView) + editor.putBoolean(keyGen.create("markMessageAsReadOnDelete"), isMarkMessageAsReadOnDelete) + editor.putBoolean(keyGen.create("alwaysShowCcBcc"), isAlwaysShowCcBcc) + + editor.putBoolean(keyGen.create("vibrate"), notificationSettings.vibration.isEnabled) + editor.putInt(keyGen.create("vibratePattern"), notificationSettings.vibration.pattern.serialize()) + editor.putInt(keyGen.create("vibrateTimes"), notificationSettings.vibration.repeatCount) + editor.putBoolean(keyGen.create("ring"), notificationSettings.isRingEnabled) + editor.putString(keyGen.create("ringtone"), notificationSettings.ringtone) + editor.putString(keyGen.create("notificationLight"), notificationSettings.light.name) + editor.putLong(keyGen.create("lastSyncTime"), lastSyncTime) + editor.putLong(keyGen.create("lastFolderListRefreshTime"), lastFolderListRefreshTime) + editor.putBoolean(keyGen.create("isFinishedSetup"), isFinishedSetup) + editor.putBoolean(keyGen.create("useCompression"), useCompression) + editor.putBoolean(keyGen.create("sendClientInfo"), isSendClientInfoEnabled) + editor.putBoolean(keyGen.create("migrateToOAuth"), shouldMigrateToOAuth) + editor.putString(keyGen.create(FOLDER_PATH_DELIMITER_KEY), folderPathDelimiter) + } + + saveIdentities(data, storage, editor) + } + + @Suppress("LongMethod") + @Synchronized + override fun delete(data: LegacyAccount, storage: Storage, editor: StorageEditor) { + val keyGen = AccountKeyGenerator(data.id) + val accountUuid = data.uuid + + profileDtoStorageHandler.delete(data, storage, editor) + + // Get the list of account UUIDs + val uuids = storage + .getStringOrDefault("accountUuids", "") + .split(",".toRegex()) + .dropLastWhile { it.isEmpty() } + .toTypedArray() + + // Create a list of all account UUIDs excluding this account + val newUuids = ArrayList(uuids.size) + for (uuid in uuids) { + if (uuid != accountUuid) { + newUuids.add(uuid) + } + } + + // Only change the 'accountUuids' value if this account's UUID was listed before + if (newUuids.size < uuids.size) { + val accountUuids = newUuids.joinToString(",") + editor.putString("accountUuids", accountUuids) + } + + editor.remove(keyGen.create("oAuthState")) + editor.remove(keyGen.create(INCOMING_SERVER_SETTINGS_KEY)) + editor.remove(keyGen.create(OUTGOING_SERVER_SETTINGS_KEY)) + editor.remove(keyGen.create("description")) + editor.remove(keyGen.create("email")) + editor.remove(keyGen.create("alwaysBcc")) + editor.remove(keyGen.create("automaticCheckIntervalMinutes")) + editor.remove(keyGen.create("idleRefreshMinutes")) + editor.remove(keyGen.create("lastAutomaticCheckTime")) + editor.remove(keyGen.create("notifyNewMail")) + editor.remove(keyGen.create("notifySelfNewMail")) + editor.remove(keyGen.create("ignoreChatMessages")) + editor.remove(keyGen.create("messagesNotificationChannelVersion")) + editor.remove(keyGen.create("deletePolicy")) + editor.remove(keyGen.create("draftsFolderName")) + editor.remove(keyGen.create("sentFolderName")) + editor.remove(keyGen.create("trashFolderName")) + editor.remove(keyGen.create("archiveFolderName")) + editor.remove(keyGen.create("spamFolderName")) + editor.remove(keyGen.create("archiveFolderSelection")) + editor.remove(keyGen.create("draftsFolderSelection")) + editor.remove(keyGen.create("sentFolderSelection")) + editor.remove(keyGen.create("spamFolderSelection")) + editor.remove(keyGen.create("trashFolderSelection")) + editor.remove(keyGen.create("autoExpandFolderName")) + editor.remove(keyGen.create("accountNumber")) + editor.remove(keyGen.create("vibrate")) + editor.remove(keyGen.create("vibratePattern")) + editor.remove(keyGen.create("vibrateTimes")) + editor.remove(keyGen.create("ring")) + editor.remove(keyGen.create("ringtone")) + editor.remove(keyGen.create("folderDisplayMode")) + editor.remove(keyGen.create("folderSyncMode")) + editor.remove(keyGen.create("folderPushMode")) + editor.remove(keyGen.create("signatureBeforeQuotedText")) + editor.remove(keyGen.create("expungePolicy")) + editor.remove(keyGen.create("syncRemoteDeletions")) + editor.remove(keyGen.create("maxPushFolders")) + editor.remove(keyGen.create("notificationLight")) + editor.remove(keyGen.create("subscribedFoldersOnly")) + editor.remove(keyGen.create("maximumPolledMessageAge")) + editor.remove(keyGen.create("maximumAutoDownloadMessageSize")) + editor.remove(keyGen.create("messageFormatAuto")) + editor.remove(keyGen.create("quoteStyle")) + editor.remove(keyGen.create("quotePrefix")) + editor.remove(keyGen.create("sortTypeEnum")) + editor.remove(keyGen.create("sortAscending")) + editor.remove(keyGen.create("showPicturesEnum")) + editor.remove(keyGen.create("replyAfterQuote")) + editor.remove(keyGen.create("stripSignature")) + editor.remove(keyGen.create("cryptoApp")) // this is no longer set, but cleans up legacy values + editor.remove(keyGen.create("cryptoAutoSignature")) + editor.remove(keyGen.create("cryptoAutoEncrypt")) + editor.remove(keyGen.create("cryptoApp")) + editor.remove(keyGen.create("cryptoKey")) + editor.remove(keyGen.create("cryptoSupportSignOnly")) + editor.remove(keyGen.create("openPgpProvider")) + editor.remove(keyGen.create("openPgpHideSignOnly")) + editor.remove(keyGen.create("openPgpEncryptSubject")) + editor.remove(keyGen.create("openPgpEncryptAllDrafts")) + editor.remove(keyGen.create("autocryptMutualMode")) + editor.remove(keyGen.create("enabled")) + editor.remove(keyGen.create("markMessageAsReadOnView")) + editor.remove(keyGen.create("markMessageAsReadOnDelete")) + editor.remove(keyGen.create("alwaysShowCcBcc")) + editor.remove(keyGen.create("remoteSearchFullText")) + editor.remove(keyGen.create("remoteSearchNumResults")) + editor.remove(keyGen.create("uploadSentMessages")) + editor.remove(keyGen.create("defaultQuotedTextShown")) + editor.remove(keyGen.create("displayCount")) + editor.remove(keyGen.create("inboxFolderName")) + editor.remove(keyGen.create("messageFormat")) + editor.remove(keyGen.create("messageReadReceipt")) + editor.remove(keyGen.create("notifyMailCheck")) + editor.remove(keyGen.create("inboxFolderId")) + editor.remove(keyGen.create("outboxFolderId")) + editor.remove(keyGen.create("draftsFolderId")) + editor.remove(keyGen.create("sentFolderId")) + editor.remove(keyGen.create("trashFolderId")) + editor.remove(keyGen.create("archiveFolderId")) + editor.remove(keyGen.create("spamFolderId")) + editor.remove(keyGen.create("autoExpandFolderId")) + editor.remove(keyGen.create("lastSyncTime")) + editor.remove(keyGen.create("lastFolderListRefreshTime")) + editor.remove(keyGen.create("isFinishedSetup")) + editor.remove(keyGen.create("useCompression")) + editor.remove(keyGen.create("sendClientInfo")) + editor.remove(keyGen.create("migrateToOAuth")) + editor.remove(keyGen.create(FOLDER_PATH_DELIMITER_KEY)) + + deleteIdentities(data, storage, editor) + // TODO: Remove preference settings that may exist for individual folders in the account. + } + + @Synchronized + private fun saveIdentities(data: LegacyAccount, storage: Storage, editor: StorageEditor) { + deleteIdentities(data, storage, editor) + var ident = 0 + val keyGen = AccountKeyGenerator(data.id) + + with(data) { + for (identity in identities) { + editor.putString(keyGen.create("$IDENTITY_NAME_KEY.$ident"), identity.name) + editor.putString(keyGen.create("$IDENTITY_EMAIL_KEY.$ident"), identity.email) + editor.putBoolean(keyGen.create("signatureUse.$ident"), identity.signatureUse) + editor.putString(keyGen.create("signature.$ident"), identity.signature) + editor.putString(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$ident"), identity.description) + editor.putString(keyGen.create("replyTo.$ident"), identity.replyTo) + ident++ + } + } + } + + @Synchronized + private fun deleteIdentities(data: LegacyAccount, storage: Storage, editor: StorageEditor) { + val keyGen = AccountKeyGenerator(data.id) + + var identityIndex = 0 + var gotOne: Boolean + do { + gotOne = false + val email = storage.getStringOrNull(keyGen.create("$IDENTITY_EMAIL_KEY.$identityIndex")) + if (email != null) { + editor.remove(keyGen.create("$IDENTITY_NAME_KEY.$identityIndex")) + editor.remove(keyGen.create("$IDENTITY_EMAIL_KEY.$identityIndex")) + editor.remove(keyGen.create("signatureUse.$identityIndex")) + editor.remove(keyGen.create("signature.$identityIndex")) + editor.remove(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$identityIndex")) + editor.remove(keyGen.create("replyTo.$identityIndex")) + gotOne = true + } + identityIndex++ + } while (gotOne) + } + + private inline fun > getEnumStringPref(storage: Storage, key: String, defaultEnum: T): T { + return try { + storage.getEnumOrDefault(key, defaultEnum) + } catch (ex: IllegalArgumentException) { + logger.warn(throwable = ex) { + "Unable to convert preference key [$key] to enum of type defaultEnum: $defaultEnum" + } + + defaultEnum + } + } + + companion object { + const val ACCOUNT_DESCRIPTION_KEY = "description" + const val INCOMING_SERVER_SETTINGS_KEY = "incomingServerSettings" + const val OUTGOING_SERVER_SETTINGS_KEY = "outgoingServerSettings" + + const val IDENTITY_NAME_KEY = "name" + const val IDENTITY_EMAIL_KEY = "email" + const val IDENTITY_DESCRIPTION_KEY = "description" + + const val FOLDER_PATH_DELIMITER_KEY = "folderPathDelimiter" + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAvatarDtoStorageHandler.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAvatarDtoStorageHandler.kt new file mode 100644 index 0000000..d707cdb --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAvatarDtoStorageHandler.kt @@ -0,0 +1,63 @@ +package net.thunderbird.feature.account.storage.legacy + +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor +import net.thunderbird.core.preference.storage.getEnumOrDefault +import net.thunderbird.core.preference.storage.putEnum +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto + +class LegacyAvatarDtoStorageHandler : AvatarDtoStorageHandler { + + override fun load( + data: LegacyAccount, + storage: Storage, + ) { + val keyGen = AccountKeyGenerator(data.id) + + with(data) { + avatar = AvatarDto( + avatarType = storage.getEnumOrDefault(keyGen.create(KEY_AVATAR_TYPE), AvatarTypeDto.MONOGRAM), + avatarMonogram = storage.getStringOrNull(keyGen.create(KEY_AVATAR_MONOGRAM)), + avatarImageUri = storage.getStringOrNull(keyGen.create(KEY_AVATAR_IMAGE_URI)), + avatarIconName = storage.getStringOrNull(keyGen.create(KEY_AVATAR_ICON_NAME)), + ) + } + } + + override fun save( + data: LegacyAccount, + storage: Storage, + editor: StorageEditor, + ) { + val keyGen = AccountKeyGenerator(data.id) + + with(data.avatar) { + editor.putEnum(keyGen.create(KEY_AVATAR_TYPE), avatarType) + editor.putString(keyGen.create(KEY_AVATAR_MONOGRAM), avatarMonogram) + editor.putString(keyGen.create(KEY_AVATAR_IMAGE_URI), avatarImageUri) + editor.putString(keyGen.create(KEY_AVATAR_ICON_NAME), avatarIconName) + } + } + + override fun delete( + data: LegacyAccount, + storage: Storage, + editor: StorageEditor, + ) { + val keyGen = AccountKeyGenerator(data.id) + + editor.remove(keyGen.create(KEY_AVATAR_TYPE)) + editor.remove(keyGen.create(KEY_AVATAR_MONOGRAM)) + editor.remove(keyGen.create(KEY_AVATAR_IMAGE_URI)) + editor.remove(keyGen.create(KEY_AVATAR_ICON_NAME)) + } + + private companion object Companion { + const val KEY_AVATAR_TYPE = "avatarType" + const val KEY_AVATAR_MONOGRAM = "avatarMonogram" + const val KEY_AVATAR_IMAGE_URI = "avatarImageUri" + const val KEY_AVATAR_ICON_NAME = "avatarIconName" + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyProfileDtoStorageHandler.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyProfileDtoStorageHandler.kt new file mode 100644 index 0000000..3254010 --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyProfileDtoStorageHandler.kt @@ -0,0 +1,58 @@ +package net.thunderbird.feature.account.storage.legacy + +import net.thunderbird.core.android.account.AccountDefaultsProvider +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor + +class LegacyProfileDtoStorageHandler( + private val avatarDtoStorageHandler: AvatarDtoStorageHandler, +) : ProfileDtoStorageHandler { + + override fun load( + data: LegacyAccount, + storage: Storage, + ) { + val keyGen = AccountKeyGenerator(data.id) + + with(data) { + name = storage.getStringOrNull(keyGen.create(KEY_NAME)) + chipColor = storage.getInt(keyGen.create(KEY_COLOR), AccountDefaultsProvider.COLOR) + } + + avatarDtoStorageHandler.load(data, storage) + } + + override fun save( + data: LegacyAccount, + storage: Storage, + editor: StorageEditor, + ) { + val keyGen = AccountKeyGenerator(data.id) + + with(data) { + editor.putString(keyGen.create(KEY_NAME), name) + editor.putInt(keyGen.create(KEY_COLOR), chipColor) + } + + avatarDtoStorageHandler.save(data, storage, editor) + } + + override fun delete( + data: LegacyAccount, + storage: Storage, + editor: StorageEditor, + ) { + val keyGen = AccountKeyGenerator(data.id) + + editor.remove(keyGen.create(KEY_NAME)) + editor.remove(keyGen.create(KEY_COLOR)) + + avatarDtoStorageHandler.delete(data, storage, editor) + } + + private companion object Companion { + const val KEY_COLOR = "chipColor" + const val KEY_NAME = "description" + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/StorageHandler.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/StorageHandler.kt new file mode 100644 index 0000000..04fad3c --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/StorageHandler.kt @@ -0,0 +1,49 @@ +package net.thunderbird.feature.account.storage.legacy + +import androidx.annotation.Discouraged +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.preference.storage.Storage +import net.thunderbird.core.preference.storage.StorageEditor + +/** + * Represents a storage handler for a specific data type. + * + * @param T The type of data that this handler can handle. + */ +@Discouraged( + message = "This interface is only used to encapsulate the [LegacyAccount] storage handling.", +) +interface StorageHandler { + + /** + * Loads the data from the storage into the provided object. + * + * @param data The object to load the data into. + * @param storage The storage to load the data from. + */ + fun load(data: T, storage: Storage) + + /** + * Saves the data from the provided object to the storage. + * + * @param data The object to save the data from. + * @param storage The storage to save the data to. + * @param editor The storage editor to use for saving the data. + */ + fun save(data: T, storage: Storage, editor: StorageEditor) + + /** + * Deletes the data from the storage. + * + * @param data The data to delete. + * @param storage The storage to delete the data from. + * @param editor The storage editor to use for deleting the data. + */ + fun delete(data: T, storage: Storage, editor: StorageEditor) +} + +interface AccountDtoStorageHandler : StorageHandler + +interface ProfileDtoStorageHandler : StorageHandler + +interface AvatarDtoStorageHandler : StorageHandler diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountAvatarDataMapper.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountAvatarDataMapper.kt new file mode 100644 index 0000000..7bcd58a --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountAvatarDataMapper.kt @@ -0,0 +1,62 @@ +package net.thunderbird.feature.account.storage.legacy.mapper + +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.storage.mapper.AccountAvatarDataMapper +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto + +class DefaultAccountAvatarDataMapper : AccountAvatarDataMapper { + + override fun toDomain(dto: AvatarDto): AccountAvatar { + return when (dto.avatarType) { + AvatarTypeDto.MONOGRAM -> AccountAvatar.Monogram( + value = dto.avatarMonogram ?: DEFAULT_MONOGRAM, + ) + + AvatarTypeDto.IMAGE -> { + val uri = dto.avatarImageUri + + if (uri.isNullOrEmpty()) { + AccountAvatar.Monogram( + value = DEFAULT_MONOGRAM, + ) + } else { + AccountAvatar.Image( + uri = uri, + ) + } + } + + AvatarTypeDto.ICON -> { + val name = dto.avatarIconName + + if (name.isNullOrEmpty()) { + AccountAvatar.Monogram( + value = DEFAULT_MONOGRAM, + ) + } else { + AccountAvatar.Icon( + name = name, + ) + } + } + } + } + + override fun toDto(domain: AccountAvatar): AvatarDto { + return AvatarDto( + avatarType = when (domain) { + is AccountAvatar.Monogram -> AvatarTypeDto.MONOGRAM + is AccountAvatar.Image -> AvatarTypeDto.IMAGE + is AccountAvatar.Icon -> AvatarTypeDto.ICON + }, + avatarMonogram = if (domain is AccountAvatar.Monogram) domain.value else null, + avatarImageUri = if (domain is AccountAvatar.Image) domain.uri else null, + avatarIconName = if (domain is AccountAvatar.Icon) domain.name else null, + ) + } + + private companion object { + const val DEFAULT_MONOGRAM = "XX" + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountProfileDataMapper.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountProfileDataMapper.kt new file mode 100644 index 0000000..f2a031e --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountProfileDataMapper.kt @@ -0,0 +1,28 @@ +package net.thunderbird.feature.account.storage.legacy.mapper + +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.storage.mapper.AccountAvatarDataMapper +import net.thunderbird.feature.account.storage.mapper.AccountProfileDataMapper +import net.thunderbird.feature.account.storage.profile.ProfileDto + +class DefaultAccountProfileDataMapper( + private val avatarMapper: AccountAvatarDataMapper, +) : AccountProfileDataMapper { + override fun toDomain(dto: ProfileDto): AccountProfile { + return AccountProfile( + id = dto.id, + name = dto.name, + color = dto.color, + avatar = avatarMapper.toDomain(dto.avatar), + ) + } + + override fun toDto(domain: AccountProfile): ProfileDto { + return ProfileDto( + id = domain.id, + name = domain.name, + color = domain.color, + avatar = avatarMapper.toDto(domain.avatar), + ) + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountWrapperDataMapper.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountWrapperDataMapper.kt new file mode 100644 index 0000000..3b21aa7 --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountWrapperDataMapper.kt @@ -0,0 +1,222 @@ +package net.thunderbird.feature.account.storage.legacy.mapper + +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.android.account.LegacyAccountWrapper +import net.thunderbird.core.architecture.data.DataMapper +import net.thunderbird.feature.account.storage.profile.ProfileDto + +class DefaultLegacyAccountWrapperDataMapper : DataMapper { + + @Suppress("LongMethod") + override fun toDomain(dto: LegacyAccount): LegacyAccountWrapper { + return LegacyAccountWrapper( + isSensitiveDebugLoggingEnabled = dto.isSensitiveDebugLoggingEnabled, + + // Account + id = dto.id, + + // BaseAccount + name = dto.name, + + // AccountProfile + profile = toProfileDto(dto), + + // Uncategorized + identities = dto.identities, + email = dto.email, + deletePolicy = dto.deletePolicy, + incomingServerSettings = dto.incomingServerSettings, + outgoingServerSettings = dto.outgoingServerSettings, + oAuthState = dto.oAuthState, + alwaysBcc = dto.alwaysBcc, + automaticCheckIntervalMinutes = dto.automaticCheckIntervalMinutes, + displayCount = dto.displayCount, + isNotifyNewMail = dto.isNotifyNewMail, + folderNotifyNewMailMode = dto.folderNotifyNewMailMode, + isNotifySelfNewMail = dto.isNotifySelfNewMail, + isNotifyContactsMailOnly = dto.isNotifyContactsMailOnly, + isIgnoreChatMessages = dto.isIgnoreChatMessages, + legacyInboxFolder = dto.legacyInboxFolder, + importedDraftsFolder = dto.importedDraftsFolder, + importedSentFolder = dto.importedSentFolder, + importedTrashFolder = dto.importedTrashFolder, + importedArchiveFolder = dto.importedArchiveFolder, + importedSpamFolder = dto.importedSpamFolder, + inboxFolderId = dto.inboxFolderId, + draftsFolderId = dto.draftsFolderId, + sentFolderId = dto.sentFolderId, + trashFolderId = dto.trashFolderId, + archiveFolderId = dto.archiveFolderId, + spamFolderId = dto.spamFolderId, + draftsFolderSelection = dto.draftsFolderSelection, + sentFolderSelection = dto.sentFolderSelection, + trashFolderSelection = dto.trashFolderSelection, + archiveFolderSelection = dto.archiveFolderSelection, + spamFolderSelection = dto.spamFolderSelection, + importedAutoExpandFolder = dto.importedAutoExpandFolder, + autoExpandFolderId = dto.autoExpandFolderId, + folderDisplayMode = dto.folderDisplayMode, + folderSyncMode = dto.folderSyncMode, + folderPushMode = dto.folderPushMode, + accountNumber = dto.accountNumber, + isNotifySync = dto.isNotifySync, + sortType = dto.sortType, + sortAscending = dto.sortAscending, + showPictures = dto.showPictures, + isSignatureBeforeQuotedText = dto.isSignatureBeforeQuotedText, + expungePolicy = dto.expungePolicy, + maxPushFolders = dto.maxPushFolders, + idleRefreshMinutes = dto.idleRefreshMinutes, + useCompression = dto.useCompression, + isSendClientInfoEnabled = dto.isSendClientInfoEnabled, + isSubscribedFoldersOnly = dto.isSubscribedFoldersOnly, + maximumPolledMessageAge = dto.maximumPolledMessageAge, + maximumAutoDownloadMessageSize = dto.maximumAutoDownloadMessageSize, + messageFormat = dto.messageFormat, + isMessageFormatAuto = dto.isMessageFormatAuto, + isMessageReadReceipt = dto.isMessageReadReceipt, + quoteStyle = dto.quoteStyle, + quotePrefix = dto.quotePrefix, + isDefaultQuotedTextShown = dto.isDefaultQuotedTextShown, + isReplyAfterQuote = dto.isReplyAfterQuote, + isStripSignature = dto.isStripSignature, + isSyncRemoteDeletions = dto.isSyncRemoteDeletions, + openPgpProvider = dto.openPgpProvider, + openPgpKey = dto.openPgpKey, + autocryptPreferEncryptMutual = dto.autocryptPreferEncryptMutual, + isOpenPgpHideSignOnly = dto.isOpenPgpHideSignOnly, + isOpenPgpEncryptSubject = dto.isOpenPgpEncryptSubject, + isOpenPgpEncryptAllDrafts = dto.isOpenPgpEncryptAllDrafts, + isMarkMessageAsReadOnView = dto.isMarkMessageAsReadOnView, + isMarkMessageAsReadOnDelete = dto.isMarkMessageAsReadOnDelete, + isAlwaysShowCcBcc = dto.isAlwaysShowCcBcc, + isRemoteSearchFullText = dto.isRemoteSearchFullText, + remoteSearchNumResults = dto.remoteSearchNumResults, + isUploadSentMessages = dto.isUploadSentMessages, + lastSyncTime = dto.lastSyncTime, + lastFolderListRefreshTime = dto.lastFolderListRefreshTime, + isFinishedSetup = dto.isFinishedSetup, + messagesNotificationChannelVersion = dto.messagesNotificationChannelVersion, + isChangedVisibleLimits = dto.isChangedVisibleLimits, + lastSelectedFolderId = dto.lastSelectedFolderId, + notificationSettings = dto.notificationSettings, + senderName = dto.senderName, + signatureUse = dto.signatureUse, + signature = dto.signature, + shouldMigrateToOAuth = dto.shouldMigrateToOAuth, + folderPathDelimiter = dto.folderPathDelimiter, + ) + } + + private fun toProfileDto(dto: LegacyAccount): ProfileDto { + return ProfileDto( + id = dto.id, + name = dto.displayName, + color = dto.chipColor, + avatar = dto.avatar, + ) + } + + @Suppress("LongMethod") + override fun toDto(domain: LegacyAccountWrapper): LegacyAccount { + return LegacyAccount( + uuid = domain.uuid, + isSensitiveDebugLoggingEnabled = domain.isSensitiveDebugLoggingEnabled, + ).apply { + identities = domain.identities.toMutableList() + email = domain.email + + // [AccountProfile] + fromProfileDto(domain.profile, this) + + // Uncategorized + deletePolicy = domain.deletePolicy + incomingServerSettings = domain.incomingServerSettings + outgoingServerSettings = domain.outgoingServerSettings + oAuthState = domain.oAuthState + alwaysBcc = domain.alwaysBcc + automaticCheckIntervalMinutes = domain.automaticCheckIntervalMinutes + displayCount = domain.displayCount + isNotifyNewMail = domain.isNotifyNewMail + folderNotifyNewMailMode = domain.folderNotifyNewMailMode + isNotifySelfNewMail = domain.isNotifySelfNewMail + isNotifyContactsMailOnly = domain.isNotifyContactsMailOnly + isIgnoreChatMessages = domain.isIgnoreChatMessages + legacyInboxFolder = domain.legacyInboxFolder + importedDraftsFolder = domain.importedDraftsFolder + importedSentFolder = domain.importedSentFolder + importedTrashFolder = domain.importedTrashFolder + importedArchiveFolder = domain.importedArchiveFolder + importedSpamFolder = domain.importedSpamFolder + inboxFolderId = domain.inboxFolderId + draftsFolderId = domain.draftsFolderId + sentFolderId = domain.sentFolderId + trashFolderId = domain.trashFolderId + archiveFolderId = domain.archiveFolderId + spamFolderId = domain.spamFolderId + draftsFolderSelection = domain.draftsFolderSelection + sentFolderSelection = domain.sentFolderSelection + trashFolderSelection = domain.trashFolderSelection + archiveFolderSelection = domain.archiveFolderSelection + spamFolderSelection = domain.spamFolderSelection + importedAutoExpandFolder = domain.importedAutoExpandFolder + autoExpandFolderId = domain.autoExpandFolderId + folderDisplayMode = domain.folderDisplayMode + folderSyncMode = domain.folderSyncMode + folderPushMode = domain.folderPushMode + accountNumber = domain.accountNumber + isNotifySync = domain.isNotifySync + sortType = domain.sortType + sortAscending = domain.sortAscending.toMutableMap() + showPictures = domain.showPictures + isSignatureBeforeQuotedText = domain.isSignatureBeforeQuotedText + expungePolicy = domain.expungePolicy + maxPushFolders = domain.maxPushFolders + idleRefreshMinutes = domain.idleRefreshMinutes + useCompression = domain.useCompression + isSendClientInfoEnabled = domain.isSendClientInfoEnabled + isSubscribedFoldersOnly = domain.isSubscribedFoldersOnly + maximumPolledMessageAge = domain.maximumPolledMessageAge + maximumAutoDownloadMessageSize = domain.maximumAutoDownloadMessageSize + messageFormat = domain.messageFormat + isMessageFormatAuto = domain.isMessageFormatAuto + isMessageReadReceipt = domain.isMessageReadReceipt + quoteStyle = domain.quoteStyle + quotePrefix = domain.quotePrefix + isDefaultQuotedTextShown = domain.isDefaultQuotedTextShown + isReplyAfterQuote = domain.isReplyAfterQuote + isStripSignature = domain.isStripSignature + isSyncRemoteDeletions = domain.isSyncRemoteDeletions + openPgpProvider = domain.openPgpProvider + openPgpKey = domain.openPgpKey + autocryptPreferEncryptMutual = domain.autocryptPreferEncryptMutual + isOpenPgpHideSignOnly = domain.isOpenPgpHideSignOnly + isOpenPgpEncryptSubject = domain.isOpenPgpEncryptSubject + isOpenPgpEncryptAllDrafts = domain.isOpenPgpEncryptAllDrafts + isMarkMessageAsReadOnView = domain.isMarkMessageAsReadOnView + isMarkMessageAsReadOnDelete = domain.isMarkMessageAsReadOnDelete + isAlwaysShowCcBcc = domain.isAlwaysShowCcBcc + isRemoteSearchFullText = domain.isRemoteSearchFullText + remoteSearchNumResults = domain.remoteSearchNumResults + isUploadSentMessages = domain.isUploadSentMessages + lastSyncTime = domain.lastSyncTime + lastFolderListRefreshTime = domain.lastFolderListRefreshTime + isFinishedSetup = domain.isFinishedSetup + messagesNotificationChannelVersion = domain.messagesNotificationChannelVersion + isChangedVisibleLimits = domain.isChangedVisibleLimits + lastSelectedFolderId = domain.lastSelectedFolderId + notificationSettings = domain.notificationSettings + senderName = domain.senderName + signatureUse = domain.signatureUse + signature = domain.signature + shouldMigrateToOAuth = domain.shouldMigrateToOAuth + folderPathDelimiter = domain.folderPathDelimiter + } + } + + private fun fromProfileDto(dto: ProfileDto, account: LegacyAccount) { + account.name = dto.name + account.chipColor = dto.color + account.avatar = dto.avatar + } +} diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/serializer/ServerSettingsDtoSerializer.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/serializer/ServerSettingsDtoSerializer.kt new file mode 100644 index 0000000..1fb8d98 --- /dev/null +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/serializer/ServerSettingsDtoSerializer.kt @@ -0,0 +1,123 @@ +package net.thunderbird.feature.account.storage.legacy.serializer + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonReader.Token +import com.squareup.moshi.JsonWriter + +class ServerSettingsDtoSerializer { + private val adapter = ServerSettingsAdapter() + + fun serialize(serverSettings: ServerSettings): String { + return adapter.toJson(serverSettings) + } + + fun deserialize(json: String): ServerSettings { + return adapter.fromJson(json)!! + } +} + +private const val KEY_TYPE = "type" +private const val KEY_HOST = "host" +private const val KEY_PORT = "port" +private const val KEY_CONNECTION_SECURITY = "connectionSecurity" +private const val KEY_AUTHENTICATION_TYPE = "authenticationType" +private const val KEY_USERNAME = "username" +private const val KEY_PASSWORD = "password" +private const val KEY_CLIENT_CERTIFICATE_ALIAS = "clientCertificateAlias" + +private val JSON_KEYS = JsonReader.Options.of( + KEY_TYPE, + KEY_HOST, + KEY_PORT, + KEY_CONNECTION_SECURITY, + KEY_AUTHENTICATION_TYPE, + KEY_USERNAME, + KEY_PASSWORD, + KEY_CLIENT_CERTIFICATE_ALIAS, +) + +@Suppress("MagicNumber") +private class ServerSettingsAdapter : JsonAdapter() { + override fun fromJson(reader: JsonReader): ServerSettings { + reader.beginObject() + + var type: String? = null + var host: String? = null + var port: Int? = null + var connectionSecurity: ConnectionSecurity? = null + var authenticationType: AuthType? = null + var username: String? = null + var password: String? = null + var clientCertificateAlias: String? = null + val extra = mutableMapOf() + + while (reader.hasNext()) { + when (reader.selectName(JSON_KEYS)) { + 0 -> type = reader.nextString() + 1 -> host = reader.nextString() + 2 -> port = reader.nextInt() + 3 -> connectionSecurity = ConnectionSecurity.valueOf(reader.nextString()) + 4 -> authenticationType = AuthType.valueOf(reader.nextString()) + 5 -> username = reader.nextString() + 6 -> password = reader.nextStringOrNull() + 7 -> clientCertificateAlias = reader.nextStringOrNull() + else -> { + val key = reader.nextName() + val value = reader.nextStringOrNull() + extra[key] = value + } + } + } + + reader.endObject() + + requireNotNull(type) { "'type' must not be missing" } + requireNotNull(host) { "'host' must not be missing" } + requireNotNull(port) { "'port' must not be missing" } + requireNotNull(connectionSecurity) { "'connectionSecurity' must not be missing" } + requireNotNull(authenticationType) { "'authenticationType' must not be missing" } + requireNotNull(username) { "'username' must not be missing" } + + return ServerSettings( + type, + host, + port, + connectionSecurity, + authenticationType, + username, + password, + clientCertificateAlias, + extra, + ) + } + + override fun toJson(writer: JsonWriter, serverSettings: ServerSettings?) { + requireNotNull(serverSettings) + + writer.beginObject() + writer.serializeNulls = true + + writer.name(KEY_TYPE).value(serverSettings.type) + writer.name(KEY_HOST).value(serverSettings.host) + writer.name(KEY_PORT).value(serverSettings.port) + writer.name(KEY_CONNECTION_SECURITY).value(serverSettings.connectionSecurity.name) + writer.name(KEY_AUTHENTICATION_TYPE).value(serverSettings.authenticationType.name) + writer.name(KEY_USERNAME).value(serverSettings.username) + writer.name(KEY_PASSWORD).value(serverSettings.password) + writer.name(KEY_CLIENT_CERTIFICATE_ALIAS).value(serverSettings.clientCertificateAlias) + + for ((key, value) in serverSettings.extra) { + writer.name(key).value(value) + } + + writer.endObject() + } + + private fun JsonReader.nextStringOrNull(): String? { + return if (peek() == Token.NULL) nextNull() else nextString() + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/AccountKeyGeneratorTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/AccountKeyGeneratorTest.kt new file mode 100644 index 0000000..6955459 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/AccountKeyGeneratorTest.kt @@ -0,0 +1,60 @@ +package net.thunderbird.feature.account.storage.legacy + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotEqualTo +import kotlin.test.Test +import net.thunderbird.account.fake.FakeAccountData + +class AccountKeyGeneratorTest { + + @Test + fun `create should combine account ID with key`() { + // Arrange + val accountId = FakeAccountData.ACCOUNT_ID + val testSubject = AccountKeyGenerator(accountId) + val key = "testKey" + + // Act + val result = testSubject.create(key) + + // Assert + assertThat(result).isEqualTo("${accountId.asRaw()}.$key") + } + + @Test + fun `create should fail with empty key`() { + // Arrange + val accountId = FakeAccountData.ACCOUNT_ID + val testSubject = AccountKeyGenerator(accountId) + val key = "" + + // Act & Assert + assertFailure { + testSubject.create(key) + }.isInstanceOf() + .hasMessage("Key must not be empty") + } + + @Test + fun `create should work with different account IDs`() { + // Arrange + val accountId1 = FakeAccountData.ACCOUNT_ID + val accountId2 = FakeAccountData.ACCOUNT_ID_OTHER + val testSubject1 = AccountKeyGenerator(accountId1) + val testSubject2 = AccountKeyGenerator(accountId2) + val key = "testKey" + + // Act + val result1 = testSubject1.create(key) + val result2 = testSubject2.create(key) + + // Assert + assertThat(result1).isEqualTo("${accountId1.asRaw()}.$key") + assertThat(result2).isEqualTo("${accountId2.asRaw()}.$key") + assertThat(result1).isNotEqualTo(result2) + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/AccountStorageLegacyModuleKtTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/AccountStorageLegacyModuleKtTest.kt new file mode 100644 index 0000000..7194e9a --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/AccountStorageLegacyModuleKtTest.kt @@ -0,0 +1,14 @@ +package net.thunderbird.feature.account.storage.legacy + +import kotlin.test.Test +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.test.verify.verify + +class AccountStorageLegacyModuleKtTest { + + @OptIn(KoinExperimentalAPI::class) + @Test + fun `should have a valid di module`() { + featureAccountStorageLegacyModule.verify() + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAvatarDtoStorageHandlerTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAvatarDtoStorageHandlerTest.kt new file mode 100644 index 0000000..1aa7361 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAvatarDtoStorageHandlerTest.kt @@ -0,0 +1,102 @@ +package net.thunderbird.feature.account.storage.legacy + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.account.fake.FakeAccountAvatarData +import net.thunderbird.account.fake.FakeAccountData +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.storage.legacy.fake.FakeStorage +import net.thunderbird.feature.account.storage.legacy.fake.FakeStorageEditor +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto + +class LegacyAvatarDtoStorageHandlerTest { + private val testSubject = LegacyAvatarDtoStorageHandler() + + @Test + fun `load should populate avatar data from storage`() { + // Arrange + val account = createAccount(accountId) + val storage = createStorage(accountId) + + // Act + testSubject.load(account, storage) + + // Assert + assertThat(account.avatar.avatarType).isEqualTo(AVATAR_TYPE) + assertThat(account.avatar.avatarMonogram).isEqualTo(AVATAR_MONOGRAM) + assertThat(account.avatar.avatarImageUri).isEqualTo(AVATAR_IMAGE_URI) + assertThat(account.avatar.avatarIconName).isEqualTo(AVATAR_ICON_NAME) + } + + @Test + fun `save should store avatar data to storage`() { + // Arrange + val account = createAccount(accountId) + val storage = FakeStorage() + val editor = FakeStorageEditor() + + // Act + testSubject.save(account, storage, editor) + + // Assert + assertThat(editor.values["${accountId.asRaw()}.avatarType"]).isEqualTo(AVATAR_TYPE.name) + assertThat(editor.values["${accountId.asRaw()}.avatarMonogram"]).isEqualTo(AVATAR_MONOGRAM) + assertThat(editor.values["${accountId.asRaw()}.avatarImageUri"]).isEqualTo(AVATAR_IMAGE_URI) + assertThat(editor.values["${accountId.asRaw()}.avatarIconName"]).isEqualTo(null) + } + + @Test + fun `delete should remove avatar data from storage`() { + // Arrange + val account = createAccount(accountId) + val storage = FakeStorage() + val editor = FakeStorageEditor() + + // Act + testSubject.delete(account, storage, editor) + + // Assert + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarType") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarMonogram") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarImageUri") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarIconName") + } + + // Arrange methods + private fun createAccount(accountId: AccountId): LegacyAccount { + return LegacyAccount(accountId.asRaw()).apply { + name = "Test Account" + chipColor = 0x0099CC // Default color + avatar = AvatarDto( + avatarType = AVATAR_TYPE, + avatarMonogram = AVATAR_MONOGRAM, + avatarImageUri = AVATAR_IMAGE_URI, + avatarIconName = null, + ) + } + } + + private fun createStorage(accountId: AccountId): FakeStorage { + return FakeStorage( + mapOf( + "${accountId.asRaw()}.avatarType" to AVATAR_TYPE.name, + "${accountId.asRaw()}.avatarMonogram" to AVATAR_MONOGRAM, + "${accountId.asRaw()}.avatarImageUri" to AVATAR_IMAGE_URI, + "${accountId.asRaw()}.avatarIconName" to AVATAR_ICON_NAME, + ), + ) + } + + private companion object { + val accountId = FakeAccountData.ACCOUNT_ID + + val AVATAR_TYPE = AvatarTypeDto.MONOGRAM + const val AVATAR_MONOGRAM = "TB" + const val AVATAR_IMAGE_URI = FakeAccountAvatarData.AVATAR_IMAGE_URI + const val AVATAR_ICON_NAME = "icon-name" + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyProfileDtoStorageHandlerTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyProfileDtoStorageHandlerTest.kt new file mode 100644 index 0000000..231ace6 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyProfileDtoStorageHandlerTest.kt @@ -0,0 +1,111 @@ +package net.thunderbird.feature.account.storage.legacy + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.account.fake.FakeAccountAvatarData +import net.thunderbird.account.fake.FakeAccountData +import net.thunderbird.account.fake.FakeAccountProfileData +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.storage.legacy.fake.FakeStorage +import net.thunderbird.feature.account.storage.legacy.fake.FakeStorageEditor +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto + +class LegacyProfileDtoStorageHandlerTest { + private val avatarDtoStorageHandler = LegacyAvatarDtoStorageHandler() + private val testSubject = LegacyProfileDtoStorageHandler(avatarDtoStorageHandler) + + @Test + fun `load should populate profile data from storage`() { + // Arrange + val account = createAccount(accountId) + val storage = createStorage(accountId) + + // Act + testSubject.load(account, storage) + + // Assert + assertThat(account.name).isEqualTo(NAME) + assertThat(account.chipColor).isEqualTo(COLOR) + assertThat(account.avatar.avatarType).isEqualTo(AvatarTypeDto.IMAGE) + assertThat(account.avatar.avatarMonogram).isEqualTo(null) + assertThat(account.avatar.avatarImageUri).isEqualTo(AVATAR_IMAGE_URI) + assertThat(account.avatar.avatarIconName).isEqualTo(null) + } + + @Test + fun `save should store profile data to storage`() { + // Arrange + val account = createAccount(accountId) + val storage = FakeStorage() + val editor = FakeStorageEditor() + + // Act + testSubject.save(account, storage, editor) + + // Assert + assertThat(editor.values["${accountId.asRaw()}.description"]).isEqualTo(NAME) + assertThat(editor.values["${accountId.asRaw()}.chipColor"]).isEqualTo(COLOR.toString()) + assertThat(editor.values["${accountId.asRaw()}.avatarType"]).isEqualTo("IMAGE") + assertThat(editor.values["${accountId.asRaw()}.avatarMonogram"]).isEqualTo(null) + assertThat(editor.values["${accountId.asRaw()}.avatarImageUri"]).isEqualTo(AVATAR_IMAGE_URI) + assertThat(editor.values["${accountId.asRaw()}.avatarIconName"]).isEqualTo(null) + } + + @Test + fun `delete should remove profile data from storage`() { + // Arrange + val account = createAccount(accountId) + val storage = FakeStorage() + val editor = FakeStorageEditor() + + // Act + testSubject.delete(account, storage, editor) + + // Assert + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.description") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.chipColor") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarType") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarMonogram") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarImageUri") + assertThat(editor.removedKeys).contains("${accountId.asRaw()}.avatarIconName") + } + + // Arrange methods + private fun createAccount(accountId: AccountId): LegacyAccount { + return LegacyAccount(accountId.asRaw()).apply { + name = NAME + chipColor = COLOR + avatar = AvatarDto( + avatarType = AvatarTypeDto.IMAGE, + avatarMonogram = null, + avatarImageUri = AVATAR_IMAGE_URI, + avatarIconName = null, + ) + } + } + + private fun createStorage(accountId: AccountId): FakeStorage { + return FakeStorage( + mapOf( + "${accountId.asRaw()}.description" to NAME, + "${accountId.asRaw()}.chipColor" to COLOR.toString(), + "${accountId.asRaw()}.avatarType" to AVATAR_TYPE.name, + "${accountId.asRaw()}.avatarImageUri" to AVATAR_IMAGE_URI, + ), + ) + } + + private companion object { + val accountId = FakeAccountData.ACCOUNT_ID + + const val NAME = FakeAccountProfileData.PROFILE_NAME + const val COLOR = FakeAccountProfileData.PROFILE_COLOR + + val AVATAR_TYPE = AvatarTypeDto.IMAGE + const val AVATAR_IMAGE_URI = FakeAccountAvatarData.AVATAR_IMAGE_URI + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/fake/FakeStorage.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/fake/FakeStorage.kt new file mode 100644 index 0000000..a71ac98 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/fake/FakeStorage.kt @@ -0,0 +1,28 @@ +package net.thunderbird.feature.account.storage.legacy.fake + +import net.thunderbird.core.preference.storage.Storage + +class FakeStorage(private val values: Map = emptyMap()) : Storage { + override fun isEmpty(): Boolean = values.isEmpty() + + override fun contains(key: String): Boolean = values.containsKey(key) + + override fun getAll(): Map = values + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + values[key]?.toBoolean() ?: defValue + + override fun getInt(key: String, defValue: Int): Int = + values[key]?.toIntOrNull() ?: defValue + + override fun getLong(key: String, defValue: Long): Long = + values[key]?.toLongOrNull() ?: defValue + + override fun getString(key: String): String = + values[key] ?: throw NoSuchElementException("No value for key $key") + + override fun getStringOrDefault(key: String, defValue: String): String = + values[key] ?: defValue + + override fun getStringOrNull(key: String): String? = values[key] +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/fake/FakeStorageEditor.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/fake/FakeStorageEditor.kt new file mode 100644 index 0000000..8840b9f --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/fake/FakeStorageEditor.kt @@ -0,0 +1,37 @@ +package net.thunderbird.feature.account.storage.legacy.fake + +import net.thunderbird.core.preference.storage.StorageEditor + +class FakeStorageEditor : StorageEditor { + val values = mutableMapOf() + + val removedKeys = mutableListOf() + + override fun putBoolean(key: String, value: Boolean): StorageEditor { + values[key] = value.toString() + return this + } + + override fun putInt(key: String, value: Int): StorageEditor { + values[key] = value.toString() + return this + } + + override fun putLong(key: String, value: Long): StorageEditor { + values[key] = value.toString() + return this + } + + override fun putString(key: String, value: String?): StorageEditor { + values[key] = value + return this + } + + override fun remove(key: String): StorageEditor { + values.remove(key) + removedKeys.add(key) + return this + } + + override fun commit(): Boolean = true +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountAvatarDataMapperTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountAvatarDataMapperTest.kt new file mode 100644 index 0000000..2bc1fd4 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountAvatarDataMapperTest.kt @@ -0,0 +1,135 @@ +package net.thunderbird.feature.account.storage.legacy.mapper + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import kotlin.test.Test +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto + +class DefaultAccountAvatarDataMapperTest { + + private val testSubject = DefaultAccountAvatarDataMapper() + + @Test + fun `toDomain should map valid AvatarDto to correct AccountAvatar type`() { + require(testCases.isNotEmpty()) { "Test cases should not be empty" } + + testCases.forEach { case -> + // Arrange + val dto = case.dto + val expected = case.domain + + // Act + val result = testSubject.toDomain(dto) + + // Assert + when (result) { + is AccountAvatar.Monogram -> assertDomainMonogram(result, expected) + is AccountAvatar.Image -> assertDomainImage(result, expected) + is AccountAvatar.Icon -> assertDomainIcon(result, expected) + } + } + } + + @Test + fun `toDomain should return default monogram for invalid AvatarDto`() { + val avatarTypeDtos = AvatarTypeDto.entries + + avatarTypeDtos.forEach { type -> + // Arrange + val dto = AvatarDto( + avatarType = type, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = null, + ) + + // Act + val result = testSubject.toDomain(dto) + + // Assert + assertDomainMonogram(result, AccountAvatar.Monogram("XX")) + } + } + + @Test + fun `toDto should map valid AccountAvatar to correct AvatarDto type`() { + require(testCases.isNotEmpty()) { "Test cases should not be empty" } + + testCases.forEach { case -> + // Arrange + val domain = case.domain + val expected = case.dto + + // Act + val result = testSubject.toDto(domain) + + // Assert + when (result.avatarType) { + AvatarTypeDto.MONOGRAM -> assertDtoMonogram(result, expected) + AvatarTypeDto.IMAGE -> assertDtoImage(result, expected) + AvatarTypeDto.ICON -> assertDtoIcon(result, expected) + } + } + } + + private fun assertDomainMonogram(actual: AccountAvatar, expected: AccountAvatar) { + require(expected is AccountAvatar.Monogram) { "Expected AccountAvatar to be of type Monogram" } + assertThat(actual).isEqualTo(expected) + } + + private fun assertDomainImage(actual: AccountAvatar, expected: AccountAvatar) { + require(expected is AccountAvatar.Image) { "Expected AccountAvatar to be of type Image" } + assertThat(actual).isEqualTo(expected) + } + + private fun assertDomainIcon(actual: AccountAvatar, expected: AccountAvatar) { + require(expected is AccountAvatar.Icon) { "Expected AccountAvatar to be of type Icon" } + assertThat(actual).isEqualTo(expected) + } + + private fun assertDtoMonogram(actual: AvatarDto, expected: AvatarDto) { + assertThat(actual.avatarType).isEqualTo(AvatarTypeDto.MONOGRAM) + assertThat(actual.avatarMonogram).isEqualTo(expected.avatarMonogram) + assertThat(actual.avatarImageUri).isNull() + assertThat(actual.avatarIconName).isNull() + } + + private fun assertDtoImage(actual: AvatarDto, expected: AvatarDto) { + assertThat(actual.avatarType).isEqualTo(AvatarTypeDto.IMAGE) + assertThat(actual.avatarMonogram).isNull() + assertThat(actual.avatarImageUri).isEqualTo(expected.avatarImageUri) + assertThat(actual.avatarIconName).isNull() + } + + private fun assertDtoIcon(actual: AvatarDto, expected: AvatarDto) { + assertThat(actual.avatarType).isEqualTo(AvatarTypeDto.ICON) + assertThat(actual.avatarMonogram).isNull() + assertThat(actual.avatarImageUri).isNull() + assertThat(actual.avatarIconName).isEqualTo(expected.avatarIconName) + } + + private companion object { + data class TestCase( + val dto: AvatarDto, + val domain: AccountAvatar, + ) + + val testCases = listOf( + TestCase( + AvatarDto(AvatarTypeDto.MONOGRAM, "AB", null, null), + AccountAvatar.Monogram("AB"), + ), + TestCase( + AvatarDto(AvatarTypeDto.IMAGE, null, "uri://img", null), + AccountAvatar.Image("uri://img"), + ), + TestCase( + AvatarDto(AvatarTypeDto.ICON, null, null, "icon_name"), + AccountAvatar.Icon("icon_name"), + ), + ) + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountProfileDataMapperTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountProfileDataMapperTest.kt new file mode 100644 index 0000000..a9d0549 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultAccountProfileDataMapperTest.kt @@ -0,0 +1,84 @@ +package net.thunderbird.feature.account.storage.legacy.mapper + +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.account.fake.FakeAccountAvatarData.AVATAR_IMAGE_URI +import net.thunderbird.account.fake.FakeAccountData.ACCOUNT_ID +import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_COLOR +import net.thunderbird.account.fake.FakeAccountProfileData.PROFILE_NAME +import net.thunderbird.account.fake.FakeAccountProfileData.createAccountProfile +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto +import net.thunderbird.feature.account.storage.profile.ProfileDto +import org.junit.Test + +class DefaultAccountProfileDataMapperTest { + + @Test + fun `toDomain should convert ProfileDto to AccountProfile`() { + // Arrange + val dto = createProfileDto() + val expected = createAccountProfile() + + val testSubject = DefaultAccountProfileDataMapper( + avatarMapper = FakeAccountAvatarDataMapper( + dto = dto.avatar, + domain = expected.avatar, + ), + ) + + // Act + val result = testSubject.toDomain(dto) + + // Assert + assertThat(result.id).isEqualTo(expected.id) + assertThat(result.name).isEqualTo(expected.name) + assertThat(result.color).isEqualTo(expected.color) + assertThat(result.avatar).isEqualTo(expected.avatar) + } + + @Test + fun `toDto should convert AccountProfile to ProfileDto`() { + // Arrange + val domain = createAccountProfile() + val expected = createProfileDto() + + val testSubject = DefaultAccountProfileDataMapper( + avatarMapper = FakeAccountAvatarDataMapper( + dto = expected.avatar, + domain = domain.avatar, + ), + ) + + // Act + val result = testSubject.toDto(domain) + + // Assert + assertThat(result.id).isEqualTo(expected.id) + assertThat(result.name).isEqualTo(expected.name) + assertThat(result.color).isEqualTo(expected.color) + assertThat(result.avatar).isEqualTo(expected.avatar) + } + + private companion object { + fun createProfileDto( + id: AccountId = ACCOUNT_ID, + name: String = PROFILE_NAME, + color: Int = PROFILE_COLOR, + avatar: AvatarDto = AvatarDto( + avatarType = AvatarTypeDto.IMAGE, + avatarMonogram = null, + avatarImageUri = AVATAR_IMAGE_URI, + avatarIconName = null, + ), + ): ProfileDto { + return ProfileDto( + id = id, + name = name, + color = color, + avatar = avatar, + ) + } + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountWrapperDataMapperTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountWrapperDataMapperTest.kt new file mode 100644 index 0000000..aa727a3 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountWrapperDataMapperTest.kt @@ -0,0 +1,408 @@ +package net.thunderbird.feature.account.storage.legacy.mapper + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import kotlin.test.Test +import net.thunderbird.account.fake.FakeAccountData.ACCOUNT_ID_RAW +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.FolderMode +import net.thunderbird.core.android.account.Identity +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.android.account.LegacyAccountWrapper +import net.thunderbird.core.android.account.MessageFormat +import net.thunderbird.core.android.account.QuoteStyle +import net.thunderbird.core.android.account.ShowPictures +import net.thunderbird.core.android.account.SortType +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto +import net.thunderbird.feature.account.storage.profile.ProfileDto +import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection +import net.thunderbird.feature.notification.NotificationSettings + +class DefaultLegacyAccountWrapperDataMapperTest { + + @Test + fun `toDomain should return wrapper`() { + // arrange + val account = createAccount() + val expected = createAccountWrapper() + val testSubject = DefaultLegacyAccountWrapperDataMapper() + + // act + val result = testSubject.toDomain(account) + + // assert + assertThat(result).isEqualTo(expected) + } + + @Suppress("LongMethod") + @Test + fun `toDto should return account`() { + // arrange + val wrapper = createAccountWrapper() + val testSubject = DefaultLegacyAccountWrapperDataMapper() + + // act + val result = testSubject.toDto(wrapper) + + // assert + assertThat(result.id).isEqualTo(AccountIdFactory.of(ACCOUNT_ID_RAW)) + assertThat(result.uuid).isEqualTo(ACCOUNT_ID_RAW) + assertThat(result.isSensitiveDebugLoggingEnabled).isEqualTo(defaultIsSensitiveDebugLoggingEnabled) + assertThat(result.identities).isEqualTo(defaultIdentities) + assertThat(result.name).isEqualTo("displayName") + assertThat(result.email).isEqualTo("demo@example.com") + assertThat(result.deletePolicy).isEqualTo(DeletePolicy.SEVEN_DAYS) + assertThat(result.incomingServerSettings).isEqualTo(defaultIncomingServerSettings) + assertThat(result.outgoingServerSettings).isEqualTo(defaultOutgoingServerSettings) + assertThat(result.oAuthState).isEqualTo("oAuthState") + assertThat(result.alwaysBcc).isEqualTo("alwaysBcc") + assertThat(result.automaticCheckIntervalMinutes).isEqualTo(60) + assertThat(result.displayCount).isEqualTo(10) + assertThat(result.chipColor).isEqualTo(0xFFFF0000.toInt()) + assertThat(result.isNotifyNewMail).isEqualTo(true) + assertThat(result.folderNotifyNewMailMode).isEqualTo(FolderMode.FIRST_AND_SECOND_CLASS) + assertThat(result.isNotifySelfNewMail).isEqualTo(true) + assertThat(result.isNotifyContactsMailOnly).isEqualTo(true) + assertThat(result.isIgnoreChatMessages).isEqualTo(true) + assertThat(result.legacyInboxFolder).isEqualTo("legacyInboxFolder") + assertThat(result.importedDraftsFolder).isEqualTo("importedDraftsFolder") + assertThat(result.importedSentFolder).isEqualTo("importedSentFolder") + assertThat(result.importedTrashFolder).isEqualTo("importedTrashFolder") + assertThat(result.importedArchiveFolder).isEqualTo("importedArchiveFolder") + assertThat(result.importedSpamFolder).isEqualTo("importedSpamFolder") + assertThat(result.inboxFolderId).isEqualTo(1) + assertThat(result.draftsFolderId).isEqualTo(3) + assertThat(result.sentFolderId).isEqualTo(4) + assertThat(result.trashFolderId).isEqualTo(5) + assertThat(result.archiveFolderId).isEqualTo(6) + assertThat(result.spamFolderId).isEqualTo(7) + assertThat(result.draftsFolderSelection).isEqualTo(SpecialFolderSelection.MANUAL) + assertThat(result.sentFolderSelection).isEqualTo(SpecialFolderSelection.MANUAL) + assertThat(result.trashFolderSelection).isEqualTo(SpecialFolderSelection.MANUAL) + assertThat(result.archiveFolderSelection).isEqualTo(SpecialFolderSelection.MANUAL) + assertThat(result.spamFolderSelection).isEqualTo(SpecialFolderSelection.MANUAL) + assertThat(result.importedAutoExpandFolder).isEqualTo("importedAutoExpandFolder") + assertThat(result.autoExpandFolderId).isEqualTo(8) + assertThat(result.folderDisplayMode).isEqualTo(FolderMode.FIRST_AND_SECOND_CLASS) + assertThat(result.folderSyncMode).isEqualTo(FolderMode.FIRST_AND_SECOND_CLASS) + assertThat(result.folderPushMode).isEqualTo(FolderMode.FIRST_AND_SECOND_CLASS) + assertThat(result.accountNumber).isEqualTo(11) + assertThat(result.isNotifySync).isEqualTo(true) + assertThat(result.sortType).isEqualTo(SortType.SORT_SUBJECT) + assertThat(result.sortAscending).isEqualTo( + mutableMapOf( + SortType.SORT_SUBJECT to false, + ), + ) + assertThat(result.showPictures).isEqualTo(ShowPictures.ALWAYS) + assertThat(result.isSignatureBeforeQuotedText).isEqualTo(true) + assertThat(result.expungePolicy).isEqualTo(Expunge.EXPUNGE_MANUALLY) + assertThat(result.maxPushFolders).isEqualTo(12) + assertThat(result.idleRefreshMinutes).isEqualTo(13) + assertThat(result.useCompression).isEqualTo(false) + assertThat(result.isSendClientInfoEnabled).isEqualTo(false) + assertThat(result.isSubscribedFoldersOnly).isEqualTo(false) + assertThat(result.maximumPolledMessageAge).isEqualTo(14) + assertThat(result.maximumAutoDownloadMessageSize).isEqualTo(15) + assertThat(result.messageFormat).isEqualTo(MessageFormat.TEXT) + assertThat(result.isMessageFormatAuto).isEqualTo(true) + assertThat(result.isMessageReadReceipt).isEqualTo(true) + assertThat(result.quoteStyle).isEqualTo(QuoteStyle.HEADER) + assertThat(result.quotePrefix).isEqualTo("quotePrefix") + assertThat(result.isDefaultQuotedTextShown).isEqualTo(true) + assertThat(result.isReplyAfterQuote).isEqualTo(true) + assertThat(result.isStripSignature).isEqualTo(true) + assertThat(result.isSyncRemoteDeletions).isEqualTo(true) + assertThat(result.openPgpProvider).isEqualTo("openPgpProvider") + assertThat(result.openPgpKey).isEqualTo(16) + assertThat(result.autocryptPreferEncryptMutual).isEqualTo(true) + assertThat(result.isOpenPgpHideSignOnly).isEqualTo(true) + assertThat(result.isOpenPgpEncryptSubject).isEqualTo(true) + assertThat(result.isOpenPgpEncryptAllDrafts).isEqualTo(true) + assertThat(result.isMarkMessageAsReadOnView).isEqualTo(true) + assertThat(result.isMarkMessageAsReadOnDelete).isEqualTo(true) + assertThat(result.isAlwaysShowCcBcc).isEqualTo(true) + assertThat(result.isRemoteSearchFullText).isEqualTo(false) + assertThat(result.remoteSearchNumResults).isEqualTo(17) + assertThat(result.isUploadSentMessages).isEqualTo(true) + assertThat(result.lastSyncTime).isEqualTo(18) + assertThat(result.lastFolderListRefreshTime).isEqualTo(19) + assertThat(result.isFinishedSetup).isEqualTo(true) + assertThat(result.messagesNotificationChannelVersion).isEqualTo(20) + assertThat(result.isChangedVisibleLimits).isEqualTo(true) + assertThat(result.lastSelectedFolderId).isEqualTo(21) + assertThat(result.notificationSettings).isEqualTo(defaultNotificationSettings) + assertThat(result.senderName).isEqualTo(defaultIdentities[0].name) + assertThat(result.signatureUse).isEqualTo(defaultIdentities[0].signatureUse) + assertThat(result.signature).isEqualTo(defaultIdentities[0].signature) + assertThat(result.shouldMigrateToOAuth).isEqualTo(true) + assertThat(result.folderPathDelimiter).isEqualTo(".") + } + + private companion object { + val defaultIsSensitiveDebugLoggingEnabled = { true } + + val defaultIncomingServerSettings = ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ) + + val defaultOutgoingServerSettings = ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "test", + password = "password", + clientCertificateAlias = null, + ) + + val defaultIdentities = mutableListOf( + Identity( + email = "demo@example.com", + name = "identityName", + signatureUse = true, + signature = "signature", + description = "Demo User", + ), + ) + + val defaultNotificationSettings = NotificationSettings() + + @Suppress("LongMethod") + fun createAccount(): LegacyAccount { + return LegacyAccount( + uuid = ACCOUNT_ID_RAW, + isSensitiveDebugLoggingEnabled = defaultIsSensitiveDebugLoggingEnabled, + ).apply { + identities = defaultIdentities + + // [BaseAccount] + name = "displayName" + email = "demo@example.com" + + // [AccountProfile] + chipColor = 0xFFFF0000.toInt() + avatar = AvatarDto( + avatarType = AvatarTypeDto.ICON, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = "star", + ) + + // Uncategorized + deletePolicy = DeletePolicy.SEVEN_DAYS + incomingServerSettings = defaultIncomingServerSettings + outgoingServerSettings = defaultOutgoingServerSettings + oAuthState = "oAuthState" + alwaysBcc = "alwaysBcc" + automaticCheckIntervalMinutes = 60 + displayCount = 10 + isNotifyNewMail = true + folderNotifyNewMailMode = FolderMode.FIRST_AND_SECOND_CLASS + isNotifySelfNewMail = true + isNotifyContactsMailOnly = true + isIgnoreChatMessages = true + legacyInboxFolder = "legacyInboxFolder" + importedDraftsFolder = "importedDraftsFolder" + importedSentFolder = "importedSentFolder" + importedTrashFolder = "importedTrashFolder" + importedArchiveFolder = "importedArchiveFolder" + importedSpamFolder = "importedSpamFolder" + inboxFolderId = 1 + draftsFolderId = 3 + sentFolderId = 4 + trashFolderId = 5 + archiveFolderId = 6 + spamFolderId = 7 + draftsFolderSelection = SpecialFolderSelection.MANUAL + sentFolderSelection = SpecialFolderSelection.MANUAL + trashFolderSelection = SpecialFolderSelection.MANUAL + archiveFolderSelection = SpecialFolderSelection.MANUAL + spamFolderSelection = SpecialFolderSelection.MANUAL + importedAutoExpandFolder = "importedAutoExpandFolder" + autoExpandFolderId = 8 + folderDisplayMode = FolderMode.FIRST_AND_SECOND_CLASS + folderSyncMode = FolderMode.FIRST_AND_SECOND_CLASS + folderPushMode = FolderMode.FIRST_AND_SECOND_CLASS + accountNumber = 11 + isNotifySync = true + sortType = SortType.SORT_SUBJECT + sortAscending = mutableMapOf( + SortType.SORT_SUBJECT to false, + ) + showPictures = ShowPictures.ALWAYS + isSignatureBeforeQuotedText = true + expungePolicy = Expunge.EXPUNGE_MANUALLY + maxPushFolders = 12 + idleRefreshMinutes = 13 + useCompression = false + isSendClientInfoEnabled = false + isSubscribedFoldersOnly = false + maximumPolledMessageAge = 14 + maximumAutoDownloadMessageSize = 15 + messageFormat = MessageFormat.TEXT + isMessageFormatAuto = true + isMessageReadReceipt = true + quoteStyle = QuoteStyle.HEADER + quotePrefix = "quotePrefix" + isDefaultQuotedTextShown = true + isReplyAfterQuote = true + isStripSignature = true + isSyncRemoteDeletions = true + openPgpProvider = "openPgpProvider" + openPgpKey = 16 + autocryptPreferEncryptMutual = true + isOpenPgpHideSignOnly = true + isOpenPgpEncryptSubject = true + isOpenPgpEncryptAllDrafts = true + isMarkMessageAsReadOnView = true + isMarkMessageAsReadOnDelete = true + isAlwaysShowCcBcc = true + isRemoteSearchFullText = false + remoteSearchNumResults = 17 + isUploadSentMessages = true + lastSyncTime = 18 + lastFolderListRefreshTime = 19 + isFinishedSetup = true + messagesNotificationChannelVersion = 20 + isChangedVisibleLimits = true + lastSelectedFolderId = 21 + notificationSettings = defaultNotificationSettings + senderName = defaultIdentities[0].name + signatureUse = defaultIdentities[0].signatureUse + signature = defaultIdentities[0].signature + shouldMigrateToOAuth = true + folderPathDelimiter = "." + } + } + + @Suppress("LongMethod") + fun createAccountWrapper(): LegacyAccountWrapper { + val id = AccountIdFactory.of(ACCOUNT_ID_RAW) + + return LegacyAccountWrapper( + isSensitiveDebugLoggingEnabled = defaultIsSensitiveDebugLoggingEnabled, + + // [Account] + id = id, + + // [BaseAccount] + name = "displayName", + email = "demo@example.com", + + // [AccountProfile] + profile = ProfileDto( + id = id, + name = "displayName", + color = 0xFFFF0000.toInt(), + avatar = AvatarDto( + avatarType = AvatarTypeDto.ICON, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = "star", + ), + ), + + // Uncategorized + deletePolicy = DeletePolicy.SEVEN_DAYS, + incomingServerSettings = defaultIncomingServerSettings, + outgoingServerSettings = defaultOutgoingServerSettings, + oAuthState = "oAuthState", + alwaysBcc = "alwaysBcc", + automaticCheckIntervalMinutes = 60, + displayCount = 10, + isNotifyNewMail = true, + folderNotifyNewMailMode = FolderMode.FIRST_AND_SECOND_CLASS, + isNotifySelfNewMail = true, + isNotifyContactsMailOnly = true, + isIgnoreChatMessages = true, + legacyInboxFolder = "legacyInboxFolder", + importedDraftsFolder = "importedDraftsFolder", + importedSentFolder = "importedSentFolder", + importedTrashFolder = "importedTrashFolder", + importedArchiveFolder = "importedArchiveFolder", + importedSpamFolder = "importedSpamFolder", + inboxFolderId = 1, + draftsFolderId = 3, + sentFolderId = 4, + trashFolderId = 5, + archiveFolderId = 6, + spamFolderId = 7, + draftsFolderSelection = SpecialFolderSelection.MANUAL, + sentFolderSelection = SpecialFolderSelection.MANUAL, + trashFolderSelection = SpecialFolderSelection.MANUAL, + archiveFolderSelection = SpecialFolderSelection.MANUAL, + spamFolderSelection = SpecialFolderSelection.MANUAL, + importedAutoExpandFolder = "importedAutoExpandFolder", + autoExpandFolderId = 8, + folderDisplayMode = FolderMode.FIRST_AND_SECOND_CLASS, + folderSyncMode = FolderMode.FIRST_AND_SECOND_CLASS, + folderPushMode = FolderMode.FIRST_AND_SECOND_CLASS, + accountNumber = 11, + isNotifySync = true, + sortType = SortType.SORT_SUBJECT, + sortAscending = mutableMapOf( + SortType.SORT_SUBJECT to false, + ), + showPictures = ShowPictures.ALWAYS, + isSignatureBeforeQuotedText = true, + expungePolicy = Expunge.EXPUNGE_MANUALLY, + maxPushFolders = 12, + idleRefreshMinutes = 13, + useCompression = false, + isSendClientInfoEnabled = false, + isSubscribedFoldersOnly = false, + maximumPolledMessageAge = 14, + maximumAutoDownloadMessageSize = 15, + messageFormat = MessageFormat.TEXT, + isMessageFormatAuto = true, + isMessageReadReceipt = true, + quoteStyle = QuoteStyle.HEADER, + quotePrefix = "quotePrefix", + isDefaultQuotedTextShown = true, + isReplyAfterQuote = true, + isStripSignature = true, + isSyncRemoteDeletions = true, + openPgpProvider = "openPgpProvider", + openPgpKey = 16, + autocryptPreferEncryptMutual = true, + isOpenPgpHideSignOnly = true, + isOpenPgpEncryptSubject = true, + isOpenPgpEncryptAllDrafts = true, + isMarkMessageAsReadOnView = true, + isMarkMessageAsReadOnDelete = true, + isAlwaysShowCcBcc = true, + isRemoteSearchFullText = false, + remoteSearchNumResults = 17, + isUploadSentMessages = true, + lastSyncTime = 18, + lastFolderListRefreshTime = 19, + isFinishedSetup = true, + messagesNotificationChannelVersion = 20, + isChangedVisibleLimits = true, + lastSelectedFolderId = 21, + identities = defaultIdentities, + notificationSettings = defaultNotificationSettings, + senderName = defaultIdentities[0].name, + signatureUse = defaultIdentities[0].signatureUse, + signature = defaultIdentities[0].signature, + shouldMigrateToOAuth = true, + folderPathDelimiter = ".", + ) + } + } +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/FakeAccountAvatarDataMapper.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/FakeAccountAvatarDataMapper.kt new file mode 100644 index 0000000..cff2d12 --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/FakeAccountAvatarDataMapper.kt @@ -0,0 +1,14 @@ +package net.thunderbird.feature.account.storage.legacy.mapper + +import net.thunderbird.feature.account.profile.AccountAvatar +import net.thunderbird.feature.account.storage.mapper.AccountAvatarDataMapper +import net.thunderbird.feature.account.storage.profile.AvatarDto + +class FakeAccountAvatarDataMapper( + private val dto: AvatarDto, + private val domain: AccountAvatar, +) : AccountAvatarDataMapper { + override fun toDomain(dto: AvatarDto): AccountAvatar = domain + + override fun toDto(domain: AccountAvatar): AvatarDto = dto +} diff --git a/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/serializer/ServerSettingsSerializerTest.kt b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/serializer/ServerSettingsSerializerTest.kt new file mode 100644 index 0000000..630bf0f --- /dev/null +++ b/feature/account/storage/legacy/src/test/kotlin/net/thunderbird/feature/account/storage/legacy/serializer/ServerSettingsSerializerTest.kt @@ -0,0 +1,75 @@ +package net.thunderbird.feature.account.storage.legacy.serializer + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.store.imap.ImapStoreSettings +import kotlin.test.Test + +class ServerSettingsSerializerTest { + private val serverSettingsDtoSerializer = ServerSettingsDtoSerializer() + + @Test + fun `serialize and deserialize IMAP server settings`() { + val serverSettings = ServerSettings( + type = "imap", + host = "imap.domain.example", + port = 143, + connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = null, + clientCertificateAlias = "alias", + extra = ImapStoreSettings.createExtra(autoDetectNamespace = true, pathPrefix = null), + ) + + val json = serverSettingsDtoSerializer.serialize(serverSettings) + val deserializedServerSettings = serverSettingsDtoSerializer.deserialize(json) + + assertThat(deserializedServerSettings).isEqualTo(serverSettings) + } + + @Test + fun `serialize and deserialize POP3 server settings`() { + val serverSettings = ServerSettings( + type = "pop3", + host = "pop3.domain.example", + port = 995, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "password", + clientCertificateAlias = null, + ) + + val json = serverSettingsDtoSerializer.serialize(serverSettings) + val deserializedServerSettings = serverSettingsDtoSerializer.deserialize(json) + + assertThat(deserializedServerSettings).isEqualTo(serverSettings) + } + + @Test + fun `deserialize JSON with missing type`() { + val json = """ + { + "host": "imap.domain.example", + "port": 993, + "connectionSecurity": "SSL_TLS_REQUIRED", + "authenticationType": "PLAIN", + "username": "user", + "password": "pass", + "clientCertificateAlias": null + } + """.trimIndent() + + assertFailure { + serverSettingsDtoSerializer.deserialize(json) + }.isInstanceOf() + .hasMessage("'type' must not be missing") + } +} diff --git a/feature/autodiscovery/api/build.gradle.kts b/feature/autodiscovery/api/build.gradle.kts new file mode 100644 index 0000000..350629b --- /dev/null +++ b/feature/autodiscovery/api/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.mail.common) + api(projects.core.common) +} diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AuthenticationType.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AuthenticationType.kt new file mode 100644 index 0000000..8b070af --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AuthenticationType.kt @@ -0,0 +1,13 @@ +package app.k9mail.autodiscovery.api + +/** + * The authentication types supported when using the [AutoDiscovery] mechanism. + * + * Note: Currently we support the same set of values in [ImapServerSettings] and [SmtpServerSettings]. As soon as this + * changes, this type should be replaced with `ImapAuthenticationType` and `SmtpAuthenticationType`. + */ +enum class AuthenticationType { + PasswordCleartext, + PasswordEncrypted, + OAuth2, +} diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscovery.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscovery.kt new file mode 100644 index 0000000..aa45d0a --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscovery.kt @@ -0,0 +1,13 @@ +package app.k9mail.autodiscovery.api + +import net.thunderbird.core.common.mail.EmailAddress + +/** + * Provides a mechanism to find mail server settings for a given email address. + */ +interface AutoDiscovery { + /** + * Returns a list of [AutoDiscoveryRunnable]s that perform the actual mail server settings discovery. + */ + fun initDiscovery(email: EmailAddress): List +} diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryRegistry.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryRegistry.kt new file mode 100644 index 0000000..097cd44 --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryRegistry.kt @@ -0,0 +1,5 @@ +package app.k9mail.autodiscovery.api + +interface AutoDiscoveryRegistry { + fun getAutoDiscoveries(): List +} diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryResult.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryResult.kt new file mode 100644 index 0000000..66913d0 --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryResult.kt @@ -0,0 +1,61 @@ +package app.k9mail.autodiscovery.api + +import java.io.IOException + +/** + * Results of a mail server settings lookup. + */ +sealed interface AutoDiscoveryResult { + /** + * Mail server settings found during the lookup. + */ + data class Settings( + val incomingServerSettings: IncomingServerSettings, + val outgoingServerSettings: OutgoingServerSettings, + + /** + * Indicates whether the mail server settings lookup was using only trusted channels. + * + * `true` if the settings lookup was only using trusted channels, e.g. lookup via HTTPS where the server + * presented a trusted certificate. `false´ otherwise. + * + * IMPORTANT: When this value is `false`, the settings should be presented to the user and only be used after + * the user has given consent. + */ + val isTrusted: Boolean, + + /** + * String describing the source of the server settings. Use a URI if possible. + */ + val source: String, + ) : AutoDiscoveryResult + + /** + * No usable mail server settings were found. + */ + object NoUsableSettingsFound : AutoDiscoveryResult + + /** + * A network error occurred while looking for mail server settings. + */ + data class NetworkError(val exception: IOException) : AutoDiscoveryResult + + /** + * Encountered an unexpected exception when looking up mail server settings. + */ + data class UnexpectedException(val exception: Exception) : AutoDiscoveryResult +} + +/** + * Incoming mail server settings. + * + * Implementations contain protocol-specific properties. + */ +interface IncomingServerSettings + +/** + * Outgoing mail server settings. + * + * Implementations contain protocol-specific properties. + */ +interface OutgoingServerSettings diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryRunnable.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryRunnable.kt new file mode 100644 index 0000000..b7b592c --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryRunnable.kt @@ -0,0 +1,10 @@ +package app.k9mail.autodiscovery.api + +/** + * Performs a mail server settings lookup. + * + * This is an abstraction that allows us to run multiple lookups in parallel. + */ +fun interface AutoDiscoveryRunnable { + suspend fun run(): AutoDiscoveryResult +} diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryService.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryService.kt new file mode 100644 index 0000000..443c65a --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/AutoDiscoveryService.kt @@ -0,0 +1,10 @@ +package app.k9mail.autodiscovery.api + +import net.thunderbird.core.common.mail.EmailAddress + +/** + * Tries to find mail server settings for a given email address. + */ +interface AutoDiscoveryService { + suspend fun discover(email: EmailAddress): AutoDiscoveryResult +} diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/ConnectionSecurity.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/ConnectionSecurity.kt new file mode 100644 index 0000000..ffe299a --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/ConnectionSecurity.kt @@ -0,0 +1,9 @@ +package app.k9mail.autodiscovery.api + +/** + * The connection security methods supported when using the [AutoDiscovery] mechanism. + */ +enum class ConnectionSecurity { + StartTLS, + TLS, +} diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/ImapServerSettings.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/ImapServerSettings.kt new file mode 100644 index 0000000..8be23f2 --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/ImapServerSettings.kt @@ -0,0 +1,12 @@ +package app.k9mail.autodiscovery.api + +import net.thunderbird.core.common.net.Hostname +import net.thunderbird.core.common.net.Port + +data class ImapServerSettings( + val hostname: Hostname, + val port: Port, + val connectionSecurity: ConnectionSecurity, + val authenticationTypes: List, + val username: String, +) : IncomingServerSettings diff --git a/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/SmtpServerSettings.kt b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/SmtpServerSettings.kt new file mode 100644 index 0000000..dfdb373 --- /dev/null +++ b/feature/autodiscovery/api/src/main/kotlin/app/k9mail/autodiscovery/api/SmtpServerSettings.kt @@ -0,0 +1,12 @@ +package app.k9mail.autodiscovery.api + +import net.thunderbird.core.common.net.Hostname +import net.thunderbird.core.common.net.Port + +data class SmtpServerSettings( + val hostname: Hostname, + val port: Port, + val connectionSecurity: ConnectionSecurity, + val authenticationTypes: List, + val username: String, +) : OutgoingServerSettings diff --git a/feature/autodiscovery/autoconfig/build.gradle.kts b/feature/autodiscovery/autoconfig/build.gradle.kts new file mode 100644 index 0000000..643c8e7 --- /dev/null +++ b/feature/autodiscovery/autoconfig/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.feature.autodiscovery.api) + api(libs.okhttp) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.minidns.hla) + compileOnly(libs.xmlpull) + + testImplementation(projects.core.logging.testing) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.kxml2) + testImplementation(libs.jsoup) + testImplementation(libs.okhttp.mockwebserver) +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscovery.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscovery.kt new file mode 100644 index 0000000..6804350 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscovery.kt @@ -0,0 +1,49 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AutoDiscovery +import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.mail.toDomain +import okhttp3.OkHttpClient + +class AutoconfigDiscovery internal constructor( + private val urlProvider: AutoconfigUrlProvider, + private val autoconfigFetcher: AutoconfigFetcher, +) : AutoDiscovery { + + override fun initDiscovery(email: EmailAddress): List { + val domain = email.domain.toDomain() + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email) + + return autoconfigUrls.map { autoconfigUrl -> + AutoDiscoveryRunnable { + autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email) + } + } + } +} + +fun createProviderAutoconfigDiscovery( + okHttpClient: OkHttpClient, + config: AutoconfigUrlConfig, +): AutoconfigDiscovery { + val urlProvider = ProviderAutoconfigUrlProvider(config) + return createAutoconfigDiscovery(okHttpClient, urlProvider) +} + +fun createIspDbAutoconfigDiscovery(okHttpClient: OkHttpClient): AutoconfigDiscovery { + val urlProvider = IspDbAutoconfigUrlProvider() + return createAutoconfigDiscovery(okHttpClient, urlProvider) +} + +private fun createAutoconfigDiscovery( + okHttpClient: OkHttpClient, + urlProvider: AutoconfigUrlProvider, +): AutoconfigDiscovery { + val autoconfigFetcher = RealAutoconfigFetcher( + fetcher = OkHttpFetcher(okHttpClient), + parser = SuspendableAutoconfigParser(RealAutoconfigParser()), + ) + return AutoconfigDiscovery(urlProvider, autoconfigFetcher) +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigFetcher.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigFetcher.kt new file mode 100644 index 0000000..c5132ee --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigFetcher.kt @@ -0,0 +1,12 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import net.thunderbird.core.common.mail.EmailAddress +import okhttp3.HttpUrl + +/** + * Fetches and parses Autoconfig settings. + */ +internal interface AutoconfigFetcher { + suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParser.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParser.kt new file mode 100644 index 0000000..f0f993d --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParser.kt @@ -0,0 +1,13 @@ +package app.k9mail.autodiscovery.autoconfig + +import java.io.InputStream +import net.thunderbird.core.common.mail.EmailAddress + +/** + * Parser for Thunderbird's Autoconfig file format. + * + * See [https://github.com/thunderbird/autoconfig](https://github.com/thunderbird/autoconfig) + */ +internal interface AutoconfigParser { + fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoconfigParserResult +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParserException.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParserException.kt new file mode 100644 index 0000000..0009c6b --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParserException.kt @@ -0,0 +1,3 @@ +package app.k9mail.autodiscovery.autoconfig + +class AutoconfigParserException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParserResult.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParserResult.kt new file mode 100644 index 0000000..4abe033 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigParserResult.kt @@ -0,0 +1,27 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.autodiscovery.api.OutgoingServerSettings + +/** + * Result type for [AutoconfigParser]. + */ +internal sealed interface AutoconfigParserResult { + /** + * Server settings extracted from the Autoconfig XML. + */ + data class Settings( + val incomingServerSettings: List, + val outgoingServerSettings: List, + ) : AutoconfigParserResult { + init { + require(incomingServerSettings.isNotEmpty()) + require(outgoingServerSettings.isNotEmpty()) + } + } + + /** + * Server settings couldn't be extracted. + */ + data class ParserError(val error: AutoconfigParserException) : AutoconfigParserResult +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigUrlProvider.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigUrlProvider.kt new file mode 100644 index 0000000..9cc5a42 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigUrlProvider.kt @@ -0,0 +1,9 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.Domain +import okhttp3.HttpUrl + +internal interface AutoconfigUrlProvider { + fun getAutoconfigUrls(domain: Domain, email: EmailAddress? = null): List +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/BaseDomainExtractor.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/BaseDomainExtractor.kt new file mode 100644 index 0000000..7445568 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/BaseDomainExtractor.kt @@ -0,0 +1,12 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain + +/** + * Extract the base domain from a host name. + * + * An implementation needs to respect the [Public Suffix List](https://publicsuffix.org/). + */ +internal interface BaseDomainExtractor { + fun extractBaseDomain(domain: Domain): Domain +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/HttpFetchResult.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/HttpFetchResult.kt new file mode 100644 index 0000000..c793225 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/HttpFetchResult.kt @@ -0,0 +1,26 @@ +package app.k9mail.autodiscovery.autoconfig + +import java.io.InputStream + +/** + * Result type for [HttpFetcher]. + */ +internal sealed interface HttpFetchResult { + /** + * The HTTP request returned a success response. + * + * @param inputStream Contains the response body. + * @param isTrusted `true` iff all associated requests were using HTTPS with a trusted certificate. + */ + data class SuccessResponse( + val inputStream: InputStream, + val isTrusted: Boolean, + ) : HttpFetchResult + + /** + * The server returned an error response. + * + * @param code The HTTP status code. + */ + data class ErrorResponse(val code: Int) : HttpFetchResult +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/HttpFetcher.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/HttpFetcher.kt new file mode 100644 index 0000000..66cd98c --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/HttpFetcher.kt @@ -0,0 +1,7 @@ +package app.k9mail.autodiscovery.autoconfig + +import okhttp3.HttpUrl + +internal interface HttpFetcher { + suspend fun fetch(url: HttpUrl): HttpFetchResult +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/IspDbAutoconfigUrlProvider.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/IspDbAutoconfigUrlProvider.kt new file mode 100644 index 0000000..580557d --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/IspDbAutoconfigUrlProvider.kt @@ -0,0 +1,20 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.Domain +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +internal class IspDbAutoconfigUrlProvider : AutoconfigUrlProvider { + override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List { + return listOf(createIspDbUrl(domain)) + } + + private fun createIspDbUrl(domain: Domain): HttpUrl { + // https://autoconfig.thunderbird.net/v1.1/{domain} + return "https://autoconfig.thunderbird.net/v1.1/".toHttpUrl() + .newBuilder() + .addPathSegment(domain.value) + .build() + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MiniDnsMxResolver.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MiniDnsMxResolver.kt new file mode 100644 index 0000000..8397d60 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MiniDnsMxResolver.kt @@ -0,0 +1,21 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain +import net.thunderbird.core.common.net.toDomainOrNull +import org.minidns.hla.DnssecResolverApi +import org.minidns.record.MX + +internal class MiniDnsMxResolver : MxResolver { + override fun lookup(domain: Domain): MxLookupResult { + val result = DnssecResolverApi.INSTANCE.resolve(domain.value, MX::class.java) + + val mxNames = result.answersOrEmptySet + .sortedBy { it.priority } + .mapNotNull { it.target.toString().toDomainOrNull() } + + return MxLookupResult( + mxNames = mxNames, + isTrusted = if (result.wasSuccessful()) result.isAuthenticData else false, + ) + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscovery.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscovery.kt new file mode 100644 index 0000000..d22bd44 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscovery.kt @@ -0,0 +1,104 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AutoDiscovery +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings +import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable +import java.io.IOException +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.mail.toDomain +import net.thunderbird.core.common.net.Domain +import net.thunderbird.core.logging.legacy.Log +import okhttp3.OkHttpClient +import org.minidns.dnsname.InvalidDnsNameException + +class MxLookupAutoconfigDiscovery internal constructor( + private val mxResolver: SuspendableMxResolver, + private val baseDomainExtractor: BaseDomainExtractor, + private val subDomainExtractor: SubDomainExtractor, + private val urlProvider: AutoconfigUrlProvider, + private val autoconfigFetcher: AutoconfigFetcher, +) : AutoDiscovery { + + override fun initDiscovery(email: EmailAddress): List { + return listOf( + AutoDiscoveryRunnable { + mxLookupAutoconfig(email) + }, + ) + } + + @Suppress("ReturnCount") + private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult { + val domain = email.domain.toDomain() + + val mxLookupResult = mxLookup(domain) ?: return NoUsableSettingsFound + val mxHostName = mxLookupResult.mxNames.first() + + val mxBaseDomain = getMxBaseDomain(mxHostName) + if (mxBaseDomain == domain) { + // Exit early to match Thunderbird's behavior. + return NoUsableSettingsFound + } + + // In addition to just the base domain, also check the MX hostname without the first label to differentiate + // between Outlook.com/Hotmail and Office365 business domains. + val mxSubDomain = getNextSubDomain(mxHostName)?.takeIf { it != mxBaseDomain } + + var latestResult: AutoDiscoveryResult = NoUsableSettingsFound + for (domainToCheck in listOfNotNull(mxSubDomain, mxBaseDomain)) { + for (autoconfigUrl in urlProvider.getAutoconfigUrls(domainToCheck, email)) { + val discoveryResult = autoconfigFetcher.fetchAutoconfig(autoconfigUrl, email) + if (discoveryResult is Settings) { + return discoveryResult.copy( + isTrusted = mxLookupResult.isTrusted && discoveryResult.isTrusted, + ) + } + + latestResult = discoveryResult + } + } + + return latestResult + } + + private suspend fun mxLookup(domain: Domain): MxLookupResult? { + // Only return the most preferred entry to match Thunderbird's behavior. + return try { + mxResolver.lookup(domain).takeIf { it.mxNames.isNotEmpty() } + } catch (e: IOException) { + Log.d(e, "Failed to get MX record for domain: %s", domain.value) + null + } catch (e: InvalidDnsNameException) { + Log.d(e, "Invalid DNS name for domain: %s", domain.value) + null + } + } + + private fun getMxBaseDomain(mxHostName: Domain): Domain { + return baseDomainExtractor.extractBaseDomain(mxHostName) + } + + private fun getNextSubDomain(domain: Domain): Domain? { + return subDomainExtractor.extractSubDomain(domain) + } +} + +fun createMxLookupAutoconfigDiscovery( + okHttpClient: OkHttpClient, + config: AutoconfigUrlConfig, +): MxLookupAutoconfigDiscovery { + val baseDomainExtractor = OkHttpBaseDomainExtractor() + val autoconfigFetcher = RealAutoconfigFetcher( + fetcher = OkHttpFetcher(okHttpClient), + parser = SuspendableAutoconfigParser(RealAutoconfigParser()), + ) + return MxLookupAutoconfigDiscovery( + mxResolver = SuspendableMxResolver(MiniDnsMxResolver()), + baseDomainExtractor = baseDomainExtractor, + subDomainExtractor = RealSubDomainExtractor(baseDomainExtractor), + urlProvider = createPostMxLookupAutoconfigUrlProvider(config), + autoconfigFetcher = autoconfigFetcher, + ) +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupResult.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupResult.kt new file mode 100644 index 0000000..318d5d1 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupResult.kt @@ -0,0 +1,14 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain + +/** + * Result for [MxResolver]. + * + * @param mxNames The hostnames from the MX records. + * @param isTrusted `true` iff the results were properly signed (DNSSEC) or were retrieved using a secure channel. + */ +data class MxLookupResult( + val mxNames: List, + val isTrusted: Boolean, +) diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxResolver.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxResolver.kt new file mode 100644 index 0000000..47d5c48 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxResolver.kt @@ -0,0 +1,10 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain + +/** + * Look up MX records for a domain. + */ +internal interface MxResolver { + fun lookup(domain: Domain): MxLookupResult +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpBaseDomainExtractor.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpBaseDomainExtractor.kt new file mode 100644 index 0000000..fd8b52a --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpBaseDomainExtractor.kt @@ -0,0 +1,18 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain +import net.thunderbird.core.common.net.toDomain +import okhttp3.HttpUrl + +internal class OkHttpBaseDomainExtractor : BaseDomainExtractor { + override fun extractBaseDomain(domain: Domain): Domain { + return domain.value.toHttpUrlOrNull().topPrivateDomain()?.toDomain() ?: domain + } + + private fun String.toHttpUrlOrNull(): HttpUrl { + return HttpUrl.Builder() + .scheme("https") + .host(this) + .build() + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpFetcher.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpFetcher.kt new file mode 100644 index 0000000..1526b8b --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpFetcher.kt @@ -0,0 +1,63 @@ +package app.k9mail.autodiscovery.autoconfig + +import java.io.IOException +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request.Builder +import okhttp3.Response + +internal class OkHttpFetcher( + private val okHttpClient: OkHttpClient, +) : HttpFetcher { + + override suspend fun fetch(url: HttpUrl): HttpFetchResult { + return suspendCancellableCoroutine { cancellableContinuation -> + val request = Builder() + .url(url) + .build() + + val call = okHttpClient.newCall(request) + + cancellableContinuation.invokeOnCancellation { + call.cancel() + } + + val responseCallback = object : Callback { + override fun onFailure(call: Call, e: IOException) { + cancellableContinuation.cancel(e) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val result = HttpFetchResult.SuccessResponse( + inputStream = response.body!!.byteStream(), + isTrusted = response.isTrusted(), + ) + cancellableContinuation.resume(result) + } else { + // We don't care about the body of error responses. + response.close() + + val result = HttpFetchResult.ErrorResponse(response.code) + cancellableContinuation.resume(result) + } + } + } + + call.enqueue(responseCallback) + } + } + + private tailrec fun Response.isTrusted(): Boolean { + val priorResponse = priorResponse + return when { + !request.isHttps -> false + priorResponse == null -> true + else -> priorResponse.isTrusted() + } + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/PostMxLookupAutoconfigUrlProvider.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/PostMxLookupAutoconfigUrlProvider.kt new file mode 100644 index 0000000..e1b233a --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/PostMxLookupAutoconfigUrlProvider.kt @@ -0,0 +1,39 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.Domain +import okhttp3.HttpUrl + +internal class PostMxLookupAutoconfigUrlProvider( + private val ispDbUrlProvider: AutoconfigUrlProvider, + private val config: AutoconfigUrlConfig, +) : AutoconfigUrlProvider { + override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List { + return buildList { + add(createProviderUrl(domain, email)) + addAll(ispDbUrlProvider.getAutoconfigUrls(domain, email)) + } + } + + private fun createProviderUrl(domain: Domain, email: EmailAddress?): HttpUrl { + // After an MX lookup only the following provider URL is checked: + // https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email} + return HttpUrl.Builder() + .scheme("https") + .host("autoconfig.${domain.value}") + .addEncodedPathSegments("mail/config-v1.1.xml") + .apply { + if (email != null && config.includeEmailAddress) { + addQueryParameter("emailaddress", email.address) + } + } + .build() + } +} + +internal fun createPostMxLookupAutoconfigUrlProvider(config: AutoconfigUrlConfig): AutoconfigUrlProvider { + return PostMxLookupAutoconfigUrlProvider( + ispDbUrlProvider = IspDbAutoconfigUrlProvider(), + config = config, + ) +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/ProviderAutoconfigUrlProvider.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/ProviderAutoconfigUrlProvider.kt new file mode 100644 index 0000000..d4b333f --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/ProviderAutoconfigUrlProvider.kt @@ -0,0 +1,54 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.Domain +import okhttp3.HttpUrl + +internal class ProviderAutoconfigUrlProvider(private val config: AutoconfigUrlConfig) : AutoconfigUrlProvider { + override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List { + return buildList { + add(createProviderUrl(domain, email, useHttps = true)) + add(createDomainUrl(domain, email, useHttps = true)) + + if (!config.httpsOnly) { + add(createProviderUrl(domain, email, useHttps = false)) + add(createDomainUrl(domain, email, useHttps = false)) + } + } + } + + private fun createProviderUrl(domain: Domain, email: EmailAddress?, useHttps: Boolean): HttpUrl { + // https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email} + // http://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email} + return HttpUrl.Builder() + .scheme(if (useHttps) "https" else "http") + .host("autoconfig.${domain.value}") + .addEncodedPathSegments("mail/config-v1.1.xml") + .apply { + if (email != null && config.includeEmailAddress) { + addQueryParameter("emailaddress", email.address) + } + } + .build() + } + + private fun createDomainUrl(domain: Domain, email: EmailAddress?, useHttps: Boolean): HttpUrl { + // https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email} + // http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email} + return HttpUrl.Builder() + .scheme(if (useHttps) "https" else "http") + .host(domain.value) + .addEncodedPathSegments(".well-known/autoconfig/mail/config-v1.1.xml") + .apply { + if (email != null && config.includeEmailAddress) { + addQueryParameter("emailaddress", email.address) + } + } + .build() + } +} + +data class AutoconfigUrlConfig( + val httpsOnly: Boolean, + val includeEmailAddress: Boolean, +) diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigFetcher.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigFetcher.kt new file mode 100644 index 0000000..36c58c8 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigFetcher.kt @@ -0,0 +1,56 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.ParserError +import app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.Settings +import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.ErrorResponse +import app.k9mail.autodiscovery.autoconfig.HttpFetchResult.SuccessResponse +import java.io.IOException +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.logging.legacy.Log +import okhttp3.HttpUrl + +internal class RealAutoconfigFetcher( + private val fetcher: HttpFetcher, + private val parser: SuspendableAutoconfigParser, +) : AutoconfigFetcher { + override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult { + return try { + when (val fetchResult = fetcher.fetch(autoconfigUrl)) { + is SuccessResponse -> { + parseSettings(fetchResult, email, autoconfigUrl) + } + is ErrorResponse -> AutoDiscoveryResult.NoUsableSettingsFound + } + } catch (e: IOException) { + Log.d(e, "Error fetching Autoconfig from URL: %s", autoconfigUrl) + AutoDiscoveryResult.NetworkError(e) + } + } + + private suspend fun parseSettings( + fetchResult: SuccessResponse, + email: EmailAddress, + autoconfigUrl: HttpUrl, + ): AutoDiscoveryResult { + return try { + fetchResult.inputStream.use { inputStream -> + return when (val parserResult = parser.parseSettings(inputStream, email)) { + is Settings -> { + AutoDiscoveryResult.Settings( + incomingServerSettings = parserResult.incomingServerSettings.first(), + outgoingServerSettings = parserResult.outgoingServerSettings.first(), + isTrusted = fetchResult.isTrusted, + source = autoconfigUrl.toString(), + ) + } + + is ParserError -> AutoDiscoveryResult.NoUsableSettingsFound + } + } + } catch (e: AutoconfigParserException) { + Log.d(e, "Failed to parse config from URL: %s", autoconfigUrl) + AutoDiscoveryResult.NoUsableSettingsFound + } + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParser.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParser.kt new file mode 100644 index 0000000..6e35f4a --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParser.kt @@ -0,0 +1,324 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AuthenticationType +import app.k9mail.autodiscovery.api.AuthenticationType.OAuth2 +import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext +import app.k9mail.autodiscovery.api.AuthenticationType.PasswordEncrypted +import app.k9mail.autodiscovery.api.ConnectionSecurity +import app.k9mail.autodiscovery.api.ConnectionSecurity.StartTLS +import app.k9mail.autodiscovery.api.ConnectionSecurity.TLS +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.autodiscovery.api.OutgoingServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import java.io.InputStream +import java.io.InputStreamReader +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.HostNameUtils +import net.thunderbird.core.common.net.Hostname +import net.thunderbird.core.common.net.Port +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort +import net.thunderbird.core.logging.legacy.Log +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import org.xmlpull.v1.XmlPullParserFactory + +private typealias ServerSettingsFactory = ( + hostname: Hostname, + port: Port, + connectionSecurity: ConnectionSecurity, + authenticationTypes: List, + username: String, +) -> T + +internal class RealAutoconfigParser : AutoconfigParser { + override fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoconfigParserResult { + return try { + ClientConfigParser(inputStream, email).parse() + } catch (e: XmlPullParserException) { + AutoconfigParserResult.ParserError(error = AutoconfigParserException("Error parsing Autoconfig XML", e)) + } catch (e: AutoconfigParserException) { + AutoconfigParserResult.ParserError(e) + } + } +} + +@Suppress("TooManyFunctions") +private class ClientConfigParser( + private val inputStream: InputStream, + private val email: EmailAddress, +) { + private val localPart = email.localPart + private val domain = email.domain.normalized + + private val pullParser: XmlPullParser = XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(InputStreamReader(inputStream)) + } + + fun parse(): AutoconfigParserResult { + var result: AutoconfigParserResult? = null + do { + val eventType = pullParser.next() + + if (eventType == XmlPullParser.START_TAG) { + when (pullParser.name) { + "clientConfig" -> { + result = parseClientConfig() + } + else -> skipElement() + } + } + } while (eventType != XmlPullParser.END_DOCUMENT) + + if (result == null) { + parserError("Missing 'clientConfig' element") + } + + return result + } + + private fun parseClientConfig(): AutoconfigParserResult { + var result: AutoconfigParserResult? = null + + readElement { eventType -> + if (eventType == XmlPullParser.START_TAG) { + when (pullParser.name) { + "emailProvider" -> { + result = parseEmailProvider() + } + else -> skipElement() + } + } + } + + return result ?: parserError("Missing 'emailProvider' element") + } + + private fun parseEmailProvider(): AutoconfigParserResult { + var domainFound = false + val incomingServerSettings = mutableListOf() + val outgoingServerSettings = mutableListOf() + + // The 'id' attribute is required (but not really used) by Thunderbird desktop. + val emailProviderId = pullParser.getAttributeValue(null, "id") + if (emailProviderId == null) { + parserError("Missing 'emailProvider.id' attribute") + } else if (!emailProviderId.isValidHostname()) { + parserError("Invalid 'emailProvider.id' attribute") + } + + readElement { eventType -> + if (eventType == XmlPullParser.START_TAG) { + when (pullParser.name) { + "domain" -> { + val domain = readText().replaceVariables() + if (domain.isValidHostname()) { + domainFound = true + } + } + "incomingServer" -> { + parseServer("imap", ::createImapServerSettings)?.let { serverSettings -> + incomingServerSettings.add(serverSettings) + } + } + "outgoingServer" -> { + parseServer("smtp", ::createSmtpServerSettings)?.let { serverSettings -> + outgoingServerSettings.add(serverSettings) + } + } + else -> { + skipElement() + } + } + } + } + + // Thunderbird desktop requires at least one valid 'domain' element. + if (!domainFound) { + parserError("Valid 'domain' element required") + } + + if (incomingServerSettings.isEmpty()) { + parserError("No supported 'incomingServer' element found") + } + + if (outgoingServerSettings.isEmpty()) { + parserError("No supported 'outgoingServer' element found") + } + + return AutoconfigParserResult.Settings( + incomingServerSettings = incomingServerSettings, + outgoingServerSettings = outgoingServerSettings, + ) + } + + private fun parseServer( + protocolType: String, + createServerSettings: ServerSettingsFactory, + ): T? { + val type = pullParser.getAttributeValue(null, "type") + if (type != protocolType) { + Log.d("Unsupported '%s[type]' value: '%s'", pullParser.name, type) + skipElement() + return null + } + + var hostname: String? = null + var port: Int? = null + var userName: String? = null + val authenticationTypes = mutableListOf() + var connectionSecurity: ConnectionSecurity? = null + + readElement { eventType -> + if (eventType == XmlPullParser.START_TAG) { + when (pullParser.name) { + "hostname" -> hostname = readHostname() + "port" -> port = readPort() + "username" -> userName = readUsername() + "authentication" -> readAuthentication(authenticationTypes) + "socketType" -> connectionSecurity = readSocketType() + } + } + } + + val finalHostname = hostname ?: parserError("Missing 'hostname' element") + val finalPort = port ?: parserError("Missing 'port' element") + val finalUserName = userName ?: parserError("Missing 'username' element") + val finalAuthenticationTypes = if (authenticationTypes.isNotEmpty()) { + authenticationTypes.toList() + } else { + parserError("No usable 'authentication' element found") + } + val finalConnectionSecurity = connectionSecurity ?: parserError("Missing 'socketType' element") + + return createServerSettings( + finalHostname.toHostname(), + finalPort.toPort(), + finalConnectionSecurity, + finalAuthenticationTypes, + finalUserName, + ) + } + + private fun readHostname(): String { + val hostNameText = readText() + val hostName = hostNameText.replaceVariables() + return hostName.takeIf { it.isValidHostname() } + ?: parserError("Invalid 'hostname' value: '$hostNameText'") + } + + private fun readPort(): Int { + val portText = readText() + return portText.toIntOrNull()?.takeIf { it.isValidPort() } + ?: parserError("Invalid 'port' value: '$portText'") + } + + private fun readUsername(): String = readText().replaceVariables() + + private fun readAuthentication(authenticationTypes: MutableList) { + val authenticationType = readText().toAuthenticationType() ?: return + authenticationTypes.add(authenticationType) + } + + private fun readSocketType() = readText().toConnectionSecurity() + + private fun String.toAuthenticationType(): AuthenticationType? { + return when (this) { + "OAuth2" -> OAuth2 + "password-cleartext" -> PasswordCleartext + "password-encrypted" -> PasswordEncrypted + else -> { + Log.d("Ignoring unknown 'authentication' value '$this'") + null + } + } + } + + private fun String.toConnectionSecurity(): ConnectionSecurity { + return when (this) { + "SSL" -> TLS + "STARTTLS" -> StartTLS + else -> parserError("Unknown 'socketType' value: '$this'") + } + } + + private fun readElement(block: (Int) -> Unit) { + require(pullParser.eventType == XmlPullParser.START_TAG) + + val tagName = pullParser.name + val depth = pullParser.depth + while (true) { + when (val eventType = pullParser.next()) { + XmlPullParser.END_DOCUMENT -> { + parserError("End of document reached while reading element '$tagName'") + } + XmlPullParser.END_TAG -> { + if (pullParser.name == tagName && pullParser.depth == depth) return + } + else -> { + block(eventType) + } + } + } + } + + private fun readText(): String { + var text = "" + readElement { eventType -> + when (eventType) { + XmlPullParser.TEXT -> { + text = pullParser.text + } + else -> { + parserError("Expected text, but got ${XmlPullParser.TYPES[eventType]}") + } + } + } + + return text + } + + private fun skipElement() { + Log.d("Skipping element '%s'", pullParser.name) + readElement { /* Do nothing */ } + } + + private fun parserError(message: String): Nothing { + throw AutoconfigParserException(message) + } + + private fun String.isValidHostname(): Boolean { + val cleanUpHostName = HostNameUtils.cleanUpHostName(this) + return HostNameUtils.isLegalHostNameOrIP(cleanUpHostName) != null + } + + @Suppress("MagicNumber") + private fun Int.isValidPort() = this in 0..65535 + + private fun String.replaceVariables(): String { + return replace("%EMAILDOMAIN%", domain) + .replace("%EMAILLOCALPART%", localPart) + .replace("%EMAILADDRESS%", email.address) + } + + private fun createImapServerSettings( + hostname: Hostname, + port: Port, + connectionSecurity: ConnectionSecurity, + authenticationTypes: List, + username: String, + ): ImapServerSettings { + return ImapServerSettings(hostname, port, connectionSecurity, authenticationTypes, username) + } + + private fun createSmtpServerSettings( + hostname: Hostname, + port: Port, + connectionSecurity: ConnectionSecurity, + authenticationTypes: List, + username: String, + ): SmtpServerSettings { + return SmtpServerSettings(hostname, port, connectionSecurity, authenticationTypes, username) + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealSubDomainExtractor.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealSubDomainExtractor.kt new file mode 100644 index 0000000..08fceeb --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealSubDomainExtractor.kt @@ -0,0 +1,26 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain +import net.thunderbird.core.common.net.toDomain + +internal class RealSubDomainExtractor(private val baseDomainExtractor: BaseDomainExtractor) : SubDomainExtractor { + @Suppress("ReturnCount") + override fun extractSubDomain(domain: Domain): Domain? { + val baseDomain = baseDomainExtractor.extractBaseDomain(domain) + if (baseDomain == domain) { + // The domain doesn't have a sub domain. + return null + } + + val baseDomainString = baseDomain.value + val domainPrefix = domain.value.removeSuffix(".$baseDomainString") + val index = domainPrefix.indexOf('.') + if (index == -1) { + // The prefix is the sub domain. When we remove it only the base domain remains. + return baseDomain + } + + val prefixWithoutFirstLabel = domainPrefix.substring(index + 1) + return "$prefixWithoutFirstLabel.$baseDomainString".toDomain() + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SubDomainExtractor.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SubDomainExtractor.kt new file mode 100644 index 0000000..39f478e --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SubDomainExtractor.kt @@ -0,0 +1,12 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain + +/** + * Extract the sub domain from a host name. + * + * An implementation needs to respect the [Public Suffix List](https://publicsuffix.org/). + */ +internal interface SubDomainExtractor { + fun extractSubDomain(domain: Domain): Domain? +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SuspendableAutoconfigParser.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SuspendableAutoconfigParser.kt new file mode 100644 index 0000000..f16ae72 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SuspendableAutoconfigParser.kt @@ -0,0 +1,14 @@ +package app.k9mail.autodiscovery.autoconfig + +import java.io.InputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import net.thunderbird.core.common.mail.EmailAddress + +internal class SuspendableAutoconfigParser(private val autoconfigParser: AutoconfigParser) { + suspend fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoconfigParserResult { + return runInterruptible(Dispatchers.IO) { + autoconfigParser.parseSettings(inputStream, email) + } + } +} diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SuspendableMxResolver.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SuspendableMxResolver.kt new file mode 100644 index 0000000..221085d --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/SuspendableMxResolver.kt @@ -0,0 +1,13 @@ +package app.k9mail.autodiscovery.autoconfig + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import net.thunderbird.core.common.net.Domain + +internal class SuspendableMxResolver(private val mxResolver: MxResolver) { + suspend fun lookup(domain: Domain): MxLookupResult { + return runInterruptible(Dispatchers.IO) { + mxResolver.lookup(domain) + } + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscoveryTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscoveryTest.kt new file mode 100644 index 0000000..49940bd --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscoveryTest.kt @@ -0,0 +1,69 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_ONE +import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_TWO +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.extracting +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.mail.toUserEmailAddress +import net.thunderbird.core.common.net.toDomain +import okhttp3.HttpUrl.Companion.toHttpUrl + +private val IRRELEVANT_EMAIL_ADDRESS = "irrelevant@domain.example".toUserEmailAddress() + +class AutoconfigDiscoveryTest { + private val urlProvider = MockAutoconfigUrlProvider() + private val autoconfigFetcher = MockAutoconfigFetcher() + private val discovery = AutoconfigDiscovery(urlProvider, autoconfigFetcher) + + @Test + fun `AutoconfigFetcher and AutoconfigParser should only be called when AutoDiscoveryRunnable is run`() = runTest { + val emailAddress = "user@domain.example".toUserEmailAddress() + val autoconfigUrl = "https://autoconfig.domain.invalid/mail/config-v1.1.xml".toHttpUrl() + urlProvider.addResult(listOf(autoconfigUrl)) + autoconfigFetcher.addResult(RESULT_ONE) + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + + assertThat(autoDiscoveryRunnables).hasSize(1) + assertThat(urlProvider.callArguments).containsExactly("domain.example".toDomain() to emailAddress) + assertThat(autoconfigFetcher.callCount).isEqualTo(0) + + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(autoconfigFetcher.callArguments).containsExactly(autoconfigUrl to emailAddress) + assertThat(discoveryResult).isEqualTo(RESULT_ONE) + } + + @Test + fun `Two Autoconfig URLs should return two AutoDiscoveryRunnables`() = runTest { + val urlOne = "https://autoconfig.domain1.invalid/mail/config-v1.1.xml".toHttpUrl() + val urlTwo = "https://autoconfig.domain2.invalid/mail/config-v1.1.xml".toHttpUrl() + + urlProvider.addResult(listOf(urlOne, urlTwo)) + autoconfigFetcher.apply { + addResult(RESULT_ONE) + addResult(RESULT_TWO) + } + + val autoDiscoveryRunnables = discovery.initDiscovery(IRRELEVANT_EMAIL_ADDRESS) + + assertThat(autoDiscoveryRunnables).hasSize(2) + + val discoveryResultOne = autoDiscoveryRunnables[0].run() + + assertThat(autoconfigFetcher.callArguments).extracting { it.first }.containsExactly(urlOne) + assertThat(discoveryResultOne).isEqualTo(RESULT_ONE) + + autoconfigFetcher.callArguments.clear() + + val discoveryResultTwo = autoDiscoveryRunnables[1].run() + + assertThat(autoconfigFetcher.callArguments).extracting { it.first }.containsExactly(urlTwo) + assertThat(discoveryResultTwo).isEqualTo(RESULT_TWO) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/IspDbAutoconfigUrlProviderTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/IspDbAutoconfigUrlProviderTest.kt new file mode 100644 index 0000000..9d6f605 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/IspDbAutoconfigUrlProviderTest.kt @@ -0,0 +1,22 @@ +package app.k9mail.autodiscovery.autoconfig + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.extracting +import net.thunderbird.core.common.net.toDomain +import org.junit.Test + +class IspDbAutoconfigUrlProviderTest { + private val urlProvider = IspDbAutoconfigUrlProvider() + + @Test + fun `getAutoconfigUrls with ASCII email address`() { + val domain = "domain.example".toDomain() + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain) + + assertThat(autoconfigUrls).extracting { it.toString() }.containsExactly( + "https://autoconfig.thunderbird.net/v1.1/domain.example", + ) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MiniDnsMxResolverTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MiniDnsMxResolverTest.kt new file mode 100644 index 0000000..99cee3d --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MiniDnsMxResolverTest.kt @@ -0,0 +1,58 @@ +package app.k9mail.autodiscovery.autoconfig + +import assertk.all +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.extracting +import assertk.assertions.index +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlin.test.Ignore +import kotlin.test.Test +import net.thunderbird.core.common.net.toDomain + +class MiniDnsMxResolverTest { + private val resolver = MiniDnsMxResolver() + + @Test + @Ignore("Requires internet") + fun `MX lookup for known domain`() { + val domain = "thunderbird.net".toDomain() + + val result = resolver.lookup(domain) + + assertThat(result.mxNames).extracting { it.value }.all { + index(0).isEqualTo("aspmx.l.google.com") + containsExactlyInAnyOrder( + "aspmx.l.google.com", + "alt1.aspmx.l.google.com", + "alt2.aspmx.l.google.com", + "alt4.aspmx.l.google.com", + "alt3.aspmx.l.google.com", + ) + } + } + + @Test + @Ignore("Requires internet") + fun `MX lookup for known domain using DNSSEC`() { + val domain = "posteo.de".toDomain() + + val result = resolver.lookup(domain) + + assertThat(result.isTrusted).isTrue() + } + + @Test + @Ignore("Requires internet") + fun `MX lookup for non-existent domain`() { + val domain = "test.invalid".toDomain() + + val result = resolver.lookup(domain) + + assertThat(result.mxNames).isEmpty() + assertThat(result.isTrusted).isFalse() + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockAutoconfigFetcher.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockAutoconfigFetcher.kt new file mode 100644 index 0000000..4265b01 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockAutoconfigFetcher.kt @@ -0,0 +1,77 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext +import app.k9mail.autodiscovery.api.AuthenticationType.PasswordEncrypted +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.ConnectionSecurity.StartTLS +import app.k9mail.autodiscovery.api.ConnectionSecurity.TLS +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort +import okhttp3.HttpUrl + +internal class MockAutoconfigFetcher : AutoconfigFetcher { + val callArguments = mutableListOf>() + + val callCount: Int + get() = callArguments.size + + val urls: List + get() = callArguments.map { (url, _) -> url.toString() } + + private val results = mutableListOf() + + fun addResult(discoveryResult: AutoDiscoveryResult) { + results.add(discoveryResult) + } + + override suspend fun fetchAutoconfig(autoconfigUrl: HttpUrl, email: EmailAddress): AutoDiscoveryResult { + callArguments.add(autoconfigUrl to email) + + check(results.isNotEmpty()) { + "MockAutoconfigFetcher.fetchAutoconfig($autoconfigUrl) called but no result provided" + } + return results.removeAt(0) + } + + companion object { + val RESULT_ONE = AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "irrelevant@domain.example", + ), + outgoingServerSettings = SmtpServerSettings( + hostname = "smtp.domain.example".toHostname(), + port = 465.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "irrelevant@domain.example", + ), + isTrusted = true, + source = "result 1", + ) + val RESULT_TWO = AutoDiscoveryResult.Settings( + incomingServerSettings = ImapServerSettings( + hostname = "imap.company.example".toHostname(), + port = 143.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordEncrypted), + username = "irrelevant@company.example", + ), + outgoingServerSettings = SmtpServerSettings( + hostname = "smtp.company.example".toHostname(), + port = 587.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordEncrypted), + username = "irrelevant@company.example", + ), + isTrusted = true, + source = "result 2", + ) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockAutoconfigUrlProvider.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockAutoconfigUrlProvider.kt new file mode 100644 index 0000000..fe215e5 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockAutoconfigUrlProvider.kt @@ -0,0 +1,25 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.net.Domain +import okhttp3.HttpUrl + +internal class MockAutoconfigUrlProvider : AutoconfigUrlProvider { + val callArguments = mutableListOf>() + + val callCount: Int + get() = callArguments.size + + private val results = mutableListOf>() + + fun addResult(urls: List) { + results.add(urls) + } + + override fun getAutoconfigUrls(domain: Domain, email: EmailAddress?): List { + callArguments.add(domain to email) + + check(results.isNotEmpty()) { "getAutoconfigUrls($domain, $email) called but no result provided" } + return results.removeAt(0) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockMxResolver.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockMxResolver.kt new file mode 100644 index 0000000..058b5f8 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MockMxResolver.kt @@ -0,0 +1,27 @@ +package app.k9mail.autodiscovery.autoconfig + +import net.thunderbird.core.common.net.Domain + +class MockMxResolver : MxResolver { + val callArguments = mutableListOf() + + val callCount: Int + get() = callArguments.size + + private val results = mutableListOf() + + fun addResult(domain: Domain, isTrusted: Boolean = true) { + results.add(MxLookupResult(mxNames = listOf(domain), isTrusted = isTrusted)) + } + + fun addResult(domains: List) { + results.add(MxLookupResult(mxNames = domains, isTrusted = true)) + } + + override fun lookup(domain: Domain): MxLookupResult { + callArguments.add(domain) + + check(results.isNotEmpty()) { "lookup($domain) called but no result provided" } + return results.removeAt(0) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscoveryTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscoveryTest.kt new file mode 100644 index 0000000..caefec9 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscoveryTest.kt @@ -0,0 +1,151 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound +import app.k9mail.autodiscovery.autoconfig.MockAutoconfigFetcher.Companion.RESULT_ONE +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.mail.toUserEmailAddress +import net.thunderbird.core.common.net.toDomain + +class MxLookupAutoconfigDiscoveryTest { + private val mxResolver = MockMxResolver() + private val baseDomainExtractor = OkHttpBaseDomainExtractor() + private val urlProvider = createPostMxLookupAutoconfigUrlProvider( + AutoconfigUrlConfig( + httpsOnly = true, + includeEmailAddress = true, + ), + ) + private val autoconfigFetcher = MockAutoconfigFetcher() + private val discovery = MxLookupAutoconfigDiscovery( + mxResolver = SuspendableMxResolver(mxResolver), + baseDomainExtractor = baseDomainExtractor, + subDomainExtractor = RealSubDomainExtractor(baseDomainExtractor), + urlProvider = urlProvider, + autoconfigFetcher = autoconfigFetcher, + ) + + @Test + fun `result from email provider should be used if available`() = runTest { + val emailAddress = "user@company.example".toUserEmailAddress() + mxResolver.addResult("mx.emailprovider.example".toDomain()) + autoconfigFetcher.apply { + addResult(RESULT_ONE) + } + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + + assertThat(autoDiscoveryRunnables).hasSize(1) + assertThat(mxResolver.callCount).isEqualTo(0) + assertThat(autoconfigFetcher.callCount).isEqualTo(0) + + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(autoconfigFetcher.urls).containsExactly( + "https://autoconfig.emailprovider.example/mail/config-v1.1.xml?emailaddress=user%40company.example", + ) + assertThat(discoveryResult).isEqualTo(RESULT_ONE) + } + + @Test + fun `result from ISPDB should be used if config is not available at email provider`() = runTest { + val emailAddress = "user@company.example".toUserEmailAddress() + mxResolver.addResult("mx.emailprovider.example".toDomain()) + autoconfigFetcher.apply { + addResult(NoUsableSettingsFound) + addResult(RESULT_ONE) + } + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + + assertThat(autoDiscoveryRunnables).hasSize(1) + assertThat(mxResolver.callCount).isEqualTo(0) + assertThat(autoconfigFetcher.callCount).isEqualTo(0) + + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(autoconfigFetcher.urls).containsExactly( + "https://autoconfig.emailprovider.example/mail/config-v1.1.xml?emailaddress=user%40company.example", + "https://autoconfig.thunderbird.net/v1.1/emailprovider.example", + ) + assertThat(discoveryResult).isEqualTo(RESULT_ONE) + } + + @Test + fun `base domain and subdomain should be extracted from MX host if possible`() = runTest { + val emailAddress = "user@company.example".toUserEmailAddress() + mxResolver.addResult("mx.something.emailprovider.example".toDomain()) + autoconfigFetcher.apply { + addResult(NoUsableSettingsFound) + addResult(NoUsableSettingsFound) + addResult(NoUsableSettingsFound) + addResult(NoUsableSettingsFound) + } + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(autoconfigFetcher.urls).containsExactly( + "https://autoconfig.something.emailprovider.example/mail/config-v1.1.xml" + + "?emailaddress=user%40company.example", + "https://autoconfig.thunderbird.net/v1.1/something.emailprovider.example", + "https://autoconfig.emailprovider.example/mail/config-v1.1.xml?emailaddress=user%40company.example", + "https://autoconfig.thunderbird.net/v1.1/emailprovider.example", + ) + assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound) + } + + @Test + fun `skip Autoconfig lookup when MX lookup does not return a result`() = runTest { + val emailAddress = "user@company.example".toUserEmailAddress() + mxResolver.addResult(emptyList()) + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(mxResolver.callCount).isEqualTo(1) + assertThat(autoconfigFetcher.callCount).isEqualTo(0) + assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound) + } + + @Test + fun `skip Autoconfig lookup when base domain of MX record is email domain`() = runTest { + val emailAddress = "user@company.example".toUserEmailAddress() + mxResolver.addResult("mx.company.example".toDomain()) + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(mxResolver.callCount).isEqualTo(1) + assertThat(autoconfigFetcher.callCount).isEqualTo(0) + assertThat(discoveryResult).isEqualTo(NoUsableSettingsFound) + } + + @Test + fun `isTrusted should be false when MxLookupResult_isTrusted is false`() = runTest { + val emailAddress = "user@company.example".toUserEmailAddress() + mxResolver.addResult("mx.emailprovider.example".toDomain(), isTrusted = false) + autoconfigFetcher.addResult(RESULT_ONE.copy(isTrusted = true)) + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(discoveryResult).isEqualTo(RESULT_ONE.copy(isTrusted = false)) + } + + @Test + fun `isTrusted should be false when AutoDiscoveryResult_isTrusted from AutoconfigFetcher is false`() = runTest { + val emailAddress = "user@company.example".toUserEmailAddress() + mxResolver.addResult("mx.emailprovider.example".toDomain(), isTrusted = true) + autoconfigFetcher.addResult(RESULT_ONE.copy(isTrusted = false)) + + val autoDiscoveryRunnables = discovery.initDiscovery(emailAddress) + val discoveryResult = autoDiscoveryRunnables.first().run() + + assertThat(discoveryResult).isEqualTo(RESULT_ONE.copy(isTrusted = false)) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpBaseDomainExtractorTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpBaseDomainExtractorTest.kt new file mode 100644 index 0000000..33ccf07 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpBaseDomainExtractorTest.kt @@ -0,0 +1,46 @@ +package app.k9mail.autodiscovery.autoconfig + +import assertk.assertThat +import assertk.assertions.isEqualTo +import net.thunderbird.core.common.net.toDomain +import org.junit.Test + +class OkHttpBaseDomainExtractorTest { + private val baseDomainExtractor = OkHttpBaseDomainExtractor() + + @Test + fun `basic domain`() { + val domain = "domain.example".toDomain() + + val result = baseDomainExtractor.extractBaseDomain(domain) + + assertThat(result).isEqualTo(domain) + } + + @Test + fun `basic subdomain`() { + val domain = "subdomain.domain.example".toDomain() + + val result = baseDomainExtractor.extractBaseDomain(domain) + + assertThat(result).isEqualTo("domain.example".toDomain()) + } + + @Test + fun `domain with public suffix`() { + val domain = "example.co.uk".toDomain() + + val result = baseDomainExtractor.extractBaseDomain(domain) + + assertThat(result).isEqualTo(domain) + } + + @Test + fun `subdomain with public suffix`() { + val domain = "subdomain.example.co.uk".toDomain() + + val result = baseDomainExtractor.extractBaseDomain(domain) + + assertThat(result).isEqualTo("example.co.uk".toDomain()) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpFetcherTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpFetcherTest.kt new file mode 100644 index 0000000..a5a20fe --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/OkHttpFetcherTest.kt @@ -0,0 +1,48 @@ +package app.k9mail.autodiscovery.autoconfig + +import assertk.all +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import java.net.UnknownHostException +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Test + +class OkHttpFetcherTest { + private val fetcher = OkHttpFetcher(OkHttpClient.Builder().build()) + + @Test + fun shouldHandleNonexistentUrl() = runTest { + val nonExistentUrl = + "https://autoconfig.domain.invalid/mail/config-v1.1.xml?emailaddress=test%40domain.example".toHttpUrl() + + assertFailure { + fetcher.fetch(nonExistentUrl) + }.isInstanceOf() + } + + @Test + fun shouldHandleEmptyResponse() = runTest { + val server = MockWebServer().apply { + this.enqueue( + MockResponse() + .setBody("") + .setResponseCode(204), + ) + start() + } + val url = server.url("/empty/") + + val result = fetcher.fetch(url) + + assertThat(result).isInstanceOf().all { + prop(HttpFetchResult.SuccessResponse::inputStream).transform { it.available() }.isEqualTo(0) + } + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/PostMxLookupAutoconfigUrlProviderTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/PostMxLookupAutoconfigUrlProviderTest.kt new file mode 100644 index 0000000..5b34930 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/PostMxLookupAutoconfigUrlProviderTest.kt @@ -0,0 +1,42 @@ +package app.k9mail.autodiscovery.autoconfig + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.extracting +import net.thunderbird.core.common.mail.toEmailAddressOrThrow +import net.thunderbird.core.common.net.toDomain +import org.junit.Test + +class PostMxLookupAutoconfigUrlProviderTest { + @Test + fun `getAutoconfigUrls with including email address`() { + val urlProvider = createPostMxLookupAutoconfigUrlProvider( + AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = true), + ) + val domain = "domain.example".toDomain() + val emailAddress = "test@domain.example".toEmailAddressOrThrow() + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, emailAddress) + + assertThat(autoconfigUrls).extracting { it.toString() }.containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example", + "https://autoconfig.thunderbird.net/v1.1/domain.example", + ) + } + + @Test + fun `getAutoconfigUrls without including email address`() { + val urlProvider = createPostMxLookupAutoconfigUrlProvider( + AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = false), + ) + val domain = "domain.example".toDomain() + val emailAddress = "test@domain.example".toEmailAddressOrThrow() + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, emailAddress) + + assertThat(autoconfigUrls).extracting { it.toString() }.containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml", + "https://autoconfig.thunderbird.net/v1.1/domain.example", + ) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/ProviderAutoconfigUrlProviderTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/ProviderAutoconfigUrlProviderTest.kt new file mode 100644 index 0000000..935ee5d --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/ProviderAutoconfigUrlProviderTest.kt @@ -0,0 +1,88 @@ +package app.k9mail.autodiscovery.autoconfig + +import assertk.assertThat +import assertk.assertions.containsExactly +import net.thunderbird.core.common.mail.toUserEmailAddress +import net.thunderbird.core.common.net.toDomain +import org.junit.Test + +class ProviderAutoconfigUrlProviderTest { + private val domain = "domain.example".toDomain() + private val email = "test@domain.example".toUserEmailAddress() + + @Test + fun `getAutoconfigUrls with http allowed and email address included`() { + val urlProvider = ProviderAutoconfigUrlProvider( + AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = true), + ) + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email) + + assertThat(autoconfigUrls.map { it.toString() }).containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example", + "https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40domain.example", + "http://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example", + "http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40domain.example", + ) + } + + @Test + fun `getAutoconfigUrls with only https and email address included`() { + val urlProvider = ProviderAutoconfigUrlProvider( + AutoconfigUrlConfig(httpsOnly = true, includeEmailAddress = true), + ) + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email) + + assertThat(autoconfigUrls.map { it.toString() }).containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example", + "https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=test%40domain.example", + ) + } + + @Test + fun `getAutoconfigUrls with only https and email address not included`() { + val urlProvider = ProviderAutoconfigUrlProvider( + AutoconfigUrlConfig(httpsOnly = true, includeEmailAddress = false), + ) + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email) + + assertThat(autoconfigUrls.map { it.toString() }).containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml", + "https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml", + ) + } + + @Test + fun `getAutoconfigUrls with http allowed and email address not included`() { + val urlProvider = ProviderAutoconfigUrlProvider( + AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = false), + ) + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email) + + assertThat(autoconfigUrls.map { it.toString() }).containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml", + "https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml", + "http://autoconfig.domain.example/mail/config-v1.1.xml", + "http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml", + ) + } + + @Test + fun `getAutoconfigUrls with http allowed and email address included, but none provided`() { + val urlProvider = ProviderAutoconfigUrlProvider( + AutoconfigUrlConfig(httpsOnly = false, includeEmailAddress = true), + ) + + val autoconfigUrls = urlProvider.getAutoconfigUrls(domain) + + assertThat(autoconfigUrls.map { it.toString() }).containsExactly( + "https://autoconfig.domain.example/mail/config-v1.1.xml", + "https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml", + "http://autoconfig.domain.example/mail/config-v1.1.xml", + "http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml", + ) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParserTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParserTest.kt new file mode 100644 index 0000000..8afe771 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParserTest.kt @@ -0,0 +1,651 @@ +package app.k9mail.autodiscovery.autoconfig + +import app.k9mail.autodiscovery.api.AuthenticationType.OAuth2 +import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext +import app.k9mail.autodiscovery.api.ConnectionSecurity.StartTLS +import app.k9mail.autodiscovery.api.ConnectionSecurity.TLS +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.ParserError +import app.k9mail.autodiscovery.autoconfig.AutoconfigParserResult.Settings +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.prop +import java.io.InputStream +import net.thunderbird.core.common.mail.toUserEmailAddress +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort +import net.thunderbird.core.logging.legacy.Log +import net.thunderbird.core.logging.testing.TestLogger +import org.intellij.lang.annotations.Language +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import org.junit.Before +import org.junit.Test + +private const val PRINT_MODIFIED_XML = false + +class RealAutoconfigParserTest { + private val parser = RealAutoconfigParser() + + @Language("XML") + private val minimalConfig = + """ + + + + domain.example + + imap.domain.example + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + smtp.domain.example + 587 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + + """.trimIndent() + + @Language("XML") + private val additionalIncomingServer = + """ + + imap.domain.example + 143 + STARTTLS + password-cleartext + %EMAILADDRESS% + + """.trimIndent() + + @Language("XML") + private val additionalOutgoingServer = + """ + + smtp.domain.example + 465 + SSL + password-cleartext + %EMAILADDRESS% + + """.trimIndent() + + private val irrelevantEmailAddress = "irrelevant@domain.example".toUserEmailAddress() + + @Before + fun setUp() { + Log.logger = TestLogger() + } + + @Test + fun `minimal data`() { + val inputStream = minimalConfig.byteInputStream() + + val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress()) + + assertThat(result).isNotNull().isEqualTo( + Settings( + incomingServerSettings = listOf( + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ), + outgoingServerSettings = listOf( + SmtpServerSettings( + hostname = "smtp.domain.example".toHostname(), + port = 587.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ), + ), + ) + } + + @Test + fun `real-world data`() { + val inputStream = javaClass.getResourceAsStream("/2022-11-19-googlemail.com.xml")!! + + val result = parser.parseSettings(inputStream, email = "test@gmail.com".toUserEmailAddress()) + + assertThat(result).isNotNull().isEqualTo( + Settings( + incomingServerSettings = listOf( + ImapServerSettings( + hostname = "imap.gmail.com".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(OAuth2, PasswordCleartext), + username = "test@gmail.com", + ), + ), + outgoingServerSettings = listOf( + SmtpServerSettings( + hostname = "smtp.gmail.com".toHostname(), + port = 465.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(OAuth2, PasswordCleartext), + username = "test@gmail.com", + ), + ), + ), + ) + } + + @Test + fun `multiple incomingServer and outgoingServer elements`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer").insertBefore(additionalIncomingServer) + element("outgoingServer").insertBefore(additionalOutgoingServer) + } + + val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress()) + + assertThat(result).isNotNull().isEqualTo( + Settings( + incomingServerSettings = listOf( + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 143.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ), + outgoingServerSettings = listOf( + SmtpServerSettings( + hostname = "smtp.domain.example".toHostname(), + port = 465.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + SmtpServerSettings( + hostname = "smtp.domain.example".toHostname(), + port = 587.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ), + ), + ) + } + + @Test + fun `replace variables`() { + val inputStream = minimalConfig.withModifications { + element("domain").text("%EMAILDOMAIN%") + element("incomingServer > hostname").text("%EMAILLOCALPART%.domain.example") + element("outgoingServer > hostname").text("%EMAILLOCALPART%.outgoing.domain.example") + element("outgoingServer > username").text("%EMAILDOMAIN%") + } + + val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress()) + + assertThat(result).isNotNull().isEqualTo( + Settings( + incomingServerSettings = listOf( + ImapServerSettings( + hostname = "user.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ), + outgoingServerSettings = listOf( + SmtpServerSettings( + hostname = "user.outgoing.domain.example".toHostname(), + port = 587.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordCleartext), + username = "domain.example", + ), + ), + ), + ) + } + + @Test + fun `data with comments`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > hostname").prepend("") + element("incomingServer > port").prepend("") + element("incomingServer > socketType").prepend("") + element("incomingServer > authentication").prepend("") + element("incomingServer > username").prepend("") + } + + val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress()) + + assertThat(result).isInstanceOf() + .prop(Settings::incomingServerSettings).containsExactly( + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ) + } + + @Test + fun `ignore unsupported 'incomingServer' type`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer").insertBefore("""""") + } + + val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress()) + + assertThat(result).isInstanceOf() + .prop(Settings::incomingServerSettings).containsExactly( + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ) + } + + @Test + fun `ignore unsupported 'outgoingServer' type`() { + val inputStream = minimalConfig.withModifications { + element("outgoingServer").insertBefore("""""") + } + + val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress()) + + assertThat(result).isInstanceOf() + .prop(Settings::outgoingServerSettings).containsExactly( + SmtpServerSettings( + hostname = "smtp.domain.example".toHostname(), + port = 587.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ) + } + + @Test + fun `empty authentication element should be ignored`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > authentication").insertBefore("") + } + + val result = parser.parseSettings(inputStream, email = "user@domain.example".toUserEmailAddress()) + + assertThat(result).isInstanceOf() + .prop(Settings::incomingServerSettings).containsExactly( + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + ) + } + + @Test + fun `config with missing 'emailProvider id' attribute should throw`() { + val inputStream = minimalConfig.withModifications { + element("emailProvider").removeAttr("id") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Missing 'emailProvider.id' attribute") + } + + @Test + fun `config with invalid 'emailProvider id' attribute should throw`() { + val inputStream = minimalConfig.withModifications { + element("emailProvider").attr("id", "-23") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Invalid 'emailProvider.id' attribute") + } + + @Test + fun `config with missing domain element should throw`() { + val inputStream = minimalConfig.withModifications { + element("emailProvider > domain").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Valid 'domain' element required") + } + + @Test + fun `config with only invalid domain elements should throw`() { + val inputStream = minimalConfig.withModifications { + element("emailProvider > domain").text("-invalid") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Valid 'domain' element required") + } + + @Test + fun `config with missing 'incomingServer' element should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("No supported 'incomingServer' element found") + } + + @Test + fun `config with missing 'outgoingServer' element should throw`() { + val inputStream = minimalConfig.withModifications { + element("outgoingServer").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("No supported 'outgoingServer' element found") + } + + @Test + fun `incomingServer with missing hostname should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > hostname").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Missing 'hostname' element") + } + + @Test + fun `incomingServer with invalid hostname should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > hostname").text("in valid") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Invalid 'hostname' value: 'in valid'") + } + + @Test + fun `incomingServer with missing port should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > port").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Missing 'port' element") + } + + @Test + fun `incomingServer with missing socketType should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > socketType").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Missing 'socketType' element") + } + + @Test + fun `incomingServer with missing authentication should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > authentication").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("No usable 'authentication' element found") + } + + @Test + fun `incomingServer with missing username should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > username").remove() + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Missing 'username' element") + } + + @Test + fun `incomingServer with invalid port should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > port").text("invalid") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Invalid 'port' value: 'invalid'") + } + + @Test + fun `incomingServer with out of range port number should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > port").text("100000") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Invalid 'port' value: '100000'") + } + + @Test + fun `incomingServer with unknown socketType should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > socketType").text("TLS") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Unknown 'socketType' value: 'TLS'") + } + + @Test + fun `element found when expecting text should throw`() { + val inputStream = minimalConfig.withModifications { + element("incomingServer > hostname").html("imap.domain.example") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Expected text, but got START_TAG") + } + + @Test + fun `ignore 'incomingServer' and 'outgoingServer' inside wrong element`() { + val inputStream = minimalConfig.withModifications { + element("emailProvider").tagName("madeUpTag") + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Missing 'emailProvider' element") + } + + @Test + fun `ignore 'incomingServer' inside unsupported 'incomingServer' element`() { + val inputStream = minimalConfig.withModifications { + val incomingServer = element("incomingServer") + val incomingServerXml = incomingServer.outerHtml() + incomingServer.attr("type", "unsupported") + incomingServer.html(incomingServerXml) + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("No supported 'incomingServer' element found") + } + + @Test + fun `ignore 'outgoingServer' inside unsupported 'outgoingServer' element`() { + val inputStream = minimalConfig.withModifications { + val outgoingServer = element("outgoingServer") + val outgoingServerXml = outgoingServer.outerHtml() + outgoingServer.attr("type", "unsupported") + outgoingServer.html(outgoingServerXml) + } + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("No supported 'outgoingServer' element found") + } + + @Test + fun `non XML data should throw`() { + val inputStream = "invalid".byteInputStream() + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Error parsing Autoconfig XML") + } + + @Test + fun `wrong root element should throw`() { + @Language("XML") + val inputStream = + """ + + + """.trimIndent().byteInputStream() + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Missing 'clientConfig' element") + } + + @Test + fun `syntactically incorrect XML should throw`() { + @Language("XML") + val inputStream = + """ + + + + + imap.domain.example + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + smtp.domain.example + 465 + SSL + password-cleartext + %EMAILADDRESS% + + + + """.trimIndent().byteInputStream() + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("Error parsing Autoconfig XML") + } + + @Test + fun `incomplete XML should throw`() { + @Language("XML") + val inputStream = + """ + + + + """.trimIndent().byteInputStream() + + val result = parser.parseSettings(inputStream, irrelevantEmailAddress) + + assertThat(result).isInstanceOf() + .prop(ParserError::error).hasMessage("End of document reached while reading element 'emailProvider'") + } + + private fun String.withModifications(block: Document.() -> Unit): InputStream { + return Jsoup.parse(this, "", Parser.xmlParser()) + .apply(block) + .toString() + .also { + if (PRINT_MODIFIED_XML) { + println(it) + } + } + .byteInputStream() + } + + private fun Document.element(query: String): Element { + return select(query).first() ?: error("Couldn't find element using '$query'") + } + + private fun Element.insertBefore(xml: String) { + val index = siblingIndex() + parent()!!.apply { + append(xml) + val newElement = lastElementChild()!! + newElement.remove() + insertChildren(index, newElement) + } + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/RealSubDomainExtractorTest.kt b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/RealSubDomainExtractorTest.kt new file mode 100644 index 0000000..6dd469d --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/kotlin/app/k9mail/autodiscovery/autoconfig/RealSubDomainExtractorTest.kt @@ -0,0 +1,95 @@ +package app.k9mail.autodiscovery.autoconfig + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import kotlin.test.Test +import net.thunderbird.core.common.net.Domain +import net.thunderbird.core.common.net.toDomain + +class RealSubDomainExtractorTest { + private val testBaseDomainExtractor = TestBaseDomainExtractor(baseDomain = "domain.example") + private val baseSubDomainExtractor = RealSubDomainExtractor(testBaseDomainExtractor) + + @Test + fun `input has one more label than the base domain`() { + val domain = "subdomain.domain.example".toDomain() + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isEqualTo("domain.example".toDomain()) + } + + @Test + fun `input has two more labels than the base domain`() { + val domain = "more.subdomain.domain.example".toDomain() + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isEqualTo("subdomain.domain.example".toDomain()) + } + + @Test + fun `input has three more labels than the base domain`() { + val domain = "three.two.one.domain.example".toDomain() + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isEqualTo("two.one.domain.example".toDomain()) + } + + @Test + fun `no sub domain available`() { + val domain = "domain.example".toDomain() + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isNull() + } + + @Test + fun `input has one more label than the base domain with public suffix`() { + val domain = "subdomain.example.co.uk".toDomain() + testBaseDomainExtractor.baseDomain = "example.co.uk" + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isEqualTo("example.co.uk".toDomain()) + } + + @Test + fun `input has two more labels than the base domain with public suffix`() { + val domain = "more.subdomain.example.co.uk".toDomain() + testBaseDomainExtractor.baseDomain = "example.co.uk" + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isEqualTo("subdomain.example.co.uk".toDomain()) + } + + @Test + fun `input has three more labels than the base domain with public suffix`() { + val domain = "three.two.one.example.co.uk".toDomain() + testBaseDomainExtractor.baseDomain = "example.co.uk" + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isEqualTo("two.one.example.co.uk".toDomain()) + } + + @Test + fun `no sub domain available with public suffix`() { + val domain = "example.co.uk".toDomain() + testBaseDomainExtractor.baseDomain = "example.co.uk" + + val result = baseSubDomainExtractor.extractSubDomain(domain) + + assertThat(result).isNull() + } +} + +private class TestBaseDomainExtractor(var baseDomain: String) : BaseDomainExtractor { + override fun extractBaseDomain(domain: Domain): Domain { + return Domain(baseDomain) + } +} diff --git a/feature/autodiscovery/autoconfig/src/test/resources/2022-11-19-googlemail.com.xml b/feature/autodiscovery/autoconfig/src/test/resources/2022-11-19-googlemail.com.xml new file mode 100644 index 0000000..e0e7c38 --- /dev/null +++ b/feature/autodiscovery/autoconfig/src/test/resources/2022-11-19-googlemail.com.xml @@ -0,0 +1,79 @@ + + + + gmail.com + googlemail.com + + google.com + + jazztel.es + + Google Mail + GMail + + + imap.gmail.com + 993 + SSL + %EMAILADDRESS% + OAuth2 + password-cleartext + + + pop.gmail.com + 995 + SSL + %EMAILADDRESS% + OAuth2 + password-cleartext + + true + + + + smtp.gmail.com + 465 + SSL + %EMAILADDRESS% + OAuth2 + password-cleartext + + + + How to enable IMAP/POP3 in GMail + + + How to configure email clients for IMAP + + + How to configure email clients for POP3 + + + How to configure TB 2.0 for POP3 + + + + + accounts.google.com + + https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav + https://accounts.google.com/o/oauth2/auth + https://www.googleapis.com/oauth2/v3/token + + + + You need to enable IMAP access + + + + + + %EMAILADDRESS% + + + + + + + diff --git a/feature/autodiscovery/demo/build.gradle.kts b/feature/autodiscovery/demo/build.gradle.kts new file mode 100644 index 0000000..a30cc71 --- /dev/null +++ b/feature/autodiscovery/demo/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.feature.autodiscovery.api) +} diff --git a/feature/autodiscovery/demo/src/main/kotlin/app/k9mail/autodiscovery/demo/DemoAutoDiscovery.kt b/feature/autodiscovery/demo/src/main/kotlin/app/k9mail/autodiscovery/demo/DemoAutoDiscovery.kt new file mode 100644 index 0000000..c11bf90 --- /dev/null +++ b/feature/autodiscovery/demo/src/main/kotlin/app/k9mail/autodiscovery/demo/DemoAutoDiscovery.kt @@ -0,0 +1,46 @@ +package app.k9mail.autodiscovery.demo + +import app.k9mail.autodiscovery.api.AutoDiscovery +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable +import app.k9mail.autodiscovery.api.IncomingServerSettings +import app.k9mail.autodiscovery.api.OutgoingServerSettings +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import net.thunderbird.core.common.mail.EmailAddress +import net.thunderbird.core.common.mail.toDomain + +class DemoAutoDiscovery : AutoDiscovery { + override fun initDiscovery(email: EmailAddress): List { + val domain = email.domain.toDomain() + + return listOf( + AutoDiscoveryRunnable { + if (domain.value == "example.com") { + AutoDiscoveryResult.Settings( + incomingServerSettings = DemoServerSettings, + outgoingServerSettings = DemoServerSettings, + isTrusted = true, + source = "DemoAutoDiscovery", + ) + } else { + AutoDiscoveryResult.NoUsableSettingsFound + } + }, + ) + } +} + +object DemoServerSettings : IncomingServerSettings, OutgoingServerSettings { + val serverSettings = ServerSettings( + type = "demo", + host = "irrelevant", + port = 23, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "irrelevant", + password = "irrelevant", + clientCertificateAlias = null, + ) +} diff --git a/feature/autodiscovery/service/build.gradle.kts b/feature/autodiscovery/service/build.gradle.kts new file mode 100644 index 0000000..a7a4f7c --- /dev/null +++ b/feature/autodiscovery/service/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id(ThunderbirdPlugins.Library.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + api(projects.feature.autodiscovery.autoconfig) + + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.kxml2) +} diff --git a/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/PriorityParallelRunner.kt b/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/PriorityParallelRunner.kt new file mode 100644 index 0000000..4e6b822 --- /dev/null +++ b/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/PriorityParallelRunner.kt @@ -0,0 +1,82 @@ +package app.k9mail.autodiscovery.service + +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NetworkError +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.UnexpectedException +import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** + * Runs a list of [AutoDiscoveryRunnable]s with descending priority in parallel and returns the result with the highest + * priority. + * + * As soon as an [AutoDiscoveryRunnable] returns a [Settings] result, runnables with a lower priority are canceled. + */ +internal class PriorityParallelRunner( + private val runnables: List, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun run(): AutoDiscoveryResult { + return coroutineScope { + val deferredList = buildList(capacity = runnables.size) { + // Create coroutines in reverse order. So ones with lower priority are created first. + for (runnable in runnables.reversed()) { + val lowerPriorityCoroutines = toList() + + val deferred = async(coroutineDispatcher, start = LAZY) { + runnable.run().also { discoveryResult -> + if (discoveryResult is Settings) { + // We've got a positive result, so cancel all coroutines with lower priority. + lowerPriorityCoroutines.cancelAll() + } + } + } + + add(deferred) + } + }.asReversed() + + for (deferred in deferredList) { + deferred.start() + } + + @Suppress("SwallowedException", "TooGenericExceptionCaught") + val discoveryResults = deferredList.map { deferred -> + try { + deferred.await() + } catch (e: CancellationException) { + null + } catch (e: Exception) { + UnexpectedException(e) + } + } + + val settingsResult = discoveryResults.firstOrNull { it is Settings } + if (settingsResult != null) { + settingsResult + } else { + val networkError = discoveryResults.firstOrNull { it is NetworkError } + val networkErrorCount = discoveryResults.count { it is NetworkError } + if (networkError != null && networkErrorCount == discoveryResults.size) { + networkError + } else { + NoUsableSettingsFound + } + } + } + } + + private fun List>.cancelAll() { + for (deferred in this) { + deferred.cancel() + } + } +} diff --git a/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryRegistry.kt b/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryRegistry.kt new file mode 100644 index 0000000..c30c2ec --- /dev/null +++ b/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryRegistry.kt @@ -0,0 +1,42 @@ +package app.k9mail.autodiscovery.service + +import app.k9mail.autodiscovery.api.AutoDiscovery +import app.k9mail.autodiscovery.api.AutoDiscoveryRegistry +import app.k9mail.autodiscovery.autoconfig.AutoconfigUrlConfig +import app.k9mail.autodiscovery.autoconfig.createIspDbAutoconfigDiscovery +import app.k9mail.autodiscovery.autoconfig.createMxLookupAutoconfigDiscovery +import app.k9mail.autodiscovery.autoconfig.createProviderAutoconfigDiscovery +import okhttp3.OkHttpClient + +class RealAutoDiscoveryRegistry( + private val autoDiscoveries: List = emptyList(), +) : AutoDiscoveryRegistry { + + override fun getAutoDiscoveries(): List = autoDiscoveries + + companion object { + private val defaultAutoconfigUrlConfig = AutoconfigUrlConfig( + httpsOnly = false, + includeEmailAddress = false, + ) + + fun createDefaultAutoDiscoveries( + okHttpClient: OkHttpClient, + autoconfigUrlConfig: AutoconfigUrlConfig = defaultAutoconfigUrlConfig, + ): List { + return listOf( + createProviderAutoconfigDiscovery( + okHttpClient = okHttpClient, + config = autoconfigUrlConfig, + ), + createIspDbAutoconfigDiscovery( + okHttpClient = okHttpClient, + ), + createMxLookupAutoconfigDiscovery( + okHttpClient = okHttpClient, + config = autoconfigUrlConfig, + ), + ) + } + } +} diff --git a/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryService.kt b/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryService.kt new file mode 100644 index 0000000..8ad0413 --- /dev/null +++ b/feature/autodiscovery/service/src/main/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryService.kt @@ -0,0 +1,21 @@ +package app.k9mail.autodiscovery.service + +import app.k9mail.autodiscovery.api.AutoDiscoveryRegistry +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryService +import net.thunderbird.core.common.mail.EmailAddress + +/** + * Uses Thunderbird's Autoconfig mechanism to find mail server settings for a given email address. + */ +class RealAutoDiscoveryService( + private val autoDiscoveryRegistry: AutoDiscoveryRegistry, +) : AutoDiscoveryService { + + override suspend fun discover(email: EmailAddress): AutoDiscoveryResult { + val runner = PriorityParallelRunner( + runnables = autoDiscoveryRegistry.getAutoDiscoveries().flatMap { it.initDiscovery(email) }, + ) + return runner.run() + } +} diff --git a/feature/autodiscovery/service/src/test/kotlin/app/k9mail/autodiscovery/service/PriorityParallelRunnerTest.kt b/feature/autodiscovery/service/src/test/kotlin/app/k9mail/autodiscovery/service/PriorityParallelRunnerTest.kt new file mode 100644 index 0000000..bea4e68 --- /dev/null +++ b/feature/autodiscovery/service/src/test/kotlin/app/k9mail/autodiscovery/service/PriorityParallelRunnerTest.kt @@ -0,0 +1,181 @@ +package app.k9mail.autodiscovery.service + +import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext +import app.k9mail.autodiscovery.api.AutoDiscoveryResult +import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound +import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable +import app.k9mail.autodiscovery.api.ConnectionSecurity.StartTLS +import app.k9mail.autodiscovery.api.ConnectionSecurity.TLS +import app.k9mail.autodiscovery.api.ImapServerSettings +import app.k9mail.autodiscovery.api.SmtpServerSettings +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.net.toHostname +import net.thunderbird.core.common.net.toPort + +@OptIn(ExperimentalCoroutinesApi::class) +class PriorityParallelRunnerTest { + @Test + fun `first runnable returning a success result should cancel remaining runnables`() = runTest { + var runnableTwoStarted = false + var runnableThreeStarted = false + var runnableTwoCompleted = false + var runnableThreeCompleted = false + val runnableOne = AutoDiscoveryRunnable { + delay(100) + DISCOVERY_RESULT_ONE + } + val runnableTwo = AutoDiscoveryRunnable { + runnableTwoStarted = true + delay(200) + runnableTwoCompleted = true + DISCOVERY_RESULT_TWO + } + val runnableThree = AutoDiscoveryRunnable { + runnableThreeStarted = true + delay(200) + runnableThreeCompleted = false + DISCOVERY_RESULT_TWO + } + val runner = PriorityParallelRunner( + runnables = listOf(runnableOne, runnableTwo, runnableThree), + coroutineDispatcher = StandardTestDispatcher(testScheduler), + ) + + var result: AutoDiscoveryResult? = null + launch { + result = runner.run() + } + + testScheduler.advanceTimeBy(50) + + assertThat(result).isNull() + assertThat(runnableTwoStarted).isTrue() + assertThat(runnableTwoCompleted).isFalse() + assertThat(runnableThreeStarted).isTrue() + assertThat(runnableThreeCompleted).isFalse() + + testScheduler.advanceUntilIdle() + + assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE) + assertThat(runnableTwoCompleted).isFalse() + assertThat(runnableThreeCompleted).isFalse() + } + + @Test + fun `highest priority result should be used even if it takes longer to be produced`() = runTest { + var runnableTwoCompleted = false + val runnableOne = AutoDiscoveryRunnable { + delay(100) + DISCOVERY_RESULT_ONE + } + val runnableTwo = AutoDiscoveryRunnable { + runnableTwoCompleted = true + DISCOVERY_RESULT_TWO + } + val runner = PriorityParallelRunner( + runnables = listOf(runnableOne, runnableTwo), + coroutineDispatcher = StandardTestDispatcher(testScheduler), + ) + + var result: AutoDiscoveryResult? = null + launch { + result = runner.run() + } + + testScheduler.advanceTimeBy(50) + + assertThat(result).isNull() + assertThat(runnableTwoCompleted).isTrue() + + testScheduler.advanceUntilIdle() + + assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE) + } + + @Test + fun `wait for higher priority runnable to complete`() = runTest { + var runnableOneCompleted = false + var runnableTwoCompleted = false + val runnableOne = AutoDiscoveryRunnable { + delay(100) + runnableOneCompleted = true + NO_DISCOVERY_RESULT + } + val runnableTwo = AutoDiscoveryRunnable { + runnableTwoCompleted = true + DISCOVERY_RESULT_TWO + } + val runner = PriorityParallelRunner( + runnables = listOf(runnableOne, runnableTwo), + coroutineDispatcher = StandardTestDispatcher(testScheduler), + ) + + var result: AutoDiscoveryResult? = null + launch { + result = runner.run() + } + + testScheduler.advanceTimeBy(50) + + assertThat(result).isNull() + assertThat(runnableOneCompleted).isFalse() + assertThat(runnableTwoCompleted).isTrue() + + testScheduler.advanceTimeBy(100) + + assertThat(result).isEqualTo(DISCOVERY_RESULT_TWO) + assertThat(runnableOneCompleted).isTrue() + } + + companion object { + private val NO_DISCOVERY_RESULT: AutoDiscoveryResult = NoUsableSettingsFound + + private val DISCOVERY_RESULT_ONE = AutoDiscoveryResult.Settings( + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 993.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + SmtpServerSettings( + hostname = "smtp.domain.example".toHostname(), + port = 587.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + isTrusted = true, + source = "result 1", + ) + + private val DISCOVERY_RESULT_TWO = AutoDiscoveryResult.Settings( + ImapServerSettings( + hostname = "imap.domain.example".toHostname(), + port = 143.toPort(), + connectionSecurity = StartTLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + SmtpServerSettings( + hostname = "smtp.domain.example".toHostname(), + port = 465.toPort(), + connectionSecurity = TLS, + authenticationTypes = listOf(PasswordCleartext), + username = "user@domain.example", + ), + isTrusted = true, + source = "result 2", + ) + } +} diff --git a/feature/autodiscovery/service/src/test/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryRegistryTest.kt b/feature/autodiscovery/service/src/test/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryRegistryTest.kt new file mode 100644 index 0000000..fcaedbb --- /dev/null +++ b/feature/autodiscovery/service/src/test/kotlin/app/k9mail/autodiscovery/service/RealAutoDiscoveryRegistryTest.kt @@ -0,0 +1,30 @@ +package app.k9mail.autodiscovery.service + +import app.k9mail.autodiscovery.api.AutoDiscovery +import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.core.common.mail.EmailAddress + +class RealAutoDiscoveryRegistryTest { + + @Test + fun `getAutoDiscoveries should return given discoveries`() { + val autoDiscoveries = listOf( + TestAutoDiscovery(), + TestAutoDiscovery(), + ) + val testSubject = RealAutoDiscoveryRegistry(autoDiscoveries) + + val result = testSubject.getAutoDiscoveries() + + assertThat(result).isEqualTo(autoDiscoveries) + } + + private class TestAutoDiscovery : AutoDiscovery { + override fun initDiscovery(email: EmailAddress): List { + return emptyList() + } + } +} diff --git a/feature/debug-settings/build.gradle.kts b/feature/debug-settings/build.gradle.kts new file mode 100644 index 0000000..9833163 --- /dev/null +++ b/feature/debug-settings/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "net.thunderbird.feature.debug.settings" + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(projects.core.ui.compose.designsystem) + implementation(projects.core.ui.compose.navigation) + implementation(projects.core.common) + implementation(projects.core.outcome) + implementation(projects.feature.mail.account.api) + implementation(projects.feature.notification.api) +} diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/DebugSectionPreview.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/DebugSectionPreview.kt new file mode 100644 index 0000000..bf82248 --- /dev/null +++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/DebugSectionPreview.kt @@ -0,0 +1,24 @@ +package net.thunderbird.feature.debug.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.theme2.MainTheme + +@PreviewLightDark +@Composable +private fun DebugSectionPreview() { + PreviewWithThemesLightDark { + Box(modifier = Modifier.padding(MainTheme.spacings.triple)) { + DebugSection( + title = "Debug section", + ) { + TextBodyLarge("Content") + } + } + } +} diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt new file mode 100644 index 0000000..3040ad5 --- /dev/null +++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreenPreview.kt @@ -0,0 +1,67 @@ +package net.thunderbird.feature.debug.settings + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.common.koin.koinPreview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemesLightDark +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.flowOf +import net.thunderbird.core.common.resources.StringsResourceManager +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSectionViewModel +import net.thunderbird.feature.mail.account.api.AccountManager +import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure +import net.thunderbird.feature.notification.api.command.NotificationCommand.Success +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver +import net.thunderbird.feature.notification.api.sender.NotificationSender + +@PreviewLightDark +@Composable +private fun SecretDebugSettingsScreenPreview() { + koinPreview { + single { + DebugNotificationSectionViewModel( + stringsResourceManager = object : StringsResourceManager { + override fun stringResource(resourceId: Int): String = "fake" + + override fun stringResource(resourceId: Int, vararg formatArgs: Any?): String = "fake" + }, + accountManager = object : AccountManager { + override fun getAccounts(): List = listOf() + override fun getAccountsFlow(): Flow> = flowOf(listOf()) + override fun getAccount(accountUuid: String): BaseAccount? = null + override fun getAccountFlow(accountUuid: String): Flow = flowOf(null) + override fun moveAccount( + account: BaseAccount, + newPosition: Int, + ) = Unit + + override fun saveAccount(account: BaseAccount) = Unit + }, + notificationSender = object : NotificationSender { + override fun send( + notification: Notification, + ): Flow, Failure>> = + error("not implemented") + }, + notificationReceiver = object : InAppNotificationReceiver { + override val events: SharedFlow + get() = error("not implemented") + }, + ) + } + } WithContent { + PreviewWithThemesLightDark { + SecretDebugSettingsScreen( + onNavigateBack = { }, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt new file mode 100644 index 0000000..84da33c --- /dev/null +++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt @@ -0,0 +1,19 @@ +package net.thunderbird.feature.debug.settings.inject + +import net.thunderbird.feature.debug.settings.navigation.DefaultSecretDebugSettingsNavigation +import net.thunderbird.feature.debug.settings.navigation.SecretDebugSettingsNavigation +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSectionViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val featureDebugSettingsModule = module { + single { DefaultSecretDebugSettingsNavigation() } + viewModel { + DebugNotificationSectionViewModel( + stringsResourceManager = get(), + accountManager = get(), + notificationSender = get(), + notificationReceiver = get(), + ) + } +} diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/navigation/DefaultSecretDebugSettingsNavigation.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/navigation/DefaultSecretDebugSettingsNavigation.kt new file mode 100644 index 0000000..21823c9 --- /dev/null +++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/navigation/DefaultSecretDebugSettingsNavigation.kt @@ -0,0 +1,25 @@ +package net.thunderbird.feature.debug.settings.navigation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import app.k9mail.core.ui.compose.navigation.deepLinkComposable +import net.thunderbird.feature.debug.settings.SecretDebugSettingsScreen +import net.thunderbird.feature.debug.settings.navigation.SecretDebugSettingsRoute.Notification + +internal class DefaultSecretDebugSettingsNavigation : SecretDebugSettingsNavigation { + override fun registerRoutes( + navGraphBuilder: NavGraphBuilder, + onBack: () -> Unit, + onFinish: (SecretDebugSettingsRoute) -> Unit, + ) { + with(navGraphBuilder) { + deepLinkComposable(Notification.basePath) { + SecretDebugSettingsScreen( + onNavigateBack = onBack, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} diff --git a/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionPreview.kt b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionPreview.kt new file mode 100644 index 0000000..cad88c5 --- /dev/null +++ b/feature/debug-settings/src/debug/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionPreview.kt @@ -0,0 +1,78 @@ +package net.thunderbird.feature.debug.settings.notification + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemeLightDark +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlinx.collections.immutable.toPersistentList +import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.notification.api.content.MailNotification + +@OptIn(ExperimentalUuidApi::class) +@PreviewLightDark +@Composable +private fun DebugNotificationSectionPreview() { + PreviewWithThemeLightDark { + val accounts = remember { + List(size = 10) { + object : BaseAccount { + override val uuid: String = Uuid.random().toString() + override val name: String? = "Account $it" + override val email: String = "account-$it@mail.com" + } + }.toPersistentList() + } + var state by remember { + mutableStateOf( + DebugNotificationSectionContract.State( + accounts = accounts, + selectedAccount = accounts.first(), + ), + ) + } + DebugNotificationSection( + state = state, + modifier = Modifier.padding(MainTheme.spacings.triple), + onAccountSelect = { state = state.copy(selectedAccount = it) }, + ) + } +} + +@OptIn(ExperimentalUuidApi::class) +@PreviewLightDark +@Composable +private fun PreviewSingleMailNotification() { + PreviewWithThemeLightDark { + val accounts = remember { + List(size = 10) { + object : BaseAccount { + override val uuid: String = Uuid.random().toString() + override val name: String? = "Account $it" + override val email: String = "account-$it@mail.com" + } + }.toPersistentList() + } + var state by remember { + mutableStateOf( + DebugNotificationSectionContract.State( + accounts = accounts, + selectedAccount = accounts.first(), + selectedSystemNotificationType = MailNotification.NewMailSingleMail::class, + ), + ) + } + DebugNotificationSection( + state = state, + modifier = Modifier.padding(MainTheme.spacings.triple), + onAccountSelect = { state = state.copy(selectedAccount = it) }, + ) + } +} diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/DebugSection.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/DebugSection.kt new file mode 100644 index 0000000..b55a925 --- /dev/null +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/DebugSection.kt @@ -0,0 +1,49 @@ +package net.thunderbird.feature.debug.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.DividerHorizontal +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +internal fun DebugSection( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + DebugSection( + title = { TextTitleLarge(title) }, + modifier = modifier, + content = content, + ) +} + +@Composable +internal fun DebugSubSection( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + DebugSection( + title = { TextTitleMedium(title) }, + modifier = modifier, + content = content, + ) +} + +@Composable +internal fun DebugSection( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column(modifier = modifier) { + title() + DividerHorizontal(modifier = Modifier.padding(vertical = MainTheme.spacings.double)) + content() + } +} diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreen.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreen.kt new file mode 100644 index 0000000..de31029 --- /dev/null +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreen.kt @@ -0,0 +1,45 @@ +package net.thunderbird.feature.debug.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBar +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.core.ui.compose.theme2.MainTheme +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSection + +@Composable +fun SecretDebugSettingsScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.debug_settings_screen_title), + navigationIcon = { + ButtonIcon( + onClick = onNavigateBack, + imageVector = Icons.Outlined.ArrowBack, + ) + }, + ) + }, + modifier = modifier, + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(MainTheme.spacings.double), + ) { + DebugNotificationSection() + } + } +} diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/navigation/SecretDebugSettingsNavigation.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/navigation/SecretDebugSettingsNavigation.kt new file mode 100644 index 0000000..f80801a --- /dev/null +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/navigation/SecretDebugSettingsNavigation.kt @@ -0,0 +1,5 @@ +package net.thunderbird.feature.debug.settings.navigation + +import app.k9mail.core.ui.compose.navigation.Navigation + +interface SecretDebugSettingsNavigation : Navigation diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/navigation/SecretDebugSettingsRoute.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/navigation/SecretDebugSettingsRoute.kt new file mode 100644 index 0000000..4cae02e --- /dev/null +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/navigation/SecretDebugSettingsRoute.kt @@ -0,0 +1,17 @@ +package net.thunderbird.feature.debug.settings.navigation + +import app.k9mail.core.ui.compose.navigation.Route +import kotlinx.serialization.Serializable + +sealed interface SecretDebugSettingsRoute : Route { + @Serializable + data object Notification : SecretDebugSettingsRoute { + override val basePath: String = "$SECRET_DEBUG_SETTINGS/notification" + + override fun route(): String = basePath + } + + companion object { + const val SECRET_DEBUG_SETTINGS = "app://secret_debug_settings" + } +} diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSection.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSection.kt new file mode 100644 index 0000000..115c173 --- /dev/null +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSection.kt @@ -0,0 +1,270 @@ +package net.thunderbird.feature.debug.settings.notification + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import app.k9mail.core.ui.compose.common.mvi.observeWithoutEffect +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.molecule.input.SelectInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput +import app.k9mail.core.ui.compose.theme2.MainTheme +import kotlin.reflect.KClass +import kotlinx.collections.immutable.ImmutableList +import net.thunderbird.feature.debug.settings.DebugSection +import net.thunderbird.feature.debug.settings.DebugSubSection +import net.thunderbird.feature.debug.settings.R +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSectionContract.Event +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSectionContract.ViewModel +import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.notification.api.content.MailNotification +import net.thunderbird.feature.notification.api.content.Notification +import org.koin.androidx.compose.koinViewModel + +private const val UUID_MAX_CHAR_DISPLAY = 4 + +@Composable +internal fun DebugNotificationSection( + modifier: Modifier = Modifier, + viewModel: ViewModel = koinViewModel(), +) { + val (state, dispatchEvent) = viewModel.observeWithoutEffect() + + DebugNotificationSection( + state = state.value, + modifier = modifier, + onAccountSelect = { account -> + dispatchEvent(Event.SelectAccount(account)) + }, + onOptionChange = { notificationType -> + dispatchEvent(Event.SelectNotificationType(notificationType)) + }, + onTriggerSystemNotificationClick = { dispatchEvent(Event.TriggerSystemNotification) }, + onTriggerInAppNotificationClick = { dispatchEvent(Event.TriggerInAppNotification) }, + onSenderChange = { dispatchEvent(Event.OnSenderChange(it)) }, + onSubjectChange = { dispatchEvent(Event.OnSubjectChange(it)) }, + onSummaryChange = { dispatchEvent(Event.OnSummaryChange(it)) }, + onPreviewChange = { dispatchEvent(Event.OnPreviewChange(it)) }, + onClearStatusLog = { dispatchEvent(Event.ClearStatusLog) }, + ) +} + +@Composable +internal fun DebugNotificationSection( + state: DebugNotificationSectionContract.State, + modifier: Modifier = Modifier, + onAccountSelect: (BaseAccount) -> Unit = {}, + onOptionChange: (KClass) -> Unit = {}, + onTriggerSystemNotificationClick: () -> Unit = {}, + onTriggerInAppNotificationClick: () -> Unit = {}, + onSenderChange: (String) -> Unit = {}, + onSubjectChange: (String) -> Unit = {}, + onSummaryChange: (String) -> Unit = {}, + onPreviewChange: (String) -> Unit = {}, + onClearStatusLog: () -> Unit = {}, +) { + DebugSection( + title = stringResource(R.string.debug_settings_notifications_title), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.quadruple), + ) { + CommonNotificationInformation(state, onAccountSelect) + SystemNotificationSection( + state = state, + onOptionChange = onOptionChange, + onClick = onTriggerSystemNotificationClick, + onSenderChange = onSenderChange, + onSubjectChange = onSubjectChange, + onSummaryChange = onSummaryChange, + onPreviewChange = onPreviewChange, + ) + InAppNotificationSection( + selectedNotificationType = state.selectedInAppNotificationType, + options = state.inAppNotificationTypes, + onOptionChange = onOptionChange, + onClick = onTriggerInAppNotificationClick, + ) + NotificationStatusLog(state.notificationStatusLog, onClearStatusLog) + } + } +} + +@Composable +private fun CommonNotificationInformation( + state: DebugNotificationSectionContract.State, + onAccountSelect: (BaseAccount) -> Unit, + modifier: Modifier = Modifier, +) { + DebugSubSection( + title = stringResource(R.string.debug_settings_notifications_common_notification_information), + modifier = modifier.padding(start = MainTheme.spacings.double), + ) { + val loadingText = stringResource(R.string.debug_settings_notifications_loading) + SelectInput( + options = state.accounts, + selectedOption = state.selectedAccount, + onOptionChange = { account -> + account?.let(onAccountSelect) + }, + optionToStringTransformation = { account -> + account?.let { account -> + val uuidStart = account.uuid.take(UUID_MAX_CHAR_DISPLAY) + val uuidEnd = account.uuid.take(UUID_MAX_CHAR_DISPLAY) + val accountDisplay = account.name ?: account.email + "$uuidStart..$uuidEnd - $accountDisplay" + } ?: loadingText + }, + ) + } +} + +@Composable +private fun SystemNotificationSection( + state: DebugNotificationSectionContract.State, + onOptionChange: (KClass) -> Unit, + onClick: () -> Unit, + onSenderChange: (String) -> Unit, + onSubjectChange: (String) -> Unit, + onSummaryChange: (String) -> Unit, + onPreviewChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + DebugSubSection( + title = stringResource(R.string.debug_settings_notifications_system_notification), + modifier = modifier.padding(start = MainTheme.spacings.double), + ) { + Column { + TriggerNotificationSection( + selectedNotificationType = state.selectedSystemNotificationType, + options = state.systemNotificationTypes, + onOptionChange = onOptionChange, + onClick = onClick, + ) + AnimatedVisibility(state.selectedSystemNotificationType == MailNotification.NewMailSingleMail::class) { + Column { + TextInput( + onTextChange = onSenderChange, + text = state.singleNotificationData.sender, + label = stringResource(R.string.debug_settings_notifications_single_mail_sender), + ) + TextInput( + onTextChange = onSubjectChange, + text = state.singleNotificationData.subject, + label = stringResource(R.string.debug_settings_notifications_single_mail_subject), + ) + TextInput( + onTextChange = onSummaryChange, + text = state.singleNotificationData.summary, + label = stringResource(R.string.debug_settings_notifications_single_mail_summary), + ) + TextInput( + onTextChange = onPreviewChange, + text = state.singleNotificationData.preview, + label = stringResource(R.string.debug_settings_notifications_single_mail_preview), + ) + } + } + } + } +} + +@Composable +private fun InAppNotificationSection( + selectedNotificationType: KClass?, + options: ImmutableList>, + onOptionChange: (KClass) -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + DebugSubSection( + title = stringResource(R.string.debug_settings_notifications_in_app_notification), + modifier = modifier.padding(start = MainTheme.spacings.double), + ) { + TriggerNotificationSection( + selectedNotificationType = selectedNotificationType, + options = options, + onOptionChange = onOptionChange, + onClick = onClick, + ) + } +} + +@Composable +private fun TriggerNotificationSection( + selectedNotificationType: KClass?, + options: ImmutableList>, + onOptionChange: (KClass) -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.oneHalf), + modifier = modifier, + ) { + val selectedOption = remember(selectedNotificationType, options) { + selectedNotificationType ?: options.firstOrNull() + } + val loadingText = stringResource(R.string.debug_settings_notifications_loading) + SelectInput( + options = options, + selectedOption = selectedOption, + onOptionChange = { it?.let(onOptionChange) }, + optionToStringTransformation = { kClass -> kClass?.realName ?: loadingText }, + ) + + ButtonFilled( + text = stringResource(R.string.debug_settings_notifications_trigger_notification), + onClick = onClick, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@Composable +private fun ColumnScope.NotificationStatusLog( + notificationStatusLog: ImmutableList, + onClearStatusLog: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = notificationStatusLog.isNotEmpty(), + modifier = modifier.padding(start = MainTheme.spacings.double), + ) { + DebugSubSection( + title = stringResource(R.string.debug_settings_notification_status_log), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + TextBodyMedium( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + appendLine(stringResource(R.string.debug_settings_notifications_status)) + } + notificationStatusLog.forEach { status -> + appendLine(status) + } + }, + ) + ButtonText( + text = stringResource(R.string.debug_settings_notifications_clear_status_log), + onClick = onClearStatusLog, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } +} diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionContract.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionContract.kt new file mode 100644 index 0000000..313b12d --- /dev/null +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionContract.kt @@ -0,0 +1,50 @@ +package net.thunderbird.feature.debug.settings.notification + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import kotlin.reflect.KClass +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.notification.api.content.Notification + +internal interface DebugNotificationSectionContract { + + interface ViewModel : UnidirectionalViewModel + + data class State( + val accounts: ImmutableList = persistentListOf(), + val selectedAccount: BaseAccount? = null, + val notificationStatusLog: ImmutableList = persistentListOf("Ready to send notification"), + val selectedSystemNotificationType: KClass? = null, + val selectedInAppNotificationType: KClass? = null, + val folderName: String? = null, + val singleNotificationData: MailSingleNotificationData = MailSingleNotificationData.Undefined, + val systemNotificationTypes: ImmutableList> = persistentListOf(), + val inAppNotificationTypes: ImmutableList> = persistentListOf(), + ) { + data class MailSingleNotificationData( + val sender: String = "", + val subject: String = "", + val summary: String = "", + val preview: String = "", + ) { + companion object { + val Undefined = MailSingleNotificationData() + } + } + } + + sealed interface Event { + data class SelectAccount(val account: BaseAccount) : Event + data class SelectNotificationType(val notificationType: KClass) : Event + data object TriggerSystemNotification : Event + data object TriggerInAppNotification : Event + data class OnSenderChange(val sender: String) : Event + data class OnSubjectChange(val subject: String) : Event + data class OnSummaryChange(val summary: String) : Event + data class OnPreviewChange(val preview: String) : Event + data object ClearStatusLog : Event + } + + sealed interface Effect +} diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt new file mode 100644 index 0000000..b355418 --- /dev/null +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt @@ -0,0 +1,293 @@ +package net.thunderbird.feature.debug.settings.notification + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import kotlin.reflect.KClass +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.thunderbird.core.common.resources.StringsResourceManager +import net.thunderbird.feature.debug.settings.R +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSectionContract.Effect +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSectionContract.Event +import net.thunderbird.feature.debug.settings.notification.DebugNotificationSectionContract.State +import net.thunderbird.feature.mail.account.api.AccountManager +import net.thunderbird.feature.mail.account.api.BaseAccount +import net.thunderbird.feature.notification.api.NotificationGroup +import net.thunderbird.feature.notification.api.NotificationGroupKey +import net.thunderbird.feature.notification.api.content.AuthenticationErrorNotification +import net.thunderbird.feature.notification.api.content.CertificateErrorNotification +import net.thunderbird.feature.notification.api.content.FailedToCreateNotification +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.content.MailNotification +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.content.PushServiceNotification +import net.thunderbird.feature.notification.api.content.SystemNotification +import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver +import net.thunderbird.feature.notification.api.sender.NotificationSender + +internal class DebugNotificationSectionViewModel( + private val stringsResourceManager: StringsResourceManager, + private val accountManager: AccountManager, + private val notificationSender: NotificationSender, + private val notificationReceiver: InAppNotificationReceiver, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : BaseViewModel(initialState = State()), DebugNotificationSectionContract.ViewModel { + + init { + viewModelScope.launch(ioDispatcher) { + val accounts = accountManager.getAccounts() + withContext(mainDispatcher) { + updateState { + val systemNotificationTypes = buildList { + add(AuthenticationErrorNotification::class) + add(CertificateErrorNotification::class) + add(FailedToCreateNotification::class) + add(MailNotification.Fetching::class) + add(MailNotification.NewMailSingleMail::class) + add(MailNotification.NewMailSummaryMail::class) + add(MailNotification.SendFailed::class) + add(MailNotification.Sending::class) + add(PushServiceNotification.AlarmPermissionMissing::class) + add(PushServiceNotification.Initializing::class) + add(PushServiceNotification.Listening::class) + add(PushServiceNotification.WaitBackgroundSync::class) + add(PushServiceNotification.WaitNetwork::class) + }.toPersistentList() + + val inAppNotificationTypes = buildList { + add(AuthenticationErrorNotification::class) + add(CertificateErrorNotification::class) + add(FailedToCreateNotification::class) + add(MailNotification.SendFailed::class) + add(PushServiceNotification.AlarmPermissionMissing::class) + }.toPersistentList() + State( + accounts = accounts.toPersistentList(), + selectedAccount = accounts.first(), + systemNotificationTypes = systemNotificationTypes, + inAppNotificationTypes = inAppNotificationTypes, + selectedSystemNotificationType = systemNotificationTypes.first(), + selectedInAppNotificationType = inAppNotificationTypes.first(), + ) + } + } + } + + viewModelScope.launch { + notificationReceiver + .events + .collectLatest { event -> + updateState { state -> + state.copy( + notificationStatusLog = state.notificationStatusLog + " In-app notification event: $event", + ) + } + } + } + } + + override fun event(event: Event) { + when (event) { + is Event.TriggerSystemNotification -> viewModelScope.launch { + if (state.value.selectedSystemNotificationType == null) { + updateState { + it.copy(selectedSystemNotificationType = state.value.systemNotificationTypes.first()) + } + } + triggerNotification( + notification = requireNotNull(buildNotification(state.value.selectedSystemNotificationType)), + ) + } + + is Event.TriggerInAppNotification -> viewModelScope.launch { + if (state.value.selectedInAppNotificationType == null) { + updateState { + it.copy(selectedInAppNotificationType = state.value.inAppNotificationTypes.first()) + } + } + triggerNotification( + notification = requireNotNull(buildNotification(state.value.selectedInAppNotificationType)), + ) + } + + is Event.SelectAccount -> updateState { state -> + state.copy(selectedAccount = event.account) + } + + is Event.SelectNotificationType -> viewModelScope.launch { + buildNotification(event.notificationType) + } + + is Event.OnSenderChange -> updateState { + it.copy(singleNotificationData = it.singleNotificationData.copy(sender = event.sender)) + } + + is Event.OnSubjectChange -> updateState { + it.copy(singleNotificationData = it.singleNotificationData.copy(subject = event.subject)) + } + + is Event.OnSummaryChange -> updateState { + it.copy(singleNotificationData = it.singleNotificationData.copy(summary = event.summary)) + } + + is Event.OnPreviewChange -> updateState { + it.copy(singleNotificationData = it.singleNotificationData.copy(preview = event.preview)) + } + + Event.ClearStatusLog -> updateState { it.copy(notificationStatusLog = persistentListOf()) } + } + } + + private suspend fun triggerNotification( + notification: Notification, + ) { + notification.let { notification -> + notificationSender + .send(notification) + .collect { result -> + updateState { + it.copy(notificationStatusLog = it.notificationStatusLog + "Result: $result") + } + } + } + } + + private suspend fun buildNotification(notificationType: KClass?): Notification? { + updateState { + it.copy( + notificationStatusLog = it.notificationStatusLog + + stringsResourceManager.stringResource( + R.string.debug_settings_notifications_preparing_notification, + notificationType?.realName, + ), + ) + } + + val state = state.value + val selectedAccount = state.selectedAccount ?: return null + val accountDisplay = selectedAccount.name ?: selectedAccount.email + + val notification = buildNotification( + notificationType = notificationType, + selectedAccount = selectedAccount, + accountDisplay = accountDisplay, + state = state, + ) + + updateState { state -> + state.copy( + selectedSystemNotificationType = (notification as? SystemNotification)?.let { it::class } + ?: state.selectedSystemNotificationType, + selectedInAppNotificationType = (notification as? InAppNotification)?.let { it::class } + ?: state.selectedInAppNotificationType, + ) + } + + return notification + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + private suspend fun buildNotification( + notificationType: KClass?, + selectedAccount: BaseAccount, + accountDisplay: String, + state: State, + ): Notification? = when (notificationType) { + AuthenticationErrorNotification::class -> AuthenticationErrorNotification( + accountUuid = selectedAccount.uuid, + accountDisplayName = accountDisplay, + ) + + CertificateErrorNotification::class -> CertificateErrorNotification( + accountUuid = selectedAccount.uuid, + accountDisplayName = accountDisplay, + ) + + FailedToCreateNotification::class -> FailedToCreateNotification( + accountUuid = selectedAccount.uuid, + failedNotification = AuthenticationErrorNotification( + accountUuid = selectedAccount.uuid, + accountDisplayName = accountDisplay, + ), + ) + + MailNotification.Fetching::class -> MailNotification.Fetching( + accountUuid = selectedAccount.uuid, + accountDisplayName = accountDisplay, + folderName = state.folderName, + ) + + MailNotification.NewMailSingleMail::class -> state.buildSingleMailNotification( + selectedAccount = selectedAccount, + accountDisplay = accountDisplay, + ) + + MailNotification.NewMailSummaryMail::class -> MailNotification.NewMailSummaryMail( + accountUuid = selectedAccount.uuid, + accountDisplayName = accountDisplay, + messagesNotificationChannelSuffix = "", + newMessageCount = 10, + additionalMessagesCount = 10, + group = NotificationGroup( + key = NotificationGroupKey("key"), + summary = "", + ), + ) + + MailNotification.SendFailed::class -> MailNotification.SendFailed( + accountUuid = selectedAccount.uuid, + exception = Exception("What a failure"), + ) + + MailNotification.Sending::class -> MailNotification.Sending( + accountUuid = selectedAccount.uuid, + accountDisplayName = accountDisplay, + ) + + PushServiceNotification.AlarmPermissionMissing::class -> PushServiceNotification.AlarmPermissionMissing() + + PushServiceNotification.Initializing::class -> PushServiceNotification.Initializing() + + PushServiceNotification.Listening::class -> PushServiceNotification.Listening() + + PushServiceNotification.WaitBackgroundSync::class -> PushServiceNotification.WaitBackgroundSync() + + PushServiceNotification.WaitNetwork::class -> PushServiceNotification.WaitNetwork() + + else -> null + } + + private fun State.buildSingleMailNotification( + selectedAccount: BaseAccount, + accountDisplay: String, + ): MailNotification.NewMailSingleMail? = MailNotification.NewMailSingleMail( + accountUuid = selectedAccount.uuid, + accountName = accountDisplay, + messagesNotificationChannelSuffix = "", + summary = singleNotificationData.summary, + sender = singleNotificationData.sender, + subject = singleNotificationData.subject, + preview = singleNotificationData.preview, + group = null, + ) + + private operator fun ImmutableList.plus(other: String): ImmutableList = + (this.toMutableList() + other).toPersistentList() +} + +internal val KClass.realName: String + get() { + val clazz = java + + return clazz.name + .replace(clazz.`package`?.name.orEmpty(), "") + .removePrefix(".") + .replace("$", ".") + } diff --git a/feature/debug-settings/src/main/res/values/strings.xml b/feature/debug-settings/src/main/res/values/strings.xml new file mode 100644 index 0000000..412c58b --- /dev/null +++ b/feature/debug-settings/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + Secret Debug Settings Screen + Loading… + Trigger notification + Sender + Subject + Summary + Preview + Common notification information + In-App notification + Notifications + System notification + Preparing notification %1$s + Notification status log + "Status: " + Clear status log + diff --git a/feature/debug-settings/src/release/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt b/feature/debug-settings/src/release/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt new file mode 100644 index 0000000..984e591 --- /dev/null +++ b/feature/debug-settings/src/release/kotlin/net/thunderbird/feature/debug/settings/inject/FeatureDebugSettingsModule.kt @@ -0,0 +1,9 @@ +package net.thunderbird.feature.debug.settings.inject + +import net.thunderbird.feature.debug.settings.navigation.NoOpSecretDebugSettingsNavigation +import net.thunderbird.feature.debug.settings.navigation.SecretDebugSettingsNavigation +import org.koin.dsl.module + +val featureDebugSettingsModule = module { + single { NoOpSecretDebugSettingsNavigation } +} diff --git a/feature/debug-settings/src/release/kotlin/net/thunderbird/feature/debug/settings/navigation/NoOpSecretDebugSettingsNavigation.kt b/feature/debug-settings/src/release/kotlin/net/thunderbird/feature/debug/settings/navigation/NoOpSecretDebugSettingsNavigation.kt new file mode 100644 index 0000000..eb1b834 --- /dev/null +++ b/feature/debug-settings/src/release/kotlin/net/thunderbird/feature/debug/settings/navigation/NoOpSecretDebugSettingsNavigation.kt @@ -0,0 +1,11 @@ +package net.thunderbird.feature.debug.settings.navigation + +import androidx.navigation.NavGraphBuilder + +object NoOpSecretDebugSettingsNavigation : SecretDebugSettingsNavigation { + override fun registerRoutes( + navGraphBuilder: NavGraphBuilder, + onBack: () -> Unit, + onFinish: (SecretDebugSettingsRoute) -> Unit, + ) = Unit +} diff --git a/feature/funding/api/build.gradle.kts b/feature/funding/api/build.gradle.kts new file mode 100644 index 0000000..5b3da51 --- /dev/null +++ b/feature/funding/api/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "app.k9mail.feature.funding.api" + resourcePrefix = "funding_api_" +} + +dependencies { + api(projects.core.ui.compose.navigation) +} diff --git a/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingManager.kt b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingManager.kt new file mode 100644 index 0000000..51050a6 --- /dev/null +++ b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingManager.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.funding.api + +import androidx.appcompat.app.AppCompatActivity + +interface FundingManager { + /** + * Returns the type of funding. + */ + fun getFundingType(): FundingType + + /** + * Adds a funding reminder. + * + * The reminder is registered to the current lifecycle of the Activity. + * + * @param activity The activity to register the reminder to. + * @param onOpenFunding The callback to be called when the user opens the funding. + */ + fun addFundingReminder(activity: AppCompatActivity, onOpenFunding: () -> Unit) +} diff --git a/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingNavigation.kt b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingNavigation.kt new file mode 100644 index 0000000..3ef6b86 --- /dev/null +++ b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingNavigation.kt @@ -0,0 +1,20 @@ +package app.k9mail.feature.funding.api + +import app.k9mail.core.ui.compose.navigation.Navigation +import app.k9mail.core.ui.compose.navigation.Route +import kotlinx.serialization.Serializable + +const val FUNDING_BASE_DEEP_LINK = "app://feature/funding" + +sealed interface FundingRoute : Route { + @Serializable + data object Contribution : FundingRoute { + override val basePath: String = BASE_PATH + + override fun route(): String = basePath + + const val BASE_PATH = "$FUNDING_BASE_DEEP_LINK/contribution" + } +} + +interface FundingNavigation : Navigation diff --git a/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingSettings.kt b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingSettings.kt new file mode 100644 index 0000000..b5e37eb --- /dev/null +++ b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingSettings.kt @@ -0,0 +1,12 @@ +package app.k9mail.feature.funding.api + +interface FundingSettings { + fun getReminderReferenceTimestamp(): Long + fun setReminderReferenceTimestamp(timestamp: Long) + + fun getReminderShownTimestamp(): Long + fun setReminderShownTimestamp(timestamp: Long) + + fun getActivityCounterInMillis(): Long + fun setActivityCounterInMillis(activeTime: Long) +} diff --git a/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingType.kt b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingType.kt new file mode 100644 index 0000000..beab5c7 --- /dev/null +++ b/feature/funding/api/src/main/kotlin/app/k9mail/feature/funding/api/FundingType.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.funding.api + +enum class FundingType { + GOOGLE_PLAY, + LINK, + NO_FUNDING, +} diff --git a/feature/funding/googleplay/build.gradle.kts b/feature/funding/googleplay/build.gradle.kts new file mode 100644 index 0000000..b461f36 --- /dev/null +++ b/feature/funding/googleplay/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id(ThunderbirdPlugins.Library.androidCompose) +} + +android { + namespace = "app.k9mail.feature.funding.googleplay" + resourcePrefix = "funding_googleplay_" +} + +dependencies { + api(projects.feature.funding.api) + + implementation(projects.core.common) + implementation(projects.core.outcome) + implementation(projects.core.logging.api) + implementation(projects.core.ui.compose.designsystem) + + implementation(libs.android.billing) + implementation(libs.android.billing.ktx) + implementation(libs.android.material) + + testImplementation(projects.core.testing) + testImplementation(projects.core.ui.compose.testing) + + testImplementation(libs.androidx.lifecycle.runtime.testing) + testImplementation(libs.androidx.fragment.testing) +} diff --git a/feature/funding/googleplay/src/debug/AndroidManifest.xml b/feature/funding/googleplay/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..43db1e8 --- /dev/null +++ b/feature/funding/googleplay/src/debug/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContentPreview.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContentPreview.kt new file mode 100644 index 0000000..02c42b1 --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContentPreview.kt @@ -0,0 +1,91 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.funding.googleplay.domain.DomainContract +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ContributionListState +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State + +@Composable +@Preview(showBackground = true) +fun ContributionContentPreview() { + PreviewWithTheme { + ContributionContent( + state = State( + listState = ContributionListState( + recurringContributions = FakeData.recurringContributions, + oneTimeContributions = FakeData.oneTimeContributions, + selectedContribution = FakeData.recurringContributions.first(), + isLoading = false, + ), + ), + onEvent = {}, + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionContentEmptyPreview() { + PreviewWithTheme { + ContributionContent( + state = State( + listState = ContributionListState( + isLoading = false, + ), + ), + onEvent = {}, + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionContentLoadingPreview() { + PreviewWithTheme { + ContributionContent( + state = State( + listState = ContributionListState( + isLoading = true, + ), + ), + onEvent = {}, + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionContentListErrorPreview() { + PreviewWithTheme { + ContributionContent( + state = State( + listState = ContributionListState( + error = DomainContract.BillingError.DeveloperError("Developer error"), + isLoading = false, + ), + ), + onEvent = {}, + contentPadding = PaddingValues(), + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionContentPurchaseErrorPreview() { + PreviewWithTheme { + ContributionContent( + state = State( + purchaseError = DomainContract.BillingError.DeveloperError("Developer error"), + ), + onEvent = {}, + contentPadding = PaddingValues(), + ) + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionErrorPreview.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionErrorPreview.kt new file mode 100644 index 0000000..f73ff3a --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionErrorPreview.kt @@ -0,0 +1,50 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError + +@Composable +@Preview(showBackground = true) +fun ContributionErrorPurchaseFailedPreview() { + PreviewWithTheme { + ContributionError( + error = BillingError.PurchaseFailed("Purchase failed"), + onDismissClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionErrorServiceDisconnectedPreview() { + PreviewWithTheme { + ContributionError( + error = BillingError.ServiceDisconnected("Service disconnected"), + onDismissClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionErrorUnknownErrorPreview() { + PreviewWithTheme { + ContributionError( + error = BillingError.DeveloperError("Unknown error"), + onDismissClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionErrorDeveloperErrorPreview() { + PreviewWithTheme { + ContributionError( + error = BillingError.UserCancelled("User cancelled"), + onDismissClick = {}, + ) + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionFooterPreview.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionFooterPreview.kt new file mode 100644 index 0000000..c391b97 --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionFooterPreview.kt @@ -0,0 +1,80 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@Preview(showBackground = true) +fun ContributionFooterPreview() { + PreviewWithTheme { + ContributionFooter( + onPurchaseClick = {}, + onManagePurchaseClick = {}, + onShowContributionListClick = {}, + purchasedContribution = null, + isPurchaseEnabled = true, + isContributionListShown = true, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionFooterDisabledPreview() { + PreviewWithTheme { + ContributionFooter( + purchasedContribution = null, + onPurchaseClick = {}, + onManagePurchaseClick = {}, + onShowContributionListClick = {}, + isContributionListShown = false, + isPurchaseEnabled = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionFooterWithRecurringContributionPreview() { + PreviewWithTheme { + ContributionFooter( + purchasedContribution = FakeData.recurringContribution, + onPurchaseClick = {}, + onManagePurchaseClick = {}, + onShowContributionListClick = {}, + isPurchaseEnabled = true, + isContributionListShown = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionFooterWithOneTimeContributionPreview() { + PreviewWithTheme { + ContributionFooter( + purchasedContribution = FakeData.oneTimeContribution, + onPurchaseClick = {}, + onManagePurchaseClick = {}, + onShowContributionListClick = {}, + isPurchaseEnabled = true, + isContributionListShown = false, + ) + } +} + +@Composable +@Preview(showBackground = true) +fun ContributionFooterWithOneTimeContributionAndListPreview() { + PreviewWithTheme { + ContributionFooter( + purchasedContribution = FakeData.oneTimeContribution, + onPurchaseClick = {}, + onManagePurchaseClick = {}, + onShowContributionListClick = {}, + isPurchaseEnabled = true, + isContributionListShown = true, + ) + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionHeaderPreview.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionHeaderPreview.kt new file mode 100644 index 0000000..e95de64 --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionHeaderPreview.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@Preview(showBackground = true) +internal fun ContributionHeaderPreview() { + PreviewWithTheme { + ContributionHeader(purchasedContribution = null) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionHeaderWithPurchasedOneTimeContributionPreview() { + PreviewWithTheme { + ContributionHeader( + purchasedContribution = FakeData.oneTimeContribution, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionHeaderWithPurchasedRecurringContributionPreview() { + PreviewWithTheme { + ContributionHeader( + purchasedContribution = FakeData.recurringContribution, + ) + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListItemPreview.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListItemPreview.kt new file mode 100644 index 0000000..32579c9 --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListItemPreview.kt @@ -0,0 +1,28 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme + +@Composable +@Preview(showBackground = true) +internal fun ContributionListItemPreview() { + PreviewWithTheme { + ContributionListItem( + text = "Monthly", + onClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionListItemPreviewSelected() { + PreviewWithTheme { + ContributionListItem( + text = "Monthly", + onClick = {}, + isSelected = true, + ) + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListPreview.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListPreview.kt new file mode 100644 index 0000000..e9631c1 --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListPreview.kt @@ -0,0 +1,151 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.funding.googleplay.domain.DomainContract +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ContributionListState +import kotlinx.collections.immutable.persistentListOf + +@Composable +@Preview(showBackground = true) +internal fun ContributionListPreview() { + PreviewWithTheme { + ContributionList( + state = ContributionListState( + oneTimeContributions = FakeData.oneTimeContributions, + recurringContributions = FakeData.recurringContributions, + selectedContribution = FakeData.recurringContributions.first(), + isRecurringContributionSelected = true, + isLoading = false, + ), + onOneTimeContributionTypeClick = {}, + onRecurringContributionTypeClick = {}, + onItemClick = {}, + onRetryClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionListRecurringPreview() { + PreviewWithTheme { + ContributionList( + state = ContributionListState( + oneTimeContributions = FakeData.oneTimeContributions, + recurringContributions = FakeData.recurringContributions, + selectedContribution = FakeData.oneTimeContributions.last(), + isRecurringContributionSelected = false, + isLoading = false, + ), + onOneTimeContributionTypeClick = {}, + onRecurringContributionTypeClick = {}, + onItemClick = {}, + onRetryClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionListOneTimeOnlyPreview() { + PreviewWithTheme { + ContributionList( + state = ContributionListState( + oneTimeContributions = FakeData.oneTimeContributions, + recurringContributions = persistentListOf(), + selectedContribution = null, + isRecurringContributionSelected = false, + isLoading = false, + ), + onOneTimeContributionTypeClick = {}, + onRecurringContributionTypeClick = {}, + onItemClick = {}, + onRetryClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionListRecurringOnlyPreview() { + PreviewWithTheme { + ContributionList( + state = ContributionListState( + oneTimeContributions = persistentListOf(), + recurringContributions = FakeData.recurringContributions, + selectedContribution = null, + isRecurringContributionSelected = true, + isLoading = false, + ), + onOneTimeContributionTypeClick = {}, + onRecurringContributionTypeClick = {}, + onItemClick = {}, + onRetryClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionListEmptyPreview() { + PreviewWithTheme { + ContributionList( + state = ContributionListState( + oneTimeContributions = persistentListOf(), + recurringContributions = persistentListOf(), + selectedContribution = null, + isRecurringContributionSelected = false, + isLoading = false, + ), + onOneTimeContributionTypeClick = {}, + onRecurringContributionTypeClick = {}, + onItemClick = {}, + onRetryClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionListLoadingPreview() { + PreviewWithTheme { + ContributionList( + state = ContributionListState( + oneTimeContributions = persistentListOf(), + recurringContributions = persistentListOf(), + selectedContribution = null, + isRecurringContributionSelected = false, + isLoading = true, + ), + onOneTimeContributionTypeClick = {}, + onRecurringContributionTypeClick = {}, + onItemClick = {}, + onRetryClick = {}, + ) + } +} + +@Composable +@Preview(showBackground = true) +internal fun ContributionListErrorPreview() { + PreviewWithTheme { + ContributionList( + state = ContributionListState( + oneTimeContributions = persistentListOf(), + recurringContributions = persistentListOf(), + selectedContribution = null, + isRecurringContributionSelected = false, + isLoading = false, + error = DomainContract.BillingError.UnknownError( + "An error occurred", + ), + ), + onOneTimeContributionTypeClick = {}, + onRecurringContributionTypeClick = {}, + onItemClick = {}, + onRetryClick = {}, + ) + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreenPreview.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreenPreview.kt new file mode 100644 index 0000000..4780fa0 --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreenPreview.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.runtime.Composable +import app.k9mail.core.ui.compose.common.annotation.PreviewDevicesWithBackground +import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ContributionListState +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State + +@Composable +@PreviewDevicesWithBackground +fun ContributionScreenPreview() { + PreviewWithTheme { + ContributionScreen( + onBack = {}, + viewModel = FakeContributionViewModel( + initialState = State( + listState = ContributionListState( + recurringContributions = FakeData.recurringContributions, + oneTimeContributions = FakeData.oneTimeContributions, + selectedContribution = FakeData.recurringContributions.first(), + ), + ), + ), + ) + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeContributionViewModel.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeContributionViewModel.kt new file mode 100644 index 0000000..75b18cf --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeContributionViewModel.kt @@ -0,0 +1,22 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Effect +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Event +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ViewModel + +internal class FakeContributionViewModel( + initialState: State, +) : BaseViewModel(initialState = initialState), ViewModel { + + val events = mutableListOf() + + override fun event(event: Event) { + events.add(event) + } + + fun applyState(state: State) { + updateState { state } + } +} diff --git a/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeData.kt b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeData.kt new file mode 100644 index 0000000..895e2e3 --- /dev/null +++ b/feature/funding/googleplay/src/debug/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeData.kt @@ -0,0 +1,114 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import kotlinx.collections.immutable.persistentListOf + +internal object FakeData { + + val recurringContribution = RecurringContribution( + id = "contribution_tfa_monthly_m", + title = "Monthly subscription: $50", + description = "Monthly subscription for $50", + price = 5000L, + priceFormatted = "50 $", + ) + + val recurringContributions = persistentListOf( + RecurringContribution( + id = "contribution_tfa_monthly_xxl", + title = "Monthly subscription: $250", + description = "Monthly subscription for $250", + price = 25000L, + priceFormatted = "250 $", + ), + RecurringContribution( + id = "contribution_tfa_monthly_xl", + title = "Monthly subscription: $140", + description = "Monthly subscription for $140", + price = 14000L, + priceFormatted = "140 $", + ), + RecurringContribution( + id = "contribution_tfa_monthly_l", + title = "Monthly subscription: $80", + description = "Monthly subscription for $80", + price = 8000L, + priceFormatted = "80 $", + ), + RecurringContribution( + id = "contribution_tfa_monthly_m", + title = "Monthly subscription: $50", + description = "Monthly subscription for $50", + price = 5000L, + priceFormatted = "50 $", + ), + RecurringContribution( + id = "contribution_tfa_monthly_s", + title = "Monthly subscription: $25", + description = "Monthly subscription for $25", + price = 2500L, + priceFormatted = "25 $", + ), + RecurringContribution( + id = "contribution_tfa_monthly_xs", + title = "Monthly subscription: $15", + description = "Monthly subscription for $15", + price = 1500L, + priceFormatted = "15 $", + ), + ) + + val oneTimeContribution = OneTimeContribution( + id = "contribution_tfa_onetime_m", + title = "One time payment: $50", + description = "One time payment for $50", + price = 5000L, + priceFormatted = "50 $", + ) + + val oneTimeContributions = persistentListOf( + OneTimeContribution( + id = "contribution_tfa_onetime_xxl", + title = "One time payment: $250", + description = "One time payment for $250", + price = 25000L, + priceFormatted = "250 $", + ), + OneTimeContribution( + id = "contribution_tfa_onetime_xl", + title = "One time payment: $140", + description = "One time payment for $140", + price = 14000L, + priceFormatted = "140 $", + ), + OneTimeContribution( + id = "contribution_tfa_onetime_l", + title = "One time payment: $80", + description = "One time payment for $80", + price = 8000L, + priceFormatted = "80 $", + ), + OneTimeContribution( + id = "contribution_tfa_onetime_m", + title = "One time payment: $50", + description = "One time payment for $50", + price = 5000L, + priceFormatted = "50 $", + ), + OneTimeContribution( + id = "contribution_tfa_onetime_s", + title = "One time payment: $25", + description = "One time payment for $25", + price = 2500L, + priceFormatted = "25 $", + ), + OneTimeContribution( + id = "contribution_tfa_onetime_xs", + title = "One time payment: $15", + description = "One time payment for $15", + price = 1500L, + priceFormatted = "15 $", + ), + ) +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt new file mode 100644 index 0000000..a42ac6f --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/FeatureFundingModule.kt @@ -0,0 +1,126 @@ +package app.k9mail.feature.funding + +import app.k9mail.feature.funding.api.FundingManager +import app.k9mail.feature.funding.api.FundingNavigation +import app.k9mail.feature.funding.googleplay.GooglePlayFundingManager +import app.k9mail.feature.funding.googleplay.GooglePlayFundingNavigation +import app.k9mail.feature.funding.googleplay.data.DataContract +import app.k9mail.feature.funding.googleplay.data.GoogleBillingClient +import app.k9mail.feature.funding.googleplay.data.mapper.BillingResultMapper +import app.k9mail.feature.funding.googleplay.data.mapper.ProductDetailsMapper +import app.k9mail.feature.funding.googleplay.data.remote.GoogleBillingClientProvider +import app.k9mail.feature.funding.googleplay.data.remote.GoogleBillingPurchaseHandler +import app.k9mail.feature.funding.googleplay.domain.BillingManager +import app.k9mail.feature.funding.googleplay.domain.ContributionIdProvider +import app.k9mail.feature.funding.googleplay.domain.DomainContract +import app.k9mail.feature.funding.googleplay.domain.usecase.GetAvailableContributions +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionViewModel +import app.k9mail.feature.funding.googleplay.ui.reminder.ActivityLifecycleObserver +import app.k9mail.feature.funding.googleplay.ui.reminder.FragmentLifecycleObserver +import app.k9mail.feature.funding.googleplay.ui.reminder.FundingReminder +import app.k9mail.feature.funding.googleplay.ui.reminder.FundingReminderContract +import app.k9mail.feature.funding.googleplay.ui.reminder.FundingReminderDialog +import com.android.billingclient.api.ProductDetails +import kotlin.time.ExperimentalTime +import net.thunderbird.core.common.cache.Cache +import net.thunderbird.core.common.cache.InMemoryCache +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val featureFundingModule = module { + single { + FundingReminderDialog() + } + + single { + FragmentLifecycleObserver( + targetFragmentTag = "MessageViewContainerFragment", + ) + } + + single { + @OptIn(ExperimentalTime::class) + ActivityLifecycleObserver( + settings = get(), + ) + } + + single { + @OptIn(ExperimentalTime::class) + FundingReminder( + settings = get(), + fragmentObserver = get(), + activityCounterObserver = get(), + dialog = get(), + ) + } + + single { + GooglePlayFundingManager( + reminder = get(), + ) + } + + single { GooglePlayFundingNavigation() } + + single { + ProductDetailsMapper() + } + + single { + BillingResultMapper() + } + + single { + GoogleBillingClientProvider( + context = get(), + ) + } + + single> { + InMemoryCache() + } + + single { + GoogleBillingPurchaseHandler( + productCache = get(), + productMapper = get(), + logger = get(), + ) + } + + single { + GoogleBillingClient( + clientProvider = get(), + productMapper = get(), + resultMapper = get(), + productCache = get(), + purchaseHandler = get(), + logger = get(), + ) + } + + single { + ContributionIdProvider() + } + + single { + BillingManager( + billingClient = get(), + contributionIdProvider = get(), + ) + } + + single { + GetAvailableContributions( + billingManager = get(), + ) + } + + viewModel { + ContributionViewModel( + billingManager = get(), + getAvailableContributions = get(), + ) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/GooglePlayFundingManager.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/GooglePlayFundingManager.kt new file mode 100644 index 0000000..41485cc --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/GooglePlayFundingManager.kt @@ -0,0 +1,18 @@ +package app.k9mail.feature.funding.googleplay + +import androidx.appcompat.app.AppCompatActivity +import app.k9mail.feature.funding.api.FundingManager +import app.k9mail.feature.funding.api.FundingType +import app.k9mail.feature.funding.googleplay.ui.reminder.FundingReminderContract + +class GooglePlayFundingManager( + private val reminder: FundingReminderContract.Reminder, +) : FundingManager { + override fun getFundingType(): FundingType { + return FundingType.GOOGLE_PLAY + } + + override fun addFundingReminder(activity: AppCompatActivity, onOpenFunding: () -> Unit) { + reminder.registerReminder(activity, onOpenFunding) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/GooglePlayFundingNavigation.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/GooglePlayFundingNavigation.kt new file mode 100644 index 0000000..32f80bb --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/GooglePlayFundingNavigation.kt @@ -0,0 +1,25 @@ +package app.k9mail.feature.funding.googleplay + +import androidx.navigation.NavGraphBuilder +import app.k9mail.core.ui.compose.navigation.deepLinkComposable +import app.k9mail.feature.funding.api.FundingNavigation +import app.k9mail.feature.funding.api.FundingRoute +import app.k9mail.feature.funding.api.FundingRoute.Contribution +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionScreen + +class GooglePlayFundingNavigation : FundingNavigation { + + override fun registerRoutes( + navGraphBuilder: NavGraphBuilder, + onBack: () -> Unit, + onFinish: (FundingRoute) -> Unit, + ) { + with(navGraphBuilder) { + deepLinkComposable(Contribution.BASE_PATH) { + ContributionScreen( + onBack = onBack, + ) + } + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt new file mode 100644 index 0000000..eae91ee --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/DataContract.kt @@ -0,0 +1,123 @@ +package app.k9mail.feature.funding.googleplay.data + +import android.app.Activity +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import kotlinx.coroutines.flow.StateFlow +import net.thunderbird.core.outcome.Outcome +import com.android.billingclient.api.BillingClient as GoogleBillingClient +import com.android.billingclient.api.BillingResult as GoogleBillingResult + +internal interface DataContract { + + interface Mapper { + interface Product { + fun mapToContribution(product: ProductDetails): Contribution + + fun mapToOneTimeContribution(product: ProductDetails): OneTimeContribution + fun mapToRecurringContribution(product: ProductDetails): RecurringContribution + } + + interface BillingResult { + suspend fun mapToOutcome( + billingResult: GoogleBillingResult, + transformSuccess: suspend () -> T, + ): Outcome + } + } + + interface Remote { + interface GoogleBillingClientProvider { + val current: GoogleBillingClient + + /** + * Set the listener to be notified of purchase updates. + */ + fun setPurchasesUpdatedListener(listener: PurchasesUpdatedListener) + + /** + * Disconnect from the billing service and clear the instance. + */ + fun clear() + } + + interface GoogleBillingPurchaseHandler { + suspend fun handlePurchases( + clientProvider: GoogleBillingClientProvider, + purchases: List, + ): List + + suspend fun handleOneTimePurchases( + clientProvider: GoogleBillingClientProvider, + purchases: List, + ): List + + suspend fun handleRecurringPurchases( + clientProvider: GoogleBillingClientProvider, + purchases: List, + ): List + } + } + + interface BillingClient { + + /** + * Flow that emits the last purchased contribution. + */ + val purchasedContribution: StateFlow> + + /** + * Connect to the billing service. + * + * @param onConnected Callback to be invoked when the billing service is connected. + */ + suspend fun connect(onConnected: suspend () -> Outcome): Outcome + + /** + * Disconnect from the billing service. + */ + fun disconnect() + + /** + * Load one-time contributions. + */ + suspend fun loadOneTimeContributions( + productIds: List, + ): Outcome, BillingError> + + /** + * Load recurring contributions. + */ + suspend fun loadRecurringContributions( + productIds: List, + ): Outcome, BillingError> + + /** + * Load purchased one-time contributions. + */ + suspend fun loadPurchasedOneTimeContributions(): Outcome, BillingError> + + /** + * Load purchased recurring contributions. + */ + suspend fun loadPurchasedRecurringContributions(): Outcome, BillingError> + + /** + * Load the most recent one-time contribution. + */ + suspend fun loadPurchasedOneTimeContributionHistory(): Outcome + + /** + * Purchase a contribution. + */ + suspend fun purchaseContribution( + activity: Activity, + contribution: Contribution, + ): Outcome + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt new file mode 100644 index 0000000..cf98570 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/GoogleBillingClient.kt @@ -0,0 +1,257 @@ +package app.k9mail.feature.funding.googleplay.data + +import android.app.Activity +import app.k9mail.feature.funding.googleplay.data.DataContract.Remote +import app.k9mail.feature.funding.googleplay.data.remote.startConnection +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesResult +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchaseHistoryParams +import com.android.billingclient.api.QueryPurchasesParams +import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchaseHistory +import com.android.billingclient.api.queryPurchasesAsync +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import net.thunderbird.core.common.cache.Cache +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.outcome.handleAsync +import net.thunderbird.core.outcome.mapFailure + +@Suppress("TooManyFunctions") +internal class GoogleBillingClient( + private val clientProvider: Remote.GoogleBillingClientProvider, + private val productMapper: DataContract.Mapper.Product, + private val resultMapper: DataContract.Mapper.BillingResult, + private val productCache: Cache, + private val purchaseHandler: Remote.GoogleBillingPurchaseHandler, + private val logger: Logger, + backgroundDispatcher: CoroutineContext = Dispatchers.IO, +) : DataContract.BillingClient, PurchasesUpdatedListener { + + init { + clientProvider.setPurchasesUpdatedListener(this) + } + + private val coroutineScope = CoroutineScope(backgroundDispatcher) + + private val _purchasedContribution = MutableStateFlow>( + value = Outcome.success(null), + ) + override val purchasedContribution: StateFlow> = + _purchasedContribution.asStateFlow() + + override suspend fun connect(onConnected: suspend () -> Outcome): Outcome { + val connectionResult = clientProvider.current.startConnection() + val result = resultMapper.mapToOutcome(connectionResult) {} + + return when (result) { + is Outcome.Success -> { + onConnected() + } + + is Outcome.Failure -> result + } + } + + override fun disconnect() { + productCache.clear() + _purchasedContribution.value = Outcome.success(null) + clientProvider.clear() + } + + override suspend fun loadOneTimeContributions( + productIds: List, + ): Outcome, BillingError> { + val oneTimeProductsResult = queryProducts(ProductType.INAPP, productIds) + return resultMapper.mapToOutcome(oneTimeProductsResult.billingResult) { + oneTimeProductsResult.productDetailsList.orEmpty().map { + val contribution = productMapper.mapToOneTimeContribution(it) + productCache[it.productId] = it + contribution + } + }.mapFailure { billingError, _ -> + logger.error(message = { + "Error loading one-time products: ${oneTimeProductsResult.billingResult.debugMessage}" + }) + billingError + } + } + + override suspend fun loadRecurringContributions( + productIds: List, + ): Outcome, BillingError> { + val recurringProductsResult = queryProducts(ProductType.SUBS, productIds) + return resultMapper.mapToOutcome(recurringProductsResult.billingResult) { + recurringProductsResult.productDetailsList.orEmpty().map { + val contribution = productMapper.mapToRecurringContribution(it) + productCache[it.productId] = it + contribution + } + }.mapFailure { billingError, _ -> + logger.error(message = { + "Error loading recurring products: ${recurringProductsResult.billingResult.debugMessage}" + }) + billingError + } + } + + override suspend fun loadPurchasedOneTimeContributions(): Outcome, BillingError> { + val purchasesResult = queryPurchase(ProductType.INAPP) + return resultMapper.mapToOutcome(purchasesResult.billingResult) { + purchaseHandler.handleOneTimePurchases(clientProvider, purchasesResult.purchasesList) + }.mapFailure { billingError, _ -> + logger.error(message = { + "Error loading one-time purchases: ${purchasesResult.billingResult.debugMessage}" + }) + billingError + } + } + + override suspend fun loadPurchasedRecurringContributions(): Outcome, BillingError> { + val purchasesResult = queryPurchase(ProductType.SUBS) + return resultMapper.mapToOutcome(purchasesResult.billingResult) { + purchaseHandler.handleRecurringPurchases(clientProvider, purchasesResult.purchasesList) + }.mapFailure { billingError, _ -> + logger.error(message = { + "Error loading recurring purchases: ${purchasesResult.billingResult.debugMessage}" + }) + billingError + } + } + + override suspend fun loadPurchasedOneTimeContributionHistory(): Outcome { + val queryPurchaseHistoryParams = QueryPurchaseHistoryParams.newBuilder() + .setProductType(ProductType.INAPP) + .build() + + val purchasesResult = clientProvider.current.queryPurchaseHistory(queryPurchaseHistoryParams) + return resultMapper.mapToOutcome(purchasesResult.billingResult) { + val recentPurchaseId = + purchasesResult.purchaseHistoryRecordList.orEmpty().firstOrNull()?.products?.firstOrNull { + productCache.hasKey(it) + } + + if (recentPurchaseId != null) { + val recentPurchase = productCache[recentPurchaseId] + productMapper.mapToOneTimeContribution(recentPurchase!!) + } else { + logger.error(message = { "No recent purchase found: ${purchasesResult.billingResult.debugMessage}" }) + null + } + }.mapFailure { billingError, _ -> + logger.error(message = { + "Error loading one-time purchase history: ${purchasesResult.billingResult.debugMessage}" + }) + billingError + } + } + + private suspend fun queryProducts( + productType: String, + productIds: List, + ): ProductDetailsResult { + val productList = productIds.map { mapIdToProduct(productType, it) } + + val queryProductDetailsParams = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + return clientProvider.current.queryProductDetails(queryProductDetailsParams) + } + + private fun mapIdToProduct( + productType: String, + productId: String, + ): QueryProductDetailsParams.Product { + return QueryProductDetailsParams.Product.newBuilder() + .setProductType(productType) + .setProductId(productId) + .build() + } + + private suspend fun queryPurchase(productType: String): PurchasesResult { + val queryPurchaseParams = QueryPurchasesParams.newBuilder() + .setProductType(productType) + .build() + + return clientProvider.current.queryPurchasesAsync(queryPurchaseParams) + } + + override suspend fun purchaseContribution( + activity: Activity, + contribution: Contribution, + ): Outcome { + val productDetails = productCache[contribution.id] + ?: return Outcome.failure(BillingError.PurchaseFailed("ProductDetails not found: ${contribution.id}")) + val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken + + val productDetailsParamsList = listOf( + ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .apply { + if (offerToken != null) { + setOfferToken(offerToken) + } + } + .build(), + ) + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailsParamsList) + .build() + + val billingResult = clientProvider.current.launchBillingFlow(activity, billingFlowParams) + return resultMapper.mapToOutcome(billingResult) { }.mapFailure( + transformFailure = { error, _ -> + logger.error(message = { "Error launching billing flow: ${error.message}" }) + error + }, + ) + } + + override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList?) { + coroutineScope.launch { + resultMapper.mapToOutcome(billingResult) { }.handleAsync( + onSuccess = { + if (purchases != null) { + val contributions = purchaseHandler.handlePurchases(clientProvider, purchases) + if (contributions.isNotEmpty()) { + _purchasedContribution.emit( + Outcome.success( + contributions.firstOrNull(), + ), + ) + } + } + }, + onFailure = { error -> + logger.error( + message = { + "Error onPurchasesUpdated: " + + "${billingResult.responseCode}: ${billingResult.debugMessage}" + }, + ) + _purchasedContribution.value = Outcome.failure(error) + }, + ) + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapper.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapper.kt new file mode 100644 index 0000000..1a4a2ab --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapper.kt @@ -0,0 +1,46 @@ +package app.k9mail.feature.funding.googleplay.data.mapper + +import app.k9mail.feature.funding.googleplay.data.DataContract.Mapper +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingResult +import net.thunderbird.core.outcome.Outcome + +class BillingResultMapper : Mapper.BillingResult { + + override suspend fun mapToOutcome( + billingResult: BillingResult, + transformSuccess: suspend () -> T, + ): Outcome { + return when (billingResult.responseCode) { + BillingResponseCode.OK -> { + Outcome.success(transformSuccess()) + } + + else -> { + Outcome.failure(mapToBillingError(billingResult)) + } + } + } + + private fun mapToBillingError(billingResult: BillingResult): BillingError { + return when (billingResult.responseCode) { + BillingResponseCode.SERVICE_DISCONNECTED, + BillingResponseCode.SERVICE_UNAVAILABLE, + BillingResponseCode.BILLING_UNAVAILABLE, + BillingResponseCode.NETWORK_ERROR, + -> BillingError.ServiceDisconnected(billingResult.debugMessage) + + BillingResponseCode.ITEM_ALREADY_OWNED, + BillingResponseCode.ITEM_NOT_OWNED, + BillingResponseCode.ITEM_UNAVAILABLE, + -> BillingError.PurchaseFailed(billingResult.debugMessage) + + BillingResponseCode.USER_CANCELED -> BillingError.UserCancelled(billingResult.debugMessage) + + BillingResponseCode.DEVELOPER_ERROR -> BillingError.DeveloperError(billingResult.debugMessage) + + else -> BillingError.UnknownError(billingResult.debugMessage) + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/ProductDetailsMapper.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/ProductDetailsMapper.kt new file mode 100644 index 0000000..a270170 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/ProductDetailsMapper.kt @@ -0,0 +1,57 @@ +package app.k9mail.feature.funding.googleplay.data.mapper + +import app.k9mail.feature.funding.googleplay.data.DataContract.Mapper +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.ProductDetails + +class ProductDetailsMapper : Mapper.Product { + + override fun mapToContribution(product: ProductDetails): Contribution { + return when (product.productType) { + BillingClient.ProductType.INAPP -> mapToOneTimeContribution(product) + BillingClient.ProductType.SUBS -> mapToRecurringContribution(product) + else -> throw IllegalArgumentException("Unknown product type: ${product.productType}") + } + } + + override fun mapToOneTimeContribution(product: ProductDetails): OneTimeContribution { + require(product.productType == BillingClient.ProductType.INAPP) { "Product type must be INAPP" } + + val offerDetails = product.oneTimePurchaseOfferDetails + + return if (offerDetails != null) { + OneTimeContribution( + id = product.productId, + title = product.name, + description = product.description.replace("\n", ""), + price = offerDetails.priceAmountMicros, + priceFormatted = offerDetails.formattedPrice, + ) + } else { + error("One-time product has no offer details: ${product.productId}") + } + } + + override fun mapToRecurringContribution(product: ProductDetails): RecurringContribution { + require(product.productType == BillingClient.ProductType.SUBS) { "Product type must be SUBS" } + + // We assume the product has only one offer and one pricing phase + val pricingPhase = + product.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull() + + return if (pricingPhase != null) { + RecurringContribution( + id = product.productId, + title = product.name, + description = product.description.replace("\n", ""), + price = pricingPhase.priceAmountMicros, + priceFormatted = pricingPhase.formattedPrice, + ) + } else { + error("Subscription product has no pricing phase: ${product.productId}") + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientExtensions.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientExtensions.kt new file mode 100644 index 0000000..30e22ad --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientExtensions.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.funding.googleplay.data.remote + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingResult +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Starts the billing client connection. + * + * Kotlin coroutines are used to suspend the coroutine until the connection is established. + */ +internal suspend fun BillingClient.startConnection(): BillingResult = suspendCancellableCoroutine { continuation -> + startConnection( + object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + continuation.resume(billingResult) + } + + override fun onBillingServiceDisconnected() { + continuation.resume( + BillingResult.newBuilder() + .setResponseCode(BillingResponseCode.SERVICE_DISCONNECTED) + .setDebugMessage("Service disconnected: onBillingServiceDisconnected") + .build(), + ) + } + }, + ) +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientProvider.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientProvider.kt new file mode 100644 index 0000000..4c63153 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingClientProvider.kt @@ -0,0 +1,46 @@ +package app.k9mail.feature.funding.googleplay.data.remote + +import android.content.Context +import app.k9mail.feature.funding.googleplay.data.DataContract +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.PurchasesUpdatedListener + +/** + * Google Billing client provider. + * + * It is responsible for creating and managing the billing client instance + */ +class GoogleBillingClientProvider( + private val context: Context, +) : DataContract.Remote.GoogleBillingClientProvider { + + private var clientInstance: BillingClient? = null + + override val current: BillingClient + get() = clientInstance ?: createBillingClient().also { clientInstance = it } + + private var listener: PurchasesUpdatedListener? = null + + override fun setPurchasesUpdatedListener(listener: PurchasesUpdatedListener) { + this.listener = listener + } + + private fun createBillingClient(): BillingClient { + require(listener != null) { "PurchasesUpdatedListener must be set before creating the billing client" } + + return BillingClient.newBuilder(context) + .setListener(listener!!) + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build(), + ) + .build() + } + + override fun clear() { + clientInstance?.endConnection() + clientInstance = null + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt new file mode 100644 index 0000000..8025947 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/data/remote/GoogleBillingPurchaseHandler.kt @@ -0,0 +1,156 @@ +package app.k9mail.feature.funding.googleplay.data.remote + +import app.k9mail.feature.funding.googleplay.data.DataContract +import app.k9mail.feature.funding.googleplay.data.DataContract.Remote +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.acknowledgePurchase +import com.android.billingclient.api.consumePurchase +import net.thunderbird.core.common.cache.Cache +import net.thunderbird.core.logging.Logger + +// TODO propagate errors via Outcome +// TODO optimize purchase handling and reduce duplicate code +@Suppress("TooManyFunctions") +internal class GoogleBillingPurchaseHandler( + private val productCache: Cache, + private val productMapper: DataContract.Mapper.Product, + private val logger: Logger, +) : Remote.GoogleBillingPurchaseHandler { + + override suspend fun handlePurchases( + clientProvider: Remote.GoogleBillingClientProvider, + purchases: List, + ): List { + return purchases.flatMap { purchase -> + handlePurchase(clientProvider.current, purchase) + } + } + + override suspend fun handleOneTimePurchases( + clientProvider: Remote.GoogleBillingClientProvider, + purchases: List, + ): List { + return purchases.flatMap { purchase -> + handleOneTimePurchase(clientProvider.current, purchase) + } + } + + override suspend fun handleRecurringPurchases( + clientProvider: Remote.GoogleBillingClientProvider, + purchases: List, + ): List { + return purchases.flatMap { purchase -> + handleRecurringPurchase(clientProvider.current, purchase) + } + } + + private suspend fun handlePurchase( + billingClient: BillingClient, + purchase: Purchase, + ): List { + // TODO verify purchase with public key + consumePurchase(billingClient, purchase) + acknowledgePurchase(billingClient, purchase) + + return extractContributions(purchase) + } + + private suspend fun handleOneTimePurchase( + billingClient: BillingClient, + purchase: Purchase, + ): List { + // TODO verify purchase with public key + consumePurchase(billingClient, purchase) + + return extractOneTimeContributions(purchase) + } + + private suspend fun handleRecurringPurchase( + billingClient: BillingClient, + purchase: Purchase, + ): List { + // TODO verify purchase with public key + acknowledgePurchase(billingClient, purchase) + + return extractRecurringContributions(purchase) + } + + private suspend fun acknowledgePurchase( + billingClient: BillingClient, + purchase: Purchase, + ) { + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + if (!purchase.isAcknowledged) { + val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + val acknowledgeResult: BillingResult = billingClient.acknowledgePurchase(acknowledgePurchaseParams) + + if (acknowledgeResult.responseCode != BillingResponseCode.OK) { + // TODO success + } else { + // handle acknowledge error + logger.error(message = { "acknowledgePurchase failed" }) + } + } else { + logger.error(message = { "purchase already acknowledged" }) + } + } else { + logger.error(message = { "purchase not purchased" }) + } + } + + private suspend fun consumePurchase( + billingClient: BillingClient, + purchase: Purchase, + ) { + val consumeParams = ConsumeParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + + // This could fail but we can ignore the error as we handle purchases + // the next time the purchases are requested + billingClient.consumePurchase(consumeParams) + } + + private fun extractContributions(purchase: Purchase): List { + if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { + return emptyList() + } + + return extractOneTimeContributions(purchase) + extractRecurringContributions(purchase) + } + + private fun extractOneTimeContributions(purchase: Purchase): List { + if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { + return emptyList() + } + + return purchase.products.mapNotNull { product -> + productCache[product] + }.filter { it.productType == ProductType.INAPP } + .map { productMapper.mapToOneTimeContribution(it) } + } + + private fun extractRecurringContributions(purchase: Purchase): List { + if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) { + return emptyList() + } + + return purchase.products.mapNotNull { product -> + productCache[product] + }.filter { it.productType == ProductType.SUBS } + .map { productMapper.mapToRecurringContribution(it) } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/BillingManager.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/BillingManager.kt new file mode 100644 index 0000000..4ed77b4 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/BillingManager.kt @@ -0,0 +1,72 @@ +package app.k9mail.feature.funding.googleplay.domain + +import android.app.Activity +import app.k9mail.feature.funding.googleplay.data.DataContract +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import kotlinx.coroutines.flow.StateFlow +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.outcome.flatMapSuccess +import net.thunderbird.core.outcome.mapSuccess + +internal class BillingManager( + private val billingClient: DataContract.BillingClient, + private val contributionIdProvider: DomainContract.ContributionIdProvider, +) : DomainContract.BillingManager { + + override val purchasedContribution: StateFlow> = + billingClient.purchasedContribution + + override suspend fun loadOneTimeContributions(): Outcome, BillingError> { + return billingClient.connect { + billingClient.loadOneTimeContributions( + productIds = contributionIdProvider.oneTimeContributionIds, + ).mapSuccess { contributions -> + contributions.sortedByDescending { it.price } + } + } + } + + override suspend fun loadRecurringContributions(): Outcome, BillingError> { + return billingClient.connect { + billingClient.loadRecurringContributions( + productIds = contributionIdProvider.recurringContributionIds, + ).mapSuccess { contributions -> + contributions.sortedByDescending { it.price } + } + } + } + + override suspend fun loadPurchasedContributions(): Outcome, BillingError> { + return billingClient.connect { + billingClient.loadPurchasedRecurringContributions().flatMapSuccess { recurringContributions -> + if (recurringContributions.isEmpty()) { + billingClient.loadPurchasedOneTimeContributionHistory().flatMapSuccess { contribution -> + if (contribution != null) { + Outcome.success(listOf(contribution)) + } else { + Outcome.success(emptyList()) + } + } + } else { + Outcome.success(recurringContributions.sortedByDescending { it.price }) + } + } + } + } + + override suspend fun purchaseContribution( + activity: Activity, + contribution: Contribution, + ): Outcome { + return billingClient.connect { + billingClient.purchaseContribution(activity, contribution) + } + } + + override fun clear() { + billingClient.disconnect() + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/ContributionIdProvider.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/ContributionIdProvider.kt new file mode 100644 index 0000000..00799d5 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/ContributionIdProvider.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.funding.googleplay.domain + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +// TODO should be provided externally per app variant +class ContributionIdProvider : + DomainContract.ContributionIdProvider { + override val oneTimeContributionIds: ImmutableList = persistentListOf( + "contribution_tfa_onetime_xs", + "contribution_tfa_onetime_s", + "contribution_tfa_onetime_m", + "contribution_tfa_onetime_l", + "contribution_tfa_onetime_xl", + "contribution_tfa_onetime_xxl", + ) + + override val recurringContributionIds: ImmutableList = persistentListOf( + "contribution_tfa_monthly_xs", + "contribution_tfa_monthly_s", + "contribution_tfa_monthly_m", + "contribution_tfa_monthly_l", + "contribution_tfa_monthly_xl", + "contribution_tfa_monthly_xxl", + ) +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/DomainContract.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/DomainContract.kt new file mode 100644 index 0000000..b30a6f3 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/DomainContract.kt @@ -0,0 +1,87 @@ +package app.k9mail.feature.funding.googleplay.domain + +import android.app.Activity +import app.k9mail.feature.funding.googleplay.domain.entity.AvailableContributions +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.StateFlow +import net.thunderbird.core.outcome.Outcome + +interface DomainContract { + + interface UseCase { + fun interface GetAvailableContributions { + suspend operator fun invoke(): Outcome + } + } + + interface ContributionIdProvider { + val oneTimeContributionIds: ImmutableList + val recurringContributionIds: ImmutableList + } + + interface BillingManager { + /** + * Flow that emits the last purchased contribution. + */ + val purchasedContribution: StateFlow> + + /** + * Load contributions. + */ + suspend fun loadOneTimeContributions(): Outcome, BillingError> + + /** + * Load recurring contributions. + */ + suspend fun loadRecurringContributions(): Outcome, BillingError> + + /** + * Load purchased contributions. + */ + suspend fun loadPurchasedContributions(): Outcome, BillingError> + + /** + * Purchase a contribution. + * + * @param activity The activity to use for the purchase flow. + * @param contribution The contribution to purchase. + * @return Outcome of the purchase. + */ + suspend fun purchaseContribution( + activity: Activity, + contribution: Contribution, + ): Outcome + + /** + * Release all resources. + */ + fun clear() + } + + sealed interface BillingError { + val message: String + + data class UserCancelled( + override val message: String, + ) : BillingError + + data class PurchaseFailed( + override val message: String, + ) : BillingError + + data class ServiceDisconnected( + override val message: String, + ) : BillingError + + data class DeveloperError( + override val message: String, + ) : BillingError + + data class UnknownError( + override val message: String, + ) : BillingError + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/AvailableContributions.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/AvailableContributions.kt new file mode 100644 index 0000000..9211ae4 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/AvailableContributions.kt @@ -0,0 +1,7 @@ +package app.k9mail.feature.funding.googleplay.domain.entity + +data class AvailableContributions( + val oneTimeContributions: List, + val recurringContributions: List, + val purchasedContribution: Contribution? = null, +) diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/Contribution.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/Contribution.kt new file mode 100644 index 0000000..9d94655 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/Contribution.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.funding.googleplay.domain.entity + +interface Contribution { + val id: String + val title: String + val description: String + val price: Long + val priceFormatted: String +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/OneTimeContribution.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/OneTimeContribution.kt new file mode 100644 index 0000000..1d4d4c8 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/OneTimeContribution.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.funding.googleplay.domain.entity + +data class OneTimeContribution( + override val id: String, + override val title: String, + override val description: String, + override val price: Long, + override val priceFormatted: String, +) : Contribution diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/RecurringContribution.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/RecurringContribution.kt new file mode 100644 index 0000000..1c63a9e --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/entity/RecurringContribution.kt @@ -0,0 +1,9 @@ +package app.k9mail.feature.funding.googleplay.domain.entity + +data class RecurringContribution( + override val id: String, + override val title: String, + override val description: String, + override val price: Long, + override val priceFormatted: String, +) : Contribution diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/usecase/GetAvailableContributions.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/usecase/GetAvailableContributions.kt new file mode 100644 index 0000000..b883660 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/domain/usecase/GetAvailableContributions.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.funding.googleplay.domain.usecase + +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingManager +import app.k9mail.feature.funding.googleplay.domain.DomainContract.UseCase +import app.k9mail.feature.funding.googleplay.domain.entity.AvailableContributions +import net.thunderbird.core.outcome.Outcome + +class GetAvailableContributions( + private val billingManager: BillingManager, +) : UseCase.GetAvailableContributions { + override suspend fun invoke(): Outcome { + val oneTimeContributionsResult = billingManager.loadOneTimeContributions() + val recurringContributionsResult = billingManager.loadRecurringContributions() + val purchasedContributionResult = billingManager.loadPurchasedContributions() + + return if (oneTimeContributionsResult is Outcome.Success && + recurringContributionsResult is Outcome.Success && + purchasedContributionResult is Outcome.Success + ) { + Outcome.success( + AvailableContributions( + oneTimeContributions = oneTimeContributionsResult.data, + recurringContributions = recurringContributionsResult.data, + purchasedContribution = purchasedContributionResult.data.firstOrNull(), + ), + ) + } else { + // TODO handle errors + Outcome.failure(BillingError.UnknownError("Failed to load contributions")) + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContent.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContent.kt new file mode 100644 index 0000000..44e1efa --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContent.kt @@ -0,0 +1,81 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Event +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State +import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId + +@Composable +internal fun ContributionContent( + state: State, + onEvent: (Event) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + ResponsiveWidthContainer( + modifier = modifier + .testTagAsResourceId("ContributionContent") + .padding(contentPadding), + ) { contentPadding -> + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = MainTheme.spacings.quadruple) + .verticalScroll(scrollState) + .padding(contentPadding), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.triple), + ) { + ContributionHeader( + purchasedContribution = state.purchasedContribution, + ) + + if (state.showContributionList) { + ContributionList( + state = state.listState, + onOneTimeContributionTypeClick = { + onEvent(Event.OnOneTimeContributionSelected) + }, + onRecurringContributionTypeClick = { + onEvent(Event.OnRecurringContributionSelected) + }, + onItemClick = { + onEvent(Event.OnContributionItemClicked(it)) + }, + onRetryClick = { + onEvent(Event.OnRetryClicked) + }, + ) + } + + if (state.purchaseError != null) { + ContributionError( + error = state.purchaseError, + onDismissClick = { onEvent(Event.OnDismissPurchaseErrorClicked) }, + ) + } + + ContributionFooter( + purchasedContribution = state.purchasedContribution, + onPurchaseClick = { onEvent(Event.OnPurchaseClicked) }, + onManagePurchaseClick = { onEvent(Event.OnManagePurchaseClicked(it)) }, + onShowContributionListClick = { onEvent(Event.OnShowContributionListClicked) }, + isPurchaseEnabled = state.listState.selectedContribution != null, + isContributionListShown = state.showContributionList, + ) + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContract.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContract.kt new file mode 100644 index 0000000..ff440b8 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionContract.kt @@ -0,0 +1,70 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import android.app.Activity +import androidx.compose.runtime.Stable +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingErrorState +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class ContributionContract { + + interface ViewModel : UnidirectionalViewModel + + @Stable + data class State( + val listState: ContributionListState = ContributionListState(), + val purchasedContribution: Contribution? = null, + + val showContributionList: Boolean = true, + val showRecurringContributions: Boolean = false, + + val purchaseError: BillingError? = null, + ) + + @Stable + data class ContributionListState( + val oneTimeContributions: ImmutableList = persistentListOf(), + val recurringContributions: ImmutableList = persistentListOf(), + val selectedContribution: Contribution? = null, + val isRecurringContributionSelected: Boolean = true, + + override val error: BillingError? = null, + override val isLoading: Boolean = true, + ) : LoadingErrorState + + sealed interface Event { + data object OnOneTimeContributionSelected : Event + data object OnRecurringContributionSelected : Event + + data object OnShowContributionListClicked : Event + + data class OnContributionItemClicked( + val item: Contribution, + ) : Event + + data object OnPurchaseClicked : Event + + data class OnManagePurchaseClicked( + val contribution: Contribution, + ) : Event + + data object OnDismissPurchaseErrorClicked : Event + + data object OnRetryClicked : Event + } + + sealed interface Effect { + data class PurchaseContribution( + val startPurchaseFlow: (Activity) -> Unit, + ) : Effect + + data class ManageSubscription( + val productId: String, + ) : Effect + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionError.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionError.kt new file mode 100644 index 0000000..f6d10c8 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionError.kt @@ -0,0 +1,126 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.funding.googleplay.R +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError + +@Composable +fun ContributionError( + error: BillingError?, + onDismissClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (error) { + is BillingError.DeveloperError, + is BillingError.PurchaseFailed, + is BillingError.ServiceDisconnected, + is BillingError.UnknownError, + -> ContributionErrorView( + title = mapErrorToTitle(error), + description = error.message, + onDismissClick = onDismissClick, + modifier = modifier, + ) + + is BillingError.UserCancelled -> Unit // could be ignored + null -> Unit + } +} + +@Composable +private fun ContributionErrorView( + title: String, + description: String, + onDismissClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val showDetails = remember { mutableStateOf(false) } + + Surface( + modifier = modifier + .fillMaxWidth(), + color = MainTheme.colors.errorContainer, + shape = MainTheme.shapes.medium, + ) { + Column( + modifier = Modifier.padding( + horizontal = MainTheme.spacings.double, + vertical = MainTheme.spacings.default, + ), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + ) { + TextBodyLarge( + text = title, + color = MainTheme.colors.onErrorContainer, + modifier = Modifier.weight(1f), + ) + if (description.isNotEmpty()) { + Icon( + imageVector = if (showDetails.value) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = stringResource(R.string.funding_googleplay_contribution_error_show_more), + modifier = Modifier + .clickable { showDetails.value = !showDetails.value } + .padding(MainTheme.spacings.quarter), + ) + } + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.funding_googleplay_contribution_error_dismiss_button), + modifier = Modifier + .clickable { onDismissClick() } + .padding(MainTheme.spacings.quarter), + ) + } + + AnimatedVisibility(visible = showDetails.value) { + TextBodySmall( + text = description, + color = MainTheme.colors.onErrorContainer, + ) + } + } + } +} + +@Composable +internal fun mapErrorToTitle(error: BillingError): String { + return when (error) { + is BillingError.PurchaseFailed -> { + stringResource(R.string.funding_googleplay_contribution_error_purchase_failed) + } + + is BillingError.ServiceDisconnected -> { + stringResource(R.string.funding_googleplay_contribution_error_service_disconnected) + } + + is BillingError.DeveloperError, + is BillingError.UnknownError, + -> { + stringResource(R.string.funding_googleplay_contribution_error_unknown) + } + + is BillingError.UserCancelled -> error("User cancelled not supported") + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionFooter.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionFooter.kt new file mode 100644 index 0000000..36dd0e0 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionFooter.kt @@ -0,0 +1,64 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled +import app.k9mail.feature.funding.googleplay.R +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution + +@Composable +internal fun ContributionFooter( + purchasedContribution: Contribution?, + onPurchaseClick: () -> Unit, + onManagePurchaseClick: (Contribution) -> Unit, + onShowContributionListClick: () -> Unit, + isPurchaseEnabled: Boolean, + isContributionListShown: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + if (purchasedContribution != null && !isContributionListShown) { + when (purchasedContribution) { + is RecurringContribution -> { + ButtonFilled( + text = stringResource( + R.string.funding_googleplay_contribution_footer_manage_button, + ), + onClick = { onManagePurchaseClick(purchasedContribution) }, + modifier = Modifier.fillMaxWidth(), + ) + } + + is OneTimeContribution -> { + ButtonFilled( + text = stringResource( + R.string.funding_googleplay_contribution_footer_show_contribution_list_button, + ), + onClick = onShowContributionListClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } else { + ButtonFilled( + text = stringResource( + if (isPurchaseEnabled) { + R.string.funding_googleplay_contribution_footer_payment_button + } else { + R.string.funding_googleplay_contribution_footer_payment_unavailable_button + }, + ), + onClick = onPurchaseClick, + enabled = isPurchaseEnabled, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionHeader.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionHeader.kt new file mode 100644 index 0000000..08c2986 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionHeader.kt @@ -0,0 +1,143 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.image.FixedScaleImage +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.funding.googleplay.R +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.ui.contribution.image.GoldenHearthSunburst +import app.k9mail.feature.funding.googleplay.ui.contribution.image.HearthSunburst +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun ContributionHeader( + purchasedContribution: Contribution?, + modifier: Modifier = Modifier, +) { + AnimatedContent( + targetState = purchasedContribution != null, + label = "ContributionHeaderLogo", + ) { targetState -> + when (targetState) { + true -> { + val contribution = purchasedContribution!! + ContributionHeaderView( + logo = GoldenHearthSunburst, + title = ContributionIdStringMapper.mapToContributionTitle(contribution.id), + description = ContributionIdStringMapper.mapToContributionDescription(contribution.id), + showThankYou = true, + benefits = ContributionIdStringMapper.mapToContributionBenefits(contribution.id), + modifier = modifier, + ) + } + + false -> { + ContributionHeaderView( + logo = HearthSunburst, + title = stringResource(R.string.funding_googleplay_contribution_header_title), + description = stringResource(R.string.funding_googleplay_contribution_header_description), + modifier = modifier, + ) + } + } + } +} + +@Composable +private fun ContributionHeaderView( + logo: ImageVector, + title: String, + description: String, + modifier: Modifier = Modifier, + showThankYou: Boolean = false, + benefits: ImmutableList = persistentListOf(), +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.triple), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = MainTheme.spacings.triple) + .height(MainTheme.sizes.large), + ) { + FixedScaleImage( + imageVector = logo, + contentDescription = null, + alignment = Alignment.TopCenter, + allowOverflow = true, + ) + } + + TextHeadlineSmall( + text = title, + color = MainTheme.colors.primary, + textAlign = TextAlign.Center, + ) + + if (showThankYou) { + TextBodyMedium( + text = stringResource(R.string.funding_googleplay_contribution_header_thank_you), + ) + TextBodyMedium( + text = stringResource(R.string.funding_googleplay_contribution_header_thank_you_message), + ) + } + + if (benefits.isNotEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + benefits.forEach { benefit -> + ContributionBenefit( + benefit = benefit, + ) + } + } + } + + TextBodyMedium( + text = description, + ) + } +} + +@Composable +private fun ContributionBenefit( + benefit: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + ) { + Icon( + imageVector = Icons.Filled.Dot, + modifier = Modifier.size(MainTheme.sizes.small), + ) + TextBodyMedium( + text = benefit, + ) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionIdStringMapper.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionIdStringMapper.kt new file mode 100644 index 0000000..e50483a --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionIdStringMapper.kt @@ -0,0 +1,154 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import app.k9mail.feature.funding.googleplay.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +// Ids need to be kept in sync with ContributionIdProvider.kt +internal object ContributionIdStringMapper { + + @Composable + fun mapToContributionTitle(contributionId: String): String { + return when (contributionId) { + "contribution_tfa_onetime_xs" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_xs_title, + ) + + "contribution_tfa_onetime_s" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_s_title, + ) + + "contribution_tfa_onetime_m" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_m_title, + ) + + "contribution_tfa_onetime_l" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_l_title, + ) + + "contribution_tfa_onetime_xl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_xl_title, + ) + + "contribution_tfa_onetime_xxl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_xxl_title, + ) + + "contribution_tfa_monthly_xs" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_xs_title, + ) + + "contribution_tfa_monthly_s" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_s_title, + ) + + "contribution_tfa_monthly_m" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_m_title, + ) + + "contribution_tfa_monthly_l" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_l_title, + ) + + "contribution_tfa_monthly_xl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_xl_title, + ) + + "contribution_tfa_monthly_xxl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_xxl_title, + ) + + else -> throw IllegalArgumentException("Unknown contribution ID: $contributionId") + } + } + + @Composable + fun mapToContributionDescription(contributionId: String): String { + return when (contributionId) { + "contribution_tfa_onetime_xs" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_xs_description, + ) + + "contribution_tfa_onetime_s" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_s_description, + ) + + "contribution_tfa_onetime_m" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_m_description, + ) + + "contribution_tfa_onetime_l" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_l_description, + ) + + "contribution_tfa_onetime_xl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_xl_description, + ) + + "contribution_tfa_onetime_xxl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_onetime_xxl_description, + ) + + "contribution_tfa_monthly_xs" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_xs_description, + ) + + "contribution_tfa_monthly_s" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_s_description, + ) + + "contribution_tfa_monthly_m" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_m_description, + ) + + "contribution_tfa_monthly_l" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_l_description, + ) + + "contribution_tfa_monthly_xl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_xl_description, + ) + + "contribution_tfa_monthly_xxl" -> stringResource( + R.string.funding_googleplay_contribution_tfa_recurring_xxl_description, + ) + + else -> throw IllegalArgumentException("Unknown contribution ID: $contributionId") + } + } + + @Composable + fun mapToContributionBenefits(contributionId: String): ImmutableList { + return when (contributionId) { + "contribution_tfa_monthly_xs" -> stringArrayResource( + R.array.funding_googleplay_contribution_tfa_recurring_xs_benefits, + ).toImmutableList() + + "contribution_tfa_monthly_s" -> stringArrayResource( + R.array.funding_googleplay_contribution_tfa_recurring_s_benefits, + ).toImmutableList() + + "contribution_tfa_monthly_m" -> stringArrayResource( + R.array.funding_googleplay_contribution_tfa_recurring_m_benefits, + ).toImmutableList() + + "contribution_tfa_monthly_l" -> stringArrayResource( + R.array.funding_googleplay_contribution_tfa_recurring_l_benefits, + ).toImmutableList() + + "contribution_tfa_monthly_xl" -> stringArrayResource( + R.array.funding_googleplay_contribution_tfa_recurring_xl_benefits, + ).toImmutableList() + + "contribution_tfa_monthly_xxl" -> stringArrayResource( + R.array.funding_googleplay_contribution_tfa_recurring_xxl_benefits, + ).toImmutableList() + + else -> persistentListOf() + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionList.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionList.kt new file mode 100644 index 0000000..bc0b6e3 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionList.kt @@ -0,0 +1,285 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import app.k9mail.core.ui.compose.common.resources.annotatedStringResource +import app.k9mail.core.ui.compose.designsystem.atom.Surface +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icon +import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall +import app.k9mail.core.ui.compose.designsystem.atom.text.TextLabelLarge +import app.k9mail.core.ui.compose.designsystem.molecule.ContentLoadingErrorView +import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView +import app.k9mail.core.ui.compose.theme2.MainTheme +import app.k9mail.feature.funding.googleplay.R +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ContributionListState +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun ContributionList( + state: ContributionListState, + onOneTimeContributionTypeClick: () -> Unit, + onRecurringContributionTypeClick: () -> Unit, + onItemClick: (Contribution) -> Unit, + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + color = MainTheme.colors.surfaceContainerLowest, + shape = MainTheme.shapes.small, + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .padding(MainTheme.spacings.double), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + TextLabelLarge( + text = stringResource(R.string.funding_googleplay_contribution_list_title), + ) + + ContentLoadingErrorView( + state = state, + loading = { + LoadingView() + }, + error = { error -> + ListErrorView( + error = error, + onRetryClick = onRetryClick, + ) + }, + content = { state -> + if (state.oneTimeContributions.isEmpty() && state.recurringContributions.isEmpty()) { + ListEmptyView() + } else { + ListContentView( + state = state, + onOneTimeContributionTypeClick = onOneTimeContributionTypeClick, + onRecurringContributionTypeClick = onRecurringContributionTypeClick, + onItemClick = onItemClick, + ) + } + }, + ) + + TextBodyMedium( + text = buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(stringResource(R.string.funding_googleplay_contribution_list_disclaimer)) + } + }, + modifier = Modifier.padding(top = MainTheme.spacings.default), + ) + } + } +} + +@Composable +private fun TypeSelectionRow( + oneTimeContributions: ImmutableList, + recurringContributions: ImmutableList, + isRecurringContributionSelected: Boolean, + onOneTimeContributionTypeClick: () -> Unit, + onRecurringContributionTypeClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = MainTheme.spacings.default), + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + ) { + if (oneTimeContributions.isEmpty() && recurringContributions.isEmpty()) { + ContributionListItem( + text = stringResource(R.string.funding_googleplay_contribution_list_type_none_available), + onClick = {}, + isSelected = true, + modifier = Modifier.weight(1f), + ) + } else { + if (oneTimeContributions.isNotEmpty()) { + ContributionListItem( + text = stringResource(R.string.funding_googleplay_contribution_list_type_one_time), + onClick = onOneTimeContributionTypeClick, + isSelected = !isRecurringContributionSelected, + modifier = Modifier.weight(1f), + ) + } + if (recurringContributions.isNotEmpty()) { + ContributionListItem( + text = stringResource(R.string.funding_googleplay_contribution_list_type_recurring), + onClick = onRecurringContributionTypeClick, + isSelected = isRecurringContributionSelected, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChoicesRow( + contributions: ImmutableList, + onItemClick: (Contribution) -> Unit, + selectedItem: Contribution?, + modifier: Modifier = Modifier, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + modifier = modifier, + ) { + contributions.forEach { + ContributionListItem( + text = it.priceFormatted, + onClick = { onItemClick(it) }, + isSelected = it == selectedItem, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun ListContentView( + state: ContributionListState, + onOneTimeContributionTypeClick: () -> Unit, + onRecurringContributionTypeClick: () -> Unit, + onItemClick: (Contribution) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + modifier = modifier, + ) { + TypeSelectionRow( + oneTimeContributions = state.oneTimeContributions, + recurringContributions = state.recurringContributions, + isRecurringContributionSelected = state.isRecurringContributionSelected, + onOneTimeContributionTypeClick = onOneTimeContributionTypeClick, + onRecurringContributionTypeClick = onRecurringContributionTypeClick, + ) + + ChoicesRow( + contributions = if (state.isRecurringContributionSelected) { + state.recurringContributions + } else { + state.oneTimeContributions + }, + selectedItem = state.selectedContribution, + onItemClick = onItemClick, + ) + } +} + +@Composable +private fun ListEmptyView( + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double), + modifier = modifier.padding(vertical = MainTheme.spacings.double), + ) { + val annotatedString = annotatedStringResource( + id = R.string.funding_googleplay_contribution_list_empty_message, + argument = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MainTheme.colors.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + withLink( + LinkAnnotation.Url( + url = stringResource(R.string.funding_googleplay_thunderbird_website_url), + ), + ) { + append(stringResource(R.string.funding_googleplay_thunderbird_website_domain)) + } + } + }, + ) + + TextBodyMedium( + text = stringResource(R.string.funding_googleplay_contribution_list_empty_title), + ) + + TextBodyMedium( + text = annotatedString, + ) + } +} + +@Composable +private fun ListErrorView( + error: BillingError, + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val showDetails = remember { mutableStateOf(false) } + + Column( + verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MainTheme.spacings.half), + ) { + TextBodyLarge( + text = mapErrorToTitle(error), + ) + if (error.message.isNotEmpty()) { + Icon( + imageVector = if (showDetails.value) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = "Show more details", + modifier = Modifier + .clickable { showDetails.value = !showDetails.value } + .padding(MainTheme.spacings.quarter), + ) + } + + AnimatedVisibility(visible = showDetails.value) { + TextBodySmall( + text = error.message, + color = MainTheme.colors.onErrorContainer, + ) + } + } + + ButtonText( + text = stringResource(R.string.funding_googleplay_contribution_list_error_retry_button), + onClick = onRetryClick, + modifier = Modifier.padding(top = MainTheme.spacings.default), + ) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListItem.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListItem.kt new file mode 100644 index 0000000..b1511a0 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionListItem.kt @@ -0,0 +1,44 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.theme2.MainTheme + +@Composable +internal fun ContributionListItem( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isSelected: Boolean = false, +) { + Box( + modifier = modifier + .border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) MainTheme.colors.primary else MainTheme.colors.outlineVariant, + shape = MainTheme.shapes.small, + ) + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + TextBodyMedium( + text = text, + modifier = Modifier.padding( + horizontal = MainTheme.spacings.triple, + vertical = MainTheme.spacings.oneHalf, + ), + ) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreen.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreen.kt new file mode 100644 index 0000000..30b6ee4 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreen.kt @@ -0,0 +1,79 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.common.mvi.observe +import app.k9mail.core.ui.compose.designsystem.organism.TopAppBarWithBackButton +import app.k9mail.core.ui.compose.designsystem.template.Scaffold +import app.k9mail.feature.funding.googleplay.R +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ViewModel +import org.koin.androidx.compose.koinViewModel + +@Composable +internal fun ContributionScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ViewModel = koinViewModel(), +) { + val activity = LocalActivity.current as ComponentActivity + val context = LocalContext.current + + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is ContributionContract.Effect.ManageSubscription -> { + context.startActivity( + getManageSubscriptionIntent( + productId = effect.productId, + packageName = context.packageName, + ), + ) + } + + is ContributionContract.Effect.PurchaseContribution -> { + effect.startPurchaseFlow(activity) + } + } + } + + BackHandler { + onBack() + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBarWithBackButton( + title = stringResource(R.string.funding_googleplay_contribution_title), + onBackClick = onBack, + ) + }, + ) { innerPadding -> + ContributionContent( + state = state.value, + onEvent = { dispatch(it) }, + contentPadding = innerPadding, + ) + } +} + +private const val SUBSCRIPTION_URL = "https://play.google.com/store/account/subscriptions" + +private fun getManageSubscriptionIntent( + productId: String, + packageName: String, +): Intent { + val uri = Uri.parse(SUBSCRIPTION_URL) + .buildUpon() + .appendQueryParameter("sku", productId) + .appendQueryParameter("package", packageName) + .build() + + return Intent(Intent.ACTION_VIEW, uri) +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionViewModel.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionViewModel.kt new file mode 100644 index 0000000..f6a73eb --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionViewModel.kt @@ -0,0 +1,238 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.funding.googleplay.domain.DomainContract +import app.k9mail.feature.funding.googleplay.domain.DomainContract.UseCase +import app.k9mail.feature.funding.googleplay.domain.entity.AvailableContributions +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Effect +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Event +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import net.thunderbird.core.outcome.handle + +@Suppress("TooManyFunctions") +internal class ContributionViewModel( + private val getAvailableContributions: UseCase.GetAvailableContributions, + private val billingManager: DomainContract.BillingManager, + initialState: State = State(), +) : BaseViewModel(initialState), + ViewModel { + + init { + viewModelScope.launch { + loadAvailableContributions() + } + + viewModelScope.launch { + billingManager.purchasedContribution.collect { result -> + result.handle( + onSuccess = { purchasedContribution -> + updateState { state -> + state.copy( + listState = state.listState.copy( + isLoading = false, + ), + purchasedContribution = purchasedContribution, + showContributionList = purchasedContribution == null, + purchaseError = null, + ) + } + }, + onFailure = { + updateState { state -> + state.copy( + listState = state.listState.copy( + isLoading = false, + ), + purchasedContribution = null, + showContributionList = true, + purchaseError = it, + ) + } + }, + ) + } + } + } + + private suspend fun loadAvailableContributions() { + getAvailableContributions().handle( + onSuccess = { data -> + updateState { state -> + val selectedContribution = selectContribution(data) + + state.copy( + listState = state.listState.copy( + oneTimeContributions = data.oneTimeContributions.toImmutableList(), + recurringContributions = data.recurringContributions.toImmutableList(), + selectedContribution = selectedContribution, + isRecurringContributionSelected = selectedContribution is RecurringContribution, + isLoading = false, + ), + purchasedContribution = data.purchasedContribution, + showContributionList = data.purchasedContribution == null, + ) + } + }, + onFailure = { + updateState { state -> + state.copy( + listState = state.listState.copy( + isLoading = false, + error = it, + ), + ) + } + }, + ) + } + + private fun selectContribution(data: AvailableContributions): Contribution? { + val hasSelectedContribution = state.value.listState.selectedContribution != null && + ( + data.oneTimeContributions.contains(state.value.listState.selectedContribution) || + data.recurringContributions.contains(state.value.listState.selectedContribution) + ) + + return if (hasSelectedContribution) { + state.value.listState.selectedContribution + } else { + if (state.value.listState.isRecurringContributionSelected) { + data.recurringContributions.getSecondLowestOrNull() + } else { + data.oneTimeContributions.getSecondLowestOrNull() + } + } + } + + override fun event(event: Event) { + when (event) { + Event.OnOneTimeContributionSelected -> onOneTimeContributionSelected() + Event.OnRecurringContributionSelected -> onRecurringContributionSelected() + is Event.OnContributionItemClicked -> onContributionItemClicked(event.item) + is Event.OnPurchaseClicked -> onPurchaseClicked() + is Event.OnManagePurchaseClicked -> onManagePurchaseClicked(event.contribution) + Event.OnShowContributionListClicked -> onShowContributionListClicked() + Event.OnDismissPurchaseErrorClicked -> updateState { + it.copy( + purchaseError = null, + ) + } + + Event.OnRetryClicked -> onRetryClicked() + } + } + + private fun onOneTimeContributionSelected() { + updateState { + it.copy( + listState = it.listState.copy( + isRecurringContributionSelected = false, + selectedContribution = it.listState.oneTimeContributions.getSecondLowestOrNull(), + ), + showContributionList = true, + ) + } + } + + private fun onRecurringContributionSelected() { + updateState { + it.copy( + listState = it.listState.copy( + isRecurringContributionSelected = true, + selectedContribution = it.listState.recurringContributions.getSecondLowestOrNull(), + ), + showContributionList = true, + ) + } + } + + private fun List.getSecondLowestOrNull(): Contribution? { + return when { + this.size > 1 -> this.sortedBy { it.price }[1] + this.size == 1 -> this[0] + else -> null + } + } + + private fun onContributionItemClicked(item: Contribution) { + updateState { + it.copy( + it.listState.copy( + selectedContribution = item, + ), + ) + } + } + + private fun onPurchaseClicked() { + val selectedContribution = state.value.listState.selectedContribution ?: return + + updateState { + it.copy( + listState = it.listState.copy( + isLoading = true, + ), + ) + } + emitEffect( + Effect.PurchaseContribution( + startPurchaseFlow = { activity -> + viewModelScope.launch { + billingManager.purchaseContribution(activity, selectedContribution).handle( + onSuccess = { + // we need to wait for the callback to be called + }, + onFailure = { error -> + updateState { state -> + state.copy( + listState = state.listState.copy( + isLoading = false, + ), + purchaseError = error, + ) + } + }, + ) + } + }, + ), + ) + } + + private fun onManagePurchaseClicked(contribution: Contribution) { + emitEffect(Effect.ManageSubscription(contribution.id)) + } + + private fun onShowContributionListClicked() { + updateState { + it.copy( + showContributionList = true, + ) + } + } + + private fun onRetryClicked() { + updateState { + it.copy( + listState = it.listState.copy( + isLoading = true, + error = null, + ), + ) + } + viewModelScope.launch { + loadAvailableContributions() + } + } + + override fun onCleared() { + super.onCleared() + billingManager.clear() + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/image/HearthSunburst.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/image/HearthSunburst.kt new file mode 100644 index 0000000..3688289 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/image/HearthSunburst.kt @@ -0,0 +1,244 @@ +@file:Suppress("MagicNumber") + +package app.k9mail.feature.funding.googleplay.ui.contribution.image + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +val GoldenHearthSunburst: ImageVector + get() { + if (goldenInstance != null) { + return goldenInstance!! + } + goldenInstance = createInstance( + name = "GoldenHearthSunburst", + hearthColor = Color(0xFFFFC107), + hearthOutline = Color(0xFFFFA500), + ) + + return goldenInstance!! + } + +val HearthSunburst: ImageVector + get() { + if (instance != null) { + return instance!! + } + instance = createInstance( + name = "HearthSunburst", + hearthColor = Color(0xFFEF4444), + hearthOutline = Color(0xFFB91C1C), + ) + + return instance!! + } + +@Suppress("LongMethod") +private fun createInstance( + name: String, + defaultWidth: Dp = 270.dp, + defaultHeight: Dp = 204.dp, + viewportWidth: Float = 270f, + viewportHeight: Float = 204f, + hearthColor: Color, + hearthOutline: Color, +): ImageVector { + return ImageVector.Builder( + name = name, + defaultWidth = defaultWidth, + defaultHeight = defaultHeight, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + ).apply { + path(fill = SolidColor(hearthColor)) { + moveTo(120.38f, 18.74f) + curveTo(120.1f, 18.74f, 119.82f, 18.88f, 119.66f, 19.15f) + curveTo(119.43f, 19.54f, 119.64f, 19.76f, 119.92f, 20.26f) + curveTo(121.35f, 22.8f, 121.49f, 21.82f, 121.01f, 19.95f) + curveTo(120.87f, 19.41f, 121.09f, 19.05f, 120.75f, 18.85f) + curveTo(120.64f, 18.78f, 120.51f, 18.74f, 120.38f, 18.74f) + close() + moveTo(117.56f, 19.82f) + curveTo(117.25f, 19.82f, 117.11f, 20.29f, 117.44f, 21.18f) + curveTo(117.94f, 22.48f, 119.4f, 22.63f, 118.62f, 20.97f) + curveTo(118.35f, 20.4f, 118.05f, 20.05f, 117.81f, 19.9f) + curveTo(117.72f, 19.85f, 117.63f, 19.83f, 117.56f, 19.82f) + close() + moveTo(150.85f, 20.17f) + curveTo(150.72f, 20.17f, 150.59f, 20.21f, 150.48f, 20.27f) + curveTo(150.14f, 20.48f, 150.36f, 20.84f, 150.22f, 21.38f) + curveTo(149.74f, 23.25f, 149.88f, 24.23f, 151.31f, 21.68f) + curveTo(151.59f, 21.19f, 151.8f, 20.97f, 151.57f, 20.58f) + curveTo(151.41f, 20.31f, 151.13f, 20.16f, 150.85f, 20.17f) + close() + moveTo(114.53f, 20.85f) + curveTo(114.24f, 20.86f, 113.93f, 21.05f, 113.77f, 21.31f) + curveTo(113.53f, 21.72f, 113.67f, 22.24f, 114.07f, 22.48f) + curveTo(114.48f, 22.72f, 115.86f, 23.35f, 116.27f, 23.14f) + curveTo(116.62f, 22.96f, 115.57f, 21.71f, 114.94f, 21.02f) + curveTo(114.9f, 20.98f, 114.85f, 20.94f, 114.81f, 20.92f) + curveTo(114.72f, 20.87f, 114.63f, 20.85f, 114.53f, 20.85f) + close() + moveTo(153.67f, 21.25f) + curveTo(153.6f, 21.25f, 153.51f, 21.28f, 153.42f, 21.33f) + curveTo(153.18f, 21.48f, 152.88f, 21.83f, 152.61f, 22.4f) + curveTo(151.83f, 24.06f, 153.29f, 23.91f, 153.79f, 22.61f) + curveTo(154.12f, 21.72f, 153.98f, 21.25f, 153.67f, 21.25f) + close() + moveTo(156.7f, 22.28f) + curveTo(156.6f, 22.28f, 156.51f, 22.3f, 156.42f, 22.35f) + curveTo(156.38f, 22.37f, 156.33f, 22.41f, 156.29f, 22.45f) + curveTo(155.66f, 23.14f, 154.61f, 24.39f, 154.96f, 24.57f) + curveTo(155.37f, 24.78f, 156.75f, 24.15f, 157.15f, 23.91f) + curveTo(157.56f, 23.67f, 157.7f, 23.14f, 157.46f, 22.74f) + curveTo(157.3f, 22.48f, 156.99f, 22.28f, 156.7f, 22.28f) + close() + moveTo(113.29f, 24.11f) + curveTo(113.01f, 24.12f, 112.69f, 24.32f, 112.55f, 24.55f) + curveTo(112.33f, 24.93f, 112.47f, 25.43f, 112.87f, 25.66f) + curveTo(113.27f, 25.9f, 115.03f, 26f, 115.21f, 25.6f) + curveTo(115.39f, 25.21f, 114.01f, 24.9f, 113.67f, 24.3f) + curveTo(113.64f, 24.24f, 113.59f, 24.19f, 113.54f, 24.16f) + curveTo(113.46f, 24.12f, 113.38f, 24.1f, 113.29f, 24.11f) + close() + moveTo(157.94f, 25.53f) + curveTo(157.85f, 25.53f, 157.77f, 25.55f, 157.69f, 25.59f) + curveTo(157.64f, 25.62f, 157.59f, 25.67f, 157.56f, 25.73f) + curveTo(157.21f, 26.32f, 155.84f, 26.64f, 156.02f, 27.03f) + curveTo(156.2f, 27.43f, 157.96f, 27.33f, 158.36f, 27.09f) + curveTo(158.76f, 26.85f, 158.9f, 26.36f, 158.68f, 25.98f) + curveTo(158.54f, 25.75f, 158.22f, 25.55f, 157.94f, 25.53f) + close() + moveTo(113.96f, 27.5f) + curveTo(113.74f, 27.51f, 113.55f, 27.54f, 113.38f, 27.72f) + curveTo(113.06f, 28.06f, 113.11f, 28.44f, 113.58f, 28.54f) + curveTo(113.9f, 28.6f, 114.14f, 28.53f, 114.39f, 28.32f) + curveTo(114.79f, 27.98f, 114.82f, 27.73f, 114.61f, 27.6f) + curveTo(114.52f, 27.55f, 114.37f, 27.51f, 114.19f, 27.51f) + curveTo(114.11f, 27.5f, 114.03f, 27.5f, 113.96f, 27.5f) + close() + moveTo(157.27f, 28.93f) + curveTo(157.2f, 28.93f, 157.12f, 28.93f, 157.04f, 28.94f) + curveTo(156.86f, 28.94f, 156.71f, 28.97f, 156.62f, 29.03f) + curveTo(156.41f, 29.16f, 156.43f, 29.41f, 156.84f, 29.75f) + curveTo(157.09f, 29.96f, 157.33f, 30.03f, 157.65f, 29.96f) + curveTo(158.12f, 29.87f, 158.17f, 29.49f, 157.85f, 29.15f) + curveTo(157.68f, 28.97f, 157.49f, 28.93f, 157.27f, 28.93f) + close() + moveTo(143.51f, 48.26f) + curveTo(142.89f, 48.3f, 142.71f, 49.43f, 141.91f, 50.3f) + curveTo(140.81f, 51.5f, 139.65f, 52.62f, 140.7f, 53.15f) + curveTo(142.39f, 53.97f, 144.52f, 48.64f, 143.68f, 48.28f) + curveTo(143.67f, 48.28f, 143.65f, 48.27f, 143.64f, 48.27f) + curveTo(143.59f, 48.26f, 143.55f, 48.26f, 143.51f, 48.26f) + close() + } + path( + fill = Brush.radialGradient( + colorStops = arrayOf( + 0f to hearthColor, + 1f to hearthColor.copy(alpha = 0f), + ), + center = Offset(134.18f, 39.04f), + radius = 155.24f, + ), + fillAlpha = 0.15f, + strokeAlpha = 0.15f, + ) { + moveTo(10.03f, 9.37f) + curveTo(-6.4f, 9.37f, -0.97f, 89.68f, 14.26f, 85.6f) + lineTo(97.33f, 37.63f) + curveTo(94.06f, 29.77f, 94.39f, 26.19f, 96.7f, 18.26f) + lineTo(10.03f, 9.37f) + close() + moveTo(258.49f, 9.37f) + lineTo(171.82f, 18.26f) + curveTo(174.7f, 26.17f, 173.89f, 29.99f, 171.19f, 37.63f) + lineTo(254.26f, 85.6f) + curveTo(274.78f, 91.1f, 273.85f, 9.37f, 258.49f, 9.37f) + close() + moveTo(105.07f, 47.53f) + lineTo(25.02f, 146.39f) + curveTo(6.37f, 178.71f, 66.1f, 215.9f, 85.07f, 183.03f) + lineTo(117.92f, 60.39f) + lineTo(105.07f, 47.53f) + close() + moveTo(163.45f, 47.53f) + lineTo(150.59f, 60.39f) + lineTo(183.45f, 183.03f) + curveTo(207.39f, 224.52f, 273.35f, 198.12f, 243.49f, 146.39f) + lineTo(163.45f, 47.53f) + close() + } + path( + fill = SolidColor(hearthOutline), + fillAlpha = 0.2f, + strokeAlpha = 0.2f, + ) { + moveTo(161.54f, 39.01f) + curveTo(164.61f, 35.95f, 166.16f, 32.4f, 166.16f, 28.11f) + curveTo(166.16f, 23.82f, 164.61f, 19.38f, 161.54f, 16.31f) + curveTo(158.47f, 13.25f, 154.47f, 11.71f, 150.46f, 11.71f) + curveTo(146.46f, 11.71f, 142.45f, 14.33f, 139.38f, 17.39f) + lineTo(135.69f, 21.08f) + lineTo(132f, 17.39f) + curveTo(128.93f, 14.33f, 124.92f, 11.71f, 120.92f, 11.71f) + curveTo(116.91f, 11.71f, 112.91f, 13.25f, 109.84f, 16.31f) + curveTo(106.77f, 19.38f, 105.25f, 23.82f, 105.25f, 28.11f) + curveTo(105.25f, 32.4f, 106.77f, 35.95f, 109.84f, 39.01f) + curveTo(117.43f, 46.42f, 124.46f, 53.41f, 131.63f, 61.22f) + curveTo(132.65f, 62.24f, 134.17f, 63.25f, 135.69f, 63.25f) + curveTo(137.21f, 63.25f, 138.73f, 62.24f, 139.75f, 61.22f) + curveTo(146.77f, 53.28f, 154.04f, 46.51f, 161.54f, 39.01f) + close() + } + path(fill = SolidColor(hearthOutline)) { + moveTo(120.92f, 9.37f) + curveTo(116.32f, 9.37f, 111.69f, 11.15f, 108.18f, 14.66f) + curveTo(104.58f, 18.25f, 102.91f, 23.27f, 102.91f, 28.11f) + curveTo(102.91f, 32.96f, 104.75f, 37.24f, 108.18f, 40.68f) + curveTo(108.19f, 40.68f, 108.19f, 40.69f, 108.2f, 40.69f) + curveTo(115.78f, 48.09f, 122.78f, 55.04f, 129.9f, 62.8f) + curveTo(129.93f, 62.83f, 129.95f, 62.85f, 129.98f, 62.88f) + curveTo(131.27f, 64.16f, 133.13f, 65.59f, 135.69f, 65.59f) + curveTo(138.25f, 65.59f, 140.12f, 64.16f, 141.41f, 62.88f) + curveTo(141.44f, 62.84f, 141.47f, 62.81f, 141.5f, 62.77f) + curveTo(148.43f, 54.94f, 155.66f, 48.2f, 163.2f, 40.68f) + curveTo(166.62f, 37.25f, 168.5f, 32.97f, 168.5f, 28.11f) + curveTo(168.5f, 23.25f, 166.78f, 18.25f, 163.2f, 14.66f) + curveTo(159.68f, 11.15f, 155.06f, 9.37f, 150.46f, 9.37f) + curveTo(145.43f, 9.37f, 141.05f, 12.42f, 137.73f, 15.74f) + lineTo(135.69f, 17.77f) + lineTo(133.66f, 15.74f) + curveTo(130.34f, 12.42f, 125.94f, 9.37f, 120.92f, 9.37f) + close() + moveTo(120.92f, 14.06f) + curveTo(123.9f, 14.06f, 127.53f, 16.24f, 130.34f, 19.05f) + lineTo(134.03f, 22.74f) + curveTo(134.47f, 23.18f, 135.07f, 23.43f, 135.69f, 23.43f) + curveTo(136.31f, 23.43f, 136.9f, 23.18f, 137.34f, 22.74f) + lineTo(141.04f, 19.05f) + curveTo(143.86f, 16.24f, 147.47f, 14.06f, 150.46f, 14.06f) + curveTo(153.86f, 14.06f, 157.26f, 15.35f, 159.88f, 17.97f) + curveTo(162.43f, 20.52f, 163.82f, 24.38f, 163.82f, 28.11f) + curveTo(163.82f, 31.84f, 162.59f, 34.65f, 159.88f, 37.35f) + curveTo(152.46f, 44.77f, 145.18f, 51.56f, 138.09f, 59.56f) + curveTo(137.36f, 60.3f, 136.17f, 60.91f, 135.69f, 60.91f) + curveTo(135.21f, 60.91f, 134.03f, 60.3f, 133.29f, 59.56f) + curveTo(126.1f, 51.74f, 119.07f, 44.74f, 111.49f, 37.35f) + curveTo(108.79f, 34.65f, 107.6f, 31.85f, 107.6f, 28.11f) + curveTo(107.6f, 24.37f, 108.95f, 20.51f, 111.49f, 17.97f) + curveTo(114.12f, 15.35f, 117.51f, 14.06f, 120.92f, 14.06f) + close() + } + }.build() +} + +private var goldenInstance: ImageVector? = null +private var instance: ImageVector? = null diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/ActivityLifecycleObserver.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/ActivityLifecycleObserver.kt new file mode 100644 index 0000000..bee4d62 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/ActivityLifecycleObserver.kt @@ -0,0 +1,64 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import app.k9mail.feature.funding.api.FundingSettings +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +class ActivityLifecycleObserver +@OptIn(ExperimentalTime::class) +constructor( + private val settings: FundingSettings, + private val clock: Clock = Clock.System, +) : FundingReminderContract.ActivityLifecycleObserver { + + private var lifecycleObserver: LifecycleObserver? = null + + override fun register(lifecycle: Lifecycle, onDestroy: () -> Unit) { + lifecycleObserver = createLifecycleObserver(onDestroy) + + lifecycleObserver?.let { + lifecycle.addObserver(it) + } + } + + override fun unregister(lifecycle: Lifecycle) { + lifecycleObserver?.let { + lifecycle.removeObserver(it) + lifecycleObserver = null + } + } + + private fun createLifecycleObserver(onDestroy: () -> Unit): DefaultLifecycleObserver { + return object : DefaultLifecycleObserver { + private var startTime: Long = 0L + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + @OptIn(ExperimentalTime::class) + startTime = clock.now().toEpochMilliseconds() + } + + override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + + @OptIn(ExperimentalTime::class) + val endTime = clock.now().toEpochMilliseconds() + val newActiveTime = endTime - startTime + val oldActiveTime = settings.getActivityCounterInMillis() + + if (newActiveTime >= 0) { + settings.setActivityCounterInMillis(oldActiveTime + newActiveTime) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + onDestroy() + } + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FragmentLifecycleObserver.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FragmentLifecycleObserver.kt new file mode 100644 index 0000000..337b3fa --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FragmentLifecycleObserver.kt @@ -0,0 +1,45 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks + +class FragmentLifecycleObserver( + private val targetFragmentTag: String, +) : FundingReminderContract.FragmentLifecycleObserver { + + private var lifecycleCallbacks: FragmentLifecycleCallbacks? = null + + override fun register(fragmentManager: FragmentManager, onShow: () -> Unit) { + lifecycleCallbacks = createFragmentLifecycleCallback( + onCallback = { + onShow() + unregister(fragmentManager) + }, + ) + + // Register the lifecycle observer with the fragment manager. + lifecycleCallbacks?.let { + fragmentManager.registerFragmentLifecycleCallbacks(it, false) + } + } + + override fun unregister(fragmentManager: FragmentManager) { + lifecycleCallbacks?.let { + fragmentManager.unregisterFragmentLifecycleCallbacks(it) + lifecycleCallbacks = null + } + } + + private fun createFragmentLifecycleCallback(onCallback: () -> Unit): FragmentLifecycleCallbacks { + return object : FragmentLifecycleCallbacks() { + override fun onFragmentDetached(fragmentManager: FragmentManager, fragment: Fragment) { + super.onFragmentDetached(fragmentManager, fragment) + + if (fragment.tag == targetFragmentTag) { + onCallback() + } + } + } + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminder.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminder.kt new file mode 100644 index 0000000..9581d3f --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminder.kt @@ -0,0 +1,99 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import android.content.Context +import android.content.pm.PackageManager.NameNotFoundException +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentManager +import app.k9mail.feature.funding.api.FundingSettings +import app.k9mail.feature.funding.googleplay.ui.reminder.FundingReminderContract.ActivityLifecycleObserver +import app.k9mail.feature.funding.googleplay.ui.reminder.FundingReminderContract.FragmentLifecycleObserver +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +class FundingReminder +@OptIn(ExperimentalTime::class) +constructor( + private val settings: FundingSettings, + private val fragmentObserver: FragmentLifecycleObserver, + private val activityCounterObserver: ActivityLifecycleObserver, + private val dialog: FundingReminderContract.Dialog, + private val clock: Clock = Clock.System, +) : FundingReminderContract.Reminder { + + override fun registerReminder( + activity: AppCompatActivity, + onOpenFunding: () -> Unit, + ) { + // TODO: Let the caller make the decision on which FragmentManager to use. + val dialogFragmentManager = activity.supportFragmentManager + + // TODO: Let the caller provide this. Or, better yet, let the caller notify FundingReminder when it's a good + // time to display the funding reminder dialog. + val observedFragmentManager = activity.supportFragmentManager + + dialogFragmentManager.setFragmentResultListener( + FundingReminderContract.Dialog.FRAGMENT_REQUEST_KEY, + activity, + ) { _, result -> + if (result.getBoolean(FundingReminderContract.Dialog.FRAGMENT_RESULT_SHOW_FUNDING, false)) { + onOpenFunding() + } + } + + // If the reminder reference timestamp is not set, we set it to the first install time. + if (settings.getReminderReferenceTimestamp() == 0L) { + resetReminderReferenceTimestamp(activity) + } + + // We register the activity counter observer to keep track of the time the user spends in the app. + // We also ensure that the observers are unregistered when the activity is destroyed. + activityCounterObserver.register(activity.lifecycle) { + fragmentObserver.unregister(observedFragmentManager) + activityCounterObserver.unregister(activity.lifecycle) + } + + // If the reminder has already been shown, we don't need to show it again. + if (wasReminderShown()) { + return + } + + if (shouldShowReminder()) { + fragmentObserver.register(observedFragmentManager) { + showFundingReminderDialog(dialogFragmentManager) + } + } + } + + private fun wasReminderShown(): Boolean { + return settings.getReminderShownTimestamp() != 0L + } + + private fun shouldShowReminder(): Boolean { + @OptIn(ExperimentalTime::class) + val currentTime = clock.now().toEpochMilliseconds() + + return settings.getReminderShownTimestamp() == 0L && + settings.getReminderReferenceTimestamp() + FUNDING_REMINDER_DELAY_MILLIS <= currentTime && + settings.getActivityCounterInMillis() >= FUNDING_REMINDER_MIN_ACTIVITY_MILLIS + } + + @Suppress("SwallowedException") + private fun resetReminderReferenceTimestamp(context: Context) { + try { + val installTime = context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + settings.setReminderReferenceTimestamp(installTime) + } catch (exception: NameNotFoundException) { + @OptIn(ExperimentalTime::class) + settings.setReminderReferenceTimestamp(clock.now().toEpochMilliseconds()) + } + } + + private fun showFundingReminderDialog(fragmentManager: FragmentManager) { + // We're about to show the funding reminder dialog. So mark it as being shown. This way, if there's an error, + // we err on the side of the dialog not being shown rather than it being shown more than once. + @OptIn(ExperimentalTime::class) + settings.setReminderShownTimestamp(clock.now().toEpochMilliseconds()) + + dialog.show(fragmentManager) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderContract.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderContract.kt new file mode 100644 index 0000000..f6bee0b --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderContract.kt @@ -0,0 +1,37 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle + +// 1 week in milliseconds +const val FUNDING_REMINDER_DELAY_MILLIS = 7 * 24 * 60 * 60 * 1000L + +// 30 minutes in milliseconds +const val FUNDING_REMINDER_MIN_ACTIVITY_MILLIS = 30 * 60 * 1000L + +interface FundingReminderContract { + + interface Reminder { + fun registerReminder(activity: AppCompatActivity, onOpenFunding: () -> Unit) + } + + fun interface Dialog { + fun show(fragmentManager: FragmentManager) + + companion object { + const val FRAGMENT_REQUEST_KEY = "funding_reminder_dialog" + const val FRAGMENT_RESULT_SHOW_FUNDING = "show_funding" + } + } + + interface FragmentLifecycleObserver { + fun register(fragmentManager: FragmentManager, onShow: () -> Unit) + fun unregister(fragmentManager: FragmentManager) + } + + interface ActivityLifecycleObserver { + fun register(lifecycle: Lifecycle, onDestroy: () -> Unit) + fun unregister(lifecycle: Lifecycle) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialog.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialog.kt new file mode 100644 index 0000000..d45824d --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialog.kt @@ -0,0 +1,10 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.fragment.app.FragmentManager + +class FundingReminderDialog : FundingReminderContract.Dialog { + override fun show(fragmentManager: FragmentManager) { + val dialogFragment = FundingReminderDialogFragment() + dialogFragment.show(fragmentManager, null) + } +} diff --git a/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialogFragment.kt b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialogFragment.kt new file mode 100644 index 0000000..06e1f68 --- /dev/null +++ b/feature/funding/googleplay/src/main/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialogFragment.kt @@ -0,0 +1,32 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import android.app.Dialog +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import app.k9mail.feature.funding.googleplay.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +internal class FundingReminderDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val contentView = layoutInflater.inflate(R.layout.funding_googleplay_contribution_reminder, null) + + return MaterialAlertDialogBuilder(requireContext()) + .setIcon(R.drawable.funding_googleplay_contribution_reminder_icon) + .setTitle(R.string.funding_googleplay_contribution_reminder_title) + .setView(contentView) + .setPositiveButton(R.string.funding_googleplay_contribution_reminder_positive_button) { _, _ -> + handlePositiveButton() + } + .setNegativeButton(R.string.funding_googleplay_contribution_reminder_negative_button, null) + .create() + } + + private fun handlePositiveButton() { + setFragmentResult( + FundingReminderContract.Dialog.FRAGMENT_REQUEST_KEY, + bundleOf(FundingReminderContract.Dialog.FRAGMENT_RESULT_SHOW_FUNDING to true), + ) + } +} diff --git a/feature/funding/googleplay/src/main/res/drawable/funding_googleplay_contribution_reminder_icon.xml b/feature/funding/googleplay/src/main/res/drawable/funding_googleplay_contribution_reminder_icon.xml new file mode 100644 index 0000000..6ec7085 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/drawable/funding_googleplay_contribution_reminder_icon.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/feature/funding/googleplay/src/main/res/layout/funding_googleplay_contribution_reminder.xml b/feature/funding/googleplay/src/main/res/layout/funding_googleplay_contribution_reminder.xml new file mode 100644 index 0000000..24b1653 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/layout/funding_googleplay_contribution_reminder.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/funding/googleplay/src/main/res/values-am/strings.xml b/feature/funding/googleplay/src/main/res/values-am/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-am/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-ar/strings.xml b/feature/funding/googleplay/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..ae50222 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ar/strings.xml @@ -0,0 +1,57 @@ + + + ادعمْ ثَندَربِرْد + ادعم ثندربيرد + يتم تمويل ثندربيرد بالكامل من مساهمات مستخدمين مثلك. نحن لا نعرض إعلانات أو نبيع بياناتك أبداً. إذا كنت تستمتع بثندربيرد، يرجى المساعدة في دعمه. لا يمكننا القيام بذلك بدونك! + خالص الشكر منّا جميعاً! + إن مساهمتك تعزز من تطوير ثندربرد ونحن ممتنون حقاً لدعمك. + المساهمة آمنة + تبرع مرة واحدة + شهرياً + لا يوجد + المساهمات غير معفاة من الضرائب كتبرعات خيرية. + المساهمات داخل التطبيق غير متاحة حالياً. + أعد المحاولة + مواصلة الدفع + الدفع غير متاح حالياً + تعديل الدفع الشهري + قدم مساهمة أخرى مؤثرة + عرض المزيد من التفاصيل + استبعاد الخطأ + خطأ غير معروف + فشلت عملية الشراء. + المساهمات غير متاحة حالياً. + المساهمة الأساسية + المساهمة القيمة + مساهمة هامة + مساهمة كبرى + مساهمة بارزة + مساهمة استثنائية + دعمكم يدفعنا إلى الأمام! فحتى أصغر المساهمات تخلق طفرات من التغيير، ونحن ممتنون للغاية لوجودكم معنا. + أنت تساعدنا في تحقيق أشياء عظيمة! بمساهمتكم على هذا المستوى، فإنكم تساعدوننا على اتخاذ خطوات مجدية إلى الأمام مما يسمح لنا بمواصلة النمو. + مساهمتك تتألق حقاً! إنكم تلعبون دوراً رئيسياً في النهوض برسالتنا، وتساعدوننا على تحقيق إنجازات مهمة وتحقيق تأثير أكبر. + أنتم سبب كبير في قدرتنا على القيام بما نقوم به! فبفضل كرمكم، يمكننا أن نقوم بمشاريع أكبر ونحقق إنجازات جديدة معاً. + دعمكم ملهم حقاً! فبفضل مساهمتكم الكبيرة، أصبحنا قادرين على تخطي الحدود والابتكار وتحقيق خطوات كبيرة نحو تحقيق أهدافنا. + أنت تساعدنا حقاً في تحقيق أحلامنا الكبيرة! إن دعمكم المذهل يمكّننا من تحقيق ما هو استثنائي، ويترك أثراً دائماً على كل ما نقوم به. + المساهمة الشهرية الأساسية + المساهمة الشهرية المقدرة + مساهمة شهرية هامة + مساهمتك تساعدنا على النمو واتخاذ خطوات هادفة إلى الأمام - شكراً لك! + دعمكم يدفعنا إلى الأمام! كل مساهمة تُحدث تغييراً - شكراً لكم! + مساهمة شهرية استثنائية + مساهمة شهرية متميزة + مساهمة شهرية ضخمة + قم بزيارة %s لمزيد من الطرق لدعم ثندربرد. + ليس الآن + نعم + انضم إلى مهمتنا لابتكار أفضل تجربة بريد إلكتروني تحترم الخصوصية وقابلة للتخصيص. + يتم تمويلنا فقط من خلال مستخدمين مثلك. + نحن لا نبيع بياناتك. + نحن لا نعرض الإعلانات. + ثندربرد مجاني ومفتوح المصدر. + ادعم ثندربرد + أنت تساعدنا على تحقيق أحلام كبيرة! دعمكم لنا يمكّننا من تحقيق أشياء غير عادية. + دعمكم ملهم! فمساهمتكم تساعدنا على تخطي الحدود وتحقيق أهدافنا. + أنت سبب كبير في قدرتنا على القيام بما نقوم به! إن كرمكم يساعدنا على تحقيق إنجازات جديدة. + مساهمتك تلمع! أنت مفتاح النهوض بمهمتنا وتحقيق الأثر. + diff --git a/feature/funding/googleplay/src/main/res/values-ast/strings.xml b/feature/funding/googleplay/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ast/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-az/strings.xml b/feature/funding/googleplay/src/main/res/values-az/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-az/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-be/strings.xml b/feature/funding/googleplay/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..a1d4f31 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-be/strings.xml @@ -0,0 +1,57 @@ + + + Падтрымаць Thunderbird + Падтрымаць Thunderbird + Бяспечны ўнёсак + Шчырая падзяка ад усіх нас! + Ахвяраваць раз + Штомесячна + Працягнуць аплату + Аплата зараз недаступная + Невядомая памылка + Каштоўныя ахвяраванні + Значныя ахвяраванні + Ваша падтрымка сапраўды натхняе! Дзякуючы вашаму значнаму ўнёску мы можам пашыраць межы, укараняць інавацыі і рабіць значныя крокі ў дасягненні нашых мэтаў. + Значныя штомесячныя ахвяраванні + Вялікія ахвяраванні + Мы не паказваем рэкламу. + Мы не прадаем вашы дадзеныя. + Мы фінансуемся выключна такімі карыстальнікамі, як вы. + Ваш унёсак спрыяе развіццю Thunderbird, і мы шчыра ўдзячныя за вашу падтрымку. + Ахвяраванні праз дадатак зараз немагчымыя. + Паўтарыць спробу + Няма даступных + Зрабіце яшчэ адзін важны ўнёсак + Змяніць штомесячны плацёж + Адхіліць памылку + Паказаць больш падрабязнасцей + Плацёж не выкананы. + Ахвяраванні пакуль недаступныя. + Істотныя ахвяраванні + Выключныя ахвяраванні + Выдатныя ахвяраванні + Ваш унёсак сапраўды ззяе! Вы адыгрываеце важную ролю ў прасоўванні нашай місіі, дапамагаеце нам дасягнуць важных этапаў і дасягнуць большага ўплыву. + Вы — вялікая прычына таго, чаму мы можам рабіць тое, што робім! Дзякуючы вашай шчодрасці мы можам брацца за больш маштабныя праекты і разам дасягаць новых вынікаў. + Вы сапраўды дапамагаеце нам марыць па-буйному! Ваша неверагодная падтрымка дае нам сілы дасягаць незвычайнага, пакідаючы працяглы ўплыў на ўсё, што мы робім. + Істотныя штомесячныя ахвяраванні + Вялікія штомесячныя ахвяраванні + Выдатныя штомесячныя ахвяраванні + Выключныя штомесячныя ахвяраванні + Ваш унёсак дапамагае нам развівацца і рабіць значныя крокі наперад — дзякуй! + Ваш унёсак ззяе! Вы — ключ да прасоўвання нашай місіі і дасягнення вынікаў. + Ваша падтрымка рухае нас наперад! Кожны ўнёсак стварае змены — дзякуй! + Вы дапамагаеце нам марыць па-буйному! Ваша падтрымка дае нам сілы дасягаць незвычайных вынікаў. + Падтрымаць Thunderbird + Далучайцеся да нашай місіі па стварэнні максімальна зручнай і наладжвальнай электроннай пошты, якая забезпечвае канфідэнцыяльнасць. + Так + Не зараз + Thunderbird цалкам фінансуецца за кошт унёскаў такіх карыстальнікаў, як вы. Мы ніколі не паказваем рэкламу і не прадаем вашы даныя. Калі вам падабаецца Thunderbird, калі ласка, падтрымайце яго. Мы не можам зрабіць гэта без вас! + Унёскі не вылічваюцца з падаткаў як дабрачынныя ахвяраванні. + Каштоўныя штомесячныя ахвяраванні + Thunderbird — гэта бясплатная праграма з адкрытым зыходным кодам. + Наведайце %s каб даведацца больш пра спосабы падтрымкі Thunderbird. + Вы дапамагаеце нам рабіць вялікія справы! Робячы такі ўнёсак, вы дапамагаеце нам рабіць значныя крокі наперад, што дазваляе нам працягваць развіццё. + Ваша падтрымка дапамагае нам рухацца наперад! Нават самы маленькі ўнёсак стварае хвалі пераменаў, і мы вельмі ўдзячныя вам за вашу падтрымку. + Вы — вялікая прычына, чаму мы можам рабіць тое, што робім! Ваша шчодрасць дапамагае нам дасягаць новых вышынь. + Ваша падтрымка натхняе! Ваш унёсак дапамагае нам пашыраць межы і дасягаць нашых мэтаў. + diff --git a/feature/funding/googleplay/src/main/res/values-bg/strings.xml b/feature/funding/googleplay/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..1bed753 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-bg/strings.xml @@ -0,0 +1,57 @@ + + + Подкрепа на Thunderbird + Подкрепа на Thunderbird + Thunderbird се финансира изцяло от дарения от потребители като вас. Никога не показваме реклами и не продаваме вашите данни. Ако харесвате Thunderbird, моля, подкрепете ни. Не можем да го направим без вас! + Сърдечно ви благодарим! + Вашият принос допринася за развитието на Thunderbird и ние сме искрено благодарни за вашата подкрепа. + Еднократно + Защитенa вноска + Месечно + Не е достъпна + Даренията не се признават за данъчни облекчения като благотворителни дарения. + В момента не е възможно да се правят дарения в приложението. + Посетете %s, за да видите как да подкрепите Thunderbird. + Отново + Продължаване към плащането + Плащането в момента не е достъпно + Промяна на месечното плащане + Направете друг значим принос + Показване на повече детайли + Отхвърлане на грешка + Непозната грешка + Неуспешна покупка. + Към момента даренията не са достъпни. + Съществен принос + Ценен принос + Значителен принос + Голям принос + Изключителен принос + Невероятен принос + Вашата подкрепа ни помага да продължаваме напред! Дори и най-малките приноси създават вълни на промяна и ние сме много благодарни, че сте с нас. + Вие ни помагате да постигнем велики неща! С вашия принос на това ниво ни помагате да направим значими стъпки напред, които ни позволяват да продължим да се развиваме. + Вашият принос наистина блести! Вие играете важна роля в напредъка на нашата мисия, като ни помагате да постигнем важни цели и да окажем по-голямо влияние. + Вие сте основната причина, поради която можем да правим това, което правим! Благодарение на вашата щедрост можем да се заемем с по-големи проекти и да постигнем нови успехи заедно. + Вашата подкрепа е наистина вдъхновяваща! Благодарение на вашия значителен принос, ние можем да разширяваме границите, да внедряваме иновации и да правим значителни стъпки към постигането на нашите цели. + Вие наистина ни помагате да мечтаем за големи неща! Вашата невероятна подкрепа ни дава сили да постигнем необикновени резултати, които оставят траен отпечатък върху всичко, което правим. + Вашата подкрепа ни дава сили да продължаваме напред! Всяка вноска води до промяна – благодарим ви! + Подкрепа на Thunderbird + Thunderbird е свободен и с отворен код. + Не показваме реклами. + Не продаваме данните ви. + Ние се финансираме изцяло от потребители като вас. + Присъединете се към нашата мисия да създадем възможно най-доброто преживяване при използването на имейл, което зачита личните данни и може да се персонализира. + Да + Не сега + Вашият принос ни помага да се развиваме и да правим значими стъпки напред – благодарим ви! + Вашият принос е от голямо значение! Вие сте ключът към напредъка на нашата мисия и постигането на резултати. + Вие сте основната причина да можем да правим това, което правим! Вашата щедрост ни помага да постигаме нови успехи. + Вашата подкрепа е вдъхновяваща! Вашият принос ни помага да разширяваме границите и да постигаме целите си. + Вие ни помагате да мечтаем за големи неща! Вашата подкрепа ни дава сили да постигнем невероятни резултати. + Месечно дарение + Месечно дарение + Месечно дарение + Месечно дарение + Месечно дарение + Месечно дарение + diff --git a/feature/funding/googleplay/src/main/res/values-bn/strings.xml b/feature/funding/googleplay/src/main/res/values-bn/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-bn/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-br/strings.xml b/feature/funding/googleplay/src/main/res/values-br/strings.xml new file mode 100644 index 0000000..78fb400 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-br/strings.xml @@ -0,0 +1,5 @@ + + + Thunderbird Skoazell + Thunderbird Skoazell + diff --git a/feature/funding/googleplay/src/main/res/values-bs/strings.xml b/feature/funding/googleplay/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-bs/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-ca/strings.xml b/feature/funding/googleplay/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000..d83d9b3 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ca/strings.xml @@ -0,0 +1,57 @@ + + + Contribució segura + Dóna suport a Thunderbird + Donació única + Continua amb el pagament + Feu una altra contribució impactant + Dóna suport a Thunderbird + Mensual + Thunderbird està totalment finançat amb les contribucions econòmiques d\'usuaris com tu. Mai mostrem anuncis ni venem les teves dades. Si t\'agrada Thunderbird, si us plau, ajudeu a donar-li suport. No podem fer això sense tu! + Cap disponible + Les contribucions no són deduïbles fiscalment com a donacions benèfiques. + Modifica el pagament mensual + Visiteu %s per obtenir més maneres de donar suport al Thunderbird. + Actualment, les contribucions des de l\'aplicació no estan disponibles. + Mostra més detalls + Error desconegut + La compra ha fallat. + En aquest moment les contribucions no estan disponibles. + Contribució essencial + Contribució destacada + Contribució valuosa + Contribució important + Gran contribució + Contribució excepcional + La teva contribució enforteix el desenvolupament del Thunderbird i t\'agraïm de tot cor el teu suport. + Torna-ho a provar + El pagament no està disponible en aquests moments + Ignora l\'error + El vostre suport ens fa avançar! Fins i tot les contribucions més petites generen un canvi, i estem molt agraïts de tenir-vos a bord. + Gràcies de tot cor de part de tots nosaltres! + El teu suport és realment inspirador! Gràcies a la teva substancial contribució, som capaços de superar els límits, innovar i progressar significativament cap als nostres objectius. + Contribució mensual important + Contribució mensual excepcional + La teva contribució brilla! Ets clau per a impulsar la nostra missió i assolir un impacte. + Ets una de les raons principals per les quals podem fer el que fem! La teva generositat ens ajuda a assolir noves fites. + Thunderbird és gratuït i de codi obert. + No mostrem anuncis. + No venem les teves dades. + Ens financem únicament gràcies a usuaris com tu. + Ets una gran raó per la qual podem fer el que fem! Gràcies a la teva generositat, podem prendre projectes més grans i assolir noves fites junts. + La teva contribució ens ajuda a créixer i a fer passos importants endavant. Moltes gràcies! + + La teva contribució realment brilla! Estàs exercint un paper important en l\'avenç del anostra missió, ajudant-nos a assolir fites importants i aconseguir un impacte major. + Realment ens estàs ajudant a somiar en gran! El teu increïble suport ens empodera per a aconseguir l\'extraordinari, deixant un impacte durador en tot el que fem. + Contribució mensual essencial + Una valiosa contribució mensual + El teu suport ens impulsa endavant! Cada contribució crea un canvi - gràcies! + Ens ajudes a somiar en gran! El teu suport ens permet aconseguir coses extraordinàries. + Donar suport a Thunderbird + El teu suport és inspirador! La teva contribució ens ajuda a superar els límits i assolir les nostres fites. + Dona suport a la nostra missió de crear la millor experiència de correu electrònic possible que respecta la privacitat i és personalitzable. + Ens estàs ajudant a fer grans coses! Contribuint en aquest nivell, ens estàs ajudant a fer passos significatius cap endavant que ens permetran continuar creixent. + Contribució mensual significativa + Contribució mensual destacada + Ara no + diff --git a/feature/funding/googleplay/src/main/res/values-co/strings.xml b/feature/funding/googleplay/src/main/res/values-co/strings.xml new file mode 100644 index 0000000..01abce2 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-co/strings.xml @@ -0,0 +1,57 @@ + + + Sustene Thunderbird + Cuntribuzione assicurata + Dà una volta + Ogni mese + Nisuna dispunibule + Cuntinuà u pagamentu + Sustene Thunderbird + Ora, e cuntribuzioni ùn sò micca dispunibule via l’appiecazione. + Visitate u situ %s per cunnosce parechje manere di sustene Thunderbird. + Pruvà torna + Pagamentu indispunibule attualmente + Mudificà u pagamentu misincu + Fate un’altra cuntribuzione impurtantissima + Affissà più d’infurmazioni + Righjittà u sbagliu + Sbagliu scunnisciutu + Fiascu di a cumprera. + Ora, e cuntribuzioni ùn sò micca dispunibule. + Thunderbird hè finanziatu sanu sanu da e cuntribuzioni da i nostri utilizatori. Ùn affissemu alcuna publicità è un vindemu mai i vostri dati. S’è Thunderbird vi piace, aiutateci à sustenelu. Senza voi, ùn si pò fà què ! + E cuntribuzioni ùn ponu micca esse scuntate da l’impositi cum’è e dunazioni. + Di core, vi ringraziemu tantu ! + A vostra cuntribuzione favurisce u sviluppu di Thunderbird è vi simu assai ricunnuscente di u vostru sustene. + Cuntribuzione essenziale + Cuntribuzione preziosa + Cuntribuzione significativa + Cuntribuzione impurtante + Cuntribuzione straurdinaria + Cuntribuzione eccezziunale + Ci aiutate à fà cose maiò ! Cuntribuiscendu à stu livellu, ci aiutate à piglià misure nutevule chì ci permettenu di cuntinuà à cresce. + U vostru sustegnu ci permette d’andà più luntanu ! Ancu e cuntribuzioni e più chjuche creanu onde di cambiamentu è vi ringraziemu assai di cuntavvi cù noi. + A vostra cuntribuzione spampigliuleghja ! Ghjucate un rollu maiò in l’avanzamentu di a nostra missione, aiutenduci à francà tappe impurtante è ottene un impattu più maiò. + Ghjè grazia à voi chì no simu capace di fà ciò chì no femu ! A vostra generusità ci permette d’incaricacci prughjetti più maiò è di francà tappe nove, inseme. + Cuntribuzione misinca impurtante + U vostru sustegnu hè una vera stimulazione ! Grazia à a vostra cuntribuzione capitale, pudemu francà i cunfini, innuvà, è fà i passi maiò versu i nostri ogettivi. + Da veru, ci aiutate à sunnià tamantu ! U vostru sustegnu fantasticu ci dà a forza di realizà u straurdinariu, lasciendu un impattu durevule nant’à tuttu ciò chì no femu. + Cuntribuzione misinca essenziale + Cuntribuzione misinca preziosa + Cuntribuzione misinca significativa + Cuntribuzione misinca straurdinaria + Cuntribuzione misinca eccezziunale + U vostru sustegnu ci permette d’andà in avanti !Ogni cuntribuzione permette un cambiamentu, è vi ringraziemu ! + A vostra cuntribuzione ci aiuta à cresce è à piglià misure nutevule per andà più luntanu. Vi ringraziemu ! + U vostru sustegnu hè una stimulazione ! A vostra cuntribuzione ci aiuta à francà i cunfini è raghjunghje i nostri ogettivi. + Ghjè grazia à voi chì no pudemu fà ciò chì no femu ! A vostra generusità ci aiuta à francà tappe nove. + A vostra cuntribuzione spampigliuleghja ! Ghjucate un rollu maiò in l’avanzamentu di a nostra missione è per ottene un impattu. + Thunderbird un prugramma di rigalu è liberu. + Ùn affissemu alcuna publicità. + Ùn vindemu micca i vostri dati. + Simu finanziatu solu da i nostri utilizatori, cum’è voi. + Ci aiutate à sunnià tamantu ! U vostru sustegnu ci dà a forza di realizà cose straurdinarie. + Micca subitu + + Sustene Thunderbird + Raghjunghjite a nostra missione di creà a più bella esperienza di messaghjeria elettronica, persunalizavule è rispettosa di a vostra vita privata. + diff --git a/feature/funding/googleplay/src/main/res/values-cs/strings.xml b/feature/funding/googleplay/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..b2beba1 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-cs/strings.xml @@ -0,0 +1,57 @@ + + + Bezpečné přispění + Jednou + Pokračovat k platbě + Thunderbird je financován pouze dary od uživatelů, jako jste vy. Nikdy nezobrazujeme reklamy ani neprodáváme vaše data. Pokud se vám Thunderbird líbí, pomozte jej podpořit. Bez vás to nedokážeme! + Příspěvky nelze odečíst z daní jako charitativní dary. + Měsíčně + Podpořte Thunderbird + Nedostupné + Podpořte Thunderbird + Upravit měsíční platbu + Přispějte dalším významným příspěvkem + Dary v aplikaci nejsou v tuto chvíli dostupné. + Zkusit znovu + Platba není v tuto chvíli dostupná + Zobrazit další podrobnosti + Zavřít chybu + Výjimečný měsíční dar + Navštivte %s pro další způsoby, jak podpořit Thunderbird. + Nákup se nezdařil. + Podstatný dar + Dary nejsou v tuto chvíli dostupné. + Neznámá chyba + Cenný dar + Důležitý dar + Významný dar + Mimořádný dar + Vaše podpora nás posouvá vpřed! I ty nejmenší příspěvky udělají změnu a my jsme vděční za to, že jste s námi. + Výjimečný dar + Pomáháte nám uskutečňovat velké věci! Přispěním na této úrovni nám pomůžete dělat smysluplné kroky vpřed, které nám umožní další růst. + Cenný měsíční dar + Důležitý měsíční dar + Podstatný měsíční dar + Významný měsíční dar + Mimořádný měsíční dar + Díky vám můžeme dělat to, co děláme! Díky vaší štědrosti se můžeme pouštět do větších projektů a společně dosahovat nových milníků. + Vaše podpora je skutečně inspirující! Díky vašemu významnému příspěvku můžeme posouvat hranice, inovovat a významně se přibližovat k našim cílům. + Opravdu nám pomáháte dělat velké věci! Vaše neuvěřitelná podpora nám umožňuje dosahovat mimořádných úspěchů a zanechává trvalý dopad na vše, co děláme. + Váš příspěvek nám pomáhá růst a dělat smysluplné kroky vpřed – děkujeme! + Váš příspěvek pomáhá! Jste klíčem k rozvoji našeho poslání a dosažení dopadu. + Díky vám můžeme dělat to, co děláme! Vaše štědrost nám pomáhá dosahovat nových milníků. + Vaše podpora je inspirující! Váš příspěvek nám pomáhá posouvat hranice a dosahovat našich cílů. + Připojte se k našemu poslání vytvořit nejlepší možnou přizpůsobitelnou e-mailovou službu respektující soukromí. + Ano + Teď ne + Váš příspěvek opravdu pomůže! Hrajete důležitou roli v rozvoji našeho poslání, pomáháte nám dosáhnout důležitých milníků a většího dopadu. + Vaše podpora nás žene kupředu! Každý příspěvek vytváří změnu – děkujeme! + Pomáháte nám dělat velké věci! Vaše podpora nám umožňuje dosahovat mimořádných úspěchů. + Podpořte Thunderbird + Váš dar podporuje vývoj Thunderbirdu a my jsme za vaši podporu opravdu vděční. + Upřímné poděkování od nás všech! + Jsme financováni výhradně uživateli, jako jste vy. + Thunderbird je zdarma a open-source. + Nezobrazujeme reklamy. + Neprodáváme vaše data. + diff --git a/feature/funding/googleplay/src/main/res/values-cy/strings.xml b/feature/funding/googleplay/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-cy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-da/strings.xml b/feature/funding/googleplay/src/main/res/values-da/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-da/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-de/strings.xml b/feature/funding/googleplay/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..478874a --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-de/strings.xml @@ -0,0 +1,57 @@ + + + Sicher spenden + Thunderbird unterstützen + Monatlich + Keine verfügbar + Monatliche Zahlung ändern + Thunderbird unterstützen + Die Spenden sind nicht als wohltätige Spenden steuerlich absetzbar. + Weiter zur Zahlung + Einmalig + Leiste einen weiteren wirkungsvollen Beitrag + Wiederholen + Zahlung derzeit nicht möglich + Weitere Details anzeigen + Fehler verwerfen + Unbekannter Fehler + Kauf fehlgeschlagen. + Maßgebliche Spende + Außergewöhnliche Spende + Du hilfst uns, Großes zu erreichen! Indem du einen solchen Spendenbeitrag leistest, hilfst du uns, einen bedeutenden Schritt vorwärts zu machen, damit wir weiter wachsen können. + Du bist ein wichtiger Grund dafür, dass wir tun können, was wir tun! Dank deiner Großzügigkeit können wir größere Projekte in Angriff nehmen und gemeinsam neue Meilensteine erreichen. + Deine Unterstützung ist wirklich inspirierend! Dank deines erheblichen Spendenbeitrags sind wir in der Lage, Grenzen zu verschieben, Innovationen voranzutreiben und unseren Zielen ein gutes Stück näher zu kommen. + Du hilfst uns wirklich, groß zu träumen! Deine unglaubliche Unterstützung ermöglicht es uns, Außergewöhnliches zu erreichen, und hinterlässt einen nachhaltigen Einfluss auf alles, was wir tun. + Grundlegende monatliche Spende + Wertvolle monatliche Spende + Signifikante monatliche Spende + Maßgebliche monatliche Spende + Dein Spendenbeitrag hilft uns zu wachsen und sinnvolle Schritte nach vorne zu machen — vielen Dank! + Du bist ein wichtiger Grund dafür, dass wir tun können, was wir tun! Deine Großzügigkeit hilft uns, neue Meilensteine zu erreichen. + Du hilfst uns, große Träume zu verwirklichen! Deine Unterstützung befähigt uns, außergewöhnliche Dinge zu erreichen. + Thunderbird finanziert sich ausschließlich durch Beiträge von Nutzern wie dir. Wir zeigen keine Werbung und verkaufen deine Daten nicht. Wenn dir Thunderbird gefällt, hilf uns bitte, es zu unterhalten. Ohne dich können wir das nicht tun! + In-App-Spenden sind derzeit nicht verfügbar. + Besuche %s für weitere Möglichkeiten zur Unterstützung von Thunderbird. + Spenden sind derzeit nicht verfügbar. + Grundlegende Spende + Wertvolle Spende + Signifikante Spende + Herausragende Spende + Deine Unterstützung bringt uns weiter voran! Selbst die kleinsten Spenden bewirken einen Wandel, und wir sind sehr dankbar, dass wir dich an Bord haben. + Dein Spenderbeitrag kann sich wirklich sehen lassen! Du spielst eine wichtige Rolle beim Weiterkommen unserer Mission, hilfst uns, wichtige Meilensteine zu erreichen und eine größere Wirkung zu erzielen. + Herausragende monatliche Spende + Außergewöhnliche monatliche Spende + Deine Unterstützung bringt uns voran! Jeder Beitrag schafft Veränderung — vielen Dank! + Dein Spendenbeitag strahlt! Du bist der Schlüssel, um unseren Auftrag voranzubringen und Wirkung zu erzielen. + Deine Unterstützung ist inspirierend! Dein Spendenbeitrag hilft uns, Grenzen zu überschreiten und unsere Ziele zu erreichen. + Thunderbird unterstützen + Ja + Schließe dich unserer Mission an, die bestmögliche E-Mail-Erfahrung zu schaffen, die den Datenschutz respektiert und anpassbar ist. + Jetzt nicht + Dein Beitrag fördert die Entwicklung von Thunderbird und wir sind sehr dankbar für deine Unterstützung. + Ein herzliches Dankeschön von uns allen! + Wir zeigen keine Werbung an. + Wir verkaufen deine Daten nicht. + Wir werden ausschließlich von Nutzern wie dir finanziert. + Thunderbird ist kostenlos und quelloffen. + diff --git a/feature/funding/googleplay/src/main/res/values-el/strings.xml b/feature/funding/googleplay/src/main/res/values-el/strings.xml new file mode 100644 index 0000000..d676335 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-el/strings.xml @@ -0,0 +1,57 @@ + + + Υποστηρίξτε το Thunderbird + Υποστηρίξτε το Thunderbird + Το Thunderbird χρηματοδοτείται εξ ολοκλήρου από συνεισφορές χρηστών όπως εσείς. Δεν προβάλλουμε ποτέ διαφημίσεις ούτε πωλούμε τα δεδομένα σας. Αν απολαμβάνετε το Thunderbird, βοηθήστε μας να το υποστηρίξουμε. Δεν μπορούμε να τα καταφέρουμε χωρίς εσάς! + Εφάπαξ δωρεά + Μηνιαία + Κανένα διαθέσιμο + Επισκεφθείτε το %s για περισσότερους τρόπους υποστήριξης του Thunderbird. + Συνέχεια στην πληρωμή + Η αγορά απέτυχε. + Οι συνεισφορές δεν είναι προς το παρόν διαθέσιμες. + Μας βοηθάτε να πραγματοποιήσουμε σπουδαία πράγματα! Συνεισφέροντας σε αυτό το επίπεδο, μας βοηθάτε να κάνουμε σημαντικά βήματα προς τα εμπρός, επιτρέποντάς μας να συνεχίσουμε να αναπτυσσόμαστε. + Απαραίτητη μηνιαία συνεισφορά + Το Thunderbird είναι δωρεάν και ανοιχτού κώδικα. + Η υποστήριξή σας εμπνέει! Η συνεισφορά σας μας βοηθά να ξεπεράσουμε τα όρια και να επιτύχουμε τους στόχους μας. + Δεν προβάλλουμε διαφημίσεις. + Δεν πωλούμε τα δεδομένα σας. + Χρηματοδοτούμαστε αποκλειστικά από χρήστες όπως εσείς. + Η συνεισφορά σας προάγει την ανάπτυξη του Thunderbird και είμαστε πραγματικά ευγνώμονες για την υποστήριξή σας. + Οι συνεισφορές δεν εκπίπτουν φορολογικά ως φιλανθρωπικές δωρεές. + Επανάληψη + Τροποποίηση μηνιαίας πληρωμής + Κάντε άλλη μια ουσιαστική συνεισφορά + Πληρωμή προς το παρόν μη διαθέσιμη + Εμφάνιση περισσότερων λεπτομερειών + Απόρριψη σφάλματος + Άγνωστο σφάλμα + Εκτιμώμενη συνεισφορά + Σημαντική συμβολή + Εξαιρετική συμβολή + Εξαιρετική συνεισφορά + Η συμβολή σας πραγματικά λάμπει! Παίζετε σημαντικό ρόλο στην προώθηση της αποστολής μας, βοηθώντας μας να φτάσουμε σε σημαντικά ορόσημα και να επιτύχουμε μεγαλύτερο αντίκτυπο. + Εκτιμώμενη μηνιαία συνεισφορά + Σημαντική μηνιαία συνεισφορά + Εξαιρετική μηνιαία συνεισφορά + Φοβερή μηνιαία συνεισφορά + Η υποστήριξή σας μας οδηγεί μπροστά! Κάθε συνεισφορά δημιουργεί αλλαγή - σας ευχαριστούμε! + Η συνεισφορά σας λάμπει! Είστε το κλειδί για την προώθηση της αποστολής μας και την επίτευξη αντίκτυπου. + Η συνεισφορά σας μας βοηθά να αναπτυχθούμε και να κάνουμε σημαντικά βήματα προς τα εμπρός - σας ευχαριστούμε! + Μας βοηθάτε να κάνουμε μεγάλα όνειρα! Η υποστήριξή σας μας δίνει τη δυνατότητα να πετύχουμε εκπληκτικά πράγματα. + Οι συνεισφορές εντός της εφαρμογής δεν είναι προς το παρόν διαθέσιμες. + Απαραίτητη συμβολή + Σημαντική συνεισφορά + Σημαντικότερη μηνιαία συνεισφορά + Όχι τώρα + Η υποστήριξή σας μας κρατάει μπροστά! Ακόμα και οι μικρότερες συνεισφορές δημιουργούν κυματισμούς αλλαγής και είμαστε ευγνώμονες που σας έχουμε μαζί μας. + Συμμετέχετε στην αποστολή μας να δημιουργήσουμε την καλύτερη δυνατή εμπειρία ηλεκτρονικού ταχυδρομείου με σεβασμό στην ιδιωτικότητα και δυνατότητα προσαρμογής. + Ένα ειλικρινές ευχαριστώ από όλους μας! + Ναι + Ασφαλής συνεισφορά + Είστε ένας σημαντικός λόγος που είμαστε σε θέση να κάνουμε αυτό που κάνουμε! Χάρη στη γενναιοδωρία σας, μπορούμε να αναλαμβάνουμε μεγαλύτερα έργα και να φτάνουμε μαζί σε νέα ορόσημα. + Μας βοηθάτε πραγματικά να κάνουμε μεγάλα όνειρα! Η απίστευτη υποστήριξή σας μας δίνει τη δυνατότητα να πετύχουμε το εξαιρετικό, αφήνοντας μόνιμο αντίκτυπο σε ό,τι κάνουμε. + Η υποστήριξή σας είναι πραγματικά εμπνευσμένη! Χάρη στην ουσιαστική συνεισφορά σας, είμαστε σε θέση να διευρύνουμε τα όρια, να καινοτομήσουμε και να κάνουμε σημαντικά βήματα προς την επίτευξη των στόχων μας. + Είστε ένας σημαντικός λόγος που μπορούμε να κάνουμε αυτό που κάνουμε! Η γενναιοδωρία σας μας βοηθά να φτάσουμε σε νέα ορόσημα. + Υποστηρίξτε το Thunderbird + diff --git a/feature/funding/googleplay/src/main/res/values-en-rGB/strings.xml b/feature/funding/googleplay/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..46378f5 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,5 @@ + + + Support Thunderbird + Support Thunderbird + diff --git a/feature/funding/googleplay/src/main/res/values-enm/strings.xml b/feature/funding/googleplay/src/main/res/values-enm/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-enm/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-eo/strings.xml b/feature/funding/googleplay/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000..9f910ee --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-eo/strings.xml @@ -0,0 +1,8 @@ + + + Subteni Thunderbird + Subteni Thunderbird + Ne nun + Subteni Thunderbird + Jes + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-es/strings.xml b/feature/funding/googleplay/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..3bf118b --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-es/strings.xml @@ -0,0 +1,57 @@ + + + Thunderbird se financia en su totalidad con contribuciones de usuarios como tú. Nunca mostramos anuncios ni vendemos tus datos. Si disfrutas de Thunderbird, por favor, ayúdanos a mantenerlo. ¡No podemos hacer esto sin ti! + Continuar con el pago + Mensual + Donación única + Apoya Thunderbird + Contribución segura + Ninguno disponible + Modifica el pago mensual + Hacer otra contribución impactante + Las contribuciones no son fiscalmente deducibles como donaciones benéficas. + Apoya Thunderbird + Reintentar + Pago no disponible + Ver más detalles + Rechazar error + Error desconocido + Compra fallida. + Actualmente, las colaboraciones no están disponibles. + Las colaboraciones dentro de la aplicación no están disponibles actualmente. + Visite %s para conocer más formas de apoyar a Thunderbird. + Gran contribución + Aporte excepcional + ¡Tu colaboración realmente brilla! Está desempeñando un papel importante en el avance de nuestra misión, ayudándonos a alcanzar hitos importantes y lograr un mayor impacto. + Una valiosa contribución mensual + Contribución mensual significativa + Importante contribución mensual + Contribución mensual pendiente + Contribución mensual excepcional + ¡Su apoyo nos impulsa hacia adelante! ¡Cada contribución crea un cambio -- gracias! + ¡Tu contribución brilla! Eres clave para impulsar nuestra misión y lograr un impacto. + Aportación esencial + Colaboración valiosa + Contribución destacada + Contribución importante + ¡Su apoyo nos mantiene avanzando! Incluso las contribuciones más pequeñas crean ondas de cambio, y estamos muy agradecidos de tenerlos a bordo. + ¡Nos estás ayudando a hacer grandes cosas! Al colaborar a este nivel, nos estás ayudando a dar pasos significativos hacia adelante que nos permitan seguir creciendo. + ¡Eres una gran razón para que podamos hacer lo que hacemos! Gracias a tu generosidad, podemos tomar proyectos más grandes y alcanzar nuevos hitos juntos. + ¡Su apoyo es verdaderamente inspirador! Gracias a su sustancial contribución, somos capaces de superar los límites, innovar y hacer progresos significativos hacia nuestros objetivos. + ¡Realmente nos estás ayudando a soñar a lo grande! Tu increíble apoyo nos empodera para lograr lo extraordinario, dejando un impacto duradero en todo lo que hacemos. + Contribución mensual esencial + Su apoyo nos ayuda a crecer y dar pasos importantes hacia adelante. ¡Muchas gracias! + ¡Eres una de las razones principales por las que podemos hacer lo que hacemos! Su generosidad nos ayuda a alcanzar nuevas metas. + ¡Tu apoyo es inspirador! Tu contribución nos ayuda a superar los límites y alcanzar nuestras metas. + ¡Nos ayudas a soñar a lo grande! Tu apoyo nos permite lograr cosas extraordinarias. + Tu contribución promueve el desarrollo de Thunderbird y estamos realmente agradecidos por tu apoyo. + Soporte Thunderbird + Apoye nuestra misión de crear la mejor experiencia de correo electrónico posible que respete la privacidad y sea personalizable. + + Ahora no + ¡Un gracias sincero de parte de todos nosotros! + No vendemos tus datos. + Thunderbird es gratuito y de código abierto. + No mostramos anuncios. + Nos financiamos únicamente gracias a usuarios como tu. + diff --git a/feature/funding/googleplay/src/main/res/values-et/strings.xml b/feature/funding/googleplay/src/main/res/values-et/strings.xml new file mode 100644 index 0000000..d82bcb5 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-et/strings.xml @@ -0,0 +1,57 @@ + + + Toeta Thunderbirdi + Turvaline rahaline toetus + Iga kuu + Jätka maksega + Pole saadaval + Toeta Thunderbirdi + Meie rahastamine põhineb terves mahus vaid kasutajate toetustel. Me kunagi ei näita reklaame ega müü sinu andmeid. Kui sulle Thunderbird meeldib, siis palun aita meid. Me ei saaks jätkata ilma sinuta! + Anneta üks kord + Toetustel puudub maksuvabastus. + Anna veel üks mõjus panus + Muuda igakuist makset + Rakenduse-sisene annetamise võimalus hetkel puudub. + Meie saidist %s leiad veel võimalusi Thunderbirdi toetamiseks. + Proovi uuesti + Maksmisvõimalus pole hetkel saadaval + Näita lisateavet + Sulge viga + Tundmatu viga + Suur panus + Eriti märkimisväärne panus + Igakuine väärtuslik panus + Igakuine märgatav panus + Igakuine suur panus + Igakuine eriti märkimisväärne panus + Sinu toetus viib meid edasi! Iga toetus loob muutusi - suur tänu! + Sa oled suur põhjus, miks me saame teha seda, mida me teeme! Sinu suuremeelsus aitab meil saavutada uusi verstaposte. + Väärtuslik panus + Ostutehing ei õnnestunud. + Rahalise toetamise võimalus hetkel puudub. + Märgatav panus + Sinu abi aitab meil edasi liikuda! Isegi väikseimad toetused tekitavad muutusi ja me oleme väga tänulikud, et oled meiega koos. + Vajalik panus + Väljapaistev panus + Sa aitad meil teha suuri asju! Sellisel tasemel panustades aitad meil astuda olulisi samme, mis võimaldavad meil jätkuvalt kasvada. + Sinu toetus paistab tõesti silma! Sa mängid olulist rolli meie missiooni edendamisel, aidates meil saavutada olulisi verstaposte ja saavutada suuremat mõju. + Sa oled suur põhjus, miks me saame teha seda, mida me teeme! Tänu sinu heldusele saame võtta ette suuremaid projekte ja saavutada koos uusi verstaposte. + Sinu toetus aitab meil kasvada ja astuda olulisi samme edasi - suur tänu! + Sinu toetus on inspireeriv! Sinu panus aitab meil ületada piire ja saavutada oma eesmärke. + Sinu toetus on tõeliselt inspireeriv! Tänu sinu märkimisväärsele panusele oleme võimelised laiendama piire, uuendama ja tegema märkimisväärseid edusamme meie eesmärkide saavutamisel. + Igakuine vajalik panus + Sa tõesti aitad meil unistada suurelt! Sinu uskumatu toetus annab meile võimaluse saavutada erakordset, jättes püsiva mõju kõigele, mida me teeme. + Igakuine väljapaistev panus + Sinu toetus paistab silma! Sa oled meie missiooni edendamisel ja mõju saavutamisel võtmetähtsusega. + Sa aitad meil unistada suurelt! Sinu toetus annab meile võimaluse saavutada erakordseid asju. + Jah + Mitte praegu + Toeta Thunderbirdi + Liitu meie missiooniga parima võimaliku privaatsustaustava ja kohandatava e-postikliendi loomisel. + Sinu toetus otseselt edendab Thunderbirdi arendust ja me oleme tänulikud sinu toe eest. + Siiras tänu sulle meie kõigi poolt! + Me ei näita reklaame. + Thunderbird on tasuta ja põhineb avatud lähtekoodil. + Me ei müü sinu andmeid. + Meid rahastavad vaid sellised kasutajad, nagu sinagi. + diff --git a/feature/funding/googleplay/src/main/res/values-eu/strings.xml b/feature/funding/googleplay/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000..80231ba --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-eu/strings.xml @@ -0,0 +1,16 @@ + + + Errore ezezaguna + Lagundu Thunderbird + Hilero + Lagundu Thunderbird + Berriro saiatu + Erakutsi xehetasun gehiago + Baztertu errorea + Bai + Orain ez + Ez dago ezer eskuragarririk + Lagundu Thunderbirdi + Thunderbird doakoa eta kode irekikoa da. + Eman behin + diff --git a/feature/funding/googleplay/src/main/res/values-fa/strings.xml b/feature/funding/googleplay/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000..b90005d --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-fa/strings.xml @@ -0,0 +1,57 @@ + + + پرداخت یک باره + حمایت از تاندربرد + مشارکت امن + ماهانه + مشارکت‌ها به عنوان اعانه‌های خیریه شامل مالیات نمی‌شوند. + ادامه به پرداخت + تاندربرد کاملاً به دست مشارکت‌های مالی کاربرانمان مثل شما، تأمین می‌شود. ما هرگز تبلیغات نشان نداده یا داده‌هایتان را نمی‌فروشیم. لطفاً در حمایت از آن کمک کنید. ما نمی توانیم این کار را بدون شما انجام دهیم! + پشتیبانی تاندربرد + هیچ کدام موجود نیست + یک کمک موثر دیگر داشته باشید + پرداخت ماهانه را تغییر دهید + شما دلیل بزرگی هستید که ما می توانیم آنچه را که باید، انجام دهیم! به لطف سخاوت شما، ما می توانیم پروژه های بزرگتری را انجام دهیم و با هم به اهداف مهم جدیدی برسیم. + کمک ماهانه قابل توجه + شما واقعاً به ما کمک می کنید رویای بزرگ داشته باشیم! حمایت باورنکردنی شما به ما قدرت می‌دهد تا به چیزهای خارق‌العاده دست پیدا کنیم و تأثیری ماندگار بر هر کاری که انجام می‌دهیم بر جای بگذاریم. + کمک ماهانه عمده + مشارکت شما به ما کمک می‌کند رشد کنیم و گام‌های معنی‌داری به جلو برداریم—متشکریم! + حمایت شما ما را به جلو می‌راند! هر مشارکتی تغییر ایجاد می کند - متشکریم! + شما دلیل بزرگی هستید که ما می‌توانیم آنچه را که باید، انجام دهیم! سخاوت شما به ما کمک می کند تا به اهداف مهم جدید برسیم. + تلاش مجدد + مشارکت اساسی + مشارکت برجسته + حمایت شما موجب تداوم پیشرفت ما می‌شود! حتی کوچک‌ترین مشارکت‌ها موج‌هایی از تغییر ایجاد می‌کنند و ما از همراهی شما بسیار سپاسگزاریم. + شما به ما کمک می کنید تا چیزهای بزرگی اتفاق بیفتد! با مشارکت در این سطح، به ما کمک می‌کنید گام‌های مهمی رو به جلو برداریم تا به رشد خود ادامه دهیم. + آری + اکنون خیر + مشارکت درون‌برنامه‌ای در حال حاضر در دسترس نیست. + جهت راه‌های دیگر حمایت از تاندربرد، از %s بازدید نمایید. + پرداخت در حال حاضر در دسترس نیست + نمایش جزییات بیشتر + نادیده گرفتن خطا + خطای ناشناخته + پرداخت ناموفق. + مشارکت در حال حاضر در دسترس نیست. + مشارکت ارزشمند + مشارکت قابل توجه + مشارکت عمده + مشارکت استثنایی + سهیم شدن شما واقعا می‌درخشد! شما نقش مهمی در پیشبرد ماموریت ما ایفا می‌کنید و به ما کمک می‌کنید به اهداف مهم خود برسیم و به تأثیر بیشتر دست پیدا کنیم. + حمایت شما واقعا الهام بخش است! به لطف کمک قابل توجه شما، ما قادریم مرزها را جابجا کنیم، نوآوری کنیم و گام های مهمی را در جهت اهداف خود برداریم. + کمک ماهانه اساسی + کمک ماهانه ارزشمند + کمک ماهانه برجسته + کمک ماهانه استثنایی + سهیم شدن شما می درخشد! شما کلید پیشبرد ماموریت ما و دستیابی به تأثیرگذاری هستید. + حمایت شما الهام بخش است! مشارکت شما به ما کمک می کند تا مرزها را پشت سر بگذاریم و به اهداف خود برسیم. + شما به ما کمک می کنید رویای بزرگ داشته باشیم! حمایت شما ما را قادر می‌سازد تا به چیزهای خارق العاده‌ای دست یابیم. + حمایت از تاندربرد + به ماموریت ما بپیوندید تا بهترین تجربه رایانامه با احترام به حریم خصوصی و قابل شخصی‌سازی را ایجاد کنید. + حمایت شما به طور مستقیم، تاندربرد را تقویت می‌کند و ما واقعا از حمایت شما سپاسگزاریم. + یک تشکر صمیمانه از طرف همه ما! + ما آگهی نمایش نمی‌دهیم. + ما داده‌های شما را نمی‌فروشیم. + ما فقط توسط کاربرانی مانند شما تأمین مالی می‌شویم. + تاندربرد آزاد و متن‌باز است. + diff --git a/feature/funding/googleplay/src/main/res/values-fi/strings.xml b/feature/funding/googleplay/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000..325a840 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-fi/strings.xml @@ -0,0 +1,20 @@ + + + Jatka maksuun + Hylkää virhe + Tuntematon virhe + Osto epäonnistui. + Tue Thunderbirdiä + Kuukausittain + Muokkaa kuukausittaista maksua + Tue Thunderbirdiä + Auta pitämään Thunderbird elossa + Yritä uudelleen + Näytä enemmän tietoja + Sovelluksen sisäiset lahjoitukset eivät ole tällä hetkellä saatavilla. + Käy osoitteessa %s nähdäksesi lisää tapoja tukea Thunderbirdiä. + Tue Thunderbirdiä + Tavoitteemme on tarjota avoimen lähdekoodin sähköpostisovellus, joka on turvallinen, yksityinen ja ilmainen kaikille. Taloudellinen tukesi auttaa työtämme. Lahjoitatko tänään? + Kyllä + Ei nyt + diff --git a/feature/funding/googleplay/src/main/res/values-fr/strings.xml b/feature/funding/googleplay/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..c412ee7 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-fr/strings.xml @@ -0,0 +1,57 @@ + + + Don unique + Tous les mois + Les contributions ne sont pas déductibles fiscalement en tant que dons de bienfaisance. + Soutenir Thunderbird + Contribution sécurisée + Non proposée + Poursuivre vers le paiement + Modifier les mensualités + Soutenir Thunderbird + Rendez-vous sur %s pour découvrir d’autres façons de soutenir Thunderbird. + Réessayer + Le paiement n’est pas accessible actuellement + Faites un autre don remarqué + Afficher plus de détails + Rejeter l’erreur + Erreur inconnue + Échec de l’achat. + Les contributions ne sont pas accessibles actuellement. + Contribution essentielle + Contribution importante + Contribution extraordinaire + Votre contribution se démarque. Vous jouez un rôle important dans l’avancement de notre mission en nous aidant à franchir des étapes importantes, pour un impact croissant. + C’est en grande partie grâce à vous que nous pouvons faire ce que nous faisons. Votre générosité nous aide à franchir de nouvelles étapes. + Vous nous aidez vraiment à rêver grand. Votre soutien nous permet de réaliser l’extraordinaire. + Thunderbird est entièrement financée par les contributions de nos utilisateurs. Nous n’affichons aucune publicité ni ne vendons vos données. Si vous aimez Thunderbird, aidez-nous en la finançant. Votre soutien est essentiel. + Les contributions ne sont actuellement pas proposées dans l’appli. + Contribution appréciable + Contribution substantielle + Contribution exceptionnelle + Votre soutien nous permet de poursuivre notre mission. Même les plus petites contributions ont des répercussions et nous sommes très reconnaissants de vous avoir à bord. + Vous nous aidez à réaliser de grandes choses. En contribuant à ce niveau, vous nous aidez à prendre des mesures significatives qui nous permettent notre croissance. + C’est en grande partie grâce à vous que nous pouvons faire ce que nous faisons. Votre générosité nous permet d’entreprendre de plus grands projets et de franchir de nouvelles étapes, ensemble. + Votre soutien est une véritable source d’inspiration. Grâce à votre contribution substantielle, nous pouvons repousser les limites, innover et avancer à grands pas vers nos objectifs. + Vous nous aidez vraiment à rêver grand. Votre fantastique soutien nous permet de réaliser l’extraordinaire, en laissant un impact durable sur tout ce que nous accomplissons. + Contribution essentielle mensuelle + Contribution appréciable mensuelle + Contribution importante mensuelle + Votre soutien nous permet d’avancer. Chaque contribution est source de changements. Merci. + Contribution substantielle mensuelle + Contribution extraordinaire mensuelle + Contribution exceptionnelle mensuelle + Votre contribution alimente notre croissance et nous permet de prendre des mesures significatives pour aller de l’avant. Merci. + Votre contribution se démarque. Vous jouez un rôle essentiel dans la poursuite de notre mission, pour un impact croissant. + Votre soutien est une source d’inspiration. Votre contribution nous aide à repousser les limites et à atteindre nos objectifs. + Soutenir Thunderbird + Participez à notre mission de créer la meilleure expérience de courriel possible, qui respecte vos données personnelles. + Oui + Pas maintenant + Votre contribution financière alimente le développement de Thunderbird et nous sommes sincèrement reconnaissants de votre soutien. + Nous vous remercions tous sincèrement. + Thunderbird est gratuite (logiciel libre). + Nous n’affichons pas de publicités. + Nous ne vendons pas vos données. + Nous sommes entièrement financés par des utilisatrices et des utilisateurs tels que vous. + diff --git a/feature/funding/googleplay/src/main/res/values-fy/strings.xml b/feature/funding/googleplay/src/main/res/values-fy/strings.xml new file mode 100644 index 0000000..58d07e0 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-fy/strings.xml @@ -0,0 +1,57 @@ + + + Trochgean nei ôfrekkenjen + Moanliks + Bydragen binne net ôflûkber as donaasjes foar goede doelen. + Moanlikse betelling oanpasse + Stypje Thunderbird + Stypje Thunderbird + Thunderbird wurdt folslein finansiere troch bydragen fan ús brûkers as jo. Wy toane jo nea advertinsjes en ferkeapje nea jo gegevens. As jo genietsje fan Thunderbird, stypje it dan. Wy kinne dit net sûnder jo! + Feilige bydrage + Donearje ien kear + Neat beskikber + Doch noch in bydrage mei impact + Mear details toane + Flatermelding slute + Unbekende flater + Bûtengewoane bydrage + Signifikante moanlikse bydrage + Bûtengewoane moanlikse bydrage + Betelling op dit stuit beskikber + Bydragen yn de app binne op dit stuit net beskikber. + Besykje %s foar mear manieren om Thunderbird te stypjen. + Grutte moanlikse bydrage + Opnij probearje + Bydragen binne op dit stuit beskikber. + Wurdearre bydrage + Essinsjele bydrage + Grutte bydrage + Signifikante bydrage + Eksepsjonele bydrage + Wurdearre moanlikse bydrage + Jo stipe helpt ús foarút te kommen! Sels de lytste bydragen kinne foar it ferskil soargje, en wy binne tankber jo oan board te hawwen. + Essinsjele moanlikse bydrage + Eksepsjonele moanlikse bydrage + Jo helpe ús moaie dingen te ferwêzentliken! Troch op dit nivo by te dragen, helpe jo ús wichtige stappen foarút te meitsjen wat ús yn steat stelt fierder te groeien. + Jo bydrage jout glâns! Jo spylje in grutte rol yn de realisaasje fan ús misje, troch te helpen wichtige mylpeallen te berikjen en gruttere impact te krijen. + Jo bydrage is echt ynspirearjend! Mei jo substansjele bydrage, kinne wy grinzen ferlizze, ynnovearje en signifikante stappen meitsje nei ús doelen. + Jo helpe ús echt grut te dreamen! Jo geweldige stipe fersterket ús it bûtengewoane te berikken, mei in bliuwende impact op alles wat wy dogge. + Jo stipe helpt ús foarút! Elke bydrage liedt ta feroaring – tige tank! + Jo bydrage jout glâns! Jo binne de kaai om ús misje fierder te bringen en impact te berikjen. + Jo helpe ús echt grut te dreamen! Jo stipe fersterket ús it bûtengewoane te berikjen. + Jo bydrage is echt ynspirearjend! Mei jo bydrage kinne wy grinzen ferlizze en ús doelen berikje. + Jo binne in grutte reden wêrtroch wy dwaan kinne wat wy dogge! Mei tank oan jo goederjouskens kinne wy gruttere projekten starte en tegearre nije mylpeallen berikje. + Jo binne in grutte reden wêrtroch wy dwaan kinne wat wy dogge! Mei tank oan jo goederjouskens kinne wy nije mylpeallen berikje. + Jo bydrage helpt ús te groeien en wichtige stappen foarút te nimmen – tige tank! + Bydrage mislearre. + Doch mei oan ús misje om de beste privacy-respektearjende, oanpasbere e-mailûnderfinng mooglik te meitsjen. + Ja + Stypje Thunderbird + No net + Jo stipe bringt de ûntwikkeling fan Thunderbird fierder en wy binne tankber foar jo stipe. + Wy toane gjin advertinsjes. + Wy ferkeapje jo gegevens net. + Wy wurde allinnich finansiere troch brûkers lykas jo. + Us oprjochte tank oan jo! + Thunderbird is fergees en iepen boarne. + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-ga/strings.xml b/feature/funding/googleplay/src/main/res/values-ga/strings.xml new file mode 100644 index 0000000..4ea0d1b --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ga/strings.xml @@ -0,0 +1,58 @@ + + + Tacaigh le Thunderbird + Athraigh íocaíocht mhíosúil + Déan cion tairbhe eile + Tacaigh le Thunderbird + Ranníocaíocht slán + Go míosúil + Tá Thunderbird maoinithe go hiomlán ag ranníocaíochtaí ó úsáideoirí cosúil leatsa. Ní thaispeánaimid fógraí ná ní dhíolaimid do shonraí riamh. Má tá tú ag baint sult as Thunderbird, le do thoil cabhrú leis. Ní féidir linn é seo a dhéanamh gan tú! + Níl aon cheann ar fáil + Tabhair uair amháin + Níl ranníocaíochtaí inasbhainte ó thaobh cánach mar thabhartais charthanachta. + Lean ar aghaidh chuig an íocaíocht + Níl ranníocaíochtaí in-aip ar fáil faoi láthair. + Tabhair cuairt ar %s le haghaidh tuilleadh bealaí chun tacaíocht a thabhairt do Thunderbird. + Earráid anaithnid + Ranníocaíocht Mhíosúil Suntasach + Bain triail eile as + Níl an íocaíocht ar fáil faoi láthair + Taispeáin tuilleadh sonraí + Theip ar an gceannach. + Níl ranníocaíochtaí ar fáil faoi láthair. + Earráid dhíbhe + Coinníonn do thacaíocht muid ag bogadh ar aghaidh! Cruthaíonn fiú na ranníocaíochtaí is lú ripples an athraithe, agus tá muid chomh buíoch go bhfuil tú ar bord. + Ranníocaíocht Riachtanach + Ranníocaíocht Mhór + Ranníocaíocht Shuntasach + Ranníocaíocht Luachmhar + Ranníocaíocht Mhíosúil Riachtanach + Ranníocaíocht Mhíosúil Sármhaith + Tiomáineann do thacaíocht muid ar aghaidh! Cruthaíonn gach ranníocaíocht athrú - go raibh maith agat! + Ranníocaíocht den Scoth + Ranníocaíocht Eisceachtúil + Tá tú ag cabhrú linn rudaí iontacha a bhaint amach! Trí rannchuidiú ag an leibhéal seo, tá tú ag cabhrú linn céimeanna suntasacha chun cinn a ghlacadh chun ligean dúinn leanúint ar aghaidh ag fás. + Is iontach an rud é do ranníocaíocht! Tá ról tábhachtach agat i gcur chun cinn ár misean, ag cabhrú linn garspriocanna tábhachtacha a bhaint amach agus tionchar níos mó a bhaint amach. + Is cúis mhór thú go bhfuil muid in ann an méid a dhéanaimid a dhéanamh! A bhuí le do fhlaithiúlacht, is féidir linn tionscadail mhóra a ghlacadh agus garspriocanna nua a bhaint amach le chéile. + Tá tú i ndáiríre ag cabhrú linn aisling mhór! Ligeann do thacaíocht dochreidte dúinn an rud neamhghnách a bhaint amach, rud a fhágann tionchar buan ar gach rud a dhéanaimid. + Ranníocaíocht Mhíosúil Luachmhar + Tá do thacaíocht fíor-spreagtha! A bhuí le do chion suntasach, is féidir linn teorainneacha a bhrú, nuálaíocht a dhéanamh agus dul chun cinn suntasach a dhéanamh i dtreo ár spriocanna. + Mór-Ranníocaíocht Mhíosúil + Ranníocaíocht Mhíosúil Eisceachtúil + Cuidíonn do ranníocaíocht linn fás agus céimeanna fiúntacha a ghlacadh chun tosaigh - go raibh maith agat! + Tá do ranníocaíocht ag taitneamh! Tá tú ríthábhachtach chun ár misean a chur chun cinn agus tionchar a bhaint amach. + Is cúis mhór thú gur féidir linn an méid a dhéanaimid a dhéanamh! Cuidíonn do fhlaithiúlacht linn garspriocanna nua a bhaint amach. + Tá tú ag cabhrú linn aisling mhór! Cuireann do thacaíocht ar ár gcumas rudaí neamhghnácha a bhaint amach. + Tá do thacaíocht spreagúil! Cuidíonn do ranníocaíocht linn teorainneacha a bhrú agus ár spriocanna a bhaint amach. + Tacaigh le Thunderbird + + Bí inár misean chun an t-eispéireas ríomhphoist saincheaptha is fearr agus is féidir a urramú do phríobháideacht. + Ní anois + Cuireann bhur gcuid oibre le forbairt Thunderbird agus táimid fíor bhuíoch as bhur dtacaíocht. + Míle buíochas ó chroí libh go léir! + Tá Thunderbird saor in aisce agus foinse oscailte. + Ní thaispeánaimid fógraí. + Ní dhíolaimid do shonraí. + Tá muid maoinithe ag úsáideoirí cosúil leatsa amháin. + + diff --git a/feature/funding/googleplay/src/main/res/values-gd/strings.xml b/feature/funding/googleplay/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000..f803c87 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-gd/strings.xml @@ -0,0 +1,41 @@ + + + Cùm taic ri Thunderbird + Chan eil Thunderbird a’ faighinn maoineachadh ach na gheibh e de thabhartasan o dhaoine mar thu fhèin. Cha seall sinn sanasachd idir is cha reic sinn an dàta agad uair sam bith. Ma tha Thunderbird a’ còrdadh riut, nach cùm thu taic rinn? Cha dèan sinn seo as d’ aonais! + Le taic mar seo, is urrainn dhuinn Thunderbird a shìor-leasachadh agus tha sinn fada nad chomain. + Mòran taing uainn uile! + Tabhartas tèarainte + Chan urrainn dhut tabhartasan a bheir thu dhuinn a thoirt air falbh o na cìsean agad mar thabhartas do charthannas. + Air adhart a dhèanamh pàigheadh + Thoir tabhartas buadhmhor eile + Seall barrachd fiosrachaidh + Chan urrainnear pàigheadh an-dràsta fhèin + Cuir am pàigheadh mìosail air gleus + Leis seachad a’ mhearachd + Mearachd neo-aithnichte + Tabhartas mòr + Tabhartas air leth + Chan urrainnear tabhartas a thoirt o bhroinn na h-aplacaid aig an ìre-sa. + Tadhail air %s airson barrachd dhòighean air an cùm thu taic ri Thunderbird. + Feuch ris a-rithist + Bun-tabhartas + Tabhartas glè mhòr + Cumaidh do thaic a’ doll sinn! Bidh buaidh aig fiù an tabhartas as lugha agus tha sinn cho toilichte gu bheil thu air bhòrd. + Gràinne-mhullaich nan tabhartasan + Cùm taic ri Thunderbird + Gach mìos + Chan eil gin ri fhaighinn + Dh’fhàillig a cheannach. + Chan urrainnear tabhartas a thoirt an-dràsta fhèin. + Thoir tabhartas aon turas + Tabhartas luachmhor + Tha Thunderbird an-asgaidh ’s tha a chòd fosgailte. + Abair tabhartas! Tha thu air thoiseach mhòran ann a bhith a’ cur ris an iomairt againn ach an ruig sinn clachan-mìle cudromach is buaidh nas motha. + Chan ann an-dràsta + Is treise sinn do thaic! Le bhith gar cuideachadh aig an ìre seo, ’s urrainn dhuinn ceuman mòra a ghabhail air adhart airson sìor-fhàs a dhèanamh. + Cùm taic ri Thunderbird + Tha + Cha seall sinn sanasachd. + Cha reic sinn an dàta agad. + Tha am maoineachadh air fad againn a’ tighinn o luchd-cleachdaidh mar thu fhèin. + diff --git a/feature/funding/googleplay/src/main/res/values-gl/strings.xml b/feature/funding/googleplay/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-gl/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-gu/strings.xml b/feature/funding/googleplay/src/main/res/values-gu/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-gu/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-hi/strings.xml b/feature/funding/googleplay/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-hi/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-hr/strings.xml b/feature/funding/googleplay/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-hr/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-hu/strings.xml b/feature/funding/googleplay/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..59c0630 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-hu/strings.xml @@ -0,0 +1,57 @@ + + + Támogassa a Thunderbirdöt + Folytatás a fizetéshez + Támogassa a Thunderbirdöt + További adományozás + Havi + Nem érhető el + Kiemelkedő havi hozzájárulás + Újra + Keresse fel a %s weboldalt a Thunderbird további támogatási módjaiért. + A támogatása visz minket előre! Még a legkisebb hozzájárulások is számítanak, és nagyra értékeljük, hogy velünk van. + Értékes havi hozzájárulás + Most nem + Csatlakozzon a küldetésünkhöz, hogy a lehető legjobb, adatvédelmet tiszteletben tartó, testreszabható levelezési élményt biztosítsuk. + Részletek megjelenítése + Hiba eltüntetése + Ismeretlen hiba + A fizetés sikertelen. + Jelentős hozzájárulás + Létfontosságú havi hozzájárulás + Jelentős havi hozzájárulás + A Thunderbirdöt teljes egészében az Önhöz hasonló felhasználók hozzájárulása finanszírozza. Sosem jelenítünk meg reklámokat, és nem adjuk el az adatait. Ha élvezi a Thunderbird használatát, segítsen a támogatásában. Ön nélkül ezt nem tudnánk megtenni! + A Thunderbird szabad és nyílt forráskódú. + Hozzájárulása segít a Thunderbird fejlesztésének folytatásában, és nagyon hálásak vagyunk a támogatásért. + Hálás köszönet mindannyiunktól! + Biztonságos hozzájárulás + Egyszeri hozzájárulás + A hozzájárulások nem vonhatók le az adóból jótékonysági adományként. + Az alkalmazáson belüli hozzájárulás jelenleg nem érhető el. + A fizetés jelenleg nem érhető el + Havi összeg módosítása + A hozzájárulás jelenleg nem érhető el. + Létfontosságú hozzájárulás + Értékes hozzájárulás + Kiemelkedő hozzájárulás + Nagy értékű hozzájárulás + Kivételes hozzájárulás + Segít a nagy dolgok elérésében! Azzal, hogy ezen a szinten adományoz, segít minket abban, hogy jelentős lépéseket tegyünk a növekedésünk folytatásában. + A hozzájárulása csak úgy ragyog! Jelentős szerepet játszik a küldetésünkben, segít minket a fontos mérföldkövek és a nagyobb hatás elérésében. + Nagyban hozzájárul ahhoz, hogy azt tehetjük, amit teszünk! Nagylelkűségének köszönhetően nagyobb projekteket vállalhatunk, és együtt új mérföldköveket érhetünk el. + A támogatása igazán inspiráló! Jelentős hozzájárulásának köszönhetően feszegethetjük a határokat, innoválhatunk, és fontos lépéseket tehetünk a céljaink elérése érdekében. + Valóban segít minket nagyot álmodni! Hihetetlen támogatásával rendkívüli eredményeket érhetünk el, ez tartós hatással bír minden tevékenységünkre. + Kivételes havi hozzájárulás + A támogatása visz minket előre! Minden hozzájárulás változást hoz – köszönjük! + Hozzájárulása segít minket abban, hogy növekedjünk, és jelentős lépéseket tegyünk előre – köszönjük! + A hozzájárulása csak úgy ragyog! Kulcsszerepet játszik a küldetésünkben és hatásunk elérésében. + Nagyban hozzájárul ahhoz, hogy azt tehetjük, amit teszünk! Nagylelkűségének köszönhetően új mérföldköveket érhetünk el. + A támogatása inspiráló! Hozzájárulásának köszönhetően feszegethetjük a határokat, és elérhetjük a céljainkat. + Segít minket nagyot álmodni! Támogatásával rendkívüli eredményeket érhetünk el. + Támogassa a Thunderbirdöt + Nem jelenítünk meg hirdetéseket. + Nem adjuk el az adatait. + Kizárólak az Önhöz hasonló felhasználókból finanszírozzuk magunkat. + Igen + Nagy értékű havi hozzájárulás + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-hy/strings.xml b/feature/funding/googleplay/src/main/res/values-hy/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-hy/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-in/strings.xml b/feature/funding/googleplay/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..c0378a4 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-in/strings.xml @@ -0,0 +1,48 @@ + + + Dukung Thunderbird + Berikan sekali saja + Kontribusi aman + Dukung Thunderbird + Bulanan + Tidak tersedia + Terima kasih atas donasi Anda yang murah hati! Dukungan Anda secara langsung memperkuat Thunderbird, dan kami bersyukur Anda mendampingi kami dalam perjalanan ini. + Kontribusi tidak dapat dikurangkan dari pajak sebagai sumbangan amal. + Kami tidak pernah menayangkan iklan atau menjual data Anda. Kami sepenuhnya didanai oleh kontribusi finansial dari para pengguna kami. Jika Anda menyukai Thunderbird, mohon bantu dukung. Kami tidak dapat melakukan ini tanpa Anda! + Ulangi + Kontribusi dalam aplikasi saat ini tidak tersedia. + Kunjungi %s untuk mengetahui lebih banyak cara mendukung Thunderbird. + Lanjutkan ke pembayaran + Pembayaran saat ini tidak tersedia + Ubah pembayaran bulanan + Kesalahan tidak diketahui + Kontribusi saat ini tidak tersedia. + Pembelian gagal. + Kontribusi Penting + Kontribusi yang Berharga + Dukungan Anda membuat kami terus maju! Bahkan kontribusi terkecil pun dapat menciptakan riak perubahan, dan kami sangat bersyukur Anda ikut serta. + Kontribusi Signifikan + Kontribusi Besar + Kontribusi Luar Biasa + Anda membantu kami mewujudkan hal-hal hebat! Dengan berkontribusi pada tingkat ini, Anda membantu kami mengambil langkah maju yang berarti sehingga kami dapat terus berkembang. + Kontribusi Anda sungguh cemerlang! Anda memainkan peran utama dalam memajukan misi kami, membantu kami mencapai pencapaian penting dan mencapai dampak yang lebih besar. + Anda benar-benar membantu kami bermimpi besar! Dukungan luar biasa Anda memberdayakan kami untuk mencapai hal luar biasa, meninggalkan dampak abadi pada semua yang kami lakukan. + Berikan kontribusi lain yang berdampak + Tampilkan lebih detail + Singkirkan kesalahan + Dukungan Anda sungguh menginspirasi! Berkat kontribusi besar Anda, kami mampu mendobrak batasan, berinovasi, dan membuat langkah signifikan menuju tujuan kami. + Kontribusi Luar Biasa + Anda adalah alasan utama kami dapat melakukan apa yang kami lakukan! Berkat kemurahan hati Anda, kita dapat mengerjakan proyek yang lebih besar dan mencapai pencapaian baru bersama-sama. + Kontribusi Bulanan Penting + Dukungan Andalah yang membuat kami untuk tetap terus maju. Tiap-tiap kontribusi akan menuai hasilnya—terima kasih, ya! + Kontribusi Andalah yang membantu kami untuk tetap tumbuh dan meniti tiap-tiap langkah—terima kasih, ya! + Sungguh Anda telah membantu kami untuk tetap mengimpikan hal-hal besar. Dukungan Anda meyakinkan kami untuk mencapai hal-hal yang tidak pernah kami sangka sebelumnya. + Kontribusi Utama Bulanan + Andalah alasan kami hingga dapat melakukan semua ini sekarang. Kedermawanan Anda membantu kami mencapai segala pencapai baru. + Sungguh menginspirasi dukungan Anda! Kontribusi Anda sangat membantu kami mendorong segala pembatas dan mencapai impian kami. + Ya + Tidak dulu, ya + Kontribusi Bulanan Berharga + Mari dukung Thunderbird + Misi kami adalah untuk menyediakan aplikask surel bersumber terbuka yang aman, privat, sekaligus gratis untuk semua pengguna di seantero dunia. Dukungan dana dari Anda sungguh dapat menyokong proyek ini. Maukah Anda berdonasi sekarang? + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-is/strings.xml b/feature/funding/googleplay/src/main/res/values-is/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-is/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-it/strings.xml b/feature/funding/googleplay/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..af80cb1 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-it/strings.xml @@ -0,0 +1,57 @@ + + + Mensile + Thunderbird è finanziato interamente dai contributi di utenti come te. Non mostriamo mai pubblicità né vendiamo i tuoi dati. Se ti piace Thunderbird, ti preghiamo di aiutarci a supportarlo. Non possiamo farcela senza di te! + Donazione sicura + Non disponibile + Continua per procedere al pagamento + Modifica il pagamento mensile + Dai un altro contributo significativo + Pagamento al momento non disponibile + Mostra maggiori dettagli + Visita %s per altre modalità di supporto a Thunderbird. + Acquisto non andato a buon fine + Riprova + Errore sconosciuto + Donazioni al momento non disponibili + Le donazioni in-app non sono al momento disponibili. + Supporta Thunderbird + Supporta Thunderbird + Donazione singola + Il tuo contributo favorisce lo sviluppo di Thunderbird e ti siamo sinceramente grati per il tuo supporto. + Il tuo supporto ci fa andare avanti! Anche i contributi più piccoli creano onde di cambiamento e siamo così grati di averti a bordo. + Ci stai aiutando a realizzare grandi cose! Contribuendo a questo livello, ci stai aiutando a fare passi avanti significativi che ci consentono di continuare a crescere. + Il tuo contributo è davvero straordinario! Stai giocando un ruolo importante nel far progredire la nostra missione, aiutandoci a raggiungere traguardi importanti e a ottenere un impatto maggiore. + Il tuo supporto è davvero stimolante! Grazie al tuo contributo sostanziale, siamo in grado di superare i limiti, innovare e fare passi da gigante verso i nostri obiettivi. + Ci stai davvero aiutando a sognare in grande! Il tuo incredibile supporto ci dà la forza di raggiungere lo straordinario, lasciando un impatto duraturo su tutto ciò che facciamo. + Siete una delle ragioni principali per cui siamo in grado di fare ciò che facciamo! Grazie alla vostra generosità, possiamo intraprendere progetti più grandi e raggiungere nuovi traguardi insieme. + Il tuo supporto ci spinge ad andare avanti! Ogni contributo crea un cambiamento: grazie! + Il tuo contributo brilla! Sei fondamentale per portare avanti la nostra missione e ottenere un impatto. + Il tuo supporto è fonte di ispirazione! Il tuo contributo ci aiuta a superare i limiti e a raggiungere i nostri obiettivi. + Siete una grande motivazione per fare ciò che facciamo! La vostra generosità ci aiuta a raggiungere nuovi traguardi. + Supporta Thunderbird + Ci stai aiutando a sognare in grande! Il tuo supporto ci dà la forza di realizzare cose straordinarie. + Unisciti alla nostra missione per creare la migliore esperienza di posta elettronica possibile, personalizzabile e rispettosa della privacy. + + Non adesso + I contributi non sono fiscalmente deducibili in quanto beneficenza. + Ignora l\'errore + Il tuo contributo ci aiuta a crescere e a compiere passi in avanti significativi: grazie! + Contributo prezioso + Contributo significativo + Contributo importante + Contributo mensile prezioso + Contributo mensile essenziale + Contributo mensile significativo + Contributo mensile notevole + Contributo mensile eccezionale + Contributo eccezionale + Contributo notevole + Contributo mensile importante + Contributo essenziale + Un sincero grazie da tutti noi! + Noi non vendiamo i tuoi dati. + Siamo finanziati esclusivamente da utenti come te. + Noi non mostriamo pubblicità. + Thunderbird è libero e open source. + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-iw/strings.xml b/feature/funding/googleplay/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000..8ab0cb4 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-iw/strings.xml @@ -0,0 +1,57 @@ + + + תמוך בת\'אנדרבירד + אנחנו לעולם לא נציג פרסומות או נמכור את הנתונים שלך. אנחנו ממומנים ע\"י תרומות מהמשתמשים שלנו. אם אתה נהנה מת\'אנדרבירד, אנא עזור לתמוך ביישום. אנחנו לא יכולים לעשות זאת בלעדיך! + תרומה מאובטחת + תן פעם אחת + חודשי + לא זמין + תרומות אינן קבילות להחזר מס. + המשך לתשלום + שנה תשלום חודשי + תמוך בת\'אנדרבירד + בצעו תרומה משמעותית נוספת + תרומה רצינית + התרומה שלך ממש זוהרת! אתה משחק תפקיד גדול בקידום המשימה שלנו, ועוזר לנו להגיע לאבני דרך חשובות ולהצליח לייצר השפעה גדולה יותר. + תרומה בלתי רגילה + תרומה בסיסית + התמיכה שלך עוזרת לנו להמשיך קדימה! גם התרומות הקטנות ביותר יוצרות אדוות של שינוי, ואנחנו אסירי תודה שאתה איתנו. + אתה עוזר לנו לגרום לדברים גדולים לקרות! תרומה בגובה כזה עוזרת לנו לקחת צעדים משמעותיים קדימה ומאפשרת לנו להמשיך לגדול. + בזכותך אנחנו יכולים לעשות את מה שאנחנו עושים! תודות לנדיבותך, אנחנו יכולים לקחת פרוייקטים גדולים יותר ולהגיע לאבני דרך חדשות ביחד. + תרומות מתוך היישום לא זמינות כרגע. + בקר ב: %s לדרכים נוספות בהן תוכל לתמוך בת\'אנדרבירד + נסה שוב + תשלום לא זמין כרגע + הצג פרטים נוספים + שגיאה לא מוכרת + רכישה נכשלה. + תרומות לא זמינות כרגע. + תרומה ערכית + תרומה משמעותית + תרומה ניכרת + תרומתך מעודדת את הפיתוח של Thunderbird ואנו אסירי תודה על תמיכתך. + לדחות את השגיאה + תודה כנה מכולנו! + תרומה חודשית מוערכת + התמיכה שלכם דוחפת אותנו קדימה! כל תרומה יוצרת שינוי - תודה! + תרומה חודשית יוצאת דופן + התרומה שלך עוזרת לנו לצמוח ולעשות צעדים משמעותיים קדימה - תודה לך! + אתה סיבה גדולה שאנחנו יכולים לעשות את מה שאנחנו עושים! הנדיבות שלך עוזרת לנו להגיע לאבני דרך חדשות. + תרומה חודשית מרכזית + אתה באמת עוזר לנו לחלום בגדול! התמיכה המדהימה שלך מעצימה אותנו להשיג את יוצא הדופן, ומשאירה השפעה מתמשכת על כל מה שאנחנו עושים. + תרומה חודשית משמעותית + התרומה שלך זורחת! אתה המפתח לקידום המשימה שלנו ולהשגת השפעה. + התמיכה שלך באמת מעוררת השראה! הודות לתרומתך המשמעותית, אנו מסוגלים לפרוץ גבולות, לחדש ולעשות צעדים משמעותיים לעבר המטרות שלנו. + תרומה חודשית חיונית + תרומה חודשית יוצאת דופן + Thunderbird היא אפלקציה חינמי וקוד פתוח. + אנחנו לא מציגים פרסומות. + אנחנו לא מוכרים את המידע שלך. + אנו ממומנים אך ורק על ידי משתמשים כמוך. + אתה עוזר לנו לחלום בגדול! תמיכתך מעצימה אותנו להשיג דברים יוצאי דופן. + תמוך בThunderbird + הצטרף למשימה שלנו ליצור את חוויית הדוא\"ל הטובה ביותר שמכבדת פרטיות, וניתנת להתאמה אישית. + לא כרגע + התמיכה שלך מעוררת השראה! ועוזרת לנו לפרוץ גבולות ולהשיג את המטרות שלנו. + כן + diff --git a/feature/funding/googleplay/src/main/res/values-ja/strings.xml b/feature/funding/googleplay/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..df266b3 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ja/strings.xml @@ -0,0 +1,57 @@ + + + Thunderbird を支援する + 一度だけ + 毎月 + 寄付する + 税控除の対象となる慈善活動への寄付ではありません。 + 支払いへ進む + Thunderbird の資金はすべてユーザーから寄付されています。私たちは広告を表示したりあなたのデータを販売したりしません。もし Thunderbird に満足していただけたら、支援にご協力ください。あなたの支援が必要です! + 毎月の支払いを変更 + Thunderbird を支援する + 利用できません + 更なる進化に向けてもう一度貢献する + Thunderbird を支援するさまざまな方法は %s で確認できます。 + 再試行 + 現在、アプリ内寄付ができません。 + 詳細を表示 + 不明なエラー + 課金に失敗しました。 + 現在、寄付ができません。 + エラーを無視 + 現在、支払いができません + あなたのご支援が私たちの原動力です!あなたの貢献が起こした変化の波に乗って、私たちはあなたと前に進み続けます。 + 良い変化を起こす支援をありがとうございます!あなたの貢献は成長への確かな一歩を支えています。 + 私たちがミッションに向け前進し、重要なマイルストーンを達成して変化を生み出すために、あなたの目覚ましい貢献は重要な役割を果たします。 + Thunderbird を支援する + プライバシーを尊重し、メール体験をカスタマイズできるようにするという、私たちのミッションに参加しませんか? + はい + あとで + あなたの貢献が Thunderbird の開発をさらに進めます。あなたの支援に心から感謝します。 + あなたの支援は私たちが活動できる大きな理由です!惜しみない援助により、更なるプロジェクトと新たな目標に向けて進めることを心から感謝します。 + 私たちの夢への大きな貢献をありがとうございます!あなたの素晴らしい支援は果てしない夢に向かう力を与え、私たちの行動すべてに大きな影響を与えました。 + あなたの支援に心から感激しています!あなたの重要な貢献により私たちは可能性を広げ、イノベーションを起こし、目標に向けて進めるようになりました。 + ありがとうございます!— あなたの貢献が成長と前進への確かな一歩を支えています。 + ありがとうございます!— あなたの目覚ましい貢献は、ミッションを達成し変化を生み出すための鍵となります。 + ありがとうございます!— あなたの支援は私たちが活動できる大きな理由です。惜しみない貢献により、次の目標に向けて進めます。 + ありがとうございます!— あなたの支援に感激しています!あなたの貢献のおかげで可能性が広がり、目標に向かって進めます。 + ありがとうございます!— すべての貢献が私たちの原動力です。あなたの支援でプロジェクトが前進します! + 私たちの夢への大きな貢献をありがとうございます!あなたの支援は果てしない夢に向かう力となります。 + Outstanding Contribution + Significant Monthly Contribution + Outstanding Monthly Contribution + Exceptional Monthly Contribution + Essential Contribution + Exceptional Contribution + Major Monthly Contribution + Valued Contribution + Major Contribution + Significant Contribution + Essential Monthly Contribution + Valued Monthly Contribution + あなたに心から感謝します! + 広告を表示しません。 + 私たちの資金はあなたのようなユーザーからの寄付のみです。 + Thunderbird は無料でオープンソースです。 + あなたのデータを販売しません。 + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-ka/strings.xml b/feature/funding/googleplay/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ka/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-kab/strings.xml b/feature/funding/googleplay/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-kab/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-kk/strings.xml b/feature/funding/googleplay/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000..6a7f524 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-kk/strings.xml @@ -0,0 +1,6 @@ + + + Thunderbird-ты қолдау + Ай сайын + Thunderbird-ты қолдау + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-ko/strings.xml b/feature/funding/googleplay/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ko/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-lt/strings.xml b/feature/funding/googleplay/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000..3735c94 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-lt/strings.xml @@ -0,0 +1,5 @@ + + + Paremti „Thunderbird“ + Paremti „Thunderbird“ + diff --git a/feature/funding/googleplay/src/main/res/values-lv/strings.xml b/feature/funding/googleplay/src/main/res/values-lv/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-lv/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-ml/strings.xml b/feature/funding/googleplay/src/main/res/values-ml/strings.xml new file mode 100644 index 0000000..a6b3dae --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ml/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-nb-rNO/strings.xml b/feature/funding/googleplay/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000..756e162 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,24 @@ + + + Støtt Thunderbird + Gi én gang + Ikke nå + Prøv igjen + Fortsett til betaling + Månedlig + Ukjent feil + Thunderbird er gratis og har åpen kildekode. + Vi viser ikke reklamer. + Støtt Thunderbird + Vis flere detaljer + Avvis feilmeldingen + Essensielt bidrag + Vi selger ikke dine data. + Endre månedlig betaling + Betydelig bidrag + Stort bidrag + Eksepsjonelt bidrag + Verdsatt bidrag + Støtt Thunderbird + Ja + diff --git a/feature/funding/googleplay/src/main/res/values-nl/strings.xml b/feature/funding/googleplay/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..a004629 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-nl/strings.xml @@ -0,0 +1,57 @@ + + + Steun Thunderbird + Veilige bijdrage + Doneer eenmalig + Maandelijks + Bijdragen zijn niet aftrekbaar als charitatieve donaties. + Thunderbird wordt volledig gefinancierd door bijdragen van gebruikers als u. We tonen nooit advertenties en verkopen nooit uw gegevens. Als u geniet van Thunderbird, ondersteun het dan. We kunnen dit niet zonder u! + Doorgaan naar afrekenen + Steun Thunderbird + Niets beschikbaar + Maandelijkse betaling aanpassen + Doe nog een bijdrage met impact + Bezoek %s voor meer manieren om Thunderbird te steunen. + Meer details tonen + Foutmelding sluiten + Onbekende fout + Bijdragen zijn momenteel beschikbaar. + Gewaardeerde bijdrage + Significante bijdrage + Grote bijdrage + Buitengewone maandelijkse bijdrage + Bijdragen in de app zijn momenteel niet beschikbaar. + Opnieuw proberen + Betaling momenteel beschikbaar + Essentiële bijdrage + Buitengewone bijdrage + Exceptionele bijdrage + Uw steun helpt ons vooruit te komen! Zelfs de kleinste bijdragen kunnen voor het verschil zorgen, en we zijn dankbaar u aan boord te hebben. + Essentiële maandelijkse bijdrage + Gewaardeerde maandelijkse bijdrage + Grote maandelijkse bijdrage + Exceptionele maandelijkse bijdrage + Significante maandelijkse bijdrage + Uw bijdrage is echt inspirerend! Met uw substantiële bijdrage, kunnen we grenzen verleggen, innoveren en significante stappen maken naar onze doelen. + Uw steun helpt ons vooruit! Elke bijdrage leidt tot verandering – dank u! + Uw bijdrage geeft glans! U bent de sleutel om onze missie verder te brengen en impact te bereiken. + U bent een grote reden waardoor we kunnen we doen wat we doen! Dankzij uw generositeit kunnen we nieuwe mijlpalen bereiken. + U helpt ons echt groot te dromen! Uw steun versterkt ons het buitengewone te bereiken. + U helpt ons echt groot te dromen! Uw ongelooflijke steun versterkt ons het buitengewone te bereiken, met een blijvende impact op alles wat we doen. + Uw bijdrage helpt ons te groeien en belangrijke stappen vooruit te nemen – dank u! + Uw bijdrage is echt inspirerend! Met uw bijdrage kunnen we grenzen verleggen en onze doelen bereiken. + U helpt ons mooie dingen te verwezenlijken! Door op dit niveau bij te dragen, helpt u ons belangrijke stappen voorwaarts te maken wat ons in staat stelt verder te groeien. + Uw bijdrage geeft glans! U speelt een grote rol in de realisatie van onze missie, door te helpen belangrijke mijlpalen te bereiken en grotere impact te krijgen. + U bent een grote reden waardoor we kunnen we doen wat we doen! Dankzij uw generositeit kunnen we grotere projecten starten en samen nieuwe mijlpalen bereiken. + Bijdrage mislukt. + Doe mee met onze missie om de beste privacy-respecterende, aanpasbare e-mailervaring mogelijk te maken. + Steun Thunderbird + Ja + Nu niet + Uw steun brengt de ontwikkeling van Thunderbird verder en we zijn heel dankbaar u voor uw steun. + Onze oprechte dank aan u! + Thunderbird is gratis en open source. + We verkopen uw gegevens niet. + We worden alleen gefinancierd door gebruikers zoals u. + We tonen geen advertenties. + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-nn/strings.xml b/feature/funding/googleplay/src/main/res/values-nn/strings.xml new file mode 100644 index 0000000..55bf659 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-nn/strings.xml @@ -0,0 +1,57 @@ + + + Støtt Thunderbird + Thunderbird er fullstendig finansiert av bidrag frå brukarar som deg. Me viser aldri reklame eller sel dataen din. Om du likar Thunderbird, ver vennleg og støtt det. Me kan ikkje gjere dette utan deg! + Gå vidare til betaling + Bidrag er ikkje skattefrie som velgjerdsdonasjonar. + Sikkert bidrag + Gje ein gong + Månadleg + Ingen tilgjengelege + Endre månadleg betaling + Støtt Thunderbird + Gje eit til verknadsfullt bidrag + Besøk %s for fleire måtar å støtte Thunderbird på. + Lukk feilmelding + Ukjend feil + Kjøp feila. + Bidrag er for tida ikkje tilgjengeleg. + Din stønad hjelper oss framover! Sjølv det minste bidrag kan gjere ein stor skilnad, og me er takknemlege for å ha deg med ombord. + Du er ein stor grunn til at me kan gjere det me gjer! Takka vere din generøsitet, kan me ta på oss større prosjekter og nå nye milepålar saman. + Eineståande månadleg bidrag + Verdsett månadleg bidrag + Verdsett bidrag + Stort bidrag + Eineståande bidrag + Eksepsjonelt bidrag + Vesentleg bidrag + Du hjelper oss å få store ting til å skje! Med å bidra på dette nivået, hjelper du oss å ta meiningsfulle skritt framover, og tillet oss til å fortsetje å vekse. + Du hjelper os verkeleg å drøyme stort! Din utrulege stønad gjer oss moglegheit til å oppnå det ekstraordinære, og gjer ein varig innverknad på det me gjer. + Essensielt månadleg bidrag + Vesentleg månadleg bidrag + Eksepsjonelt månadleg bidrag + Din stønad er inspirerande! Ditt bidrag hjelper oss å flytte grenser og nå måla våre. + Du hjelper oss å drøyme stort! Din stønad gjer oss moglegheit til å oppnå eksepsjonelle ting. + Prøv igjen + Bidrag innanfor appen er ikkje tilgjengelig for tida. + Betaling er ikkje tilgjengeleg + Vis fleire detaljar + Essensielt bidrag + Stort månadleg bidrag + Ditt bidrag skin verkeleg! Du speler ei stor rolle i å avansere oppdraget vårt, hjelper oss å nå viktige milepålar og oppnå ein større innverknad. + Din stønad driver oss framover! Kvart bidrag er nok til å skape endringar—tusen takk! + Ditt bidrag hjelper oss å vekse, og gjer at me kan ta meiningsfulle skritt framover—tusen takk! + Du er ein stor grunn til at me kan gjere som me gjer! Din generøsitet hjelper oss å nye milepålar. + Din stønad er verkeleg inspirerande! Takka vere dit substansielle bidrag, er me i stand til å flytte grenser, innovere, og gjere vesentlege framskritt mot måla våre. + Ditt bidrag skin! Du er ein nøkkel til å avansere oppdraget vårt, og gjere ein innverknad. + Bli med på oppdraget vårt til å skape den best moglege e-postopplevinga som respekterer personvernet, og som kan tilpassast. + Ja + Ikkje no + Støtt Thunderbird + Ditt bidrag frammer utviklinga av Thunderbird og me er verkeleg tekknemlege for din stønad. + Ein oppriktig takk frå oss alle! + Me sel ikkje dataen din. + Thunderbird er fri og med open kjeldekode. + Me viser ikkje reklame. + Me er fullstendig finansiert av brukarar som deg. + diff --git a/feature/funding/googleplay/src/main/res/values-pl/strings.xml b/feature/funding/googleplay/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..09ca57f --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-pl/strings.xml @@ -0,0 +1,57 @@ + + + Bezpieczna wpłata + Wpłaty nie podlegają odliczeniu od podatku jako darowizny na cele charytatywne. + Przekaż raz + Miesięcznie + Przejdź do płatności + Wesprzyj Thunderbirda + Thunderbird jest finansowany w całości ze składek użytkowników takich jak Ty. Nigdy nie wyświetlamy reklam ani nie sprzedajemy Twoich danych. Jeśli podoba Ci się Thunderbird, pomóż nam go wesprzeć. Nie możemy tego zrobić bez Ciebie! + Zmodyfikuj płatność miesięczną + Wesprzyj Thunderbirda + Brak dostępnych + Dokonaj kolejnego znaczącego wkładu + Możliwość dokonywania wpłat w aplikacji jest obecnie niedostępna. + Odwiedź %s, aby poznać więcej sposobów na wsparcie Thunderbirda. + Spróbuj ponownie + Płatność obecnie niedostępna + Odrzuć błąd + Twoje wsparcie nas napędza! Każda wpłata tworzy zmianę — dziękujemy! + Twoja wpłata pomaga nam się rozwijać i wykonywać znaczące kroki naprzód — dziękujemy! + Twoja wpłata jest widoczny! Jesteś kluczem do realizacji naszej misji i osiągnięcia wpływu. + Jesteś ważnym powodem, dla którego możemy robić to, co robimy! Twoja hojność pomaga nam osiągać nowe kamienie milowe. + Pomagasz nam marzyć na wielką skalę! Twoje wsparcie daje nam siłę do osiągania niezwykłych rzeczy. + Twoje wsparcie jest inspirujące! Twoja wpłata pomaga nam przekraczać granice i osiągać nasze cele. + Ceniona wpłata + Niezbędna wpłata + Znacząca wpłata miesięczna + Wybitna wpłata miesięczna + Twoja wpłata naprawdę błyszczy! Odgrywasz ważną rolę w realizacji naszej misji, pomagając nam osiągnąć ważne kamienie milowe i większy wpływ. + Główna wpłata + Wyjątkowa wpłata + Niezbędna wpłata miesięczna + Ceniona wpłata miesięczna + Wyjątkowa wpłata miesięczna + Pokaż więcej szczegółów + Nieznany błąd + Wpłaty są obecnie niedostępne. + Znacząca wpłata + Zakup nie powiódł się. + Wybitna wpłata + Twoje wsparcie pozwala nam iść naprzód! Nawet najmniejsze wpłaty wywołują fale zmian, a my jesteśmy bardzo wdzięczni, że jesteś na pokładzie. + Pomagasz nam w tworzeniu wspaniałych rzeczy! Dokonując wpłaty na tym poziomie, pomagasz nam wykonywać znaczące kroki naprzód, co pozwala nam nadal się rozwijać. + Jesteś ważnym powodem, dla którego możemy robić to, co robimy! Dzięki Twojej hojności możemy podejmować się większych projektów i wspólnie osiągać nowe kamienie milowe. + Twoje wsparcie jest naprawdę inspirujące! Dzięki Twojej znaczącej wpłacie jesteśmy w stanie przesuwać granice, wprowadzać innowacje i robić znaczące postępy w kierunku naszych celów. + Naprawdę pomagasz nam marzyć na wielką skalę! Twoje niesamowite wsparcie daje nam siłę do osiągania czegoś niezwykłego, pozostawiając trwały wpływ na wszystko, co robimy. + Główna wpłata miesięczna + Tak + Dołącz do naszej misji tworzenia możliwie najlepszego, szanującego prywatność, konfigurowalnego sposobu korzystania z poczty elektronicznej. + Wesprzyj Thunderbirda + Nie teraz + Twoja wpłata przyczynia się do dalszego rozwoju Thunderbirda, a my jesteśmy szczerze wdzięczni za Twoje wsparcie. + Serdeczne podziękowania od nas wszystkich! + Thunderbird jest darmowym i otwartym oprogramowaniem. + Nie wyświetlamy reklam. + Nie sprzedajemy Twoich danych. + Jesteśmy finansowani wyłącznie przez użytkowników takich jak Ty. + diff --git a/feature/funding/googleplay/src/main/res/values-pt-rBR/strings.xml b/feature/funding/googleplay/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..7fea3a9 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,57 @@ + + + Apoie o Thunderbird + Contribuições não são deduzíveis de impostos como doações de caridade. + Continuar para o pagamento + Apoie o Thunderbird + Contribuição segura + Mensalmente + Nenhum disponível + Modificar pagamento mensal + Faça outra contribuição impactante + Doar uma vez + O Thunderbird é financiado inteiramente por contribuições de usuários como você. Nunca mostramos anúncios nem vendemos seus dados. Se estiver gostando do Thunderbird, ajude a apoiá-lo. Não podemos fazer isso sem você! + Contribuições dentro do aplicativo não estão disponíveis no momento. + Tentar novamente + Mostrar mais detalhes + Erro desconhecido + Compra falhou. + Contribuição essencial + Contribuição de valor + Visite %s para ver mais formas de apoiar o Thunderbird. + Descartar erro + Contribuição significativa + Pagamento não disponível no momento + Contribuições não estão disponíveis no momento. + Grande contribuição + Contribuição excepcional + Contribuição incrível + Seu apoio nos mantém seguindo em frente! Até pequenas contribuições produzem efeitos de mudança, somos muito gratos por ter você a bordo. + Você está ajudando a fazer grandes coisas acontecer! Ao contribuir neste nível, nos ajuda a dar passos significativos, nos permitindo continuar a crescer. + Apoie o Thunderbird + Sim + Agora não + Sua contribuição promove o desenvolvimento do Thunderbird, agradecemos imensamente seu apoio. + Você é um grande motivo pelo qual nos podemos fazer o que fazemos! Graças a sua generosidade, podemos trabalhar em projetos maiores e chegar em conquistas novas juntos. + Seu apoio é realmente inspirador! Graças à sua contribuição substancial, podemos avançar novas barreiras, inovar, e promover grandes passos em direção aos nossos objetivos. + Sua contribuição realmente brilha! Você desempenha uma grande parte na nossa missão, nos ajudando a chegar a passos importantes e causar um grande impacto. + Um agradecimento sincero de todos nós! + Contribuição Mensal Essencial + Contribuição Mensal Importante + Contribuição Mensal Significante + Você realmente está nos ajudando ter grande sonhos! O seu apoio incrível nos capacita para atingir o extraordinário, tendo um impacto duradouro em todo o que fazemos. + Sua contribuição nos ajuda crescer and dar paços significativos para frente—obrigado! + Contribuição Mensal Valorizado + Contribuição Mensal Excepcional + Sua contribuição brilha! Você é essential para o avanço da nossa missão e ter um impacto. + O seu apoio nos leva adiante! Cada contribuição gera mudança—obrigado! + O Thunderbird é gratuito e de código aberto. + Não exibimos anúncios. + Não vendemos seus dados. + Somos financiados apenas por usuários como você. + Contribuição Mensal Extraordinária + Você é grande razão pela qual podemos fazer o que fazemos! Sua generosidade nos ajuda a atingir novos marcos. + Seu apoio é inspirador! Sua contribuição nos ajuda a ultrapassar limites e atingir nossos objetivos. + Você está nos ajudando a sonhar alto! Seu apoio nos capacita a alcançar coisas extraordinárias. + Junte-se à nossa missão de criar a melhor experiência de e-mail personalizável, respeitando ao máximo a sua privacidade. + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-pt-rPT/strings.xml b/feature/funding/googleplay/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..b920bd1 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,57 @@ + + + Apoie o Thunderbird + Apoie o Thunderbird + Contribuição segura + Mensalmente + Nenhum disponível + Tentar novamente + Descartar erro + Erro desconhecido + Contribuições não estão disponíveis no momento. + Contribuição essencial + Contribuição de valor + Contribuições não são deduzíveis de impostos como doações de caridade. + Mostrar mais pormenores + Compra falhou. + Contribuição significativa + O seu apoio mantém-nos a seguir em frente! Até pequenas contribuições produzem efeitos de mudança, somos muito gratos por ter-lo a bordo. + É um grande motivo pelo qual podemo-nos fazer o que fazemos! Graças a sua generosidade, podemos trabalhar em projetos maiores e chegar em conquistas novas juntos. + Contribuição Mensal Significante + A sua contribuição ajuda-nos crescer e dar paços significativos para frente—obrigado! + Sim + Agora não + Grande contribuição + Contribuição incrível + Contribuição excepcional + Ajuda a fazer grandes coisas acontecer! Ao contribuir neste nível, ajuda-nos a dar passos significativos, permitindo-nos a continuar a crescer. + Realmente está a ajudar-nos ter grande sonhos! O seu apoio incrível capacita-nos para atingir o extraordinário, tendo um impacto duradouro em todo o que fazemos. + Contribuição Mensal Essencial + Contribuição Mensal Valorizado + Contribuição Mensal Importante + Contribuição Mensal Extraordinária + Contribuição Mensal Excepcional + É grande razão pela qual podemos fazer o que fazemos! A sua generosidade ajuda-nos a atingir novos marcos. + Ajuda-nos a sonhar alto! O seu apoio capacita-nos a alcançar coisas extraordinárias. + Visite %s para ver mais formas de apoiar o Thunderbird. + Apoie o Thunderbird + O Thunderbird é financiado inteiramente por contribuições de utilizadores como você. Nunca mostramos anúncios nem vendemos os seus dados. Se gostar do Thunderbird, ajude a apoiá-lo. Não podemos fazer isto sem você! + Um agradecimento sincero de todos nós! + A sua contribuição promove o desenvolvimento do Thunderbird, agradecemos imensamente o seu apoio. + Doar uma vez + Contribuições dentro do app não estão disponíveis no momento. + Pagamento não disponível no momento + Continuar para o pagamento + Modificar pagamento mensal + Faça outra contribuição impactante + A sua contribuição realmente brilha! Desempenha uma grande parte na nossa missão, a ajudar-nos a chegar a passos importantes e causar um grande impacto. + O seu apoio é realmente inspirador! Graças à sua contribuição substancial, podemos avançar novas barreiras, inovar e promover grandes passos em direção aos nossos objetivos. + O seu apoio é inspirador! A sua contribuição ajuda-nos a ultrapassar limites e atingir os nossos objetivos. + O seu apoio leva-nos adiante! Cada contribuição gera mudança—obrigado! + A sua contribuição brilha! É essential para o avanço da nossa missão e ter um impacto. + O Thunderbird é gratuito e de código aberto. + Não exibimos anúncios. + Não vendemos os seus dados. + Somos financiados apenas por utilizadores como si. + Junte-se à nossa missão de criar a melhor experiência de e-mail personalizável, respeitando ao máximo a sua privacidade. + diff --git a/feature/funding/googleplay/src/main/res/values-pt/strings.xml b/feature/funding/googleplay/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..833bce8 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-pt/strings.xml @@ -0,0 +1,57 @@ + + + Apoie o Thunderbird + Contribuição segura + Apoie o Thunderbird + Mensalmente + Não vendemos os seus dados. + Junte-se à nossa missão de criar a melhor experiência de e-mail personalizável, respeitando ao máximo a sua privacidade. + A sua contribuição promove o desenvolvimento do Thunderbird, agradecemos imensamente o seu apoio. + Nenhum disponível + Contribuições não são deduzíveis de impostos como doações de caridade. + Contribuições dentro do app não estão disponíveis no momento. + Pagamento não disponível no momento + Modificar pagamento mensal + Faça outra contribuição impactante + Contribuição de valor + Grande contribuição + A sua contribuição realmente brilha! Desempenha uma grande parte na nossa missão, a ajudar-nos a chegar a passos importantes e causar um grande impacto. + O seu apoio é realmente inspirador! Graças à sua contribuição substancial, podemos avançar novas barreiras, inovar e promover grandes passos em direção aos nossos objetivos. + Contribuição Mensal Importante + Sim + Agora não + Contribuição essencial + Contribuição significativa + Contribuição excepcional + Contribuição Mensal Extraordinária + Contribuição Mensal Excepcional + A sua contribuição ajuda-nos crescer e dar paços significativos para frente—obrigado! + A sua contribuição brilha! É essential para o avanço da nossa missão e ter um impacto. + O seu apoio é inspirador! A sua contribuição ajuda-nos a ultrapassar limites e atingir os nossos objetivos. + Ajuda-nos a sonhar alto! O seu apoio capacita-nos a alcançar coisas extraordinárias. + O Thunderbird é financiado inteiramente por contribuições de utilizadores como você. Nunca mostramos anúncios nem vendemos os seus dados. Se gostar do Thunderbird, ajude a apoiá-lo. Não podemos fazer isto sem você! + Um agradecimento sincero de todos nós! + Doar uma vez + Visite %s para ver mais formas de apoiar o Thunderbird. + Tentar novamente + Continuar para o pagamento + Mostrar mais pormenores + Descartar erro + Erro desconhecido + Compra falhou. + Contribuições não estão disponíveis no momento. + Contribuição incrível + O seu apoio mantém-nos a seguir em frente! Até pequenas contribuições produzem efeitos de mudança, somos muito gratos por ter-lo a bordo. + Ajuda a fazer grandes coisas acontecer! Ao contribuir neste nível, ajuda-nos a dar passos significativos, permitindo-nos a continuar a crescer. + Realmente está a ajudar-nos ter grande sonhos! O seu apoio incrível capacita-nos para atingir o extraordinário, tendo um impacto duradouro em todo o que fazemos. + Contribuição Mensal Essencial + É um grande motivo pelo qual podemo-nos fazer o que fazemos! Graças a sua generosidade, podemos trabalhar em projetos maiores e chegar em conquistas novas juntos. + Contribuição Mensal Valorizado + Contribuição Mensal Significante + O seu apoio leva-nos adiante! Cada contribuição gera mudança—obrigado! + É grande razão pela qual podemos fazer o que fazemos! A sua generosidade ajuda-nos a atingir novos marcos. + Apoie o Thunderbird + O Thunderbird é gratuito e de código aberto. + Não exibimos anúncios. + Somos financiados apenas por utilizadores como si. + diff --git a/feature/funding/googleplay/src/main/res/values-ro/strings.xml b/feature/funding/googleplay/src/main/res/values-ro/strings.xml new file mode 100644 index 0000000..90544b8 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ro/strings.xml @@ -0,0 +1,57 @@ + + + Contribuție securizată + Sprijină Thunderbird + Plată unică + Niciunul disponibil + Treceți la efectuarea plății + Modificați plata lunară + Faceți o altă contribuție de impact + Thunderbird este finanțat în întregime prin contribuții de la utilizatori ca tine. Nu afișăm niciodată reclame și nu vă vindem datele. Dacă vă place Thunderbird, vă rugăm să contribuiți la susținerea sa. Nu putem face asta fără tine! + Sprijină Thunderbird + Contribuțiile nu sunt deductibile fiscal ca donații caritabile. + Lunară + Contribuțiile în aplicație nu sunt disponibile în prezent. + Vizitați %s pentru a descoperi mai multe modalități de a sprijini Thunderbird. + Reîncearcă + Plata nu este disponibilă în prezent + Afișează mai multe detalii + Respinge eroarea + Eroare necunoscută + Achiziția a eșuat. + Contribuțiile nu sunt disponibile în prezent. + Contribuție semnificativă + Contribuție remarcabilă + Contribuție excepțională + Chiar ne ajutați să visăm în stil mare! Sprijinul dumneavoastră incredibil ne permite să realizăm lucruri extraordinare, lăsând un impact de durată asupra a tot ceea ce facem. + Contribuție lunară valoroasă + Contribuție lunară importantă + Contribuție lunară extraordinară + Sunteți un motiv important pentru care putem face ceea ce facem! Generozitatea dumneavoastră ne ajută să atingem noi obiective. + Contribuție importantă + Sprijinul tău ne face să mergem mai departe! Chiar și cele mai mici contribuții creează valuri de schimbare și suntem foarte recunoscători să vă avem alături de noi. + Ne ajuți să realizăm lucruri minunate! Contribuind la acest nivel, ne ajutați să facem pași importanți înainte, permițându-ne să continuăm să creștem. + Contribuția ta chiar strălucește! Joci un rol major în avansarea misiunii noastre, ajutându-ne să atingem etape importante și să obținem un impact mai mare. + Sunteți un motiv important pentru care putem face ceea ce facem! Mulțumită generozității dumneavoastră, putem să ne asumăm proiecte mai mari și să atingem împreună noi obiective importante. + Sprijinul dvs. este o adevărată sursă de inspirație! Datorită contribuției dvs. substanțiale, suntem capabili să depășim limitele, să inovăm și să facem pași importanți către obiectivele noastre. + Contribuție lunară esențială + Contribuție esențială + Contribuție valoroasă + Contribuție lunară semnificativă + Contribuție lunară excepțională + Sprijinul dumneavoastră ne face să mergem mai departe! Fiecare contribuție creează schimbare - vă mulțumim! + Contribuția dumneavoastră ne ajută să creștem și să facem pași semnificativi înainte - vă mulțumim! + Contribuția dvs. strălucește! Sunteți cheia promovării misiunii noastre și a producerii impactului. + Sprijinul dumneavoastră este o sursă de inspirație! Contribuția dumneavoastră ne ajută să ne depășim limitele și să ne atingem obiectivele. + Ne ajutați să visăm în stil mare! Sprijinul dumneavoastră ne permite să realizăm lucruri extraordinare. + Sprijină Thunderbird + Alăturați-vă misiunii noastre de a crea cea mai bună experiență de poștă electronică personalizabilă și care respectă confidențialitatea. + Nu acum + Da + Contribuția dumneavoastră promovează dezvoltarea Thunderbird și vă suntem cu adevărat recunoscători pentru sprijinul dumneavoastră. + Un sincer mulțumesc din partea noastră! + Thunderbird este liber și cu cod sursă deschis. + Nu afișăm reclame. + Noi nu vă vindem datele. + Suntem finanțați exclusiv de utilizatori ca tine. + diff --git a/feature/funding/googleplay/src/main/res/values-ru/strings.xml b/feature/funding/googleplay/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..0d13e1e --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ru/strings.xml @@ -0,0 +1,57 @@ + + + Поддержка Thunderbird + Разово + Поддержка Thunderbird + Thunderbird полностью финансируется за счёт пожертвований таких же пользователей, как вы. Мы никогда не показываем рекламу и не продаём ваши данные. Если вам нравится Thunderbird, поддержите его. Мы не можем обойтись без вас! + Ежемесячно + Перейти к платежу + Безопасное пожертвование + Недоступно + Пожертвования не подлежат налогообложению как благотворительность. + Изменить ежемесячный платёж + Сделать ещё один важный вклад + Посетите %s, чтобы узнать о других способах поддержки Thunderbird. + Повторить + Платёж временно недоступен + Игнорировать ошибку + Платёж не выполнен. + Пожертвования временно недоступны. + Значительный вклад + Важный вклад + Выдающийся вклад + Существенный ежемесячный вклад + Исключительный ежемесячный вклад + Ваш вклад помогает нам расти и делать значимые шаги вперёд, спасибо вам! + Вы помогаете нам мечтать о большем! Ваша поддержка даёт нам возможность достигнуть невероятных результатов. + Пожертвования из приложения сейчас недоступны. + Подробнее + Неизвестная ошибка + Ценный вклад + Ваша поддержка помогает нам двигаться вперёд! Даже самые незначительные вложения приводят к переменам, и мы очень благодарны вам за то, что вы с нами. + Существенный вклад + Исключительный вклад + Вы помогаете нам творить великие дела! Внося такой вклад, вы помогаете нам делать значимые шаги вперёд и продолжать развиваться. + Ваш вклад действительно блистателен! Вы играете важную роль в реализации нашей миссии, помогая нам достичь важных целей и добиться большего результата. + Вы — главная причина, по которой мы можем делать то, что делаем! Благодаря вашей щедрости мы можем браться за более масштабные проекты и вместе достигать новых целей. + Ваша поддержка действительно вдохновляет! Благодаря вашему существенному вкладу мы можем расширять границы, внедрять инновации и добиваться значительных успехов в достижении наших целей. + Вы действительно помогаете нам мечтать о большем! Ваша невероятная поддержка помогает нам достигать невероятных результатов, оказывая неизгладимое влияние на всё, что мы делаем. + Ваша поддержка вдохновляет! Ваш вклад поможет нам расширить границы и достичь наших целей. + Ваша поддержка помогает нам двигаться вперёд! Каждый вклад способствует переменам, спасибо вам! + Вы — главная причина, по которой мы можем делать то, что делаем! Ваша щедрость помогает нам достигать новых высот. + Ваш вклад очевиден! Вы — ключ к продвижению нашей миссии и достижению результатов. + Ценный ежемесячный вклад + Значительный ежемесячный вклад + Важный ежемесячный вклад + Выдающийся ежемесячный вклад + Да + Позже + Поддержка Thunderbird + Присоединяйтесь к нашей миссии по созданию максимально удобной и настраиваемой электронной почты, соблюдающей конфиденциальность. + Ваше пожертвование поспособствует развитию Thunderbird и мы искренне благодарны за поддержку. + Thunderbird бесплатен и имеет открытый исходный код. + Мы не продаём ваши данные. + Мы финансируемся исключительно такими же пользователями, как вы. + Искренняя благодарность от всех нас! + Мы не показываем рекламу. + diff --git a/feature/funding/googleplay/src/main/res/values-sk/strings.xml b/feature/funding/googleplay/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..aa3ab0a --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-sk/strings.xml @@ -0,0 +1,16 @@ + + + Pokračujte na platbu + Teraz nie + Áno + Podporte Thunderbird + Podporte Thunderbird + Zadať raz + Mesačne + Nedostupné + Zobraziť viac podrobností + Neznáma chyba + Podporte Thunderbird + Skúsiť znova + Nákup zlyhal. + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-sl/strings.xml b/feature/funding/googleplay/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000..138d445 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-sl/strings.xml @@ -0,0 +1,57 @@ + + + Podprite Thunderbird + Podprite Thunderbird + Znova znatno prispevaj + Iskreno hvala od vseh nas! + Varen prispevek + Prispevaj enkrat + Mesečno + Ni na voljo + Prispevki niso davčna olajšava, kot so prispevki v dobrodelne namene. + Nadaljuj na plačilo + Vaši prispevki omogočajo nadaljnji razvoj Thunderbirda in zelo smo hvaležni za vašo podporo. + Za več oblik podpore Thunderbirdu obiščite %s. + Prispevki znotraj programa trenutno niso na voljo. + Poizkusi znova + Spremni mesečna plačila + Prikaži več podrobnosti + Plačila trenutno niso na voljo. + Opusti napako + Neznana napaka + Nakup je spodletel. + Velik prispevek + Osnoven prispevek + Pomagate nam, da ustvarjamo velike stvari! S prispevki na tej ravni nam pomagate, da naredimo smiselne korake k nadaljnji rasti. + Cenjen prispevek + Znaten prispevek + Izvrsten prispevek + Thunderbird se v celoti financira s prispevki uporabnikov, kot ste vi. Nikoli ne prikazujemo oglasov ali prodamo vaših podatkov. Če vam je Thunderbird všeč, ga prosimo podprite. Tega brez vas ne zmoremo. + Prispevki trenutno niso na voljo. + Izrazit prispevek + Vaša podpora nas ohranja na poti naprej! Celo najmanjši prispevki ustvarjajo drobce sprememb in zelo smo hvaležni, da ste z nami! + Vaš prispevek resnično izstopa! Prevzeli ste veliko vlogo pri uresničevanju naše naloge, ki nam pomaga doseči pomembne mejnike in večji vpliv. + Vaš prispevek izstopa! Igrate znatno vlogo pri uresničevanju naše naloge in doseganju večjega vpliva. + Ne prikazujemo oglasov. + Ne prodajamo vaših podatkov. + Financirajo nas samo uporabniki, kot ste vi. + Ne zdaj + Vaša podpora je resnično navdihujoča! Zahvaljujoč vašemu znatnemu prispevku lahko premikamo meje, izumljamo in naredimo veliko korake napram našim ciljem. + Velik mesečni prispevek + Vaša podpora nam da moči za naprej! Vsak prispevek naredi spremembo — hvala! + Izrazit mesečni prispevek + Vaš prispevek nam pomaga rasti in narediti smiselne korake naprej — hvala! + Vaša podpora je navdihujoča! Vaš prispevek nam pomaga premikati meje in dosegati naše cilje. + Ste velik razlog, da lahko to počnemo! Vaša velikodušnost nam pomaga doseči nove mejnike. + Podprite Thunderbird + Ste velik razlog, da lahko to počnemo! Zahvaljujoč vaši velikodušnosti se lahko lotimo večjih projektov in skupaj dosegamo nove mejnike. + Osnoven mesečni prispevek + Resnično nam pomagate, da sanjamo velike sanje! Vaša neverjetna podpora nam da moči, da presegamo samega sebe in pustimo trajen vtis. + Cenjen mesečni prispevek + Pomemben mesečni prispevek + Izvrsten mesečni prispevek + Pridružite se nam, da ustvarimo najboljšo možno e-poštno izkušnjo, ki spoštuje zasebnost in je prilagodljiva. + Da + Thunderbird je prost in odprtokoden. + Pomagate nam, da sanjamo velike sanje! Vaša podpora nam daje moč, da dosežemo izjemne stvari. + diff --git a/feature/funding/googleplay/src/main/res/values-sq/strings.xml b/feature/funding/googleplay/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000..c5e635a --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-sq/strings.xml @@ -0,0 +1,57 @@ + + + Përkrahni Thunderbird-in + Përkrahni Thunderbird-in + Mujor + Jepni një herë + Asnjë i passhëm + Vazhdoni te pagesa + Ndryshoni pagesë mujore + Kontributet s’janë të zbritshëm nga taksat, si dhurime për bamirësi. + Bëni një tjetër kontribut plot ndikim + Kontribut i siguruar + Thunderbird-i financohet tërësisht nga kontribute prej përdoruesish si ju. S’shfaqim kurrë reklama, apo të shesim të dhënat tuaja. Nëse ju pëlqen Thunderbird-i, ju lutemi, ndihmoni të përkrahet. Këtë s’e bëjmë dot pa ju! + Riprovoni + Përkrahja juaj është frymëzuese! Kontributi juaj na ndihmon të shtyhemi më tej dhe të arrijmë synimet tona. + Për më tepër rrugë se si të përkrahet Thunderbird-i, vizitoni %s. + Aktualisht s’mund të bëhet pagesa + Shfaq më shumë hollësi + Hidhe tej gabimin + Gabim i panjohur + Blerja dështoi. + Aktualisht s’mund të bëhen kontribute. + Kontribut Thelbësor + Kontribut Domethënës + Kontribut i Rëndësishëm + Kontribut i Spikatur + Kontribut i Jashtëzakonshëm + Përkrahja juaj na bën të ecim më tej! Edhe kontributet më të vockla krijojnë valë ndryshimi dhe jemi kaq mirënjohës që jeni me ne. + Po na ndihmoni të kryejmë gjëra të rëndësishme! Duke kontribuar në këtë shkallë, po na ndihmoni të ndërmarrim hapa domethënës përpara, që na lejojnë të vazhdojmë të fuqizohemi. + Kontributi juaj rrezaton vërtet! Po luani një rol të rëndësishëm në shpënien përpara të misionit tonë, duke na ndihmuar të arrijmë piketa të rëndësishme dhe të kemi ndikim më të madh. + Jeni një arsye e fortë për të bërë atë çka bëjmë! Falë bujarisë tuaj, mund të merremi me projekte më të mëdha dhe të arrijmë tok piketa të reja. + Përkrahja juaj është vërtet frymëzuese! Falë kontributit tuaj thelbësor, jemi në gjendje të shtrihemi më tej, të sjellim risi dhe të bëjmë hapa domethënës drejt objektivave tona. + Vërtet që po na ndihmoni të ëndërrojmë fort! Përkrahja juaj e pabesueshme na jep fuqi të arrijmë të jashtëzakonshmen, duke lënë një ndikim jetëgjatë në gjithçka që bëjmë. + Kontribut Mujor i Vyer + Kontribut Mujor Domethënës + Kontribut Mujor i Rëndësishëm + Kontribut Mujor i Spikatur + Kontribut Mujor i Jashtëzakonshëm + Kontributi juaj na ndihmon të fuqizoheni dhe të ndërmarrim hapa të kuptimtë përpara—faleminderit! + Kontributi juaj rrezaton! Jeni me rëndësi kyçe në shpënien përpara të misionit tonë dhe në arritje ndikimi. + Vërtet që po na ndihmoni të ëndërrojmë fort! Përkrahja juaj na jep fuqi të arrijmë gjëra të jashtëzakonshmen. + Po + Kontributet që nga aplikacioni aktualisht s’mund të kryhen. + Kontribut i Vyer + Kontribut Mujor Thelbësor + Përkrahja juaj na shpie përpara! Çdo kontribut krijon ndryshim—faleminderit! + Jeni një arsye e fortë për të bërë atë çka bëjmë! Bujaria juaj na ndihmon të arrijmë piketa të reja. + Përkrahni Thunderbird-in + Përkrahni misionin tonë për të krijuar punimin më të mirë të mundshëm me email-et, që respekton privatësinë dhe mund të përshtatet. + Jo tani + Kontributi juaj shpie më tej zhvillimin e Thunderbird-it dhe ju jemi vërtet mirënjohës për përkrahjen tuaj. + Një falënderim të sinqertë prej krejt nesh! + S’shfaqim reklama. + Financohemi vetëm nga përdorues si ju. + S’shesim të dhëna tuajat. + Thunderbird është i lirë dhe me burim të hapët. + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-sr/strings.xml b/feature/funding/googleplay/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000..3cae935 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-sr/strings.xml @@ -0,0 +1,57 @@ + + + Подржи Thunderbird + Подржи Thunderbird + Искрено хвала од свих нас! + Сигуран допринос + Дај једном + Месечно + Нема доступних + Измените месечну уплату + Дајте још један утицајан допринос + Изузетни месечни допринос + Thunderbird је бесплатан и отвореног кода. + Не приказујемо огласе. + Не продајемо ваше податке. + Финансирају нас искључиво корисници попут вас. + Ваш допринос унапређује развој Thunderbird-а и ми смо заиста захвални на вашој подршци. + Посетите %s за више начина за подршку Thunderbird-у. + Доприноси у апликацији тренутно нису доступни. + Плаћање тренутно није доступно + Покушајте поново + Наставите са плаћањем + Цењен допринос + Прикажи више детаља + Главни допринос + Ваша подршка нас покреће напред! Чак и најмањи доприноси стварају таласе промене, и ми смо тако захвални што сте са нама. + Вредновани месечни допринос + Главни месечни допринос + Изузетан месечни допринос + Ваш допринос блиста! Ви сте кључни за унапређење наше мисије и постизање утицаја. + Ваша подршка нас води напред! Сваки допринос ствара промену—хвала! + Ваш допринос нам помаже да растемо и да предузмемо значајне кораке напред—хвала! + Не сада + Ваша подршка је инспиративна! Ваш допринос нам помаже да померимо границе и постигнемо своје циљеве. + Доприноси тренутно нису доступни. + Непозната грешка + Битан допринос + Главни допринос + Изузетан допринос + Значајан допринос + Ваша подршка је заиста инспиративна! Захваљујући вашем значајном доприносу, у могућности смо да померамо границе, иновирамо и направимо значајне кораке ка нашим циљевима. + Значајан месечни допринос + Подржите Thunderbird + Да + Придружите се нашој мисији да створимо најбоље могуће искуство имејла које поштује приватност. + Доприноси се не одбијају од пореза као добротворне донације. + Помажете нам да остваримо велике ствари! Доприносом на овом нивоу, помажете нам да предузмемо значајне кораке напред, омогућавајући нам да наставимо да растемо. + Одбаци грешку + Ви сте велики разлог зашто можемо да радимо оно што радимо! Захваљујући вашој великодушности, можемо да преузмемо веће пројекте и заједно достигнемо нове прекретнице. + Ви сте велики разлог зашто можемо да радимо оно што радимо! Ваша великодушност нам помаже да достигнемо нове прекретнице. + Заиста нам помажете да сањамо велике снове! Ваша невероватна подршка нам даје снагу да постигнемо изванредно, остављајући трајан утицај на све што радимо. + Куповина није успела. + Ваш допринос заиста блиста! Ви играте главну улогу у унапређењу наше мисије, помажући нам да достигнемо важне прекретнице и остваримо већи утицај. + Thunderbird се у потпуности финансира доприносима корисника попут вас. Никада не приказујемо огласе нити продајемо ваше податке. Ако уживате у Thunderbird-у, помозите да га подржите. Не можемо ово без вас! + Помажете нам да сањамо велике снове! Ваша подршка нам даје снагу да постигнемо изванредне ствари. + Основни месечни допринос + diff --git a/feature/funding/googleplay/src/main/res/values-sv/strings.xml b/feature/funding/googleplay/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..0f7a18e --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-sv/strings.xml @@ -0,0 +1,57 @@ + + + Stöd Thunderbird + Månadsvis + Bidrag är inte avdragsgilla som donationer till välgörande ändamål. + Fortsätt till betalning + Thunderbird finansieras helt av bidrag från användare som du. Vi visar aldrig annonser eller säljer din data. Om du gillar Thunderbird, vänligen hjälp till att stödja det. Vi kan inte göra detta utan dig! + Säkert bidrag + Donera en gång + Stöd Thunderbird + Ändra den månatliga betalningen + Inga tillgängliga + Gör ännu ett effektfullt bidrag + Försök igen + Köpet misslyckades. + Bidrag i appen är för närvarande inte tillgängliga. + Besök %s för fler sätt att stödja Thunderbird. + Visa fler detaljer + Betalning är inte tillgänglig för närvarande + Ignorera felet + Okänt fel + Bidrag är för närvarande inte tillgängliga. + Signifikant bidrag + Stort bidrag + Ditt stöd får oss att gå framåt! Även de minsta bidragen ringlar av förändring, och vi är så tacksamma för att ha dig ombord. + Ditt stöd är verkligen inspirerande! Tack vare ditt betydande bidrag kan vi tänja på gränser, förnya oss och ta betydande framsteg mot våra mål. + Värdefullt månatligt bidrag + Stort månatligt bidrag + Enastående månatligt bidrag + Exceptionellt månatligt bidrag + Ditt stöd driver oss framåt! Varje bidrag skapar förändring – tack! + Ditt bidrag strålar! Du är nyckeln till att främja vårt uppdrag och uppnå effekt. + Ditt stöd är inspirerande! Ditt bidrag hjälper oss att flytta gränser och nå våra mål. + Ditt bidrag lyser verkligen! Du spelar en viktig roll i att främja vårt uppdrag, hjälpa oss att nå viktiga milstolpar och uppnå en större inverkan. + Väsentligt bidrag + Utomordentligt bidrag + Exceptionellt bidrag + Värdefullt bidrag + Du hjälper oss att få fantastiska saker att hända! Genom att bidra på den här nivån hjälper du oss att ta meningsfulla steg framåt så att vi kan fortsätta växa. + Du är en stor anledning till att vi kan göra det vi gör! Tack vare din generositet kan vi ta oss an större projekt och nå nya milstolpar tillsammans. + Du hjälper oss verkligen att drömma stort! Ditt otroliga stöd ger oss möjlighet att uppnå det extraordinära och lämnar en bestående inverkan på allt vi gör. + Viktigt månatligt bidrag + Signifikant månatligt bidrag + Du hjälper oss att drömma stort! Ditt stöd ger oss möjlighet att uppnå extraordinära saker. + Ditt bidrag hjälper oss att växa och ta meningsfulla steg framåt – tack! + Du är en stor anledning till att vi kan göra det vi gör! Din generositet hjälper oss att nå nya milstolpar. + Stöd Thunderbird + Inte nu + Ja + Gå med i vårt uppdrag att skapa den bästa möjliga e-postupplevelsen som respekterar integritet och kan anpassas. + Ditt bidrag främjar utvecklingen av Thunderbird och vi är verkligen tacksamma för ditt stöd. + Ett uppriktigt tack från oss alla! + Thunderbird är fritt och öppen källkod. + Vi visar inga annonser. + Vi säljer inte din data. + Vi finansieras enbart av användare som du. + diff --git a/feature/funding/googleplay/src/main/res/values-sw/strings.xml b/feature/funding/googleplay/src/main/res/values-sw/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-sw/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-ta/strings.xml b/feature/funding/googleplay/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..e5bde18 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-ta/strings.xml @@ -0,0 +1,57 @@ + + + தண்டர்பேர்டை ஆதரிக்கவும் + பாதுகாப்பான பங்களிப்பு + மற்றொரு பயனுள்ள பங்களிப்பை செய்யுங்கள் + தண்டர்பேர்டை ஆதரிக்கவும் + உங்களைப் போன்ற பயனர்களின் பங்களிப்புகளால் தண்டர்பேர்ட் முற்றிலும் நிதியளிக்கப்படுகிறது. நாங்கள் ஒருபோதும் விளம்பரங்களைக் காட்ட மாட்டோம் அல்லது உங்கள் தரவை விற்க மாட்டோம். நீங்கள் தண்டர்பேர்டை ரசிக்கிறீர்கள் என்றால், தயவுசெய்து அதை ஆதரிக்க உதவுங்கள். நீங்கள் இல்லாமல் இதை நாங்கள் செய்ய முடியாது! + எங்கள் அனைவரிடமிருந்தும் ஒரு மனமார்ந்த நன்றி! + உங்கள் பங்களிப்பு தண்டர்பேர்டின் வளர்ச்சியை மேலும் மேம்படுத்துகிறது, மேலும் உங்கள் ஆதரவுக்கு நாங்கள் உண்மையிலேயே நன்றியுள்ளவர்களாக இருக்கிறோம். + எதுவும் கிடைக்கவில்லை + பங்களிப்புகள் தொண்டு நன்கொடைகளாக வரி விலக்கு அளிக்கப்படாது. + மீண்டும் முயற்சிக்கவும் + கட்டணத்தைத் தொடரவும் + கட்டணம் தற்போது கிடைக்கவில்லை + மேலும் விவரங்களைக் காட்டு + பங்களிப்புகள் தற்போது கிடைக்கவில்லை. + உங்கள் உதவி எங்களை முன்னோக்கி நகர்த்துகிறது! மிகச்சிறிய பங்களிப்புகள் கூட மாற்றத்தின் சிற்றலைகளை உருவாக்குகின்றன, மேலும் உங்களை கப்பலில் வைத்திருப்பதற்கு நாங்கள் மிகவும் நன்றியுள்ளவர்களாக இருக்கிறோம். + பெரிய காரியங்களைச் செய்ய நீங்கள் எங்களுக்கு உதவுகிறீர்கள்! இந்த மட்டத்தில் பங்களிப்பதன் மூலம், தொடர்ந்து வளர்ந்து வர அனுமதிக்கும் அர்த்தமுள்ள நடவடிக்கைகளை எடுக்க எங்களுக்கு உதவுகிறீர்கள். + உங்கள் உதவி உண்மையிலேயே ஊக்கமளிக்கிறது! உங்கள் கணிசமான பங்களிப்புக்கு நன்றி, நாங்கள் எல்லைகளைத் தள்ளவும், புதுமைப்படுத்தவும், எங்கள் இலக்குகளை நோக்கி குறிப்பிடத்தக்க முன்னேற்றங்களைச் செய்யவும் முடியும். + பெரிய கனவு காண எங்களுக்கு உண்மையிலேயே உதவுகிறீர்கள்! உங்கள் நம்பமுடியாத உதவி அசாதாரணத்தை அடைய எங்களுக்கு அதிகாரம் அளிக்கிறது, நாங்கள் செய்யும் எல்லாவற்றிலும் நீடித்த தாக்கத்தை ஏற்படுத்துகிறது. + குறிப்பிடத்தக்க மாதாந்திர பங்களிப்பு + விதிவிலக்கான மாதாந்திர பங்களிப்பு + உங்கள் உதவி எங்களை முன்னோக்கி செலுத்துகிறது! ஒவ்வொரு பங்களிப்பும் மாற்றத்தை உருவாக்குகிறது -நன்றி! + உங்கள் பங்களிப்பு எங்களுக்கு வளரவும் அர்த்தமுள்ள நடவடிக்கைகளை எடுக்கவும் உதவுகிறது - நன்றி! + உங்கள் பங்களிப்பு பிரகாசிக்கிறது! எங்கள் பணியை முன்னேற்றுவதற்கும் தாக்கத்தை அடைவதற்கும் நீங்கள் முதன்மை. + உங்கள் உதவி ஊக்கமளிக்கிறது! உங்கள் பங்களிப்பு எல்லைகளைத் தள்ளவும் எங்கள் இலக்குகளை அடையவும் உதவுகிறது. + பெரிய கனவு காண எங்களுக்கு உதவுகிறீர்கள்! அசாதாரண விசயங்களை அடைய உங்கள் உதவி எங்களுக்கு அதிகாரம் அளிக்கிறது. + தண்டர்பேர்ட் இலவச மற்றும் திறந்த மூலமாகும். + உங்களைப் போன்ற பயனர்களால் மட்டுமே நாங்கள் நிதியளிக்கப்படுகிறோம். + சிறந்த தனியுரிமை-மரியாதைக்குரிய, தனிப்பயனாக்கக்கூடிய மின்னஞ்சல் அனுபவத்தை உருவாக்க எங்கள் பணியில் சேரவும். + ஒரு முறை கொடுங்கள் + மாதாந்திர + உங்கள் பங்களிப்பு உண்மையில் பிரகாசிக்கிறது! எங்கள் பணியை முன்னேற்றுவதில் நீங்கள் முக்கிய பங்கு வகிக்கிறீர்கள், முக்கியமான மைல்கற்களை அடையவும் அதிக தாக்கத்தை அடையவும் உதவுகிறீர்கள். + நாங்கள் என்ன செய்கிறோம் என்பதை நாங்கள் செய்யக்கூடிய ஒரு பெரிய காரணம்! உங்கள் தாராள மனப்பான்மைக்கு நன்றி, நாங்கள் பெரிய திட்டங்களை எடுத்து புதிய மைல்கற்களை ஒன்றாக அடையலாம். + அத்தியாவசிய மாதாந்திர பங்களிப்பு + நாங்கள் விளம்பரங்களைக் காட்ட மாட்டோம். + நாங்கள் உங்கள் தரவை விற்க மாட்டோம். + மாதாந்திர கட்டணத்தை மாற்றவும் + மதிப்புமிக்க பங்களிப்பு + குறிப்பிடத்தக்க பங்களிப்பு + சிறந்த பங்களிப்பு + விதிவிலக்கான பங்களிப்பு + முக்கிய மாத பங்களிப்பு + சிறந்த மாதாந்திர பங்களிப்பு + தண்டர்பேர்டை ஆதரிக்கவும் + ஆம் + இப்போது இல்லை + பயன்பாட்டில் உள்ள பங்களிப்புகள் தற்போது கிடைக்கவில்லை. + பிழையை நிராகரிக்கவும் + தெரியாத பிழை + கொள்முதல் தோல்வியுற்றது. + அத்தியாவசிய பங்களிப்பு + பெரிய பங்களிப்பு + மதிப்புமிக்க மாத பங்களிப்பு + நாங்கள் செய்வதை நாங்கள் செய்ய ஒரு பெரிய காரணம்! உங்கள் தாராள மனப்பான்மை புதிய மைல்கற்களை அடைய எங்களுக்கு உதவுகிறது. + %s ஐப் பார்வையிடவும். தண்டர்பேர்டை ஆதரிப்பதற்கான கூடுதல் வழிகள். + diff --git a/feature/funding/googleplay/src/main/res/values-th/strings.xml b/feature/funding/googleplay/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-th/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-tr/strings.xml b/feature/funding/googleplay/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..ac047d5 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-tr/strings.xml @@ -0,0 +1,40 @@ + + + Thunderbird’ü Destekleyin + Thunderbird tamamen kullanıcılarımızın bağışlarıyla finanse ediliyor. Reklam göstermiyoruz ve verilerinizi satmıyoruz. Thunderbird’ü beğeniyorsanız lütfen siz de bağış yapın. Siz olmadan başaramayız! + Güvenli bağış + Tek seferlik + Her ay + Mevcut değil + Ödemeye geç + Aylık ödemeyi değiştir + Thunderbird’ü Destekleyin + Yeni bir bağış daha yapın + Bağışlarınız vergiden düşülemez. + Şu anda uygulama içi bağış yapılamıyor. + Thunderbird\'ü nasıl destekleyebileceğinizi öğrenmek için %s sayfasını ziyaret edin. + Şu anda ödeme yapılamıyor + Yeniden dene + Ayrıntıları göster + Hatayı kapat + Bilinmeyen hata + Satın alma başarısız oldu. + Hepimizden içten bir teşekkür! + Desteğiniz bizi ileri itiyor! En ufak katkılar bile değişim adına küçük dalgalar yaratarak ilerleyebilir, ve aramızda olmanıza çok minnettarız. + Bağışlarınız Thunderbird\'ün gelişimini ileri taşıyor ve desteğiniz için gerçekten minnettarız. + Değerli Katkı + Temel Katkı + Sıradışı Katkı + Önemli Katkı + Ezber Bozan Katkı + Şu anda bağış yapılamıyor. + Büyük Katkı + Thunderbird’ü destekle + Reklam göstermiyoruz. + Verilerinizi satmıyoruz. + Sadece sizin gibi kullanıcılar tarafından finanse ediliyoruz. + Evet + Şimdi değil + Thunderbird ücretsiz ve açık kaynaklıdır. + Harika şeyler yapmamıza yardım ediyorsunuz! Bu seviyede katkıda bulunarak, büyümeye devam etmemize izin vererek anlamlı adımlar atmamıza yardımcı olursunuz. + diff --git a/feature/funding/googleplay/src/main/res/values-uk/strings.xml b/feature/funding/googleplay/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..fde586b --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-uk/strings.xml @@ -0,0 +1,57 @@ + + + Перейти до оплати + Підтримка Thunderbird + Безпечний внесок + Одноразово + Щомісяця + Недоступно + Внески не оподатковуються як благодійна допомога. + Змінити щомісячний платіж + Підтримка Thunderbird + Зробіть ще один вагомий внесок + Thunderbird повністю фінансується шляхом внесків таких користувачів, як ви. Ми ніколи не показуємо рекламу та не продаємо ваші дані. Якщо вам подобається Thunderbird, підтримайте його. Ми не можемо зробити це без вас! + Щира подяка від усіх нас! + Ваш внесок сприяє розвитку Thunderbird, і ми щиро вдячні за вашу підтримку. + Внески через застосунок наразі недоступні. + Відвідайте %s, щоб переглянути більше способів підтримки Thunderbird. + Оплата наразі недоступна + Докладніше + Відкинути помилку + Невідома помилка + Покупка не здійснена. + Суттєвий внесок + Вагомий внесок + Видатний внесок + Видатний місячний внесок + Винятковий щомісячний внесок + Ми не продаємо ваші дані. + Цінний внесок + Ваша підтримка дає нам змогу розвиватися! Навіть найменші внески викликають хвилі змін, і ми дуже вдячні, що ви долучилися. + Ваш внесок дійсно блискучий! Ви відіграєте важливу роль у просуванні нашої місії, допомагаючи нам досягти важливих віх і досягти кращого результату. + Ваша підтримка справді надихає! Завдяки вашому значному внеску ми можемо розширювати межі, впроваджувати інновації та досягати значних успіхів у досягненні наших цілей. + Цінний місячний внесок + Вагомий місячний внесок + Значний щомісячний внесок + Ви дійсно допомагаєте нам мріяти про велике! Ваша неймовірна підтримка дає нам змогу досягати надзвичайних результатів, справляючи довготривалий вплив на все, що ми робимо. + Ваш внесок блискучий! Ви маєте ключ до просування нашої місії та досягнення результатів. + Ви є важливою причиною того, що ми можемо робити те, що ми робимо! Ваша щедрість допомагає нам досягти нових віх. + Ваш внесок допомагає нам розвиватися та робити важливі кроки вперед — дякуємо! + Ви допомагаєте нам мріяти про більше! Ваша підтримка дає нам змогу досягати надзвичайних звершень. + Підтримка Thunderbird + Долучайтеся до нашої місії, щоб створити найзручніший і налаштовуваний застосунок електронної пошти, який поважає вашу приватність. + Ваша підтримка надихає! Ваш внесок допомагає нам розширювати межі та досягати наших цілей. + Так + Внески наразі недоступні. + Значний внесок + Винятковий внесок + Ви допомагаєте нам робити великі речі! Роблячи внесок на цьому рівні, ви допомагаєте нам робити важливі кроки вперед, що дозволяє нам продовжувати розвиватися. + Ваша підтримка допомагає нашому розвитку! Кожен внесок сприяє змінам — дякуємо! + Thunderbird безплатний і з відкритим кодом. + Ми не показуємо рекламу. + Ми фінансуємося виключно такими користувачами, як ви. + Повторіть спробу + Ви є важливою причиною того, що ми можемо робити те, що ми робимо! Завдяки вашій щедрості ми можемо братися за більші проєкти та досягати нових віх разом. + Суттєвий щомісячний внесок + Не зараз + diff --git a/feature/funding/googleplay/src/main/res/values-vi/strings.xml b/feature/funding/googleplay/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..8cad3ca --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-vi/strings.xml @@ -0,0 +1,57 @@ + + + An toàn khi đóng góp + Ủng hộ Thunderbird + Các khoản đóng góp đều không được khấu trừ thuế như các khoảng đóng góp từ thiện. + Ủng hộ Thunderbird + Hàng tháng + Không có cái nào khả dụng + Tiếp tục để thanh toán + Thanh toán hiện giờ không khả dụng + Điều chỉnh thanh toán hàng tháng + Đóng góp được coi trọng + Chúng tôi không hiện quảng cáo. + Chúng tôi không bán dữ liệu của bạn. + Chúng tôi được gây quỹ hoàn toàn bằng những người dùng như bạn. + Đóng góp trong ứng dụng hiện tại không khả dụng. + Sự đóng góp của bạn giúp sự phát triển của Thunderbird tiến xa hơn nữa và chúng tôi thật sự biết ơn vì sự đóng góp của bạn. + Thử lại + Ghé %s để biết nhiều cách hơn để hỗ trợ Thunderbird. + Thực hiện thêm một khoản đóng góp có ảnh hưởng + Hiện thêm chi tiết + Đóng góp hiện giờ không khả dụng. + Đóng góp thiết thực + Bỏ qua lỗi + Lỗi khộng xác định + Mua hàng thất bại. + Đóng góp có ảnh hưởng lớn + Đóng góp to lớn + Đóng góp siêu to lớn + Đóng góp của bạn giúp chúng tôi tiến lên phía trước! Ngay cả những đóng góp nhỏ nhất cũng tạo ra những làn sống thay đổi, và chúng tôi rất biết ơn để có bạn làm điều này. + Bạn đang giúp chúng tôi làm những điều tốt đẹp xảy ra! Bằng việc đóng góp tại mức này, bạn đang giúp chúng tôi thực hiện những bước đi có nghĩa cho phép chúng tôi để tiếp tục phát triển. + Đóng góp của bạn thật sự truyền cảm hứng! Nhờ khoản quyên góp to lớn của bạn, chúng tôi có thể phá bỏ rào cản, đổi mới, và tạo ra những bước sải lớn hơn trên hành trình tiến tới sứ mệnh của chúng tôi. + Đóng góp thiết thực hàng tháng + Đóng góp có ảnh hưởng lớn hàng tháng + Đóng góp siêu to lớn hàng tháng + Đóng góp phi thường hàng tháng + Sự đóng góp của bạn giúp chúng tôi tiến về phía trước! Mọi sự đóng góp tạo ra thay đổi—cảm ơn! + Sự đóng góp của bạn toả sáng đấy! Bạn là chìa khoá để tiến gần hơn với sứ mệnh của chúng tôi và tạo ra được sự ảnh hưởng tích cực. + Bạn là một lí do to lớn chúng tôi có thể thực hiện những gì chúng tôi làm! Sự hào phóng của ban giúp chúng tôi đạt được những cột mốc mới. + Sự ủng hộ của bạn thật sự truyền cảm hứng! Sự đóng góp của bạn giúp chúng tôi vượt qua các rào cản và đạt được mục tiêu của chúng tôi. + Tham gia sứ mệnh của chúng tôi để tạo ra một trải nghiệm thư điện tử mà tôn trọng quyền tư, có thể tuỳ chỉnh nhất có thể. + Không phải lúc này + Đóng góp phi thường + Đóng góp to lớn hàng tháng + Bạn đang giúp chúng tôi dám mơ bự hơn đấy! Sự đóng góp của bạn tiếp thêm năng lượng cho chúng tôi để đạt được những điều phi thường. + Ủng hộ Thunderbird + + Cho một lần + Đóng góp của bạn thực sự toả sáng! Bạn đang đóng một vai trò lớn trong việc tiến gần hơn với sứ mệnh của chúng tôi, giúp chúng tôi chạm được những cột mốc quan trọng và tạo ra tác động mạnh mẽ hơn. + Thunderbird hoàn toàn miễn phí và mã nguồn mở. + Đóng góp được coi trọng hàng tháng + Đóng góp của bạn giúp chúng tôi phát triển và thực hiện những bước đi có ý nghĩa phía trước—cảm ơn! + Bạn là một lí do to lớn mà chúng tôi có thể làm những thứ chúng tôi đang làm! Cảm ơn sự hào phóng của bạn, chúng tôi có thể đảm nhận những dự án lớn hơn và đạt được những cốt mốc mới cùng nhau. + Bạn đang thật sự giúp chúng tôi dám mơ lớn hơn! Sự hỗ trợ tuyệt vời của bạn tiếp thêm năng lượng cho chúng tôi để đạt được những điều phi thường, để lại một tác động lâu dài cho mọi việc chúng tôi làm. + Một lời cảm ơn chân thành đến từ tất cả chúng tôi! + Thunderbird hoàn toàn kiếm tiền hoàn toàn từ các khoản đóng góp từ các người dùng như bạn. Chúng tôi không bao giờ hiện thị quảng cáo hay bán dữ liệu của bạn. Nếu bạn đang tận hưởng Thunderbird, hãy hỗ trợ giúp nó. Chúng tôi không thể làm được việc này mà thiếu bạn được! + \ No newline at end of file diff --git a/feature/funding/googleplay/src/main/res/values-zh-rCN/strings.xml b/feature/funding/googleplay/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..93bb021 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,57 @@ + + + 捐款一次 + 捐款不能作为慈善捐款免税。 + 继续捐款 + 支持 Thunderbird + Thunderbird 的资金完全来自用户的捐款。我们永远不会显示广告或出售您的数据。如果您喜欢 Thunderbird,请考虑支持它。我们需要您的支持! + 安全捐款 + 每月捐款 + 支持 Thunderbird + 修改每月捐款 + 不可用 + 做出另一项有影响力的贡献 + 访问 %s 了解更多支持 Thunderbird 的方法。 + 重试 + 显示更多详情 + 当前无法捐款 + 捐款失败。 + 目前无法捐款。 + 重要支持 + 杰出支持 + 卓越支持 + 主要支持 + 重大支持 + 您的支持意义非凡!您在推动我们的使命、帮助我们达到重要的里程碑并产生更大的影响方面发挥了重要作用。 + 您确实在帮助我们实现远大的梦想!您的大力支持让我们能够实现非凡的目标,对我们所做的一切产生了深远的影响。 + 每月重要支持 + 每月杰出支持 + 每月卓越支持 + 您的支持是我们前进的动力!每一份支持都会带来改变——谢谢! + 您的支持帮助我们成长并向前迈出有意义的步伐——谢谢! + 您的支持意义非凡!您是推动我们完成使命和产生影响的关键。 + 您的支持令人鼓舞!您的支持帮助我们突破界限并实现我们的目标。 + 应用内捐款目前不可用。 + 关闭错误 + 您在帮助我们实现远大的梦想!您的支持让我们能够取得非凡的成就。 + 基本支持 + 每月基本支持 + 每月重大支持 + 每月主要支持 + 未知错误 + 您正在帮助我们实现伟大的目标!通过这种程度的支持,您正在帮助我们向前迈出有意义的步伐,让我们继续发展壮大。 + 您的支持让我们不断前进!即使是最小的支持也会产生变革的涟漪,我们非常感激您的加入。 + 您是我们能够做到这些的重要原因!有了您的慷慨解囊,我们才能承担更大的项目,共同迈向新的里程碑。 + 您的支持确实令人鼓舞!有了您的大力支持,我们才能突破极限、不断创新,并朝着我们的目标大步迈进。 + 您是我们能够做到这些的重要原因!您的慷慨解囊帮助我们迈向了新的里程碑。 + 支持 Thunderbird + + 现在不 + 加入我们的使命,共同创造尊重隐私、可定制的最佳电子邮件体验。 + 您的支持促进了 Thunderbird 的发展,我们衷心感谢您的支持。 + 我们向您表示衷心的感谢! + Thunderbird 是自由和开源的。 + 我们不会显示广告。 + 我们不会出售您的数据。 + 我们的资金完全来自像您这样的用户。 + diff --git a/feature/funding/googleplay/src/main/res/values-zh-rTW/strings.xml b/feature/funding/googleplay/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..a0daf72 --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,52 @@ + + + 支持 Thunderbird + 做出另一項有影響力的貢獻 + 前往付款 + 重大貢獻 + 不可用 + 重試 + 此捐款不能作為慈善捐款免稅。 + 造訪 %s 以了解更多支援 Thunderbird 的方法。 + 您的支持使我們前進!每一份貢獻都會帶來改變-謝謝! + 關閉錯誤訊息 + 未知的錯誤 + 主要貢獻 + 重要貢獻 + 每月重要貢獻 + Thunderbird 的資金完全來自像您這樣的用戶的捐助。我們從不展示廣告或出售您的資料。如果您喜歡 Thunderbird,請幫助支持它。沒有你我們做不到! + 我們全體人員向您致上誠摯的謝意! + 您的貢獻促進了 Thunderbird 的發展,我們真誠地感謝您的支持。 + 貢獻一次 + 每月貢獻 + 購買失敗。 + 安全貢獻 + 您正在幫助我們實現偉大的事情!透過這種程度的貢獻,您正在幫助我們向前邁出有意義的步伐,使我們能夠繼續成長。 + 您的貢獻確實傑出!您在推進我們的使命、幫助我們達到重要里程碑和產生更大影響力方面發揮著重要作用。 + 目前無法進行 App 內捐款。 + 顯示詳細資訊 + 目前無法提供捐款。 + 關鍵貢獻 + 您是我們能夠做到這些事情的一個重要原因!感謝您的慷慨,我們可以承擔更大的專案並共同達到新的里程碑。 + 修改每月付款 + 您的支持使我們不斷前進!即使是最微小的貢獻也會產生巨大的變化,我們非常感謝您加入我們。 + 傑出貢獻 + 您確實幫助我們實現了遠大的夢想!您令人難以置信的支持使我們能夠取得非凡的成就,並對我們所做的一切產生持久的影響。 + 支持 Thunderbird + 目前無法付款 + 您的貢獻幫助我們成長並向前邁出有意義的一步—謝謝! + 卓越貢獻 + 您的支持確實鼓舞人心!感謝您的巨大貢獻,使我們能夠突破界限,不斷創新,並朝著我們的目標邁出重要一步。 + Thunderbird 是免費且開源的。 + 我們從不顯示廣告。 + 您的貢獻卓越非凡!您正是推動我們使命達成的關鍵力量。 + 正因有您,我們才能成就一切!您的慷慨支持,助我們不斷突破新里程碑。 + 您的支持振奮人心!您的貢獻幫助我們突破邊界從而實現我們的目標。 + 有您同行,我們敢於夢想!您的支持賦予我們實現非凡成就的力量。 + 支持 Thunderbird + 我們不會出售您的數據。 + 我們完全由像您這樣的用戶資助。 + 加入我們的使命,共同打造最尊重隱私、高度可定制的電子郵件體驗。 + + 不是現在 + diff --git a/feature/funding/googleplay/src/main/res/values/constants.xml b/feature/funding/googleplay/src/main/res/values/constants.xml new file mode 100644 index 0000000..3ae62bd --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values/constants.xml @@ -0,0 +1,5 @@ + + + www.thunderbird.net + https://www.thunderbird.net + diff --git a/feature/funding/googleplay/src/main/res/values/strings.xml b/feature/funding/googleplay/src/main/res/values/strings.xml new file mode 100644 index 0000000..92285cf --- /dev/null +++ b/feature/funding/googleplay/src/main/res/values/strings.xml @@ -0,0 +1,106 @@ + + + + Support Thunderbird + + Support Thunderbird + Thunderbird is funded entirely by contributions from users like you. We never show ads or sell your data. If you’re enjoying Thunderbird, please help support it. We can’t do this without you! + A sincere thank you from all of us! + Your contribution furthers the development of Thunderbird and we are truly grateful for your support. + + Secure contribution + Give once + Monthly + None available + + Contributions are not tax-deductible as charitable donations. + In-app contributions are currently unavailable. + Visit %s for more ways to support Thunderbird. + Retry + + Continue to payment + Payment currently unavailable + Modify monthly payment + Make another impactful contribution + + Show more details + Dismiss error + Unknown error + Purchase failed. + Contributions are currently unavailable. + + Essential Contribution + Valued Contribution + Significant Contribution + Major Contribution + Outstanding Contribution + Exceptional Contribution + + Your support keeps us moving forward! Even the smallest contributions create ripples of change, and we’re so grateful to have you on board. + You’re helping us make great things happen! By contributing at this level, you’re helping us take meaningful steps forward allowing us to continue growing. + Your contribution really shines! You’re playing a major role in advancing our mission, helping us reach important milestones and achieve a greater impact. + You’re a big reason we’re able to do what we do! Thanks to your generosity, we can take on bigger projects and reach new milestones together. + Your support is truly inspiring! Thanks to your substantial contribution, we’re able to push boundaries, innovate, and make significant strides toward our goals. + You’re really helping us dream big! Your incredible support empowers us to achieve the extraordinary, leaving a lasting impact on everything we do. + + Essential Monthly Contribution + Valued Monthly Contribution + Significant Monthly Contribution + Major Monthly Contribution + Outstanding Monthly Contribution + Exceptional Monthly Contribution + + Your support drives us forward! Every contribution creates change—thank you! + Your contribution helps us grow and take meaningful steps forward—thank you! + Your contribution shines! You’re key to advancing our mission and achieving impact. + You’re a big reason we can do what we do! Your generosity helps us reach new milestones. + Your support is inspiring! Your contribution helps us push boundaries and achieve our goals. + You’re helping us dream big! Your support empowers us to achieve extraordinary things. + + + A monthly, voluntary contribution + Support the backbone of our efforts + Keep us moving forward + Contributions are not tax-deductible + + + A monthly, voluntary contribution + Enable us to build new features + Help us take meaningful steps forward + Contributions are not tax-deductible + + + A monthly, voluntary contribution + Play a major role in aiding our mission + Support key improvements + Contributions are not tax-deductible + + + A monthly, voluntary contribution + Be a driving force behind our success + Enable us to take on larger projects + Contributions are not tax-deductible + + + A monthly, voluntary contribution + Enable us to make significant strides + Help us push boundaries + Contributions are not tax-deductible + + + A monthly, voluntary contribution + Empower us to dream bigger + Make our boldest ideas a reality + Contributions are not tax-deductible + + + Support Thunderbird + + Thunderbird is free and open source. + We don’t show ads. + We don’t sell your data. + We are funded solely by users like you. + Join our mission to create the best privacy-respecting, customizable email experience possible. + Yes + Not now + diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapperTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapperTest.kt new file mode 100644 index 0000000..d4f2580 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/BillingResultMapperTest.kt @@ -0,0 +1,120 @@ +package app.k9mail.feature.funding.googleplay.data.mapper + +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingResult +import kotlin.reflect.KClass +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.outcome.Outcome + +class BillingResultMapperTest { + + private val testSubject = BillingResultMapper() + + @Test + fun `mapToBillingClientResult returns Success when billing result is OK`() = runTest { + val billingResult = BillingResult.newBuilder() + .setResponseCode(BillingResponseCode.OK) + .build() + + val result = testSubject.mapToOutcome(billingResult) {} + + assertThat(result).isInstanceOf(Outcome.Success::class) + } + + @Test + fun `mapToBillingClientResult returns ServiceDisconnected when billing result is SERVICE_DISCONNECTED`() = + runTest { + val errorResults = listOf( + createErrorBillingResult(BillingResponseCode.SERVICE_DISCONNECTED), + createErrorBillingResult(BillingResponseCode.SERVICE_UNAVAILABLE), + createErrorBillingResult(BillingResponseCode.BILLING_UNAVAILABLE), + createErrorBillingResult(BillingResponseCode.NETWORK_ERROR), + ) + + val results = errorResults.map { billingResult -> + testSubject.mapToOutcome(billingResult) {} + } + + results.forEach { result -> + assertOutcomeFailure(result, BillingError.ServiceDisconnected::class) + } + } + + @Test + fun `mapToBillingClientResult returns PurchaseFailed when billing result is ITEM_ALREADY_OWNED`() = runTest { + val errorResults = listOf( + createErrorBillingResult(BillingResponseCode.ITEM_ALREADY_OWNED), + createErrorBillingResult(BillingResponseCode.ITEM_NOT_OWNED), + createErrorBillingResult(BillingResponseCode.ITEM_UNAVAILABLE), + ) + + val results = errorResults.map { billingResult -> + testSubject.mapToOutcome(billingResult) {} + } + + results.forEach { result -> + assertOutcomeFailure(result, BillingError.PurchaseFailed::class) + } + } + + @Test + fun `mapToBillingClientResult returns UserCancelled when billing result is USER_CANCELED`() = runTest { + val billingResult = createErrorBillingResult(BillingResponseCode.USER_CANCELED) + + val result = testSubject.mapToOutcome(billingResult) {} + + assertOutcomeFailure(result, BillingError.UserCancelled::class) + } + + @Test + fun `mapToBillingClientResult returns DeveloperError when billing result is DEVELOPER_ERROR`() = runTest { + val billingResult = createErrorBillingResult(BillingResponseCode.DEVELOPER_ERROR) + + val result = testSubject.mapToOutcome(billingResult) {} + + assertOutcomeFailure(result, BillingError.DeveloperError::class) + } + + @Test + fun `mapToBillingClientResult returns UnknownError when billing result is unknown`() = runTest { + val errorResult = listOf( + createErrorBillingResult(BillingResponseCode.ERROR), + createErrorBillingResult(BillingResponseCode.FEATURE_NOT_SUPPORTED), + ) + + val results = errorResult.map { billingResult -> + testSubject.mapToOutcome(billingResult) {} + } + + results.forEach { result -> + assertOutcomeFailure(result, BillingError.UnknownError::class) + } + } + + private fun assertOutcomeFailure(result: Outcome, kClass: KClass) { + assertThat(result).isInstanceOf(Outcome.Failure::class) + val error = (result as Outcome.Failure).error + assertThat(error).all { + isInstanceOf(kClass) + prop(BillingError::message).isEqualTo(DEBUG_MESSAGE) + } + } + + private fun createErrorBillingResult(responseCode: Int, debugMessage: String = DEBUG_MESSAGE): BillingResult { + return BillingResult.newBuilder() + .setResponseCode(responseCode) + .setDebugMessage(debugMessage) + .build() + } + + private companion object { + private const val DEBUG_MESSAGE = "Debug message" + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/ProductDetailsMapperTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/ProductDetailsMapperTest.kt new file mode 100644 index 0000000..d5dd82c --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/data/mapper/ProductDetailsMapperTest.kt @@ -0,0 +1,159 @@ +package app.k9mail.feature.funding.googleplay.data.mapper + +import app.k9mail.feature.funding.googleplay.domain.entity.OneTimeContribution +import app.k9mail.feature.funding.googleplay.domain.entity.RecurringContribution +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.ProductDetails +import kotlin.test.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class ProductDetailsMapperTest { + + private val testSubject = ProductDetailsMapper() + + @Test + fun `mapToOneTimeContribution returns OneTimeContribution when product type is INAPP`() { + val productDetails = createInAppProductDetails() + + val result = testSubject.mapToOneTimeContribution(productDetails) + + assertThat(result).isEqualTo(ONE_TIME_CONTRIBUTION) + } + + @Test + fun `mapToOneTimeContribution throws IllegalStateException when in app product has no offer details`() { + val productDetails = createInAppProductDetails(hasOfferDetails = false) + + assertFailure { + testSubject.mapToOneTimeContribution(productDetails) + }.isInstanceOf(IllegalStateException::class) + } + + @Test + fun `mapToRecurringContribution returns RecurringContribution when product type is SUBS`() { + val productDetails = createSubscriptionProductDetails() + + val result = testSubject.mapToRecurringContribution(productDetails) + + assertThat(result).isEqualTo(RECURRING_CONTRIBUTION) + } + + @Test + fun `mapToRecurringContribution throws IllegalStateException when subscription product has no pricing phase`() { + val productDetails = createSubscriptionProductDetails(hasPricingPhase = false) + + assertFailure { + testSubject.mapToRecurringContribution(productDetails) + }.isInstanceOf(IllegalStateException::class) + } + + @Test + fun `mapToContribution return contribution for all supported types`() { + val inAppProductDetails = createInAppProductDetails() + val subscriptionProductDetails = createSubscriptionProductDetails() + + val oneTimeContribution = testSubject.mapToContribution(inAppProductDetails) + val recurringContribution = testSubject.mapToContribution(subscriptionProductDetails) + + assertThat(oneTimeContribution).isEqualTo(ONE_TIME_CONTRIBUTION) + assertThat(recurringContribution).isEqualTo(RECURRING_CONTRIBUTION) + } + + @Test + fun `mapToContribution throws IllegalArgumentException when product type is unknown`() { + val productDetails = mock { + on { productType } doReturn "unknown" + } + + assertFailure { + testSubject.mapToContribution(productDetails) + }.isInstanceOf(IllegalArgumentException::class) + } + + private fun createInAppProductDetails( + hasOfferDetails: Boolean = true, + ): ProductDetails { + val oneTimePurchaseOfferDetails = mock { + on { priceAmountMicros }.thenReturn(ONE_TIME_PRICE) + on { formattedPrice }.thenReturn(ONE_TIME_PRICE_FORMATTED) + } + + return mock { + on { productType } doReturn ProductType.INAPP + on { productId } doReturn ONE_TIME_ID + on { name } doReturn ONE_TIME_TITLE + on { description } doReturn ONE_TIME_DESCRIPTION_WITH_NEW_LINE + on { getOneTimePurchaseOfferDetails() } doReturn if (hasOfferDetails) { + oneTimePurchaseOfferDetails + } else { + null + } + } + } + + private fun createSubscriptionProductDetails( + hasPricingPhase: Boolean = true, + ): ProductDetails { + val pricingPhase = mock { + on { priceAmountMicros } doReturn RECURRING_PRICE + on { formattedPrice } doReturn RECURRING_PRICE_FORMATTED + } + + val pricingPhaseList = mock { + on { pricingPhaseList } doReturn listOf(pricingPhase) + } + + val subscriptionOfferDetails = mock { + on { pricingPhases } doReturn pricingPhaseList + } + + return mock { + on { productType } doReturn ProductType.SUBS + on { productId } doReturn RECURRING_ID + on { name } doReturn RECURRING_TITLE + on { description } doReturn RECURRING_DESCRIPTION_WITH_NEW_LINE + on { getSubscriptionOfferDetails() } doReturn if (hasPricingPhase) { + listOf(subscriptionOfferDetails) + } else { + null + } + } + } + + private companion object { + const val ONE_TIME_ID = "one_time_id" + const val ONE_TIME_TITLE = "One-Time" + const val ONE_TIME_DESCRIPTION = "One-Time Description" + const val ONE_TIME_DESCRIPTION_WITH_NEW_LINE = "One-Time\n Description" + const val ONE_TIME_PRICE = 1_000L + const val ONE_TIME_PRICE_FORMATTED = "$10.00" + + val ONE_TIME_CONTRIBUTION = OneTimeContribution( + id = ONE_TIME_ID, + title = ONE_TIME_TITLE, + description = ONE_TIME_DESCRIPTION, + price = ONE_TIME_PRICE, + priceFormatted = ONE_TIME_PRICE_FORMATTED, + ) + + const val RECURRING_ID = "recurring_product_id" + const val RECURRING_TITLE = "Recurring" + const val RECURRING_DESCRIPTION = "Recurring Description" + const val RECURRING_DESCRIPTION_WITH_NEW_LINE = "Recurring\n Description" + const val RECURRING_PRICE = 2_000L + const val RECURRING_PRICE_FORMATTED = "$20.00" + + val RECURRING_CONTRIBUTION = RecurringContribution( + id = RECURRING_ID, + title = RECURRING_TITLE, + description = RECURRING_DESCRIPTION, + price = RECURRING_PRICE, + priceFormatted = RECURRING_PRICE_FORMATTED, + ) + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreenKtTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreenKtTest.kt new file mode 100644 index 0000000..d241a88 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionScreenKtTest.kt @@ -0,0 +1,39 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import androidx.compose.ui.test.performClick +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.onNodeWithTag +import app.k9mail.core.ui.compose.testing.pressBack +import app.k9mail.core.ui.compose.testing.setContentWithTheme +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +internal class ContributionScreenKtTest : ComposeTest() { + + @Test + fun `should call onBack when back button is pressed`() = runTest { + val initialState = State() + val viewModel = FakeContributionViewModel(initialState) + var onBackCounter = 0 + + setContentWithTheme { + ContributionScreen( + onBack = { onBackCounter++ }, + viewModel = viewModel, + ) + } + + assertThat(onBackCounter).isEqualTo(0) + + pressBack() + + assertThat(onBackCounter).isEqualTo(1) + + onNodeWithTag("TopAppBarBackButton").performClick() + + assertThat(onBackCounter).isEqualTo(2) + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionStateTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionStateTest.kt new file mode 100644 index 0000000..5ab6109 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionStateTest.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ContributionListState +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlinx.collections.immutable.persistentListOf + +internal class ContributionStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).isEqualTo( + State( + listState = ContributionListState( + recurringContributions = persistentListOf(), + oneTimeContributions = persistentListOf(), + selectedContribution = null, + isRecurringContributionSelected = true, + error = null, + isLoading = true, + ), + purchasedContribution = null, + showContributionList = true, + showRecurringContributions = false, + purchaseError = null, + ), + ) + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionViewModelTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionViewModelTest.kt new file mode 100644 index 0000000..a449959 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/ContributionViewModelTest.kt @@ -0,0 +1,169 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import app.k9mail.core.ui.compose.testing.mvi.MviContext +import app.k9mail.core.ui.compose.testing.mvi.MviTurbines +import app.k9mail.core.ui.compose.testing.mvi.runMviTest +import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck +import app.k9mail.feature.funding.googleplay.domain.entity.AvailableContributions +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ContributionListState +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Effect +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Event +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule + +class ContributionViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should change selected contribution and selected type when one time contribution selected`() = runMviTest { + val initialState = State( + listState = ContributionListState( + oneTimeContributions = FakeData.oneTimeContributions, + recurringContributions = FakeData.recurringContributions, + selectedContribution = FakeData.recurringContributions[FakeData.recurringContributions.size - 2], + isRecurringContributionSelected = true, + isLoading = false, + ), + purchasedContribution = null, + showContributionList = true, + ) + + contributionRobot(initialState) { + selectOneTimeContribution() + verifyOneTimeContributionSelected() + } + } + + @Test + fun `should change selected contribution and selected type when recurring contribution selected`() = runMviTest { + val initialState = State( + listState = ContributionListState( + oneTimeContributions = FakeData.oneTimeContributions, + recurringContributions = FakeData.recurringContributions, + selectedContribution = FakeData.oneTimeContributions[FakeData.oneTimeContributions.size - 2], + isRecurringContributionSelected = false, + isLoading = false, + ), + purchasedContribution = null, + showContributionList = true, + ) + + contributionRobot(initialState) { + selectRecurringContribution() + verifyRecurringContributionSelected() + } + } + + @Test + fun `should change selected contribution when contribution item clicked`() = runMviTest { + val initialState = State( + listState = ContributionListState( + oneTimeContributions = FakeData.oneTimeContributions, + recurringContributions = FakeData.recurringContributions, + selectedContribution = FakeData.recurringContributions[FakeData.recurringContributions.size - 2], + isRecurringContributionSelected = true, + isLoading = false, + ), + purchasedContribution = null, + showContributionList = true, + ) + val selectedContribution = FakeData.recurringContributions[2] + + contributionRobot(initialState) { + selectContributionItem(selectedContribution) + verifyContributionItemSelected(selectedContribution) + } + } +} + +private suspend fun MviContext.contributionRobot( + initialState: State = State(), + interaction: suspend ContributionRobot.() -> Unit, +) = ContributionRobot(this, initialState).apply { + initialize() + interaction() +} + +private class ContributionRobot( + private val mviContext: MviContext, + private val initialState: State = State(), +) { + // FIX use case + private val viewModel: ContributionContract.ViewModel = ContributionViewModel( + getAvailableContributions = { + Outcome.success( + AvailableContributions( + oneTimeContributions = FakeData.oneTimeContributions, + recurringContributions = FakeData.recurringContributions, + purchasedContribution = FakeData.oneTimeContributions.first(), + ), + ) + }, + billingManager = FakeBillingManager(), + initialState = initialState, + ) + private lateinit var turbines: MviTurbines + + suspend fun initialize() { + turbines = mviContext.turbinesWithInitialStateCheck(viewModel, initialState) + } + + fun selectOneTimeContribution() { + viewModel.event(Event.OnOneTimeContributionSelected) + } + + suspend fun verifyOneTimeContributionSelected() { + val oneTimeContributions = initialState.listState.oneTimeContributions + + assertThat(turbines.awaitStateItem()).isEqualTo( + + initialState.copy( + listState = initialState.listState.copy( + isRecurringContributionSelected = false, + selectedContribution = oneTimeContributions[oneTimeContributions.size - 2], + ), + showContributionList = true, + ), + ) + } + + fun selectRecurringContribution() { + viewModel.event(Event.OnRecurringContributionSelected) + } + + suspend fun verifyRecurringContributionSelected() { + val recurringContributions = initialState.listState.recurringContributions + + assertThat(turbines.awaitStateItem()).isEqualTo( + initialState.copy( + listState = initialState.listState.copy( + isRecurringContributionSelected = true, + selectedContribution = recurringContributions[recurringContributions.size - 2], + ), + showContributionList = true, + ), + ) + } + + fun selectContributionItem(item: Contribution) { + viewModel.event(Event.OnContributionItemClicked(item)) + } + + suspend fun verifyContributionItemSelected(item: Contribution) { + assertThat(turbines.awaitStateItem()).isEqualTo( + initialState.copy( + listState = initialState.listState.copy( + selectedContribution = item, + ), + ), + ) + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeBillingManager.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeBillingManager.kt new file mode 100644 index 0000000..b168106 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeBillingManager.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import android.app.Activity +import app.k9mail.feature.funding.googleplay.domain.DomainContract +import app.k9mail.feature.funding.googleplay.domain.DomainContract.BillingError +import app.k9mail.feature.funding.googleplay.domain.entity.Contribution +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import net.thunderbird.core.outcome.Outcome + +class FakeBillingManager : DomainContract.BillingManager { + + override val purchasedContribution: StateFlow> = MutableStateFlow( + Outcome.success(null), + ) + + override suspend fun loadOneTimeContributions() = Outcome.success(FakeData.oneTimeContributions) + + override suspend fun loadRecurringContributions() = Outcome.success(FakeData.recurringContributions) + + override suspend fun loadPurchasedContributions(): Outcome, BillingError> { + return Outcome.success( + listOf( + FakeData.oneTimeContributions.first(), + ), + ) + } + + override suspend fun purchaseContribution(activity: Activity, contribution: Contribution) = + Outcome.success(Unit) + + override fun clear() = Unit +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeContributionViewModel.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeContributionViewModel.kt new file mode 100644 index 0000000..ca5dde7 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/contribution/FakeContributionViewModel.kt @@ -0,0 +1,11 @@ +package app.k9mail.feature.funding.googleplay.ui.contribution + +import app.k9mail.core.ui.compose.testing.BaseFakeViewModel +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Effect +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.Event +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.State +import app.k9mail.feature.funding.googleplay.ui.contribution.ContributionContract.ViewModel + +internal class FakeContributionViewModel( + initialState: State = State(), +) : BaseFakeViewModel(initialState), ViewModel diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/ActivityLifecycleObserverTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/ActivityLifecycleObserverTest.kt new file mode 100644 index 0000000..2f8583c --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/ActivityLifecycleObserverTest.kt @@ -0,0 +1,78 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.testing.TestLifecycleOwner +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.testing.TestClock +import net.thunderbird.core.testing.coroutines.MainDispatcherRule +import org.junit.Rule + +@OptIn(ExperimentalTime::class) +class ActivityLifecycleObserverTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `should add lifecycle observer when register is called`() { + val settings = FakeFundingSettings() + val observer = ActivityLifecycleObserver(settings) + val owner = TestLifecycleOwner() + + observer.register(owner.lifecycle) {} + + assertThat(owner.lifecycle.observerCount).isEqualTo(1) + } + + @Test + fun `should remove lifecycle observer when unregister is called`() { + val settings = FakeFundingSettings() + val observer = ActivityLifecycleObserver(settings) + val owner = TestLifecycleOwner() + + observer.register(owner.lifecycle) {} + observer.unregister(owner.lifecycle) + + assertThat(owner.lifecycle.observerCount).isEqualTo(0) + } + + @Test + fun `should update activity counter on pause`() = runTest { + val settings = FakeFundingSettings( + activityCounterInMillis = 0L, + ) + val startTime = Instant.fromEpochMilliseconds(1000L) + val clock = TestClock(startTime) + val observer = ActivityLifecycleObserver(settings, clock) + val owner = TestLifecycleOwner() + + observer.register(owner.lifecycle) {} + + owner.setCurrentState(Lifecycle.State.RESUMED) + clock.changeTimeTo(Instant.fromEpochMilliseconds(2000L)) + owner.setCurrentState(Lifecycle.State.STARTED) + + assertThat(settings.getActivityCounterInMillis()).isEqualTo(1000L) + } + + @Test + fun `should call onDestroy when lifecycle is onDestroyed`() = runTest { + val settings = FakeFundingSettings() + val observer = ActivityLifecycleObserver(settings) + val owner = TestLifecycleOwner() + var onDestroyCalled = false + + observer.register(owner.lifecycle) { + onDestroyCalled = true + } + + owner.setCurrentState(Lifecycle.State.DESTROYED) + + assertThat(onDestroyCalled).isEqualTo(true) + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeActivityLifecycleObserver.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeActivityLifecycleObserver.kt new file mode 100644 index 0000000..db41f60 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeActivityLifecycleObserver.kt @@ -0,0 +1,16 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.lifecycle.Lifecycle + +class FakeActivityLifecycleObserver( + var isRegistered: Boolean = false, +) : FundingReminderContract.ActivityLifecycleObserver { + + override fun register(lifecycle: Lifecycle, onDestroy: () -> Unit) { + isRegistered = true + } + + override fun unregister(lifecycle: Lifecycle) { + isRegistered = false + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeFragmentLifecycleObserver.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeFragmentLifecycleObserver.kt new file mode 100644 index 0000000..9fd3b76 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeFragmentLifecycleObserver.kt @@ -0,0 +1,16 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.fragment.app.FragmentManager + +class FakeFragmentLifecycleObserver( + var isRegistered: Boolean = false, +) : FundingReminderContract.FragmentLifecycleObserver { + override fun register(fragmentManager: FragmentManager, onShow: () -> Unit) { + isRegistered = true + onShow() + } + + override fun unregister(fragmentManager: FragmentManager) { + isRegistered = false + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeFundingSettings.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeFundingSettings.kt new file mode 100644 index 0000000..2c42115 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FakeFundingSettings.kt @@ -0,0 +1,33 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import app.k9mail.feature.funding.api.FundingSettings + +internal class FakeFundingSettings( + private var reminderReferenceTimestamp: Long = 0L, + private var reminderShownTimestamp: Long = 0L, + private var activityCounterInMillis: Long = 0L, +) : FundingSettings { + override fun getReminderReferenceTimestamp(): Long { + return reminderReferenceTimestamp + } + + override fun setReminderReferenceTimestamp(timestamp: Long) { + reminderReferenceTimestamp = timestamp + } + + override fun getReminderShownTimestamp(): Long { + return reminderShownTimestamp + } + + override fun setReminderShownTimestamp(timestamp: Long) { + reminderShownTimestamp = timestamp + } + + override fun getActivityCounterInMillis(): Long { + return activityCounterInMillis + } + + override fun setActivityCounterInMillis(activeTime: Long) { + activityCounterInMillis = activeTime + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FragmentLifecycleObserverTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FragmentLifecycleObserverTest.kt new file mode 100644 index 0000000..80b7e84 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FragmentLifecycleObserverTest.kt @@ -0,0 +1,81 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.Test +import org.mockito.ArgumentMatchers.eq +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions + +class FragmentLifecycleObserverTest { + + @Test + fun `should call onShow and unregister when target fragment is detached`() { + val targetFragmentTag = "targetFragment" + val fragmentManager = mock() + val fragment = mock { + on { tag } doReturn targetFragmentTag + } + var showed = false + val onShow = { showed = true } + val observer = FragmentLifecycleObserver(targetFragmentTag) + + observer.register(fragmentManager, onShow) + val lifecycleCallbacksCaptor = argumentCaptor() + verify(fragmentManager).registerFragmentLifecycleCallbacks(lifecycleCallbacksCaptor.capture(), eq(false)) + + // Simulate fragment detached + lifecycleCallbacksCaptor.firstValue.onFragmentDetached(fragmentManager, fragment) + + assertThat(showed).isTrue() + verify(fragmentManager).unregisterFragmentLifecycleCallbacks(lifecycleCallbacksCaptor.firstValue) + } + + @Test + fun `should not call onShow when target fragment is not detached`() { + val targetFragmentTag = "targetFragment" + val fragmentManager = mock() + val fragment = mock { + on { tag } doReturn "otherFragment" + } + var showed = false + val onShow = { showed = true } + val observer = FragmentLifecycleObserver(targetFragmentTag) + + observer.register(fragmentManager, onShow) + val lifecycleCallbacksCaptor = argumentCaptor() + verify(fragmentManager).registerFragmentLifecycleCallbacks(lifecycleCallbacksCaptor.capture(), eq(false)) + + // Simulate fragment detached + lifecycleCallbacksCaptor.firstValue.onFragmentDetached(fragmentManager, fragment) + + assertThat(showed).isFalse() + verifyNoMoreInteractions(fragmentManager) + } + + @Test + fun `should remove callback when unregister is called`() { + val targetFragmentTag = "targetFragment" + val fragmentManager = mock() + val fragment = mock { + on { tag } doReturn targetFragmentTag + } + val onShow = { } + val observer = FragmentLifecycleObserver(targetFragmentTag) + + observer.register(fragmentManager, onShow) + val lifecycleCallbacksCaptor = argumentCaptor() + verify(fragmentManager).registerFragmentLifecycleCallbacks(lifecycleCallbacksCaptor.capture(), eq(false)) + + observer.unregister(fragmentManager) + lifecycleCallbacksCaptor.firstValue.onFragmentDetached(fragmentManager, fragment) + + verify(fragmentManager).unregisterFragmentLifecycleCallbacks(lifecycleCallbacksCaptor.firstValue) + } +} diff --git a/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialogFragmentTest.kt b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialogFragmentTest.kt new file mode 100644 index 0000000..f0a3097 --- /dev/null +++ b/feature/funding/googleplay/src/test/kotlin/app/k9mail/feature/funding/googleplay/ui/reminder/FundingReminderDialogFragmentTest.kt @@ -0,0 +1,71 @@ +package app.k9mail.feature.funding.googleplay.ui.reminder + +import android.os.Bundle +import android.widget.Button +import androidx.fragment.app.testing.FragmentScenario.FragmentAction +import androidx.fragment.app.testing.launchFragment +import androidx.lifecycle.Lifecycle +import assertk.Assert +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.google.android.material.R +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FundingReminderDialogFragmentTest { + + @Test + fun `should return 'show_funding = true' in result bundle when positive button is clicked`() { + val resultBundle = startFundingReminderDialogFragmentForResult { fragment -> + val dialog = checkNotNull(fragment.dialog) + dialog.findViewById
    +This document contains the historical manual release process for K-9 Mail. Please use the automated process instead. +We're keeping this around in case we need to do a manual release. +